mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 15:02:21 +02:00
feat(listening): add Rewind highlights and refresh statistics
This commit is contained in:
1 parent
97d8ee3695
commit
08266c7369
7 files changed
+2104
-68
No files matched your search
@@ -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.",
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user