feat: quality picker with track info, update dialog redesign, finalizing notification fix

- Quality picker now shows track name, artist, and cover
- Tap to expand long track titles (icon only shows when truncated)
- Ripple effect follows rounded corners including drag handle
- Update dialog redesigned with Material Expressive 3 style
- Fixed update notification stuck at 100% after download complete
- Ask before download now enabled by default
- Finalizing notification for multi-progress polling
This commit is contained in:
zarzet
2026-01-03 04:26:19 +07:00
parent 8fcb389bb2
commit b87de1f00a
31 changed files with 2757 additions and 595 deletions
+3 -2
View File
@@ -1,10 +1,11 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '1.6.2';
static const String buildNumber = '27';
static const String version = '2.0.0';
static const String buildNumber = '30';
static const String fullVersion = '$version+$buildNumber';
static const String appName = 'SpotiFLAC';
static const String copyright = '© 2026 SpotiFLAC';
+1
View File
@@ -7,6 +7,7 @@ part 'download_item.g.dart';
enum DownloadStatus {
queued,
downloading,
finalizing, // Embedding metadata, cover, lyrics
completed,
failed,
skipped,
+1
View File
@@ -36,6 +36,7 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
const _$DownloadStatusEnumMap = {
DownloadStatus.queued: 'queued',
DownloadStatus.downloading: 'downloading',
DownloadStatus.finalizing: 'finalizing',
DownloadStatus.completed: 'completed',
DownloadStatus.failed: 'failed',
DownloadStatus.skipped: 'skipped',
+1 -1
View File
@@ -35,7 +35,7 @@ class AppSettings {
this.folderOrganization = 'none', // Default: no folder organization
this.convertLyricsToRomaji = false, // Default: keep original Japanese
this.historyViewMode = 'grid', // Default: grid view
this.askQualityBeforeDownload = false, // Default: use preset quality
this.askQualityBeforeDownload = true, // Default: ask quality before download
});
AppSettings copyWith({
+1 -1
View File
@@ -21,7 +21,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
folderOrganization: json['folderOrganization'] as String? ?? 'none',
convertLyricsToRomaji: json['convertLyricsToRomaji'] as bool? ?? false,
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? false,
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
);
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
+58 -4
View File
@@ -99,8 +99,16 @@ class DownloadHistoryItem {
// Download History State
class DownloadHistoryState {
final List<DownloadHistoryItem> items;
final Set<String> _downloadedSpotifyIds; // Cache for O(1) lookup
const DownloadHistoryState({this.items = const []});
DownloadHistoryState({this.items = const []})
: _downloadedSpotifyIds = items
.where((item) => item.spotifyId != null && item.spotifyId!.isNotEmpty)
.map((item) => item.spotifyId!)
.toSet();
/// Check if a track has been downloaded (by Spotify ID)
bool isDownloaded(String spotifyId) => _downloadedSpotifyIds.contains(spotifyId);
DownloadHistoryState copyWith({List<DownloadHistoryItem>? items}) {
return DownloadHistoryState(items: items ?? this.items);
@@ -116,7 +124,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
DownloadHistoryState build() {
// Load history from storage on init
_loadFromStorageSync();
return const DownloadHistoryState();
return DownloadHistoryState();
}
/// Synchronously schedule load - ensures it runs before any UI renders
@@ -173,8 +181,22 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
_saveToStorage();
}
/// Remove item from history by Spotify ID
void removeBySpotifyId(String spotifyId) {
state = state.copyWith(
items: state.items.where((item) => item.spotifyId != spotifyId).toList(),
);
_saveToStorage();
_historyLog.d('Removed item with spotifyId: $spotifyId');
}
/// Get history item by Spotify ID
DownloadHistoryItem? getBySpotifyId(String spotifyId) {
return state.items.where((item) => item.spotifyId == spotifyId).firstOrNull;
}
void clearHistory() {
state = const DownloadHistoryState();
state = DownloadHistoryState();
_saveToStorage();
}
}
@@ -340,6 +362,22 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final bytesReceived = progress['bytes_received'] as int? ?? 0;
final bytesTotal = progress['bytes_total'] as int? ?? 0;
final isDownloading = progress['is_downloading'] as bool? ?? false;
final status = progress['status'] as String? ?? 'downloading';
// Check if status is "finalizing" (embedding metadata)
if (status == 'finalizing') {
updateItemStatus(itemId, DownloadStatus.finalizing, progress: 1.0);
// Update notification to show finalizing
final currentItem = state.items.where((i) => i.id == itemId).firstOrNull;
if (currentItem != null) {
_notificationService.showDownloadFinalizing(
trackName: currentItem.track.name,
artistName: currentItem.track.artistName,
);
}
return;
}
if (isDownloading && bytesTotal > 0) {
final percentage = bytesReceived / bytesTotal;
@@ -392,6 +430,22 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final bytesReceived = itemProgress['bytes_received'] as int? ?? 0;
final bytesTotal = itemProgress['bytes_total'] as int? ?? 0;
final isDownloading = itemProgress['is_downloading'] as bool? ?? false;
final status = itemProgress['status'] as String? ?? 'downloading';
// Check if status is "finalizing" (embedding metadata)
if (status == 'finalizing') {
updateItemStatus(itemId, DownloadStatus.finalizing, progress: 1.0);
// Update notification to show finalizing
final currentItem = state.items.where((i) => i.id == itemId).firstOrNull;
if (currentItem != null) {
_notificationService.showDownloadFinalizing(
trackName: currentItem.track.name,
artistName: currentItem.track.artistName,
);
}
continue;
}
if (isDownloading && bytesTotal > 0) {
final percentage = bytesReceived / bytesTotal;
@@ -412,7 +466,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
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();
final downloadingItems = state.items.where((i) => i.status == DownloadStatus.downloading || i.status == DownloadStatus.finalizing).toList();
if (downloadingItems.isNotEmpty) {
_notificationService.showDownloadProgress(
trackName: '${downloadingItems.length} downloads',
+52 -93
View File
@@ -6,54 +6,59 @@ class TrackState {
final List<Track> tracks;
final bool isLoading;
final String? error;
final String? albumId;
final String? albumName;
final String? playlistName;
final String? artistId;
final String? artistName;
final String? coverUrl;
final List<ArtistAlbum>? artistAlbums; // For artist page
final TrackState? previousState; // For back navigation
final List<SearchArtist>? searchArtists; // For search results
final bool hasSearchText; // For back button handling
const TrackState({
this.tracks = const [],
this.isLoading = false,
this.error,
this.albumId,
this.albumName,
this.playlistName,
this.artistId,
this.artistName,
this.coverUrl,
this.artistAlbums,
this.previousState,
this.searchArtists,
this.hasSearchText = false,
});
bool get canGoBack => previousState != null;
bool get hasContent => tracks.isNotEmpty || artistAlbums != null;
bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty);
TrackState copyWith({
List<Track>? tracks,
bool? isLoading,
String? error,
String? albumId,
String? albumName,
String? playlistName,
String? artistId,
String? artistName,
String? coverUrl,
List<ArtistAlbum>? artistAlbums,
TrackState? previousState,
bool clearPreviousState = false,
List<SearchArtist>? searchArtists,
bool? hasSearchText,
}) {
return TrackState(
tracks: tracks ?? this.tracks,
isLoading: isLoading ?? this.isLoading,
error: error,
albumId: albumId ?? this.albumId,
albumName: albumName ?? this.albumName,
playlistName: playlistName ?? this.playlistName,
artistId: artistId ?? this.artistId,
artistName: artistName ?? this.artistName,
coverUrl: coverUrl ?? this.coverUrl,
artistAlbums: artistAlbums ?? this.artistAlbums,
previousState: clearPreviousState ? null : (previousState ?? this.previousState),
searchArtists: searchArtists ?? this.searchArtists,
hasSearchText: hasSearchText ?? this.hasSearchText,
);
}
@@ -80,6 +85,23 @@ class ArtistAlbum {
});
}
/// Represents an artist in search results
class SearchArtist {
final String id;
final String name;
final String? imageUrl;
final int followers;
final int popularity;
const SearchArtist({
required this.id,
required this.name,
this.imageUrl,
required this.followers,
required this.popularity,
});
}
class TrackNotifier extends Notifier<TrackState> {
/// Request ID to track and cancel outdated requests
int _currentRequestId = 0;
@@ -95,19 +117,8 @@ class TrackNotifier extends Notifier<TrackState> {
Future<void> fetchFromUrl(String url) async {
// Increment request ID to cancel any pending requests
final requestId = ++_currentRequestId;
// Save current state for back navigation (only if we have content or it's empty)
final savedState = state.hasContent ? TrackState(
tracks: state.tracks,
albumName: state.albumName,
playlistName: state.playlistName,
artistName: state.artistName,
coverUrl: state.coverUrl,
artistAlbums: state.artistAlbums,
previousState: state.previousState,
) : const TrackState(); // Empty state for back to home
state = TrackState(isLoading: true, previousState: savedState);
state = const TrackState(isLoading: true);
try {
final parsed = await PlatformBridge.parseSpotifyUrl(url);
@@ -125,7 +136,6 @@ class TrackNotifier extends Notifier<TrackState> {
tracks: [track],
isLoading: false,
coverUrl: track.coverUrl,
previousState: savedState,
);
} else if (type == 'album') {
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
@@ -134,9 +144,9 @@ class TrackNotifier extends Notifier<TrackState> {
state = TrackState(
tracks: tracks,
isLoading: false,
albumId: parsed['id'] as String?,
albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?,
previousState: savedState,
);
} else if (type == 'playlist') {
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
@@ -148,7 +158,6 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false,
playlistName: owner?['name'] as String?,
coverUrl: owner?['images'] as String?,
previousState: savedState,
);
} else if (type == 'artist') {
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
@@ -157,49 +166,42 @@ class TrackNotifier extends Notifier<TrackState> {
state = TrackState(
tracks: [], // No tracks for artist view
isLoading: false,
artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?,
coverUrl: artistInfo['images'] as String?,
artistAlbums: albums,
previousState: savedState,
);
}
} catch (e) {
if (!_isRequestValid(requestId)) return; // Request cancelled
state = TrackState(isLoading: false, error: e.toString(), previousState: savedState);
state = TrackState(isLoading: false, error: e.toString());
}
}
Future<void> search(String query) async {
// Increment request ID to cancel any pending requests
final requestId = ++_currentRequestId;
// Save current state for back navigation
final savedState = state.hasContent ? TrackState(
tracks: state.tracks,
albumName: state.albumName,
playlistName: state.playlistName,
artistName: state.artistName,
coverUrl: state.coverUrl,
artistAlbums: state.artistAlbums,
previousState: state.previousState,
) : const TrackState();
state = TrackState(isLoading: true, previousState: savedState);
state = const TrackState(isLoading: true);
try {
final results = await PlatformBridge.searchSpotify(query, limit: 20);
final results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
if (!_isRequestValid(requestId)) return; // Request cancelled
final trackList = results['tracks'] as List<dynamic>? ?? [];
final artistList = results['artists'] as List<dynamic>? ?? [];
final tracks = trackList.map((t) => _parseSearchTrack(t as Map<String, dynamic>)).toList();
final artists = artistList.map((a) => _parseSearchArtist(a as Map<String, dynamic>)).toList();
state = TrackState(
tracks: tracks,
searchArtists: artists,
isLoading: false,
previousState: savedState,
);
} catch (e) {
if (!_isRequestValid(requestId)) return; // Request cancelled
state = TrackState(isLoading: false, error: e.toString(), previousState: savedState);
state = TrackState(isLoading: false, error: e.toString());
}
}
@@ -250,59 +252,6 @@ class TrackNotifier extends Notifier<TrackState> {
state = state.copyWith(hasSearchText: hasText);
}
/// Go back to previous state (if available)
bool goBack() {
if (state.previousState != null) {
state = state.previousState!;
return true;
}
return false;
}
/// Fetch album from artist view - saves current artist state for back navigation
Future<void> fetchAlbumFromArtist(String albumId) async {
// Increment request ID to cancel any pending requests
final requestId = ++_currentRequestId;
// Save current artist state before fetching album
final savedState = TrackState(
artistName: state.artistName,
coverUrl: state.coverUrl,
artistAlbums: state.artistAlbums,
previousState: state.previousState, // Keep the chain
);
state = TrackState(
isLoading: true,
previousState: savedState,
);
try {
final url = 'https://open.spotify.com/album/$albumId';
final metadata = await PlatformBridge.getSpotifyMetadata(url);
if (!_isRequestValid(requestId)) return; // Request cancelled
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
state = TrackState(
tracks: tracks,
isLoading: false,
albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?,
previousState: savedState,
);
} catch (e) {
if (!_isRequestValid(requestId)) return; // Request cancelled
state = TrackState(
isLoading: false,
error: e.toString(),
previousState: savedState,
);
}
}
Track _parseTrack(Map<String, dynamic> data) {
return Track(
id: data['spotify_id'] as String? ?? '',
@@ -346,6 +295,16 @@ class TrackNotifier extends Notifier<TrackState> {
artists: data['artists'] as String? ?? '',
);
}
SearchArtist _parseSearchArtist(Map<String, dynamic> data) {
return SearchArtist(
id: data['id'] as String? ?? '',
name: data['name'] as String? ?? '',
imageUrl: data['images'] as String?,
followers: data['followers'] as int? ?? 0,
popularity: data['popularity'] as int? ?? 0,
);
}
}
final trackProvider = NotifierProvider<TrackNotifier, TrackState>(
+591
View File
@@ -0,0 +1,591 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
/// Simple in-memory cache for album tracks
class _AlbumCache {
static final Map<String, _CacheEntry> _cache = {};
static const Duration _ttl = Duration(minutes: 10);
static List<Track>? get(String albumId) {
final entry = _cache[albumId];
if (entry == null) return null;
if (DateTime.now().isAfter(entry.expiresAt)) {
_cache.remove(albumId);
return null;
}
return entry.tracks;
}
static void set(String albumId, List<Track> tracks) {
_cache[albumId] = _CacheEntry(tracks, DateTime.now().add(_ttl));
}
}
class _CacheEntry {
final List<Track> tracks;
final DateTime expiresAt;
_CacheEntry(this.tracks, this.expiresAt);
}
/// Album detail screen with Material Expressive 3 design
class AlbumScreen extends ConsumerStatefulWidget {
final String albumId;
final String albumName;
final String? coverUrl;
final List<Track>? tracks; // Optional - will fetch if null
const AlbumScreen({
super.key,
required this.albumId,
required this.albumName,
this.coverUrl,
this.tracks,
});
@override
ConsumerState<AlbumScreen> createState() => _AlbumScreenState();
}
class _AlbumScreenState extends ConsumerState<AlbumScreen> {
List<Track>? _tracks;
bool _isLoading = false;
String? _error;
@override
void initState() {
super.initState();
// Priority: widget.tracks > cache > fetch
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
if (_tracks == null) {
_fetchTracks();
}
}
Future<void> _fetchTracks() async {
setState(() => _isLoading = true);
try {
final url = 'https://open.spotify.com/album/${widget.albumId}';
final metadata = await PlatformBridge.getSpotifyMetadata(url);
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
// Store in cache
_AlbumCache.set(widget.albumId, tracks);
if (mounted) {
setState(() {
_tracks = tracks;
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
}
Track _parseTrack(Map<String, dynamic> data) {
return Track(
id: data['spotify_id'] as String? ?? '',
name: data['name'] as String? ?? '',
artistName: data['artists'] as String? ?? '',
albumName: data['album_name'] as String? ?? '',
albumArtist: data['album_artist'] as String?,
coverUrl: data['images'] as String?,
isrc: data['isrc'] as String?,
duration: data['duration_ms'] as int? ?? 0,
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
releaseDate: data['release_date'] as String?,
);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final tracks = _tracks ?? [];
return Scaffold(
body: CustomScrollView(
slivers: [
_buildAppBar(context, colorScheme),
_buildInfoCard(context, colorScheme),
if (_isLoading)
const SliverToBoxAdapter(child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
)),
if (_error != null)
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.all(16),
child: Text(_error!, style: TextStyle(color: colorScheme.error)),
)),
if (!_isLoading && _error == null && tracks.isNotEmpty) ...[
_buildTrackListHeader(context, colorScheme),
_buildTrackList(context, colorScheme, tracks),
],
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
);
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
return SliverAppBar(
expandedHeight: 280,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
flexibleSpace: FlexibleSpaceBar(
background: Stack(
fit: StackFit.expand,
children: [
if (widget.coverUrl != null)
CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
color: Colors.black.withValues(alpha: 0.5),
colorBlendMode: BlendMode.darken,
memCacheWidth: 600,
),
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
colorScheme.surface.withValues(alpha: 0.8),
colorScheme.surface,
],
stops: const [0.0, 0.7, 1.0],
),
),
),
Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Container(
width: 140,
height: 140,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: widget.coverUrl != null
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 48, color: colorScheme.onSurfaceVariant),
),
),
),
),
),
],
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
),
leading: IconButton(
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
),
onPressed: () => Navigator.pop(context),
),
);
}
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
final tracks = _tracks ?? [];
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.albumName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
),
const SizedBox(height: 8),
if (tracks.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer),
const SizedBox(width: 4),
Text('${tracks.length} tracks', style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
if (tracks.isNotEmpty) ...[
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () => _downloadAll(context),
icon: const Icon(Icons.download),
label: Text('Download All (${tracks.length})'),
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
),
],
],
),
),
),
),
);
}
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
child: Row(
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text('Tracks', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
],
),
),
);
}
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<Track> tracks) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final track = tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _AlbumTrackItem(
track: track,
onDownload: () => _downloadTrack(context, track),
),
);
},
childCount: tracks.length,
),
);
}
void _downloadTrack(BuildContext context, Track track) {
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
_showQualityPicker(context, (quality) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
} else {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
}
}
void _downloadAll(BuildContext context) {
final tracks = _tracks;
if (tracks == null || tracks.isEmpty) return;
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
_showQualityPicker(context, (quality) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
}, trackName: '${tracks.length} tracks', artistName: widget.albumName);
} else {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
}
}
void _showQualityPicker(BuildContext context, void Function(String quality) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (trackName != null) ...[
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
] else ...[
const SizedBox(height: 8),
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
],
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
),
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); }),
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES'); }),
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.hd, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }),
const SizedBox(height: 16),
],
),
),
);
}
}
class _QualityOption extends StatelessWidget {
final String title;
final String subtitle;
final IconData icon;
final VoidCallback onTap;
const _QualityOption({required this.title, required this.subtitle, required this.icon, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
leading: Container(padding: const EdgeInsets.all(10), decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20)),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)),
onTap: onTap,
);
}
}
class _TrackInfoHeader extends StatefulWidget {
final String trackName;
final String? artistName;
final String? coverUrl;
const _TrackInfoHeader({required this.trackName, this.artistName, this.coverUrl});
@override
State<_TrackInfoHeader> createState() => _TrackInfoHeaderState();
}
class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
bool _expanded = false;
bool _isOverflowing = false;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null,
borderRadius: const BorderRadius.only(topLeft: Radius.circular(28), topRight: Radius.circular(28)),
child: Column(
children: [
const SizedBox(height: 8),
Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: widget.coverUrl != null
? Image.network(widget.coverUrl!, width: 56, height: 56, fit: BoxFit.cover,
errorBuilder: (_, e, s) => Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)))
: Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
),
const SizedBox(width: 12),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600);
final titleSpan = TextSpan(text: widget.trackName, style: titleStyle);
final titlePainter = TextPainter(text: titleSpan, maxLines: 1, textDirection: TextDirection.ltr)..layout(maxWidth: constraints.maxWidth);
final titleOverflows = titlePainter.didExceedMaxLines;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _isOverflowing != titleOverflows) {
setState(() => _isOverflowing = titleOverflows);
}
});
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.trackName,
style: titleStyle,
maxLines: _expanded ? 10 : 1,
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
),
if (widget.artistName != null) ...[
const SizedBox(height: 2),
Text(
widget.artistName!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
maxLines: _expanded ? 3 : 1,
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
),
],
],
);
},
),
),
if (_isOverflowing || _expanded)
Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: colorScheme.onSurfaceVariant, size: 20),
],
),
),
],
),
),
);
}
}
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
class _AlbumTrackItem extends ConsumerWidget {
final Track track;
final VoidCallback onDownload;
const _AlbumTrackItem({required this.track, required this.onDownload});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
// Only watch the specific item for this track
final queueItem = ref.watch(downloadQueueProvider.select((state) {
return state.items.where((item) => item.track.id == track.id).firstOrNull;
}));
// Check if track is in history (already downloaded before)
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
return state.isDownloaded(track.id);
}));
final isQueued = queueItem != null;
final isDownloading = queueItem?.status == DownloadStatus.downloading;
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
final isCompleted = queueItem?.status == DownloadStatus.completed;
final progress = queueItem?.progress ?? 0.0;
// Show as downloaded if in queue completed OR in history
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Card(
elevation: 0,
color: Colors.transparent,
margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
leading: track.coverUrl != null
? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 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, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress),
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
),
),
);
}
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory}) async {
if (isQueued) return;
if (isInHistory) {
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
if (historyItem != null) {
final fileExists = await File(historyItem.filePath).exists();
if (fileExists) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('"${track.name}" already downloaded')));
}
return;
} else {
ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id);
}
}
}
onDownload();
}
Widget _buildDownloadButton(BuildContext context, WidgetRef ref, ColorScheme colorScheme, {
required bool isQueued,
required bool isDownloading,
required bool isFinalizing,
required bool showAsDownloaded,
required bool isInHistory,
required double progress,
}) {
const double size = 44.0;
const double iconSize = 20.0;
if (showAsDownloaded) {
return GestureDetector(
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.primaryContainer, shape: BoxShape.circle), child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize)),
);
} else if (isFinalizing) {
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary, backgroundColor: colorScheme.surfaceContainerHighest),
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
],
),
);
} else if (isDownloading) {
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(value: progress > 0 ? progress : null, strokeWidth: 3, color: colorScheme.primary, backgroundColor: colorScheme.surfaceContainerHighest),
if (progress > 0) Text('${(progress * 100).toInt()}', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: colorScheme.primary)),
],
),
);
} else if (isQueued) {
return Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, shape: BoxShape.circle), child: Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant, size: iconSize));
} else {
return GestureDetector(
onTap: onDownload,
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.secondaryContainer, shape: BoxShape.circle), child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize)),
);
}
}
}
+321
View File
@@ -0,0 +1,321 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/screens/album_screen.dart';
/// Simple in-memory cache for artist discography
class _ArtistCache {
static final Map<String, _CacheEntry> _cache = {};
static const Duration _ttl = Duration(minutes: 10);
static List<ArtistAlbum>? get(String artistId) {
final entry = _cache[artistId];
if (entry == null) return null;
if (DateTime.now().isAfter(entry.expiresAt)) {
_cache.remove(artistId);
return null;
}
return entry.albums;
}
static void set(String artistId, List<ArtistAlbum> albums) {
_cache[artistId] = _CacheEntry(albums, DateTime.now().add(_ttl));
}
}
class _CacheEntry {
final List<ArtistAlbum> albums;
final DateTime expiresAt;
_CacheEntry(this.albums, this.expiresAt);
}
/// Artist screen with Material Expressive 3 design - shows discography
class ArtistScreen extends ConsumerStatefulWidget {
final String artistId;
final String artistName;
final String? coverUrl;
final List<ArtistAlbum>? albums; // Optional - will fetch if null
const ArtistScreen({
super.key,
required this.artistId,
required this.artistName,
this.coverUrl,
this.albums,
});
@override
ConsumerState<ArtistScreen> createState() => _ArtistScreenState();
}
class _ArtistScreenState extends ConsumerState<ArtistScreen> {
bool _isLoadingDiscography = false;
List<ArtistAlbum>? _albums;
String? _error;
@override
void initState() {
super.initState();
// Priority: widget.albums > cache > fetch
_albums = widget.albums ?? _ArtistCache.get(widget.artistId);
if (_albums == null) {
_fetchDiscography();
}
}
Future<void> _fetchDiscography() async {
setState(() => _isLoadingDiscography = true);
try {
final url = 'https://open.spotify.com/artist/${widget.artistId}';
final metadata = await PlatformBridge.getSpotifyMetadata(url);
final albumsList = metadata['albums'] as List<dynamic>;
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
// Store in cache
_ArtistCache.set(widget.artistId, albums);
if (mounted) {
setState(() {
_albums = albums;
_isLoadingDiscography = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_error = e.toString();
_isLoadingDiscography = false;
});
}
}
}
ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) {
return ArtistAlbum(
id: data['id'] as String? ?? '',
name: data['name'] as String? ?? '',
releaseDate: data['release_date'] as String? ?? '',
totalTracks: data['total_tracks'] as int? ?? 0,
coverUrl: data['images'] as String?,
albumType: data['album_type'] as String? ?? 'album',
artists: data['artists'] as String? ?? '',
);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final albums = _albums ?? [];
final albumsOnly = albums.where((a) => a.albumType == 'album').toList();
final singles = albums.where((a) => a.albumType == 'single').toList();
final compilations = albums.where((a) => a.albumType == 'compilation').toList();
return Scaffold(
body: Stack(
children: [
CustomScrollView(
slivers: [
_buildAppBar(context, colorScheme),
_buildInfoCard(context, colorScheme),
if (_isLoadingDiscography)
const SliverToBoxAdapter(child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
)),
if (_error != null)
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.all(16),
child: Text(_error!, style: TextStyle(color: colorScheme.error)),
)),
if (!_isLoadingDiscography && _error == null) ...[
if (albumsOnly.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Albums', albumsOnly, colorScheme)),
if (singles.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Singles & EPs', singles, colorScheme)),
if (compilations.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Compilations', compilations, colorScheme)),
],
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
],
),
);
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
return SliverAppBar(
expandedHeight: 280,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
flexibleSpace: FlexibleSpaceBar(
background: Stack(
fit: StackFit.expand,
children: [
if (widget.coverUrl != null)
CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, color: Colors.black.withValues(alpha: 0.5), colorBlendMode: BlendMode.darken, memCacheWidth: 600),
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, colorScheme.surface.withValues(alpha: 0.8), colorScheme.surface],
stops: const [0.0, 0.7, 1.0],
),
),
),
Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Container(
width: 140,
height: 140,
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))],
),
child: ClipOval(
child: widget.coverUrl != null
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant)),
),
),
),
),
],
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
),
leading: IconButton(
icon: Container(padding: const EdgeInsets.all(8), decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle), child: Icon(Icons.arrow_back, color: colorScheme.onSurface)),
onPressed: () => Navigator.pop(context),
),
);
}
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.artistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
const SizedBox(height: 8),
if (_albums != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(20)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.album, size: 14, color: colorScheme.onPrimaryContainer),
const SizedBox(width: 4),
Text('${_albums!.length} releases', style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
],
),
),
),
),
);
}
Widget _buildAlbumSection(String title, List<ArtistAlbum> albums, ColorScheme colorScheme) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Row(
children: [
Icon(Icons.album, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text('$title (${albums.length})', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.primary)),
],
),
),
SizedBox(
height: 210,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: albums.length,
itemBuilder: (context, index) {
final album = albums[index];
return KeyedSubtree(key: ValueKey(album.id), child: _buildAlbumCard(album, colorScheme));
},
),
),
],
);
}
Widget _buildAlbumCard(ArtistAlbum album, ColorScheme colorScheme) {
return GestureDetector(
onTap: () => _navigateToAlbum(album),
child: Container(
width: 140,
margin: const EdgeInsets.symmetric(horizontal: 6),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: album.coverUrl != null
? CachedNetworkImage(imageUrl: album.coverUrl!, width: 124, height: 124, fit: BoxFit.cover, memCacheWidth: 248)
: Container(width: 124, height: 124, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40)),
),
const SizedBox(height: 6),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(album.name, style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), maxLines: 2, overflow: TextOverflow.ellipsis),
const Spacer(),
Text(
'${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate}${album.totalTracks} tracks',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant, fontSize: 11),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
),
),
);
}
void _navigateToAlbum(ArtistAlbum album) {
// Navigate immediately with data from artist discography, fetch tracks in AlbumScreen
ref.read(settingsProvider.notifier).setHasSearchedBefore();
Navigator.push(context, MaterialPageRoute(
builder: (context) => AlbumScreen(
albumId: album.id,
albumName: album.name,
coverUrl: album.coverUrl,
// tracks: null - will be fetched in AlbumScreen
),
));
}
}
+487 -288
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -8,6 +9,10 @@ import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/screens/artist_screen.dart';
import 'package:spotiflac_android/screens/playlist_screen.dart';
import 'package:spotiflac_android/models/download_item.dart';
class HomeTab extends ConsumerStatefulWidget {
const HomeTab({super.key});
@@ -20,6 +25,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
Timer? _debounce;
bool _isTyping = false;
final FocusNode _searchFocusNode = FocusNode();
String? _lastSearchQuery; // Track last searched query to avoid duplicate searches
@override
bool get wantKeepAlive => true;
@@ -89,6 +95,10 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
}
Future<void> _performSearch(String query) async {
// Skip if same query already searched
if (_lastSearchQuery == query) return;
_lastSearchQuery = query;
await ref.read(trackProvider.notifier).search(query);
ref.read(settingsProvider.notifier).setHasSearchedBefore();
}
@@ -109,6 +119,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
_debounce?.cancel();
_urlController.clear();
_searchFocusNode.unfocus();
_lastSearchQuery = null; // Reset last query
setState(() => _isTyping = false);
ref.read(trackProvider.notifier).clear();
}
@@ -118,12 +129,59 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
if (url.isEmpty) return;
if (url.startsWith('http') || url.startsWith('spotify:')) {
await ref.read(trackProvider.notifier).fetchFromUrl(url);
_navigateToDetailIfNeeded();
} else {
await ref.read(trackProvider.notifier).search(url);
}
ref.read(settingsProvider.notifier).setHasSearchedBefore();
}
/// Navigate to detail screen based on fetched content type
void _navigateToDetailIfNeeded() {
final trackState = ref.read(trackProvider);
// Navigate to Album screen
if (trackState.albumId != null && trackState.albumName != null && trackState.tracks.isNotEmpty) {
Navigator.push(context, MaterialPageRoute(builder: (context) => AlbumScreen(
albumId: trackState.albumId!,
albumName: trackState.albumName!,
coverUrl: trackState.coverUrl,
tracks: trackState.tracks,
)));
ref.read(trackProvider.notifier).clear();
_urlController.clear();
setState(() => _isTyping = false);
return;
}
// Navigate to Playlist screen
if (trackState.playlistName != null && trackState.tracks.isNotEmpty) {
Navigator.push(context, MaterialPageRoute(builder: (context) => PlaylistScreen(
playlistName: trackState.playlistName!,
coverUrl: trackState.coverUrl,
tracks: trackState.tracks,
)));
ref.read(trackProvider.notifier).clear();
_urlController.clear();
setState(() => _isTyping = false);
return;
}
// Navigate to Artist screen
if (trackState.artistId != null && trackState.artistName != null && trackState.artistAlbums != null) {
Navigator.push(context, MaterialPageRoute(builder: (context) => ArtistScreen(
artistId: trackState.artistId!,
artistName: trackState.artistName!,
coverUrl: trackState.coverUrl,
albums: trackState.artistAlbums!,
)));
ref.read(trackProvider.notifier).clear();
_urlController.clear();
setState(() => _isTyping = false);
return;
}
}
void _downloadTrack(int index) {
final trackState = ref.read(trackProvider);
if (index >= 0 && index < trackState.tracks.length) {
@@ -134,7 +192,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
_showQualityPicker(context, (quality) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
});
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
} else {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
@@ -142,23 +200,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
}
}
void _downloadAll() {
final trackState = ref.read(trackProvider);
if (trackState.tracks.isEmpty) return;
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
_showQualityPicker(context, (quality) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(trackState.tracks, settings.defaultService, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${trackState.tracks.length} tracks to queue')));
});
} else {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(trackState.tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${trackState.tracks.length} tracks to queue')));
}
}
void _showQualityPicker(BuildContext context, void Function(String quality) onSelect) {
void _showQualityPicker(BuildContext context, void Function(String quality) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
@@ -169,8 +211,15 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (trackName != null) ...[
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
] else ...[
const SizedBox(height: 8),
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
],
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
),
_QualityPickerOption(
@@ -199,22 +248,24 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
Widget build(BuildContext context) {
super.build(context);
// Listen for state changes to sync search bar
ref.listen<TrackState>(trackProvider, _onTrackStateChanged);
// Listen for state changes to sync search bar and auto-navigate
ref.listen<TrackState>(trackProvider, (previous, next) {
_onTrackStateChanged(previous, next);
// Auto-navigate when URL fetch completes
if (previous != null && previous.isLoading && !next.isLoading && next.error == null) {
_navigateToDetailIfNeeded();
}
});
// Use select() to only rebuild when specific fields change
final tracks = ref.watch(trackProvider.select((s) => s.tracks));
final searchArtists = ref.watch(trackProvider.select((s) => s.searchArtists));
final isLoading = ref.watch(trackProvider.select((s) => s.isLoading));
final error = ref.watch(trackProvider.select((s) => s.error));
final albumName = ref.watch(trackProvider.select((s) => s.albumName));
final playlistName = ref.watch(trackProvider.select((s) => s.playlistName));
final artistName = ref.watch(trackProvider.select((s) => s.artistName));
final coverUrl = ref.watch(trackProvider.select((s) => s.coverUrl));
final artistAlbums = ref.watch(trackProvider.select((s) => s.artistAlbums));
final hasSearchedBefore = ref.watch(settingsProvider.select((s) => s.hasSearchedBefore));
final colorScheme = Theme.of(context).colorScheme;
final hasResults = _isTyping || tracks.isNotEmpty || artistAlbums != null || isLoading;
final hasResults = _isTyping || tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty) || isLoading;
final screenHeight = MediaQuery.of(context).size.height;
final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
@@ -320,16 +371,12 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
),
),
// Results content - always in tree
..._buildResultsContentOptimized(
// Results content - search results only (albums/artists/playlists navigate to separate screens)
..._buildSearchResults(
tracks: tracks,
searchArtists: searchArtists,
isLoading: isLoading,
error: error,
albumName: albumName,
playlistName: playlistName,
artistName: artistName,
coverUrl: coverUrl,
artistAlbums: artistAlbums,
colorScheme: colorScheme,
hasResults: hasResults,
),
@@ -354,7 +401,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
),
),
SizedBox(
height: 80,
height: 130,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: displayItems.length,
@@ -365,32 +412,32 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
child: GestureDetector(
onTap: () => _navigateToMetadataScreen(item),
child: Container(
width: 60,
width: 100,
margin: const EdgeInsets.only(right: 12),
child: Column(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(12),
child: item.coverUrl != null
? CachedNetworkImage(
imageUrl: item.coverUrl!,
width: 56,
height: 56,
width: 100,
height: 100,
fit: BoxFit.cover,
memCacheWidth: 112,
memCacheHeight: 112,
memCacheWidth: 200,
memCacheHeight: 200,
)
: Container(
width: 56,
height: 56,
width: 100,
height: 100,
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 24),
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 32),
),
),
const SizedBox(height: 4),
const SizedBox(height: 6),
Text(
item.trackName,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 1,
@@ -418,20 +465,15 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
));
}
// Results content slivers (without app bar and search bar) - optimized version
List<Widget> _buildResultsContentOptimized({
// Search results slivers - only shows search results (track list)
List<Widget> _buildSearchResults({
required List<Track> tracks,
required List<SearchArtist>? searchArtists,
required bool isLoading,
required String? error,
required String? albumName,
required String? playlistName,
required String? artistName,
required String? coverUrl,
required List<ArtistAlbum>? artistAlbums,
required ColorScheme colorScheme,
required bool hasResults,
}) {
// Return empty slivers when no results to keep tree structure stable
if (!hasResults) {
return [const SliverToBoxAdapter(child: SizedBox.shrink())];
}
@@ -448,177 +490,131 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
if (isLoading)
const SliverToBoxAdapter(child: Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: LinearProgressIndicator())),
// Album/Playlist header
if (albumName != null || playlistName != null)
SliverToBoxAdapter(child: _buildHeaderOptimized(
albumName: albumName,
playlistName: playlistName,
coverUrl: coverUrl,
trackCount: tracks.length,
colorScheme: colorScheme,
)),
// Artist search results (horizontal scroll)
if (searchArtists != null && searchArtists.isNotEmpty)
SliverToBoxAdapter(child: _buildArtistSearchResults(searchArtists, colorScheme)),
// Artist header and discography
if (artistName != null && artistAlbums != null)
SliverToBoxAdapter(child: _buildArtistHeaderOptimized(
artistName: artistName,
coverUrl: coverUrl,
albumCount: artistAlbums.length,
colorScheme: colorScheme,
)),
if (artistAlbums != null && artistAlbums.isNotEmpty)
SliverToBoxAdapter(child: _buildArtistDiscographyOptimized(artistAlbums, colorScheme)),
// Download All button
if (tracks.length > 1 && albumName == null && playlistName == null && artistAlbums == null)
// Songs section header
if (tracks.isNotEmpty)
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 (${tracks.length})'),
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(48))),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text('Songs', style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
)),
// Track list with keys for efficient updates
SliverList(delegate: SliverChildBuilderDelegate(
(context, index) {
final track = tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _buildTrackTileOptimized(track, index, colorScheme),
);
},
childCount: tracks.length,
)),
// Track list in grouped card
if (tracks.isNotEmpty)
SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.dark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
clipBehavior: Clip.antiAlias,
child: Material(
color: Colors.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (int i = 0; i < tracks.length; i++)
_TrackItemWithStatus(
key: ValueKey(tracks[i].id),
track: tracks[i],
index: i,
showDivider: i < tracks.length - 1,
onDownload: () => _downloadTrack(i),
),
],
),
),
),
),
// Bottom padding
const SliverToBoxAdapter(child: SizedBox(height: 16)),
];
}
Widget _buildHeaderOptimized({
required String? albumName,
required String? playlistName,
required String? coverUrl,
required int trackCount,
required ColorScheme colorScheme,
}) {
return Card(
margin: const EdgeInsets.all(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
if (coverUrl != null)
ClipRRect(borderRadius: BorderRadius.circular(12),
child: CachedNetworkImage(imageUrl: 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(albumName ?? playlistName ?? '',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
maxLines: 2, overflow: TextOverflow.ellipsis),
const SizedBox(height: 4),
Text('$trackCount 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)),
],
Widget _buildArtistSearchResults(List<SearchArtist> artists, ColorScheme colorScheme) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text('Artists', style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
),
),
SizedBox(
height: 160,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: artists.length,
itemBuilder: (context, index) {
final artist = artists[index];
return KeyedSubtree(
key: ValueKey(artist.id),
child: _buildArtistCard(artist, colorScheme),
);
},
),
),
],
);
}
Widget _buildArtistHeaderOptimized({
required String? artistName,
required String? coverUrl,
required int albumCount,
required ColorScheme colorScheme,
}) {
return Card(
margin: const EdgeInsets.all(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
Widget _buildArtistCard(SearchArtist artist, ColorScheme colorScheme) {
return GestureDetector(
onTap: () => _navigateToArtist(artist.id, artist.name, artist.imageUrl),
child: Container(
width: 110,
margin: const EdgeInsets.symmetric(horizontal: 6),
child: Column(
children: [
if (coverUrl != null)
ClipRRect(
borderRadius: BorderRadius.circular(40),
child: CachedNetworkImage(
imageUrl: coverUrl,
width: 80,
height: 80,
fit: BoxFit.cover,
placeholder: (_, _) => Container(
width: 80,
height: 80,
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.person, color: colorScheme.onSurfaceVariant),
),
),
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.surfaceContainerHighest,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
artistName ?? '',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'$albumCount releases',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
child: ClipOval(
child: artist.imageUrl != null
? CachedNetworkImage(
imageUrl: artist.imageUrl!,
fit: BoxFit.cover,
memCacheWidth: 200,
memCacheHeight: 200,
)
: Icon(Icons.person, color: colorScheme.onSurfaceVariant, size: 44),
),
),
const SizedBox(height: 8),
Text(
artist.name,
style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
],
),
),
);
}
Widget _buildArtistDiscographyOptimized(List<ArtistAlbum> albums, ColorScheme colorScheme) {
final albumsOnly = albums.where((a) => a.albumType == 'album').toList();
final singles = albums.where((a) => a.albumType == 'single').toList();
final compilations = albums.where((a) => a.albumType == 'compilation').toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (albumsOnly.isNotEmpty) _buildAlbumSection('Albums', albumsOnly, colorScheme),
if (singles.isNotEmpty) _buildAlbumSection('Singles & EPs', singles, colorScheme),
if (compilations.isNotEmpty) _buildAlbumSection('Compilations', compilations, colorScheme),
],
);
}
Widget _buildTrackTileOptimized(Track track, int index, ColorScheme colorScheme) {
return ListTile(
leading: track.coverUrl != null
? ClipRRect(borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: track.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(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)),
onTap: () => _downloadTrack(index),
);
void _navigateToArtist(String artistId, String artistName, String? imageUrl) {
// Navigate immediately with data from search, fetch albums in ArtistScreen
ref.read(settingsProvider.notifier).setHasSearchedBefore();
Navigator.push(context, MaterialPageRoute(
builder: (context) => ArtistScreen(
artistId: artistId,
artistName: artistName,
coverUrl: imageUrl,
// albums: null - will be fetched in ArtistScreen
),
));
}
Widget _buildSearchBar(ColorScheme colorScheme) {
@@ -668,93 +664,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
);
}
Widget _buildAlbumSection(String title, List<ArtistAlbum> albums, ColorScheme colorScheme) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
'$title (${albums.length})',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.primary,
),
),
),
SizedBox(
height: 180,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: albums.length,
itemBuilder: (context, index) {
final album = albums[index];
return KeyedSubtree(
key: ValueKey(album.id),
child: _buildAlbumCard(album, colorScheme),
);
},
),
),
],
);
}
Widget _buildAlbumCard(ArtistAlbum album, ColorScheme colorScheme) {
return GestureDetector(
onTap: () => _fetchAlbum(album.id),
child: Container(
width: 130,
margin: const EdgeInsets.symmetric(horizontal: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: album.coverUrl != null
? CachedNetworkImage(
imageUrl: album.coverUrl!,
width: 130,
height: 130,
fit: BoxFit.cover,
memCacheWidth: 260,
memCacheHeight: 260,
)
: Container(
width: 130,
height: 130,
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, color: colorScheme.onSurfaceVariant),
),
),
const SizedBox(height: 8),
Text(
album.name,
style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
'${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate}${album.totalTracks} tracks',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 11,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
);
}
void _fetchAlbum(String albumId) {
// Use fetchAlbumFromArtist to save artist state for back navigation
ref.read(trackProvider.notifier).fetchAlbumFromArtist(albumId);
ref.read(settingsProvider.notifier).setHasSearchedBefore();
}
}
class _QualityPickerOption extends StatelessWidget {
@@ -775,3 +684,293 @@ class _QualityPickerOption extends StatelessWidget {
);
}
}
class _TrackInfoHeader extends StatefulWidget {
final String trackName;
final String? artistName;
final String? coverUrl;
const _TrackInfoHeader({required this.trackName, this.artistName, this.coverUrl});
@override
State<_TrackInfoHeader> createState() => _TrackInfoHeaderState();
}
class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
bool _expanded = false;
bool _isOverflowing = false;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null,
borderRadius: const BorderRadius.only(topLeft: Radius.circular(28), topRight: Radius.circular(28)),
child: Column(
children: [
const SizedBox(height: 8),
Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: widget.coverUrl != null
? Image.network(widget.coverUrl!, width: 56, height: 56, fit: BoxFit.cover,
errorBuilder: (_, e, s) => Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)))
: Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
),
const SizedBox(width: 12),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600);
final titleSpan = TextSpan(text: widget.trackName, style: titleStyle);
final titlePainter = TextPainter(text: titleSpan, maxLines: 1, textDirection: TextDirection.ltr)..layout(maxWidth: constraints.maxWidth);
final titleOverflows = titlePainter.didExceedMaxLines;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _isOverflowing != titleOverflows) {
setState(() => _isOverflowing = titleOverflows);
}
});
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.trackName,
style: titleStyle,
maxLines: _expanded ? 10 : 1,
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
),
if (widget.artistName != null) ...[
const SizedBox(height: 2),
Text(
widget.artistName!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
maxLines: _expanded ? 3 : 1,
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
),
],
],
);
},
),
),
if (_isOverflowing || _expanded)
Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: colorScheme.onSurfaceVariant, size: 20),
],
),
),
],
),
),
);
}
}
/// Separate Consumer widget for each track item - only rebuilds when this specific track's status changes
class _TrackItemWithStatus extends ConsumerWidget {
final Track track;
final int index;
final bool showDivider;
final VoidCallback onDownload;
const _TrackItemWithStatus({
super.key,
required this.track,
required this.index,
required this.showDivider,
required this.onDownload,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
// Only watch the specific item for this track using select()
final queueItem = ref.watch(downloadQueueProvider.select((state) {
return state.items.where((item) => item.track.id == track.id).firstOrNull;
}));
// Check if track is in history (already downloaded before)
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
return state.isDownloaded(track.id);
}));
final isQueued = queueItem != null;
final isDownloading = queueItem?.status == DownloadStatus.downloading;
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
final isCompleted = queueItem?.status == DownloadStatus.completed;
final progress = queueItem?.progress ?? 0.0;
// Show as downloaded if in queue completed OR in history
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
splashColor: colorScheme.primary.withValues(alpha: 0.12),
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
// Album art
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: track.coverUrl != null
? CachedNetworkImage(
imageUrl: track.coverUrl!,
width: 56,
height: 56,
fit: BoxFit.cover,
memCacheWidth: 112,
memCacheHeight: 112,
)
: Container(
width: 56,
height: 56,
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
),
),
const SizedBox(width: 12),
// Track info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
track.name,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
track.artistName,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
// Download button / status indicator
_buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress),
],
),
),
),
if (showDivider)
Divider(
height: 1,
thickness: 1,
indent: 80,
endIndent: 12,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
],
);
}
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory}) async {
// If already in queue, do nothing
if (isQueued) return;
// If in history, check if file still exists
if (isInHistory) {
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
if (historyItem != null) {
final fileExists = await File(historyItem.filePath).exists();
if (fileExists) {
// File exists, show snackbar
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('"${track.name}" already downloaded')),
);
}
return;
} else {
// File doesn't exist, remove from history and allow download
ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id);
}
}
}
// Proceed with download
onDownload();
}
Widget _buildDownloadButton(BuildContext context, WidgetRef ref, ColorScheme colorScheme, {
required bool isQueued,
required bool isDownloading,
required bool isFinalizing,
required bool showAsDownloaded,
required bool isInHistory,
required double progress,
}) {
const double size = 44.0;
const double iconSize = 20.0;
if (showAsDownloaded) {
return GestureDetector(
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(color: colorScheme.primaryContainer, shape: BoxShape.circle),
child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize),
),
);
} else if (isFinalizing) {
// Show finalizing status (embedding metadata)
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary, backgroundColor: colorScheme.surfaceContainerHighest),
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
],
),
);
} else if (isDownloading) {
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(value: progress > 0 ? progress : null, strokeWidth: 3, color: colorScheme.primary, backgroundColor: colorScheme.surfaceContainerHighest),
if (progress > 0) Text('${(progress * 100).toInt()}', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: colorScheme.primary)),
],
),
);
} else if (isQueued) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, shape: BoxShape.circle),
child: Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant, size: iconSize),
);
} else {
return GestureDetector(
onTap: onDownload,
child: Container(
width: size,
height: size,
decoration: BoxDecoration(color: colorScheme.secondaryContainer, shape: BoxShape.circle),
child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize),
),
);
}
}
}
+52 -47
View File
@@ -27,6 +27,7 @@ class _MainShellState extends ConsumerState<MainShell> {
late PageController _pageController;
bool _hasCheckedUpdate = false;
StreamSubscription<String>? _shareSubscription;
DateTime? _lastBackPress; // For double-tap to exit
@override
void initState() {
@@ -120,65 +121,66 @@ class _MainShellState extends ConsumerState<MainShell> {
}
}
Future<bool> _showExitDialog() async {
return await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Exit App'),
content: const Text('Are you sure you want to exit?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('No'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Yes'),
),
],
),
) ?? false;
/// Handle back press with double-tap to exit
void _handleBackPress() {
final trackState = ref.read(trackProvider);
// If on Home tab and has text in search bar or has content (but not loading), clear it
if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) {
ref.read(trackProvider.notifier).clear();
return;
}
// If not on Home tab, go to Home tab first
if (_currentIndex != 0) {
_onNavTap(0);
return;
}
// If loading, ignore back press
if (trackState.isLoading) {
return;
}
// Double-tap to exit
final now = DateTime.now();
if (_lastBackPress != null && now.difference(_lastBackPress!) < const Duration(seconds: 2)) {
SystemNavigator.pop();
} else {
_lastBackPress = now;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Press back again to exit'),
duration: Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
),
);
}
}
@override
Widget build(BuildContext context) {
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
final trackState = ref.watch(trackProvider);
// Determine if we can pop (for predictive back animation)
// canPop is true when we're at root with no content - enables predictive back gesture
final canPop = _currentIndex == 0 &&
!trackState.hasSearchText &&
!trackState.hasContent &&
!trackState.isLoading;
return PopScope(
canPop: false,
canPop: canPop,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
// If on Search tab and can go back in track history, go back
if (_currentIndex == 0 && trackState.canGoBack) {
ref.read(trackProvider.notifier).goBack();
if (didPop) {
// System handled the pop - this means predictive back completed
// We need to handle double-tap to exit here
return;
}
// If on Search tab and has text in search bar or has content (but not loading), clear it
// Don't clear while loading - this prevents clearing during share intent processing
if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) {
ref.read(trackProvider.notifier).clear();
return;
}
// If not on Search tab, go to Search tab first
if (_currentIndex != 0) {
_onNavTap(0);
return;
}
// If loading, ignore back press
if (trackState.isLoading) {
return;
}
// Already at root, show exit dialog
final shouldPop = await _showExitDialog();
if (shouldPop && context.mounted) {
SystemNavigator.pop();
}
// Handle back press manually when canPop is false
_handleBackPress();
},
child: Scaffold(
body: PageView(
@@ -195,6 +197,9 @@ class _MainShellState extends ConsumerState<MainShell> {
selectedIndex: _currentIndex,
onDestinationSelected: _onNavTap,
animationDuration: const Duration(milliseconds: 200),
backgroundColor: Theme.of(context).brightness == Brightness.dark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), Theme.of(context).colorScheme.surface)
: Color.alphaBlend(Colors.black.withValues(alpha: 0.03), Theme.of(context).colorScheme.surface),
destinations: [
const NavigationDestination(
icon: Icon(Icons.home_outlined),
+453
View File
@@ -0,0 +1,453 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
/// Playlist detail screen with Material Expressive 3 design
class PlaylistScreen extends ConsumerWidget {
final String playlistName;
final String? coverUrl;
final List<Track> tracks;
const PlaylistScreen({
super.key,
required this.playlistName,
this.coverUrl,
required this.tracks,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
body: CustomScrollView(
slivers: [
_buildAppBar(context, colorScheme),
_buildInfoCard(context, ref, colorScheme),
_buildTrackListHeader(context, colorScheme),
_buildTrackList(context, ref, colorScheme),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
);
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
return SliverAppBar(
expandedHeight: 280,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
flexibleSpace: FlexibleSpaceBar(
background: Stack(
fit: StackFit.expand,
children: [
if (coverUrl != null)
CachedNetworkImage(imageUrl: coverUrl!, fit: BoxFit.cover, color: Colors.black.withValues(alpha: 0.5), colorBlendMode: BlendMode.darken, memCacheWidth: 600),
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, colorScheme.surface.withValues(alpha: 0.8), colorScheme.surface],
stops: const [0.0, 0.7, 1.0],
),
),
),
Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Container(
width: 140,
height: 140,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: coverUrl != null
? CachedNetworkImage(imageUrl: coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.playlist_play, size: 48, color: colorScheme.onSurfaceVariant)),
),
),
),
),
],
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
),
leading: IconButton(
icon: Container(padding: const EdgeInsets.all(8), decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle), child: Icon(Icons.arrow_back, color: colorScheme.onSurface)),
onPressed: () => Navigator.pop(context),
),
);
}
Widget _buildInfoCard(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(playlistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.playlist_play, size: 14, color: colorScheme.onTertiaryContainer),
const SizedBox(width: 4),
Text('${tracks.length} tracks', style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () => _downloadAll(context, ref),
icon: const Icon(Icons.download),
label: Text('Download All (${tracks.length})'),
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
),
],
),
),
),
),
);
}
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
child: Row(
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text('Tracks', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
],
),
),
);
}
Widget _buildTrackList(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final track = tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _PlaylistTrackItem(
track: track,
onDownload: () => _downloadTrack(context, ref, track),
),
);
},
childCount: tracks.length,
),
);
}
void _downloadTrack(BuildContext context, WidgetRef ref, Track track) {
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
_showQualityPicker(context, (quality) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
} else {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
}
}
void _downloadAll(BuildContext context, WidgetRef ref) {
if (tracks.isEmpty) return;
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
_showQualityPicker(context, (quality) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
}, trackName: '${tracks.length} tracks', artistName: playlistName);
} else {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
}
}
void _showQualityPicker(BuildContext context, void Function(String quality) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (trackName != null) ...[
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
] else ...[
const SizedBox(height: 8),
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
],
Padding(padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold))),
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); }),
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES'); }),
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.hd, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }),
const SizedBox(height: 16),
],
),
),
);
}
}
class _QualityOption extends StatelessWidget {
final String title;
final String subtitle;
final IconData icon;
final VoidCallback onTap;
const _QualityOption({required this.title, required this.subtitle, required this.icon, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
leading: Container(padding: const EdgeInsets.all(10), decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20)),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)),
onTap: onTap,
);
}
}
class _TrackInfoHeader extends StatefulWidget {
final String trackName;
final String? artistName;
final String? coverUrl;
const _TrackInfoHeader({required this.trackName, this.artistName, this.coverUrl});
@override
State<_TrackInfoHeader> createState() => _TrackInfoHeaderState();
}
class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
bool _expanded = false;
bool _isOverflowing = false;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null,
borderRadius: const BorderRadius.only(topLeft: Radius.circular(28), topRight: Radius.circular(28)),
child: Column(
children: [
const SizedBox(height: 8),
Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: widget.coverUrl != null
? Image.network(widget.coverUrl!, width: 56, height: 56, fit: BoxFit.cover,
errorBuilder: (_, e, s) => Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)))
: Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
),
const SizedBox(width: 12),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600);
final titleSpan = TextSpan(text: widget.trackName, style: titleStyle);
final titlePainter = TextPainter(text: titleSpan, maxLines: 1, textDirection: TextDirection.ltr)..layout(maxWidth: constraints.maxWidth);
final titleOverflows = titlePainter.didExceedMaxLines;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _isOverflowing != titleOverflows) {
setState(() => _isOverflowing = titleOverflows);
}
});
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.trackName,
style: titleStyle,
maxLines: _expanded ? 10 : 1,
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
),
if (widget.artistName != null) ...[
const SizedBox(height: 2),
Text(
widget.artistName!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
maxLines: _expanded ? 3 : 1,
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
),
],
],
);
},
),
),
if (_isOverflowing || _expanded)
Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: colorScheme.onSurfaceVariant, size: 20),
],
),
),
],
),
),
);
}
}
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
class _PlaylistTrackItem extends ConsumerWidget {
final Track track;
final VoidCallback onDownload;
const _PlaylistTrackItem({required this.track, required this.onDownload});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
// Only watch the specific item for this track
final queueItem = ref.watch(downloadQueueProvider.select((state) {
return state.items.where((item) => item.track.id == track.id).firstOrNull;
}));
// Check if track is in history (already downloaded before)
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
return state.isDownloaded(track.id);
}));
final isQueued = queueItem != null;
final isDownloading = queueItem?.status == DownloadStatus.downloading;
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
final isCompleted = queueItem?.status == DownloadStatus.completed;
final progress = queueItem?.progress ?? 0.0;
// Show as downloaded if in queue completed OR in history
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Card(
elevation: 0,
color: Colors.transparent,
margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
leading: track.coverUrl != null
? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 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, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress),
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
),
),
);
}
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory}) async {
if (isQueued) return;
if (isInHistory) {
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
if (historyItem != null) {
final fileExists = await File(historyItem.filePath).exists();
if (fileExists) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('"${track.name}" already downloaded')));
}
return;
} else {
ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id);
}
}
}
onDownload();
}
Widget _buildDownloadButton(BuildContext context, WidgetRef ref, ColorScheme colorScheme, {
required bool isQueued,
required bool isDownloading,
required bool isFinalizing,
required bool showAsDownloaded,
required bool isInHistory,
required double progress,
}) {
const double size = 44.0;
const double iconSize = 20.0;
if (showAsDownloaded) {
return GestureDetector(
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.primaryContainer, shape: BoxShape.circle), child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize)),
);
} else if (isFinalizing) {
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary, backgroundColor: colorScheme.surfaceContainerHighest),
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
],
),
);
} else if (isDownloading) {
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(value: progress > 0 ? progress : null, strokeWidth: 3, color: colorScheme.primary, backgroundColor: colorScheme.surfaceContainerHighest),
if (progress > 0) Text('${(progress * 100).toInt()}', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: colorScheme.primary)),
],
),
);
} else if (isQueued) {
return Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, shape: BoxShape.circle), child: Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant, size: iconSize));
} else {
return GestureDetector(
onTap: onDownload,
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.secondaryContainer, shape: BoxShape.circle), child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize)),
);
}
}
}
+12
View File
@@ -144,6 +144,18 @@ class QueueScreen extends ConsumerWidget {
color: colorScheme.primary,
),
);
case DownloadStatus.finalizing:
return SizedBox(
width: 24,
height: 24,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(strokeWidth: 2, color: colorScheme.tertiary),
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 12),
],
),
);
case DownloadStatus.completed:
return Icon(Icons.check_circle, color: colorScheme.primary);
case DownloadStatus.failed:
+19
View File
@@ -424,6 +424,25 @@ class _QueueTabState extends ConsumerState<QueueTab> {
],
);
case DownloadStatus.finalizing:
// Finalizing: Show spinner with edit icon (embedding metadata)
return Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 40,
height: 40,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary),
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
],
),
),
],
);
case DownloadStatus.completed:
// Completed: Show play button and check icon
final fileExists = _checkFileExists(item.filePath);
@@ -144,27 +144,38 @@ class _ThemeModeChip extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
// Unselected chips need to be darker than the card background
// Unselected chips need contrast with card background
// Card uses: dark = white 8% overlay, light = surfaceContainerHighest
// So chips use: dark = white 5% overlay (darker), light = black 5% overlay (darker than card)
final unselectedColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
: colorScheme.surfaceContainerHigh;
: Color.alphaBlend(Colors.black.withValues(alpha: 0.05), colorScheme.surfaceContainerHighest);
return Expanded(
child: Material(
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
child: Container(
decoration: BoxDecoration(
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
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)),
]),
border: !isDark && !isSelected
? Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5), width: 1)
: null,
),
child: Material(
color: 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)),
]),
),
),
),
),
@@ -251,26 +262,36 @@ class _ViewModeChip extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
// Unselected chips need contrast with card background
final unselectedColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
: colorScheme.surfaceContainerHigh;
: Color.alphaBlend(Colors.black.withValues(alpha: 0.05), colorScheme.surfaceContainerHighest);
return Expanded(
child: Material(
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
child: Container(
decoration: BoxDecoration(
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
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)),
]),
border: !isDark && !isSelected
? Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5), width: 1)
: null,
),
child: Material(
color: 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)),
]),
),
),
),
),
+43
View File
@@ -98,6 +98,49 @@ class NotificationService {
);
}
Future<void> showDownloadFinalizing({
required String trackName,
required String artistName,
}) async {
if (!_isInitialized) await initialize();
final androidDetails = AndroidNotificationDetails(
channelId,
channelName,
channelDescription: channelDescription,
importance: Importance.low,
priority: Priority.low,
showProgress: true,
maxProgress: 100,
progress: 100,
indeterminate: false,
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,
'Finalizing $trackName',
'$artistName • Embedding metadata...',
details,
);
}
Future<void> showDownloadComplete({
required String trackName,
required String artistName,
+10
View File
@@ -26,6 +26,16 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>;
}
/// Search Spotify for tracks and artists
static Future<Map<String, dynamic>> searchSpotifyAll(String query, {int trackLimit = 15, int artistLimit = 3}) async {
final result = await _channel.invokeMethod('searchSpotifyAll', {
'query': query,
'track_limit': trackLimit,
'artist_limit': artistLimit,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
/// Check track availability on streaming services
static Future<Map<String, dynamic>> checkAvailability(String spotifyId, String isrc) async {
final result = await _channel.invokeMethod('checkAvailability', {
+2 -1
View File
@@ -20,9 +20,10 @@ class SettingsGroup extends StatelessWidget {
// Use a more contrasting color for cards
// In dark mode with dynamic color, surfaceContainerHighest can be too similar to surface
// So we add a slight white overlay to make it more visible
// In light mode with dynamic color, we add a slight black overlay for the same reason
final cardColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
: colorScheme.surfaceContainerHighest;
: Color.alphaBlend(Colors.black.withValues(alpha: 0.04), colorScheme.surface);
return Container(
margin: margin ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
+221 -118
View File
@@ -69,6 +69,9 @@ class _UpdateDialogState extends State<UpdateDialog> {
);
if (filePath != null) {
// Cancel progress notification first
await notificationService.cancelUpdateNotification();
await notificationService.showUpdateDownloadComplete(
version: widget.updateInfo.version,
);
@@ -80,6 +83,9 @@ class _UpdateDialogState extends State<UpdateDialog> {
// Open APK for installation
await ApkDownloader.installApk(filePath);
} else {
// Cancel progress notification first
await notificationService.cancelUpdateNotification();
await notificationService.showUpdateDownloadFailed();
if (mounted) {
@@ -98,129 +104,202 @@ class _UpdateDialogState extends State<UpdateDialog> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
return AlertDialog(
title: Row(
children: [
Icon(Icons.system_update, color: colorScheme.primary),
const SizedBox(width: 12),
const Text('Update Available'),
],
),
content: SizedBox(
width: double.maxFinite,
return Dialog(
backgroundColor: colorScheme.surfaceContainerHigh,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Version info
// Header with icon
Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Icon(Icons.system_update_rounded, color: colorScheme.onPrimaryContainer, size: 28),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Update Available', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 2),
Text('A new version is ready', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
],
),
),
],
),
const SizedBox(height: 20),
// Version badge
Container(
padding: const EdgeInsets.all(12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
color: isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
: Color.alphaBlend(Colors.black.withValues(alpha: 0.04), colorScheme.surface),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'v${AppInfo.version}',
style: TextStyle(color: colorScheme.onPrimaryContainer),
),
const SizedBox(width: 8),
Icon(Icons.arrow_forward, size: 16, color: colorScheme.onPrimaryContainer),
const SizedBox(width: 8),
Text(
'v${widget.updateInfo.version}',
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.bold,
),
),
_VersionChip(version: AppInfo.version, label: 'Current', colorScheme: colorScheme),
const SizedBox(width: 12),
Icon(Icons.arrow_forward_rounded, size: 20, color: colorScheme.primary),
const SizedBox(width: 12),
_VersionChip(version: widget.updateInfo.version, label: 'New', colorScheme: colorScheme, isNew: true),
],
),
),
const SizedBox(height: 16),
const SizedBox(height: 20),
// Changelog header
Text(
'What\'s New:',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
// Changelog content (scrollable) - hide when downloading
if (!_isDownloading)
Flexible(
child: Container(
constraints: const BoxConstraints(maxHeight: 200),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Text(
_formatChangelog(widget.updateInfo.changelog),
style: Theme.of(context).textTheme.bodySmall,
// Download progress (when downloading)
if (_isDownloading) ...[
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
: Color.alphaBlend(Colors.black.withValues(alpha: 0.03), colorScheme.surface),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
SizedBox(
width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.primary),
),
const SizedBox(width: 12),
Text('Downloading...', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
],
),
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: _progress,
minHeight: 6,
backgroundColor: colorScheme.surfaceContainerHighest,
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(_statusText, style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)),
Text('${(_progress * 100).toInt()}%', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.primary, fontWeight: FontWeight.w600)),
],
),
],
),
),
] else ...[
// Changelog section
Text("What's New", style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Container(
constraints: const BoxConstraints(maxHeight: 180),
decoration: BoxDecoration(
color: isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
: Color.alphaBlend(Colors.black.withValues(alpha: 0.03), colorScheme.surface),
borderRadius: BorderRadius.circular(16),
),
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Text(
_formatChangelog(widget.updateInfo.changelog),
style: Theme.of(context).textTheme.bodySmall?.copyWith(height: 1.5),
),
),
),
// Download progress
if (_isDownloading) ...[
const SizedBox(height: 8),
LinearProgressIndicator(value: _progress),
const SizedBox(height: 8),
Text(
_statusText,
style: Theme.of(context).textTheme.bodySmall,
),
],
const SizedBox(height: 24),
// Action buttons
if (_isDownloading)
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: () => Navigator.pop(context),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: const Text('Cancel'),
),
)
else
Column(
children: [
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _downloadAndInstall,
icon: const Icon(Icons.download_rounded, size: 20),
label: const Text('Download & Install'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextButton(
onPressed: () {
widget.onDisableUpdates();
Navigator.pop(context);
},
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: Text("Don't remind", style: TextStyle(color: colorScheme.onSurfaceVariant)),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton(
onPressed: () {
widget.onDismiss();
Navigator.pop(context);
},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: const Text('Later'),
),
),
],
),
],
),
],
),
),
actions: _isDownloading
? [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
]
: [
// Don't remind again button
TextButton(
onPressed: () {
widget.onDisableUpdates();
Navigator.pop(context);
},
child: Text(
'Don\'t remind',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
// Later button
TextButton(
onPressed: () {
widget.onDismiss();
Navigator.pop(context);
},
child: const Text('Later'),
),
// Download button
FilledButton(
onPressed: _downloadAndInstall,
child: const Text('Install'),
),
],
);
}
/// Format changelog - clean up markdown and extract relevant content
String _formatChangelog(String changelog) {
// Try to extract just the changelog section (between "What's New" and "Downloads" or "---")
var content = changelog;
// Find content after "What's New" header
@@ -238,19 +317,18 @@ class _UpdateDialogState extends State<UpdateDialog> {
// 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.)
// Check if it's a section header
final sectionMatch = RegExp(r'^#{1,3}\s*(.+)$').firstMatch(line);
if (sectionMatch != null) {
currentSection = sectionMatch.group(1)?.trim();
if (currentSection != null && currentSection.isNotEmpty) {
final section = sectionMatch.group(1)?.trim();
if (section != null && section.isNotEmpty) {
if (formattedLines.isNotEmpty) formattedLines.add('');
formattedLines.add('$currentSection:');
formattedLines.add(section);
}
continue;
}
@@ -259,36 +337,23 @@ class _UpdateDialogState extends State<UpdateDialog> {
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) ?? ''
);
itemText = itemText.replaceAllMapped(RegExp(r'\*\*([^*]+)\*\*'), (m) => m.group(1) ?? '');
itemText = itemText.replaceAllMapped(RegExp(r'`([^`]+)`'), (m) => m.group(1) ?? '');
formattedLines.add('$itemText');
continue;
}
// Check if it's a sub-item (indented list)
// Check if it's a sub-item
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) ?? ''
);
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)}...';
}
@@ -297,6 +362,44 @@ class _UpdateDialogState extends State<UpdateDialog> {
}
}
class _VersionChip extends StatelessWidget {
final String version;
final String label;
final ColorScheme colorScheme;
final bool isNew;
const _VersionChip({
required this.version,
required this.label,
required this.colorScheme,
this.isNew = false,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(label, style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isNew ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Text(
'v$version',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: isNew ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
fontWeight: isNew ? FontWeight.bold : FontWeight.w500,
),
),
),
],
);
}
}
/// Show update dialog
Future<void> showUpdateDialog(
BuildContext context, {