From abb8dc4a427f8fc99b788f3f48e6d0348541cacf Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:54:29 +0700 Subject: [PATCH] feat(concerts): show artwork-themed event details in app --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 31 + docs/artist_concerts.md | 42 +- ios/Runner.xcodeproj/project.pbxproj | 4 + ios/Runner/AppDelegate.swift | 4 + ios/Runner/ConcertCalendarBridge.swift | 65 ++ ios/Runner/Info.plist | 2 + lib/l10n/arb/app_en.arb | 7 + lib/l10n/arb/app_id.arb | 7 + lib/models/artist_concert.dart | 66 ++ lib/screens/artist_concerts_screen.dart | 31 +- lib/screens/artist_screen_widgets.dart | 1 + lib/screens/concert_detail_screen.dart | 572 ++++++++++++++++++ lib/widgets/artist_concerts_button.dart | 3 + .../src/backend/provider_metadata.rs | 2 + .../backend/provider_metadata/value_tests.rs | 36 +- .../extensions/src/manager/providers.rs | 5 +- .../crates/extensions/src/provider.js | 14 +- test/concert_detail_screen_test.dart | 178 ++++++ 18 files changed, 1047 insertions(+), 23 deletions(-) create mode 100644 ios/Runner/ConcertCalendarBridge.swift create mode 100644 lib/screens/concert_detail_screen.dart create mode 100644 test/concert_detail_screen_test.dart diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 3ba6e746..106fc41e 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -78,6 +78,7 @@ class MainActivity: FlutterFragmentActivity() { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private var backendChannel: MethodChannel? = null private var audioOutputChannel: MethodChannel? = null + private var concertCalendarChannel: MethodChannel? = null internal val coreBackend: CoreBackend by lazy { createCoreBackend(applicationContext) } private val nativeBackendMethods = setOf( "getBackendImplementations", @@ -934,6 +935,8 @@ class MainActivity: FlutterFragmentActivity() { } override fun onDestroy() { + concertCalendarChannel?.setMethodCallHandler(null) + concertCalendarChannel = null audioOutputChannel?.setMethodCallHandler(null) audioOutputChannel = null libraryStorageReceiver?.let { @@ -974,6 +977,34 @@ class MainActivity: FlutterFragmentActivity() { onBackPressedDispatcher.addCallback(this, flutterBackCallback!!) val messenger = flutterEngine.dartExecutor.binaryMessenger + concertCalendarChannel = MethodChannel(messenger, "com.zarz.spotiflac/concert_calendar").also { channel -> + channel.setMethodCallHandler { call, result -> + if (call.method != "add") { + result.notImplemented() + } else { + val start = call.argument("start")?.toLong() + if (start == null) { + result.success(false) + } else { + val suppliedEnd = call.argument("end")?.toLong() + val end = suppliedEnd?.takeIf { it > start } ?: (start + 3600000) + try { + startActivity(Intent(Intent.ACTION_INSERT).apply { + data = android.provider.CalendarContract.Events.CONTENT_URI + putExtra(android.provider.CalendarContract.Events.TITLE, call.argument("title")) + putExtra(android.provider.CalendarContract.Events.EVENT_LOCATION, call.argument("location")) + putExtra(android.provider.CalendarContract.Events.DESCRIPTION, call.argument("url")) + putExtra(android.provider.CalendarContract.EXTRA_EVENT_BEGIN_TIME, start) + putExtra(android.provider.CalendarContract.EXTRA_EVENT_END_TIME, end) + }) + result.success(true) + } catch (_: Exception) { + result.success(false) + } + } + } + } + } audioOutputChannel = MethodChannel(messenger, "com.zarz.spotiflac/audio_output").also { channel -> channel.setMethodCallHandler { call, result -> if (call.method != "show") { diff --git a/docs/artist_concerts.md b/docs/artist_concerts.md index 88c2ff5e..9e23e68d 100644 --- a/docs/artist_concerts.md +++ b/docs/artist_concerts.md @@ -13,6 +13,7 @@ Older extensions may omit it; no concert button is shown for an empty list. "venue": "Example Hall", "start_at": "2026-10-07T01:00:00Z", "time_zone": "America/New_York", + "detail_id": "event-123", "url": "https://example.com/events/123" }] } @@ -26,7 +27,8 @@ Older extensions may omit it; no concert button is shown for an empty list. 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. +- Tapping an event opens its internal detail page. Only explicit ticket and map + actions open external HTTP(S) links; the event URL is used for sharing. - Providers should return upcoming events only. Failure to fetch this optional information must not fail the artist's albums or top tracks. @@ -34,3 +36,41 @@ 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. + +The schedule follows the application's light/dark theme. The detail page uses +the artist portrait's palette and light text in either theme, including its +loading placeholders. Any metadata extension can implement `getConcert(id)` +and supply `detail_id` on its events. The app requests it only when opening an +event through `getProviderMetadata(provider, "concert", detail_id)`, receiving +the generic `concert` envelope. Older providers without `detail_id` still open +a detail page with the schedule's date and venue. + +```json +{ + "id": "event-123", + "artist_name": "Example Artist", + "title": "Example Tour", + "cover_url": "https://example.com/artist.jpg", + "start_at": "2026-10-07T01:00:00Z", + "end_at": "2026-10-07T04:00:00Z", + "time_zone": "America/New_York", + "venue": "Example Hall", + "address": "123 Example Street", + "ticket_url": "https://example.com/tickets/123", + "map_url": "https://example.com/maps/123", + "url": "https://example.com/events/123", + "attribution": "Powered by Example Events", + "set_list": { + "id": "playlist-123", + "name": "Example Tour Set List", + "cover_url": "https://example.com/set-list.jpg" + } +} +``` + +All detail fields are optional. Omit unavailable actions instead of fabricating +links. Set lists open the existing internal playlist screen using the same +metadata provider. Calendar actions present a system editor for the user to +review and save; no event is added automatically. iOS 17+ needs no calendar +read permission; iOS 16 requests legacy permission. Android uses an insert +intent. Failed detail requests leave the date and venue visible with Retry. diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 97b7a07b..cd8987c8 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + C0CE00000000000000000001 /* ConcertCalendarBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0CE00000000000000000002 /* ConcertCalendarBridge.swift */; }; A0D100000000000000000001 /* AudioOutputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D100000000000000000002 /* AudioOutputView.swift */; }; F59500000000000000000011 /* PlayerWidgetState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F59500000000000000000001 /* PlayerWidgetState.swift */; }; F59500000000000000000012 /* PlayerControlIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = F59500000000000000000002 /* PlayerControlIntent.swift */; }; @@ -70,6 +71,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + C0CE00000000000000000002 /* ConcertCalendarBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcertCalendarBridge.swift; sourceTree = ""; }; A0D100000000000000000002 /* AudioOutputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioOutputView.swift; sourceTree = ""; }; F59500000000000000000009 /* PlayerWidget.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = PlayerWidget.xcconfig; sourceTree = ""; }; F59500000000000000000001 /* PlayerWidgetState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerWidgetState.swift; sourceTree = ""; }; @@ -213,6 +215,7 @@ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, F59500000000000000000004 /* PlayerWidgetBridge.swift */, A0D100000000000000000002 /* AudioOutputView.swift */, + C0CE00000000000000000002 /* ConcertCalendarBridge.swift */, F59500000000000000000007 /* Runner.entitlements */, C03400000000000000000002 /* CoreBackend.swift */, D08A594730CF000100000002 /* DownloadProgressSubscription.swift */, @@ -475,6 +478,7 @@ F59500000000000000000012 /* PlayerControlIntent.swift in Sources */, F59500000000000000000013 /* PlayerWidgetBridge.swift in Sources */, A0D100000000000000000001 /* AudioOutputView.swift in Sources */, + C0CE00000000000000000001 /* ConcertCalendarBridge.swift in Sources */, C03400000000000000000001 /* CoreBackend.swift in Sources */, D08A594730CF000100000001 /* DownloadProgressSubscription.swift in Sources */, A11CE0012F00000000000001 /* ExtensionCallbackParser.swift in Sources */, diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index bd42d332..4a913b91 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -23,6 +23,7 @@ import UniformTypeIdentifiers private var lastLibraryScanProgressPayload: String? private var libraryScanProgressGeneration: UInt64 = 0 private var backendChannel: FlutterMethodChannel? + private var concertCalendar: ConcertCalendarBridge? private let coreBackend: CoreBackend = createCoreBackend() private var pendingSessionGrantEvents: [[String: Any]] = [] @@ -55,6 +56,9 @@ import UniformTypeIdentifiers } private func configureFlutterEngine(_ messenger: FlutterBinaryMessenger, registry: FlutterPluginRegistry) { + concertCalendar = ConcertCalendarBridge(messenger: messenger) { [weak self] in + self?.activeWindow?.rootViewController + } PlayerWidgetBridge.shared.attach(messenger) let channel = FlutterMethodChannel( name: CHANNEL, diff --git a/ios/Runner/ConcertCalendarBridge.swift b/ios/Runner/ConcertCalendarBridge.swift new file mode 100644 index 00000000..17b1721f --- /dev/null +++ b/ios/Runner/ConcertCalendarBridge.swift @@ -0,0 +1,65 @@ +import EventKit +import EventKitUI +import Flutter +import UIKit + +final class ConcertCalendarBridge: NSObject, EKEventEditViewDelegate { + private let channel: FlutterMethodChannel + private let presenter: () -> UIViewController? + private let store = EKEventStore() + + init(messenger: FlutterBinaryMessenger, presenter: @escaping () -> UIViewController?) { + channel = FlutterMethodChannel(name: "com.zarz.spotiflac/concert_calendar", binaryMessenger: messenger) + self.presenter = presenter + super.init() + channel.setMethodCallHandler { [weak self] call, result in + guard call.method == "add" else { + result(FlutterMethodNotImplemented) + return + } + guard let self, let data = call.arguments as? [String: Any], + let start = data["start"] as? NSNumber else { + result(false) + return + } + if #available(iOS 17.0, *) { + // The system editor saves only after the user confirms; it + // needs no permission to read the person's existing events. + self.present(data, start: start, result: result) + } else { + self.store.requestAccess(to: .event) { [weak self] allowed, _ in + DispatchQueue.main.async { + guard let self, allowed else { result(false); return } + self.present(data, start: start, result: result) + } + } + } + } + } + + private func present(_ data: [String: Any], start: NSNumber, result: @escaping FlutterResult) { + guard let controller = presenter(), controller.viewIfLoaded?.window != nil, + controller.presentedViewController == nil else { + result(false) + return + } + let event = EKEvent(eventStore: store) + event.title = data["title"] as? String + event.location = data["location"] as? String + event.startDate = Date(timeIntervalSince1970: start.doubleValue / 1000) + let end = (data["end"] as? NSNumber)?.doubleValue ?? 0 + event.endDate = end > start.doubleValue + ? Date(timeIntervalSince1970: end / 1000) + : event.startDate.addingTimeInterval(3600) + if let url = data["url"] as? String { event.url = URL(string: url) } + let editor = EKEventEditViewController() + editor.eventStore = store + editor.event = event + editor.editViewDelegate = self + controller.present(editor, animated: true) { result(true) } + } + + func eventEditViewController(_ controller: EKEventEditViewController, didCompleteWith action: EKEventEditViewAction) { + controller.dismiss(animated: true) + } +} diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index a8248e32..4efc1f8c 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + NSCalendarsUsageDescription + Add a concert to your calendar after you review and save it. CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleLocalizations diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 1fe9e1cc..b4c6e9e1 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -6282,6 +6282,13 @@ }, "nowPlayingUpNext": "Up next", "nowPlayingAudioOutput": "Audio Output", + "concertGetTickets": "Get Tickets", + "concertSetList": "Concert Set List", + "concertSetListButton": "Set List", + "concertAddToCalendar": "Add to Calendar", + "concertOpenMaps": "Open Maps", + "concertDetailsUnavailable": "Concert details are currently unavailable.", + "concertCalendarUnavailable": "Unable to open the calendar.", "nowPlayingAudioOutputUnavailable": "Unable to open audio output settings.", "@nowPlayingUpNext": { "description": "Title for the playback queue sheet" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 86e3558c..30ab7dee 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -4578,6 +4578,13 @@ }, "nowPlayingUpNext": "Up next", "nowPlayingAudioOutput": "Output Audio", + "concertGetTickets": "Beli Tiket", + "concertSetList": "Set List Konser", + "concertSetListButton": "Set List", + "concertAddToCalendar": "Tambahkan ke Kalender", + "concertOpenMaps": "Buka Peta", + "concertDetailsUnavailable": "Detail konser belum tersedia saat ini.", + "concertCalendarUnavailable": "Tidak dapat membuka kalender.", "nowPlayingAudioOutputUnavailable": "Tidak dapat membuka pengaturan output audio.", "@nowPlayingUpNext": { "description": "Title for the playback queue sheet" diff --git a/lib/models/artist_concert.dart b/lib/models/artist_concert.dart index 7dd58dbd..4a1d2dfa 100644 --- a/lib/models/artist_concert.dart +++ b/lib/models/artist_concert.dart @@ -12,6 +12,7 @@ class ArtistConcert { required this.date, required this.hasTime, this.url, + this.detailId, }); final String id; @@ -20,6 +21,7 @@ class ArtistConcert { final DateTime date; final bool hasTime; final String? url; + final String? detailId; static bool _timeZonesReady = false; @@ -75,9 +77,73 @@ class ArtistConcert { date: date, hasTime: match[4] != null, url: normalizeRemoteHttpUrl(text('url')), + detailId: text('detail_id').isEmpty ? null : text('detail_id'), ), ); } return concerts.values.toList()..sort((a, b) => a.date.compareTo(b.date)); } } + +class ConcertDetail { + const ConcertDetail({ + this.artistName, + this.title, + this.coverUrl, + this.venue, + this.address, + this.start, + this.end, + this.ticketUrl, + this.mapUrl, + this.url, + this.attribution, + this.setListId, + this.setListName, + this.setListCover, + }); + + final String? artistName; + final String? title; + final String? coverUrl; + final String? venue; + final String? address; + final DateTime? start; + final DateTime? end; + final String? ticketUrl; + final String? mapUrl; + final String? url; + final String? attribution; + final String? setListId; + final String? setListName; + final String? setListCover; + + factory ConcertDetail.fromJson(Map json) { + String? text(Map data, String key) { + final value = data[key]; + return value is String && value.trim().isNotEmpty ? value.trim() : null; + } + + String? link(Map data, String key) => + normalizeRemoteHttpUrl(text(data, key)); + final setList = json['set_list'] is Map + ? json['set_list'] as Map + : const {}; + return ConcertDetail( + artistName: text(json, 'artist_name'), + title: text(json, 'title'), + coverUrl: link(json, 'cover_url'), + venue: text(json, 'venue'), + address: text(json, 'address'), + start: DateTime.tryParse(text(json, 'start_at') ?? ''), + end: DateTime.tryParse(text(json, 'end_at') ?? ''), + ticketUrl: link(json, 'ticket_url'), + mapUrl: link(json, 'map_url'), + url: link(json, 'url'), + attribution: text(json, 'attribution'), + setListId: text(setList, 'id'), + setListName: text(setList, 'name'), + setListCover: link(setList, 'cover_url'), + ); + } +} diff --git a/lib/screens/artist_concerts_screen.dart b/lib/screens/artist_concerts_screen.dart index 746114ed..04d6aaa7 100644 --- a/lib/screens/artist_concerts_screen.dart +++ b/lib/screens/artist_concerts_screen.dart @@ -1,14 +1,15 @@ 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/screens/concert_detail_screen.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'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; class ArtistConcertsScreen extends StatefulWidget { const ArtistConcertsScreen({ @@ -16,11 +17,13 @@ class ArtistConcertsScreen extends StatefulWidget { required this.artistName, required this.concerts, this.coverUrl, + this.providerId, }); final String artistName; final List concerts; final String? coverUrl; + final String? providerId; @override State createState() => _ArtistConcertsScreenState(); @@ -154,9 +157,7 @@ class _ArtistConcertsScreenState extends State { DateFormat.Hm(locale).format(concert.date), ].join(' · '); return InkWell( - onTap: concert.url == null - ? null - : () => _openConcert(context, concert), + onTap: () => _openConcert(context, concert), borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.symmetric(vertical: 5), @@ -256,19 +257,15 @@ class _ArtistConcertsScreenState extends State { } 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)), + await Navigator.of(context).push( + slidePageRoute( + page: ConcertDetailScreen( + artistName: widget.artistName, + coverUrl: widget.coverUrl, + providerId: widget.providerId, + concert: concert, + ), + ), ); } } diff --git a/lib/screens/artist_screen_widgets.dart b/lib/screens/artist_screen_widgets.dart index feb784d5..98a71cc4 100644 --- a/lib/screens/artist_screen_widgets.dart +++ b/lib/screens/artist_screen_widgets.dart @@ -6,6 +6,7 @@ extension _ArtistScreenSections on _ArtistScreenState { artistName: widget.artistName, concerts: _concerts, coverUrl: widget.coverUrl ?? _headerImageUrl, + providerId: _directMetadataProviderId(), ); List _buildHeader( diff --git a/lib/screens/concert_detail_screen.dart b/lib/screens/concert_detail_screen.dart new file mode 100644 index 00000000..b46e34c2 --- /dev/null +++ b/lib/screens/concert_detail_screen.dart @@ -0,0 +1,572 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:intl/intl.dart'; +import 'package:share_plus/share_plus.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/screens/playlist_screen.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/services/shell_navigation_service.dart'; +import 'package:spotiflac_android/theme/cover_palette.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/utils/nav_bar_inset.dart'; +import 'package:spotiflac_android/widgets/album_detail_header.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; +import 'package:spotiflac_android/widgets/cached_cover_image.dart'; + +class ConcertDetailScreen extends StatefulWidget { + const ConcertDetailScreen({ + super.key, + required this.artistName, + required this.concert, + this.coverUrl, + this.providerId, + }); + + final String artistName; + final ArtistConcert concert; + final String? coverUrl; + final String? providerId; + + @override + State createState() => _ConcertDetailScreenState(); +} + +class _ConcertDetailScreenState extends State { + ConcertDetail _detail = const ConcertDetail(); + bool _loading = false; + bool _failed = false; + bool _calendarOpen = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final provider = widget.providerId; + final id = widget.concert.detailId; + if (provider == null || provider.isEmpty || id == null) return; + setState(() { + _loading = true; + _failed = false; + }); + try { + final result = await PlatformBridge.getProviderMetadata( + provider, + 'concert', + id, + ); + final json = result['concert']; + if (json is! Map) { + throw const FormatException('Missing concert'); + } + final detail = ConcertDetail.fromJson(json); + if (mounted) setState(() => _detail = detail); + } catch (_) { + if (mounted) setState(() => _failed = true); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + void dispose() { + ShellNavigationService.clearChromeBrightness(this); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final source = _detail.coverUrl ?? widget.coverUrl; + final base = context.isMornye + ? MornyeTheme.build(Brightness.dark) + : ThemeData.dark(); + return Theme( + data: base, + child: CoverPaletteBuilder( + imageSource: source, + builder: (context, palette) { + final color = HSLColor.fromColor( + source == null + ? Colors.grey + : CoverPalette.sourceColor(source, Brightness.dark) ?? + Colors.grey, + ); + final top = color + .withSaturation((color.saturation * 1.2).clamp(0, 0.65)) + .withLightness(0.27) + .toColor(); + final bottom = color + .withSaturation((color.saturation * 0.6).clamp(0, 0.4)) + .withLightness(0.36) + .toColor(); + final route = ModalRoute.of(context); + if (route != null) { + ShellNavigationService.setChromeBrightness( + owner: this, + route: route, + brightness: Brightness.dark, + surface: bottom, + ); + } + final scheme = base.colorScheme.copyWith( + surface: top, + onSurface: Colors.white, + onSurfaceVariant: Colors.white70, + primary: Colors.white, + onPrimary: top, + ); + return Theme( + data: base.copyWith( + colorScheme: scheme, + scaffoldBackgroundColor: top, + ), + child: Builder( + builder: (context) => _page(context, source, top, bottom), + ), + ); + }, + ), + ); + } + + Widget _page(BuildContext context, String? source, Color top, Color bottom) { + final locale = Localizations.localeOf(context).toString(); + final date = widget.concert.date; + final artist = _detail.artistName ?? widget.artistName; + final hasSetList = _detail.setListId != null && widget.providerId != null; + final photoSize = (MediaQuery.sizeOf(context).width * 0.5).clamp( + 144.0, + 220.0, + ); + return Scaffold( + body: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [top, bottom], + ), + ), + child: CustomScrollView( + slivers: [ + SliverAppBar( + pinned: true, + backgroundColor: top, + 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), + ), + ), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 12), + child: Builder( + builder: (context) => HeaderCircleButton( + icon: CupertinoIcons.share, + tooltip: context.l10n.trackMetadataShare, + onPressed: () => _share(context), + ), + ), + ), + ], + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 24), + sliver: SliverToBoxAdapter( + child: Column( + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + ClipOval( + child: SizedBox.square( + dimension: photoSize, + child: source == null + ? const Icon(Icons.person, size: 96) + : CachedCoverImage( + imageUrl: source, + fit: BoxFit.cover, + errorWidget: (_, _, _) => + const Icon(Icons.person, size: 96), + ), + ), + ), + Positioned( + right: -4, + bottom: -4, + child: Container( + width: 64, + padding: const EdgeInsets.symmetric(vertical: 4), + decoration: BoxDecoration( + color: const Color(0xff202023), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white24), + ), + child: Column( + children: [ + Text( + DateFormat.MMM(locale).format(date), + style: const TextStyle( + fontSize: 13, + color: Color(0xffff646b), + fontWeight: FontWeight.w600, + ), + ), + Text( + DateFormat.d(locale).format(date), + style: const TextStyle( + fontSize: 40, + height: 1.05, + color: Colors.white, + ), + ), + ], + ), + ), + ), + ], + ), + const SizedBox(height: 26), + Text( + artist, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 34, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 24), + if (_loading) + const ShimmerLoading( + child: Column( + children: [ + Row( + spacing: 16, + children: [ + Expanded( + child: SkeletonBox( + width: double.infinity, + height: 48, + borderRadius: 28, + ), + ), + Expanded( + child: SkeletonBox( + width: double.infinity, + height: 48, + borderRadius: 28, + ), + ), + ], + ), + SizedBox(height: 20), + SkeletonBox( + width: double.infinity, + height: 164, + borderRadius: 28, + ), + ], + ), + ) + else ...[ + if (_detail.ticketUrl != null || hasSetList) ...[ + Row( + spacing: 16, + children: [ + if (_detail.ticketUrl != null) + Expanded( + child: _action( + context, + CupertinoIcons.tickets_fill, + context.l10n.concertGetTickets, + () => _openLink(_detail.ticketUrl!), + ), + ), + if (hasSetList) + Expanded( + child: _action( + context, + CupertinoIcons.play_fill, + context.l10n.concertSetListButton, + _openSetList, + ), + ), + ], + ), + const SizedBox(height: 20), + ], + Container( + decoration: _glassDecoration, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 8, + ), + child: Column( + children: [ + _infoRow( + '${DateFormat.yMMMMd(locale).format(date)} · ${DateFormat.EEEE(locale).format(date)}', + widget.concert.hasTime + ? DateFormat.Hm(locale).format(date) + : '', + CupertinoIcons.calendar_badge_plus, + _detail.start == null ? null : _addToCalendar, + context.l10n.concertAddToCalendar, + ), + const Divider(color: Colors.white24, height: 1), + _infoRow( + _detail.venue ?? widget.concert.venue, + _detail.address ?? widget.concert.location, + CupertinoIcons.map_pin_ellipse, + _detail.mapUrl == null + ? null + : () => _openLink(_detail.mapUrl!), + context.l10n.concertOpenMaps, + ), + ], + ), + ), + if (_detail.attribution != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + _detail.attribution!, + style: const TextStyle( + color: Colors.white70, + fontSize: 13, + ), + ), + ), + if (_failed) + Padding( + padding: const EdgeInsets.only(top: 16), + child: Column( + children: [ + Text( + context.l10n.concertDetailsUnavailable, + textAlign: TextAlign.center, + ), + TextButton( + onPressed: _load, + child: Text(context.l10n.dialogRetry), + ), + ], + ), + ), + if (hasSetList) ...[ + const SizedBox(height: 32), + Align( + alignment: Alignment.centerLeft, + child: Text( + context.l10n.concertSetList, + style: const TextStyle( + fontSize: 21, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(height: 12), + InkWell( + onTap: _openSetList, + borderRadius: BorderRadius.circular(28), + child: Container( + decoration: _glassDecoration, + padding: const EdgeInsets.all(12), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox.square( + dimension: 88, + child: _detail.setListCover == null + ? const Icon(Icons.queue_music) + : CachedCoverImage( + imageUrl: _detail.setListCover!, + fit: BoxFit.cover, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + _detail.setListName ?? + context.l10n.concertSetList, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 8), + const Icon( + CupertinoIcons.chevron_forward, + size: 18, + ), + ], + ), + ), + ), + ], + ], + ], + ), + ), + ), + const NavBarSliverSpacer(), + ], + ), + ), + ); + } + + BoxDecoration get _glassDecoration => BoxDecoration( + color: Colors.black.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(28), + border: Border.all(color: Colors.white.withValues(alpha: 0.16)), + ); + + Widget _action( + BuildContext context, + IconData icon, + String label, + VoidCallback onTap, + ) => OutlinedButton.icon( + onPressed: onTap, + icon: Icon(icon, size: 19), + label: Text(label, textAlign: TextAlign.center), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + backgroundColor: Colors.black.withValues(alpha: 0.15), + side: BorderSide(color: Colors.white.withValues(alpha: 0.16)), + minimumSize: const Size(0, 48), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + ); + + Widget _infoRow( + String title, + String subtitle, + IconData icon, + VoidCallback? onTap, + String tooltip, + ) => Padding( + padding: const EdgeInsets.symmetric(vertical: 14), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + if (subtitle.isNotEmpty) + Text( + subtitle, + style: const TextStyle(fontSize: 15, color: Colors.white70), + ), + ], + ), + ), + if (onTap != null) + IconButton( + onPressed: onTap, + tooltip: tooltip, + icon: Icon(icon, size: 21), + style: IconButton.styleFrom( + backgroundColor: Colors.white.withValues(alpha: 0.12), + ), + ), + ], + ), + ); + + void _openSetList() { + Navigator.of(context).push( + slidePageRoute( + page: PlaylistScreen( + playlistName: _detail.setListName ?? context.l10n.concertSetList, + coverUrl: _detail.setListCover, + tracks: const [], + playlistId: _detail.setListId, + metadataProviderId: widget.providerId, + ), + ), + ); + } + + Future _openLink(String url) async { + try { + if (await launchUrl( + Uri.parse(url), + mode: LaunchMode.externalApplication, + )) { + return; + } + } catch (_) { + /* Keep the detail page open when no handler is installed. */ + } + if (mounted) _error(context.l10n.announcementUnableToOpenLink); + } + + Future _share(BuildContext context) async { + final box = context.findRenderObject() as RenderBox?; + await SharePlus.instance.share( + ShareParams( + text: + '${_detail.artistName ?? widget.artistName}\n${_detail.venue ?? widget.concert.venue}\n${_detail.url ?? widget.concert.url ?? ''}', + sharePositionOrigin: box == null + ? null + : box.localToGlobal(Offset.zero) & box.size, + ), + ); + } + + Future _addToCalendar() async { + if (_calendarOpen || _detail.start == null) return; + _calendarOpen = true; + try { + final opened = + await const MethodChannel( + 'com.zarz.spotiflac/concert_calendar', + ).invokeMethod('add', { + 'title': _detail.title ?? widget.artistName, + 'location': [ + _detail.venue ?? widget.concert.venue, + _detail.address ?? widget.concert.location, + ].join(', '), + 'start': _detail.start!.millisecondsSinceEpoch, + 'end': _detail.end?.millisecondsSinceEpoch, + 'url': _detail.url ?? widget.concert.url, + }); + if (mounted && opened != true) { + _error(context.l10n.concertCalendarUnavailable); + } + } on PlatformException { + if (mounted) _error(context.l10n.concertCalendarUnavailable); + } on MissingPluginException { + if (mounted) _error(context.l10n.concertCalendarUnavailable); + } finally { + _calendarOpen = false; + } + } + + void _error(String message) => ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); +} diff --git a/lib/widgets/artist_concerts_button.dart b/lib/widgets/artist_concerts_button.dart index b03dbdbb..13d8741b 100644 --- a/lib/widgets/artist_concerts_button.dart +++ b/lib/widgets/artist_concerts_button.dart @@ -10,11 +10,13 @@ class ArtistConcertsButton extends StatelessWidget { required this.artistName, required this.concerts, this.coverUrl, + this.providerId, }); final String artistName; final List concerts; final String? coverUrl; + final String? providerId; @override Widget build(BuildContext context) { @@ -26,6 +28,7 @@ class ArtistConcertsButton extends StatelessWidget { artistName: artistName, concerts: concerts, coverUrl: coverUrl, + providerId: providerId, ), ), ), diff --git a/rust_backend/crates/extensions/src/backend/provider_metadata.rs b/rust_backend/crates/extensions/src/backend/provider_metadata.rs index 31b30178..be0da99d 100644 --- a/rust_backend/crates/extensions/src/backend/provider_metadata.rs +++ b/rust_backend/crates/extensions/src/backend/provider_metadata.rs @@ -185,6 +185,7 @@ impl Backend { "album" => "getAlbum", "playlist" => "getPlaylist", "artist" => "getArtist", + "concert" => "getConcert", _ => { return Err(ResolverError::Failed(format!( "unsupported provider resource type: {kind}" @@ -393,6 +394,7 @@ fn response(kind: &str, value: &Value, check: &Check<'_>) -> Result json!({"concert": value}), // Move normalized values into the envelope. json! serializes borrowed // expressions, duplicating every field of an already-built track list. "track" => Map::from_iter([("track".into(), track(value, "", 0))]).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 1c9d4b1e..ac7dde0b 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 @@ -21,11 +21,19 @@ 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"}], + 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")}; } -registerExtension({getAlbum: collection, getPlaylist: collection, getArtist, handleUrl}); +function getConcert(id) { + return {id, artistName: "Example Artist", title: "Example Show", venue: "Example Hall", + address: "123 Example Street", startAt: "2026-10-07T01:00:00Z", + endAt: "2026-10-07T04:00:00Z", timeZone: "America/New_York", + ticketUrl: "https://example.invalid/tickets/1", mapUrl: "https://example.invalid/maps/1", + coverUrl: "https://example.invalid/artist.jpg", attribution: "Example Events", + setList: {id: "list-1", name: "Concert Set List", coverUrl: "https://example.invalid/list.jpg"}}; +} +registerExtension({getAlbum: collection, getPlaylist: collection, getArtist, getConcert, handleUrl}); "#; fn fixture() -> (tempfile::TempDir, Backend) { @@ -54,6 +62,30 @@ fn fixture() -> (tempfile::TempDir, Backend) { (root, backend) } +#[test] +fn concert_details_preserve_generic_actions_and_set_list() { + let (_root, backend) = fixture(); + let result: Value = serde_json::from_str( + &backend + .get_provider_metadata_json(ID, "concert", "event-1", &|| Ok(())) + .unwrap(), + ) + .unwrap(); + let detail = &result["concert"]; + assert_eq!(detail["id"], "event-1"); + assert_eq!(detail["artist_name"], "Example Artist"); + assert_eq!(detail["start_at"], "2026-10-07T01:00:00Z"); + assert_eq!(detail["end_at"], "2026-10-07T04:00:00Z"); + assert_eq!(detail["ticket_url"], "https://example.invalid/tickets/1"); + assert_eq!(detail["map_url"], "https://example.invalid/maps/1"); + assert_eq!(detail["set_list"]["id"], "list-1"); + assert_eq!( + detail["set_list"]["cover_url"], + "https://example.invalid/list.jpg" + ); + assert_eq!(detail["attribution"], "Example Events"); +} + #[test] fn response_envelopes_preserve_json_and_cancellation() { let value = json!({ diff --git a/rust_backend/crates/extensions/src/manager/providers.rs b/rust_backend/crates/extensions/src/manager/providers.rs index 9c4d7f6a..01ab7e52 100644 --- a/rust_backend/crates/extensions/src/manager/providers.rs +++ b/rust_backend/crates/extensions/src/manager/providers.rs @@ -214,8 +214,8 @@ impl ExtensionManager { let manifest = &entry.manifest; let requirement = match method { - "getTrack" | "getAlbum" | "getPlaylist" | "getArtist" | "searchTracks" - | "enrichTrack" => (!manifest.has_type("metadata_provider")) + "getTrack" | "getAlbum" | "getPlaylist" | "getArtist" | "getConcert" + | "searchTracks" | "enrichTrack" => (!manifest.has_type("metadata_provider")) .then_some("is not a metadata provider"), "checkAvailability" => (!manifest.has_type("download_provider")) .then_some("is not a download provider"), @@ -313,6 +313,7 @@ impl ExtensionManager { "getAlbum" => "album", "getPlaylist" => "playlist", "getArtist" => "artist", + "getConcert" => "concert", "handleUrl" => "URL handle result", "fetchLyrics" => "lyrics result", "checkAvailability" => "availability result", diff --git a/rust_backend/crates/extensions/src/provider.js b/rust_backend/crates/extensions/src/provider.js index 67611288..59c863e5 100644 --- a/rust_backend/crates/extensions/src/provider.js +++ b/rust_backend/crates/extensions/src/provider.js @@ -208,7 +208,18 @@ 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"]); + ["url","s"],["detail_id","s","detailId"]], ["id","location","start_at"]); + } + function concertDetail(value) { + const result = fields(value, [["id","s"],["artist_name","s","artistName"], + ["title","s"],["cover_url","s","coverUrl"],["venue","s"],["address","s"], + ["start_at","s","startAt"],["end_at","s","endAt"],["time_zone","s","timeZone"], + ["ticket_url","s","ticketUrl"],["map_url","s","mapUrl"],["url","s"], + ["attribution","s"]], ["id"]); + const setList = first(object(value), ["set_list","setList"]); + if (!empty(setList)) result.set_list = fields(setList, [["id","s"],["name","s"], + ["cover_url","s","coverUrl"],["url","s"]], ["id","name"]); + return result; } function decryption(value) { if (empty(value)) return null; @@ -274,6 +285,7 @@ } const parsers = { getTrack: track, enrichTrack: track, getAlbum: album, getPlaylist: album, getArtist: artist, + getConcert: concertDetail, searchTracks: search, customSearch: value => array(value, track), handleUrl: url, checkAvailability: value => fields(value, [["available","b"],["reason","s"],["track_id","s","trackId"], ["skip_fallback","b","skipFallback"],["prepared_context","m","preparedContext"]], ["available"]), diff --git a/test/concert_detail_screen_test.dart b/test/concert_detail_screen_test.dart new file mode 100644 index 00000000..b4778e02 --- /dev/null +++ b/test/concert_detail_screen_test.dart @@ -0,0 +1,178 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/l10n/app_localizations.dart'; +import 'package:spotiflac_android/models/artist_concert.dart'; +import 'package:spotiflac_android/screens/artist_concerts_screen.dart'; +import 'package:spotiflac_android/screens/concert_detail_screen.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const backend = MethodChannel('com.zarz.spotiflac/backend'); + const calendar = MethodChannel('com.zarz.spotiflac/concert_calendar'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + ArtistConcert event(String id, {bool details = true}) => + ArtistConcert.parseList([ + { + 'id': id, + 'location': 'Example City', + 'venue': 'Example Hall', + 'start_at': '2026-10-07T01:00:00Z', + 'time_zone': 'America/New_York', + if (details) 'detail_id': id, + 'url': 'https://example.com/events/1', + }, + ]).single; + + Widget app(Widget home, {Brightness brightness = Brightness.light}) => + ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(brightness), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: home, + ), + ); + + setUp(() => SharedPreferences.setMockInitialValues({})); + tearDown(() { + messenger.setMockMethodCallHandler(backend, null); + messenger.setMockMethodCallHandler(calendar, null); + }); + + for (final brightness in Brightness.values) { + testWidgets( + 'schedule follows $brightness and a row opens an internal dark detail', + (tester) async { + await tester.pumpWidget( + app( + ArtistConcertsScreen( + artistName: 'Example Artist', + concerts: [event('basic-${brightness.name}', details: false)], + ), + brightness: brightness, + ), + ); + await tester.pumpAndSettle(); + expect( + Theme.of( + tester.element(find.text('All Upcoming Concerts')), + ).brightness, + brightness, + ); + await tester.tap(find.text('Example City')); + await tester.pumpAndSettle(); + expect(find.byType(ConcertDetailScreen), findsOneWidget); + expect( + Theme.of(tester.element(find.text('Example Artist').last)).brightness, + Brightness.dark, + ); + expect(find.textContaining('21:00'), findsOneWidget); + expect(find.text('Get Tickets'), findsNothing); + await tester.tap(find.byTooltip('Back').last); + await tester.pumpAndSettle(); + expect(find.byType(ConcertDetailScreen), findsNothing); + }, + ); + } + + testWidgets( + 'details load on demand with skeleton, retry and calendar action', + (tester) async { + tester.view.physicalSize = const Size(390, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final pending = Completer(); + var requests = 0; + messenger.setMockMethodCallHandler(backend, (call) async { + if (call.method != 'getProviderMetadata') return null; + expect(call.arguments, containsPair('resource_type', 'concert')); + expect(call.arguments, containsPair('provider_id', 'example.metadata')); + requests++; + if (requests == 1) return pending.future; + return { + 'concert': { + 'id': 'load', + 'address': '123 Example Street', + 'start_at': '2026-10-07T01:00:00Z', + 'end_at': '2026-10-07T04:00:00Z', + 'ticket_url': 'https://example.com/tickets/1', + 'set_list': {'id': 'list-1', 'name': 'Example Set List'}, + }, + }; + }); + final calendarCalls = []; + messenger.setMockMethodCallHandler(calendar, (call) async { + calendarCalls.add(call); + return true; + }); + await tester.pumpWidget( + app( + ConcertDetailScreen( + artistName: 'Example Artist', + providerId: 'example.metadata', + concert: event('load'), + ), + ), + ); + await tester.pump(); + expect(find.byType(ShimmerLoading), findsOneWidget); + pending.completeError(PlatformException(code: 'offline')); + await tester.pumpAndSettle(); + expect( + find.text('Concert details are currently unavailable.'), + findsOneWidget, + ); + await tester.tap(find.text('Retry')); + await tester.pumpAndSettle(); + expect(find.text('Get Tickets'), findsOneWidget); + expect(find.text('Example Set List'), findsOneWidget); + expect(find.text('123 Example Street'), findsOneWidget); + await tester.tap(find.byTooltip('Add to Calendar')); + await tester.pumpAndSettle(); + expect( + calendarCalls.single.arguments, + containsPair( + 'start', + DateTime.utc(2026, 10, 7, 1).millisecondsSinceEpoch, + ), + ); + expect( + calendarCalls.single.arguments, + containsPair( + 'end', + DateTime.utc(2026, 10, 7, 4).millisecondsSinceEpoch, + ), + ); + expect(tester.takeException(), isNull); + await tester.pump(const Duration(seconds: 1)); + }, + ); + + test( + 'optional fields and unsafe links cannot create bogus detail actions', + () { + final detail = ConcertDetail.fromJson({ + 'ticket_url': 'javascript:alert(1)', + 'map_url': 'file:///private', + 'set_list': [], + 'start_at': 'bad date', + 'artist_name': 42, + }); + expect(detail.ticketUrl, isNull); + expect(detail.mapUrl, isNull); + expect(detail.start, isNull); + expect(detail.setListId, isNull); + expect(detail.artistName, isNull); + }, + ); +}