diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 6a6f1a53..d7bf224d 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -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.", diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 25e0db75..94095e52 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -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.", diff --git a/lib/screens/listening_rewind_screen.dart b/lib/screens/listening_rewind_screen.dart new file mode 100644 index 00000000..b8d57625 --- /dev/null +++ b/lib/screens/listening_rewind_screen.dart @@ -0,0 +1,1368 @@ +import 'dart:io'; +import 'dart:math' as math; +import 'dart:ui' show ImageByteFormat, ImageFilter; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show RenderRepaintBoundary; +import 'package:flutter/semantics.dart' show CustomSemanticsAction; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:material_3_expressive/material_3_expressive.dart' + show M3EShapeKind; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus, XFile; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/services/listening_rewind.dart'; +import 'package:spotiflac_android/services/listening_statistics.dart'; +import 'package:spotiflac_android/theme/app_motion.dart'; +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_icon.dart'; +import 'package:spotiflac_android/widgets/app_icon_button.dart'; +import 'package:spotiflac_android/widgets/app_loading_indicator.dart'; +import 'package:spotiflac_android/widgets/app_shape.dart'; +import 'package:spotiflac_android/widgets/player_artwork.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; + +final listeningRewindProvider = FutureProvider.autoDispose + .family((ref, period) async { + await listeningRecorder.flush(); + final range = ListeningRewindRange.of(period, DateTime.now()); + return ListeningStatisticsStore.instance.read( + (db) => readListeningRewind(db, range), + ); + }); + +String listeningRewindPeriodLabel( + BuildContext context, + ListeningRewindPeriod period, +) => switch (period) { + ListeningRewindPeriod.weeks => context.l10n.listeningRewindLastWeeks(4), + ListeningRewindPeriod.quarter => context.l10n.listeningRewindLastMonths(3), + ListeningRewindPeriod.halfYear => context.l10n.listeningRewindLastMonths(6), + ListeningRewindPeriod.year => context.l10n.listeningRewindThisYear( + DateTime.now().year, + ), +}; + +/// Asks for a window, then plays its Rewind. +Future showListeningRewind(BuildContext context) async { + const icons = { + ListeningRewindPeriod.weeks: Icons.today, + ListeningRewindPeriod.quarter: Icons.date_range, + ListeningRewindPeriod.halfYear: Icons.event_outlined, + ListeningRewindPeriod.year: Icons.auto_awesome, + }; + final period = await showAppBottomSheet( + context: context, + title: context.l10n.listeningRewindChoosePeriod, + builder: (sheetContext) => Padding( + padding: EdgeInsets.only(bottom: sheetContext.tokens.gapLg), + child: SettingsGroup( + children: [ + for (final period in ListeningRewindPeriod.values) + SettingsItem( + icon: icons[period], + showIconInMornye: true, + title: listeningRewindPeriodLabel(sheetContext, period), + showDivider: period != ListeningRewindPeriod.values.last, + onTap: () => Navigator.pop(sheetContext, period), + ), + ], + ), + ), + ); + if (period == null || !context.mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => ListeningRewindScreen(period: period), + ), + ); +} + +/// Statistics screen entry. Material gets a tertiary hero banner; Mornye +/// keeps a grouped row. +class ListeningRewindEntry extends StatelessWidget { + const ListeningRewindEntry({super.key}); + + @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, + borderRadius: context.tokens.borderRadiusCard, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => showListeningRewind(context), + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + AppShapeBadge( + kind: AppShapes.badge, + size: 56, + color: colors.tertiary, + child: AppIcon(Icons.auto_awesome, color: colors.onTertiary), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.listeningRewind, + style: textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + color: colors.onTertiaryContainer, + ), + ), + const SizedBox(height: 2), + Text( + l10n.listeningRewindDescription, + style: textTheme.bodyMedium?.copyWith( + color: colors.onTertiaryContainer, + ), + ), + ], + ), + ), + AppIcon(Icons.chevron_right, color: colors.onTertiaryContainer), + ], + ), + ), + ), + ), + ); + } +} + +class ListeningRewindScreen extends ConsumerWidget { + const ListeningRewindScreen({super.key, required this.period}); + + final ListeningRewindPeriod period; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final rewind = ref.watch(listeningRewindProvider(period)); + final mornye = context.isMornye; + final colors = Theme.of(context).colorScheme; + final background = mornye ? Colors.black : colors.surface; + final foreground = mornye ? Colors.white : colors.onSurface; + Widget message(String text) => _RewindMessage( + text: text, + background: background, + foreground: foreground, + ); + return Scaffold( + backgroundColor: background, + body: rewind.when( + loading: () => Center(child: AppLoadingIndicator(color: foreground)), + error: (_, _) => message(context.l10n.listeningRewindLoadError), + data: (rewind) => rewind.isEmpty + ? message(context.l10n.listeningRewindEmpty) + : _RewindStory(rewind: rewind, period: period), + ), + ); + } +} + +class _RewindMessage extends StatelessWidget { + const _RewindMessage({ + required this.text, + required this.background, + required this.foreground, + }); + + final String text; + final Color background; + final Color foreground; + + @override + Widget build(BuildContext context) => SafeArea( + child: Stack( + children: [ + Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text( + text, + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(color: foreground), + ), + ), + ), + Positioned(top: 8, right: 8, child: _CloseButton(color: foreground)), + ], + ), + ); +} + +class _CloseButton extends StatelessWidget { + const _CloseButton({required this.color}); + + final Color color; + + @override + Widget build(BuildContext context) => AppIconButton( + tooltip: MaterialLocalizations.of(context).closeButtonTooltip, + color: color, + icon: AppIcon.adaptive(Icons.close, color: color), + onPressed: () => Navigator.of(context).maybePop(), + ); +} + +enum _Slide { + intro, + minutes, + topArtist, + artists, + topTrack, + tracks, + album, + 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, +]; + +/// 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 +/// goes forward, holding pauses, and swiping pages manually. +class _RewindStory extends StatefulWidget { + const _RewindStory({required this.rewind, required this.period}); + + final ListeningRewind rewind; + final ListeningRewindPeriod period; + + @override + State<_RewindStory> createState() => _RewindStoryState(); +} + +class _RewindStoryState extends State<_RewindStory> + with SingleTickerProviderStateMixin { + static const _slideDuration = Duration(seconds: 6); + + late final List<_Slide> _slides = _slidesFor(widget.rewind); + final _pages = PageController(); + late final _progress = AnimationController( + vsync: this, + duration: _slideDuration, + )..addStatusListener(_onProgressStatus); + final _captureKey = GlobalKey(); + var _index = 0; + var _autoAdvance = false; + var _sharing = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final autoAdvance = !MediaQuery.disableAnimationsOf(context); + if (autoAdvance != _autoAdvance) { + _autoAdvance = autoAdvance; + _restartProgress(); + } + } + + @override + void dispose() { + _progress.dispose(); + _pages.dispose(); + super.dispose(); + } + + void _onProgressStatus(AnimationStatus status) { + if (status == AnimationStatus.completed) _go(_index + 1); + } + + void _restartProgress() { + if (!_autoAdvance) { + _progress.stop(); + return; + } + _progress.value = 0; + if (_index < _slides.length - 1) { + _progress.forward(); + } else { + _progress.value = 1; + } + } + + void _go(int index) { + if (index < 0 || index >= _slides.length) return; + final motion = AppMotion.spatialSlow( + context, + AppMotionSpec( + MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 320), + Curves.easeOutCubic, + ), + ); + if (motion.duration == Duration.zero) { + _pages.jumpToPage(index); + } else { + _pages.animateToPage( + index, + duration: motion.duration, + curve: motion.curve, + ); + } + } + + /// Shares the visible slide, without the story controls, as an image. + Future _share() async { + if (_sharing) return; + final l10n = context.l10n; + final messenger = ScaffoldMessenger.of(context); + final pixelRatio = MediaQuery.devicePixelRatioOf(context); + setState(() => _sharing = true); + try { + final boundary = + _captureKey.currentContext?.findRenderObject() + as RenderRepaintBoundary?; + if (boundary == null) return; + final image = await boundary.toImage(pixelRatio: pixelRatio); + final bytes = await image.toByteData(format: ImageByteFormat.png); + image.dispose(); + if (bytes == null) return; + final directory = await getTemporaryDirectory(); + final file = File('${directory.path}/spotiflac_rewind.png'); + await file.writeAsBytes(bytes.buffer.asUint8List(), flush: true); + await SharePlus.instance.share( + ShareParams( + files: [XFile(file.path, mimeType: 'image/png')], + text: l10n.listeningRewindIntroTitle, + ), + ); + } catch (_) { + messenger.showSnackBar( + SnackBar(content: Text(l10n.listeningRewindShareError)), + ); + } finally { + if (mounted) setState(() => _sharing = false); + } + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final palettes = [ + for (var i = 0; i < _slides.length; i++) + _RewindPalette.of(context, i, widget.rewind), + ]; + final palette = palettes[_index]; + final lastSlide = _index == _slides.length - 1; + return AnnotatedRegion( + value: + ThemeData.estimateBrightnessForColor(palette.background) == + Brightness.dark + ? SystemUiOverlayStyle.light + : SystemUiOverlayStyle.dark, + child: Semantics( + customSemanticsActions: { + CustomSemanticsAction(label: l10n.listeningRewindNext): () => + _go(_index + 1), + CustomSemanticsAction(label: l10n.listeningRewindPrevious): () => + _go(_index - 1), + }, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapUp: (details) { + final width = context.size?.width ?? 0; + _go(details.localPosition.dx < width / 3 ? _index - 1 : _index + 1); + }, + onLongPressStart: (_) => _progress.stop(), + onLongPressEnd: (_) { + if (_autoAdvance && _index < _slides.length - 1) { + _progress.forward(); + } + }, + child: Stack( + children: [ + RepaintBoundary( + key: _captureKey, + child: Stack( + children: [ + Positioned.fill( + child: _RewindBackdrop( + pages: _pages, + palettes: palettes, + artworks: [ + for (final slide in _slides) + _slideArtwork(slide, widget.rewind), + ], + ), + ), + PageView.builder( + controller: _pages, + itemCount: _slides.length, + onPageChanged: (index) { + setState(() => _index = index); + _restartProgress(); + }, + itemBuilder: (context, index) => Semantics( + label: l10n.listeningRewindSlide( + index + 1, + _slides.length, + ), + child: _RewindSlide( + slide: _slides[index], + rewind: widget.rewind, + period: widget.period, + palette: palettes[index], + ), + ), + ), + ], + ), + ), + if (lastSlide) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Center( + child: _Reveal( + order: 4, + child: AppActionButton( + onPressed: _sharing ? null : _share, + icon: const Icon(Icons.share), + label: Text(l10n.listeningRewindShare), + ), + ), + ), + ), + ), + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 4, 0), + child: Row( + children: [ + Expanded( + child: AnimatedBuilder( + animation: _progress, + builder: (context, _) => _StoryProgress( + count: _slides.length, + index: _index, + value: _autoAdvance ? _progress.value : 1, + color: palette.foreground, + ), + ), + ), + _CloseButton(color: palette.foreground), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _StoryProgress extends StatelessWidget { + const _StoryProgress({ + required this.count, + required this.index, + required this.value, + required this.color, + }); + + final int count; + final int index; + final double value; + final Color color; + + @override + Widget build(BuildContext context) => ExcludeSemantics( + child: Row( + children: [ + for (var i = 0; i < count; i++) ...[ + if (i > 0) const SizedBox(width: 4), + Expanded( + child: Container( + height: 4, + decoration: ShapeDecoration( + color: color.withValues(alpha: 0.28), + shape: const StadiumBorder(), + ), + alignment: AlignmentDirectional.centerStart, + child: FractionallySizedBox( + heightFactor: 1, + widthFactor: i < index + ? 1 + : i == index + ? value.clamp(0.0, 1.0) + : 0, + child: DecoratedBox( + decoration: ShapeDecoration( + color: color, + shape: const StadiumBorder(), + ), + ), + ), + ), + ), + ], + ], + ), + ); +} + +/// Colors of one slide. Material cycles through the tonal containers; +/// Mornye stays dark over blurred artwork. +class _RewindPalette { + const _RewindPalette(this.background, this.foreground, this.accent); + + factory _RewindPalette.of( + BuildContext context, + int index, + ListeningRewind rewind, + ) { + if (context.isMornye) { + return _RewindPalette( + Colors.black, + Colors.white, + Theme.of(context).colorScheme.primary, + ); + } + final c = Theme.of(context).colorScheme; + final pairs = [ + (c.primaryContainer, c.onPrimaryContainer, c.primary), + (c.tertiaryContainer, c.onTertiaryContainer, c.tertiary), + (c.secondaryContainer, c.onSecondaryContainer, c.secondary), + (c.primary, c.onPrimary, c.primaryContainer), + (c.tertiary, c.onTertiary, c.tertiaryContainer), + (c.surfaceContainerHighest, c.onSurface, c.primary), + ]; + final (background, foreground, accent) = pairs[index % pairs.length]; + return _RewindPalette(background, foreground, accent); + } + + final Color background; + final Color foreground; + final Color accent; +} + +class _RewindSlide extends StatelessWidget { + const _RewindSlide({ + required this.slide, + required this.rewind, + required this.period, + required this.palette, + }); + + final _Slide slide; + final ListeningRewind rewind; + final ListeningRewindPeriod period; + final _RewindPalette palette; + + /// The slide's content only; [_RewindBackdrop] paints the shared, moving + /// background behind every slide. + @override + Widget build(BuildContext context) => SafeArea( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + 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), + ), + ), + ), + ), + ), + ); + + Widget _content(BuildContext context) { + final l10n = context.l10n; + final locale = Localizations.localeOf(context).toLanguageTag(); + final number = NumberFormat.decimalPattern(locale); + final shortDate = DateFormat.MMMd(locale); + final minutes = rewind.minutes; + String minutesOf(int milliseconds) => + l10n.listeningStatsMinutes((milliseconds / 60000).round()); + final tracks = rewind.summary.tracks; + final artwork = rewind.summary.artistArtwork; + + switch (slide) { + case _Slide.intro: + return _Column( + center: true, + children: [ + _Eyebrow(listeningRewindPeriodLabel(context, period)), + _Headline(l10n.listeningRewindIntroTitle, size: _Size.huge), + _Body( + l10n.listeningRewindIntroBody( + shortDate.format(rewind.range.start), + shortDate.format(rewind.range.lastDay), + ), + ), + ], + ); + case _Slide.minutes: + final change = rewind.changePercent; + return _Column( + center: true, + children: [ + _Eyebrow(l10n.listeningRewindMinutesTitle), + _CountUp(value: minutes, format: number.format), + _Headline(l10n.listeningRewindMinutesUnit(minutes)), + if (minutes >= 60) + _Body(l10n.listeningRewindHours((minutes / 60).round())), + if (change != null) + _Pill( + change.abs() < 5 + ? l10n.listeningRewindSame + : change > 0 + ? l10n.listeningRewindMore(change) + : l10n.listeningRewindLess(change.abs()), + color: palette.foreground, + ), + _Body( + l10n.listeningStatsTotals( + rewind.summary.plays, + tracks.length, + rewind.summary.days.length, + ), + ), + ], + ); + case _Slide.topArtist: + final top = rewind.topArtists.first; + return _Column( + center: true, + children: [ + _Eyebrow(l10n.listeningRewindTopArtistTitle), + _Artwork(uri: artwork[top.key], size: 220, artist: true), + _Headline(top.key, size: _Size.large), + _Body( + '${minutesOf(top.value)} · ' + '${l10n.listeningRewindArtistShare(rewind.topArtistShare)}', + ), + ], + ); + case _Slide.artists: + return _Column( + children: [ + _Headline(l10n.listeningRewindArtistsTitle), + for (final (index, artist) in rewind.topArtists.take(5).indexed) + _RankedRow( + rank: index + 1, + artwork: _Artwork( + uri: artwork[artist.key], + size: 56, + artist: true, + ), + title: artist.key, + subtitle: minutesOf(artist.value), + ), + ], + ); + case _Slide.topTrack: + final top = tracks.first; + return _Column( + center: true, + children: [ + _Eyebrow(l10n.listeningRewindTopTrackTitle), + _Artwork(uri: top.track.artwork, size: 240), + _Headline(top.track.title, size: _Size.large), + _Body(top.track.artist), + _Pill( + '${l10n.listeningRewindPlays(top.plays)} · ' + '${minutesOf(top.milliseconds)}', + color: palette.foreground, + ), + ], + ); + case _Slide.tracks: + return _Column( + children: [ + _Headline(l10n.listeningRewindTracksTitle), + for (final (index, total) in tracks.take(5).indexed) + _RankedRow( + rank: index + 1, + artwork: _Artwork(uri: total.track.artwork, size: 56), + title: total.track.title, + subtitle: total.track.artist, + ), + ], + ); + case _Slide.album: + final album = rewind.topAlbums.first; + return _Column( + center: true, + children: [ + _Eyebrow(l10n.listeningRewindTopAlbumTitle), + _Artwork(uri: album.artwork, size: 240), + _Headline(album.album, size: _Size.large), + _Body('${album.artist} · ${minutesOf(album.milliseconds)}'), + ], + ); + case _Slide.discoveries: + final count = rewind.newArtists.length; + return _Column( + center: true, + children: [ + _Eyebrow(l10n.listeningRewindNewArtistsTitle), + _CountUp(value: count, format: number.format), + _Body(l10n.listeningRewindNewArtistsBody(count)), + for (final name in rewind.newArtists.take(3)) + _RankedRow( + artwork: _Artwork(uri: artwork[name], size: 48, artist: true), + title: name, + subtitle: minutesOf(rewind.summary.artists[name] ?? 0), + ), + ], + ); + case _Slide.rhythm: + final weekday = rewind.favoriteWeekday; + return _Column( + children: [ + _Headline(l10n.listeningRewindRhythmTitle), + _Fact( + label: l10n.listeningRewindPeakDay, + value: shortDate.format(rewind.peakDay!), + detail: minutesOf(rewind.peakDayMilliseconds), + ), + if (weekday != null) + _Fact( + label: l10n.listeningRewindFavoriteDay, + // 1 January 2024 was a Monday. + value: DateFormat.EEEE( + locale, + ).format(DateTime(2024, 1, weekday)), + ), + _Fact( + label: l10n.listeningRewindStreak, + value: l10n.listeningRewindStreakDays(rewind.longestStreak), + ), + ], + ); + case _Slide.replay: + final replay = rewind.replay!; + return _Column( + center: true, + children: [ + _Eyebrow(l10n.listeningRewindReplayTitle), + _Artwork(uri: replay.track.artwork, size: 220), + _Headline(replay.track.title, size: _Size.large), + _Body(replay.track.artist), + _Pill( + l10n.listeningRewindReplayBody( + replay.plays, + shortDate.format(replay.day), + ), + color: palette.foreground, + ), + ], + ); + case _Slide.outro: + return _Column( + children: [ + _Headline(l10n.listeningRewindOutroTitle, size: _Size.large), + _Body(l10n.listeningRewindOutroBody), + _Fact( + label: l10n.listeningRewindMinutesTitle, + value: + '${number.format(minutes)} ' + '${l10n.listeningRewindMinutesUnit(minutes)}', + ), + if (rewind.topArtists.isNotEmpty) + _Fact( + label: l10n.listeningRewindTopArtistTitle, + value: rewind.topArtists.first.key, + ), + if (tracks.isNotEmpty) + _Fact( + label: l10n.listeningRewindTopTrackTitle, + value: tracks.first.track.title, + ), + if (rewind.longestStreak > 1) + _Fact( + label: l10n.listeningRewindStreak, + value: l10n.listeningRewindStreakDays(rewind.longestStreak), + ), + ], + ); + } + } +} + +/// Artwork the Mornye backdrop shows behind [slide]. +String? _slideArtwork(_Slide slide, ListeningRewind rewind) => switch (slide) { + _Slide.topArtist || _Slide.artists || _Slide.discoveries => + rewind.summary.artistArtwork[rewind.topArtists.first.key], + _Slide.album => rewind.topAlbums.first.artwork, + _Slide.replay => rewind.replay?.track.artwork, + _ => rewind.summary.tracks.firstOrNull?.track.artwork, +}; + +/// One background behind every slide, so colors blend while swiping. +/// +/// Material floats large expressive shapes that drift, turn and follow the +/// swipe. Mornye cross-fades the slides' blurred artwork while it slowly +/// zooms. Reduced motion keeps the shapes and artwork still. +class _RewindBackdrop extends StatefulWidget { + const _RewindBackdrop({ + required this.pages, + required this.palettes, + required this.artworks, + }); + + final PageController pages; + final List<_RewindPalette> palettes; + final List artworks; + + @override + State<_RewindBackdrop> createState() => _RewindBackdropState(); +} + +class _RewindBackdropState extends State<_RewindBackdrop> + with SingleTickerProviderStateMixin { + late final _ambient = AnimationController( + vsync: this, + duration: const Duration(seconds: 24), + ); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (MediaQuery.disableAnimationsOf(context)) { + _ambient.stop(); + } else if (!_ambient.isAnimating) { + _ambient.repeat(); + } + } + + @override + void dispose() { + _ambient.dispose(); + super.dispose(); + } + + double get _page { + final pages = widget.pages; + if (pages.hasClients && pages.position.haveDimensions) { + return pages.page ?? 0; + } + return pages.initialPage.toDouble(); + } + + @override + Widget build(BuildContext context) => ExcludeSemantics( + child: RepaintBoundary( + child: AnimatedBuilder( + animation: Listenable.merge([widget.pages, _ambient]), + builder: (context, _) { + final last = widget.palettes.length - 1; + final page = _page.clamp(0.0, last.toDouble()); + final from = page.floor(); + final to = math.min(from + 1, last); + final blend = page - from; + final t = _ambient.value * 2 * math.pi; + return context.isMornye + ? _mornye(from, to, blend, t) + : _material(from, to, blend, page, t); + }, + ), + ), + ); + + Widget _material(int from, int to, double blend, double page, double t) { + final a = widget.palettes[from]; + final b = widget.palettes[to]; + final foreground = Color.lerp(a.foreground, b.foreground, blend)!; + final accent = Color.lerp(a.accent, b.accent, blend)!; + // Each shape drifts on its own orbit, turns slowly, and leans into the + // swipe so the background feels continuous across slides. + Widget shape({ + required M3EShapeKind kind, + required double size, + required Alignment alignment, + required Offset orbit, + required double speed, + required double spin, + required Color color, + }) => Align( + alignment: alignment, + child: Transform.translate( + offset: Offset( + math.sin(t * speed) * orbit.dx - page * size * 0.12, + math.cos(t * speed) * orbit.dy, + ), + child: Transform.rotate( + angle: t * spin + page * 0.5, + child: Transform.scale( + scale: 1 + 0.05 * math.sin(t * speed * 2), + child: AppShapeBadge( + kind: kind, + size: size, + color: color, + child: const SizedBox(), + ), + ), + ), + ), + ); + return ColoredBox( + color: Color.lerp(a.background, b.background, blend)!, + child: Stack( + fit: StackFit.expand, + children: [ + shape( + kind: AppShapes.badge, + size: 340, + alignment: const Alignment(1.6, -1.1), + orbit: const Offset(18, 14), + speed: 1, + spin: 0.25, + color: foreground.withValues(alpha: 0.07), + ), + shape( + kind: AppShapes.brand, + size: 260, + alignment: const Alignment(-1.7, 1.15), + orbit: const Offset(14, 20), + speed: 1.3, + spin: -0.2, + color: foreground.withValues(alpha: 0.07), + ), + shape( + kind: AppShapes.artist, + size: 96, + alignment: const Alignment(0.95, 0.72), + orbit: const Offset(10, 16), + speed: 2, + spin: 0.6, + color: accent.withValues(alpha: 0.35), + ), + shape( + kind: AppShapes.badge, + size: 56, + alignment: const Alignment(-0.85, -0.55), + orbit: const Offset(12, 8), + speed: 1.7, + spin: -0.8, + color: accent.withValues(alpha: 0.3), + ), + ], + ), + ); + } + + Widget _mornye(int from, int to, double blend, double t) { + Widget artwork(String? uri, double opacity) => uri == null || opacity <= 0 + ? const SizedBox.shrink() + : Opacity( + opacity: opacity, + child: Transform.scale( + // A slow Ken Burns drift. + scale: 1.15 + 0.06 * math.sin(t), + child: ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: 48, sigmaY: 48), + child: PlayerArtwork( + artUri: uri, + colorScheme: Theme.of(context).colorScheme, + cacheWidth: 200, + ), + ), + ), + ); + final current = widget.artworks[from]; + final next = widget.artworks[to]; + return ColoredBox( + color: Colors.black, + child: Stack( + fit: StackFit.expand, + children: [ + if (current == next) + artwork(current, 1) + else ...[ + artwork(current, 1 - blend), + artwork(next, blend), + ], + DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.45), + Colors.black.withValues(alpha: 0.8), + ], + ), + ), + ), + ], + ), + ); + } +} + +/// Slide content with a staggered spring entrance. +class _Column extends StatelessWidget { + const _Column({required this.children, this.center = false}); + + final List children; + final bool center; + + @override + Widget build(BuildContext context) => DefaultTextStyle.merge( + textAlign: center ? TextAlign.center : TextAlign.start, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: center + ? CrossAxisAlignment.center + : CrossAxisAlignment.stretch, + children: [ + for (final (index, child) in children.indexed) + _Reveal( + order: index, + child: Padding( + padding: EdgeInsets.only(top: index == 0 ? 0 : 16), + child: child, + ), + ), + ], + ), + ); +} + +class _Reveal extends StatelessWidget { + const _Reveal({required this.order, required this.child}); + + final int order; + final Widget child; + + @override + Widget build(BuildContext context) { + final reduced = MediaQuery.disableAnimationsOf(context); + final motion = AppMotion.spatial( + context, + AppMotionSpec( + reduced ? Duration.zero : const Duration(milliseconds: 420), + Curves.easeOutCubic, + ), + ); + if (motion.duration == Duration.zero) return child; + const stagger = Duration(milliseconds: 90); + final total = motion.duration + stagger * order; + return TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + duration: total, + curve: Interval( + stagger.inMicroseconds * order / total.inMicroseconds, + 1, + curve: motion.curve, + ), + builder: (context, t, child) => Opacity( + opacity: t.clamp(0.0, 1.0), + child: Transform.translate( + offset: Offset(0, 28 * (1 - t)), + child: child, + ), + ), + child: child, + ); + } +} + +class _CountUp extends StatelessWidget { + const _CountUp({required this.value, required this.format}); + + final int value; + final String Function(num) format; + + @override + Widget build(BuildContext context) { + final reduced = MediaQuery.disableAnimationsOf(context); + return TweenAnimationBuilder( + tween: Tween( + begin: reduced ? value.toDouble() : 0, + end: value.toDouble(), + ), + duration: reduced ? Duration.zero : const Duration(milliseconds: 1400), + curve: Curves.easeOutCubic, + builder: (context, current, _) => FittedBox( + fit: BoxFit.scaleDown, + child: Text( + format(current.round()), + style: Theme.of(context).textTheme.displayLarge?.copyWith( + fontSize: 96, + height: 1, + fontWeight: FontWeight.w800, + color: DefaultTextStyle.of(context).style.color, + ), + ), + ), + ); + } +} + +enum _Size { regular, large, huge } + +class _Headline extends StatelessWidget { + const _Headline(this.text, {this.size = _Size.regular}); + + final String text; + final _Size size; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final style = switch (size) { + _Size.regular => textTheme.headlineMedium, + _Size.large => textTheme.headlineLarge, + _Size.huge => textTheme.displayMedium, + }; + return Text( + text, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: style?.copyWith( + fontWeight: FontWeight.w800, + color: DefaultTextStyle.of(context).style.color, + ), + ); + } +} + +class _Eyebrow extends StatelessWidget { + const _Eyebrow(this.text); + + final String text; + + @override + Widget build(BuildContext context) => Text( + text, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: DefaultTextStyle.of(context).style.color?.withValues(alpha: 0.8), + ), + ); +} + +class _Body extends StatelessWidget { + const _Body(this.text); + + final String text; + + @override + Widget build(BuildContext context) => Text( + text, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: DefaultTextStyle.of(context).style.color?.withValues(alpha: 0.85), + ), + ); +} + +/// A short highlighted statement. A tinted pill rather than a card. +class _Pill extends StatelessWidget { + const _Pill(this.text, {required this.color}); + + final String text; + final Color color; + + @override + Widget build(BuildContext context) => DecoratedBox( + decoration: ShapeDecoration( + color: color.withValues(alpha: 0.14), + shape: const StadiumBorder(), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + text, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + color: DefaultTextStyle.of(context).style.color, + ), + ), + ), + ); +} + +class _Artwork extends StatelessWidget { + const _Artwork({required this.uri, required this.size, this.artist = false}); + + final String? uri; + final double size; + final bool artist; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final image = SizedBox.square( + dimension: size, + child: uri == null + ? ColoredBox( + color: colors.surfaceContainerHighest, + child: AppIcon( + artist ? Icons.person : Icons.music_note, + size: size * 0.4, + color: colors.onSurfaceVariant, + ), + ) + : PlayerArtwork( + artUri: uri, + colorScheme: colors, + cacheWidth: (size * MediaQuery.devicePixelRatioOf(context)) + .ceil(), + iconSize: size * 0.4, + ), + ); + if (artist) return AppShapeClip(kind: AppShapes.artist, child: image); + final radius = context.isMornye + ? size * 0.06 + : size >= 120 + ? 28.0 + : 12.0; + return ClipRRect(borderRadius: BorderRadius.circular(radius), child: image); + } +} + +class _RankedRow extends StatelessWidget { + const _RankedRow({ + required this.artwork, + required this.title, + required this.subtitle, + this.rank, + }); + + final int? rank; + final Widget artwork; + final String title; + final String subtitle; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final color = DefaultTextStyle.of(context).style.color; + return Row( + children: [ + if (rank case final rank?) + SizedBox( + width: 40, + child: Text( + '$rank', + style: textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w800, + color: color?.withValues(alpha: 0.55), + ), + ), + ), + artwork, + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + color: color, + ), + ), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.bodyMedium?.copyWith( + color: color?.withValues(alpha: 0.8), + ), + ), + ], + ), + ), + ], + ); + } +} + +class _Fact extends StatelessWidget { + const _Fact({required this.label, required this.value, this.detail}); + + final String label; + final String value; + final String? detail; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final color = DefaultTextStyle.of(context).style.color; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: textTheme.labelLarge?.copyWith( + color: color?.withValues(alpha: 0.75), + ), + ), + Text( + detail == null ? value : '$value · $detail', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + color: color, + ), + ), + ], + ); + } +} diff --git a/lib/screens/listening_statistics_screen.dart b/lib/screens/listening_statistics_screen.dart index 2d7d8460..1d423e2c 100644 --- a/lib/screens/listening_statistics_screen.dart +++ b/lib/screens/listening_statistics_screen.dart @@ -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 _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( + 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(), + ), + ), + ), + ), + ); + } +} diff --git a/lib/services/listening_rewind.dart b/lib/services/listening_rewind.dart new file mode 100644 index 00000000..8e7b98ba --- /dev/null +++ b/lib/services/listening_rewind.dart @@ -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? 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 = { + 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 = {}; + 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> topArtists; + final List topAlbums; + + /// Artists first heard in this window, most listened first. + final List 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 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, + ), + ); +} diff --git a/lib/services/listening_statistics.dart b/lib/services/listening_statistics.dart index 54d42a71..fa181675 100644 --- a/lib/services/listening_statistics.dart +++ b/lib/services/listening_statistics.dart @@ -325,6 +325,10 @@ class ListeningStatisticsStore { return readListeningSummary(db, start, end); } + /// Runs a read-only aggregate against the statistics database. + Future read(Future Function(DatabaseExecutor db) query) async => + query(await _open()); + Future clear() async => (await _open()).delete('listening'); } diff --git a/test/listening_rewind_test.dart b/test/listening_rewind_test.dart new file mode 100644 index 00000000..b29c23a5 --- /dev/null +++ b/test/listening_rewind_test.dart @@ -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 _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? 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 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); + }); +}