feat(albums): show formatted editorial descriptions

This commit is contained in:
zarzet
2026-09-26 03:50:37 +07:00
parent 0e1881d837
commit 7d7716cd6f
13 changed files with 465 additions and 25 deletions
+11
View File
@@ -6,6 +6,7 @@ import 'package:spotiflac_android/models/artist_concert.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/utils/editorial_notes.dart';
import 'package:spotiflac_android/utils/extension_auth_launcher.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
@@ -19,6 +20,7 @@ class TrackState {
final String? error;
final String? albumId;
final String? albumName;
final String? albumDescription;
final String? playlistName;
final String? playlistId;
final String? artistId;
@@ -44,6 +46,7 @@ class TrackState {
this.error,
this.albumId,
this.albumName,
this.albumDescription,
this.playlistName,
this.playlistId,
this.artistId,
@@ -72,6 +75,7 @@ class TrackState {
String? error,
String? albumId,
String? albumName,
String? albumDescription,
String? playlistName,
String? playlistId,
String? artistId,
@@ -99,6 +103,7 @@ class TrackState {
error: error,
albumId: albumId ?? this.albumId,
albumName: albumName ?? this.albumName,
albumDescription: albumDescription ?? this.albumDescription,
playlistName: playlistName ?? this.playlistName,
playlistId: playlistId ?? this.playlistId,
artistId: artistId ?? this.artistId,
@@ -262,6 +267,12 @@ class TrackNotifier extends Notifier<TrackState> {
albumName:
collectionName ??
(result['album'] as Map<String, dynamic>?)?['name'] as String?,
albumDescription: type == 'album'
? albumDescriptionFromMetadata(
result['album'] as Map<String, dynamic>?,
) ??
albumDescriptionFromMetadata(result)
: null,
playlistName: type == 'playlist' ? collectionName : null,
playlistId: type == 'playlist' ? result['id'] as String? : null,
coverUrl: normalizeCoverReference(result['cover_url']?.toString()),
+30 -8
View File
@@ -20,6 +20,8 @@ import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart';
import 'package:spotiflac_android/screens/selection_mode_mixin.dart';
import 'package:spotiflac_android/widgets/error_card.dart';
import 'package:spotiflac_android/widgets/album_detail_header.dart';
import 'package:spotiflac_android/widgets/album_description.dart';
import 'package:spotiflac_android/utils/editorial_notes.dart';
import 'package:spotiflac_android/utils/adaptive_layout.dart';
import 'package:spotiflac_android/utils/provider_resource_ids.dart';
import 'package:spotiflac_android/utils/ttl_cache.dart';
@@ -37,15 +39,16 @@ import 'package:spotiflac_android/widgets/downloadable_cover.dart';
import 'package:spotiflac_android/widgets/mornye_artist_header.dart';
class _AlbumCache {
static final _cache = TtlCache<List<Track>>(
static final _cache = TtlCache<({List<Track> tracks, String? description})>(
const Duration(minutes: 10),
maxEntries: 40,
);
static List<Track>? get(String albumId) => _cache.get(albumId);
static ({List<Track> tracks, String? description})? get(String key) =>
_cache.get(key);
static void set(String albumId, List<Track> tracks) =>
_cache.set(albumId, tracks);
static void set(String key, List<Track> tracks, String? description) =>
_cache.set(key, (tracks: tracks, description: description));
}
class AlbumScreen extends ConsumerStatefulWidget {
@@ -59,6 +62,7 @@ class AlbumScreen extends ConsumerStatefulWidget {
final String? extensionId;
final String? artistId;
final String? artistName;
final String? description;
const AlbumScreen({
super.key,
@@ -72,6 +76,7 @@ class AlbumScreen extends ConsumerStatefulWidget {
this.extensionId,
this.artistId,
this.artistName,
this.description,
});
@override
@@ -92,6 +97,10 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
String? _headerVideoUrl;
String? _headerImageUrl;
List<String> _audioTraits = const [];
String? _description;
String get _cacheKey =>
'${_effectiveMetadataProviderIdFromAlbumId()}:${widget.albumId}';
String _effectiveMetadataProviderIdFromAlbumId() {
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
@@ -123,10 +132,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
);
});
final cached = _AlbumCache.get(_cacheKey);
_description = widget.description ?? cached?.description;
if (widget.tracks != null && widget.tracks!.isNotEmpty) {
_tracks = widget.tracks;
} else {
_tracks = _AlbumCache.get(widget.albumId);
_tracks = cached?.tracks;
}
_artistId = widget.artistId;
_albumType = _tracks?.firstOrNull?.albumType;
@@ -154,8 +165,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
_metadataResourceId(directProviderId),
);
_applyAlbumMetadata(
metadata['track_list'] as List<dynamic>,
metadata['album_info'] as Map<String, dynamic>?,
(metadata['track_list'] ?? metadata['tracks']) as List<dynamic>,
metadata['album_info'] as Map<String, dynamic>? ?? metadata,
);
return;
} else {
@@ -217,11 +228,15 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
)
.toList();
_AlbumCache.set(widget.albumId, tracks);
final description =
albumDescriptionFromMetadata(albumInfo) ??
albumDescriptionFromMetadata(fallbackSource);
_AlbumCache.set(_cacheKey, tracks, description);
if (mounted) {
setState(() {
_tracks = tracks;
_description = description;
_artistId = artistId;
_albumType = albumType;
_albumTotalTracks = totalTracks;
@@ -384,6 +399,13 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
),
appBar: _buildAppBar(context, colorScheme, pageBackgroundColor),
slivers: [
if (_description != null)
SliverToBoxAdapter(
child: AlbumDescription(
title: widget.albumName,
description: _description!,
),
),
if (_isLoading)
SliverToBoxAdapter(
child: Padding(
+2
View File
@@ -41,6 +41,7 @@ import 'package:spotiflac_android/utils/extension_auth_launcher.dart';
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/utils/editorial_notes.dart';
import 'package:spotiflac_android/screens/playlist_screen.dart';
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
@@ -627,6 +628,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
builder: (context) => AlbumScreen(
albumId: trackState.albumId!,
albumName: trackState.albumName!,
description: trackState.albumDescription,
coverUrl: trackState.coverUrl,
tracks: trackState.tracks,
extensionId: extensionId,
+3
View File
@@ -1014,6 +1014,7 @@ class ExtensionAlbumScreen extends ConsumerStatefulWidget {
class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
List<Track>? _tracks;
String? _albumName;
String? _description;
bool _isLoading = true;
String? _error;
String? _artistId;
@@ -1095,6 +1096,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
setState(() {
_tracks = tracks;
_albumName = albumName;
_description = albumDescriptionFromMetadata(albumInfo);
_artistId = artistId;
_artistName = artistName;
_albumType = albumType;
@@ -1165,6 +1167,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
return AlbumScreen(
albumId: widget.albumId,
albumName: _albumName ?? widget.albumName,
description: _description,
coverUrl: widget.coverUrl,
headerVideoUrl: _headerVideoUrl,
headerImageUrl: _headerImageUrl,
+30
View File
@@ -0,0 +1,30 @@
import 'package:html/dom.dart';
import 'package:html/parser.dart' as html;
/// Editorial text is display-only. Never load embedded media or execute markup.
DocumentFragment parseEditorialNotes(String value) {
final fragment = html.parseFragment(value);
for (final element in fragment.querySelectorAll(
'script, style, iframe, object, template',
)) {
element.remove();
}
return fragment;
}
String? albumDescriptionFromMetadata(Map<String, dynamic>? metadata) {
final notes =
metadata?['editorial_notes'] ??
metadata?['editorialNotes'] ??
metadata?['description'];
final candidates = notes is Map
? [notes['standard'], notes['short']]
: [notes];
for (final candidate in candidates) {
if (candidate is String &&
(parseEditorialNotes(candidate).text ?? '').trim().isNotEmpty) {
return candidate.trim();
}
}
return null;
}
+164
View File
@@ -0,0 +1,164 @@
import 'package:flutter/cupertino.dart' show CupertinoIcons;
import 'package:flutter/material.dart';
import 'package:html/dom.dart' as dom;
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/utils/editorial_notes.dart';
import 'package:spotiflac_android/widgets/album_detail_header.dart';
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
/// A short editorial preview below the album controls, with a full reading sheet.
class AlbumDescription extends StatelessWidget {
const AlbumDescription({
super.key,
required this.title,
required this.description,
});
final String title;
final String description;
TextSpan _text() {
final fragment = parseEditorialNotes(description);
final formatted = fragment.children.isNotEmpty;
List<InlineSpan> spans(List<dom.Node> nodes) => [
for (final node in nodes)
if (node is dom.Text)
TextSpan(
text: formatted
// Catalog notes can mix inline markup with literal paragraph
// breaks. Only collapse horizontal whitespace.
? node.data.replaceAll(RegExp(r'[\t\f\v ]+'), ' ')
: node.data,
)
else if (node is dom.Element)
if (node.localName == 'br')
const TextSpan(text: '\n')
else ...[
TextSpan(
style: switch (node.localName) {
'b' || 'strong' => const TextStyle(fontWeight: FontWeight.bold),
'i' || 'em' => const TextStyle(fontStyle: FontStyle.italic),
_ => null,
},
children: spans(node.nodes),
),
if (['p', 'div'].contains(node.localName))
const TextSpan(text: '\n\n'),
],
];
return TextSpan(children: spans(fragment.nodes));
}
void _showFullDescription(BuildContext context, TextSpan text) {
showAppModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
useRootNavigator: true,
builder: (context) {
final theme = Theme.of(context);
return SizedBox(
height: MediaQuery.sizeOf(context).height * 0.9,
child: SafeArea(
top: false,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
HeaderCircleButton(
buttonSize: 48,
icon: context.isMornye
? CupertinoIcons.xmark
: Icons.close,
tooltip: MaterialLocalizations.of(
context,
).closeButtonTooltip,
onPressed: () => Navigator.of(context).pop(),
),
Expanded(
child: Text(
title,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 48),
],
),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 32),
child: SelectionArea(
child: Text.rich(
text,
style: theme.textTheme.bodyLarge?.copyWith(
fontSize: context.isMornye ? 18 : 16,
height: 1.45,
color: theme.colorScheme.onSurface,
),
),
),
),
),
],
),
),
);
},
);
}
@override
Widget build(BuildContext context) {
final text = _text();
if (text.toPlainText().trim().isEmpty) return const SizedBox.shrink();
final theme = Theme.of(context);
final more = MaterialLocalizations.of(context).moreButtonTooltip;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
children: [
InkWell(
onTap: () => _showFullDescription(context, text),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Text.rich(
text,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyLarge?.copyWith(
fontSize: context.isMornye ? 17 : 16,
height: 1.4,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: 8),
Text(
context.isMornye ? more.toUpperCase() : more,
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
],
),
),
),
Divider(color: theme.colorScheme.onSurface.withValues(alpha: 0.15)),
],
),
);
}
}
+1 -1
View File
@@ -669,7 +669,7 @@ packages:
source: hosted
version: "2.2.0"
html:
dependency: transitive
dependency: "direct main"
description:
name: html
sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699"
+1
View File
@@ -71,6 +71,7 @@ dependencies:
video_player: ^2.14.0
xml: ^7.0.1
html: ^0.15.7
# Notifications
flutter_local_notifications: ^22.3.0
@@ -64,6 +64,11 @@ impl Backend {
],
);
info.insert("audio_traits".into(), value["audio_traits"].clone());
for key in ["editorial_notes", "description"] {
if let Some(notes) = value.get(key) {
info.insert(key.into(), notes.clone());
}
}
info.insert(
"total_tracks".into(),
json!(value["total_tracks"].as_i64().unwrap_or_default()),
@@ -386,6 +391,11 @@ fn album(value: &Value, full: bool) -> Value {
&["artist_id", "header_image", "header_video"],
));
result.insert("audio_traits".into(), json!(array(value, "audio_traits")));
for key in ["editorial_notes", "description"] {
if let Some(notes) = value.get(key) {
result.insert(key.into(), notes.clone());
}
}
}
result.into()
}
@@ -12,6 +12,8 @@ function collection(id) {
const count = Number(id);
return {id, name: "Album 音楽 🎵", artists: "Artist Café", provider_id: "supplied",
cover_url: "https://example.invalid/cover.jpg", total_tracks: count,
editorialNotes: {standard: "<p>A <i>new</i> direction &amp; sound.</p>", short: "A new direction."},
description: "Collection notes",
tracks: Array.from({length: count}, (_, i) => ({id: "track-" + i,
name: "歌 🎵 " + i, artists: "Artist Café", album_name: "Album 音楽 🎵",
provider_id: "supplied-track", duration_ms: 123456, track_number: i + 1,
@@ -24,7 +26,10 @@ function getArtist(id) {
url: "https://example.invalid/events/1", detailId: "event-1"}],
headerLogo: "https://example.invalid/logo.png", albumsNext: "artist-page-2", albums: []};
}
function handleUrl() { return {type: "artist", artist: getArtist("artist-1")}; }
function handleUrl(url) {
if (url.includes("/album/")) return {type: "album", album: collection("1"), tracks: collection("1").tracks};
return {type: "artist", artist: getArtist("artist-1")};
}
function getConcert(id) {
return {id, artistName: "Example Artist", title: "Example Show", venue: "Example Hall",
address: "123 Example Street", startAt: "2026-10-07T01:00:00Z",
@@ -86,6 +91,31 @@ fn concert_details_preserve_generic_actions_and_set_list() {
assert_eq!(detail["attribution"], "Example Events");
}
#[test]
fn album_editorial_notes_survive_metadata_and_url_routes() {
let (_root, backend) = fixture();
let direct: Value = serde_json::from_str(
&backend
.get_provider_metadata_json(ID, "album", "1", &|| Ok(()))
.unwrap(),
)
.unwrap();
let linked: Value = serde_json::from_str(
&backend
.handle_url_json("https://example.invalid/album/1")
.unwrap(),
)
.unwrap();
for album in [&direct["album_info"], &linked["album"]] {
assert_eq!(
album["editorial_notes"]["standard"],
"<p>A <i>new</i> direction &amp; sound.</p>"
);
assert_eq!(album["editorial_notes"]["short"], "A new direction.");
assert_eq!(album["description"], "Collection notes");
}
}
#[test]
fn response_envelopes_preserve_json_and_cancellation() {
let value = json!({
@@ -160,7 +160,8 @@
["cover_url","s","coverUrl","images"],["header_image","s","headerImage"],
["header_video","s","headerVideo"],["release_date","s","releaseDate"],
["total_tracks","i","totalTracks"],["album_type","s","albumType"],
["audio_traits","a","audioTraits"],["provider_id","s","providerId"]
["audio_traits","a","audioTraits"],["provider_id","s","providerId"],
["editorial_notes","M","editorialNotes"],["description","s"]
], ["id","name","artists","total_tracks","provider_id"]);
result.tracks = tracks;
if (!trim(result.artists)) {
+145
View File
@@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/utils/editorial_notes.dart';
import 'package:spotiflac_android/widgets/album_description.dart';
void main() {
test(
'editorial notes prefer full text and fall back to a usable summary',
() {
expect(
albumDescriptionFromMetadata({
'editorial_notes': {
'standard': '<p>Full notes.</p>',
'short': 'Short notes.',
},
}),
'<p>Full notes.</p>',
);
expect(
albumDescriptionFromMetadata({
'editorial_notes': {
'standard': '<p>&nbsp;</p>',
'short': 'Short notes.',
},
}),
'Short notes.',
);
expect(
albumDescriptionFromMetadata({'description': 'Plain notes.'}),
'Plain notes.',
);
expect(albumDescriptionFromMetadata({'editorial_notes': null}), isNull);
expect(albumDescriptionFromMetadata({'editorial_notes': 42}), isNull);
expect(
albumDescriptionFromMetadata({
'editorial_notes': {'standard': '<script>hidden</script>'},
}),
isNull,
);
},
);
for (final style in ['mornye-light', 'mornye-dark', 'material']) {
testWidgets('album description opens a formatted reading sheet ($style)', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(393, 700));
addTearDown(() => tester.binding.setSurfaceSize(null));
final mornye = style.startsWith('mornye');
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
theme: mornye
? MornyeTheme.build(
style.endsWith('light')
? Brightness.light
: Brightness.dark,
)
: ThemeData(),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Navigator(
onGenerateRoute: (_) => MaterialPageRoute<void>(
builder: (_) => Scaffold(
body: AlbumDescription(
title: 'Example Album',
description:
'A <i>new direction</i> &amp; a <b>bold sound</b>.\n\n'
'A second paragraph.\nOne line break remains.\n\n'
'${List.filled(12, '<p>The artists explore melodies and new ideas in these songs.</p>').join()}'
'<p>Final paragraph.</p><script>hidden script</script>',
),
),
),
),
),
),
);
await tester.pumpAndSettle();
final preview = tester.widget<Text>(
find.byWidgetPredicate(
(widget) => widget is Text && widget.textSpan != null,
),
);
expect(preview.maxLines, 2);
expect(
preview.textSpan!.toPlainText(),
contains('new direction & a bold sound.\n\n'),
);
expect(preview.textSpan!.toPlainText(), isNot(contains('hidden script')));
expect(
preview.textSpan!.toPlainText(),
contains('sound.\n\nA second paragraph.\nOne line break remains.\n\n'),
);
Iterable<TextSpan> spans(InlineSpan span) sync* {
if (span is TextSpan) {
yield span;
for (final child in span.children ?? <InlineSpan>[]) {
yield* spans(child);
}
}
}
expect(
spans(preview.textSpan!).any(
(span) =>
span.style?.fontStyle == FontStyle.italic &&
span.toPlainText() == 'new direction',
),
isTrue,
);
await tester.tap(find.text(mornye ? 'MORE' : 'More'));
await tester.pumpAndSettle();
expect(find.text('Example Album'), findsOneWidget);
expect(find.byType(SelectionArea), findsOneWidget);
expect(
ModalRoute.of(tester.element(find.byType(SelectionArea)))!.navigator,
tester.state<NavigatorState>(find.byType(Navigator).first),
);
expect(find.byTooltip('Close').hitTestable(), findsOneWidget);
await tester.drag(
find.byType(SingleChildScrollView),
const Offset(0, -600),
);
await tester.pumpAndSettle();
final scroll = tester.state<ScrollableState>(
find.descendant(
of: find.byType(SingleChildScrollView),
matching: find.byType(Scrollable),
),
);
expect(scroll.position.pixels, greaterThan(0));
expect(find.byTooltip('Close').hitTestable(), findsOneWidget);
await tester.tap(find.byTooltip('Close'));
await tester.pumpAndSettle();
expect(find.byType(SelectionArea), findsNothing);
expect(tester.takeException(), isNull);
});
}
}
+35 -14
View File
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/screens/home_tab.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/album_description.dart';
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
import 'package:spotiflac_android/widgets/track_list_tile.dart';
@@ -54,6 +55,10 @@ void main() {
'name': fetchedName,
'artists': 'Example Artist',
'total_tracks': 1,
'editorial_notes': {
'standard': '<p>A <i>new direction</i> for the band.</p>',
'short': 'A new direction.',
},
};
final tracks = [
{
@@ -89,6 +94,10 @@ void main() {
final expected = fetchedName.trim().isEmpty ? 'Album' : fetchedName;
final album = tester.widget<AlbumScreen>(find.byType(AlbumScreen));
expect(album.albumName, expected);
expect(
album.description,
'<p>A <i>new direction</i> for the band.</p>',
);
expect(album.tracks!.single.albumName, expected);
expect(find.text(expected), findsWidgets);
expect(requests, 1);
@@ -124,7 +133,7 @@ void main() {
'audio_modes': 'DOLBY_ATMOS',
'explicit': true,
};
var albumRequested = false;
var albumRequests = 0;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'getProviderMetadata') {
@@ -133,7 +142,7 @@ void main() {
'resource_type': 'album',
'resource_id': 'metadata-album',
});
albumRequested = true;
albumRequests++;
return jsonEncode({
'track_list': [payload],
'album_info': {
@@ -141,6 +150,7 @@ void main() {
'total_tracks': 1,
'album_type': 'album',
'audio_traits': ['lossless', 'dolby_atmos'],
'editorial_notes': {'standard': '<p>A new direction.</p>'},
},
});
}
@@ -156,22 +166,22 @@ void main() {
expect(searchTrack.label, 'Example Label');
expect(searchTrack.copyright, 'Example Copyright');
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
theme: MornyeTheme.build(Brightness.light),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const AlbumScreen(
albumId: 'metadata-album',
albumName: 'Example Album',
extensionId: 'example-metadata',
),
final page = ProviderScope(
child: MaterialApp(
theme: MornyeTheme.build(Brightness.light),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const AlbumScreen(
albumId: 'metadata-album',
albumName: 'Example Album',
extensionId: 'example-metadata',
),
),
);
await tester.pumpWidget(page);
await tester.pumpAndSettle();
expect(albumRequested, isTrue);
expect(albumRequests, 1);
expect(find.byType(AlbumDescription), findsOneWidget);
final albumTrack = tester
.widget<TrackListTile>(find.byType(TrackListTile).first)
.track;
@@ -193,5 +203,16 @@ void main() {
expect(find.byType(ExplicitBadge), findsOneWidget);
await tester.pumpWidget(const SizedBox());
await tester.pumpAndSettle();
await tester.pumpWidget(page);
await tester.pumpAndSettle();
expect(albumRequests, 1);
expect(
tester
.widget<AlbumDescription>(find.byType(AlbumDescription))
.description,
'<p>A new direction.</p>',
);
await tester.pumpWidget(const SizedBox());
await tester.pumpAndSettle();
});
}