mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-19 14:07:26 +02:00
perf(details): load local albums with skeletons and bound artwork decoding
This commit is contained in:
1 parent
a377885e56
commit
d97b4bfd2d
7 files changed
+320
-57
No files matched your search
@@ -211,17 +211,20 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen>
|
||||
required int navigationIndex,
|
||||
}) async {
|
||||
final navigator = Navigator.of(context);
|
||||
final embeddedCoverPath =
|
||||
await DownloadedEmbeddedCoverResolver.resolveOrExtract(
|
||||
item.filePath,
|
||||
onChanged: _onEmbeddedCoverChanged,
|
||||
);
|
||||
final embeddedCoverPath = context.isMornye
|
||||
? DownloadedEmbeddedCoverResolver.resolve(item.filePath)
|
||||
: await DownloadedEmbeddedCoverResolver.resolveOrExtract(
|
||||
item.filePath,
|
||||
onChanged: _onEmbeddedCoverChanged,
|
||||
);
|
||||
if (!mounted) return;
|
||||
final artworkSource = embeddedCoverPath ?? item.coverUrl;
|
||||
if (embeddedCoverPath == null) {
|
||||
if (embeddedCoverPath == null && !context.isMornye) {
|
||||
precacheCoverImage(context, item.coverUrl);
|
||||
}
|
||||
final backdropReady = precacheMetadataBackdrop(context, artworkSource);
|
||||
final backdropReady = context.isMornye
|
||||
? Future<void>.value()
|
||||
: precacheMetadataBackdrop(context, artworkSource);
|
||||
final beforeModTime =
|
||||
await DownloadedEmbeddedCoverResolver.readFileModTimeMillis(
|
||||
item.filePath,
|
||||
@@ -229,15 +232,17 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen>
|
||||
await backdropReady;
|
||||
if (!mounted) return;
|
||||
|
||||
final result = await navigator.push(
|
||||
slidePageRoute<bool>(
|
||||
page: TrackMetadataScreen(
|
||||
item: item,
|
||||
historyNavigationItems: navigationItems,
|
||||
navigationIndex: navigationIndex,
|
||||
),
|
||||
final route = slidePageRoute<bool>(
|
||||
page: TrackMetadataScreen(
|
||||
item: item,
|
||||
historyNavigationItems: navigationItems,
|
||||
navigationIndex: navigationIndex,
|
||||
),
|
||||
);
|
||||
final result = await navigator.push(route);
|
||||
// Refresh the album after the return animation, not while both pages paint.
|
||||
if (route is TransitionRoute<bool>) await route.completed;
|
||||
if (!mounted) return;
|
||||
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
|
||||
item.filePath,
|
||||
beforeModTime: beforeModTime,
|
||||
|
||||
@@ -42,13 +42,15 @@ class LocalAlbumScreen extends ConsumerStatefulWidget {
|
||||
final String artistName;
|
||||
final String? coverPath;
|
||||
final List<LocalLibraryItem> tracks;
|
||||
final Future<List<LocalLibraryItem>> Function()? loadTracks;
|
||||
|
||||
const LocalAlbumScreen({
|
||||
super.key,
|
||||
required this.albumName,
|
||||
required this.artistName,
|
||||
this.coverPath,
|
||||
required this.tracks,
|
||||
this.tracks = const [],
|
||||
this.loadTracks,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -61,6 +63,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
|
||||
CollapsingHeaderScrollMixin<LocalAlbumScreen> {
|
||||
late List<LocalLibraryItem> _sortedTracksCache;
|
||||
late Map<int, List<LocalLibraryItem>> _discGroupsCache;
|
||||
List<LocalLibraryItem>? _loadedTracks;
|
||||
bool _loadingTracks = false;
|
||||
Object? _loadError;
|
||||
int _loadGeneration = 0;
|
||||
|
||||
void _showCueVirtualTrackSnackBar() {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -77,19 +83,44 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
|
||||
void initState() {
|
||||
super.initState();
|
||||
_rebuildTrackCaches();
|
||||
_loadTracks();
|
||||
}
|
||||
|
||||
Future<void> _loadTracks() async {
|
||||
final loader = widget.loadTracks;
|
||||
if (loader == null) return;
|
||||
final generation = ++_loadGeneration;
|
||||
_loadingTracks = true;
|
||||
_loadError = null;
|
||||
try {
|
||||
final tracks = await loader();
|
||||
if (!mounted || generation != _loadGeneration) return;
|
||||
_loadedTracks = tracks;
|
||||
} catch (error) {
|
||||
if (!mounted || generation != _loadGeneration) return;
|
||||
_loadError = error;
|
||||
}
|
||||
_loadingTracks = false;
|
||||
setState(_rebuildTrackCaches);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant LocalAlbumScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (!identical(oldWidget.tracks, widget.tracks) ||
|
||||
oldWidget.tracks.length != widget.tracks.length) {
|
||||
oldWidget.tracks.length != widget.tracks.length ||
|
||||
oldWidget.loadTracks != widget.loadTracks) {
|
||||
_loadGeneration++;
|
||||
_loadedTracks = null;
|
||||
_loadingTracks = false;
|
||||
_loadError = null;
|
||||
_rebuildTrackCaches();
|
||||
_loadTracks();
|
||||
}
|
||||
}
|
||||
|
||||
List<LocalLibraryItem> _buildSortedTracks() {
|
||||
final tracks = List<LocalLibraryItem>.from(widget.tracks);
|
||||
final tracks = List<LocalLibraryItem>.from(_loadedTracks ?? widget.tracks);
|
||||
tracks.sort((a, b) {
|
||||
final aDisc = a.discNumber ?? 1;
|
||||
final bDisc = b.discNumber ?? 1;
|
||||
@@ -196,7 +227,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
|
||||
: 0.0;
|
||||
final tracks = _sortedTracksCache;
|
||||
|
||||
if (tracks.isEmpty) {
|
||||
if (tracks.isEmpty && !_loadingTracks && _loadError == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.albumName)),
|
||||
body: Center(child: Text(context.l10n.noTracksFoundForAlbum)),
|
||||
@@ -210,7 +241,26 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
|
||||
isSelectionMode: isSelectionMode,
|
||||
onExitSelectionMode: exitSelectionMode,
|
||||
appBar: _buildAppBar(context, colorScheme, qualityLabelMode),
|
||||
slivers: [_buildTrackList(context, colorScheme, tracks)],
|
||||
slivers: [
|
||||
if (_loadError != null)
|
||||
SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: TextButton.icon(
|
||||
onPressed: () => setState(() {
|
||||
_loadTracks();
|
||||
}),
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text(context.l10n.dialogRetry),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (_loadingTracks)
|
||||
const SliverToBoxAdapter(
|
||||
child: RepaintBoundary(child: AlbumTrackListSkeleton(itemCount: 8)),
|
||||
)
|
||||
else
|
||||
_buildTrackList(context, colorScheme, tracks),
|
||||
],
|
||||
selectionBar: _buildSelectionBottomBar(
|
||||
context,
|
||||
colorScheme,
|
||||
@@ -231,7 +281,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
|
||||
.watch(
|
||||
playerMotionArtworkProvider((
|
||||
album: widget.albumName,
|
||||
artist: _sortedTracksCache.first.artistName,
|
||||
artist:
|
||||
_sortedTracksCache.firstOrNull?.artistName ??
|
||||
widget.artistName,
|
||||
)),
|
||||
)
|
||||
.value
|
||||
@@ -403,6 +455,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
|
||||
key: ValueKey(track.id),
|
||||
child: StaggeredListItem(
|
||||
index: index,
|
||||
animate: widget.loadTracks == null,
|
||||
child: _buildTrackItem(context, colorScheme, track),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -119,17 +119,20 @@ class _MornyeLibraryScreenState extends ConsumerState<MornyeLibraryScreen> {
|
||||
);
|
||||
return;
|
||||
}
|
||||
final rows = await LibraryDatabase.instance.getQueueLocalAlbumTracksByKey(
|
||||
entry.key,
|
||||
);
|
||||
if (!mounted) return;
|
||||
Future<List<LocalLibraryItem>> loadTracks() async {
|
||||
final rows = await LibraryDatabase.instance.getQueueLocalAlbumTracksByKey(
|
||||
entry.key,
|
||||
);
|
||||
return rows.map(LocalLibraryItem.fromJson).toList(growable: false);
|
||||
}
|
||||
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => LocalAlbumScreen(
|
||||
albumName: entry.name,
|
||||
artistName: entry.artist,
|
||||
coverPath: entry.cover,
|
||||
tracks: rows.map(LocalLibraryItem.fromJson).toList(growable: false),
|
||||
loadTracks: loadTracks,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -104,24 +104,30 @@ extension _QueueTabNavigation on _QueueTabState {
|
||||
);
|
||||
|
||||
final navigator = Navigator.of(context);
|
||||
final embeddedCoverPath =
|
||||
await DownloadedEmbeddedCoverResolver.resolveOrExtract(
|
||||
historyItem.filePath,
|
||||
onChanged: _onEmbeddedCoverChanged,
|
||||
);
|
||||
final embeddedCoverPath = context.isMornye
|
||||
? DownloadedEmbeddedCoverResolver.resolve(historyItem.filePath)
|
||||
: await DownloadedEmbeddedCoverResolver.resolveOrExtract(
|
||||
historyItem.filePath,
|
||||
onChanged: _onEmbeddedCoverChanged,
|
||||
);
|
||||
if (!mounted) return;
|
||||
final artworkSource = embeddedCoverPath ?? historyItem.coverUrl;
|
||||
if (embeddedCoverPath == null) {
|
||||
if (embeddedCoverPath == null && !context.isMornye) {
|
||||
precacheCoverImage(context, historyItem.coverUrl);
|
||||
}
|
||||
final backdropReady = precacheMetadataBackdrop(context, artworkSource);
|
||||
final backdropReady = context.isMornye
|
||||
? Future<void>.value()
|
||||
: precacheMetadataBackdrop(context, artworkSource);
|
||||
_searchFocusNode.unfocus();
|
||||
final beforeModTime = await _readFileModTimeMillis(historyItem.filePath);
|
||||
await backdropReady;
|
||||
if (!mounted) return;
|
||||
final result = await navigator.push(
|
||||
slidePageRoute<bool>(page: TrackMetadataScreen(item: historyItem)),
|
||||
final route = slidePageRoute<bool>(
|
||||
page: TrackMetadataScreen(item: historyItem),
|
||||
);
|
||||
final result = await navigator.push(route);
|
||||
if (route is TransitionRoute<bool>) await route.completed;
|
||||
if (!mounted) return;
|
||||
_searchFocusNode.unfocus();
|
||||
if (result == true) {
|
||||
await _scheduleDownloadedEmbeddedCoverRefreshForPath(
|
||||
@@ -143,31 +149,35 @@ extension _QueueTabNavigation on _QueueTabState {
|
||||
int? navigationIndex,
|
||||
}) async {
|
||||
final navigator = Navigator.of(context);
|
||||
final embeddedCoverPath =
|
||||
await DownloadedEmbeddedCoverResolver.resolveOrExtract(
|
||||
item.filePath,
|
||||
onChanged: _onEmbeddedCoverChanged,
|
||||
);
|
||||
final embeddedCoverPath = context.isMornye
|
||||
? DownloadedEmbeddedCoverResolver.resolve(item.filePath)
|
||||
: await DownloadedEmbeddedCoverResolver.resolveOrExtract(
|
||||
item.filePath,
|
||||
onChanged: _onEmbeddedCoverChanged,
|
||||
);
|
||||
if (!mounted) return;
|
||||
final artworkSource = embeddedCoverPath ?? item.coverUrl;
|
||||
if (embeddedCoverPath == null) {
|
||||
if (embeddedCoverPath == null && !context.isMornye) {
|
||||
precacheCoverImage(context, item.coverUrl);
|
||||
}
|
||||
final backdropReady = precacheMetadataBackdrop(context, artworkSource);
|
||||
final backdropReady = context.isMornye
|
||||
? Future<void>.value()
|
||||
: precacheMetadataBackdrop(context, artworkSource);
|
||||
_searchFocusNode.unfocus();
|
||||
final beforeModTime = await _readFileModTimeMillis(item.filePath);
|
||||
await backdropReady;
|
||||
if (!mounted) return;
|
||||
final result = await navigator.push(
|
||||
slidePageRoute<bool>(
|
||||
page: TrackMetadataScreen(
|
||||
item: item,
|
||||
historyNavigationItems: navigationItems,
|
||||
navigationIndex: navigationIndex,
|
||||
coverHeroTag: 'cover_lib_dl_${item.id}',
|
||||
),
|
||||
final route = slidePageRoute<bool>(
|
||||
page: TrackMetadataScreen(
|
||||
item: item,
|
||||
historyNavigationItems: navigationItems,
|
||||
navigationIndex: navigationIndex,
|
||||
coverHeroTag: 'cover_lib_dl_${item.id}',
|
||||
),
|
||||
);
|
||||
final result = await navigator.push(route);
|
||||
if (route is TransitionRoute<bool>) await route.completed;
|
||||
if (!mounted) return;
|
||||
_searchFocusNode.unfocus();
|
||||
if (result == true) {
|
||||
await _scheduleDownloadedEmbeddedCoverRefreshForPath(
|
||||
@@ -189,7 +199,9 @@ extension _QueueTabNavigation on _QueueTabState {
|
||||
int? navigationIndex,
|
||||
}) async {
|
||||
_searchFocusNode.unfocus();
|
||||
await precacheMetadataBackdrop(context, item.coverPath);
|
||||
if (!context.isMornye) {
|
||||
await precacheMetadataBackdrop(context, item.coverPath);
|
||||
}
|
||||
if (!mounted) return;
|
||||
await Navigator.push(
|
||||
context,
|
||||
@@ -222,9 +234,8 @@ extension _QueueTabNavigation on _QueueTabState {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _navigateToLocalAlbum(_GroupedLocalAlbum album) async {
|
||||
var tracks = album.tracks;
|
||||
if (tracks.isEmpty && album.displayTrackCount > 0) {
|
||||
void _navigateToLocalAlbum(_GroupedLocalAlbum album) {
|
||||
Future<List<LocalLibraryItem>> loadTracks() async {
|
||||
var rows = album.albumKey.isNotEmpty
|
||||
? await LibraryDatabase.instance.getQueueLocalAlbumTracksByKey(
|
||||
album.albumKey,
|
||||
@@ -239,16 +250,19 @@ extension _QueueTabNavigation on _QueueTabState {
|
||||
album.artistName,
|
||||
);
|
||||
}
|
||||
tracks = rows.map(LocalLibraryItem.fromJson).toList(growable: false);
|
||||
if (!mounted) return;
|
||||
return rows.map(LocalLibraryItem.fromJson).toList(growable: false);
|
||||
}
|
||||
|
||||
_navigateWithUnfocus(
|
||||
slidePageRoute(
|
||||
page: LocalAlbumScreen(
|
||||
albumName: album.albumName,
|
||||
artistName: album.artistName,
|
||||
coverPath: album.coverPath,
|
||||
tracks: tracks,
|
||||
tracks: album.tracks,
|
||||
loadTracks: album.tracks.isEmpty && album.displayTrackCount > 0
|
||||
? loadTracks
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -73,6 +73,13 @@ extension _TrackMetadataMornye on _TrackMetadataScreenState {
|
||||
artUri: artwork,
|
||||
colorScheme: scheme,
|
||||
iconSize: 80,
|
||||
cacheWidth:
|
||||
(320 *
|
||||
MediaQuery.devicePixelRatioOf(
|
||||
context,
|
||||
))
|
||||
.round()
|
||||
.clamp(320, 1280),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -191,7 +191,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
|
||||
_currentDownloadItem = widget.item;
|
||||
_currentLocalLibraryItem = widget.localItem;
|
||||
_currentNavigationIndex = widget.navigationIndex;
|
||||
_checkFile();
|
||||
unawaited(_checkFile());
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/screens/local_album_screen.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
|
||||
LocalLibraryItem _track(int number) => LocalLibraryItem(
|
||||
id: 'track-$number',
|
||||
trackName: 'Song $number',
|
||||
artistName: 'Artist',
|
||||
albumName: 'Album',
|
||||
filePath: 'content://library/document/song-$number.flac',
|
||||
trackNumber: number,
|
||||
scannedAt: DateTime(2026),
|
||||
);
|
||||
|
||||
void main() {
|
||||
Future<GlobalKey<NavigatorState>> mount(WidgetTester tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
tester.view.physicalSize = const Size(430, 1200);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final navigator = GlobalKey<NavigatorState>();
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
child: MaterialApp(
|
||||
navigatorKey: navigator,
|
||||
theme: MornyeTheme.build(Brightness.light),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const Scaffold(body: Text('Library')),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
return navigator;
|
||||
}
|
||||
|
||||
PageRoute<void> route(Widget page) => PageRouteBuilder<void>(
|
||||
transitionDuration: const Duration(milliseconds: 400),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 400),
|
||||
pageBuilder: (_, _, _) => page,
|
||||
transitionsBuilder: (_, animation, _, child) => SlideTransition(
|
||||
position: Tween(
|
||||
begin: const Offset(1, 0),
|
||||
end: Offset.zero,
|
||||
).animate(animation),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
|
||||
for (final popEarly in [false, true]) {
|
||||
testWidgets(
|
||||
'metadata starts reading during entrance, early pop=$popEarly',
|
||||
(tester) async {
|
||||
final navigator = await mount(tester);
|
||||
var statCalls = 0;
|
||||
var followUpCalls = 0;
|
||||
final delayedStat = Completer<String>();
|
||||
const channel = MethodChannel('com.zarz.spotiflac/backend');
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
if (call.method == 'safStat') {
|
||||
statCalls++;
|
||||
if (popEarly) return delayedStat.future;
|
||||
return jsonEncode({'exists': false});
|
||||
}
|
||||
followUpCalls++;
|
||||
return null;
|
||||
});
|
||||
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
|
||||
unawaited(
|
||||
navigator.currentState!.push(
|
||||
route(TrackMetadataScreen(localItem: _track(1))),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 150));
|
||||
expect(find.text('Song 1'), findsWidgets);
|
||||
expect(statCalls, 1);
|
||||
if (popEarly) navigator.currentState!.pop();
|
||||
await tester.pumpAndSettle();
|
||||
if (popEarly) {
|
||||
delayedStat.complete(jsonEncode({'exists': true, 'size': 100}));
|
||||
await tester.pump();
|
||||
expect(followUpCalls, 0);
|
||||
}
|
||||
expect(statCalls, 1);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'album shows header immediately and applies rows during entrance',
|
||||
(tester) async {
|
||||
final navigator = await mount(tester);
|
||||
final result = Completer<List<LocalLibraryItem>>();
|
||||
unawaited(
|
||||
navigator.currentState!.push(
|
||||
route(
|
||||
LocalAlbumScreen(
|
||||
albumName: 'Album',
|
||||
artistName: 'Artist',
|
||||
loadTracks: () => result.future,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(find.text('Album'), findsWidgets);
|
||||
expect(find.text('No tracks found for this album'), findsNothing);
|
||||
expect(find.byType(AlbumTrackListSkeleton), findsOneWidget);
|
||||
expect(find.byType(ShimmerLoading), findsOneWidget);
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
result.complete([_track(2), _track(1)]);
|
||||
await tester.pump(const Duration(milliseconds: 150));
|
||||
await tester.pump();
|
||||
expect(find.text('Song 1'), findsOneWidget);
|
||||
expect(find.text('Song 2'), findsOneWidget);
|
||||
expect(find.byType(AlbumTrackListSkeleton), findsNothing);
|
||||
expect(
|
||||
ModalRoute.of(
|
||||
tester.element(find.byType(LocalAlbumScreen)),
|
||||
)!.animation!.value,
|
||||
lessThan(1),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Song 1'), findsOneWidget);
|
||||
expect(find.text('Song 2'), findsOneWidget);
|
||||
expect(
|
||||
tester.getTopLeft(find.text('Song 1')).dy,
|
||||
lessThan(tester.getTopLeft(find.text('Song 2')).dy),
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('album retries failed loading and ignores completion after pop', (
|
||||
tester,
|
||||
) async {
|
||||
final navigator = await mount(tester);
|
||||
final result = Completer<List<LocalLibraryItem>>();
|
||||
var attempts = 0;
|
||||
unawaited(
|
||||
navigator.currentState!.push(
|
||||
route(
|
||||
LocalAlbumScreen(
|
||||
albumName: 'Album',
|
||||
artistName: 'Artist',
|
||||
loadTracks: () async {
|
||||
if (++attempts == 1) throw StateError('Unavailable');
|
||||
return result.future;
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Retry'));
|
||||
await tester.pump();
|
||||
expect(attempts, 2);
|
||||
navigator.currentState!.pop();
|
||||
await tester.pumpAndSettle();
|
||||
result.complete([_track(1)]);
|
||||
await tester.pump();
|
||||
expect(find.byType(LocalAlbumScreen), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user