feat(artists): show provider-neutral upcoming concerts

This commit is contained in:
zarzet
2026-09-25 10:58:19 +07:00
parent 3ca881b169
commit a9898e8b30
18 changed files with 804 additions and 3 deletions
+3
View File
@@ -6820,6 +6820,9 @@
"profileRemovePhoto": "Remove photo",
"profilePhotoError": "Choose a valid image smaller than 20 MB.",
"profileSaveError": "Could not save your profile. Please try again.",
"artistUpcomingConcerts": "Upcoming Concerts",
"artistAllUpcomingConcerts": "All Upcoming Concerts",
"artistNoUpcomingConcerts": "No upcoming concerts.",
"optionsEmbeddedCoverSizeOriginal": "Original resolution",
"@optionsEmbeddedCoverSizeOriginal": {
"description": "Option that preserves the provider artwork at its original resolution"
+3
View File
@@ -6294,6 +6294,9 @@
"autoMixDescription": "Sambungkan lagu dengan pencocokan beat dan penyesuaian tempo tanpa mengubah nada. Gunakan crossfade jika beat tidak cocok.",
"autoMixOn": "AutoMix aktif",
"autoMixOff": "AutoMix nonaktif",
"artistUpcomingConcerts": "Konser Mendatang",
"artistAllUpcomingConcerts": "Semua Konser Mendatang",
"artistNoUpcomingConcerts": "Belum ada konser mendatang.",
"profileTitle": "Profil",
"profileSetUp": "Atur profil kamu",
"profileEdit": "Ubah nama dan foto",
+83
View File
@@ -0,0 +1,83 @@
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:timezone/data/latest.dart' as tz_data;
import 'package:timezone/timezone.dart' as tz;
/// Provider-neutral event metadata. Calendar labels always use the venue's
/// local time, never the listener's device time zone.
class ArtistConcert {
const ArtistConcert({
required this.id,
required this.location,
required this.venue,
required this.date,
required this.hasTime,
this.url,
});
final String id;
final String location;
final String venue;
final DateTime date;
final bool hasTime;
final String? url;
static bool _timeZonesReady = false;
static List<ArtistConcert> parseList(Object? value) {
if (value is! List) return const [];
final concerts = <String, ArtistConcert>{};
for (final item in value.take(500)) {
if (item is! Map<String, dynamic>) continue;
String text(String key) =>
item[key] is String ? (item[key] as String).trim() : '';
final rawDate = text('start_at');
final match = RegExp(
r'^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::\d{2}(?:\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$',
).firstMatch(rawDate);
final instant = DateTime.tryParse(rawDate);
final location = text('location');
if (match == null || instant == null || location.isEmpty) continue;
final year = int.parse(match[1]!);
final month = int.parse(match[2]!);
final day = int.parse(match[3]!);
final hour = int.parse(match[4] ?? '0');
final minute = int.parse(match[5] ?? '0');
var date = DateTime.utc(year, month, day, hour, minute);
// DateTime.parse normalizes impossible dates; do not show invented days.
if (date.year != year ||
date.month != month ||
date.day != day ||
hour > 23 ||
minute > 59) {
continue;
}
final zone = text('time_zone');
if (zone.isNotEmpty && match[6] != null) {
if (!_timeZonesReady) {
tz_data.initializeTimeZones();
_timeZonesReady = true;
}
try {
date = tz.TZDateTime.from(instant, tz.getLocation(zone));
} on tz.LocationNotFoundException {
// Preserve the supplied wall-clock date if the zone is unknown.
}
}
final id = text('id');
final venue = text('venue');
final key = id.isEmpty ? '$rawDate|$location|$venue' : id;
concerts.putIfAbsent(
key,
() => ArtistConcert(
id: key,
location: location,
venue: venue,
date: date,
hasTime: match[4] != null,
url: normalizeRemoteHttpUrl(text('url')),
),
);
}
return concerts.values.toList()..sort((a, b) => a.date.compareTo(b.date));
}
}
+6
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/artist_concert.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
@@ -30,6 +31,7 @@ class TrackState {
final int? monthlyListeners;
final List<ArtistAlbum>? artistAlbums;
final List<Track>? artistTopTracks;
final List<ArtistConcert> artistConcerts;
final bool hasSearchText;
final bool isShowingRecentAccess;
final String? searchExtensionId;
@@ -54,6 +56,7 @@ class TrackState {
this.monthlyListeners,
this.artistAlbums,
this.artistTopTracks,
this.artistConcerts = const [],
this.hasSearchText = false,
this.isShowingRecentAccess = false,
this.searchExtensionId,
@@ -81,6 +84,7 @@ class TrackState {
int? monthlyListeners,
List<ArtistAlbum>? artistAlbums,
List<Track>? artistTopTracks,
List<ArtistConcert>? artistConcerts,
bool? hasSearchText,
bool? isShowingRecentAccess,
String? searchExtensionId,
@@ -107,6 +111,7 @@ class TrackState {
monthlyListeners: monthlyListeners ?? this.monthlyListeners,
artistAlbums: artistAlbums ?? this.artistAlbums,
artistTopTracks: artistTopTracks ?? this.artistTopTracks,
artistConcerts: artistConcerts ?? this.artistConcerts,
hasSearchText: hasSearchText ?? this.hasSearchText,
isShowingRecentAccess:
isShowingRecentAccess ?? this.isShowingRecentAccess,
@@ -305,6 +310,7 @@ class TrackNotifier extends Notifier<TrackState> {
artistAlbumsNext: artistData['albums_next'] as String?,
artistAlbums: albums,
artistTopTracks: topTracks.isNotEmpty ? topTracks : null,
artistConcerts: ArtistConcert.parseList(artistData['concerts']),
searchExtensionId: extensionId,
);
return;
+278
View File
@@ -0,0 +1,278 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/artist_concert.dart';
import 'package:spotiflac_android/services/shell_navigation_service.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/utils/adaptive_layout.dart';
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
import 'package:spotiflac_android/widgets/album_detail_header.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
class ArtistConcertsScreen extends StatefulWidget {
const ArtistConcertsScreen({
super.key,
required this.artistName,
required this.concerts,
this.coverUrl,
});
final String artistName;
final List<ArtistConcert> concerts;
final String? coverUrl;
@override
State<ArtistConcertsScreen> createState() => _ArtistConcertsScreenState();
}
class _ArtistConcertsScreenState extends State<ArtistConcertsScreen> {
@override
void dispose() {
ShellNavigationService.clearChromeBrightness(this);
super.dispose();
}
@override
Widget build(BuildContext context) {
if (context.isMornye) {
final theme = MornyeTheme.build(Brightness.dark);
final route = ModalRoute.of(context);
if (route != null) {
ShellNavigationService.setChromeBrightness(
owner: this,
route: route,
brightness: Brightness.dark,
surface: theme.colorScheme.surface,
);
}
return Theme(
data: theme,
child: Builder(builder: _buildPage),
);
}
return _buildPage(context);
}
Widget _buildPage(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final inset = wideListInset(context) + 24;
final locale = Localizations.localeOf(context).toString();
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
pinned: true,
surfaceTintColor: Colors.transparent,
leadingWidth: 64,
leading: Padding(
padding: const EdgeInsets.only(left: 12),
child: HeaderCircleButton(
icon: Icons.arrow_back,
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
onPressed: () => Navigator.pop(context),
),
),
),
SliverPadding(
padding: EdgeInsets.fromLTRB(inset, 24, inset, 32),
sliver: SliverToBoxAdapter(
child: Row(
children: [
ClipOval(
child: SizedBox.square(
dimension: 96,
child: widget.coverUrl == null || widget.coverUrl!.isEmpty
? const Icon(Icons.person, size: 56)
: CachedCoverImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: 288,
errorWidget: (_, _, _) =>
const Icon(Icons.person, size: 56),
),
),
),
const SizedBox(width: 18),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.artistUpcomingConcerts,
style: TextStyle(
color: scheme.onSurfaceVariant,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
widget.artistName,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
),
),
SliverPadding(
padding: EdgeInsets.fromLTRB(inset, 0, inset, 12),
sliver: SliverToBoxAdapter(
child: Text(
context.l10n.artistAllUpcomingConcerts,
style: const TextStyle(
fontSize: 21,
fontWeight: FontWeight.bold,
),
),
),
),
if (widget.concerts.isEmpty)
SliverPadding(
padding: EdgeInsets.all(inset),
sliver: SliverToBoxAdapter(
child: Text(context.l10n.artistNoUpcomingConcerts),
),
)
else
SliverPadding(
padding: EdgeInsets.symmetric(horizontal: inset),
sliver: SliverList.separated(
itemCount: widget.concerts.length,
separatorBuilder: (_, _) =>
const Divider(height: 1, indent: 60),
itemBuilder: (context, index) {
final concert = widget.concerts[index];
final date = DateFormat.MMMEd(locale).format(concert.date);
final details = [
if (concert.venue.isNotEmpty) concert.venue,
DateFormat.y(locale).format(concert.date),
date,
if (concert.hasTime)
DateFormat.Hm(locale).format(concert.date),
].join(' · ');
return InkWell(
onTap: concert.url == null
? null
: () => _openConcert(context, concert),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: Row(
children: [
Semantics(
label: date,
excludeSemantics: true,
child: Container(
width: 48,
padding: const EdgeInsets.symmetric(vertical: 3),
decoration: BoxDecoration(
color: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(9),
border: Border.all(
color: scheme.outlineVariant,
),
),
child: Column(
children: [
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
DateFormat.MMM(
locale,
).format(concert.date),
maxLines: 1,
style: TextStyle(
color: context.isMornye
? scheme.primary
: scheme.error,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
DateFormat.d(locale).format(concert.date),
style: const TextStyle(
fontSize: 28,
height: 1.05,
),
),
),
],
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
concert.location,
style: const TextStyle(fontSize: 16),
),
const SizedBox(height: 2),
Text(
details,
maxLines:
MediaQuery.textScalerOf(
context,
).scale(1) >
1.3
? null
: 1,
overflow:
MediaQuery.textScalerOf(
context,
).scale(1) >
1.3
? null
: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
color: scheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
);
},
),
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
const NavBarSliverSpacer(),
],
),
);
}
Future<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)),
);
}
}
+19
View File
@@ -14,6 +14,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/artist_concert.dart';
import 'package:spotiflac_android/widgets/artist_concerts_button.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
@@ -60,6 +62,7 @@ class _ArtistCache {
required List<ArtistAlbum> albums,
List<ArtistAlbum>? releases,
List<Track>? topTracks,
List<ArtistConcert> concerts = const [],
String? headerImageUrl,
String? headerVideoUrl,
String? headerLogoUrl,
@@ -72,6 +75,7 @@ class _ArtistCache {
albums: albums,
releases: releases,
topTracks: topTracks,
concerts: concerts,
headerImageUrl: headerImageUrl,
headerVideoUrl: headerVideoUrl,
headerLogoUrl: headerLogoUrl,
@@ -86,6 +90,7 @@ class _CacheEntry {
final List<ArtistAlbum> albums;
final List<ArtistAlbum>? releases;
final List<Track>? topTracks;
final List<ArtistConcert> concerts;
final String? headerImageUrl;
final String? headerVideoUrl;
final String? headerLogoUrl;
@@ -96,6 +101,7 @@ class _CacheEntry {
required this.albums,
this.releases,
this.topTracks,
this.concerts = const [],
this.headerImageUrl,
this.headerVideoUrl,
this.headerLogoUrl,
@@ -115,6 +121,7 @@ class ArtistScreen extends ConsumerStatefulWidget {
final int? monthlyListeners;
final List<ArtistAlbum>? albums;
final List<Track>? topTracks;
final List<ArtistConcert> concerts;
final String? extensionId;
const ArtistScreen({
@@ -129,6 +136,7 @@ class ArtistScreen extends ConsumerStatefulWidget {
this.monthlyListeners,
this.albums,
this.topTracks,
this.concerts = const [],
this.extensionId,
});
@@ -143,6 +151,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
List<ArtistAlbum>? _albums;
List<ArtistAlbum>? _releases;
List<Track>? _topTracks;
List<ArtistConcert> _concerts = const [];
String? _headerImageUrl;
String? _headerVideoUrl;
String? _headerLogoUrl;
@@ -223,6 +232,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
@override
void initState() {
super.initState();
_concerts = widget.concerts;
_scrollController.addListener(_onScroll);
@@ -272,6 +282,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
_albums = cached.albums;
_releases = cached.releases;
_topTracks = cached.topTracks;
_concerts = cached.concerts;
_headerImageUrl = cached.headerImageUrl;
_headerVideoUrl = cached.headerVideoUrl;
_headerLogoUrl = cached.headerLogoUrl;
@@ -311,6 +322,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
List<ArtistAlbum> albums;
List<ArtistAlbum>? releases;
List<Track>? topTracks;
List<ArtistConcert> concerts = const [];
String? headerImage;
String? headerVideo;
String? headerLogo;
@@ -344,6 +356,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
}
final artistInfo = artistData['artist_info'] as Map<String, dynamic>?;
concerts = ArtistConcert.parseList(
artistInfo?['concerts'] ?? artistData['concerts'],
);
albumsNext =
(artistInfo?['albums_next'] ?? artistData['albums_next'])
as String?;
@@ -368,6 +383,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
if (result != null && result['artist'] != null) {
final artistData = result['artist'] as Map<String, dynamic>;
concerts = ArtistConcert.parseList(artistData['concerts']);
albumsNext = artistData['albums_next'] as String?;
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
albums = albumsList
@@ -408,6 +424,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
releases: releases,
topTracks: topTracks,
headerImageUrl: finalHeaderImage,
concerts: concerts,
headerVideoUrl: finalHeaderVideo,
headerLogoUrl: finalHeaderLogo,
albumsNext: albumsNext,
@@ -419,6 +436,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
_albums = albums;
_releases = releases;
_topTracks = topTracks;
_concerts = concerts;
_headerImageUrl = finalHeaderImage;
_headerVideoUrl = finalHeaderVideo;
_headerLogoUrl = finalHeaderLogo;
@@ -497,6 +515,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
albums: _albums!,
releases: _releases,
topTracks: _topTracks,
concerts: _concerts,
headerImageUrl: _headerImageUrl,
headerVideoUrl: _headerVideoUrl,
headerLogoUrl: _headerLogoUrl,
+11
View File
@@ -2,6 +2,12 @@
part of 'artist_screen.dart';
extension _ArtistScreenSections on _ArtistScreenState {
Widget _concertsButton() => ArtistConcertsButton(
artistName: widget.artistName,
concerts: _concerts,
coverUrl: widget.coverUrl ?? _headerImageUrl,
);
List<Widget> _buildHeader(
BuildContext context,
ColorScheme colorScheme, {
@@ -68,6 +74,7 @@ extension _ArtistScreenSections on _ArtistScreenState {
return MornyeArtistHeader(
name: widget.artistName,
logoUrl: _headerLogoUrl ?? widget.headerLogoUrl,
badge: _concerts.isEmpty || isSelectionMode ? null : _concertsButton(),
listeners: listenersText,
showTitle: _showTitleInAppBar,
artwork: hasMotionBanner
@@ -276,6 +283,10 @@ extension _ArtistScreenSections on _ArtistScreenState {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (_concerts.isNotEmpty && !isSelectionMode) ...[
_concertsButton(),
const SizedBox(height: 8),
],
Text(
widget.artistName,
style: Theme.of(context).textTheme.headlineLarge
+3
View File
@@ -18,6 +18,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/artist_concert.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
@@ -681,8 +682,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
headerImageUrl: trackState.headerImageUrl,
headerVideoUrl: trackState.headerVideoUrl,
headerLogoUrl: trackState.headerLogoUrl,
concerts: trackState.artistConcerts,
albumsNext: trackState.artistAlbumsNext,
albums: trackState.artistAlbums!,
topTracks: trackState.artistTopTracks,
extensionId: extensionId,
),
),
+5
View File
@@ -1337,6 +1337,7 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen>
String? _headerImageUrl;
String? _headerVideoUrl;
String? _headerLogoUrl;
List<ArtistConcert> _concerts = const [];
String? _albumsNext;
int? _monthlyListeners;
bool _isLoading = true;
@@ -1405,6 +1406,9 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen>
_headerImageUrl = headerImage;
_headerVideoUrl = headerVideo;
_headerLogoUrl = headerLogo;
_concerts = ArtistConcert.parseList(
artistInfo['concerts'] ?? result['concerts'],
);
_albumsNext = artistInfo['albums_next'] as String?;
_monthlyListeners = listeners;
_isLoading = false;
@@ -1472,6 +1476,7 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen>
headerImageUrl: _headerImageUrl,
headerVideoUrl: _headerVideoUrl,
headerLogoUrl: _headerLogoUrl,
concerts: _concerts,
albumsNext: _albumsNext,
monthlyListeners: _monthlyListeners,
albums: _albums,
+51
View File
@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/artist_concert.dart';
import 'package:spotiflac_android/screens/artist_concerts_screen.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
class ArtistConcertsButton extends StatelessWidget {
const ArtistConcertsButton({
super.key,
required this.artistName,
required this.concerts,
this.coverUrl,
});
final String artistName;
final List<ArtistConcert> concerts;
final String? coverUrl;
@override
Widget build(BuildContext context) {
if (concerts.isEmpty) return const SizedBox.shrink();
return TextButton.icon(
onPressed: () => Navigator.of(context).push(
slidePageRoute<void>(
page: ArtistConcertsScreen(
artistName: artistName,
concerts: concerts,
coverUrl: coverUrl,
),
),
),
style: TextButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: Colors.black.withValues(alpha: 0.35),
minimumSize: const Size(0, 36),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(9),
side: BorderSide(color: Colors.white.withValues(alpha: 0.22)),
),
textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
icon: const Icon(Icons.confirmation_number_rounded, size: 16),
label: Text(
context.l10n.artistUpcomingConcerts,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
);
}
}
+8
View File
@@ -146,6 +146,7 @@ class MornyeArtistHeader extends StatelessWidget {
required this.showTitle,
this.listeners,
this.logoUrl,
this.badge,
});
final String name;
@@ -154,6 +155,7 @@ class MornyeArtistHeader extends StatelessWidget {
final bool showTitle;
final String? listeners;
final String? logoUrl;
final Widget? badge;
@override
Widget build(BuildContext context) =>
@@ -196,6 +198,7 @@ class MornyeArtistHeader extends StatelessWidget {
? 0.0
: 7 + textHeight(listeners!, const TextStyle(fontSize: 13));
final photoHeight = (width * 0.76).clamp(240.0, 340.0);
final badgeHeight = badge == null ? 0.0 : textScaler.scale(13) + 39;
final expandedHeight =
photoHeight +
identityHeight +
@@ -242,6 +245,11 @@ class MornyeArtistHeader extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (badge != null)
SizedBox(
height: badgeHeight,
child: Center(child: badge),
),
SizedBox(
height: identityHeight,
child: Center(child: _buildIdentity()),