diff --git a/docs/artist_concerts.md b/docs/artist_concerts.md new file mode 100644 index 00000000..88c2ff5e --- /dev/null +++ b/docs/artist_concerts.md @@ -0,0 +1,36 @@ +# Artist concerts + +Any metadata extension can include an optional `concerts` list in `getArtist` +or the `artist` object returned by `handleUrl`. The native metadata bridge +preserves it as `artist_info.concerts` or `artist.concerts`, respectively. +Older extensions may omit it; no concert button is shown for an empty list. + +```json +{ + "concerts": [{ + "id": "event-123", + "location": "Example City, Region", + "venue": "Example Hall", + "start_at": "2026-10-07T01:00:00Z", + "time_zone": "America/New_York", + "url": "https://example.com/events/123" + }] +} +``` + +- `location` and a valid ISO 8601 `start_at` are required. Date-only values + (`2026-10-06`) are supported when a time has not been announced. +- For UTC timestamps, supply the venue's IANA `time_zone`; the example above + displays **October 6 at 21:00**, regardless of the listener's device time zone. + Explicit offsets are also supported. Without a recognized time zone, the app + preserves the wall-clock fields supplied in `start_at`. +- `id`, `venue`, and `url` are optional. The app deduplicates by event ID (or + date/location/venue), orders events by date, and accepts at most 500 entries. +- Only HTTP(S) event links can be opened, and only after tapping an event. +- Providers should return upcoming events only. Failure to fetch this optional + information must not fail the artist's albums or top tracks. + +The badge opens a native schedule with artist identity, venue-local date tiles, +and event details. Labels are provider-neutral in both application themes. +Concert metadata is supplied with the artist response, so the schedule opens +immediately without a second loading state or network request. diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index c7ca8272..ba4d7c9d 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -6820,6 +6820,9 @@ "profileRemovePhoto": "Remove photo", "profilePhotoError": "Choose a valid image smaller than 20 MB.", "profileSaveError": "Could not save your profile. Please try again.", + "artistUpcomingConcerts": "Upcoming Concerts", + "artistAllUpcomingConcerts": "All Upcoming Concerts", + "artistNoUpcomingConcerts": "No upcoming concerts.", "optionsEmbeddedCoverSizeOriginal": "Original resolution", "@optionsEmbeddedCoverSizeOriginal": { "description": "Option that preserves the provider artwork at its original resolution" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index eda6e48f..8cb655b5 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -6294,6 +6294,9 @@ "autoMixDescription": "Sambungkan lagu dengan pencocokan beat dan penyesuaian tempo tanpa mengubah nada. Gunakan crossfade jika beat tidak cocok.", "autoMixOn": "AutoMix aktif", "autoMixOff": "AutoMix nonaktif", + "artistUpcomingConcerts": "Konser Mendatang", + "artistAllUpcomingConcerts": "Semua Konser Mendatang", + "artistNoUpcomingConcerts": "Belum ada konser mendatang.", "profileTitle": "Profil", "profileSetUp": "Atur profil kamu", "profileEdit": "Ubah nama dan foto", diff --git a/lib/models/artist_concert.dart b/lib/models/artist_concert.dart new file mode 100644 index 00000000..7dd58dbd --- /dev/null +++ b/lib/models/artist_concert.dart @@ -0,0 +1,83 @@ +import 'package:spotiflac_android/utils/string_utils.dart'; +import 'package:timezone/data/latest.dart' as tz_data; +import 'package:timezone/timezone.dart' as tz; + +/// Provider-neutral event metadata. Calendar labels always use the venue's +/// local time, never the listener's device time zone. +class ArtistConcert { + const ArtistConcert({ + required this.id, + required this.location, + required this.venue, + required this.date, + required this.hasTime, + this.url, + }); + + final String id; + final String location; + final String venue; + final DateTime date; + final bool hasTime; + final String? url; + + static bool _timeZonesReady = false; + + static List parseList(Object? value) { + if (value is! List) return const []; + final concerts = {}; + for (final item in value.take(500)) { + if (item is! Map) continue; + String text(String key) => + item[key] is String ? (item[key] as String).trim() : ''; + final rawDate = text('start_at'); + final match = RegExp( + r'^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::\d{2}(?:\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$', + ).firstMatch(rawDate); + final instant = DateTime.tryParse(rawDate); + final location = text('location'); + if (match == null || instant == null || location.isEmpty) continue; + final year = int.parse(match[1]!); + final month = int.parse(match[2]!); + final day = int.parse(match[3]!); + final hour = int.parse(match[4] ?? '0'); + final minute = int.parse(match[5] ?? '0'); + var date = DateTime.utc(year, month, day, hour, minute); + // DateTime.parse normalizes impossible dates; do not show invented days. + if (date.year != year || + date.month != month || + date.day != day || + hour > 23 || + minute > 59) { + continue; + } + final zone = text('time_zone'); + if (zone.isNotEmpty && match[6] != null) { + if (!_timeZonesReady) { + tz_data.initializeTimeZones(); + _timeZonesReady = true; + } + try { + date = tz.TZDateTime.from(instant, tz.getLocation(zone)); + } on tz.LocationNotFoundException { + // Preserve the supplied wall-clock date if the zone is unknown. + } + } + final id = text('id'); + final venue = text('venue'); + final key = id.isEmpty ? '$rawDate|$location|$venue' : id; + concerts.putIfAbsent( + key, + () => ArtistConcert( + id: key, + location: location, + venue: venue, + date: date, + hasTime: match[4] != null, + url: normalizeRemoteHttpUrl(text('url')), + ), + ); + } + return concerts.values.toList()..sort((a, b) => a.date.compareTo(b.date)); + } +} diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index d8c2699d..6ddb7015 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/models/track.dart'; +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'; @@ -30,6 +31,7 @@ class TrackState { final int? monthlyListeners; final List? artistAlbums; final List? artistTopTracks; + final List artistConcerts; final bool hasSearchText; final bool isShowingRecentAccess; final String? searchExtensionId; @@ -54,6 +56,7 @@ class TrackState { this.monthlyListeners, this.artistAlbums, this.artistTopTracks, + this.artistConcerts = const [], this.hasSearchText = false, this.isShowingRecentAccess = false, this.searchExtensionId, @@ -81,6 +84,7 @@ class TrackState { int? monthlyListeners, List? artistAlbums, List? artistTopTracks, + List? artistConcerts, bool? hasSearchText, bool? isShowingRecentAccess, String? searchExtensionId, @@ -107,6 +111,7 @@ class TrackState { monthlyListeners: monthlyListeners ?? this.monthlyListeners, artistAlbums: artistAlbums ?? this.artistAlbums, artistTopTracks: artistTopTracks ?? this.artistTopTracks, + artistConcerts: artistConcerts ?? this.artistConcerts, hasSearchText: hasSearchText ?? this.hasSearchText, isShowingRecentAccess: isShowingRecentAccess ?? this.isShowingRecentAccess, @@ -305,6 +310,7 @@ class TrackNotifier extends Notifier { artistAlbumsNext: artistData['albums_next'] as String?, artistAlbums: albums, artistTopTracks: topTracks.isNotEmpty ? topTracks : null, + artistConcerts: ArtistConcert.parseList(artistData['concerts']), searchExtensionId: extensionId, ); return; diff --git a/lib/screens/artist_concerts_screen.dart b/lib/screens/artist_concerts_screen.dart new file mode 100644 index 00000000..69643f86 --- /dev/null +++ b/lib/screens/artist_concerts_screen.dart @@ -0,0 +1,278 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/artist_concert.dart'; +import 'package:spotiflac_android/services/shell_navigation_service.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/utils/adaptive_layout.dart'; +import 'package:spotiflac_android/utils/nav_bar_inset.dart'; +import 'package:spotiflac_android/widgets/album_detail_header.dart'; +import 'package:spotiflac_android/widgets/cached_cover_image.dart'; + +class ArtistConcertsScreen extends StatefulWidget { + const ArtistConcertsScreen({ + super.key, + required this.artistName, + required this.concerts, + this.coverUrl, + }); + + final String artistName; + final List concerts; + final String? coverUrl; + + @override + State createState() => _ArtistConcertsScreenState(); +} + +class _ArtistConcertsScreenState extends State { + @override + void dispose() { + ShellNavigationService.clearChromeBrightness(this); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (context.isMornye) { + final theme = MornyeTheme.build(Brightness.dark); + final route = ModalRoute.of(context); + if (route != null) { + ShellNavigationService.setChromeBrightness( + owner: this, + route: route, + brightness: Brightness.dark, + surface: theme.colorScheme.surface, + ); + } + return Theme( + data: theme, + child: Builder(builder: _buildPage), + ); + } + return _buildPage(context); + } + + Widget _buildPage(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final inset = wideListInset(context) + 24; + final locale = Localizations.localeOf(context).toString(); + return Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar( + pinned: true, + surfaceTintColor: Colors.transparent, + leadingWidth: 64, + leading: Padding( + padding: const EdgeInsets.only(left: 12), + child: HeaderCircleButton( + icon: Icons.arrow_back, + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + onPressed: () => Navigator.pop(context), + ), + ), + ), + SliverPadding( + padding: EdgeInsets.fromLTRB(inset, 24, inset, 32), + sliver: SliverToBoxAdapter( + child: Row( + children: [ + ClipOval( + child: SizedBox.square( + dimension: 96, + child: widget.coverUrl == null || widget.coverUrl!.isEmpty + ? const Icon(Icons.person, size: 56) + : CachedCoverImage( + imageUrl: widget.coverUrl!, + fit: BoxFit.cover, + memCacheWidth: 288, + errorWidget: (_, _, _) => + const Icon(Icons.person, size: 56), + ), + ), + ), + const SizedBox(width: 18), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.artistUpcomingConcerts, + style: TextStyle( + color: scheme.onSurfaceVariant, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + widget.artistName, + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ], + ), + ), + ), + SliverPadding( + padding: EdgeInsets.fromLTRB(inset, 0, inset, 12), + sliver: SliverToBoxAdapter( + child: Text( + context.l10n.artistAllUpcomingConcerts, + style: const TextStyle( + fontSize: 21, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + if (widget.concerts.isEmpty) + SliverPadding( + padding: EdgeInsets.all(inset), + sliver: SliverToBoxAdapter( + child: Text(context.l10n.artistNoUpcomingConcerts), + ), + ) + else + SliverPadding( + padding: EdgeInsets.symmetric(horizontal: inset), + sliver: SliverList.separated( + itemCount: widget.concerts.length, + separatorBuilder: (_, _) => + const Divider(height: 1, indent: 60), + itemBuilder: (context, index) { + final concert = widget.concerts[index]; + final date = DateFormat.MMMEd(locale).format(concert.date); + final details = [ + if (concert.venue.isNotEmpty) concert.venue, + DateFormat.y(locale).format(concert.date), + date, + if (concert.hasTime) + DateFormat.Hm(locale).format(concert.date), + ].join(' ยท '); + return InkWell( + onTap: concert.url == null + ? null + : () => _openConcert(context, concert), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: Row( + children: [ + Semantics( + label: date, + excludeSemantics: true, + child: Container( + width: 48, + padding: const EdgeInsets.symmetric(vertical: 3), + decoration: BoxDecoration( + color: scheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(9), + border: Border.all( + color: scheme.outlineVariant, + ), + ), + child: Column( + children: [ + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + DateFormat.MMM( + locale, + ).format(concert.date), + maxLines: 1, + style: TextStyle( + color: context.isMornye + ? scheme.primary + : scheme.error, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + ), + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + DateFormat.d(locale).format(concert.date), + style: const TextStyle( + fontSize: 28, + height: 1.05, + ), + ), + ), + ], + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + concert.location, + style: const TextStyle(fontSize: 16), + ), + const SizedBox(height: 2), + Text( + details, + maxLines: + MediaQuery.textScalerOf( + context, + ).scale(1) > + 1.3 + ? null + : 1, + overflow: + MediaQuery.textScalerOf( + context, + ).scale(1) > + 1.3 + ? null + : TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 24)), + const NavBarSliverSpacer(), + ], + ), + ); + } + + Future _openConcert(BuildContext context, ArtistConcert concert) async { + try { + if (await launchUrl( + Uri.parse(concert.url!), + mode: LaunchMode.externalApplication, + )) { + return; + } + } catch (_) { + // Keep the schedule visible if no browser can handle the event link. + } + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.announcementUnableToOpenLink)), + ); + } +} diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 8e8feb98..4510719d 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -14,6 +14,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/models/artist_concert.dart'; +import 'package:spotiflac_android/widgets/artist_concerts_button.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/track_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; @@ -60,6 +62,7 @@ class _ArtistCache { required List albums, List? releases, List? topTracks, + List concerts = const [], String? headerImageUrl, String? headerVideoUrl, String? headerLogoUrl, @@ -72,6 +75,7 @@ class _ArtistCache { albums: albums, releases: releases, topTracks: topTracks, + concerts: concerts, headerImageUrl: headerImageUrl, headerVideoUrl: headerVideoUrl, headerLogoUrl: headerLogoUrl, @@ -86,6 +90,7 @@ class _CacheEntry { final List albums; final List? releases; final List? topTracks; + final List concerts; final String? headerImageUrl; final String? headerVideoUrl; final String? headerLogoUrl; @@ -96,6 +101,7 @@ class _CacheEntry { required this.albums, this.releases, this.topTracks, + this.concerts = const [], this.headerImageUrl, this.headerVideoUrl, this.headerLogoUrl, @@ -115,6 +121,7 @@ class ArtistScreen extends ConsumerStatefulWidget { final int? monthlyListeners; final List? albums; final List? topTracks; + final List concerts; final String? extensionId; const ArtistScreen({ @@ -129,6 +136,7 @@ class ArtistScreen extends ConsumerStatefulWidget { this.monthlyListeners, this.albums, this.topTracks, + this.concerts = const [], this.extensionId, }); @@ -143,6 +151,7 @@ class _ArtistScreenState extends ConsumerState List? _albums; List? _releases; List? _topTracks; + List _concerts = const []; String? _headerImageUrl; String? _headerVideoUrl; String? _headerLogoUrl; @@ -223,6 +232,7 @@ class _ArtistScreenState extends ConsumerState @override void initState() { super.initState(); + _concerts = widget.concerts; _scrollController.addListener(_onScroll); @@ -272,6 +282,7 @@ class _ArtistScreenState extends ConsumerState _albums = cached.albums; _releases = cached.releases; _topTracks = cached.topTracks; + _concerts = cached.concerts; _headerImageUrl = cached.headerImageUrl; _headerVideoUrl = cached.headerVideoUrl; _headerLogoUrl = cached.headerLogoUrl; @@ -311,6 +322,7 @@ class _ArtistScreenState extends ConsumerState List albums; List? releases; List? topTracks; + List concerts = const []; String? headerImage; String? headerVideo; String? headerLogo; @@ -344,6 +356,9 @@ class _ArtistScreenState extends ConsumerState } final artistInfo = artistData['artist_info'] as Map?; + concerts = ArtistConcert.parseList( + artistInfo?['concerts'] ?? artistData['concerts'], + ); albumsNext = (artistInfo?['albums_next'] ?? artistData['albums_next']) as String?; @@ -368,6 +383,7 @@ class _ArtistScreenState extends ConsumerState if (result != null && result['artist'] != null) { final artistData = result['artist'] as Map; + concerts = ArtistConcert.parseList(artistData['concerts']); albumsNext = artistData['albums_next'] as String?; final albumsList = artistData['albums'] as List? ?? []; albums = albumsList @@ -408,6 +424,7 @@ class _ArtistScreenState extends ConsumerState releases: releases, topTracks: topTracks, headerImageUrl: finalHeaderImage, + concerts: concerts, headerVideoUrl: finalHeaderVideo, headerLogoUrl: finalHeaderLogo, albumsNext: albumsNext, @@ -419,6 +436,7 @@ class _ArtistScreenState extends ConsumerState _albums = albums; _releases = releases; _topTracks = topTracks; + _concerts = concerts; _headerImageUrl = finalHeaderImage; _headerVideoUrl = finalHeaderVideo; _headerLogoUrl = finalHeaderLogo; @@ -497,6 +515,7 @@ class _ArtistScreenState extends ConsumerState albums: _albums!, releases: _releases, topTracks: _topTracks, + concerts: _concerts, headerImageUrl: _headerImageUrl, headerVideoUrl: _headerVideoUrl, headerLogoUrl: _headerLogoUrl, diff --git a/lib/screens/artist_screen_widgets.dart b/lib/screens/artist_screen_widgets.dart index 9c15b77b..feb784d5 100644 --- a/lib/screens/artist_screen_widgets.dart +++ b/lib/screens/artist_screen_widgets.dart @@ -2,6 +2,12 @@ part of 'artist_screen.dart'; extension _ArtistScreenSections on _ArtistScreenState { + Widget _concertsButton() => ArtistConcertsButton( + artistName: widget.artistName, + concerts: _concerts, + coverUrl: widget.coverUrl ?? _headerImageUrl, + ); + List _buildHeader( BuildContext context, ColorScheme colorScheme, { @@ -68,6 +74,7 @@ extension _ArtistScreenSections on _ArtistScreenState { return MornyeArtistHeader( name: widget.artistName, logoUrl: _headerLogoUrl ?? widget.headerLogoUrl, + badge: _concerts.isEmpty || isSelectionMode ? null : _concertsButton(), listeners: listenersText, showTitle: _showTitleInAppBar, artwork: hasMotionBanner @@ -276,6 +283,10 @@ extension _ArtistScreenSections on _ArtistScreenState { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + if (_concerts.isNotEmpty && !isSelectionMode) ...[ + _concertsButton(), + const SizedBox(height: 8), + ], Text( widget.artistName, style: Theme.of(context).textTheme.headlineLarge diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 30c746d4..358929fd 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -18,6 +18,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/models/artist_concert.dart'; import 'package:spotiflac_android/providers/track_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; @@ -681,8 +682,10 @@ class _HomeTabState extends ConsumerState headerImageUrl: trackState.headerImageUrl, headerVideoUrl: trackState.headerVideoUrl, headerLogoUrl: trackState.headerLogoUrl, + concerts: trackState.artistConcerts, albumsNext: trackState.artistAlbumsNext, albums: trackState.artistAlbums!, + topTracks: trackState.artistTopTracks, extensionId: extensionId, ), ), diff --git a/lib/screens/home_tab_widgets.dart b/lib/screens/home_tab_widgets.dart index aa0c148e..49fe1e09 100644 --- a/lib/screens/home_tab_widgets.dart +++ b/lib/screens/home_tab_widgets.dart @@ -1337,6 +1337,7 @@ class _ExtensionArtistScreenState extends ConsumerState String? _headerImageUrl; String? _headerVideoUrl; String? _headerLogoUrl; + List _concerts = const []; String? _albumsNext; int? _monthlyListeners; bool _isLoading = true; @@ -1405,6 +1406,9 @@ class _ExtensionArtistScreenState extends ConsumerState _headerImageUrl = headerImage; _headerVideoUrl = headerVideo; _headerLogoUrl = headerLogo; + _concerts = ArtistConcert.parseList( + artistInfo['concerts'] ?? result['concerts'], + ); _albumsNext = artistInfo['albums_next'] as String?; _monthlyListeners = listeners; _isLoading = false; @@ -1472,6 +1476,7 @@ class _ExtensionArtistScreenState extends ConsumerState headerImageUrl: _headerImageUrl, headerVideoUrl: _headerVideoUrl, headerLogoUrl: _headerLogoUrl, + concerts: _concerts, albumsNext: _albumsNext, monthlyListeners: _monthlyListeners, albums: _albums, diff --git a/lib/widgets/artist_concerts_button.dart b/lib/widgets/artist_concerts_button.dart new file mode 100644 index 00000000..b03dbdbb --- /dev/null +++ b/lib/widgets/artist_concerts_button.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/artist_concert.dart'; +import 'package:spotiflac_android/screens/artist_concerts_screen.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; + +class ArtistConcertsButton extends StatelessWidget { + const ArtistConcertsButton({ + super.key, + required this.artistName, + required this.concerts, + this.coverUrl, + }); + + final String artistName; + final List concerts; + final String? coverUrl; + + @override + Widget build(BuildContext context) { + if (concerts.isEmpty) return const SizedBox.shrink(); + return TextButton.icon( + onPressed: () => Navigator.of(context).push( + slidePageRoute( + page: ArtistConcertsScreen( + artistName: artistName, + concerts: concerts, + coverUrl: coverUrl, + ), + ), + ), + style: TextButton.styleFrom( + foregroundColor: Colors.white, + backgroundColor: Colors.black.withValues(alpha: 0.35), + minimumSize: const Size(0, 36), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(9), + side: BorderSide(color: Colors.white.withValues(alpha: 0.22)), + ), + textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + ), + icon: const Icon(Icons.confirmation_number_rounded, size: 16), + label: Text( + context.l10n.artistUpcomingConcerts, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ); + } +} diff --git a/lib/widgets/mornye_artist_header.dart b/lib/widgets/mornye_artist_header.dart index 094db34e..a3ffae10 100644 --- a/lib/widgets/mornye_artist_header.dart +++ b/lib/widgets/mornye_artist_header.dart @@ -146,6 +146,7 @@ class MornyeArtistHeader extends StatelessWidget { required this.showTitle, this.listeners, this.logoUrl, + this.badge, }); final String name; @@ -154,6 +155,7 @@ class MornyeArtistHeader extends StatelessWidget { final bool showTitle; final String? listeners; final String? logoUrl; + final Widget? badge; @override Widget build(BuildContext context) => @@ -196,6 +198,7 @@ class MornyeArtistHeader extends StatelessWidget { ? 0.0 : 7 + textHeight(listeners!, const TextStyle(fontSize: 13)); final photoHeight = (width * 0.76).clamp(240.0, 340.0); + final badgeHeight = badge == null ? 0.0 : textScaler.scale(13) + 39; final expandedHeight = photoHeight + identityHeight + @@ -242,6 +245,11 @@ class MornyeArtistHeader extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ + if (badge != null) + SizedBox( + height: badgeHeight, + child: Center(child: badge), + ), SizedBox( height: identityHeight, child: Center(child: _buildIdentity()), diff --git a/pubspec.lock b/pubspec.lock index 44e18bdf..36fd63f1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1347,7 +1347,7 @@ packages: source: hosted version: "0.7.12" timezone: - dependency: transitive + dependency: "direct main" description: name: timezone sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb" diff --git a/pubspec.yaml b/pubspec.yaml index 25852660..81d48e7d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,6 +14,7 @@ dependencies: flutter_localizations: sdk: flutter intl: ^0.20.3 + timezone: ^0.11.1 # State Management flutter_riverpod: ^3.4.2 diff --git a/rust_backend/crates/extensions/src/backend/provider_metadata.rs b/rust_backend/crates/extensions/src/backend/provider_metadata.rs index 57c2bb2f..31b30178 100644 --- a/rust_backend/crates/extensions/src/backend/provider_metadata.rs +++ b/rust_backend/crates/extensions/src/backend/provider_metadata.rs @@ -88,6 +88,7 @@ impl Backend { "listeners".into(), json!(value["listeners"].as_i64().unwrap_or_default()), ); + info.insert("concerts".into(), array(value, "concerts").into()); for key in ["albums", "releases"] { let values = array(value, key); if !values.is_empty() { @@ -450,6 +451,7 @@ fn response(kind: &str, value: &Value, check: &Check<'_>) -> Result 0 { info.insert("listeners".into(), value["listeners"].clone()); } 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 c2b24427..1c9d4b1e 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 @@ -19,9 +19,13 @@ function collection(id) { } function getArtist(id) { return {id, name: "Example Artist", image_url: "https://example.invalid/portrait.jpg", + concerts: id === "bad-concerts" ? {} : [{id: "event-1", location: "Example City", + venue: "Example Hall", startAt: "2026-10-07T01:00:00Z", timeZone: "America/New_York", + url: "https://example.invalid/events/1"}], headerLogo: "https://example.invalid/logo.png", albumsNext: "artist-page-2", albums: []}; } -registerExtension({getAlbum: collection, getPlaylist: collection, getArtist}); +function handleUrl() { return {type: "artist", artist: getArtist("artist-1")}; } +registerExtension({getAlbum: collection, getPlaylist: collection, getArtist, handleUrl}); "#; fn fixture() -> (tempfile::TempDir, Backend) { @@ -31,7 +35,8 @@ fn fixture() -> (tempfile::TempDir, Backend) { std::fs::write( source.join("manifest.json"), json!({"name":ID,"displayName":"Example Metadata","version":"1", - "description":"Generic metadata fixture","type":["metadata_provider"]}) + "description":"Generic metadata fixture","type":["metadata_provider"], + "urlHandler":{"enabled":true,"patterns":["example.invalid"]}}) .to_string(), ) .unwrap(); @@ -104,6 +109,37 @@ fn artist_metadata_preserves_logo_separately_from_portrait() { backend.shutdown(); } +#[test] +fn artist_concerts_survive_metadata_and_url_routes_without_provider_special_cases() { + let (_root, backend) = fixture(); + let metadata: Value = serde_json::from_str( + &backend + .get_provider_metadata_json(ID, "artist", "artist-1", &|| Ok(())) + .unwrap(), + ) + .unwrap(); + let concerts = &metadata["artist_info"]["concerts"]; + assert_eq!(concerts[0]["start_at"], "2026-10-07T01:00:00Z"); + assert_eq!(concerts[0]["time_zone"], "America/New_York"); + assert_eq!(concerts[0]["venue"], "Example Hall"); + let handled: Value = serde_json::from_str( + &backend + .handle_url_json("https://example.invalid/artist/1") + .unwrap(), + ) + .unwrap(); + assert_eq!(&handled["artist"]["concerts"], concerts); + let malformed: Value = serde_json::from_str( + &backend + .get_provider_metadata_json(ID, "artist", "bad-concerts", &|| Ok(())) + .unwrap(), + ) + .unwrap(); + assert_eq!(malformed["artist_info"]["concerts"], json!([])); + assert_eq!(malformed["artist_info"]["name"], "Example Artist"); + backend.shutdown(); +} + #[test] fn metadata_value_bridge_preserves_album_playlist_and_public_json() { let (_root, backend) = fixture(); diff --git a/rust_backend/crates/extensions/src/provider.js b/rust_backend/crates/extensions/src/provider.js index 138f93df..67611288 100644 --- a/rust_backend/crates/extensions/src/provider.js +++ b/rust_backend/crates/extensions/src/provider.js @@ -200,8 +200,16 @@ if (albums.length) result.albums = albums; if (releases.length) result.releases = releases; if (tracks.length) result.top_tracks = tracks; + const concertItems = first(object(value), ["concerts"]); + const concerts = isArray(concertItems) ? array(concertItems.slice(0, 500), concert) : []; + if (concerts.length) result.concerts = concerts; return result; } + function concert(value) { + return fields(value, [["id","s"],["location","s"],["venue","s"], + ["start_at","s","startAt"],["time_zone","s","timeZone"], + ["url","s"]], ["id","location","start_at"]); + } function decryption(value) { if (empty(value)) return null; const result = fields(value, [["strategy","s"],["key","s"],["iv","s"], diff --git a/test/artist_concerts_test.dart b/test/artist_concerts_test.dart new file mode 100644 index 00000000..442127e9 --- /dev/null +++ b/test/artist_concerts_test.dart @@ -0,0 +1,248 @@ +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:spotiflac_android/l10n/app_localizations.dart'; +import 'package:spotiflac_android/models/artist_concert.dart'; +import 'package:spotiflac_android/screens/artist_concerts_screen.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/artist_concerts_button.dart'; +import 'package:spotiflac_android/widgets/mornye_artist_header.dart'; + +Map event({ + String id = 'event-1', + String date = '2026-10-07T01:00:00Z', + String zone = 'America/New_York', +}) => { + 'id': id, + 'location': 'Example City, Region', + 'venue': 'Example Hall', + 'start_at': date, + 'time_zone': zone, + 'url': 'https://example.com/events/$id', +}; + +Widget app(Widget child, {bool mornye = true, String language = 'en'}) => + ProviderScope( + child: MaterialApp( + theme: mornye ? MornyeTheme.build(Brightness.light) : ThemeData(), + locale: Locale(language), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: child, + ), + ); + +void main() { + setUpAll(() async { + await (FontLoader( + 'Inter', + )..addFont(rootBundle.load('assets/fonts/InterVariable.ttf'))).load(); + }); + test('venue dates cross UTC midnight and follow daylight saving time', () { + final summer = ArtistConcert.parseList([event()]).single; + expect(summer.date.day, 6); + expect(summer.date.hour, 21); + final winter = ArtistConcert.parseList([ + event(date: '2026-12-07T01:00:00Z'), + ]).single; + expect(winter.date.day, 6); + expect(winter.date.hour, 20); + }); + + test('date-only and explicit-offset events retain their calendar fields', () { + for (final zone in ['', 'Unknown/Zone']) { + final concert = ArtistConcert.parseList([ + event(date: '2026-10-06T21:00:00-04:00', zone: zone), + ]).single; + expect(concert.date.day, 6); + expect(concert.date.hour, 21); + } + final dateOnly = ArtistConcert.parseList([ + event(date: '2026-10-06'), + ]).single; + expect(dateOnly.hasTime, isFalse); + expect(dateOnly.date.day, 6); + }); + + test('invalid optional metadata is ignored without losing valid events', () { + expect(ArtistConcert.parseList(null), isEmpty); + expect(ArtistConcert.parseList({'unexpected': true}), isEmpty); + final concerts = ArtistConcert.parseList([ + null, + 'bad', + event(date: '2026-02-30'), + event(date: '2026-10-06T25:00'), + event(date: 'unknown'), + {...event(), 'location': 5}, + {...event(), 'url': 'javascript:alert(1)'}, + ]); + expect(concerts, hasLength(1)); + expect(concerts.single.url, isNull); + }); + + test('events are ordered, deduplicated, and bounded', () { + final concerts = ArtistConcert.parseList([ + event(), + event(), + event(id: 'early', date: '2026-10-01'), + {...event(id: ''), 'venue': ''}, + {...event(id: ''), 'venue': ''}, + ]); + expect(concerts, hasLength(3)); + expect(concerts.first.id, 'early'); + expect( + ArtistConcert.parseList(List.generate(600, (i) => event(id: '$i'))), + hasLength(500), + ); + }); + + for (final mornye in [true, false]) { + testWidgets( + 'concert badge opens a generic schedule and back restores artist ($mornye)', + (tester) async { + final concerts = ArtistConcert.parseList([event()]); + await tester.pumpWidget( + app( + Scaffold( + body: Center( + child: ArtistConcertsButton( + artistName: 'Example Artist', + concerts: concerts, + ), + ), + ), + mornye: mornye, + ), + ); + await tester.tap(find.text('Upcoming Concerts')); + await tester.pumpAndSettle(); + expect(find.byType(ArtistConcertsScreen), findsOneWidget); + expect(find.text('Example Artist'), findsOneWidget); + expect(find.text('All Upcoming Concerts'), findsOneWidget); + expect(find.text('Example City, Region'), findsOneWidget); + expect(find.text('6'), findsOneWidget); + expect(find.textContaining('21:00'), findsOneWidget); + await tester.tap(find.byTooltip('Back')); + await tester.pumpAndSettle(); + expect(find.byType(ArtistConcertsScreen), findsNothing); + expect(find.byType(ArtistConcertsButton), findsOneWidget); + }, + ); + } + + testWidgets('artist without concerts has no badge', (tester) async { + await tester.pumpWidget( + app( + const Scaffold( + body: ArtistConcertsButton( + artistName: 'Example Artist', + concerts: [], + ), + ), + ), + ); + expect(find.text('Upcoming Concerts'), findsNothing); + expect(find.byType(TextButton), findsNothing); + }); + + testWidgets( + 'badge stays above artist identity without pushing actions down', + (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + Widget header(bool showBadge) => app( + Scaffold( + body: CustomScrollView( + slivers: [ + MornyeArtistHeader( + name: 'Example Artist', + artwork: const SizedBox.expand(), + showTitle: false, + badge: showBadge + ? ArtistConcertsButton( + artistName: 'Example Artist', + concerts: ArtistConcert.parseList([event()]), + ) + : null, + actions: const [ + SizedBox.square( + dimension: 74, + key: ValueKey('artist-action'), + ), + ], + ), + ], + ), + ), + ); + await tester.pumpWidget(header(false)); + final actionY = tester + .getTopLeft(find.byKey(const ValueKey('artist-action'))) + .dy; + await tester.pumpWidget(header(true)); + await tester.pumpAndSettle(); + expect( + tester.getTopLeft(find.byKey(const ValueKey('artist-action'))).dy, + actionY, + ); + expect( + tester.getBottomLeft(find.byType(ArtistConcertsButton)).dy, + lessThan( + tester + .getTopLeft( + find.descendant( + of: find.byType(FlexibleSpaceBar), + matching: find.text('Example Artist'), + ), + ) + .dy, + ), + ); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'localized schedule supports large text and clears shell controls', + (tester) async { + tester.view.physicalSize = const Size(320, 740); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + app( + Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: const TextScaler.linear(2), + padding: const EdgeInsets.only(bottom: 140), + ), + child: ArtistConcertsScreen( + artistName: 'Example Artist With a Long Name', + concerts: ArtistConcert.parseList([event()]), + ), + ), + ), + language: 'id', + ), + ); + await tester.pumpAndSettle(); + await tester.drag(find.byType(CustomScrollView), const Offset(0, -700)); + await tester.pumpAndSettle(); + expect(find.text('Semua Konser Mendatang'), findsOneWidget); + await tester.scrollUntilVisible(find.text('Example City, Region'), 200); + await tester.scrollUntilVisible(find.textContaining('21.00'), 150); + await tester.drag(find.byType(CustomScrollView), const Offset(0, -200)); + await tester.pumpAndSettle(); + expect(find.text('Example City, Region'), findsOneWidget); + expect( + tester.getBottomLeft(find.textContaining('21.00')).dy, + lessThanOrEqualTo(600), + ); + expect(tester.takeException(), isNull); + }, + ); +}