diff --git a/lib/providers/explore_provider.dart b/lib/providers/explore_provider.dart index dfbcd1f5..87be22fe 100644 --- a/lib/providers/explore_provider.dart +++ b/lib/providers/explore_provider.dart @@ -5,6 +5,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/models/settings.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/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; @@ -20,6 +21,7 @@ class ExploreItem { final String? coverUrl; final String? featuredCoverUrl; final String? heading; + final bool? explicit; final String? providerId; final String? albumId; final String? albumName; @@ -36,6 +38,7 @@ class ExploreItem { this.coverUrl, this.featuredCoverUrl, this.heading, + this.explicit, this.providerId, this.albumId, this.albumName, @@ -54,6 +57,7 @@ class ExploreItem { coverUrl: json['cover_url'] as String?, featuredCoverUrl: json['featured_cover_url'] as String?, heading: json['heading'] as String?, + explicit: parseExplicitFlag(json['explicit']), providerId: json['provider_id'] as String?, albumId: json['album_id'] as String?, albumName: json['album_name'] as String?, @@ -72,6 +76,7 @@ class ExploreItem { 'cover_url': coverUrl, 'featured_cover_url': featuredCoverUrl, 'heading': heading, + 'explicit': explicit, 'provider_id': providerId, 'album_id': albumId, 'album_name': albumName, diff --git a/lib/screens/home_tab_explore.dart b/lib/screens/home_tab_explore.dart index 5d44458e..46258703 100644 --- a/lib/screens/home_tab_explore.dart +++ b/lib/screens/home_tab_explore.dart @@ -233,8 +233,9 @@ extension _HomeTabExploreUI on _HomeTabState { ), ), const SizedBox(height: 8), - Text( - item.name, + ExplicitTrackTitle( + title: item.name, + explicit: item.explicit == true, maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: isArtist ? TextAlign.center : TextAlign.start, @@ -414,8 +415,9 @@ extension _HomeTabExploreUI on _HomeTabState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - item.name, + ExplicitTrackTitle( + title: item.name, + explicit: item.explicit == true, style: Theme.of(context).textTheme.titleMedium ?.copyWith(fontWeight: FontWeight.w600), maxLines: 2, @@ -478,6 +480,7 @@ extension _HomeTabExploreUI on _HomeTabState { releaseDate: item.releaseDate, coverUrl: item.coverUrl, source: _providerIdForExploreItem(item), + explicit: item.explicit, ); if (settings.askQualityBeforeDownload || settings.allowQualityVariants) { diff --git a/lib/screens/home_tab_widgets.dart b/lib/screens/home_tab_widgets.dart index 4f0bec71..0238f796 100644 --- a/lib/screens/home_tab_widgets.dart +++ b/lib/screens/home_tab_widgets.dart @@ -1022,6 +1022,7 @@ class ExtensionAlbumScreen extends ConsumerStatefulWidget { class _ExtensionAlbumScreenState extends ConsumerState { List? _tracks; + String? _albumName; bool _isLoading = true; String? _error; String? _artistId; @@ -1074,6 +1075,9 @@ class _ExtensionAlbumScreenState extends ConsumerState { final artistId = (albumInfo['artist_id'] ?? albumInfo['artistId']) ?.toString(); + final albumName = + normalizeOptionalString(albumInfo['name']?.toString()) ?? + widget.albumName; final artistName = (albumInfo['artists'] ?? albumInfo['artist']) ?.toString(); final albumType = @@ -1090,6 +1094,7 @@ class _ExtensionAlbumScreenState extends ConsumerState { .map( (t) => _parseTrack( t as Map, + albumNameFallback: albumName, albumTypeFallback: albumType, totalTracksFallback: totalTracks, ), @@ -1098,6 +1103,7 @@ class _ExtensionAlbumScreenState extends ConsumerState { setState(() { _tracks = tracks; + _albumName = albumName; _artistId = artistId; _artistName = artistName; _albumType = albumType; @@ -1124,13 +1130,17 @@ class _ExtensionAlbumScreenState extends ConsumerState { Track _parseTrack( Map data, { + String? albumNameFallback, String? albumTypeFallback, int? totalTracksFallback, }) { final base = Track.fromBackendMap(data, source: widget.extensionId); return base.copyWith( id: (data['id'] ?? '').toString(), - albumName: (data['album_name'] ?? widget.albumName).toString(), + albumName: + normalizeOptionalString(data['album_name']?.toString()) ?? + albumNameFallback ?? + widget.albumName, albumArtist: normalizeOptionalString(data['album_artist']?.toString()), artistId: base.artistId ?? _artistId, albumId: base.albumId ?? widget.albumId, @@ -1163,7 +1173,7 @@ class _ExtensionAlbumScreenState extends ConsumerState { return AlbumScreen( albumId: widget.albumId, - albumName: widget.albumName, + albumName: _albumName ?? widget.albumName, coverUrl: widget.coverUrl, headerVideoUrl: _headerVideoUrl, headerImageUrl: _headerImageUrl, @@ -1638,8 +1648,9 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text( - item.name, + ExplicitTrackTitle( + title: item.name, + explicit: item.explicit == true, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyMedium?.copyWith( diff --git a/lib/screens/settings/settings_tab.dart b/lib/screens/settings/settings_tab.dart index d4a4372e..c6135b0c 100644 --- a/lib/screens/settings/settings_tab.dart +++ b/lib/screens/settings/settings_tab.dart @@ -441,7 +441,11 @@ class _SettingsTabState extends ConsumerState { overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleLarge, ), - subtitle: Text(context.l10n.profileEdit), + subtitle: + profile?.name.isNotEmpty == true || + profile?.photoPath?.isNotEmpty == true + ? null + : Text(context.l10n.profileEdit), trailing: const Icon(Icons.chevron_right), onTap: profile == null ? null diff --git a/lib/widgets/explore_featured_section.dart b/lib/widgets/explore_featured_section.dart index 8533afd9..a359062f 100644 --- a/lib/widgets/explore_featured_section.dart +++ b/lib/widgets/explore_featured_section.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:spotiflac_android/providers/explore_provider.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; +import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; /// Large editorial cards requested by a home-feed provider. class ExploreFeaturedSection extends StatelessWidget { @@ -92,8 +93,9 @@ class ExploreFeaturedSection extends StatelessWidget { ), SizedBox( height: titleHeight, - child: Text( - item.name, + child: ExplicitTrackTitle( + title: item.name, + explicit: item.explicit == true, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( diff --git a/test/album_track_metadata_test.dart b/test/album_track_metadata_test.dart index 993a6d2d..27327542 100644 --- a/test/album_track_metadata_test.dart +++ b/test/album_track_metadata_test.dart @@ -8,11 +8,98 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/l10n/app_localizations.dart'; import 'package:spotiflac_android/models/track.dart'; 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/audio_quality_badges.dart'; import 'package:spotiflac_android/widgets/track_list_tile.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const backendChannel = MethodChannel('com.zarz.spotiflac/backend'); + final backendMessenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + setUp(() async { + SharedPreferences.setMockInitialValues({}); + backendMessenger.setMockMethodCallHandler( + backendChannel, + (_) async => null, + ); + await PlatformBridge.clearTrackCache(); + }); + tearDown(() async { + backendMessenger.setMockMethodCallHandler( + backendChannel, + (_) async => null, + ); + await PlatformBridge.clearTrackCache(); + backendMessenger.setMockMethodCallHandler(backendChannel, null); + }); + + for (final nested in [true, false]) { + for (final fetchedName in ['Actual Album Title', ' ']) { + testWidgets( + 'extension album resolves its title (nested: $nested, name: "$fetchedName")', + (tester) async { + SharedPreferences.setMockInitialValues({}); + await tester.binding.setSurfaceSize(const Size(430, 1200)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + const channel = MethodChannel('com.zarz.spotiflac/backend'); + final messenger = tester.binding.defaultBinaryMessenger; + var requests = 0; + messenger.setMockMethodCallHandler(channel, (call) async { + if (call.method != 'getProviderMetadata') return null; + requests++; + final info = { + 'name': fetchedName, + 'artists': 'Example Artist', + 'total_tracks': 1, + }; + final tracks = [ + { + 'id': 'example-song', + 'name': 'Example Song', + 'artists': 'Example Artist', + 'album_name': '', + 'duration_ms': 180000, + }, + ]; + return jsonEncode( + nested + ? {'album_info': info, 'track_list': tracks} + : {...info, 'tracks': tracks}, + ); + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(Brightness.light), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const ExtensionAlbumScreen( + extensionId: 'example-metadata', + albumId: 'example-album', + albumName: 'Album', + ), + ), + ), + ); + await tester.pumpAndSettle(); + final expected = fetchedName.trim().isEmpty ? 'Album' : fetchedName; + final album = tester.widget(find.byType(AlbumScreen)); + expect(album.albumName, expected); + expect(album.tracks!.single.albumName, expected); + expect(find.text(expected), findsWidgets); + expect(requests, 1); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + }, + ); + } + } + testWidgets('album tracks retain the extended tags supplied in search', ( tester, ) async { diff --git a/test/explore_featured_section_test.dart b/test/explore_featured_section_test.dart index 2e7d432e..8c067e86 100644 --- a/test/explore_featured_section_test.dart +++ b/test/explore_featured_section_test.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/providers/explore_provider.dart'; import 'package:spotiflac_android/widgets/explore_featured_section.dart'; +import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; +import 'package:spotiflac_android/l10n/app_localizations.dart'; void main() { test('featured layout and artwork survive cache serialization', () { @@ -18,6 +20,7 @@ void main() { 'featured_cover_url': 'https://example.test/banner.jpg', 'heading': 'Updated playlist', 'provider_id': 'example-provider', + 'explicit': true, }, ], }); @@ -27,6 +30,9 @@ void main() { expect(restored.items.single.featuredCoverUrl, endsWith('/banner.jpg')); expect(restored.items.single.coverUrl, endsWith('/cover.jpg')); expect(restored.items.single.providerId, 'example-provider'); + expect(restored.items.single.explicit, isTrue); + expect(ExploreItem.fromJson({'explicit': false}).explicit, isFalse); + expect(ExploreItem.fromJson({}).explicit, isNull); expect(ExploreSection.fromJson({'items': []}).isFeatured, isFalse); }); @@ -51,11 +57,14 @@ void main() { name: 'Featured $index', artists: 'Example artist', heading: 'New album', + explicit: index == 0, description: 'An editorial description of the featured album.', ), ); await tester.pumpWidget( MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, home: MediaQuery( data: MediaQueryData(textScaler: TextScaler.linear(scale)), child: Scaffold( @@ -75,12 +84,23 @@ void main() { ), ); expect(tester.takeException(), isNull); + expect(find.byType(ExplicitBadge), findsOneWidget); + expect( + tester + .widget(find.byType(ExplicitTrackTitle).first) + .explicit, + isTrue, + ); + final firstTitle = find.byWidgetPredicate( + (widget) => + widget is ExplicitTrackTitle && widget.title == 'Featured 0', + ); final card = find.ancestor( - of: find.text('Featured 0'), + of: firstTitle, matching: find.byType(GestureDetector), ); expect(tester.getSize(card).width, greaterThan(250)); - await tester.tap(find.text('Featured 0')); + await tester.tap(firstTitle); expect(opened?.id, 'collection-0'); await tester.drag(find.byType(ListView), const Offset(-600, 0)); await tester.pumpAndSettle();