diff --git a/lib/providers/download_history_provider.dart b/lib/providers/download_history_provider.dart index 096b9438..a256c21f 100644 --- a/lib/providers/download_history_provider.dart +++ b/lib/providers/download_history_provider.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:math'; import 'dart:io'; +import 'package:flutter/foundation.dart' show setEquals; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/models/download_history.dart'; @@ -1211,27 +1212,49 @@ Set mergeVisibleHistoryLookupKeys({ return keys; } +class _VisibleHistoryBatchExistsNotifier extends Notifier> { + _VisibleHistoryBatchExistsNotifier(this.request); + + final HistoryBatchLookupRequest request; + + @override + Set build() { + final history = ref.watch(downloadHistoryProvider); + final persistedKeys = ref + .watch(downloadHistoryBatchExistsProvider(request)) + .maybeWhen( + skipLoadingOnReload: true, + skipLoadingOnRefresh: true, + data: (keys) => keys, + orElse: () => const {}, + ); + return mergeVisibleHistoryLookupKeys( + state: history, + request: request, + persistedKeys: persistedKeys, + ); + } + + @override + bool updateShouldNotify(Set previous, Set next) => + !setEquals(previous, next); +} + +final _visibleHistoryBatchExistsProvider = NotifierProvider.autoDispose + .family< + _VisibleHistoryBatchExistsNotifier, + Set, + HistoryBatchLookupRequest + >(_VisibleHistoryBatchExistsNotifier.new); + /// UI-facing history lookup that keeps the last database result visible while /// a batch query reloads and immediately merges downloads persisted in memory. /// This prevents completed tracks from losing their Play action until the /// remainder of a large download batch finishes. final downloadHistoryVisibleBatchExistsProvider = Provider.autoDispose - .family, HistoryBatchLookupRequest>((ref, request) { - final state = ref.watch(downloadHistoryProvider); - final persistedKeys = ref - .watch(downloadHistoryBatchExistsProvider(request)) - .maybeWhen( - skipLoadingOnReload: true, - skipLoadingOnRefresh: true, - data: (keys) => keys, - orElse: () => const {}, - ); - return mergeVisibleHistoryLookupKeys( - state: state, - request: request, - persistedKeys: persistedKeys, - ); - }); + .family, HistoryBatchLookupRequest>( + (ref, request) => ref.watch(_visibleHistoryBatchExistsProvider(request)), + ); class DownloadedAlbumTracksRequest { final String albumName; diff --git a/test/download_history_visible_membership_test.dart b/test/download_history_visible_membership_test.dart new file mode 100644 index 00000000..c2042dd9 --- /dev/null +++ b/test/download_history_visible_membership_test.dart @@ -0,0 +1,236 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/providers/download_history_provider.dart'; +import 'package:spotiflac_android/services/history_database.dart'; + +class _History extends DownloadHistoryNotifier { + @override + DownloadHistoryState build() => DownloadHistoryState(); + + void publish(DownloadHistoryState Function(DownloadHistoryState) update) => + state = update(state); +} + +const _older = HistoryLookupRequest( + spotifyId: 'older-track', + trackName: 'Older song', + artistName: 'Example artist', +); +const _completed = HistoryLookupRequest( + spotifyId: 'completed-track', + trackName: 'Completed song', + artistName: 'Example artist', +); +const _unrelated = HistoryLookupRequest( + spotifyId: 'unrelated-track', + trackName: 'Unrelated song', + artistName: 'Example artist', +); +const _request = HistoryBatchLookupRequest([_older, _completed]); + +DownloadHistoryItem _item(HistoryLookupRequest lookup) => DownloadHistoryItem( + id: lookup.spotifyId, + spotifyId: lookup.spotifyId, + trackName: lookup.trackName, + artistName: lookup.artistName, + albumName: 'Example album', + filePath: '/music/${lookup.spotifyId}.flac', + service: 'example-provider', + downloadedAt: DateTime.utc(2026), +); + +void main() { + test( + 'unchanged membership skips unrelated history and revision updates', + () async { + final history = _History(); + final databaseProvider = downloadHistoryBatchExistsProvider(_request); + var queries = 0; + final container = ProviderContainer( + overrides: [ + downloadHistoryProvider.overrideWith(() => history), + databaseProvider.overrideWith((ref) { + ref.watch( + downloadHistoryProvider.select((s) => s.loadedIndexVersion), + ); + queries++; + return queries == 1 + ? {_older.lookupKey, _completed.lookupKey} + : {_completed.lookupKey, _older.lookupKey}; + }), + ], + ); + addTearDown(container.dispose); + final provider = downloadHistoryVisibleBatchExistsProvider(_request); + final notifications = >[]; + final subscription = container.listen( + provider, + (_, next) => notifications.add(next), + fireImmediately: true, + ); + addTearDown(subscription.close); + final expected = {_older.lookupKey, _completed.lookupKey}; + expect(notifications, [expected]); + expect(queries, 1); + + history.publish( + (s) => s.copyWith( + items: [_item(_unrelated)], + lookupItems: [_item(_unrelated)], + totalCount: 1, + ), + ); + await container.pump(); + expect(container.read(provider), expected); + expect(notifications, hasLength(1)); + expect(queries, 1); + + history.publish((s) => s.copyWith(totalCount: 20)); + await container.pump(); + expect(notifications, hasLength(1)); + + history.publish((s) => s.copyWith(loadedIndexVersion: 1)); + await container.pump(); + expect(queries, 2); + expect(container.read(provider), expected); + expect(notifications, hasLength(1)); + }, + ); + + test( + 'matching completion and removal notify before the revision changes', + () async { + final history = _History(); + var queries = 0; + final container = ProviderContainer( + overrides: [ + downloadHistoryProvider.overrideWith(() => history), + downloadHistoryBatchExistsProvider(_request).overrideWith((ref) { + ref.watch( + downloadHistoryProvider.select((s) => s.loadedIndexVersion), + ); + queries++; + return {}; + }), + ], + ); + addTearDown(container.dispose); + final provider = downloadHistoryVisibleBatchExistsProvider(_request); + final notifications = >[]; + final subscription = container.listen( + provider, + (_, next) => notifications.add(next), + fireImmediately: true, + ); + addTearDown(subscription.close); + expect(notifications, [{}]); + + history.publish( + (s) => s.copyWith( + items: [_item(_completed)], + lookupItems: [_item(_completed)], + totalCount: 1, + ), + ); + expect(container.read(provider), {_completed.lookupKey}); + await container.pump(); + expect(notifications, [ + {}, + {_completed.lookupKey}, + ]); + + history.publish( + (s) => s.copyWith(items: [], lookupItems: [], totalCount: 0), + ); + expect(container.read(provider), isEmpty); + await container.pump(); + expect(notifications, [ + {}, + {_completed.lookupKey}, + {}, + ]); + expect(container.read(downloadHistoryProvider).loadedIndexVersion, 0); + expect(queries, 1); + }, + ); + + for (final refresh in [false, true]) { + test('retains database keys while loading (refresh=$refresh)', () async { + final history = _History(); + final databaseProvider = downloadHistoryBatchExistsProvider(_request); + final response = Completer>(); + addTearDown(() { + if (!response.isCompleted) response.complete({}); + }); + var queries = 0; + final container = ProviderContainer( + overrides: [ + downloadHistoryProvider.overrideWith(() => history), + databaseProvider.overrideWith((ref) { + ref.watch( + downloadHistoryProvider.select((s) => s.loadedIndexVersion), + ); + queries++; + return queries == 1 ? {_older.lookupKey} : response.future; + }), + ], + ); + addTearDown(container.dispose); + final provider = downloadHistoryVisibleBatchExistsProvider(_request); + final notifications = >[]; + final subscription = container.listen( + provider, + (_, next) => notifications.add(next), + fireImmediately: true, + ); + addTearDown(subscription.close); + expect(notifications, [ + {_older.lookupKey}, + ]); + + if (refresh) { + container.invalidate(databaseProvider); + } else { + history.publish((s) => s.copyWith(loadedIndexVersion: 1)); + } + await container.pump(); + final loading = container.read(databaseProvider); + expect(loading.isLoading, isTrue); + expect(loading.isRefreshing, refresh); + expect(loading.isReloading, !refresh); + expect(queries, 2); + expect(container.read(provider), {_older.lookupKey}); + expect(notifications, hasLength(1)); + + history.publish( + (s) => s.copyWith( + items: [_item(_completed)], + lookupItems: [_item(_completed)], + totalCount: 1, + ), + ); + expect(container.read(provider), { + _older.lookupKey, + _completed.lookupKey, + }); + await container.pump(); + expect(notifications, [ + {_older.lookupKey}, + {_older.lookupKey, _completed.lookupKey}, + ]); + + response.complete({}); + await container.read(databaseProvider.future); + await container.pump(); + expect(container.read(provider), {_completed.lookupKey}); + expect(notifications, [ + {_older.lookupKey}, + {_older.lookupKey, _completed.lookupKey}, + {_completed.lookupKey}, + ]); + expect(queries, 2); + }); + } +}