From 7d7716cd6f885b04b154b6f039853ac796cd2a83 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sat, 26 Sep 2026 03:50:37 +0700 Subject: [PATCH] feat(albums): show formatted editorial descriptions --- lib/providers/track_provider.dart | 11 ++ lib/screens/album_screen.dart | 38 +++- lib/screens/home_tab.dart | 2 + lib/screens/home_tab_widgets.dart | 3 + lib/utils/editorial_notes.dart | 30 ++++ lib/widgets/album_description.dart | 164 ++++++++++++++++++ pubspec.lock | 2 +- pubspec.yaml | 1 + .../src/backend/provider_metadata.rs | 10 ++ .../backend/provider_metadata/value_tests.rs | 32 +++- .../crates/extensions/src/provider.js | 3 +- test/album_description_test.dart | 145 ++++++++++++++++ test/album_track_metadata_test.dart | 49 ++++-- 13 files changed, 465 insertions(+), 25 deletions(-) create mode 100644 lib/utils/editorial_notes.dart create mode 100644 lib/widgets/album_description.dart create mode 100644 test/album_description_test.dart diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 6ddb7015..6b45bd2b 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -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 { albumName: collectionName ?? (result['album'] as Map?)?['name'] as String?, + albumDescription: type == 'album' + ? albumDescriptionFromMetadata( + result['album'] as Map?, + ) ?? + albumDescriptionFromMetadata(result) + : null, playlistName: type == 'playlist' ? collectionName : null, playlistId: type == 'playlist' ? result['id'] as String? : null, coverUrl: normalizeCoverReference(result['cover_url']?.toString()), diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 3b5d7130..617aa597 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -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>( + static final _cache = TtlCache<({List tracks, String? description})>( const Duration(minutes: 10), maxEntries: 40, ); - static List? get(String albumId) => _cache.get(albumId); + static ({List tracks, String? description})? get(String key) => + _cache.get(key); - static void set(String albumId, List tracks) => - _cache.set(albumId, tracks); + static void set(String key, List 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 String? _headerVideoUrl; String? _headerImageUrl; List _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 ); }); + 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 _metadataResourceId(directProviderId), ); _applyAlbumMetadata( - metadata['track_list'] as List, - metadata['album_info'] as Map?, + (metadata['track_list'] ?? metadata['tracks']) as List, + metadata['album_info'] as Map? ?? metadata, ); return; } else { @@ -217,11 +228,15 @@ class _AlbumScreenState extends ConsumerState ) .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 ), appBar: _buildAppBar(context, colorScheme, pageBackgroundColor), slivers: [ + if (_description != null) + SliverToBoxAdapter( + child: AlbumDescription( + title: widget.albumName, + description: _description!, + ), + ), if (_isLoading) SliverToBoxAdapter( child: Padding( diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 358929fd..ce77d2b4 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -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 builder: (context) => AlbumScreen( albumId: trackState.albumId!, albumName: trackState.albumName!, + description: trackState.albumDescription, coverUrl: trackState.coverUrl, tracks: trackState.tracks, extensionId: extensionId, diff --git a/lib/screens/home_tab_widgets.dart b/lib/screens/home_tab_widgets.dart index 49fe1e09..9ba0093f 100644 --- a/lib/screens/home_tab_widgets.dart +++ b/lib/screens/home_tab_widgets.dart @@ -1014,6 +1014,7 @@ class ExtensionAlbumScreen extends ConsumerStatefulWidget { class _ExtensionAlbumScreenState extends ConsumerState { List? _tracks; String? _albumName; + String? _description; bool _isLoading = true; String? _error; String? _artistId; @@ -1095,6 +1096,7 @@ class _ExtensionAlbumScreenState extends ConsumerState { setState(() { _tracks = tracks; _albumName = albumName; + _description = albumDescriptionFromMetadata(albumInfo); _artistId = artistId; _artistName = artistName; _albumType = albumType; @@ -1165,6 +1167,7 @@ class _ExtensionAlbumScreenState extends ConsumerState { return AlbumScreen( albumId: widget.albumId, albumName: _albumName ?? widget.albumName, + description: _description, coverUrl: widget.coverUrl, headerVideoUrl: _headerVideoUrl, headerImageUrl: _headerImageUrl, diff --git a/lib/utils/editorial_notes.dart b/lib/utils/editorial_notes.dart new file mode 100644 index 00000000..1cbd0b13 --- /dev/null +++ b/lib/utils/editorial_notes.dart @@ -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? 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; +} diff --git a/lib/widgets/album_description.dart b/lib/widgets/album_description.dart new file mode 100644 index 00000000..569d1ebc --- /dev/null +++ b/lib/widgets/album_description.dart @@ -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 spans(List 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( + 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)), + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 036aa90e..275c9dc0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -669,7 +669,7 @@ packages: source: hosted version: "2.2.0" html: - dependency: transitive + dependency: "direct main" description: name: html sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699" diff --git a/pubspec.yaml b/pubspec.yaml index 9287fc10..faf5d662 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/rust_backend/crates/extensions/src/backend/provider_metadata.rs b/rust_backend/crates/extensions/src/backend/provider_metadata.rs index be0da99d..5b9d72a2 100644 --- a/rust_backend/crates/extensions/src/backend/provider_metadata.rs +++ b/rust_backend/crates/extensions/src/backend/provider_metadata.rs @@ -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() } diff --git a/rust_backend/crates/extensions/src/backend/provider_metadata/value_tests.rs b/rust_backend/crates/extensions/src/backend/provider_metadata/value_tests.rs index ac7dde0b..120f1fc4 100644 --- a/rust_backend/crates/extensions/src/backend/provider_metadata/value_tests.rs +++ b/rust_backend/crates/extensions/src/backend/provider_metadata/value_tests.rs @@ -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: "

A new direction & sound.

", 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"], + "

A new direction & sound.

" + ); + 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!({ diff --git a/rust_backend/crates/extensions/src/provider.js b/rust_backend/crates/extensions/src/provider.js index 59c863e5..f039bb99 100644 --- a/rust_backend/crates/extensions/src/provider.js +++ b/rust_backend/crates/extensions/src/provider.js @@ -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)) { diff --git a/test/album_description_test.dart b/test/album_description_test.dart new file mode 100644 index 00000000..26068bfc --- /dev/null +++ b/test/album_description_test.dart @@ -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': '

Full notes.

', + 'short': 'Short notes.', + }, + }), + '

Full notes.

', + ); + expect( + albumDescriptionFromMetadata({ + 'editorial_notes': { + 'standard': '

 

', + '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': ''}, + }), + 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( + builder: (_) => Scaffold( + body: AlbumDescription( + title: 'Example Album', + description: + 'A new direction & a bold sound.\n\n' + 'A second paragraph.\nOne line break remains.\n\n' + '${List.filled(12, '

The artists explore melodies and new ideas in these songs.

').join()}' + '

Final paragraph.

', + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final preview = tester.widget( + 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 spans(InlineSpan span) sync* { + if (span is TextSpan) { + yield span; + for (final child in span.children ?? []) { + 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(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( + 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); + }); + } +} diff --git a/test/album_track_metadata_test.dart b/test/album_track_metadata_test.dart index 27327542..eb9b6969 100644 --- a/test/album_track_metadata_test.dart +++ b/test/album_track_metadata_test.dart @@ -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': '

A new direction for the band.

', + 'short': 'A new direction.', + }, }; final tracks = [ { @@ -89,6 +94,10 @@ void main() { final expected = fetchedName.trim().isEmpty ? 'Album' : fetchedName; final album = tester.widget(find.byType(AlbumScreen)); expect(album.albumName, expected); + expect( + album.description, + '

A new direction for the band.

', + ); 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': '

A new direction.

'}, }, }); } @@ -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(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(find.byType(AlbumDescription)) + .description, + '

A new direction.

', + ); + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); }); }