mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 13:19:44 +02:00
feat(artists): show provider-neutral upcoming concerts
This commit is contained in:
1 parent
3ca881b169
commit
a9898e8b30
18 files changed
+804
-3
No files matched your search
@@ -0,0 +1,36 @@
|
||||
# Artist concerts
|
||||
|
||||
Any metadata extension can include an optional `concerts` list in `getArtist`
|
||||
or the `artist` object returned by `handleUrl`. The native metadata bridge
|
||||
preserves it as `artist_info.concerts` or `artist.concerts`, respectively.
|
||||
Older extensions may omit it; no concert button is shown for an empty list.
|
||||
|
||||
```json
|
||||
{
|
||||
"concerts": [{
|
||||
"id": "event-123",
|
||||
"location": "Example City, Region",
|
||||
"venue": "Example Hall",
|
||||
"start_at": "2026-10-07T01:00:00Z",
|
||||
"time_zone": "America/New_York",
|
||||
"url": "https://example.com/events/123"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
- `location` and a valid ISO 8601 `start_at` are required. Date-only values
|
||||
(`2026-10-06`) are supported when a time has not been announced.
|
||||
- For UTC timestamps, supply the venue's IANA `time_zone`; the example above
|
||||
displays **October 6 at 21:00**, regardless of the listener's device time zone.
|
||||
Explicit offsets are also supported. Without a recognized time zone, the app
|
||||
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.
|
||||
- Providers should return upcoming events only. Failure to fetch this optional
|
||||
information must not fail the artist's albums or top tracks.
|
||||
|
||||
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.
|
||||
@@ -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()),
|
||||
|
||||
+1
-1
@@ -1347,7 +1347,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: timezone
|
||||
sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb"
|
||||
|
||||
@@ -14,6 +14,7 @@ dependencies:
|
||||
flutter_localizations:
|
||||
sdk: flutter
|
||||
intl: ^0.20.3
|
||||
timezone: ^0.11.1
|
||||
|
||||
# State Management
|
||||
flutter_riverpod: ^3.4.2
|
||||
|
||||
@@ -88,6 +88,7 @@ impl Backend {
|
||||
"listeners".into(),
|
||||
json!(value["listeners"].as_i64().unwrap_or_default()),
|
||||
);
|
||||
info.insert("concerts".into(), array(value, "concerts").into());
|
||||
for key in ["albums", "releases"] {
|
||||
let values = array(value, key);
|
||||
if !values.is_empty() {
|
||||
@@ -450,6 +451,7 @@ fn response(kind: &str, value: &Value, check: &Check<'_>) -> Result<Value, Resol
|
||||
);
|
||||
info.insert("images".into(), json!(image));
|
||||
info.insert("cover_url".into(), json!(cover));
|
||||
info.insert("concerts".into(), array(value, "concerts").into());
|
||||
if value["listeners"].as_i64().unwrap_or_default() > 0 {
|
||||
info.insert("listeners".into(), value["listeners"].clone());
|
||||
}
|
||||
|
||||
@@ -19,9 +19,13 @@ function collection(id) {
|
||||
}
|
||||
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"}],
|
||||
headerLogo: "https://example.invalid/logo.png", albumsNext: "artist-page-2", albums: []};
|
||||
}
|
||||
registerExtension({getAlbum: collection, getPlaylist: collection, getArtist});
|
||||
function handleUrl() { return {type: "artist", artist: getArtist("artist-1")}; }
|
||||
registerExtension({getAlbum: collection, getPlaylist: collection, getArtist, handleUrl});
|
||||
"#;
|
||||
|
||||
fn fixture() -> (tempfile::TempDir, Backend) {
|
||||
@@ -31,7 +35,8 @@ fn fixture() -> (tempfile::TempDir, Backend) {
|
||||
std::fs::write(
|
||||
source.join("manifest.json"),
|
||||
json!({"name":ID,"displayName":"Example Metadata","version":"1",
|
||||
"description":"Generic metadata fixture","type":["metadata_provider"]})
|
||||
"description":"Generic metadata fixture","type":["metadata_provider"],
|
||||
"urlHandler":{"enabled":true,"patterns":["example.invalid"]}})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -104,6 +109,37 @@ fn artist_metadata_preserves_logo_separately_from_portrait() {
|
||||
backend.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artist_concerts_survive_metadata_and_url_routes_without_provider_special_cases() {
|
||||
let (_root, backend) = fixture();
|
||||
let metadata: Value = serde_json::from_str(
|
||||
&backend
|
||||
.get_provider_metadata_json(ID, "artist", "artist-1", &|| Ok(()))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let concerts = &metadata["artist_info"]["concerts"];
|
||||
assert_eq!(concerts[0]["start_at"], "2026-10-07T01:00:00Z");
|
||||
assert_eq!(concerts[0]["time_zone"], "America/New_York");
|
||||
assert_eq!(concerts[0]["venue"], "Example Hall");
|
||||
let handled: Value = serde_json::from_str(
|
||||
&backend
|
||||
.handle_url_json("https://example.invalid/artist/1")
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(&handled["artist"]["concerts"], concerts);
|
||||
let malformed: Value = serde_json::from_str(
|
||||
&backend
|
||||
.get_provider_metadata_json(ID, "artist", "bad-concerts", &|| Ok(()))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(malformed["artist_info"]["concerts"], json!([]));
|
||||
assert_eq!(malformed["artist_info"]["name"], "Example Artist");
|
||||
backend.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_value_bridge_preserves_album_playlist_and_public_json() {
|
||||
let (_root, backend) = fixture();
|
||||
|
||||
@@ -200,8 +200,16 @@
|
||||
if (albums.length) result.albums = albums;
|
||||
if (releases.length) result.releases = releases;
|
||||
if (tracks.length) result.top_tracks = tracks;
|
||||
const concertItems = first(object(value), ["concerts"]);
|
||||
const concerts = isArray(concertItems) ? array(concertItems.slice(0, 500), concert) : [];
|
||||
if (concerts.length) result.concerts = concerts;
|
||||
return result;
|
||||
}
|
||||
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"]);
|
||||
}
|
||||
function decryption(value) {
|
||||
if (empty(value)) return null;
|
||||
const result = fields(value, [["strategy","s"],["key","s"],["iv","s"],
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
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: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/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/artist_concerts_button.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_artist_header.dart';
|
||||
|
||||
Map<String, dynamic> event({
|
||||
String id = 'event-1',
|
||||
String date = '2026-10-07T01:00:00Z',
|
||||
String zone = 'America/New_York',
|
||||
}) => {
|
||||
'id': id,
|
||||
'location': 'Example City, Region',
|
||||
'venue': 'Example Hall',
|
||||
'start_at': date,
|
||||
'time_zone': zone,
|
||||
'url': 'https://example.com/events/$id',
|
||||
};
|
||||
|
||||
Widget app(Widget child, {bool mornye = true, String language = 'en'}) =>
|
||||
ProviderScope(
|
||||
child: MaterialApp(
|
||||
theme: mornye ? MornyeTheme.build(Brightness.light) : ThemeData(),
|
||||
locale: Locale(language),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: child,
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
setUpAll(() async {
|
||||
await (FontLoader(
|
||||
'Inter',
|
||||
)..addFont(rootBundle.load('assets/fonts/InterVariable.ttf'))).load();
|
||||
});
|
||||
test('venue dates cross UTC midnight and follow daylight saving time', () {
|
||||
final summer = ArtistConcert.parseList([event()]).single;
|
||||
expect(summer.date.day, 6);
|
||||
expect(summer.date.hour, 21);
|
||||
final winter = ArtistConcert.parseList([
|
||||
event(date: '2026-12-07T01:00:00Z'),
|
||||
]).single;
|
||||
expect(winter.date.day, 6);
|
||||
expect(winter.date.hour, 20);
|
||||
});
|
||||
|
||||
test('date-only and explicit-offset events retain their calendar fields', () {
|
||||
for (final zone in ['', 'Unknown/Zone']) {
|
||||
final concert = ArtistConcert.parseList([
|
||||
event(date: '2026-10-06T21:00:00-04:00', zone: zone),
|
||||
]).single;
|
||||
expect(concert.date.day, 6);
|
||||
expect(concert.date.hour, 21);
|
||||
}
|
||||
final dateOnly = ArtistConcert.parseList([
|
||||
event(date: '2026-10-06'),
|
||||
]).single;
|
||||
expect(dateOnly.hasTime, isFalse);
|
||||
expect(dateOnly.date.day, 6);
|
||||
});
|
||||
|
||||
test('invalid optional metadata is ignored without losing valid events', () {
|
||||
expect(ArtistConcert.parseList(null), isEmpty);
|
||||
expect(ArtistConcert.parseList({'unexpected': true}), isEmpty);
|
||||
final concerts = ArtistConcert.parseList([
|
||||
null,
|
||||
'bad',
|
||||
event(date: '2026-02-30'),
|
||||
event(date: '2026-10-06T25:00'),
|
||||
event(date: 'unknown'),
|
||||
{...event(), 'location': 5},
|
||||
{...event(), 'url': 'javascript:alert(1)'},
|
||||
]);
|
||||
expect(concerts, hasLength(1));
|
||||
expect(concerts.single.url, isNull);
|
||||
});
|
||||
|
||||
test('events are ordered, deduplicated, and bounded', () {
|
||||
final concerts = ArtistConcert.parseList([
|
||||
event(),
|
||||
event(),
|
||||
event(id: 'early', date: '2026-10-01'),
|
||||
{...event(id: ''), 'venue': ''},
|
||||
{...event(id: ''), 'venue': ''},
|
||||
]);
|
||||
expect(concerts, hasLength(3));
|
||||
expect(concerts.first.id, 'early');
|
||||
expect(
|
||||
ArtistConcert.parseList(List.generate(600, (i) => event(id: '$i'))),
|
||||
hasLength(500),
|
||||
);
|
||||
});
|
||||
|
||||
for (final mornye in [true, false]) {
|
||||
testWidgets(
|
||||
'concert badge opens a generic schedule and back restores artist ($mornye)',
|
||||
(tester) async {
|
||||
final concerts = ArtistConcert.parseList([event()]);
|
||||
await tester.pumpWidget(
|
||||
app(
|
||||
Scaffold(
|
||||
body: Center(
|
||||
child: ArtistConcertsButton(
|
||||
artistName: 'Example Artist',
|
||||
concerts: concerts,
|
||||
),
|
||||
),
|
||||
),
|
||||
mornye: mornye,
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Upcoming Concerts'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(ArtistConcertsScreen), findsOneWidget);
|
||||
expect(find.text('Example Artist'), findsOneWidget);
|
||||
expect(find.text('All Upcoming Concerts'), findsOneWidget);
|
||||
expect(find.text('Example City, Region'), findsOneWidget);
|
||||
expect(find.text('6'), findsOneWidget);
|
||||
expect(find.textContaining('21:00'), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('Back'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(ArtistConcertsScreen), findsNothing);
|
||||
expect(find.byType(ArtistConcertsButton), findsOneWidget);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('artist without concerts has no badge', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
app(
|
||||
const Scaffold(
|
||||
body: ArtistConcertsButton(
|
||||
artistName: 'Example Artist',
|
||||
concerts: [],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.text('Upcoming Concerts'), findsNothing);
|
||||
expect(find.byType(TextButton), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'badge stays above artist identity without pushing actions down',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
Widget header(bool showBadge) => app(
|
||||
Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
MornyeArtistHeader(
|
||||
name: 'Example Artist',
|
||||
artwork: const SizedBox.expand(),
|
||||
showTitle: false,
|
||||
badge: showBadge
|
||||
? ArtistConcertsButton(
|
||||
artistName: 'Example Artist',
|
||||
concerts: ArtistConcert.parseList([event()]),
|
||||
)
|
||||
: null,
|
||||
actions: const [
|
||||
SizedBox.square(
|
||||
dimension: 74,
|
||||
key: ValueKey('artist-action'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpWidget(header(false));
|
||||
final actionY = tester
|
||||
.getTopLeft(find.byKey(const ValueKey('artist-action')))
|
||||
.dy;
|
||||
await tester.pumpWidget(header(true));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.getTopLeft(find.byKey(const ValueKey('artist-action'))).dy,
|
||||
actionY,
|
||||
);
|
||||
expect(
|
||||
tester.getBottomLeft(find.byType(ArtistConcertsButton)).dy,
|
||||
lessThan(
|
||||
tester
|
||||
.getTopLeft(
|
||||
find.descendant(
|
||||
of: find.byType(FlexibleSpaceBar),
|
||||
matching: find.text('Example Artist'),
|
||||
),
|
||||
)
|
||||
.dy,
|
||||
),
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'localized schedule supports large text and clears shell controls',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(320, 740);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(
|
||||
app(
|
||||
Builder(
|
||||
builder: (context) => MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
textScaler: const TextScaler.linear(2),
|
||||
padding: const EdgeInsets.only(bottom: 140),
|
||||
),
|
||||
child: ArtistConcertsScreen(
|
||||
artistName: 'Example Artist With a Long Name',
|
||||
concerts: ArtistConcert.parseList([event()]),
|
||||
),
|
||||
),
|
||||
),
|
||||
language: 'id',
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.drag(find.byType(CustomScrollView), const Offset(0, -700));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Semua Konser Mendatang'), findsOneWidget);
|
||||
await tester.scrollUntilVisible(find.text('Example City, Region'), 200);
|
||||
await tester.scrollUntilVisible(find.textContaining('21.00'), 150);
|
||||
await tester.drag(find.byType(CustomScrollView), const Offset(0, -200));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Example City, Region'), findsOneWidget);
|
||||
expect(
|
||||
tester.getBottomLeft(find.textContaining('21.00')).dy,
|
||||
lessThanOrEqualTo(600),
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user