feat(listening): add Rewind highlights and refresh statistics

This commit is contained in:
zarzet committed 2026-10-14 23:05:26 +07:00
1 parent 97d8ee3695
commit 08266c7369
7 files changed
+2104 -68

No files matched your search

+53
View File
@@ -7132,6 +7132,59 @@
"listeningStatsClearError": "Could not clear listening statistics. Please try again.",
"listeningStatsClearDescription": "Delete all listening statistics on this device? Your music and playlists will be kept.",
"libraryArtistsEmpty": "Artists appear here once you download music or scan a local library.",
"listeningRewind": "Rewind",
"listeningRewindDescription": "Relive the highlights of your recent weeks and months",
"listeningRewindChoosePeriod": "Choose a period",
"listeningRewindLastWeeks": "Last {weeks} weeks",
"@listeningRewindLastWeeks": { "placeholders": { "weeks": { "type": "int" } } },
"listeningRewindLastMonths": "Last {months} months",
"@listeningRewindLastMonths": { "placeholders": { "months": { "type": "int" } } },
"listeningRewindThisYear": "{year} so far",
"@listeningRewindThisYear": { "placeholders": { "year": { "type": "int" } } },
"listeningRewindIntroTitle": "Your Rewind",
"listeningRewindIntroBody": "A look back at what you played from {start} to {end}.",
"@listeningRewindIntroBody": { "placeholders": { "start": { "type": "String" }, "end": { "type": "String" } } },
"listeningRewindMinutesTitle": "You listened for",
"listeningRewindMinutesUnit": "{minutes, plural, =1{minute} other{minutes}}",
"@listeningRewindMinutesUnit": { "placeholders": { "minutes": { "type": "int" } } },
"listeningRewindHours": "{hours, plural, =1{That's about 1 hour of music} other{That's about {hours} hours of music}}",
"@listeningRewindHours": { "placeholders": { "hours": { "type": "int" } } },
"listeningRewindMore": "{percent}% more than the period before",
"@listeningRewindMore": { "placeholders": { "percent": { "type": "int" } } },
"listeningRewindLess": "{percent}% less than the period before",
"@listeningRewindLess": { "placeholders": { "percent": { "type": "int" } } },
"listeningRewindSame": "About the same as the period before",
"listeningRewindTopArtistTitle": "Your top artist",
"listeningRewindArtistShare": "{percent}% of your listening time",
"@listeningRewindArtistShare": { "placeholders": { "percent": { "type": "int" } } },
"listeningRewindArtistsTitle": "Artists on rotation",
"listeningRewindTopTrackTitle": "Your top song",
"listeningRewindPlays": "{plays, plural, =1{1 play} other{{plays} plays}}",
"@listeningRewindPlays": { "placeholders": { "plays": { "type": "int" } } },
"listeningRewindTracksTitle": "Songs on repeat",
"listeningRewindTopAlbumTitle": "The album you kept coming back to",
"listeningRewindNewArtistsTitle": "New discoveries",
"listeningRewindNewArtistsBody": "{count, plural, =1{You found 1 new artist} other{You found {count} new artists}}",
"@listeningRewindNewArtistsBody": { "placeholders": { "count": { "type": "int" } } },
"listeningRewindRhythmTitle": "Your listening rhythm",
"listeningRewindPeakDay": "Biggest day",
"listeningRewindFavoriteDay": "Favorite day",
"listeningRewindStreak": "Longest streak",
"listeningRewindStreakDays": "{days, plural, =1{1 day} other{{days} days in a row}}",
"@listeningRewindStreakDays": { "placeholders": { "days": { "type": "int" } } },
"listeningRewindReplayTitle": "Couldn't stop playing",
"listeningRewindReplayBody": "You played it {plays} times on {date}",
"@listeningRewindReplayBody": { "placeholders": { "plays": { "type": "int" }, "date": { "type": "String" } } },
"listeningRewindOutroTitle": "That's your Rewind",
"listeningRewindOutroBody": "Keep listening. Your next Rewind is already taking shape.",
"listeningRewindEmpty": "Not enough listening in this period yet. Play some music with the built-in player and come back later.",
"listeningRewindLoadError": "Could not load your Rewind",
"listeningRewindSlide": "Slide {index} of {count}",
"@listeningRewindSlide": { "placeholders": { "index": { "type": "int" }, "count": { "type": "int" } } },
"listeningRewindShare": "Share",
"listeningRewindShareError": "Could not share your Rewind",
"listeningRewindNext": "Next",
"listeningRewindPrevious": "Previous",
"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.",
+39
View File
@@ -102,6 +102,45 @@
"listeningStatsClearError": "Statistik mendengarkan tidak dapat dihapus. Silakan coba lagi.",
"listeningStatsClearDescription": "Hapus semua statistik mendengarkan di perangkat ini? Musik dan playlist tetap tersimpan.",
"libraryArtistsEmpty": "Artis muncul di sini setelah kamu mengunduh musik atau memindai library lokal.",
"listeningRewind": "Rewind",
"listeningRewindDescription": "Lihat lagi momen menarik dari minggu dan bulan terakhir",
"listeningRewindChoosePeriod": "Pilih periode",
"listeningRewindLastWeeks": "{weeks} minggu terakhir",
"listeningRewindLastMonths": "{months} bulan terakhir",
"listeningRewindThisYear": "Sepanjang {year}",
"listeningRewindIntroTitle": "Rewind kamu",
"listeningRewindIntroBody": "Kilas balik musik yang kamu putar dari {start} sampai {end}.",
"listeningRewindMinutesTitle": "Kamu mendengarkan selama",
"listeningRewindMinutesUnit": "menit",
"listeningRewindHours": "Sekitar {hours} jam musik",
"listeningRewindMore": "{percent}% lebih banyak dari periode sebelumnya",
"listeningRewindLess": "{percent}% lebih sedikit dari periode sebelumnya",
"listeningRewindSame": "Kurang lebih sama dengan periode sebelumnya",
"listeningRewindTopArtistTitle": "Artis teratasmu",
"listeningRewindArtistShare": "{percent}% dari waktu mendengarkanmu",
"listeningRewindArtistsTitle": "Artis yang sering diputar",
"listeningRewindTopTrackTitle": "Lagu teratasmu",
"listeningRewindPlays": "{plays}x diputar",
"listeningRewindTracksTitle": "Lagu yang terus diulang",
"listeningRewindTopAlbumTitle": "Album yang terus kamu putar",
"listeningRewindNewArtistsTitle": "Temuan baru",
"listeningRewindNewArtistsBody": "Kamu menemukan {count} artis baru",
"listeningRewindRhythmTitle": "Ritme mendengarkanmu",
"listeningRewindPeakDay": "Hari tersibuk",
"listeningRewindFavoriteDay": "Hari favorit",
"listeningRewindStreak": "Streak terpanjang",
"listeningRewindStreakDays": "{days, plural, =1{1 hari} other{{days} hari berturut-turut}}",
"listeningRewindReplayTitle": "Tak bisa berhenti memutar",
"listeningRewindReplayBody": "Kamu memutarnya {plays} kali pada {date}",
"listeningRewindOutroTitle": "Itulah Rewind kamu",
"listeningRewindOutroBody": "Terus dengarkan. Rewind berikutnya sudah mulai terbentuk.",
"listeningRewindEmpty": "Belum cukup aktivitas mendengarkan di periode ini. Putar musik lewat pemutar bawaan, lalu kembali lagi nanti.",
"listeningRewindLoadError": "Gagal memuat Rewind",
"listeningRewindSlide": "Slide {index} dari {count}",
"listeningRewindShare": "Bagikan",
"listeningRewindShareError": "Gagal membagikan Rewind",
"listeningRewindNext": "Berikutnya",
"listeningRewindPrevious": "Sebelumnya",
"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.",
File diff suppressed because it is too large. Load diff
+170 -68
View File
@@ -3,7 +3,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/listening_rewind_screen.dart';
import 'package:spotiflac_android/services/listening_statistics.dart';
import 'package:spotiflac_android/theme/app_motion.dart';
import 'package:spotiflac_android/theme/material_expressive.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
import 'package:spotiflac_android/widgets/app_choice_chip.dart';
import 'package:spotiflac_android/widgets/app_icon_button.dart';
@@ -106,6 +110,7 @@ class _ListeningStatisticsScreenState
],
),
),
const SliverToBoxAdapter(child: ListeningRewindEntry()),
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverToBoxAdapter(
@@ -181,20 +186,55 @@ class _ListeningStatisticsScreenState
);
}
/// Material rows form an expressive connected list; Mornye keeps plain rows.
Widget _connectedRow(
BuildContext context, {
required int index,
required int count,
required Widget child,
}) {
if (context.isMornye) return child;
return Padding(
padding: EdgeInsets.fromLTRB(16, index == 0 ? 0 : 2, 16, 0),
child: Material(
color: settingsGroupColor(context),
clipBehavior: Clip.antiAlias,
borderRadius: connectedRowRadius(
context,
first: index == 0,
last: index == count - 1,
),
child: child,
),
);
}
List<Widget> _summarySlivers(BuildContext context, ListeningSummary data) {
final colors = Theme.of(context).colorScheme;
final mornye = context.isMornye;
final artists = data.artists.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
final topArtists = artists.take(10).toList();
final topTracks = data.tracks.take(20).toList();
final artistArtwork = data.artistArtwork;
final days = data.days.entries.toList()
..sort((a, b) => a.key.compareTo(b.key));
final recentDays = days
.skip((days.length - 7).clamp(0, days.length))
.toList();
final peakDay = days.isEmpty
? 0
: days.map((e) => e.value).reduce((a, b) => a > b ? a : b);
final textTheme = Theme.of(context).textTheme;
return [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Card(
// Material leads with the total as an emphasized hero figure.
color: mornye ? null : colors.primaryContainer,
child: Padding(
padding: const EdgeInsets.all(20),
padding: EdgeInsets.all(mornye ? 20 : 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -202,7 +242,12 @@ class _ListeningStatisticsScreenState
context.l10n.listeningStatsMinutes(
(data.milliseconds / 60000).round(),
),
style: Theme.of(context).textTheme.headlineMedium,
style: mornye
? textTheme.headlineMedium
: textTheme.displaySmall?.copyWith(
fontWeight: FontWeight.w700,
color: colors.onPrimaryContainer,
),
),
const SizedBox(height: 8),
Text(
@@ -211,6 +256,11 @@ class _ListeningStatisticsScreenState
data.tracks.length,
data.days.length,
),
style: mornye
? null
: textTheme.bodyLarge?.copyWith(
color: colors.onPrimaryContainer,
),
),
],
),
@@ -236,9 +286,7 @@ class _ListeningStatisticsScreenState
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
for (final day in days.skip(
(days.length - 7).clamp(0, days.length),
))
for (final day in recentDays)
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
@@ -253,15 +301,16 @@ class _ListeningStatisticsScreenState
),
const SizedBox(width: 8),
Expanded(
child: LinearProgressIndicator(
value:
day.value /
days
.map((e) => e.value)
.reduce((a, b) => a > b ? a : b),
minHeight: 8,
borderRadius: BorderRadius.circular(8),
),
child: materialExpressiveEnabled(context)
? _ActivityBar(
fraction: day.value / peakDay,
peak: day.value == peakDay,
)
: LinearProgressIndicator(
value: day.value / peakDay,
minHeight: 8,
borderRadius: BorderRadius.circular(8),
),
),
const SizedBox(width: 12),
Text('${(day.value / 60000).round()}'),
@@ -280,33 +329,38 @@ class _ListeningStatisticsScreenState
),
SliverList.list(
children: [
for (final artist in artists.take(10))
ListTile(
leading: ExcludeSemantics(
child: AppShapeClip(
kind: AppShapes.artist,
child: SizedBox.square(
dimension: 48,
child: artistArtwork[artist.key] == null
? ColoredBox(
color: colors.surfaceContainerHighest,
child: const Icon(Icons.person_outline),
)
: PlayerArtwork(
artUri: artistArtwork[artist.key],
colorScheme: colors,
cacheWidth:
(48 * MediaQuery.devicePixelRatioOf(context))
.ceil(),
iconSize: 24,
),
for (final (index, artist) in topArtists.indexed)
_connectedRow(
context,
index: index,
count: topArtists.length,
child: ListTile(
leading: ExcludeSemantics(
child: AppShapeClip(
kind: AppShapes.artist,
child: SizedBox.square(
dimension: 48,
child: artistArtwork[artist.key] == null
? ColoredBox(
color: colors.surfaceContainerHighest,
child: const Icon(Icons.person_outline),
)
: PlayerArtwork(
artUri: artistArtwork[artist.key],
colorScheme: colors,
cacheWidth:
(48 * MediaQuery.devicePixelRatioOf(context))
.ceil(),
iconSize: 24,
),
),
),
),
),
title: Text(artist.key),
subtitle: Text(
context.l10n.listeningStatsMinutes(
(artist.value / 60000).round(),
title: Text(artist.key),
subtitle: Text(
context.l10n.listeningStatsMinutes(
(artist.value / 60000).round(),
),
),
),
),
@@ -319,38 +373,44 @@ class _ListeningStatisticsScreenState
),
SliverList.list(
children: [
for (final total in data.tracks.take(20))
ListTile(
leading: ExcludeSemantics(
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox.square(
dimension: 48,
child: PlayerArtwork(
artUri: total.track.artwork,
colorScheme: Theme.of(context).colorScheme,
cacheWidth: (48 * MediaQuery.devicePixelRatioOf(context))
.ceil(),
iconSize: 24,
for (final (index, total) in topTracks.indexed)
_connectedRow(
context,
index: index,
count: topTracks.length,
child: ListTile(
leading: ExcludeSemantics(
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox.square(
dimension: 48,
child: PlayerArtwork(
artUri: total.track.artwork,
colorScheme: Theme.of(context).colorScheme,
cacheWidth:
(48 * MediaQuery.devicePixelRatioOf(context))
.ceil(),
iconSize: 24,
),
),
),
),
),
title: Text(
total.track.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
total.track.artist.trim().isEmpty
? context.l10n.unknownArtist
: total.track.artist,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
trailing: Text(
context.l10n.listeningStatsMinutes(
(total.milliseconds / 60000).round(),
title: Text(
total.track.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
total.track.artist.trim().isEmpty
? context.l10n.unknownArtist
: total.track.artist,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
trailing: Text(
context.l10n.listeningStatsMinutes(
(total.milliseconds / 60000).round(),
),
),
),
),
@@ -359,3 +419,45 @@ class _ListeningStatisticsScreenState
];
}
}
/// One day of listening activity. The bar grows in on a spatial spring and the
/// busiest day takes the primary color.
class _ActivityBar extends StatelessWidget {
const _ActivityBar({required this.fraction, required this.peak});
final double fraction;
final bool peak;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final motion = AppMotion.spatial(
context,
const AppMotionSpec(Duration.zero, Curves.linear),
);
return Container(
height: 12,
decoration: ShapeDecoration(
color: colors.surfaceContainerHighest,
shape: const StadiumBorder(),
),
alignment: AlignmentDirectional.centerStart,
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: fraction.clamp(0.0, 1.0)),
duration: motion.duration,
curve: motion.curve,
builder: (context, value, _) => FractionallySizedBox(
// The spring may overshoot; keep the fill inside its track.
widthFactor: value.clamp(0.0, 1.0),
heightFactor: 1,
child: DecoratedBox(
decoration: ShapeDecoration(
color: peak ? colors.primary : colors.secondary,
shape: const StadiumBorder(),
),
),
),
),
);
}
}
+283
View File
@@ -0,0 +1,283 @@
import 'package:spotiflac_android/services/listening_statistics.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:sqflite/sqflite.dart';
/// Windows a Rewind can look back over. Rolling windows end today; [year]
/// covers the calendar year so far.
enum ListeningRewindPeriod {
weeks(28),
quarter(91),
halfYear(182),
year(0);
const ListeningRewindPeriod(this.days);
final int days;
}
/// Local calendar days. Both ends are exclusive of [end] and [previousEnd].
///
/// The previous window is the same span immediately before a rolling window,
/// or the same dates last year for [ListeningRewindPeriod.year].
class ListeningRewindRange {
const ListeningRewindRange({
required this.start,
required this.end,
required this.previousStart,
required this.previousEnd,
});
factory ListeningRewindRange.of(ListeningRewindPeriod period, DateTime now) {
final end = DateTime(now.year, now.month, now.day + 1);
if (period == ListeningRewindPeriod.year) {
return ListeningRewindRange(
start: DateTime(now.year),
end: end,
previousStart: DateTime(now.year - 1),
previousEnd: DateTime(now.year - 1, now.month, now.day + 1),
);
}
final start = DateTime(end.year, end.month, end.day - period.days);
return ListeningRewindRange(
start: start,
end: end,
previousStart: DateTime(start.year, start.month, start.day - period.days),
previousEnd: start,
);
}
final DateTime start;
final DateTime end;
final DateTime previousStart;
final DateTime previousEnd;
/// The last day inside the window.
DateTime get lastDay => DateTime(end.year, end.month, end.day - 1);
}
class ListeningAlbumTotal {
const ListeningAlbumTotal({
required this.album,
required this.artist,
required this.milliseconds,
required this.plays,
this.artwork,
});
final String album;
final String artist;
final String? artwork;
final int milliseconds;
final int plays;
}
/// The most plays one song received on a single day.
class ListeningReplay {
const ListeningReplay(this.track, this.day, this.plays);
final ListeningTrack track;
final DateTime day;
final int plays;
}
/// Highlights of one Rewind window, derived from the daily listening table.
class ListeningRewind {
const ListeningRewind({
required this.range,
required this.summary,
required this.previousMilliseconds,
required this.topArtists,
required this.topAlbums,
required this.newArtists,
required this.longestStreak,
this.peakDay,
this.peakDayMilliseconds = 0,
this.favoriteWeekday,
this.replay,
});
/// Songs replayed fewer times than this in one day are not highlighted.
static const minimumReplays = 3;
/// Builds the highlights from the window's [summary].
///
/// [newArtistNames] holds artists first heard inside the window; pass null
/// when nothing was recorded before it, since every artist would be "new".
factory ListeningRewind.compute({
required ListeningRewindRange range,
required ListeningSummary summary,
required int previousMilliseconds,
Set<String>? newArtistNames,
ListeningReplay? replay,
}) {
final artists = summary.artists.entries.where((e) => e.value > 0).toList()
..sort((a, b) => b.value.compareTo(a.value));
final albums = <(String, String), ListeningAlbumTotal>{};
for (final total in summary.tracks) {
final album = total.track.album.trim();
if (album.isEmpty) continue;
final key = (album.toLowerCase(), total.track.artist.toLowerCase());
final existing = albums[key];
albums[key] = ListeningAlbumTotal(
album: existing?.album ?? album,
artist: existing?.artist ?? total.track.artist,
// Tracks arrive most listened first, so the first cover wins.
artwork:
existing?.artwork ?? normalizeCoverReference(total.track.artwork),
milliseconds: (existing?.milliseconds ?? 0) + total.milliseconds,
plays: (existing?.plays ?? 0) + total.plays,
);
}
final topAlbums = albums.values.toList()
..sort((a, b) => b.milliseconds.compareTo(a.milliseconds));
final days = <DateTime, int>{
for (final entry in summary.days.entries)
if (entry.value > 0) DateTime.parse(entry.key): entry.value,
};
final ordered = days.keys.toList()..sort();
DateTime? peakDay;
var peakMilliseconds = 0;
final weekdays = <int, int>{};
var longestStreak = 0;
var streak = 0;
DateTime? previous;
for (final day in ordered) {
final time = days[day]!;
if (time > peakMilliseconds) {
peakDay = day;
peakMilliseconds = time;
}
weekdays[day.weekday] = (weekdays[day.weekday] ?? 0) + time;
final expected = previous == null
? null
: DateTime(previous.year, previous.month, previous.day + 1);
streak = expected == day ? streak + 1 : 1;
if (streak > longestStreak) longestStreak = streak;
previous = day;
}
int? favoriteWeekday;
for (final entry in weekdays.entries) {
if (favoriteWeekday == null || entry.value > weekdays[favoriteWeekday]!) {
favoriteWeekday = entry.key;
}
}
return ListeningRewind(
range: range,
summary: summary,
previousMilliseconds: previousMilliseconds,
topArtists: artists,
topAlbums: topAlbums,
newArtists: newArtistNames == null
? const []
: [
for (final artist in artists)
if (newArtistNames.contains(artist.key)) artist.key,
],
longestStreak: longestStreak,
peakDay: peakDay,
peakDayMilliseconds: peakMilliseconds,
favoriteWeekday: favoriteWeekday,
replay: replay != null && replay.plays >= minimumReplays ? replay : null,
);
}
final ListeningRewindRange range;
final ListeningSummary summary;
final int previousMilliseconds;
/// Artists by listening time, most first.
final List<MapEntry<String, int>> topArtists;
final List<ListeningAlbumTotal> topAlbums;
/// Artists first heard in this window, most listened first.
final List<String> newArtists;
final DateTime? peakDay;
final int peakDayMilliseconds;
/// [DateTime.monday] to [DateTime.sunday].
final int? favoriteWeekday;
final int longestStreak;
final ListeningReplay? replay;
bool get isEmpty => summary.milliseconds <= 0;
int get minutes => (summary.milliseconds / 60000).round();
/// Signed change from the previous window, or null without earlier
/// listening to compare against.
int? get changePercent => previousMilliseconds <= 0
? null
: ((summary.milliseconds - previousMilliseconds) *
100 /
previousMilliseconds)
.round();
/// The top artist's share of all listening, in percent.
int get topArtistShare => topArtists.isEmpty || summary.milliseconds <= 0
? 0
: (topArtists.first.value * 100 / summary.milliseconds).round();
}
/// Reads one Rewind window, aggregating in SQLite like the statistics screen.
Future<ListeningRewind> readListeningRewind(
DatabaseExecutor db,
ListeningRewindRange range,
) async {
final summary = await readListeningSummary(db, range.start, range.end);
final start = listeningDay(range.start);
final end = listeningDay(range.end);
final previous = await db.rawQuery(
'''
SELECT COALESCE(SUM(milliseconds), 0) AS milliseconds
FROM listening WHERE day >= ? AND day < ?
''',
[listeningDay(range.previousStart), listeningDay(range.previousEnd)],
);
final earlier = await db.rawQuery(
'SELECT COUNT(*) AS count FROM listening WHERE day < ?',
[start],
);
final hasHistory = (earlier.first['count'] as int? ?? 0) > 0;
final newArtists = hasHistory
? await db.rawQuery(
'''
SELECT artist FROM listening GROUP BY artist
HAVING MIN(day) >= ? AND MIN(day) < ?
''',
[start, end],
)
: null;
final replays = await db.rawQuery(
'''
SELECT day, track_key, title, artist, album, artwork, plays
FROM listening WHERE day >= ? AND day < ?
ORDER BY plays DESC, milliseconds DESC LIMIT 1
''',
[start, end],
);
final replay = replays.isEmpty ? null : replays.first;
return ListeningRewind.compute(
range: range,
summary: summary,
previousMilliseconds: previous.first['milliseconds'] as int? ?? 0,
newArtistNames: newArtists == null
? null
: {for (final row in newArtists) row['artist'] as String},
replay: replay == null
? null
: ListeningReplay(
ListeningTrack(
key: replay['track_key'] as String,
title: replay['title'] as String,
artist: replay['artist'] as String,
album: replay['album'] as String,
artwork: replay['artwork'] as String?,
),
DateTime.parse(replay['day'] as String),
replay['plays'] as int,
),
);
}
+4
View File
@@ -325,6 +325,10 @@ class ListeningStatisticsStore {
return readListeningSummary(db, start, end);
}
/// Runs a read-only aggregate against the statistics database.
Future<T> read<T>(Future<T> Function(DatabaseExecutor db) query) async =>
query(await _open());
Future<void> clear() async => (await _open()).delete('listening');
}
+187
View File
@@ -0,0 +1,187 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/screens/listening_rewind_screen.dart';
import 'package:spotiflac_android/services/listening_rewind.dart';
import 'package:spotiflac_android/services/listening_statistics.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
Map<String, Object?> _row(
String day,
String key, {
String artist = 'Artist',
String album = 'Album',
int minutes = 10,
int plays = 1,
}) => {
'day': day,
'track_key': key,
'title': 'Song $key',
'artist': artist,
'album': album,
'artwork': null,
'milliseconds': minutes * 60000,
'plays': plays,
};
final _range = ListeningRewindRange.of(
ListeningRewindPeriod.weeks,
DateTime(2026, 10, 14, 21),
);
ListeningRewind _sample({Set<String>? newArtists, ListeningReplay? replay}) =>
ListeningRewind.compute(
range: _range,
summary: ListeningSummary.fromRows([
_row('2026-10-01', 'a', minutes: 30, plays: 3),
_row('2026-10-02', 'a', minutes: 20, plays: 2),
_row('2026-10-03', 'b', album: 'ALBUM', minutes: 15),
_row('2026-10-05', 'c', artist: 'Other', album: 'Else', minutes: 40),
_row('2026-10-06', 'd', artist: 'Fresh', album: '', minutes: 5),
]),
previousMilliseconds: 100 * 60000,
newArtistNames: newArtists,
replay: replay,
);
void main() {
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));
expect(_range.lastDay, DateTime(2026, 10, 14));
expect(_range.previousStart, DateTime(2026, 8, 20));
expect(_range.previousEnd, DateTime(2026, 9, 17));
final year = ListeningRewindRange.of(
ListeningRewindPeriod.year,
DateTime(2026, 10, 14),
);
expect(year.start, DateTime(2026));
expect(year.previousStart, DateTime(2025));
expect(year.previousEnd, DateTime(2025, 10, 15));
});
test('highlights rank artists, merge albums and read listening rhythm', () {
final rewind = _sample();
expect(rewind.minutes, 110);
expect(rewind.changePercent, 10);
expect(rewind.topArtists.map((e) => e.key), ['Artist', 'Other', 'Fresh']);
expect(rewind.topArtistShare, 59);
// Album names match case-insensitively and empty albums are skipped.
expect(rewind.topAlbums.first.album, 'Album');
expect(rewind.topAlbums.first.milliseconds, 65 * 60000);
expect(rewind.topAlbums.length, 2);
expect(rewind.peakDay, DateTime(2026, 10, 5));
expect(rewind.peakDayMilliseconds, 40 * 60000);
// 1–3 October run three days in a row; 5–6 October two.
expect(rewind.longestStreak, 3);
expect(rewind.favoriteWeekday, DateTime(2026, 10, 5).weekday);
// Without earlier history every artist would be new, so none are shown.
expect(rewind.newArtists, isEmpty);
expect(rewind.replay, isNull);
});
test('discoveries follow listening order and replays need repeats', () {
const track = ListeningTrack(
key: 'a',
title: 'Song a',
artist: 'Artist',
album: 'Album',
);
final rewind = _sample(
newArtists: {'Fresh', 'Other', 'Elsewhere'},
replay: ListeningReplay(track, DateTime(2026, 10, 1), 4),
);
expect(rewind.newArtists, ['Other', 'Fresh']);
expect(rewind.replay?.plays, 4);
expect(
_sample(replay: ListeningReplay(track, DateTime(2026, 10, 1), 2)).replay,
isNull,
);
expect(
ListeningRewind.compute(
range: _range,
summary: const ListeningSummary(),
previousMilliseconds: 0,
).changePercent,
isNull,
);
});
for (final mornye in [false, true]) {
testWidgets('Rewind story steps through highlights ($mornye)', (
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 => _sample(newArtists: {'Fresh'}),
),
],
child: MaterialApp(
theme: mornye ? MornyeTheme.build(Brightness.dark) : ThemeData(),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
// Reduced motion: no auto-advance, so the story waits for taps.
builder: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(disableAnimations: true),
child: child!,
),
home: const ListeningRewindScreen(
period: ListeningRewindPeriod.weeks,
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Your Rewind'), findsOneWidget);
expect(find.text('Last 4 weeks'), findsOneWidget);
final story = tester.getRect(find.byType(PageView));
Future<void> next() async {
await tester.tapAt(Offset(story.right - 40, story.center.dy));
await tester.pumpAndSettle();
}
await next();
expect(find.text('110'), findsOneWidget);
expect(find.text('10% more than the period before'), findsOneWidget);
await next();
expect(find.text('Your top artist'), findsOneWidget);
expect(find.text('65 min · 59% of your listening time'), findsOneWidget);
await tester.tapAt(Offset(story.left + 20, story.center.dy));
await tester.pumpAndSettle();
expect(find.text('110'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
testWidgets('empty windows explain how to fill a Rewind', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
listeningRewindProvider.overrideWith(
(ref, period) async => ListeningRewind.compute(
range: _range,
summary: const ListeningSummary(),
previousMilliseconds: 0,
),
),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const ListeningRewindScreen(period: ListeningRewindPeriod.year),
),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('Not enough listening'), findsOneWidget);
expect(find.byTooltip('Close'), findsOneWidget);
});
}