mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-22 07:18:38 +02:00
feat(rewind): add yearly highlights and seasonal library banners
This commit is contained in:
1 parent
318fbe714e
commit
1d53bf9301
9 files changed
+1219
-77
No files matched your search
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user