feat: allow hiding downloads from recents without deleting files

- Add hiddenDownloadIds set to RecentAccessState
- X button on download items hides from recents (not delete file)
- Hidden IDs persisted in SharedPreferences
- Clear All also clears hidden downloads list
- Single track shows as Track, 2+ tracks shows as Album in recents
This commit is contained in:
zarzet
2026-01-18 12:52:25 +07:00
parent 5ea454a0b0
commit bc120ffa76
2 changed files with 98 additions and 30 deletions
+41 -5
View File
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _recentAccessKey = 'recent_access_history';
const _hiddenDownloadsKey = 'hidden_downloads_in_recents';
const _maxRecentItems = 20;
/// Types of items that can be accessed
@@ -75,19 +76,23 @@ class RecentAccessItem {
/// State for recent access history
class RecentAccessState {
final List<RecentAccessItem> items;
final Set<String> hiddenDownloadIds; // IDs of downloads hidden from recents
final bool isLoaded;
const RecentAccessState({
this.items = const [],
this.hiddenDownloadIds = const {},
this.isLoaded = false,
});
RecentAccessState copyWith({
List<RecentAccessItem>? items,
Set<String>? hiddenDownloadIds,
bool? isLoaded,
}) {
return RecentAccessState(
items: items ?? this.items,
hiddenDownloadIds: hiddenDownloadIds ?? this.hiddenDownloadIds,
isLoaded: isLoaded ?? this.isLoaded,
);
}
@@ -104,19 +109,27 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
Future<void> _loadHistory() async {
final prefs = await SharedPreferences.getInstance();
final json = prefs.getString(_recentAccessKey);
final hiddenJson = prefs.getStringList(_hiddenDownloadsKey);
List<RecentAccessItem> items = [];
Set<String> hiddenIds = {};
if (json != null) {
try {
final List<dynamic> decoded = jsonDecode(json);
final items = decoded
items = decoded
.map((e) => RecentAccessItem.fromJson(e as Map<String, dynamic>))
.toList();
state = state.copyWith(items: items, isLoaded: true);
} catch (e) {
state = state.copyWith(isLoaded: true);
// Ignore parse errors
}
} else {
state = state.copyWith(isLoaded: true);
}
if (hiddenJson != null) {
hiddenIds = hiddenJson.toSet();
}
state = state.copyWith(items: items, hiddenDownloadIds: hiddenIds, isLoaded: true);
}
Future<void> _saveHistory() async {
@@ -125,6 +138,11 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
await prefs.setString(_recentAccessKey, json);
}
Future<void> _saveHiddenDownloads() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(_hiddenDownloadsKey, state.hiddenDownloadIds.toList());
}
/// Record an access to an artist
void recordArtistAccess({
required String id,
@@ -229,11 +247,29 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
_saveHistory();
}
/// Hide a download item from recents (without deleting the actual download)
void hideDownloadFromRecents(String downloadId) {
final updatedHidden = {...state.hiddenDownloadIds, downloadId};
state = state.copyWith(hiddenDownloadIds: updatedHidden);
_saveHiddenDownloads();
}
/// Check if a download is hidden from recents
bool isDownloadHidden(String downloadId) {
return state.hiddenDownloadIds.contains(downloadId);
}
/// Clear all history
void clearHistory() {
state = state.copyWith(items: []);
_saveHistory();
}
/// Clear hidden downloads (show all again)
void clearHiddenDownloads() {
state = state.copyWith(hiddenDownloadIds: {});
_saveHiddenDownloads();
}
}
/// Provider instance
+57 -25
View File
@@ -651,39 +651,64 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
Widget _buildRecentAccess(List<RecentAccessItem> items, ColorScheme colorScheme) {
final historyItems = ref.read(downloadHistoryProvider).items;
// Group download history by album to avoid flooding recents with individual tracks
final albumMap = <String, DownloadHistoryItem>{};
// Group download history by album
final albumGroups = <String, List<DownloadHistoryItem>>{};
for (final h in historyItems) {
// Use album name + artist as unique key (handle empty albumArtist)
final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty)
? h.albumArtist!
: h.artistName;
final albumKey = '${h.albumName}|$artistForKey';
// Keep the most recent download for each album
if (!albumMap.containsKey(albumKey) ||
h.downloadedAt.isAfter(albumMap[albumKey]!.downloadedAt)) {
albumMap[albumKey] = h;
albumGroups.putIfAbsent(albumKey, () => []).add(h);
}
// Convert to RecentAccessItem based on track count:
// - 1 track: show as individual Track
// - 2+ tracks: show as Album
final downloadItems = <RecentAccessItem>[];
for (final entry in albumGroups.entries) {
final tracks = entry.value;
final mostRecent = tracks.reduce((a, b) =>
a.downloadedAt.isAfter(b.downloadedAt) ? a : b);
final artistForKey = (mostRecent.albumArtist != null && mostRecent.albumArtist!.isNotEmpty)
? mostRecent.albumArtist!
: mostRecent.artistName;
if (tracks.length == 1) {
// Single track - show as Track
downloadItems.add(RecentAccessItem(
id: mostRecent.spotifyId ?? mostRecent.id,
name: mostRecent.trackName,
subtitle: mostRecent.artistName,
imageUrl: mostRecent.coverUrl,
type: RecentAccessType.track,
accessedAt: mostRecent.downloadedAt,
providerId: 'download',
));
} else {
// Multiple tracks - show as Album
downloadItems.add(RecentAccessItem(
id: '${mostRecent.albumName}|$artistForKey',
name: mostRecent.albumName,
subtitle: artistForKey,
imageUrl: mostRecent.coverUrl,
type: RecentAccessType.album,
accessedAt: mostRecent.downloadedAt,
providerId: 'download',
));
}
}
// Convert grouped albums to RecentAccessItem with album type
final downloadItems = albumMap.values.take(10).map((h) {
// Use albumArtist if available and not empty, otherwise artistName
final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty)
? h.albumArtist!
: h.artistName;
return RecentAccessItem(
id: '${h.albumName}|$artistForKey', // Use album key as ID
name: h.albumName,
subtitle: artistForKey,
imageUrl: h.coverUrl,
type: RecentAccessType.album,
accessedAt: h.downloadedAt,
providerId: 'download',
);
}).toList();
// Sort by most recent and take top 10
downloadItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt));
final allItems = [...items, ...downloadItems];
// Filter out hidden downloads
final hiddenIds = ref.read(recentAccessProvider).hiddenDownloadIds;
final visibleDownloads = downloadItems
.where((item) => !hiddenIds.contains(item.id))
.take(10)
.toList();
final allItems = [...items, ...visibleDownloads];
allItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt));
final seen = <String>{};
@@ -711,6 +736,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
TextButton(
onPressed: () {
ref.read(recentAccessProvider.notifier).clearHistory();
ref.read(recentAccessProvider.notifier).clearHiddenDownloads();
},
child: Text(
context.l10n.dialogClearAll,
@@ -804,7 +830,13 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
IconButton(
icon: Icon(Icons.close, size: 20, color: colorScheme.onSurfaceVariant),
onPressed: () {
ref.read(recentAccessProvider.notifier).removeItem(item);
if (item.providerId == 'download') {
// For download items, hide from recents without deleting the file
ref.read(recentAccessProvider.notifier).hideDownloadFromRecents(item.id);
} else {
// For other items, remove from recent history
ref.read(recentAccessProvider.notifier).removeItem(item);
}
},
),
],