mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 04:42:02 +02:00
feat(concerts): show artwork-themed event details in app
This commit is contained in:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 */,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ extension _ArtistScreenSections on _ArtistScreenState {
|
||||
artistName: widget.artistName,
|
||||
concerts: _concerts,
|
||||
coverUrl: widget.coverUrl ?? _headerImageUrl,
|
||||
providerId: _directMetadataProviderId(),
|
||||
);
|
||||
|
||||
List<Widget> _buildHeader(
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user