mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 12:52:13 +02:00
feat(artists): show provider-neutral upcoming concerts
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
|
||||
Reference in New Issue
Block a user