feat(rewind): add yearly highlights and seasonal library banners

This commit is contained in:
zarzet committed 2026-10-15 17:15:32 +07:00
1 parent 318fbe714e
commit 1d53bf9301
9 files changed
+1219 -77

No files matched your search

+27
View File
@@ -7185,6 +7185,33 @@
"listeningRewindShareError": "Could not share your Rewind",
"listeningRewindNext": "Next",
"listeningRewindPrevious": "Previous",
"listeningRewindMonthlyReady": "Your last 4 weeks are ready to replay",
"listeningRewindYearTitle": "Your {year} in Music",
"@listeningRewindYearTitle": { "placeholders": { "year": { "type": "String" } } },
"listeningRewindYearEyebrow": "Year-end Rewind",
"listeningRewindYearBanner": "Your top artists, songs and moments of the year",
"listeningRewindYearIntroBody": "Everything you played this year, from your first song to your biggest month.",
"listeningRewindNumbersTitle": "Your year in numbers",
"listeningRewindSongsLabel": "Songs",
"listeningRewindArtistsLabel": "Artists",
"listeningRewindAlbumsLabel": "Albums",
"listeningRewindDaysLabel": "Days with music",
"listeningRewindDaysShare": "Music on {percent}% of your days",
"@listeningRewindDaysShare": { "placeholders": { "percent": { "type": "int" } } },
"listeningRewindMonthsTitle": "Month by month",
"listeningRewindBiggestMonthLabel": "Biggest month",
"listeningRewindBiggestMonth": "Your biggest month was {month}",
"@listeningRewindBiggestMonth": { "placeholders": { "month": { "type": "String" } } },
"listeningRewindMonthlyArtistsTitle": "Artist of the month",
"listeningRewindLoyalTitle": "Always by your side",
"listeningRewindLoyalBody": "{days, plural, =1{With you on 1 day} other{With you on {days} different days}}",
"@listeningRewindLoyalBody": { "placeholders": { "days": { "type": "int" } } },
"listeningRewindFirstSongTitle": "Where your year began",
"listeningRewindFirstSongBody": "Your most-played song on your first listening day, {date}",
"@listeningRewindFirstSongBody": { "placeholders": { "date": { "type": "String" } } },
"listeningRewindYearOutroTitle": "That was your {year}",
"@listeningRewindYearOutroTitle": { "placeholders": { "year": { "type": "String" } } },
"listeningRewindYearOutroBody": "Here's to another year of great music.",
"weeklyReleases": "New releases",
"weeklyReleasesDescription": "Recent albums and singles from your favorite artists, supplied by installed metadata extensions.",
"weeklyReleasesEmpty": "No dated releases found in this period. Add favorite artists or try a longer period.",
+21
View File
@@ -141,6 +141,27 @@
"listeningRewindShareError": "Gagal membagikan Rewind",
"listeningRewindNext": "Berikutnya",
"listeningRewindPrevious": "Sebelumnya",
"listeningRewindMonthlyReady": "4 minggu terakhirmu siap diputar ulang",
"listeningRewindYearTitle": "Musikmu di {year}",
"listeningRewindYearEyebrow": "Rewind akhir tahun",
"listeningRewindYearBanner": "Artis, lagu, dan momen terbaikmu tahun ini",
"listeningRewindYearIntroBody": "Semua yang kamu putar tahun ini, dari lagu pertama sampai bulan tersibukmu.",
"listeningRewindNumbersTitle": "Tahunmu dalam angka",
"listeningRewindSongsLabel": "Lagu",
"listeningRewindArtistsLabel": "Artis",
"listeningRewindAlbumsLabel": "Album",
"listeningRewindDaysLabel": "Hari bermusik",
"listeningRewindDaysShare": "Ada musik di {percent}% harimu",
"listeningRewindMonthsTitle": "Bulan demi bulan",
"listeningRewindBiggestMonthLabel": "Bulan tersibuk",
"listeningRewindBiggestMonth": "Bulan tersibukmu adalah {month}",
"listeningRewindMonthlyArtistsTitle": "Artis tiap bulan",
"listeningRewindLoyalTitle": "Selalu menemanimu",
"listeningRewindLoyalBody": "{days, plural, =1{Menemanimu di 1 hari} other{Menemanimu di {days} hari berbeda}}",
"listeningRewindFirstSongTitle": "Awal tahunmu",
"listeningRewindFirstSongBody": "Lagu yang paling sering diputar pada hari pertama kamu mendengarkan, {date}",
"listeningRewindYearOutroTitle": "Itulah {year}-mu",
"listeningRewindYearOutroBody": "Sampai jumpa di tahun penuh musik berikutnya.",
"weeklyReleases": "Rilis baru",
"weeklyReleasesDescription": "Album dan single terbaru dari artis favorit, melalui extension metadata yang terpasang.",
"weeklyReleasesEmpty": "Tidak ditemukan rilis dengan tanggal dalam periode ini. Tambahkan artis favorit atau coba periode lebih panjang.",
+629 -75
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'dart:ui' show ImageByteFormat, ImageFilter;
@@ -20,6 +21,7 @@ import 'package:spotiflac_android/theme/app_tokens.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/app_action_button.dart';
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
import 'package:spotiflac_android/widgets/app_content_card.dart';
import 'package:spotiflac_android/widgets/app_icon.dart';
import 'package:spotiflac_android/widgets/app_icon_button.dart';
import 'package:spotiflac_android/widgets/app_loading_indicator.dart';
@@ -32,10 +34,36 @@ final listeningRewindProvider = FutureProvider.autoDispose
await listeningRecorder.flush();
final range = ListeningRewindRange.of(period, DateTime.now());
return ListeningStatisticsStore.instance.read(
(db) => readListeningRewind(db, range),
(db) => readListeningRewind(
db,
range,
yearly: period == ListeningRewindPeriod.year,
),
);
});
/// The Rewind the Library advertises today, or null. A window without any
/// listening is not advertised, so the banner never opens an empty story.
final listeningRewindSpotlightProvider =
FutureProvider.autoDispose<ListeningRewindSpotlight?>((ref) async {
final now = DateTime.now();
final tomorrow = DateTime(now.year, now.month, now.day + 1);
final refresh = Timer(tomorrow.difference(now), ref.invalidateSelf);
ref.onDispose(refresh.cancel);
final spotlight = ListeningRewindSpotlight.at(now);
if (spotlight == null) return null;
await listeningRecorder.flush();
final range = ListeningRewindRange.of(spotlight.period, now);
final rows = await ListeningStatisticsStore.instance.read(
(db) => db.rawQuery(
'SELECT 1 FROM listening '
'WHERE day >= ? AND day < ? AND milliseconds > 0 LIMIT 1',
[listeningDay(range.start), listeningDay(range.end)],
),
);
return rows.isEmpty ? null : spotlight;
});
String listeningRewindPeriodLabel(
BuildContext context,
ListeningRewindPeriod period,
@@ -56,8 +84,11 @@ Future<void> showListeningRewind(BuildContext context) async {
ListeningRewindPeriod.halfYear: Icons.event_outlined,
ListeningRewindPeriod.year: Icons.auto_awesome,
};
// Opened from the Library tab: the sheet and the story cover the shell
// chrome instead of sliding beneath the tab bar and mini player.
final period = await showAppBottomSheet<ListeningRewindPeriod>(
context: context,
useRootNavigator: true,
title: context.l10n.listeningRewindChoosePeriod,
builder: (sheetContext) => Padding(
padding: EdgeInsets.only(bottom: sheetContext.tokens.gapLg),
@@ -76,79 +107,257 @@ Future<void> showListeningRewind(BuildContext context) async {
),
);
if (period == null || !context.mounted) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
fullscreenDialog: true,
builder: (_) => ListeningRewindScreen(period: period),
),
);
await openListeningRewind(context, period);
}
/// Statistics screen entry. Material gets a tertiary hero banner; Mornye
/// keeps a grouped row.
/// Plays the Rewind of [period] above the shell chrome.
Future<void> openListeningRewind(
BuildContext context,
ListeningRewindPeriod period,
) => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
fullscreenDialog: true,
builder: (_) => ListeningRewindScreen(period: period),
),
);
/// The permanent way in, on the listening statistics screen: a row that asks
/// for any window.
class ListeningRewindEntry extends StatelessWidget {
const ListeningRewindEntry({super.key});
@override
Widget build(BuildContext context) => SettingsGroup(
children: [
SettingsItem(
icon: Icons.auto_awesome,
showIconInMornye: true,
title: context.l10n.listeningRewind,
subtitle: context.l10n.listeningRewindDescription,
showDivider: false,
onTap: () => showListeningRewind(context),
),
],
);
}
/// The Library's seasonal Rewind banner. It shows only while
/// [listeningRewindSpotlightProvider] has a Rewind to offer and opens that
/// window directly.
class ListeningRewindBanner extends ConsumerWidget {
const ListeningRewindBanner({super.key, this.margin});
final EdgeInsetsGeometry? margin;
@override
Widget build(BuildContext context, WidgetRef ref) {
final spotlight = ref.watch(listeningRewindSpotlightProvider).value;
final child = switch (spotlight) {
null => null,
ListeningRewindSpotlight.monthly => _MonthlyRewindBanner(margin: margin),
ListeningRewindSpotlight.yearly => _YearlyRewindBanner(margin: margin),
};
// Grows in rather than shifting the list when the check completes.
return AnimatedSize(
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 280),
curve: Curves.easeOutCubic,
alignment: Alignment.topCenter,
child: child ?? const SizedBox(width: double.infinity),
);
}
}
/// The four-weekly invitation: a quiet tonal card.
class _MonthlyRewindBanner extends StatelessWidget {
const _MonthlyRewindBanner({this.margin});
final EdgeInsetsGeometry? margin;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
if (context.isMornye) {
return SettingsGroup(
children: [
SettingsItem(
icon: Icons.auto_awesome,
showIconInMornye: true,
title: l10n.listeningRewind,
subtitle: l10n.listeningRewindDescription,
showDivider: false,
onTap: () => showListeningRewind(context),
),
],
);
}
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Material(
color: colors.tertiaryContainer,
final mornye = context.isMornye;
// Mornye keeps its neutral control fill and accent tint.
final foreground = mornye ? colors.onSurface : colors.onTertiaryContainer;
final accent = mornye ? colors.primary : colors.tertiary;
final onAccent = mornye ? colors.onPrimary : colors.onTertiary;
return AppContentCard(
margin: margin,
color: colors.tertiaryContainer,
shape: RoundedRectangleBorder(
borderRadius: context.tokens.borderRadiusCard,
),
onTap: () => openListeningRewind(context, ListeningRewindPeriod.weeks),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
AppShapeBadge(
kind: AppShapes.badge,
size: 48,
color: accent,
child: AppIcon(Icons.auto_awesome, color: onAccent),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.listeningRewind,
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
color: foreground,
),
),
const SizedBox(height: 2),
Text(
l10n.listeningRewindMonthlyReady,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: textTheme.bodyMedium?.copyWith(color: foreground),
),
],
),
),
AppIcon(Icons.chevron_right, color: foreground),
],
),
),
);
}
}
/// The once-a-year invitation: a gradient poster with the year set large
/// behind the title and expressive shapes scattered around it.
class _YearlyRewindBanner extends StatelessWidget {
const _YearlyRewindBanner({this.margin});
final EdgeInsetsGeometry? margin;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final year = '${DateTime.now().year}';
final foreground = colors.onPrimary;
final radius = context.isMornye
? BorderRadius.circular(28)
: context.tokens.borderRadiusCard;
return Padding(
padding: margin ?? EdgeInsets.zero,
child: Material(
borderRadius: radius,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => showListeningRewind(context),
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
child: Ink(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [colors.primary, colors.tertiary],
),
),
child: InkWell(
onTap: () =>
openListeningRewind(context, ListeningRewindPeriod.year),
child: Stack(
children: [
AppShapeBadge(
kind: AppShapes.badge,
size: 56,
color: colors.tertiary,
child: AppIcon(Icons.auto_awesome, color: colors.onTertiary),
// The year as a giant, faded watermark.
PositionedDirectional(
end: -8,
bottom: -28,
child: ExcludeSemantics(
child: Text(
year,
style: textTheme.displayLarge?.copyWith(
fontSize: 112,
height: 1,
fontWeight: FontWeight.w900,
color: foreground.withValues(alpha: 0.16),
),
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
PositionedDirectional(
end: 96,
top: -18,
child: AppShapeBadge(
kind: AppShapes.badge,
size: 64,
color: foreground.withValues(alpha: 0.12),
child: const SizedBox.shrink(),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 20),
child: Row(
children: [
Text(
l10n.listeningRewind,
style: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
color: colors.onTertiaryContainer,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
AppIcon(
Icons.auto_awesome,
size: 18,
color: foreground,
),
const SizedBox(width: 6),
Text(
l10n.listeningRewindYearEyebrow,
style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 0.4,
color: foreground,
),
),
],
),
const SizedBox(height: 10),
Text(
l10n.listeningRewindYearTitle(year),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
color: foreground,
),
),
const SizedBox(height: 4),
Text(
l10n.listeningRewindYearBanner,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: textTheme.bodyMedium?.copyWith(
color: foreground.withValues(alpha: 0.9),
),
),
],
),
),
const SizedBox(height: 2),
Text(
l10n.listeningRewindDescription,
style: textTheme.bodyMedium?.copyWith(
color: colors.onTertiaryContainer,
const SizedBox(width: 12),
DecoratedBox(
decoration: ShapeDecoration(
color: foreground,
shape: const CircleBorder(),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: AppIcon(
Icons.play_arrow_rounded,
color: colors.primary,
),
),
),
],
),
),
AppIcon(Icons.chevron_right, color: colors.onTertiaryContainer),
],
),
),
@@ -238,30 +447,47 @@ class _CloseButton extends StatelessWidget {
enum _Slide {
intro,
minutes,
yearNumbers,
months,
topArtist,
artists,
monthlyArtists,
loyal,
topTrack,
tracks,
album,
firstSong,
discoveries,
rhythm,
replay,
outro,
}
List<_Slide> _slidesFor(ListeningRewind rewind) => [
_Slide.intro,
_Slide.minutes,
if (rewind.topArtists.isNotEmpty) _Slide.topArtist,
if (rewind.topArtists.length >= 3) _Slide.artists,
if (rewind.summary.tracks.isNotEmpty) _Slide.topTrack,
if (rewind.summary.tracks.length >= 3) _Slide.tracks,
if (rewind.topAlbums.isNotEmpty) _Slide.album,
if (rewind.newArtists.isNotEmpty) _Slide.discoveries,
if (rewind.peakDay != null) _Slide.rhythm,
if (rewind.replay != null) _Slide.replay,
_Slide.outro,
];
/// A calendar-year Rewind adds its year-in-review slides between the regular
/// highlights.
List<_Slide> _slidesFor(ListeningRewind rewind) {
final year = rewind.year;
return [
_Slide.intro,
_Slide.minutes,
if (year != null) _Slide.yearNumbers,
if (year != null && rewind.monthlyMilliseconds.length >= 2) _Slide.months,
if (rewind.topArtists.isNotEmpty) _Slide.topArtist,
if (rewind.topArtists.length >= 3) _Slide.artists,
if (year != null && year.monthlyTopArtists.length >= 2)
_Slide.monthlyArtists,
if (year != null && year.loyalArtist != null && year.loyalArtistDays > 1)
_Slide.loyal,
if (rewind.summary.tracks.isNotEmpty) _Slide.topTrack,
if (rewind.summary.tracks.length >= 3) _Slide.tracks,
if (rewind.topAlbums.isNotEmpty) _Slide.album,
if (year?.firstPlay != null) _Slide.firstSong,
if (rewind.newArtists.isNotEmpty) _Slide.discoveries,
if (rewind.peakDay != null) _Slide.rhythm,
if (rewind.replay != null) _Slide.replay,
_Slide.outro,
];
}
/// Full-screen story of Rewind slides. Slides advance on their own unless the
/// system asks for reduced motion; tapping the left third goes back, the rest
@@ -611,11 +837,19 @@ class _RewindSlide extends StatelessWidget {
child: Padding(
// Room for the progress bar above and the share button below.
padding: const EdgeInsets.fromLTRB(28, 64, 28, 88),
child: DefaultTextStyle.merge(
style: TextStyle(color: palette.foreground),
child: IconTheme.merge(
data: IconThemeData(color: palette.foreground),
child: _content(context),
child: LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
primary: false,
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: DefaultTextStyle.merge(
style: TextStyle(color: palette.foreground),
child: IconTheme.merge(
data: IconThemeData(color: palette.foreground),
child: _content(context),
),
),
),
),
),
),
@@ -633,9 +867,27 @@ class _RewindSlide extends StatelessWidget {
l10n.listeningStatsMinutes((milliseconds / 60000).round());
final tracks = rewind.summary.tracks;
final artwork = rewind.summary.artistArtwork;
final yearDetails = rewind.year;
final yearLabel = '${rewind.range.start.year}';
String monthName(int month) => DateFormat.MMMM(
locale,
).format(DateTime(rewind.range.start.year, month));
switch (slide) {
case _Slide.intro:
if (yearDetails != null) {
return _Column(
center: true,
children: [
_Eyebrow(l10n.listeningRewindYearEyebrow),
_Headline(
l10n.listeningRewindYearTitle(yearLabel),
size: _Size.huge,
),
_Body(l10n.listeningRewindYearIntroBody),
],
);
}
return _Column(
center: true,
children: [
@@ -649,6 +901,92 @@ class _RewindSlide extends StatelessWidget {
),
],
);
case _Slide.yearNumbers:
return _Column(
children: [
_Headline(l10n.listeningRewindNumbersTitle),
_StatGrid(
stats: [
(tracks.length, l10n.listeningRewindSongsLabel),
(rewind.topArtists.length, l10n.listeningRewindArtistsLabel),
(rewind.topAlbums.length, l10n.listeningRewindAlbumsLabel),
(rewind.activeDays, l10n.listeningRewindDaysLabel),
],
format: number.format,
),
// "0% of your days" reads as a mistake, so tiny shares stay out.
if (rewind.activeDayShare >= 1)
Align(
alignment: AlignmentDirectional.centerStart,
child: _Pill(
l10n.listeningRewindDaysShare(rewind.activeDayShare),
color: palette.foreground,
),
),
],
);
case _Slide.months:
final biggest = rewind.biggestMonth!;
return _Column(
children: [
_Headline(l10n.listeningRewindMonthsTitle),
_MonthChart(
milliseconds: rewind.monthlyMilliseconds,
highlight: biggest,
year: rewind.range.start.year,
accent: palette.foreground,
),
_Body(l10n.listeningRewindBiggestMonth(monthName(biggest))),
],
);
case _Slide.monthlyArtists:
final months = yearDetails!.monthlyTopArtists.keys.toList()..sort();
return _Column(
children: [
_Headline(l10n.listeningRewindMonthlyArtistsTitle),
_MonthArtists(
months: [
for (final month in months)
(
DateFormat.MMM(
locale,
).format(DateTime(rewind.range.start.year, month)),
yearDetails.monthlyTopArtists[month]!,
),
],
artwork: artwork,
),
],
);
case _Slide.loyal:
final name = yearDetails!.loyalArtist!;
return _Column(
center: true,
children: [
_Eyebrow(l10n.listeningRewindLoyalTitle),
_Artwork(uri: artwork[name], size: 200, artist: true),
_Headline(name, size: _Size.large),
_Pill(
l10n.listeningRewindLoyalBody(yearDetails.loyalArtistDays),
color: palette.foreground,
),
],
);
case _Slide.firstSong:
final first = yearDetails!.firstPlay!;
return _Column(
center: true,
children: [
_Eyebrow(l10n.listeningRewindFirstSongTitle),
_Artwork(uri: first.track.artwork, size: 220),
_Headline(first.track.title, size: _Size.large),
_Body(first.track.artist),
_Pill(
l10n.listeningRewindFirstSongBody(shortDate.format(first.day)),
color: palette.foreground,
),
],
);
case _Slide.minutes:
final change = rewind.changePercent;
return _Column(
@@ -807,10 +1145,20 @@ class _RewindSlide extends StatelessWidget {
],
);
case _Slide.outro:
final biggestMonth = rewind.biggestMonth;
return _Column(
children: [
_Headline(l10n.listeningRewindOutroTitle, size: _Size.large),
_Body(l10n.listeningRewindOutroBody),
_Headline(
yearDetails != null
? l10n.listeningRewindYearOutroTitle(yearLabel)
: l10n.listeningRewindOutroTitle,
size: _Size.large,
),
_Body(
yearDetails != null
? l10n.listeningRewindYearOutroBody
: l10n.listeningRewindOutroBody,
),
_Fact(
label: l10n.listeningRewindMinutesTitle,
value:
@@ -827,6 +1175,11 @@ class _RewindSlide extends StatelessWidget {
label: l10n.listeningRewindTopTrackTitle,
value: tracks.first.track.title,
),
if (yearDetails != null && biggestMonth != null)
_Fact(
label: l10n.listeningRewindBiggestMonthLabel,
value: monthName(biggestMonth),
),
if (rewind.longestStreak > 1)
_Fact(
label: l10n.listeningRewindStreak,
@@ -840,8 +1193,13 @@ class _RewindSlide extends StatelessWidget {
/// Artwork the Mornye backdrop shows behind [slide].
String? _slideArtwork(_Slide slide, ListeningRewind rewind) => switch (slide) {
_Slide.topArtist || _Slide.artists || _Slide.discoveries =>
_Slide.topArtist ||
_Slide.artists ||
_Slide.monthlyArtists ||
_Slide.discoveries =>
rewind.summary.artistArtwork[rewind.topArtists.first.key],
_Slide.loyal => rewind.summary.artistArtwork[rewind.year?.loyalArtist],
_Slide.firstSong => rewind.year?.firstPlay?.track.artwork,
_Slide.album => rewind.topAlbums.first.artwork,
_Slide.replay => rewind.replay?.track.artwork,
_ => rewind.summary.tracks.firstOrNull?.track.artwork,
@@ -1118,10 +1476,11 @@ class _Reveal extends StatelessWidget {
}
class _CountUp extends StatelessWidget {
const _CountUp({required this.value, required this.format});
const _CountUp({required this.value, required this.format, this.fontSize});
final int value;
final String Function(num) format;
final double? fontSize;
@override
Widget build(BuildContext context) {
@@ -1138,7 +1497,7 @@ class _CountUp extends StatelessWidget {
child: Text(
format(current.round()),
style: Theme.of(context).textTheme.displayLarge?.copyWith(
fontSize: 96,
fontSize: fontSize ?? 96,
height: 1,
fontWeight: FontWeight.w800,
color: DefaultTextStyle.of(context).style.color,
@@ -1333,6 +1692,201 @@ class _RankedRow extends StatelessWidget {
}
}
/// Big counted-up figures in a two-column grid.
class _StatGrid extends StatelessWidget {
const _StatGrid({required this.stats, required this.format});
final List<(int, String)> stats;
final String Function(num) format;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final color = DefaultTextStyle.of(context).style.color;
Widget stat((int, String) entry) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_CountUp(value: entry.$1, format: format, fontSize: 56),
Text(
entry.$2,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: color?.withValues(alpha: 0.8),
),
),
],
);
return Column(
children: [
for (var i = 0; i < stats.length; i += 2) ...[
if (i > 0) const SizedBox(height: 20),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: stat(stats[i])),
const SizedBox(width: 16),
Expanded(
child: i + 1 < stats.length
? stat(stats[i + 1])
: const SizedBox.shrink(),
),
],
),
],
],
);
}
}
/// Twelve bars of monthly listening; [highlight] stands out at full color.
class _MonthChart extends StatelessWidget {
const _MonthChart({
required this.milliseconds,
required this.highlight,
required this.year,
required this.accent,
});
final Map<int, int> milliseconds;
final int highlight;
final int year;
final Color accent;
@override
Widget build(BuildContext context) {
final locale = Localizations.localeOf(context).toLanguageTag();
// Stand-alone narrow month names (J, F, M...).
final narrow = DateFormat('LLLLL', locale);
final peak = milliseconds.values.fold(0, math.max);
final labelStyle = Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
color: accent.withValues(alpha: 0.8),
);
final reduced = MediaQuery.disableAnimationsOf(context);
return ExcludeSemantics(
child: SizedBox(
height: 180,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (var month = 1; month <= 12; month++) ...[
if (month > 1) const SizedBox(width: 6),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Expanded(
child: Align(
alignment: Alignment.bottomCenter,
// Bars grow from the baseline, the peak month last.
child: TweenAnimationBuilder<double>(
tween: Tween(begin: reduced ? 1 : 0, end: 1),
duration: reduced
? Duration.zero
: Duration(milliseconds: 600 + month * 60),
curve: Curves.easeOutCubic,
builder: (context, t, _) => FractionallySizedBox(
heightFactor:
peak <= 0 || (milliseconds[month] ?? 0) <= 0
? 0
: math.max(
0.04,
(milliseconds[month] ?? 0) / peak,
) *
t,
child: DecoratedBox(
decoration: ShapeDecoration(
color: month == highlight
? accent
: accent.withValues(alpha: 0.3),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const SizedBox(width: double.infinity),
),
),
),
),
),
const SizedBox(height: 6),
Text(
narrow.format(DateTime(year, month)),
style: labelStyle,
),
],
),
),
],
],
),
),
);
}
}
/// Each month beside the artist heard longest that month, in two columns.
class _MonthArtists extends StatelessWidget {
const _MonthArtists({required this.months, required this.artwork});
/// Short month name and artist, in calendar order.
final List<(String, String)> months;
final Map<String, String> artwork;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final color = DefaultTextStyle.of(context).style.color;
Widget row((String, String) entry) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
children: [
_Artwork(uri: artwork[entry.$2], size: 36, artist: true),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.$1,
style: textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w700,
color: color?.withValues(alpha: 0.7),
),
),
Text(
entry.$2,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
color: color,
),
),
],
),
),
],
),
);
final half = (months.length / 2).ceil();
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(children: [for (final e in months.take(half)) row(e)]),
),
const SizedBox(width: 16),
Expanded(
child: Column(children: [for (final e in months.skip(half)) row(e)]),
),
],
);
}
}
class _Fact extends StatelessWidget {
const _Fact({required this.label, required this.value, this.detail});
@@ -72,6 +72,8 @@ class _ListeningStatisticsScreenState
try {
await listeningRecorder.clear(ListeningStatisticsStore.instance.clear);
ref.invalidate(listeningSummaryProvider);
ref.invalidate(listeningRewindProvider);
ref.invalidate(listeningRewindSpotlightProvider);
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
+18
View File
@@ -5,7 +5,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/library_browse_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
import 'package:spotiflac_android/screens/listening_rewind_screen.dart';
import 'package:spotiflac_android/screens/local_album_screen.dart';
import 'package:spotiflac_android/services/album_completeness.dart';
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
@@ -392,6 +394,22 @@ class _MornyeLibraryScreenState extends ConsumerState<MornyeLibraryScreen> {
],
),
),
// The seasonal Rewind, beside the listening history it replays.
if (_overview &&
_query.isEmpty &&
ref.watch(
settingsProvider.select((s) => s.listeningStatisticsEnabled),
))
SliverToBoxAdapter(
child: ListeningRewindBanner(
margin: EdgeInsets.fromLTRB(
24 + listInset,
0,
24 + listInset,
16,
),
),
),
if (_overview)
SliverToBoxAdapter(
child: Padding(
+1
View File
@@ -69,6 +69,7 @@ import 'package:spotiflac_android/services/local_track_redownload_service.dart';
import 'package:spotiflac_android/services/batch_track_actions.dart';
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/screens/listening_rewind_screen.dart';
import 'package:spotiflac_android/screens/favorite_artists_screen.dart';
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
+15
View File
@@ -331,6 +331,21 @@ extension _QueueTabFilterWidgets on _QueueTabState {
if (rootLibrary && (filterMode == 'albums' || filterMode == 'singles'))
SliverToBoxAdapter(child: _buildAlbumsModeToggle(context)),
// Rewind lives with the listening history it replays.
if (rootLibrary &&
filterMode == 'all' &&
_searchQuery.isEmpty &&
!context.isMornye &&
!_isSelectionMode &&
ref.watch(
settingsProvider.select((s) => s.listeningStatisticsEnabled),
))
SliverToBoxAdapter(
child: ListeningRewindBanner(
margin: EdgeInsets.fromLTRB(edge, 8, edge, 4),
),
),
if (totalTrackCount > 0 && filterMode == 'all')
SliverToBoxAdapter(
child: _countHeaderRow(
+155 -2
View File
@@ -15,6 +15,37 @@ enum ListeningRewindPeriod {
final int days;
}
/// The Rewind the Library advertises right now, if any.
enum ListeningRewindSpotlight {
/// The last four weeks, offered for a week at the start of every four-week
/// cycle.
monthly(ListeningRewindPeriod.weeks),
/// The calendar year, offered through December.
yearly(ListeningRewindPeriod.year);
const ListeningRewindSpotlight(this.period);
final ListeningRewindPeriod period;
static const cycleDays = 28;
static const visibleDays = 7;
/// December shows the year; otherwise the monthly Rewind appears during the
/// first [visibleDays] of each [cycleDays] cycle and is hidden in between.
static ListeningRewindSpotlight? at(DateTime now) {
if (now.month == DateTime.december) return yearly;
// Cycles start on Mondays counted from Monday 1 January 2024. UTC dates
// count calendar days, immune to daylight-saving hour shifts.
final days = DateTime.utc(
now.year,
now.month,
now.day,
).difference(DateTime.utc(2024)).inDays;
return days % cycleDays < visibleDays ? monthly : null;
}
}
/// Local calendar days. Both ends are exclusive of [end] and [previousEnd].
///
/// The previous window is the same span immediately before a rolling window,
@@ -80,6 +111,32 @@ class ListeningReplay {
final int plays;
}
/// The first song heard in a window: the most played on its first day.
class ListeningFirstPlay {
const ListeningFirstPlay(this.track, this.day);
final ListeningTrack track;
final DateTime day;
}
/// Year-in-review extras, read only for a calendar-year Rewind.
class ListeningYearDetails {
const ListeningYearDetails({
this.monthlyTopArtists = const {},
this.loyalArtist,
this.loyalArtistDays = 0,
this.firstPlay,
});
/// Month (1 to 12) to the artist heard longest that month.
final Map<int, String> monthlyTopArtists;
/// The artist heard on the most different days, and on how many.
final String? loyalArtist;
final int loyalArtistDays;
final ListeningFirstPlay? firstPlay;
}
/// Highlights of one Rewind window, derived from the daily listening table.
class ListeningRewind {
const ListeningRewind({
@@ -94,6 +151,8 @@ class ListeningRewind {
this.peakDayMilliseconds = 0,
this.favoriteWeekday,
this.replay,
this.monthlyMilliseconds = const {},
this.year,
});
/// Songs replayed fewer times than this in one day are not highlighted.
@@ -109,6 +168,7 @@ class ListeningRewind {
required int previousMilliseconds,
Set<String>? newArtistNames,
ListeningReplay? replay,
ListeningYearDetails? year,
}) {
final artists = summary.artists.entries.where((e) => e.value > 0).toList()
..sort((a, b) => b.value.compareTo(a.value));
@@ -140,6 +200,7 @@ class ListeningRewind {
DateTime? peakDay;
var peakMilliseconds = 0;
final weekdays = <int, int>{};
final months = <int, int>{};
var longestStreak = 0;
var streak = 0;
DateTime? previous;
@@ -150,6 +211,7 @@ class ListeningRewind {
peakMilliseconds = time;
}
weekdays[day.weekday] = (weekdays[day.weekday] ?? 0) + time;
months[day.month] = (months[day.month] ?? 0) + time;
final expected = previous == null
? null
: DateTime(previous.year, previous.month, previous.day + 1);
@@ -181,6 +243,8 @@ class ListeningRewind {
peakDayMilliseconds: peakMilliseconds,
favoriteWeekday: favoriteWeekday,
replay: replay != null && replay.plays >= minimumReplays ? replay : null,
monthlyMilliseconds: months,
year: year,
);
}
@@ -202,8 +266,35 @@ class ListeningRewind {
final int longestStreak;
final ListeningReplay? replay;
/// Month (1 to 12) to listening time. Only meaningful within one year.
final Map<int, int> monthlyMilliseconds;
/// Present for a calendar-year Rewind.
final ListeningYearDetails? year;
bool get isEmpty => summary.milliseconds <= 0;
/// The month with the most listening, or null without any.
int? get biggestMonth {
int? best;
for (final entry in monthlyMilliseconds.entries) {
if (best == null || entry.value > monthlyMilliseconds[best]!) {
best = entry.key;
}
}
return best;
}
/// Calendar days the window covers, up to today. Rounded so a
/// daylight-saving day of 23 or 25 hours still counts once.
int get dayCount => (range.end.difference(range.start).inHours / 24).round();
/// Share of the window's days with any listening, in percent.
int get activeDayShare =>
dayCount <= 0 ? 0 : (activeDays * 100 / dayCount).round().clamp(0, 100);
int get activeDays => summary.days.values.where((time) => time > 0).length;
int get minutes => (summary.milliseconds / 60000).round();
/// Signed change from the previous window, or null without earlier
@@ -221,11 +312,71 @@ class ListeningRewind {
: (topArtists.first.value * 100 / summary.milliseconds).round();
}
/// Reads one Rewind window, aggregating in SQLite like the statistics screen.
Future<ListeningRewind> readListeningRewind(
/// Year-in-review extras for the window, aggregated in SQLite.
Future<ListeningYearDetails> readListeningYearDetails(
DatabaseExecutor db,
ListeningRewindRange range,
) async {
final window = [listeningDay(range.start), listeningDay(range.end)];
final monthRows = await db.rawQuery('''
SELECT substr(day, 6, 2) AS month, artist,
SUM(milliseconds) AS milliseconds
FROM listening WHERE day >= ? AND day < ?
GROUP BY month, artist
''', window);
final monthly = <int, (String, int)>{};
for (final row in monthRows) {
final month = int.parse(row['month'] as String);
final time = row['milliseconds'] as int;
if (time <= 0) continue;
final best = monthly[month];
if (best == null || time > best.$2) {
monthly[month] = (row['artist'] as String, time);
}
}
final loyal = await db.rawQuery('''
SELECT artist, COUNT(DISTINCT day) AS days
FROM listening WHERE day >= ? AND day < ? AND milliseconds > 0
GROUP BY artist ORDER BY days DESC, SUM(milliseconds) DESC LIMIT 1
''', window);
final first = await db.rawQuery('''
SELECT day, track_key, title, artist, album, artwork
FROM listening
WHERE milliseconds > 0 AND day = (
SELECT MIN(day) FROM listening
WHERE day >= ? AND day < ? AND milliseconds > 0
)
ORDER BY plays DESC, milliseconds DESC LIMIT 1
''', window);
final firstRow = first.firstOrNull;
return ListeningYearDetails(
monthlyTopArtists: {
for (final entry in monthly.entries) entry.key: entry.value.$1,
},
loyalArtist: loyal.firstOrNull?['artist'] as String?,
loyalArtistDays: loyal.firstOrNull?['days'] as int? ?? 0,
firstPlay: firstRow == null
? null
: ListeningFirstPlay(
ListeningTrack(
key: firstRow['track_key'] as String,
title: firstRow['title'] as String,
artist: firstRow['artist'] as String,
album: firstRow['album'] as String,
artwork: firstRow['artwork'] as String?,
),
DateTime.parse(firstRow['day'] as String),
),
);
}
/// Reads one Rewind window, aggregating in SQLite like the statistics screen.
/// [yearly] adds the year-in-review extras.
Future<ListeningRewind> readListeningRewind(
DatabaseExecutor db,
ListeningRewindRange range, {
bool yearly = false,
}) async {
final summary = await readListeningSummary(db, range.start, range.end);
final start = listeningDay(range.start);
final end = listeningDay(range.end);
@@ -259,7 +410,9 @@ Future<ListeningRewind> readListeningRewind(
[start, end],
);
final replay = replays.isEmpty ? null : replays.first;
final year = yearly ? await readListeningYearDetails(db, range) : null;
return ListeningRewind.compute(
year: year,
range: range,
summary: summary,
previousMilliseconds: previous.first['milliseconds'] as int? ?? 0,
+351
View File
@@ -7,6 +7,8 @@ import 'package:spotiflac_android/services/listening_rewind.dart';
import 'package:spotiflac_android/services/listening_statistics.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'support/sqlite_process_database.dart';
Map<String, Object?> _row(
String day,
String key, {
@@ -45,7 +47,159 @@ ListeningRewind _sample({Set<String>? newArtists, ListeningReplay? replay}) =>
replay: replay,
);
final _yearRange = ListeningRewindRange.of(
ListeningRewindPeriod.year,
DateTime(2026, 10, 14, 21),
);
ListeningRewind _yearSample() => ListeningRewind.compute(
range: _yearRange,
summary: ListeningSummary.fromRows([
_row('2026-01-02', 'a', minutes: 30, plays: 3),
_row('2026-01-03', 'b', artist: 'Other', minutes: 20),
_row('2026-03-10', 'c', artist: 'Third', album: 'Else', minutes: 90),
_row('2026-10-01', 'a', minutes: 40, plays: 4),
]),
previousMilliseconds: 0,
year: ListeningYearDetails(
monthlyTopArtists: const {1: 'Artist', 3: 'Third', 10: 'Artist'},
loyalArtist: 'Artist',
loyalArtistDays: 2,
firstPlay: ListeningFirstPlay(
const ListeningTrack(
key: 'a',
title: 'Song a',
artist: 'Artist',
album: 'Album',
),
DateTime(2026, 1, 2),
),
),
);
void main() {
group('yearly database aggregates', () {
late SqliteProcessDatabase db;
setUp(() async {
db = await SqliteProcessDatabase.open();
await db.execute('''
CREATE TABLE listening (
day TEXT, track_key TEXT, title TEXT, artist TEXT, album TEXT,
artwork TEXT, milliseconds INTEGER, plays INTEGER,
PRIMARY KEY(day, track_key)
)
''');
});
tearDown(() async => db.close());
test(
'yearly highlights exclude unplayed rows and out-of-window history',
() async {
final rows = [
_row('2025-12-31', 'old', minutes: 1000, plays: 50),
_row('2026-01-01', 'empty-day', minutes: 0, plays: 99),
_row('2026-01-02', 'unplayed', minutes: 0, plays: 99),
_row('2026-01-02', 'first', minutes: 1, plays: 2),
_row('2026-01-02', 'longer', artist: 'Other', minutes: 5),
_row('2026-03-10', 'third', artist: 'Third', minutes: 6),
_row('2026-10-01', 'first', minutes: 1),
_row('2026-10-15', 'future', minutes: 1000, plays: 50),
];
for (final row in rows) {
await db.rawInsert(
'INSERT INTO listening '
'(day, track_key, title, artist, album, artwork, milliseconds, plays) '
'VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[
row['day'],
row['track_key'],
row['title'],
row['artist'],
row['album'],
row['artwork'],
row['milliseconds'],
row['plays'],
],
);
}
final rewind = await readListeningRewind(db, _yearRange, yearly: true);
expect(rewind.year?.monthlyTopArtists, {
1: 'Other',
3: 'Third',
10: 'Artist',
});
expect(rewind.year?.loyalArtist, 'Artist');
expect(rewind.year?.loyalArtistDays, 2);
expect(rewind.year?.firstPlay?.track.key, 'first');
expect(rewind.year?.firstPlay?.day, DateTime(2026, 1, 2));
expect(rewind.activeDays, 3);
expect(rewind.activeDayShare, 1);
expect(rewind.monthlyMilliseconds, {
1: 6 * 60000,
3: 6 * 60000,
10: 60000,
});
},
);
test('empty history has no invented yearly highlights', () async {
final rewind = await readListeningRewind(db, _yearRange, yearly: true);
expect(rewind.isEmpty, isTrue);
expect(rewind.year?.monthlyTopArtists, isEmpty);
expect(rewind.year?.loyalArtist, isNull);
expect(rewind.year?.firstPlay, isNull);
expect(rewind.activeDays, 0);
});
});
test('the Library offers the monthly Rewind one week in four', () {
ListeningRewindSpotlight? at(int year, int month, int day) =>
ListeningRewindSpotlight.at(DateTime(year, month, day, 18));
// Cycles start on Monday 1 January 2024 and repeat every 28 days.
expect(at(2024, 1, 1), ListeningRewindSpotlight.monthly);
expect(at(2024, 1, 7), ListeningRewindSpotlight.monthly);
expect(at(2024, 1, 8), isNull);
expect(at(2024, 1, 28), isNull);
expect(at(2024, 1, 29), ListeningRewindSpotlight.monthly);
// 5 October 2026 starts a cycle; the 15th falls between offers.
expect(at(2026, 10, 5), ListeningRewindSpotlight.monthly);
expect(at(2026, 10, 11), ListeningRewindSpotlight.monthly);
expect(at(2026, 10, 15), isNull);
expect(at(2026, 11, 2), ListeningRewindSpotlight.monthly);
});
test('December offers the year instead of the monthly Rewind', () {
expect(
ListeningRewindSpotlight.at(DateTime(2026, 12)),
ListeningRewindSpotlight.yearly,
);
expect(
ListeningRewindSpotlight.at(DateTime(2026, 12, 31, 23)),
ListeningRewindSpotlight.yearly,
);
expect(ListeningRewindSpotlight.yearly.period, ListeningRewindPeriod.year);
expect(
ListeningRewindSpotlight.monthly.period,
ListeningRewindPeriod.weeks,
);
});
test('a year groups listening by month and counts days with music', () {
final rewind = _yearSample();
expect(rewind.monthlyMilliseconds, {
1: 50 * 60000,
3: 90 * 60000,
10: 40 * 60000,
});
expect(rewind.biggestMonth, 3);
// 1 January to 14 October 2026.
expect(rewind.dayCount, 287);
// Four days with music out of 287.
expect(rewind.activeDayShare, 1);
expect(rewind.year?.loyalArtist, 'Artist');
});
test('rolling and yearly windows compare with the span before them', () {
expect(_range.start, DateTime(2026, 9, 17));
expect(_range.end, DateTime(2026, 10, 15));
@@ -161,6 +315,203 @@ void main() {
});
}
testWidgets('a year-end Rewind adds its year-in-review slides', (
tester,
) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 2.5;
addTearDown(tester.view.reset);
await tester.pumpWidget(
ProviderScope(
overrides: [
listeningRewindProvider.overrideWith(
(ref, period) async => _yearSample(),
),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
builder: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(disableAnimations: true),
child: child!,
),
home: const ListeningRewindScreen(period: ListeningRewindPeriod.year),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Your 2026 in Music'), findsOneWidget);
final story = tester.getRect(find.byType(PageView));
final seen = <String>{};
const expected = [
'Your year in numbers',
'Month by month',
'Your biggest month was March',
'Artist of the month',
'Always by your side',
'With you on 2 different days',
'Where your year began',
'That was your 2026',
];
for (var i = 0; i < 20; i++) {
for (final text in expected) {
if (find.text(text).evaluate().isNotEmpty) seen.add(text);
}
await tester.tapAt(Offset(story.right - 40, story.center.dy));
await tester.pumpAndSettle();
}
expect(seen, containsAll(expected));
expect(tester.takeException(), isNull);
});
for (final spotlight in ListeningRewindSpotlight.values) {
testWidgets('the Library banner follows the $spotlight spotlight', (
tester,
) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
listeningRewindSpotlightProvider.overrideWith(
(ref) async => spotlight,
),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: ListeningRewindBanner()),
),
),
);
await tester.pumpAndSettle();
final year = DateTime.now().year;
expect(
find.text('Your $year in Music'),
spotlight == ListeningRewindSpotlight.yearly
? findsOneWidget
: findsNothing,
);
expect(
find.text('Your last 4 weeks are ready to replay'),
spotlight == ListeningRewindSpotlight.monthly
? findsOneWidget
: findsNothing,
);
});
}
testWidgets('months without listening have no activity bars', (tester) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 2.5;
addTearDown(tester.view.reset);
await tester.pumpWidget(
ProviderScope(
overrides: [
listeningRewindProvider.overrideWith(
(ref, period) async => _yearSample(),
),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
builder: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(disableAnimations: true),
child: child!,
),
home: const ListeningRewindScreen(period: ListeningRewindPeriod.year),
),
),
);
await tester.pumpAndSettle();
final story = tester.getRect(find.byType(PageView));
for (var i = 0; i < 3; i++) {
await tester.tapAt(Offset(story.right - 40, story.center.dy));
await tester.pumpAndSettle();
}
expect(find.text('Month by month'), findsOneWidget);
final bars = tester
.widgetList<FractionallySizedBox>(find.byType(FractionallySizedBox))
.where(
(widget) => widget.heightFactor != null && widget.widthFactor == null,
);
expect(bars.length, 12);
expect(bars.where((widget) => widget.heightFactor == 0).length, 9);
expect(tester.takeException(), isNull);
});
for (final mornye in [false, true]) {
testWidgets('yearly slides fit landscape with large text ($mornye)', (
tester,
) async {
tester.view.physicalSize = const Size(780, 360);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final sample = _yearSample();
final rewind = ListeningRewind.compute(
range: sample.range,
summary: sample.summary,
previousMilliseconds: 0,
year: ListeningYearDetails(
monthlyTopArtists: {
for (var month = 1; month <= 12; month++) month: 'Artist $month',
},
loyalArtist: sample.year?.loyalArtist,
loyalArtistDays: sample.year!.loyalArtistDays,
firstPlay: sample.year?.firstPlay,
),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
listeningRewindProvider.overrideWith((ref, period) async => rewind),
],
child: MaterialApp(
theme: mornye ? MornyeTheme.build(Brightness.dark) : ThemeData(),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
builder: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(
disableAnimations: true,
textScaler: const TextScaler.linear(1.6),
),
child: child!,
),
home: const ListeningRewindScreen(
period: ListeningRewindPeriod.year,
),
),
),
);
await tester.pumpAndSettle();
final story = tester.getRect(find.byType(PageView));
for (var i = 0; i < 20; i++) {
expect(tester.takeException(), isNull);
await tester.tapAt(Offset(story.right - 40, story.center.dy));
await tester.pumpAndSettle();
}
expect(find.text('That was your 2026'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
testWidgets('no spotlight leaves no banner', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
listeningRewindSpotlightProvider.overrideWith((ref) async => null),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: ListeningRewindBanner()),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Rewind'), findsNothing);
expect(find.byType(InkWell), findsNothing);
});
testWidgets('empty windows explain how to fill a Rewind', (tester) async {
await tester.pumpWidget(
ProviderScope(