refactor(rewind): split the listening rewind screen and move its providers

The two Rewind providers move to lib/providers and are re-exported from the
screen so existing imports keep working. Story, slides and widgets become
part files of the screen library. No behavior change.
This commit is contained in:
zarzet committed 2026-10-18 22:40:10 +07:00
1 parent 77c8639df2
commit 8fd574f010
5 files changed
+1525 -1511

No files matched your search

@@ -0,0 +1,40 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/services/listening_rewind.dart';
import 'package:spotiflac_android/services/listening_statistics.dart';
final listeningRewindProvider = FutureProvider.autoDispose
.family<ListeningRewind, ListeningRewindPeriod>((ref, period) async {
await listeningRecorder.flush();
final range = ListeningRewindRange.of(period, DateTime.now());
return ListeningStatisticsStore.instance.read(
(db) => readListeningRewind(
db,
range,
yearly: period == ListeningRewindPeriod.year,
),
);
});
/// The Rewind the Library advertises today, or null. A window without any
/// listening is not advertised, so the banner never opens an empty story.
final listeningRewindSpotlightProvider =
FutureProvider.autoDispose<ListeningRewindSpotlight?>((ref) async {
final now = DateTime.now();
final tomorrow = DateTime(now.year, now.month, now.day + 1);
final refresh = Timer(tomorrow.difference(now), ref.invalidateSelf);
ref.onDispose(refresh.cancel);
final spotlight = ListeningRewindSpotlight.at(now);
if (spotlight == null) return null;
await listeningRecorder.flush();
final range = ListeningRewindRange.of(spotlight.period, now);
final rows = await ListeningStatisticsStore.instance.read(
(db) => db.rawQuery(
'SELECT 1 FROM listening '
'WHERE day >= ? AND day < ? AND milliseconds > 0 LIMIT 1',
[listeningDay(range.start), listeningDay(range.end)],
),
);
return rows.isEmpty ? null : spotlight;
});
File diff suppressed because it is too large. Load diff
+629
View File
@@ -0,0 +1,629 @@
part of 'listening_rewind_screen.dart';
/// 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: LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
primary: false,
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: DefaultTextStyle.merge(
style: TextStyle(color: palette.foreground),
child: IconTheme.merge(
data: IconThemeData(color: palette.foreground),
child: _content(context),
),
),
),
),
),
),
),
),
);
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;
final yearDetails = rewind.year;
final yearLabel = '${rewind.range.start.year}';
String monthName(int month) => DateFormat.MMMM(
locale,
).format(DateTime(rewind.range.start.year, month));
switch (slide) {
case _Slide.intro:
if (yearDetails != null) {
return _Column(
center: true,
children: [
_Eyebrow(l10n.listeningRewindYearEyebrow),
_Headline(
l10n.listeningRewindYearTitle(yearLabel),
size: _Size.huge,
),
_Body(l10n.listeningRewindYearIntroBody),
],
);
}
return _Column(
center: true,
children: [
_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.yearNumbers:
return _Column(
children: [
_Headline(l10n.listeningRewindNumbersTitle),
_StatGrid(
stats: [
(tracks.length, l10n.listeningRewindSongsLabel),
(rewind.topArtists.length, l10n.listeningRewindArtistsLabel),
(rewind.topAlbums.length, l10n.listeningRewindAlbumsLabel),
(rewind.activeDays, l10n.listeningRewindDaysLabel),
],
format: number.format,
),
// "0% of your days" reads as a mistake, so tiny shares stay out.
if (rewind.activeDayShare >= 1)
Align(
alignment: AlignmentDirectional.centerStart,
child: _Pill(
l10n.listeningRewindDaysShare(rewind.activeDayShare),
color: palette.foreground,
),
),
],
);
case _Slide.months:
final biggest = rewind.biggestMonth!;
return _Column(
children: [
_Headline(l10n.listeningRewindMonthsTitle),
_MonthChart(
milliseconds: rewind.monthlyMilliseconds,
highlight: biggest,
year: rewind.range.start.year,
accent: palette.foreground,
),
_Body(l10n.listeningRewindBiggestMonth(monthName(biggest))),
],
);
case _Slide.monthlyArtists:
final months = yearDetails!.monthlyTopArtists.keys.toList()..sort();
return _Column(
children: [
_Headline(l10n.listeningRewindMonthlyArtistsTitle),
_MonthArtists(
months: [
for (final month in months)
(
DateFormat.MMM(
locale,
).format(DateTime(rewind.range.start.year, month)),
yearDetails.monthlyTopArtists[month]!,
),
],
artwork: artwork,
),
],
);
case _Slide.loyal:
final name = yearDetails!.loyalArtist!;
return _Column(
center: true,
children: [
_Eyebrow(l10n.listeningRewindLoyalTitle),
_Artwork(uri: artwork[name], size: 200, artist: true),
_Headline(name, size: _Size.large),
_Pill(
l10n.listeningRewindLoyalBody(yearDetails.loyalArtistDays),
color: palette.foreground,
),
],
);
case _Slide.firstSong:
final first = yearDetails!.firstPlay!;
return _Column(
center: true,
children: [
_Eyebrow(l10n.listeningRewindFirstSongTitle),
_Artwork(uri: first.track.artwork, size: 220),
_Headline(first.track.title, size: _Size.large),
_Body(first.track.artist),
_Pill(
l10n.listeningRewindFirstSongBody(shortDate.format(first.day)),
color: palette.foreground,
),
],
);
case _Slide.minutes:
final change = rewind.changePercent;
return _Column(
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:
final biggestMonth = rewind.biggestMonth;
return _Column(
children: [
_Headline(
yearDetails != null
? l10n.listeningRewindYearOutroTitle(yearLabel)
: l10n.listeningRewindOutroTitle,
size: _Size.large,
),
_Body(
yearDetails != null
? l10n.listeningRewindYearOutroBody
: 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 (yearDetails != null && biggestMonth != null)
_Fact(
label: l10n.listeningRewindBiggestMonthLabel,
value: monthName(biggestMonth),
),
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.monthlyArtists ||
_Slide.discoveries =>
rewind.summary.artistArtwork[rewind.topArtists.first.key],
_Slide.loyal => rewind.summary.artistArtwork[rewind.year?.loyalArtist],
_Slide.firstSong => rewind.year?.firstPlay?.track.artwork,
_Slide.album => rewind.topAlbums.first.artwork,
_Slide.replay => rewind.replay?.track.artwork,
_ => rewind.summary.tracks.firstOrNull?.track.artwork,
};
/// 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<String?> 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),
],
),
),
),
],
),
);
}
}
+336
View File
@@ -0,0 +1,336 @@
part of 'listening_rewind_screen.dart';
enum _Slide {
intro,
minutes,
yearNumbers,
months,
topArtist,
artists,
monthlyArtists,
loyal,
topTrack,
tracks,
album,
firstSong,
discoveries,
rhythm,
replay,
outro,
}
/// A calendar-year Rewind adds its year-in-review slides between the regular
/// highlights.
List<_Slide> _slidesFor(ListeningRewind rewind) {
final year = rewind.year;
return [
_Slide.intro,
_Slide.minutes,
if (year != null) _Slide.yearNumbers,
if (year != null && rewind.monthlyMilliseconds.length >= 2) _Slide.months,
if (rewind.topArtists.isNotEmpty) _Slide.topArtist,
if (rewind.topArtists.length >= 3) _Slide.artists,
if (year != null && year.monthlyTopArtists.length >= 2)
_Slide.monthlyArtists,
if (year != null && year.loyalArtist != null && year.loyalArtistDays > 1)
_Slide.loyal,
if (rewind.summary.tracks.isNotEmpty) _Slide.topTrack,
if (rewind.summary.tracks.length >= 3) _Slide.tracks,
if (rewind.topAlbums.isNotEmpty) _Slide.album,
if (year?.firstPlay != null) _Slide.firstSong,
if (rewind.newArtists.isNotEmpty) _Slide.discoveries,
if (rewind.peakDay != null) _Slide.rhythm,
if (rewind.replay != null) _Slide.replay,
_Slide.outro,
];
}
/// Full-screen story of Rewind slides. Slides advance on their own unless the
/// system asks for reduced motion; tapping the left third goes back, the rest
/// 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<void> _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<SystemUiOverlayStyle>(
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(),
),
),
),
),
),
],
],
),
);
}
+515
View File
@@ -0,0 +1,515 @@
part of 'listening_rewind_screen.dart';
/// Slide content with a staggered spring entrance.
class _Column extends StatelessWidget {
const _Column({required this.children, this.center = false});
final List<Widget> 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<double>(
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, this.fontSize});
final int value;
final String Function(num) format;
final double? fontSize;
@override
Widget build(BuildContext context) {
final reduced = MediaQuery.disableAnimationsOf(context);
return TweenAnimationBuilder<double>(
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: 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),
),
),
],
),
),
],
);
}
}
/// Big counted-up figures in a two-column grid.
class _StatGrid extends StatelessWidget {
const _StatGrid({required this.stats, required this.format});
final List<(int, String)> stats;
final String Function(num) format;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final color = DefaultTextStyle.of(context).style.color;
Widget stat((int, String) entry) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_CountUp(value: entry.$1, format: format, fontSize: 56),
Text(
entry.$2,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: color?.withValues(alpha: 0.8),
),
),
],
);
return Column(
children: [
for (var i = 0; i < stats.length; i += 2) ...[
if (i > 0) const SizedBox(height: 20),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: stat(stats[i])),
const SizedBox(width: 16),
Expanded(
child: i + 1 < stats.length
? stat(stats[i + 1])
: const SizedBox.shrink(),
),
],
),
],
],
);
}
}
/// Twelve bars of monthly listening; [highlight] stands out at full color.
class _MonthChart extends StatelessWidget {
const _MonthChart({
required this.milliseconds,
required this.highlight,
required this.year,
required this.accent,
});
final Map<int, int> milliseconds;
final int highlight;
final int year;
final Color accent;
@override
Widget build(BuildContext context) {
final locale = Localizations.localeOf(context).toLanguageTag();
// Stand-alone narrow month names (J, F, M...).
final narrow = DateFormat('LLLLL', locale);
final peak = milliseconds.values.fold(0, math.max);
final labelStyle = Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
color: accent.withValues(alpha: 0.8),
);
final reduced = MediaQuery.disableAnimationsOf(context);
return ExcludeSemantics(
child: SizedBox(
height: 180,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (var month = 1; month <= 12; month++) ...[
if (month > 1) const SizedBox(width: 6),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Expanded(
child: Align(
alignment: Alignment.bottomCenter,
// Bars grow from the baseline, the peak month last.
child: TweenAnimationBuilder<double>(
tween: Tween(begin: reduced ? 1 : 0, end: 1),
duration: reduced
? Duration.zero
: Duration(milliseconds: 600 + month * 60),
curve: Curves.easeOutCubic,
builder: (context, t, _) => FractionallySizedBox(
heightFactor:
peak <= 0 || (milliseconds[month] ?? 0) <= 0
? 0
: math.max(
0.04,
(milliseconds[month] ?? 0) / peak,
) *
t,
child: DecoratedBox(
decoration: ShapeDecoration(
color: month == highlight
? accent
: accent.withValues(alpha: 0.3),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const SizedBox(width: double.infinity),
),
),
),
),
),
const SizedBox(height: 6),
Text(
narrow.format(DateTime(year, month)),
style: labelStyle,
),
],
),
),
],
],
),
),
);
}
}
/// Each month beside the artist heard longest that month, in two columns.
class _MonthArtists extends StatelessWidget {
const _MonthArtists({required this.months, required this.artwork});
/// Short month name and artist, in calendar order.
final List<(String, String)> months;
final Map<String, String> artwork;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final color = DefaultTextStyle.of(context).style.color;
Widget row((String, String) entry) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
children: [
_Artwork(uri: artwork[entry.$2], size: 36, artist: true),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.$1,
style: textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w700,
color: color?.withValues(alpha: 0.7),
),
),
Text(
entry.$2,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
color: color,
),
),
],
),
),
],
),
);
final half = (months.length / 2).ceil();
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(children: [for (final e in months.take(half)) row(e)]),
),
const SizedBox(width: 16),
Expanded(
child: Column(children: [for (final e in months.skip(half)) row(e)]),
),
],
);
}
}
class _Fact extends StatelessWidget {
const _Fact({required this.label, required this.value, this.detail});
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,
),
),
],
);
}
}