feat(concerts): show artwork-themed event details in app

This commit is contained in:
zarzet
2026-09-25 12:00:28 +07:00
parent ab58cdbb0c
commit abb8dc4a42
18 changed files with 1047 additions and 23 deletions
@@ -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<Number>("start")?.toLong()
if (start == null) {
result.success(false)
} else {
val suppliedEnd = call.argument<Number>("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<String>("title"))
putExtra(android.provider.CalendarContract.Events.EVENT_LOCATION, call.argument<String>("location"))
putExtra(android.provider.CalendarContract.Events.DESCRIPTION, call.argument<String>("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") {
+41 -1
View File
@@ -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.
+4
View File
@@ -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 = "<group>"; };
A0D100000000000000000002 /* AudioOutputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioOutputView.swift; sourceTree = "<group>"; };
F59500000000000000000009 /* PlayerWidget.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = PlayerWidget.xcconfig; sourceTree = "<group>"; };
F59500000000000000000001 /* PlayerWidgetState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerWidgetState.swift; sourceTree = "<group>"; };
@@ -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 */,
+4
View File
@@ -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,
+65
View File
@@ -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)
}
}
+2
View File
@@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSCalendarsUsageDescription</key>
<string>Add a concert to your calendar after you review and save it.</string>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleLocalizations</key>
+7
View File
@@ -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"
+7
View File
@@ -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"
+66
View File
@@ -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<String, dynamic> json) {
String? text(Map<String, dynamic> data, String key) {
final value = data[key];
return value is String && value.trim().isNotEmpty ? value.trim() : null;
}
String? link(Map<String, dynamic> data, String key) =>
normalizeRemoteHttpUrl(text(data, key));
final setList = json['set_list'] is Map<String, dynamic>
? json['set_list'] as Map<String, dynamic>
: const <String, dynamic>{};
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'),
);
}
}
+14 -17
View File
@@ -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<ArtistConcert> concerts;
final String? coverUrl;
final String? providerId;
@override
State<ArtistConcertsScreen> createState() => _ArtistConcertsScreenState();
@@ -154,9 +157,7 @@ class _ArtistConcertsScreenState extends State<ArtistConcertsScreen> {
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<ArtistConcertsScreen> {
}
Future<void> _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<void>(
page: ConcertDetailScreen(
artistName: widget.artistName,
coverUrl: widget.coverUrl,
providerId: widget.providerId,
concert: concert,
),
),
);
}
}
+1
View File
@@ -6,6 +6,7 @@ extension _ArtistScreenSections on _ArtistScreenState {
artistName: widget.artistName,
concerts: _concerts,
coverUrl: widget.coverUrl ?? _headerImageUrl,
providerId: _directMetadataProviderId(),
);
List<Widget> _buildHeader(
+572
View File
@@ -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<ConcertDetailScreen> createState() => _ConcertDetailScreenState();
}
class _ConcertDetailScreenState extends State<ConcertDetailScreen> {
ConcertDetail _detail = const ConcertDetail();
bool _loading = false;
bool _failed = false;
bool _calendarOpen = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _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<String, dynamic>) {
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<void>(
page: PlaylistScreen(
playlistName: _detail.setListName ?? context.l10n.concertSetList,
coverUrl: _detail.setListCover,
tracks: const [],
playlistId: _detail.setListId,
metadataProviderId: widget.providerId,
),
),
);
}
Future<void> _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<void> _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<void> _addToCalendar() async {
if (_calendarOpen || _detail.start == null) return;
_calendarOpen = true;
try {
final opened =
await const MethodChannel(
'com.zarz.spotiflac/concert_calendar',
).invokeMethod<bool>('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)));
}
+3
View File
@@ -10,11 +10,13 @@ class ArtistConcertsButton extends StatelessWidget {
required this.artistName,
required this.concerts,
this.coverUrl,
this.providerId,
});
final String artistName;
final List<ArtistConcert> 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,
),
),
),
@@ -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<Value, Resol
check().map_err(ResolverError::Cancelled)?;
let cover = text(value, "cover_url");
Ok(match kind {
"concert" => 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(),
@@ -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!({
@@ -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",
+13 -1
View File
@@ -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"]),
+178
View File
@@ -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<Object?>();
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 = <MethodCall>[];
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': <Object?>[],
'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);
},
);
}