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

This commit is contained in:
zarzet committed 2026-09-25 12:00:28 +07:00
1 parent ab58cdbb0c
commit abb8dc4a42
18 files changed
+1047 -23

No files matched your search

+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)));
}