v1.5.0: UI rework, multi-progress tracking, performance optimizations

This commit is contained in:
zarzet
2026-01-02 02:54:50 +07:00
parent db1439e08f
commit d227d57545
38 changed files with 2801 additions and 833 deletions
+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.2.0';
static const String buildNumber = '10';
static const String version = '1.5.0';
static const String buildNumber = '14';
static const String fullVersion = '$version+$buildNumber';
static const String appName = 'SpotiFLAC';
+6 -1
View File
@@ -2,9 +2,14 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/app.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/services/notification_service.dart';
void main() {
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize notification service
await NotificationService().initialize();
runApp(
ProviderScope(
child: const _EagerInitialization(
+168 -2
View File
@@ -12,6 +12,7 @@ import 'package:spotiflac_android/models/track.dart';
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';
// Download History Item model
class DownloadHistoryItem {
@@ -183,6 +184,7 @@ class DownloadQueueState {
final List<DownloadItem> items;
final DownloadItem? currentDownload;
final bool isProcessing;
final bool isPaused; // NEW: pause state
final String outputDir;
final String filenameFormat;
final String audioQuality; // LOSSLESS, HI_RES, HI_RES_LOSSLESS
@@ -193,6 +195,7 @@ class DownloadQueueState {
this.items = const [],
this.currentDownload,
this.isProcessing = false,
this.isPaused = false,
this.outputDir = '',
this.filenameFormat = '{artist} - {title}',
this.audioQuality = 'LOSSLESS',
@@ -204,6 +207,7 @@ class DownloadQueueState {
List<DownloadItem>? items,
DownloadItem? currentDownload,
bool? isProcessing,
bool? isPaused,
String? outputDir,
String? filenameFormat,
String? audioQuality,
@@ -214,6 +218,7 @@ class DownloadQueueState {
items: items ?? this.items,
currentDownload: currentDownload ?? this.currentDownload,
isProcessing: isProcessing ?? this.isProcessing,
isPaused: isPaused ?? this.isPaused,
outputDir: outputDir ?? this.outputDir,
filenameFormat: filenameFormat ?? this.filenameFormat,
audioQuality: audioQuality ?? this.audioQuality,
@@ -233,6 +238,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
Timer? _progressTimer;
int _downloadCount = 0; // Counter for connection cleanup
static const _cleanupInterval = 50; // Cleanup every 50 downloads
final NotificationService _notificationService = NotificationService();
int _totalQueuedAtStart = 0; // Track total items when queue started
@override
DownloadQueueState build() {
@@ -256,6 +263,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final percentage = bytesReceived / bytesTotal;
updateProgress(itemId, percentage);
// Update notification with progress
final currentItem = state.currentDownload;
if (currentItem != null) {
_notificationService.showDownloadProgress(
trackName: currentItem.track.name,
artistName: currentItem.track.artistName,
progress: bytesReceived,
total: bytesTotal,
);
}
// Log progress
final mbReceived = bytesReceived / (1024 * 1024);
final mbTotal = bytesTotal / (1024 * 1024);
@@ -267,6 +285,56 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
});
}
/// Start multi-progress polling for concurrent downloads
void _startMultiProgressPolling() {
_progressTimer?.cancel();
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) async {
try {
final allProgress = await PlatformBridge.getAllDownloadProgress();
final items = allProgress['items'] as Map<String, dynamic>? ?? {};
for (final entry in items.entries) {
final itemId = entry.key;
final itemProgress = entry.value as Map<String, dynamic>;
final bytesReceived = itemProgress['bytes_received'] as int? ?? 0;
final bytesTotal = itemProgress['bytes_total'] as int? ?? 0;
final isDownloading = itemProgress['is_downloading'] as bool? ?? false;
if (isDownloading && bytesTotal > 0) {
final percentage = bytesReceived / bytesTotal;
updateProgress(itemId, percentage);
// 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)');
}
}
// Update notification with first active download
if (items.isNotEmpty) {
final firstEntry = items.entries.first;
final firstProgress = firstEntry.value as Map<String, dynamic>;
final bytesReceived = firstProgress['bytes_received'] as int? ?? 0;
final bytesTotal = firstProgress['bytes_total'] as int? ?? 0;
// Find the item to get track info
final downloadingItems = state.items.where((i) => i.status == DownloadStatus.downloading).toList();
if (downloadingItems.isNotEmpty) {
_notificationService.showDownloadProgress(
trackName: '${downloadingItems.length} downloads',
artistName: 'Downloading...',
progress: bytesReceived,
total: bytesTotal > 0 ? bytesTotal : 1,
);
}
}
} catch (e) {
// Ignore polling errors
}
});
}
void _stopProgressPolling() {
_progressTimer?.cancel();
_progressTimer = null;
@@ -409,7 +477,59 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
void clearAll() {
state = const DownloadQueueState();
state = state.copyWith(items: [], isPaused: false);
}
/// Pause the download queue
void pauseQueue() {
if (state.isProcessing && !state.isPaused) {
state = state.copyWith(isPaused: true);
_notificationService.cancelDownloadNotification();
print('[DownloadQueue] Queue paused');
}
}
/// Resume the download queue
void resumeQueue() {
if (state.isPaused) {
state = state.copyWith(isPaused: false);
print('[DownloadQueue] Queue resumed');
// If there are still queued items, continue processing
if (state.queuedCount > 0 && !state.isProcessing) {
Future.microtask(() => _processQueue());
}
}
}
/// Toggle pause/resume
void togglePause() {
if (state.isPaused) {
resumeQueue();
} else {
pauseQueue();
}
}
/// Retry a failed download
void retryItem(String id) {
final items = state.items.map((item) {
if (item.id == id && item.status == DownloadStatus.failed) {
return item.copyWith(status: DownloadStatus.queued, progress: 0, error: null);
}
return item;
}).toList();
state = state.copyWith(items: items);
// Start processing if not already
if (!state.isProcessing) {
Future.microtask(() => _processQueue());
}
}
/// Remove a specific item from queue
void removeItem(String id) {
final items = state.items.where((item) => item.id != id).toList();
state = state.copyWith(items: items);
}
/// Embed metadata and cover to a FLAC file after M4A conversion
@@ -482,6 +602,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
state = state.copyWith(isProcessing: true);
print('[DownloadQueue] 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...');
@@ -522,6 +645,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_downloadCount = 0;
}
// Show queue completion notification
final completedCount = state.completedCount;
final failedCount = state.failedCount;
if (_totalQueuedAtStart > 0) {
await _notificationService.showQueueComplete(
completedCount: completedCount,
failedCount: failedCount,
);
}
print('[DownloadQueue] Queue processing finished');
state = state.copyWith(isProcessing: false, currentDownload: null);
}
@@ -529,6 +662,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
/// Sequential download processing (original behavior)
Future<void> _processQueueSequential() async {
while (true) {
// Check if paused
if (state.isPaused) {
print('[DownloadQueue] Queue is paused, waiting...');
await Future.delayed(const Duration(milliseconds: 500));
continue;
}
final nextItem = state.items.firstWhere(
(item) => item.status == DownloadStatus.queued,
orElse: () => DownloadItem(
@@ -553,7 +693,21 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final maxConcurrent = state.concurrentDownloads;
final activeDownloads = <String, Future<void>>{}; // Map item ID to future
// Start multi-progress polling for concurrent downloads
_startMultiProgressPolling();
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...');
if (activeDownloads.isNotEmpty) {
await Future.any(activeDownloads.values);
} else {
await Future.delayed(const Duration(milliseconds: 500));
}
continue;
}
// Get queued items
final queuedItems = state.items.where((item) => item.status == DownloadStatus.queued).toList();
@@ -563,7 +717,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
// Start new downloads up to max concurrent limit
while (activeDownloads.length < maxConcurrent && queuedItems.isNotEmpty) {
while (activeDownloads.length < maxConcurrent && queuedItems.isNotEmpty && !state.isPaused) {
final item = queuedItems.removeAt(0);
// Mark as downloading immediately to prevent double-processing
@@ -572,6 +726,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// Create the download future
final future = _downloadSingleItem(item).whenComplete(() {
activeDownloads.remove(item.id);
// Clear item progress after download completes
PlatformBridge.clearItemProgress(item.id).catchError((_) {});
});
activeDownloads[item.id] = future;
@@ -624,6 +780,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
discNumber: item.track.discNumber ?? 1,
releaseDate: item.track.releaseDate,
preferredService: item.service,
itemId: item.id, // Pass item ID for progress tracking
);
} else {
result = await PlatformBridge.downloadTrack(
@@ -641,6 +798,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
trackNumber: item.track.trackNumber ?? 1,
discNumber: item.track.discNumber ?? 1,
releaseDate: item.track.releaseDate,
itemId: item.id, // Pass item ID for progress tracking
);
}
@@ -685,6 +843,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
filePath: filePath,
);
// Show completion notification for this track
await _notificationService.showDownloadComplete(
trackName: item.track.name,
artistName: item.track.artistName,
completedCount: state.completedCount,
totalCount: _totalQueuedAtStart,
);
if (filePath != null) {
ref.read(downloadHistoryProvider.notifier).addToHistory(
DownloadHistoryItem(
+283 -360
View File
@@ -11,34 +11,49 @@ import 'package:spotiflac_android/screens/track_metadata_screen.dart';
class HomeTab extends ConsumerStatefulWidget {
const HomeTab({super.key});
@override
ConsumerState<HomeTab> createState() => _HomeTabState();
}
class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClientMixin {
final _urlController = TextEditingController();
final Map<String, bool> _fileExistsCache = {}; // Cache file existence
@override
bool get wantKeepAlive => true;
@override
void dispose() {
_urlController.dispose();
super.dispose();
void dispose() { _urlController.dispose(); super.dispose(); }
/// Check if file exists with caching to avoid blocking main thread
bool _checkFileExists(String filePath) {
if (_fileExistsCache.containsKey(filePath)) {
return _fileExistsCache[filePath]!;
}
// Schedule async check and return false for now
Future.microtask(() async {
final exists = await File(filePath).exists();
if (mounted && _fileExistsCache[filePath] != exists) {
setState(() => _fileExistsCache[filePath] = exists);
}
});
_fileExistsCache[filePath] = false; // Assume false until checked
return false;
}
Future<void> _pasteFromClipboard() async {
final data = await Clipboard.getData(Clipboard.kTextPlain);
if (data?.text != null) {
_urlController.text = data!.text!;
}
if (data?.text != null) _urlController.text = data!.text!;
}
Future<void> _clearAndRefresh() async {
_urlController.clear();
ref.read(trackProvider.notifier).clear();
await Future.delayed(const Duration(milliseconds: 300));
}
Future<void> _fetchMetadata() async {
final url = _urlController.text.trim();
if (url.isEmpty) return;
if (url.startsWith('http') || url.startsWith('spotify:')) {
await ref.read(trackProvider.notifier).fetchFromUrl(url);
} else {
@@ -52,35 +67,21 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
final track = trackState.tracks[index];
final settings = ref.read(settingsProvider);
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Added "${track.name}" to queue')),
);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
}
}
void _downloadAll() {
final trackState = ref.read(trackProvider);
if (trackState.tracks.isEmpty) return;
final settings = ref.read(settingsProvider);
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(
trackState.tracks,
settings.defaultService,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Added ${trackState.tracks.length} tracks to queue')),
);
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(trackState.tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${trackState.tracks.length} tracks to queue')));
}
Future<void> _openFile(String filePath) async {
try {
await OpenFilex.open(filePath);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Cannot open file: $e')),
);
}
try { await OpenFilex.open(filePath); } catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Cannot open file: $e')));
}
}
@@ -91,138 +92,173 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
final historyState = ref.watch(downloadHistoryProvider);
final colorScheme = Theme.of(context).colorScheme;
return CustomScrollView(
slivers: [
// Search bar
return RefreshIndicator(
onRefresh: _clearAndRefresh,
displacement: 100,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
// Collapsing App Bar - Simplified for performance
SliverAppBar(
expandedHeight: 100,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
automaticallyImplyLeading: false,
flexibleSpace: FlexibleSpaceBar(
expandedTitleScale: 1.4,
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
title: Text(
'Home',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
),
),
// Search bar - Simple TextField with border
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: TextField(
controller: _urlController,
decoration: InputDecoration(
hintText: 'Paste Spotify URL or search...',
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(color: colorScheme.outline.withOpacity(0.5)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(color: colorScheme.outline.withOpacity(0.5)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(color: colorScheme.primary, width: 2),
),
prefixIcon: const Icon(Icons.link),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(icon: const Icon(Icons.paste), onPressed: _pasteFromClipboard),
IconButton(icon: const Icon(Icons.search), onPressed: _fetchMetadata),
IconButton(
icon: const Icon(Icons.paste),
onPressed: _pasteFromClipboard,
tooltip: 'Paste',
),
Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.search, color: colorScheme.onPrimary, size: 20),
),
onPressed: _fetchMetadata,
tooltip: 'Search',
),
),
],
),
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
),
onSubmitted: (_) => _fetchMetadata(),
),
),
),
// Helper text
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Text(
'Supports: Track, Album, Playlist URLs',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
),
),
// Error message
if (trackState.error != null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
trackState.error!,
style: TextStyle(color: colorScheme.error),
),
),
),
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(trackState.error!, style: TextStyle(color: colorScheme.error)),
)),
// Loading indicator
if (trackState.isLoading)
const SliverToBoxAdapter(
child: LinearProgressIndicator(),
),
const SliverToBoxAdapter(child: Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: LinearProgressIndicator())),
// Album/Playlist header
if (trackState.albumName != null || trackState.playlistName != null)
SliverToBoxAdapter(child: _buildHeader(trackState, colorScheme)),
// Download All button (when no header)
// Download All button
if (trackState.tracks.length > 1 && trackState.albumName == null && trackState.playlistName == null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: FilledButton.icon(
onPressed: _downloadAll,
icon: const Icon(Icons.download),
label: Text('Download All (${trackState.tracks.length})'),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
),
),
),
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: FilledButton.icon(onPressed: _downloadAll, icon: const Icon(Icons.download),
label: Text('Download All (${trackState.tracks.length})'),
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(48))),
)),
// Track list
if (trackState.tracks.isNotEmpty)
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _buildTrackTile(index, colorScheme),
childCount: trackState.tracks.length,
),
),
SliverList(delegate: SliverChildBuilderDelegate(
(context, index) => _buildTrackTile(index, colorScheme),
childCount: trackState.tracks.length,
)),
// Divider between search results and history
// Divider
if (trackState.tracks.isNotEmpty && historyState.items.isNotEmpty)
const SliverToBoxAdapter(
child: Divider(height: 32),
),
const SliverToBoxAdapter(child: Divider(height: 32)),
// Recent Downloads section header
// Recent Downloads header
if (historyState.items.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Recent Downloads',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
TextButton(
onPressed: () => _showClearHistoryDialog(colorScheme),
child: const Text('Clear'),
),
],
),
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Recent Downloads', style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.primary, fontWeight: FontWeight.w600)),
TextButton(onPressed: () => _showClearHistoryDialog(colorScheme), child: const Text('Clear')),
],
),
),
)),
// Recent Downloads list
if (historyState.items.isNotEmpty)
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _buildHistoryTile(historyState.items[index], colorScheme),
childCount: historyState.items.length > 5 ? 5 : historyState.items.length,
),
),
SliverList(delegate: SliverChildBuilderDelegate(
(context, index) => _buildHistoryTile(historyState.items[index], colorScheme),
childCount: historyState.items.length > 10 ? 10 : historyState.items.length,
)),
// Show more history button
if (historyState.items.length > 5)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: OutlinedButton(
onPressed: () => _showAllHistory(colorScheme),
child: Text('Show all ${historyState.items.length} downloads'),
),
),
),
// Show more button
if (historyState.items.length > 10)
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.all(16),
child: OutlinedButton(onPressed: () => _showAllHistory(colorScheme),
child: Text('Show all ${historyState.items.length} downloads')),
)),
// Empty state (when no tracks and no history)
// Empty state or fill remaining for scroll
if (trackState.tracks.isEmpty && historyState.items.isEmpty)
SliverFillRemaining(
child: _buildEmptyState(colorScheme),
),
// Bottom padding
const SliverToBoxAdapter(
child: SizedBox(height: 16),
),
SliverFillRemaining(hasScrollBody: false, child: _buildEmptyState(colorScheme))
else
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
],
),
);
}
@@ -234,52 +270,21 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
child: Row(
children: [
if (state.coverUrl != null)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: state.coverUrl!,
width: 80,
height: 80,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
width: 80,
height: 80,
color: colorScheme.surfaceContainerHighest,
),
),
),
ClipRRect(borderRadius: BorderRadius.circular(12),
child: CachedNetworkImage(imageUrl: state.coverUrl!, width: 80, height: 80, fit: BoxFit.cover,
placeholder: (_, __) => Container(width: 80, height: 80, color: colorScheme.surfaceContainerHighest))),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
state.albumName ?? state.playlistName ?? '',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'${state.tracks.length} tracks',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
// Download all button
FilledButton.tonal(
onPressed: _downloadAll,
style: FilledButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(16),
),
child: const Icon(Icons.download),
),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(state.albumName ?? state.playlistName ?? '',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
maxLines: 2, overflow: TextOverflow.ellipsis),
const SizedBox(height: 4),
Text('${state.tracks.length} tracks',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
])),
FilledButton.tonal(onPressed: _downloadAll,
style: FilledButton.styleFrom(shape: const CircleBorder(), padding: const EdgeInsets.all(16)),
child: const Icon(Icons.download)),
],
),
),
@@ -290,231 +295,149 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
final track = ref.watch(trackProvider).tracks[index];
return ListTile(
leading: track.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
? ClipRRect(borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: track.coverUrl!,
width: 48,
height: 48,
imageUrl: track.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
),
)
: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
),
memCacheWidth: 96,
memCacheHeight: 96,
))
: Container(width: 48, height: 48,
decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)),
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
trailing: IconButton(
icon: Icon(Icons.download, color: colorScheme.primary),
onPressed: () => _downloadTrack(index),
),
subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
trailing: IconButton(icon: Icon(Icons.download, color: colorScheme.primary), onPressed: () => _downloadTrack(index)),
onTap: () => _downloadTrack(index),
);
}
Widget _buildHistoryTile(DownloadHistoryItem item, ColorScheme colorScheme) {
final fileExists = File(item.filePath).existsSync();
final fileExists = _checkFileExists(item.filePath);
return ListTile(
leading: Hero(
tag: 'cover_${item.id}',
leading: Hero(tag: 'cover_${item.id}',
child: item.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
? ClipRRect(borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: item.coverUrl!,
width: 48,
height: 48,
imageUrl: item.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
),
)
: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
),
),
memCacheWidth: 96,
memCacheHeight: 96,
))
: Container(width: 48, height: 48,
decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)),
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant))),
title: Text(item.trackName, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
item.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
subtitle: Text(item.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
trailing: fileExists
? IconButton(
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
onPressed: () => _openFile(item.filePath),
)
? IconButton(icon: Icon(Icons.play_arrow, color: colorScheme.primary), onPressed: () => _openFile(item.filePath))
: Icon(Icons.error_outline, color: colorScheme.error, size: 20),
// Tap to show metadata details
onTap: () => _navigateToMetadataScreen(item),
);
}
void _navigateToMetadataScreen(DownloadHistoryItem item) {
Navigator.push(
context,
PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
pageBuilder: (context, animation, secondaryAnimation) =>
TrackMetadataScreen(item: item),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: animation,
child: child,
);
},
),
);
}
Widget _buildEmptyState(ColorScheme colorScheme) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.music_note,
size: 64,
Widget _buildEmptyState(ColorScheme colorScheme) => Center(child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: colorScheme.primaryContainer.withOpacity(0.3),
shape: BoxShape.circle,
),
child: Icon(Icons.music_note, size: 48, color: colorScheme.primary),
),
const SizedBox(height: 24),
Text(
'Ready to Download',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Paste a Spotify link in the search bar above to get started',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'Paste a Spotify URL to get started',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
);
),
],
),
));
void _navigateToMetadataScreen(DownloadHistoryItem item) {
Navigator.push(context, PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
pageBuilder: (context, animation, secondaryAnimation) => TrackMetadataScreen(item: item),
transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child),
));
}
void _showClearHistoryDialog(ColorScheme colorScheme) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear History'),
content: const Text('Clear all download history?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
ref.read(downloadHistoryProvider.notifier).clearHistory();
Navigator.pop(context);
},
child: Text('Clear', style: TextStyle(color: colorScheme.error)),
),
],
),
);
showDialog(context: context, builder: (context) => AlertDialog(
title: const Text('Clear History'),
content: const Text('Clear all download history?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
TextButton(onPressed: () { ref.read(downloadHistoryProvider.notifier).clearHistory(); Navigator.pop(context); },
child: Text('Clear', style: TextStyle(color: colorScheme.error))),
],
));
}
void _showAllHistory(ColorScheme colorScheme) {
final historyState = ref.read(downloadHistoryProvider);
showModalBottomSheet(
context: context,
isScrollControlled: true,
showModalBottomSheet(context: context, isScrollControlled: true,
builder: (context) => DraggableScrollableSheet(
initialChildSize: 0.7,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) => Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'All Downloads (${historyState.items.length})',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
),
),
const Divider(height: 1),
Expanded(
child: ListView.builder(
controller: scrollController,
itemCount: historyState.items.length,
itemBuilder: (context, index) {
final item = historyState.items[index];
final fileExists = File(item.filePath).existsSync();
return ListTile(
leading: item.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: item.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
),
)
: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
),
title: Text(item.trackName, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
item.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
trailing: fileExists
? IconButton(
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
onPressed: () => _openFile(item.filePath),
)
: Icon(Icons.error_outline, color: colorScheme.error, size: 20),
onTap: () {
Navigator.pop(context); // Close bottom sheet first
Future.delayed(const Duration(milliseconds: 100), () {
_navigateToMetadataScreen(item);
});
},
);
},
),
),
],
),
initialChildSize: 0.7, minChildSize: 0.5, maxChildSize: 0.95, expand: false,
builder: (context, scrollController) => Column(children: [
Padding(padding: const EdgeInsets.all(16), child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('All Downloads (${historyState.items.length})',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)),
],
)),
const Divider(height: 1),
Expanded(child: ListView.builder(
controller: scrollController,
itemCount: historyState.items.length,
itemBuilder: (context, index) {
final item = historyState.items[index];
final fileExists = _checkFileExists(item.filePath);
return ListTile(
leading: item.coverUrl != null
? ClipRRect(borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: item.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
memCacheWidth: 96,
memCacheHeight: 96,
))
: Container(width: 48, height: 48,
decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)),
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
title: Text(item.trackName, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(item.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
trailing: fileExists
? IconButton(icon: Icon(Icons.play_arrow, color: colorScheme.primary), onPressed: () => _openFile(item.filePath))
: Icon(Icons.error_outline, color: colorScheme.error, size: 20),
onTap: () { Navigator.pop(context); Future.delayed(const Duration(milliseconds: 100), () => _navigateToMetadataScreen(item)); },
);
},
)),
]),
),
);
}
+17 -36
View File
@@ -4,7 +4,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/home_tab.dart';
import 'package:spotiflac_android/screens/queue_tab.dart';
import 'package:spotiflac_android/screens/settings_tab.dart';
import 'package:spotiflac_android/screens/settings/settings_tab.dart';
import 'package:spotiflac_android/services/update_checker.dart';
import 'package:spotiflac_android/widgets/update_dialog.dart';
@@ -19,14 +19,6 @@ class _MainShellState extends ConsumerState<MainShell> {
int _currentIndex = 0;
late PageController _pageController;
bool _hasCheckedUpdate = false;
bool _isAnimating = false;
// Cache tab widgets to prevent rebuilds
final List<Widget> _tabs = const [
HomeTab(),
QueueTab(),
SettingsTab(),
];
@override
void initState() {
@@ -64,14 +56,13 @@ class _MainShellState extends ConsumerState<MainShell> {
}
void _onNavTap(int index) {
if (_currentIndex != index && !_isAnimating) {
_isAnimating = true;
if (_currentIndex != index) {
setState(() => _currentIndex = index);
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
).then((_) => _isAnimating = false);
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
);
}
}
@@ -83,33 +74,23 @@ class _MainShellState extends ConsumerState<MainShell> {
@override
Widget build(BuildContext context) {
final queueState = ref.watch(downloadQueueProvider);
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
return Scaffold(
appBar: AppBar(
leading: Padding(
padding: const EdgeInsets.all(8.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Image.asset(
'assets/images/logo.png',
width: 40,
height: 40,
),
),
),
title: const Text('SpotiFLAC'),
),
body: PageView(
controller: _pageController,
onPageChanged: _onPageChanged,
physics: const ClampingScrollPhysics(),
children: _tabs,
physics: const BouncingScrollPhysics(),
children: const [
HomeTab(),
QueueTab(),
SettingsTab(),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: _onNavTap,
animationDuration: const Duration(milliseconds: 300),
animationDuration: const Duration(milliseconds: 200),
destinations: [
const NavigationDestination(
icon: Icon(Icons.home_outlined),
@@ -118,13 +99,13 @@ class _MainShellState extends ConsumerState<MainShell> {
),
NavigationDestination(
icon: Badge(
isLabelVisible: queueState.queuedCount > 0,
label: Text('${queueState.queuedCount}'),
isLabelVisible: queueState > 0,
label: Text('$queueState'),
child: const Icon(Icons.download_outlined),
),
selectedIcon: Badge(
isLabelVisible: queueState.queuedCount > 0,
label: Text('${queueState.queuedCount}'),
isLabelVisible: queueState > 0,
label: Text('$queueState'),
child: const Icon(Icons.download),
),
label: 'Downloads',
+316 -197
View File
@@ -12,23 +12,108 @@ class QueueTab extends ConsumerWidget {
final queueState = ref.watch(downloadQueueProvider);
final colorScheme = Theme.of(context).colorScheme;
return Column(
children: [
// Header with actions
if (queueState.items.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${queueState.items.length} items',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
return CustomScrollView(
slivers: [
// Collapsing App Bar - Simplified for performance
SliverAppBar(
expandedHeight: 100,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
automaticallyImplyLeading: false,
flexibleSpace: FlexibleSpaceBar(
expandedTitleScale: 1.4,
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
title: Text(
'Downloads',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
),
),
// Pause/Resume controls when downloading
if (queueState.isProcessing || queueState.queuedCount > 0)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// Status icon
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: queueState.isPaused
? colorScheme.errorContainer
: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
queueState.isPaused ? Icons.pause : Icons.downloading,
color: queueState.isPaused
? colorScheme.onErrorContainer
: colorScheme.onPrimaryContainer,
),
),
const SizedBox(width: 12),
// Status text
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
queueState.isPaused ? 'Queue Paused' : 'Downloading...',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
'${queueState.completedCount}/${queueState.items.length} completed',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
// Pause/Resume button
FilledButton.tonal(
onPressed: () => ref.read(downloadQueueProvider.notifier).togglePause(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(queueState.isPaused ? Icons.play_arrow : Icons.pause, size: 20),
const SizedBox(width: 4),
Text(queueState.isPaused ? 'Resume' : 'Pause'),
],
),
),
],
),
),
Row(
children: [
),
),
),
// Header with actions
if (queueState.items.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('${queueState.items.length} items',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
Row(children: [
TextButton.icon(
onPressed: () => ref.read(downloadQueueProvider.notifier).clearCompleted(),
icon: const Icon(Icons.done_all, size: 18),
@@ -39,213 +124,247 @@ class QueueTab extends ConsumerWidget {
icon: Icon(Icons.clear_all, size: 18, color: colorScheme.error),
label: Text('Clear all', style: TextStyle(color: colorScheme.error)),
),
],
),
],
]),
],
),
),
),
// Queue list
Expanded(
child: queueState.items.isEmpty
? _buildEmptyState(context, colorScheme)
: ListView.builder(
itemCount: queueState.items.length,
itemBuilder: (context, index) => _buildQueueItem(context, ref, queueState.items[index], colorScheme),
),
),
if (queueState.items.isNotEmpty)
SliverList(delegate: SliverChildBuilderDelegate(
(context, index) => _buildQueueItem(context, ref, queueState.items[index], colorScheme),
childCount: queueState.items.length,
)),
// Empty state or fill remaining for scroll
if (queueState.items.isEmpty)
SliverFillRemaining(hasScrollBody: false, child: _buildEmptyState(context, colorScheme))
else
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
],
);
}
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.queue_music,
size: 64,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'No downloads in queue',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
'Add tracks from the Home tab',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
),
],
),
);
}
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) => Center(
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(Icons.queue_music, size: 64, color: colorScheme.onSurfaceVariant),
const SizedBox(height: 16),
Text('No downloads in queue', style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant)),
const SizedBox(height: 8),
Text('Add tracks from the Home tab', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7))),
]),
);
Widget _buildQueueItem(BuildContext context, WidgetRef ref, DownloadItem item, ColorScheme colorScheme) {
return ListTile(
leading: item.track.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: item.track.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
),
)
: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
),
title: Text(item.track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
if (item.status == DownloadStatus.downloading) ...[
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: LinearProgressIndicator(
value: item.progress > 0 ? item.progress : null,
backgroundColor: colorScheme.surfaceContainerHighest,
color: colorScheme.primary,
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// Cover art
item.track.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: item.track.coverUrl!,
width: 56,
height: 56,
fit: BoxFit.cover,
memCacheWidth: 112,
memCacheHeight: 112,
),
)
: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
),
),
const SizedBox(width: 8),
Text(
'${(item.progress * 100).toStringAsFixed(0)}%',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold,
const SizedBox(width: 12),
// Track info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.track.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
],
const SizedBox(height: 2),
Text(
item.track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
if (item.status == DownloadStatus.downloading) ...[
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: item.progress > 0 ? item.progress : null,
backgroundColor: colorScheme.surfaceContainerHighest,
color: colorScheme.primary,
minHeight: 6,
),
),
),
const SizedBox(width: 8),
Text(
'${(item.progress * 100).toStringAsFixed(0)}%',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
],
),
],
if (item.status == DownloadStatus.failed) ...[
const SizedBox(height: 4),
Text(
item.error ?? 'Download failed',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.error,
),
),
],
],
),
),
const SizedBox(width: 8),
// Action buttons based on status
_buildActionButtons(context, ref, item, colorScheme),
],
],
),
),
trailing: _buildStatusIcon(context, item, colorScheme),
onTap: item.status == DownloadStatus.queued
? () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id)
: null,
);
}
Widget _buildStatusIcon(BuildContext context, DownloadItem item, ColorScheme colorScheme) {
Widget _buildActionButtons(BuildContext context, WidgetRef ref, DownloadItem item, ColorScheme colorScheme) {
switch (item.status) {
case DownloadStatus.queued:
return Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant);
case DownloadStatus.downloading:
return SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
value: item.progress,
strokeWidth: 2,
color: colorScheme.primary,
),
);
case DownloadStatus.completed:
return Icon(Icons.check_circle, color: colorScheme.primary);
case DownloadStatus.failed:
return IconButton(
icon: Icon(Icons.error, color: colorScheme.error),
onPressed: () => _showErrorDialog(context, item, colorScheme),
tooltip: 'Tap to see error details',
);
case DownloadStatus.skipped:
return Icon(Icons.skip_next, color: colorScheme.primary);
}
}
void _showErrorDialog(BuildContext context, DownloadItem item, ColorScheme colorScheme) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
// Queued: Show play (start) and cancel buttons
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error, color: colorScheme.error),
const SizedBox(width: 8),
const Text('Download Failed'),
],
),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Track: ${item.track.name}', style: const TextStyle(fontWeight: FontWeight.bold)),
Text('Artist: ${item.track.artistName}'),
const SizedBox(height: 16),
const Text('Error:', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
item.error ?? 'Unknown error',
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: colorScheme.onErrorContainer,
),
),
// Cancel button
IconButton(
onPressed: () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id),
icon: Icon(Icons.close, color: colorScheme.error),
tooltip: 'Cancel',
style: IconButton.styleFrom(
backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3),
),
],
),
],
);
case DownloadStatus.downloading:
// Downloading: Show progress indicator and cancel button
return Row(
mainAxisSize: MainAxisSize.min,
children: [
// Cancel button (skip this download)
IconButton(
onPressed: () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id),
icon: Icon(Icons.stop, color: colorScheme.error),
tooltip: 'Stop',
style: IconButton.styleFrom(
backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3),
),
),
],
);
case DownloadStatus.completed:
// Completed: Show check icon
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: 20),
);
case DownloadStatus.failed:
// Failed: Show retry and remove buttons
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: () => ref.read(downloadQueueProvider.notifier).retryItem(item.id),
icon: Icon(Icons.refresh, color: colorScheme.primary),
tooltip: 'Retry',
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
),
),
const SizedBox(width: 4),
IconButton(
onPressed: () => ref.read(downloadQueueProvider.notifier).removeItem(item.id),
icon: Icon(Icons.close, color: colorScheme.error),
tooltip: 'Remove',
style: IconButton.styleFrom(
backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3),
),
),
],
);
case DownloadStatus.skipped:
// Skipped: Show retry and remove buttons
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: () => ref.read(downloadQueueProvider.notifier).retryItem(item.id),
icon: Icon(Icons.refresh, color: colorScheme.primary),
tooltip: 'Retry',
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
),
),
const SizedBox(width: 4),
IconButton(
onPressed: () => ref.read(downloadQueueProvider.notifier).removeItem(item.id),
icon: Icon(Icons.close, color: colorScheme.onSurfaceVariant),
tooltip: 'Remove',
),
],
);
}
}
void _showClearAllDialog(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear All'),
content: const Text('Are you sure you want to clear all downloads?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
ref.read(downloadQueueProvider.notifier).clearAll();
Navigator.pop(context);
},
child: Text('Clear', style: TextStyle(color: colorScheme.error)),
),
],
),
);
showDialog(context: context, builder: (context) => AlertDialog(
title: const Text('Clear All'),
content: const Text('Are you sure you want to clear all downloads?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
TextButton(onPressed: () { ref.read(downloadQueueProvider.notifier).clearAll(); Navigator.pop(context); },
child: Text('Clear', style: TextStyle(color: colorScheme.error))),
],
));
}
}
+167
View File
@@ -0,0 +1,167 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:spotiflac_android/constants/app_info.dart';
class AboutPage extends StatelessWidget {
const AboutPage({super.key});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
return Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
final animation = AlwaysStoppedAnimation(expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.zero,
title: SafeArea(
child: Container(
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
bottom: Tween<double>(begin: 12, end: 16).evaluate(animation),
),
child: Text('About',
style: TextStyle(
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
),
),
);
},
),
),
// App info card
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerHigh,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(children: [
Container(
width: 56, height: 56,
decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(16)),
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)),
),
),
const SizedBox(width: 16),
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(AppInfo.appName, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(12)),
child: Text('v${AppInfo.version}', style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSecondaryContainer)),
),
]),
]),
),
),
),
),
// GitHub section
SliverToBoxAdapter(child: _SectionHeader(title: 'GitHub')),
SliverList(delegate: SliverChildListDelegate([
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Icon(Icons.phone_android, color: colorScheme.onSurfaceVariant),
title: Text('${AppInfo.appName} Mobile'),
subtitle: Text('github.com/${AppInfo.githubRepo}'),
trailing: const Icon(Icons.open_in_new, size: 20),
onTap: () => _launchUrl(AppInfo.githubUrl),
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Icon(Icons.computer, color: colorScheme.onSurfaceVariant),
title: Text('Original ${AppInfo.appName}'),
subtitle: Text('github.com/${AppInfo.originalAuthor}/SpotiFLAC'),
trailing: const Icon(Icons.open_in_new, size: 20),
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
),
])),
// Credits section
SliverToBoxAdapter(child: _SectionHeader(title: 'Credits')),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(children: [
_CreditRow(label: 'Mobile Version', value: AppInfo.mobileAuthor),
const SizedBox(height: 12),
_CreditRow(label: 'Original Project', value: AppInfo.originalAuthor),
]),
),
),
// Copyright
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24),
child: Center(child: Text(AppInfo.copyright,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant))),
),
),
],
),
);
}
Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
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 _CreditRow extends StatelessWidget {
final String label;
final String value;
const _CreditRow({required this.label, required this.value});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Text(label, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
Text(value, style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600)),
]);
}
}
@@ -0,0 +1,203 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/providers/theme_provider.dart';
class AppearanceSettingsPage extends ConsumerWidget {
const AppearanceSettingsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final themeSettings = ref.watch(themeProvider);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
return Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
final animation = AlwaysStoppedAnimation(expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.zero,
title: SafeArea(
child: Container(
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
bottom: Tween<double>(begin: 12, end: 16).evaluate(animation),
),
child: Text('Appearance',
style: TextStyle(
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
),
),
);
},
),
),
// Theme section
SliverToBoxAdapter(child: _SectionHeader(title: 'Theme')),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: _ThemeModeSelector(
currentMode: themeSettings.themeMode,
onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
),
),
),
// Color section
SliverToBoxAdapter(child: _SectionHeader(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),
),
),
if (!themeSettings.useDynamicColor)
SliverToBoxAdapter(
child: _ColorPicker(
currentColor: themeSettings.seedColorValue,
onColorSelected: (color) => ref.read(themeProvider.notifier).setSeedColor(color),
),
),
// Fill remaining for scroll
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
],
),
);
}
}
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;
const _ThemeModeSelector({required this.currentMode, required this.onChanged});
@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)),
]),
),
);
}
}
class _ThemeModeChip extends StatelessWidget {
final IconData icon;
final String label;
final bool isSelected;
final VoidCallback onTap;
const _ThemeModeChip({required this.icon, required this.label, required this.isSelected, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Expanded(
child: Material(
color: isSelected ? colorScheme.primaryContainer : Colors.transparent,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(children: [
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
const SizedBox(height: 6),
Text(label, style: TextStyle(fontSize: 12,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
]),
),
),
),
);
}
}
class _ColorPicker extends StatelessWidget {
final int currentColor;
final ValueChanged<Color> onColorSelected;
const _ColorPicker({required this.currentColor, required this.onColorSelected});
static const _colors = [
Color(0xFF1DB954), Color(0xFF6750A4), Color(0xFF0061A4), Color(0xFF006E1C),
Color(0xFFBA1A1A), Color(0xFF984061), Color(0xFF7D5260), Color(0xFF006874), Color(0xFFFF6F00),
];
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Accent Color', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 12),
Wrap(spacing: 12, runSpacing: 12, children: _colors.map((color) {
final isSelected = color.toARGB32() == currentColor;
return GestureDetector(
onTap: () => onColorSelected(color),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 44, height: 44,
decoration: BoxDecoration(
color: color, shape: BoxShape.circle,
border: isSelected ? Border.all(color: colorScheme.onSurface, width: 3) : null,
boxShadow: isSelected ? [BoxShadow(color: color.withValues(alpha: 0.4), blurRadius: 8, spreadRadius: 2)] : null,
),
child: isSelected ? const Icon(Icons.check, color: Colors.white, size: 20) : null,
),
);
}).toList()),
]),
);
}
}
@@ -0,0 +1,232 @@
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';
class DownloadSettingsPage extends ConsumerWidget {
const DownloadSettingsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
return Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
final animation = AlwaysStoppedAnimation(expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.zero,
title: SafeArea(
child: Container(
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
bottom: Tween<double>(begin: 12, end: 16).evaluate(animation),
),
child: Text('Download',
style: TextStyle(
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
),
),
);
},
),
),
// Service section
SliverToBoxAdapter(child: _SectionHeader(title: 'Service')),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: _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')),
])),
// 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),
),
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),
),
])),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
);
}
void _showFormatEditor(BuildContext context, WidgetRef ref, String current) {
final controller = TextEditingController(text: current);
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context, isScrollControlled: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
builder: (context) => Padding(
padding: EdgeInsets.fromLTRB(24, 24, 24, MediaQuery.of(context).viewInsets.bottom + 24),
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Filename Format', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
TextField(controller: controller, decoration: const InputDecoration(hintText: '{artist} - {title}'), autofocus: true),
const SizedBox(height: 16),
Text('Available: {title}, {artist}, {album}, {track}, {year}, {disc}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)),
const SizedBox(height: 24),
Row(mainAxisAlignment: MainAxisAlignment.end, children: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
const SizedBox(width: 8),
FilledButton(onPressed: () { ref.read(settingsProvider.notifier).setFilenameFormat(controller.text); Navigator.pop(context); }, child: const Text('Save')),
]),
]),
),
);
}
Future<void> _pickDirectory(WidgetRef ref) async {
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) ref.read(settingsProvider.notifier).setDownloadDirectory(result);
}
}
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;
const _ServiceSelector({required this.currentService, required this.onChanged});
@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')),
]),
),
);
}
}
class _ServiceChip extends StatelessWidget {
final IconData icon;
final String label;
final bool isSelected;
final VoidCallback onTap;
const _ServiceChip({required this.icon, required this.label, required this.isSelected, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Expanded(
child: Material(
color: isSelected ? colorScheme.primaryContainer : Colors.transparent,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(children: [
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
const SizedBox(height: 6),
Text(label, style: TextStyle(fontSize: 12,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
]),
),
),
),
);
}
}
class _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});
@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,
);
}
}
@@ -0,0 +1,191 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
class OptionsSettingsPage extends ConsumerWidget {
const OptionsSettingsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
return Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
final animation = AlwaysStoppedAnimation(expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.zero,
title: SafeArea(
child: Container(
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
bottom: Tween<double>(begin: 12, end: 16).evaluate(animation),
),
child: Text('Options',
style: TextStyle(
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
),
),
);
},
),
),
// 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),
),
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')),
SliverToBoxAdapter(
child: _ConcurrentDownloadsSelector(
currentValue: settings.concurrentDownloads,
onChanged: (v) => ref.read(settingsProvider.notifier).setConcurrentDownloads(v),
),
),
// App section
SliverToBoxAdapter(child: _SectionHeader(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),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
);
}
}
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 {
final int currentValue;
final ValueChanged<int> onChanged;
const _ConcurrentDownloadsSelector({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),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Icon(Icons.download_for_offline, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 16),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('Concurrent Downloads'),
Text(currentValue == 1 ? 'Sequential (1 at a time)' : '$currentValue parallel downloads',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)),
])),
]),
const SizedBox(height: 16),
Row(children: [
_ConcurrentChip(label: '1', isSelected: currentValue == 1, onTap: () => onChanged(1)),
const SizedBox(width: 8),
_ConcurrentChip(label: '2', isSelected: currentValue == 2, onTap: () => onChanged(2)),
const SizedBox(width: 8),
_ConcurrentChip(label: '3', isSelected: currentValue == 3, onTap: () => onChanged(3)),
]),
const SizedBox(height: 12),
Row(children: [
Icon(Icons.warning_amber_rounded, size: 16, color: colorScheme.error),
const SizedBox(width: 8),
Expanded(child: Text('Parallel downloads may trigger rate limiting',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.error))),
]),
]),
);
}
}
class _ConcurrentChip extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
const _ConcurrentChip({required this.label, required this.isSelected, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Expanded(
child: Material(
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Center(child: Text(label, style: TextStyle(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant))),
),
),
),
);
}
}
+112
View File
@@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/constants/app_info.dart';
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';
class SettingsTab extends ConsumerWidget {
const SettingsTab({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
return CustomScrollView(
slivers: [
// Collapsing App Bar - Simplified for performance
SliverAppBar(
expandedHeight: 100,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
automaticallyImplyLeading: false,
flexibleSpace: FlexibleSpaceBar(
expandedTitleScale: 1.4,
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
title: Text(
'Settings',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
),
),
// Menu items
SliverList(delegate: SliverChildListDelegate([
_SettingsMenuItem(
icon: Icons.palette_outlined,
title: 'Appearance',
subtitle: 'Theme, colors, display',
onTap: () => _navigateTo(context, const AppearanceSettingsPage()),
),
_SettingsMenuItem(
icon: Icons.download_outlined,
title: 'Download',
subtitle: 'Service, quality, filename format',
onTap: () => _navigateTo(context, const DownloadSettingsPage()),
),
_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
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
],
);
}
void _navigateTo(BuildContext context, Widget page) {
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),
]),
),
);
}
}
+210 -171
View File
@@ -1,4 +1,5 @@
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:permission_handler/permission_handler.dart';
@@ -17,11 +18,15 @@ class SetupScreen extends ConsumerStatefulWidget {
class _SetupScreenState extends ConsumerState<SetupScreen> {
int _currentStep = 0;
bool _permissionGranted = false;
bool _storagePermissionGranted = false;
bool _notificationPermissionGranted = false;
String? _selectedDirectory;
bool _isLoading = false;
int _androidSdkVersion = 0;
// Total steps: Storage -> Notification (Android 13+) -> Folder
int get _totalSteps => _androidSdkVersion >= 33 ? 3 : 2;
@override
void initState() {
super.initState();
@@ -35,47 +40,54 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
_androidSdkVersion = androidInfo.version.sdkInt;
debugPrint('Android SDK Version: $_androidSdkVersion');
}
await _checkInitialPermission();
await _checkInitialPermissions();
}
Future<void> _checkInitialPermission() async {
Future<void> _checkInitialPermissions() async {
if (Platform.isIOS) {
// iOS doesn't need storage permission - app uses its own Documents directory
if (mounted) {
setState(() => _permissionGranted = true);
setState(() {
_storagePermissionGranted = true;
_notificationPermissionGranted = true;
});
}
} else if (Platform.isAndroid) {
PermissionStatus status;
// Check storage permission
PermissionStatus storageStatus;
if (_androidSdkVersion >= 33) {
status = await Permission.audio.status;
storageStatus = await Permission.audio.status;
} else if (_androidSdkVersion >= 30) {
status = await Permission.manageExternalStorage.status;
storageStatus = await Permission.manageExternalStorage.status;
} else {
status = await Permission.storage.status;
storageStatus = await Permission.storage.status;
}
if (status.isGranted && mounted) {
setState(() => _permissionGranted = true);
// Check notification permission (Android 13+)
PermissionStatus notificationStatus = PermissionStatus.granted;
if (_androidSdkVersion >= 33) {
notificationStatus = await Permission.notification.status;
}
if (mounted) {
setState(() {
_storagePermissionGranted = storageStatus.isGranted;
_notificationPermissionGranted = notificationStatus.isGranted;
});
}
}
}
Future<void> _requestPermission() async {
Future<void> _requestStoragePermission() async {
setState(() => _isLoading = true);
try {
if (Platform.isIOS) {
// iOS doesn't need storage permission - app uses its own Documents directory
setState(() => _permissionGranted = true);
setState(() => _storagePermissionGranted = true);
} else if (Platform.isAndroid) {
PermissionStatus status;
if (_androidSdkVersion >= 33) {
status = await Permission.audio.request();
if (!status.isGranted) {
await Permission.notification.request();
}
} else if (_androidSdkVersion >= 30) {
status = await Permission.manageExternalStorage.request();
} else {
@@ -83,15 +95,13 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
}
if (status.isGranted) {
setState(() => _permissionGranted = true);
setState(() => _storagePermissionGranted = true);
} else if (status.isPermanentlyDenied) {
_showPermissionDeniedDialog();
_showPermissionDeniedDialog('Storage');
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Permission denied. Please grant permission to continue.'),
),
const SnackBar(content: Text('Permission denied. Please grant permission to continue.')),
);
}
}
@@ -99,22 +109,46 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
} catch (e) {
debugPrint('Permission error: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
}
} finally {
setState(() => _isLoading = false);
}
}
void _showPermissionDeniedDialog() {
Future<void> _requestNotificationPermission() async {
setState(() => _isLoading = true);
try {
if (_androidSdkVersion >= 33) {
final status = await Permission.notification.request();
if (status.isGranted) {
setState(() => _notificationPermissionGranted = true);
} else if (status.isPermanentlyDenied) {
_showPermissionDeniedDialog('Notification');
}
} else {
// Notification permission not needed for older Android
setState(() => _notificationPermissionGranted = true);
}
} catch (e) {
debugPrint('Notification permission error: $e');
} finally {
setState(() => _isLoading = false);
}
}
void _skipNotificationPermission() {
setState(() => _notificationPermissionGranted = true);
}
void _showPermissionDeniedDialog(String permissionType) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Permission Required'),
content: const Text(
'Storage permission is required to save downloaded music files. '
title: Text('$permissionType Permission Required'),
content: Text(
'$permissionType permission is required for the best experience. '
'Please grant permission in app settings.',
),
actions: [
@@ -151,18 +185,10 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
context: context,
builder: (context) => AlertDialog(
title: const Text('Use Default Folder?'),
content: Text(
'No folder selected. Would you like to use the default Music folder?\n\n$defaultDir',
),
content: Text('No folder selected. Would you like to use the default Music folder?\n\n$defaultDir'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Use Default'),
),
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Use Default')),
],
),
);
@@ -179,7 +205,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Future<String> _getDefaultDirectory() async {
if (Platform.isIOS) {
// iOS: Use Documents directory (accessible via Files app)
final appDir = await getApplicationDocumentsDirectory();
final musicDir = Directory('${appDir.path}/SpotiFLAC');
try {
@@ -225,9 +250,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
}
} finally {
setState(() => _isLoading = false);
@@ -244,9 +267,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
padding: const EdgeInsets.all(24.0),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: MediaQuery.of(context).size.height -
minHeight: math.max(0, MediaQuery.of(context).size.height -
MediaQuery.of(context).padding.top -
MediaQuery.of(context).padding.bottom - 48,
MediaQuery.of(context).padding.bottom - 48),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -257,27 +280,16 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
const SizedBox(height: 24),
ClipRRect(
borderRadius: BorderRadius.circular(24),
child: Image.asset(
'assets/images/logo.png',
width: 96,
height: 96,
),
child: Image.asset('assets/images/logo.png', width: 96, height: 96),
),
const SizedBox(height: 12),
Text(
'SpotiFLAC',
Text('SpotiFLAC',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
),
fontWeight: FontWeight.bold, color: colorScheme.primary)),
const SizedBox(height: 4),
Text(
'Download Spotify tracks in FLAC',
Text('Download Spotify tracks in FLAC',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
color: colorScheme.onSurfaceVariant)),
],
),
@@ -287,9 +299,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
const SizedBox(height: 24),
_buildStepIndicator(colorScheme),
const SizedBox(height: 24),
_currentStep == 0
? _buildPermissionStep(colorScheme)
: _buildDirectoryStep(colorScheme),
_buildCurrentStepContent(colorScheme),
],
),
@@ -310,27 +320,32 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
}
Widget _buildStepIndicator(ColorScheme colorScheme) {
final steps = _androidSdkVersion >= 33
? ['Storage', 'Notification', 'Folder']
: ['Permission', 'Folder'];
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildStepDot(0, 'Permission', colorScheme),
Padding(
padding: const EdgeInsets.only(bottom: 20), // Offset for label height
child: Container(
width: 40,
height: 2,
color: _currentStep >= 1 ? colorScheme.primary : colorScheme.surfaceContainerHighest,
),
),
_buildStepDot(1, 'Folder', colorScheme),
for (int i = 0; i < steps.length; i++) ...[
if (i > 0)
Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Container(
width: 32,
height: 2,
color: _currentStep >= i ? colorScheme.primary : colorScheme.surfaceContainerHighest,
),
),
_buildStepDot(i, steps[i], colorScheme),
],
],
);
}
Widget _buildStepDot(int step, String label, ColorScheme colorScheme) {
final isActive = _currentStep >= step;
final isCompleted = (step == 0 && _permissionGranted) ||
(step == 1 && _selectedDirectory != null);
final isCompleted = _isStepCompleted(step);
return Column(
children: [
@@ -341,86 +356,143 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
shape: BoxShape.circle,
color: isCompleted
? colorScheme.primary
: isActive
? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest,
: isActive ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
),
child: Center(
child: isCompleted
? Icon(Icons.check, size: 18, color: colorScheme.onPrimary)
: Text(
'${step + 1}',
: Text('${step + 1}',
style: TextStyle(
color: isActive ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
fontWeight: FontWeight.bold)),
),
),
const SizedBox(height: 4),
Text(
label,
Text(label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: isActive ? colorScheme.onSurface : colorScheme.onSurfaceVariant,
),
),
color: isActive ? colorScheme.onSurface : colorScheme.onSurfaceVariant)),
],
);
}
Widget _buildPermissionStep(ColorScheme colorScheme) {
bool _isStepCompleted(int step) {
if (_androidSdkVersion >= 33) {
// 3 steps: Storage, Notification, Folder
switch (step) {
case 0: return _storagePermissionGranted;
case 1: return _notificationPermissionGranted;
case 2: return _selectedDirectory != null;
}
} else {
// 2 steps: Permission, Folder
switch (step) {
case 0: return _storagePermissionGranted;
case 1: return _selectedDirectory != null;
}
}
return false;
}
Widget _buildCurrentStepContent(ColorScheme colorScheme) {
if (_androidSdkVersion >= 33) {
switch (_currentStep) {
case 0: return _buildStoragePermissionStep(colorScheme);
case 1: return _buildNotificationPermissionStep(colorScheme);
case 2: return _buildDirectoryStep(colorScheme);
}
} else {
switch (_currentStep) {
case 0: return _buildStoragePermissionStep(colorScheme);
case 1: return _buildDirectoryStep(colorScheme);
}
}
return const SizedBox();
}
Widget _buildStoragePermissionStep(ColorScheme colorScheme) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_permissionGranted ? Icons.check_circle : Icons.folder_open,
_storagePermissionGranted ? Icons.check_circle : Icons.folder_open,
size: 56,
color: _permissionGranted ? colorScheme.primary : colorScheme.onSurfaceVariant,
color: _storagePermissionGranted ? colorScheme.primary : colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
_permissionGranted
? 'Storage Permission Granted!'
: 'Storage Permission Required',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
_storagePermissionGranted ? 'Storage Permission Granted!' : 'Storage Permission Required',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
_permissionGranted
? 'You can now select where to save your music files.'
_storagePermissionGranted
? 'You can now proceed to the next step.'
: 'SpotiFLAC needs storage access to save downloaded music files to your device.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
if (!_permissionGranted)
if (!_storagePermissionGranted)
FilledButton.icon(
onPressed: _isLoading ? null : _requestPermission,
onPressed: _isLoading ? null : _requestStoragePermission,
icon: _isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.onPrimary,
),
)
? SizedBox(width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
: const Icon(Icons.security),
label: const Text('Grant Permission'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
),
],
);
}
Widget _buildNotificationPermissionStep(ColorScheme colorScheme) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_notificationPermissionGranted ? Icons.check_circle : Icons.notifications_outlined,
size: 56,
color: _notificationPermissionGranted ? colorScheme.primary : colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
_notificationPermissionGranted ? 'Notification Permission Granted!' : 'Enable Notifications',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
_notificationPermissionGranted
? 'You will receive download progress notifications.'
: 'Get notified about download progress and completion. This helps you track downloads when the app is in background.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
if (!_notificationPermissionGranted) ...[
FilledButton.icon(
onPressed: _isLoading ? null : _requestNotificationPermission,
icon: _isLoading
? SizedBox(width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
: const Icon(Icons.notifications_active),
label: const Text('Enable Notifications'),
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
),
const SizedBox(height: 12),
TextButton(
onPressed: _skipNotificationPermission,
child: const Text('Skip for now'),
),
],
],
);
}
Widget _buildDirectoryStep(ColorScheme colorScheme) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -433,12 +505,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
),
const SizedBox(height: 16),
Text(
_selectedDirectory != null
? 'Download Folder Selected!'
: 'Choose Download Folder',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
_selectedDirectory != null ? 'Download Folder Selected!' : 'Choose Download Folder',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
@@ -455,46 +523,35 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Icon(Icons.folder, color: colorScheme.primary, size: 20),
const SizedBox(width: 8),
Flexible(
child: Text(
_selectedDirectory!,
child: Text(_selectedDirectory!,
style: Theme.of(context).textTheme.bodySmall,
overflow: TextOverflow.ellipsis,
),
overflow: TextOverflow.ellipsis),
),
],
),
)
else
Text(
'Select a folder where your downloaded music will be saved.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
Text('Select a folder where your downloaded music will be saved.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: _isLoading ? null : _selectDirectory,
icon: _isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.onPrimary,
),
)
? SizedBox(width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
: Icon(_selectedDirectory != null ? Icons.edit : Icons.folder_open),
label: Text(_selectedDirectory != null ? 'Change Folder' : 'Select Folder'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
),
],
);
}
Widget _buildNavigationButtons(ColorScheme colorScheme) {
final isLastStep = _currentStep == _totalSteps - 1;
final canProceed = _isStepCompleted(_currentStep);
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -509,41 +566,23 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
const SizedBox(width: 100),
// Next/Finish button
if (_currentStep == 0)
if (!isLastStep)
FilledButton(
onPressed: _permissionGranted
? () => setState(() => _currentStep++)
: null,
onPressed: canProceed ? () => setState(() => _currentStep++) : null,
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('Next'),
SizedBox(width: 8),
Icon(Icons.arrow_forward, size: 18),
],
children: [Text('Next'), SizedBox(width: 8), Icon(Icons.arrow_forward, size: 18)],
),
)
else
FilledButton(
onPressed: _selectedDirectory != null && !_isLoading
? _completeSetup
: null,
onPressed: _selectedDirectory != null && !_isLoading ? _completeSetup : null,
child: _isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.onPrimary,
),
)
? SizedBox(width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('Get Started'),
SizedBox(width: 8),
Icon(Icons.check, size: 18),
],
children: [Text('Get Started'), SizedBox(width: 8), Icon(Icons.check, size: 18)],
),
),
],
+37 -13
View File
@@ -9,23 +9,47 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
/// Screen to display detailed metadata for a downloaded track
/// Designed with Material Expressive 3 style
class TrackMetadataScreen extends ConsumerWidget {
class TrackMetadataScreen extends ConsumerStatefulWidget {
final DownloadHistoryItem item;
const TrackMetadataScreen({super.key, required this.item});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final fileExists = File(item.filePath).existsSync();
// Get file info
int? fileSize;
if (fileExists) {
ConsumerState<TrackMetadataScreen> createState() => _TrackMetadataScreenState();
}
class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bool _fileExists = false;
int? _fileSize;
@override
void initState() {
super.initState();
_checkFile();
}
Future<void> _checkFile() async {
final file = File(widget.item.filePath);
final exists = await file.exists();
int? size;
if (exists) {
try {
fileSize = File(item.filePath).lengthSync();
size = await file.length();
} catch (_) {}
}
if (mounted) {
setState(() {
_fileExists = exists;
_fileSize = size;
});
}
}
DownloadHistoryItem get item => widget.item;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
body: CustomScrollView(
@@ -77,22 +101,22 @@ class TrackMetadataScreen extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Track info card
_buildTrackInfoCard(context, colorScheme, fileExists),
_buildTrackInfoCard(context, colorScheme, _fileExists),
const SizedBox(height: 16),
// Metadata card
_buildMetadataCard(context, colorScheme, fileSize),
_buildMetadataCard(context, colorScheme, _fileSize),
const SizedBox(height: 16),
// File info card
_buildFileInfoCard(context, colorScheme, fileExists, fileSize),
_buildFileInfoCard(context, colorScheme, _fileExists, _fileSize),
const SizedBox(height: 24),
// Action buttons
_buildActionButtons(context, ref, colorScheme, fileExists),
_buildActionButtons(context, ref, colorScheme, _fileExists),
const SizedBox(height: 32),
],
+185
View File
@@ -0,0 +1,185 @@
import 'dart:io';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
class NotificationService {
static final NotificationService _instance = NotificationService._internal();
factory NotificationService() => _instance;
NotificationService._internal();
final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin();
bool _isInitialized = false;
static const int downloadProgressId = 1;
static const String channelId = 'download_progress';
static const String channelName = 'Download Progress';
static const String channelDescription = 'Shows download progress for tracks';
Future<void> initialize() async {
if (_isInitialized) return;
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const iosSettings = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: false,
);
const initSettings = InitializationSettings(
android: androidSettings,
iOS: iosSettings,
);
await _notifications.initialize(initSettings);
// Create notification channel for Android
if (Platform.isAndroid) {
await _notifications
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(
const AndroidNotificationChannel(
channelId,
channelName,
description: channelDescription,
importance: Importance.low,
showBadge: false,
playSound: false,
enableVibration: false,
),
);
}
_isInitialized = true;
}
Future<void> showDownloadProgress({
required String trackName,
required String artistName,
required int progress,
required int total,
}) async {
if (!_isInitialized) await initialize();
final percentage = total > 0 ? (progress * 100 ~/ total) : 0;
final androidDetails = AndroidNotificationDetails(
channelId,
channelName,
channelDescription: channelDescription,
importance: Importance.low,
priority: Priority.low,
showProgress: true,
maxProgress: 100,
progress: percentage,
ongoing: true,
autoCancel: false,
playSound: false,
enableVibration: false,
onlyAlertOnce: true,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: false,
presentBadge: false,
presentSound: false,
);
final details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _notifications.show(
downloadProgressId,
'Downloading $trackName',
'$artistName$percentage%',
details,
);
}
Future<void> showDownloadComplete({
required String trackName,
required String artistName,
int? completedCount,
int? totalCount,
}) async {
if (!_isInitialized) await initialize();
final title = completedCount != null && totalCount != null
? 'Download Complete ($completedCount/$totalCount)'
: 'Download Complete';
const androidDetails = AndroidNotificationDetails(
channelId,
channelName,
channelDescription: channelDescription,
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
autoCancel: true,
playSound: false,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: false,
);
const details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _notifications.show(
downloadProgressId,
title,
'$trackName - $artistName',
details,
);
}
Future<void> showQueueComplete({
required int completedCount,
required int failedCount,
}) async {
if (!_isInitialized) await initialize();
final title = failedCount > 0
? 'Downloads Finished ($completedCount done, $failedCount failed)'
: 'All Downloads Complete';
const androidDetails = AndroidNotificationDetails(
channelId,
channelName,
channelDescription: channelDescription,
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
autoCancel: true,
playSound: true,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
);
const details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _notifications.show(
downloadProgressId,
title,
'$completedCount tracks downloaded successfully',
details,
);
}
Future<void> cancelDownloadNotification() async {
await _notifications.cancel(downloadProgressId);
}
}
+26 -1
View File
@@ -54,6 +54,7 @@ class PlatformBridge {
int discNumber = 1,
int totalTracks = 1,
String? releaseDate,
String? itemId,
}) async {
final request = jsonEncode({
'isrc': isrc,
@@ -73,6 +74,7 @@ class PlatformBridge {
'disc_number': discNumber,
'total_tracks': totalTracks,
'release_date': releaseDate ?? '',
'item_id': itemId ?? '',
});
final result = await _channel.invokeMethod('downloadTrack', request);
@@ -98,6 +100,7 @@ class PlatformBridge {
int totalTracks = 1,
String? releaseDate,
String preferredService = 'tidal',
String? itemId,
}) async {
final request = jsonEncode({
'isrc': isrc,
@@ -117,18 +120,40 @@ class PlatformBridge {
'disc_number': discNumber,
'total_tracks': totalTracks,
'release_date': releaseDate ?? '',
'item_id': itemId ?? '',
});
final result = await _channel.invokeMethod('downloadWithFallback', request);
return jsonDecode(result as String) as Map<String, dynamic>;
}
/// Get download progress
/// Get download progress (legacy single download)
static Future<Map<String, dynamic>> getDownloadProgress() async {
final result = await _channel.invokeMethod('getDownloadProgress');
return jsonDecode(result as String) as Map<String, dynamic>;
}
/// Get progress for all active downloads (concurrent mode)
static Future<Map<String, dynamic>> getAllDownloadProgress() async {
final result = await _channel.invokeMethod('getAllDownloadProgress');
return jsonDecode(result as String) as Map<String, dynamic>;
}
/// Initialize progress tracking for a download item
static Future<void> initItemProgress(String itemId) async {
await _channel.invokeMethod('initItemProgress', {'item_id': itemId});
}
/// Finish progress tracking for a download item
static Future<void> finishItemProgress(String itemId) async {
await _channel.invokeMethod('finishItemProgress', {'item_id': itemId});
}
/// Clear progress tracking for a download item
static Future<void> clearItemProgress(String itemId) async {
await _channel.invokeMethod('clearItemProgress', {'item_id': itemId});
}
/// Set download directory
static Future<void> setDownloadDirectory(String path) async {
await _channel.invokeMethod('setDownloadDirectory', {'path': path});
+156
View File
@@ -0,0 +1,156 @@
import 'package:flutter/material.dart';
/// A collapsing header widget
/// Title collapses from large to small when scrolling
class CollapsingHeader extends StatelessWidget {
final String title;
final bool showBackButton;
final Widget? infoCard;
final List<Widget> slivers;
const CollapsingHeader({
super.key,
required this.title,
this.showBackButton = false,
this.infoCard,
required this.slivers,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 140,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: showBackButton
? IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
)
: null,
automaticallyImplyLeading: false,
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final expandRatio = _calculateExpandRatio(constraints, topPadding);
final animation = AlwaysStoppedAnimation(expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.zero,
title: Container(
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(
left: Tween<double>(begin: showBackButton ? 56 : 24, end: 24).evaluate(animation),
bottom: Tween<double>(begin: 16, end: 24).evaluate(animation),
),
child: Text(
title,
style: TextStyle(
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
),
);
},
),
),
// Info card if provided
if (infoCard != null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: infoCard,
),
),
// Content slivers
...slivers,
],
);
}
double _calculateExpandRatio(BoxConstraints constraints, double topPadding) {
final maxHeight = 140;
final minHeight = kToolbarHeight + topPadding;
final currentHeight = constraints.maxHeight;
final expandRatio = (currentHeight - minHeight) / (maxHeight - minHeight);
return expandRatio.clamp(0.0, 1.0);
}
}
/// Section header for settings
class SettingsSection extends StatelessWidget {
final String title;
const SettingsSection({super.key, required this.title});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
);
}
}
/// Info card widget (like version info)
class InfoCard extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final VoidCallback? onTap;
const InfoCard({
super.key,
required this.icon,
required this.title,
required this.subtitle,
this.onTap,
});
@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: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(icon, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.bodyLarge),
Text(subtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant)),
],
),
],
),
),
),
);
}
}
+72 -11
View File
@@ -127,21 +127,82 @@ class UpdateDialog extends StatelessWidget {
);
}
/// Format changelog - clean up markdown
/// Format changelog - clean up markdown and extract relevant content
String _formatChangelog(String changelog) {
// Remove markdown headers but keep content
var formatted = changelog
.replaceAll(RegExp(r'^#{1,6}\s*', multiLine: true), '')
.replaceAll(RegExp(r'\*\*([^*]+)\*\*'), r'$1') // Remove bold
.replaceAll(RegExp(r'`([^`]+)`'), r'$1') // Remove code
.trim();
// Try to extract just the changelog section (between "What's New" and "Downloads" or "---")
var content = changelog;
// Limit length
if (formatted.length > 1000) {
formatted = '${formatted.substring(0, 1000)}...';
// Find content after "What's New" header
final whatsNewMatch = RegExp(r"###?\s*What'?s\s*New\s*\n", caseSensitive: false).firstMatch(content);
if (whatsNewMatch != null) {
content = content.substring(whatsNewMatch.end);
}
return formatted;
// Cut off at "Downloads" section or horizontal rule
final cutoffMatch = RegExp(r'\n---|\n###?\s*Downloads', caseSensitive: false).firstMatch(content);
if (cutoffMatch != null) {
content = content.substring(0, cutoffMatch.start);
}
// Process line by line for better formatting
final lines = content.split('\n');
final formattedLines = <String>[];
String? currentSection;
for (var line in lines) {
line = line.trim();
if (line.isEmpty) continue;
// Check if it's a section header (### Added, ### Fixed, etc.)
final sectionMatch = RegExp(r'^#{1,3}\s*(.+)$').firstMatch(line);
if (sectionMatch != null) {
currentSection = sectionMatch.group(1)?.trim();
if (currentSection != null && currentSection.isNotEmpty) {
if (formattedLines.isNotEmpty) formattedLines.add('');
formattedLines.add('$currentSection:');
}
continue;
}
// Check if it's a list item
final listMatch = RegExp(r'^[-*]\s+(.+)$').firstMatch(line);
if (listMatch != null) {
var itemText = listMatch.group(1) ?? '';
// Remove bold markdown
itemText = itemText.replaceAllMapped(
RegExp(r'\*\*([^*]+)\*\*'),
(m) => m.group(1) ?? ''
);
// Remove code markdown
itemText = itemText.replaceAllMapped(
RegExp(r'`([^`]+)`'),
(m) => m.group(1) ?? ''
);
formattedLines.add('$itemText');
continue;
}
// Check if it's a sub-item (indented list)
final subListMatch = RegExp(r'^\s+[-*]\s+(.+)$').firstMatch(line);
if (subListMatch != null) {
var itemText = subListMatch.group(1) ?? '';
itemText = itemText.replaceAllMapped(
RegExp(r'\*\*([^*]+)\*\*'),
(m) => m.group(1) ?? ''
);
formattedLines.add(' - $itemText');
continue;
}
}
var formatted = formattedLines.join('\n').trim();
// Limit length
if (formatted.length > 2000) {
formatted = '${formatted.substring(0, 2000)}...';
}
return formatted.isEmpty ? 'See release notes for details.' : formatted;
}
}