This repository has been archived on 2026-08-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
melo-app/lib/screens/recap_screen.dart
T
Dustin 20ef788178 v2.46 — Jahres-Recap (Spotify-Wrapped-Stil) mit Hörstatistiken
## Neuer Recap-Screen
- lib/screens/recap_screen.dart: Full-Screen mit 7 animierten Seiten (Intro, Top 5 Songs, Top 3 Künstler, Hörzeit, häufigster Tag, Nachtaktive %, Gesamtzahl + Teilen)
- Wrapped-Stil: Verlaufs-Gradienten, große Zahlen mit Count-up-Animation, Fade+Scale-Seitenwechsel, Fortschritts-Punkte, Weiter-Button
- Teilen-Button kopiert Recap-Text in die Zwischenablage (kein neues Package)

## Datenbasis
- lib/services/recap_service.dart: aggregiert lokale Abspiel-Historie zu Top-5-Songs, Top-3-Künstler, Hörzeit, Wochentag, Nacht-%, Gesamtzahl
- Fallback auf Gesamt-Historie wenn im Zieljahr keine Daten; freundlicher Leer-Zustand ohne History

## DB + Tracking
- db_helper.dart: Migration v6→v7 mit neuer Tabelle abspiel_historie (song_id, played_at) + Backfill aus wiedergabe_verlauf
- player_service.dart: registriert jeden Abspielvorgang in der Historie

## Einstieg
- Home-Screen: Karte 'Jahres-Recap 2026' unter der Statistik-Card
- Einstellungen: Kachel 'Jahres-Recap' in Sektion Daten & Privatsphäre

flutter analyze: 0 Issues, Tests 4/4
2026-08-03 02:54:11 +02:00

720 lines
22 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../services/recap_service.dart';
import '../utils/farb_theme.dart';
/// Jahres-Recap-Screen (im Stil von Spotify Wrapped / YT Music Recap).
///
/// Blättert durch animierte Seiten mit Hörstatistiken aus der lokalen
/// `abspiel_historie`-Tabelle. Keine externen Packages Animationen
/// laufen über Flutter-Bordmittel (AnimationController, TweenAnimationBuilder).
class RecapScreen extends StatefulWidget {
const RecapScreen({super.key});
@override
State<RecapScreen> createState() => _RecapScreenState();
}
class _RecapScreenState extends State<RecapScreen> {
final RecapService _service = RecapService();
late Future<RecapDaten?> _future;
final PageController _controller = PageController();
int _seite = 0;
@override
void initState() {
super.initState();
_future = _service.berechne();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _weiter() {
if (_seite < 6) {
_controller.nextPage(
duration: const Duration(milliseconds: 450),
curve: Curves.easeInOutCubic,
);
} else {
Navigator.pop(context);
}
}
Future<void> _teilen(RecapDaten d) async {
final text = StringBuffer()
..writeln('🎧 Melo Jahres-Recap ${d.jahr}')
..writeln('─────────────────')
..writeln('🔥 Top 5 Songs:');
for (var i = 0; i < d.topSongs.length; i++) {
text.writeln(' ${i + 1}. ${d.topSongs[i].song.titel} '
'${d.topSongs[i].song.kuenstler} (${d.topSongs[i].anzahl}×)');
}
text
..writeln('')
..writeln('⭐ Top 3 Künstler:');
for (var i = 0; i < d.topKuenstler.length; i++) {
text.writeln(' ${i + 1}. ${d.topKuenstler[i].name} '
'(${d.topKuenstler[i].anzahl}×)');
}
text
..writeln('')
..writeln('⏱ Hörzeit: ${d.hoerzeitFormatiert}')
..writeln('📅 Häufigster Tag: ${d.haeufigsterTag}')
..writeln('🌙 Nach 22 Uhr: ${d.nachtProzent.toStringAsFixed(0)}%')
..writeln('🎵 Gesamt: ${d.gesamtAnzahl} Abspielungen');
await Clipboard.setData(ClipboardData(text: text.toString()));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('📋 Recap in die Zwischenablage kopiert')),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: MeloTheme.schwarz,
body: FutureBuilder<RecapDaten?>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(
child: CircularProgressIndicator(color: MeloTheme.rot),
);
}
final daten = snapshot.data;
if (daten == null) return _leerZustand();
return _recapAnsicht(daten);
},
),
);
}
/// Freundlicher Leer-Zustand ohne Hör-Historie
Widget _leerZustand() {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('🎧', style: TextStyle(fontSize: 64)),
const SizedBox(height: 20),
const Text(
'Noch keine Hör-Historie',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
const SizedBox(height: 12),
const Text(
'Sobald du Musik abspielst, sammelt Melo deine Statistiken. '
'Schau bald wieder vorbei dann bekommst du deinen persönlichen '
'Jahres-Recap!',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: MeloTheme.textSekundaer, height: 1.5),
),
const SizedBox(height: 28),
ElevatedButton(
onPressed: () => Navigator.pop(context),
style: ElevatedButton.styleFrom(
backgroundColor: MeloTheme.rot,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30),
),
),
child: const Text('Schließen'),
),
],
),
),
);
}
Widget _recapAnsicht(RecapDaten d) {
return Stack(
children: [
// Verlaufs-Gradient-Hintergrund
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
const Color(0xFF0D0D0D),
const Color(0xFF2A0000),
const Color(0xFF1A0000),
],
),
),
),
),
SafeArea(
child: Column(
children: [
_kopfzeile(d),
Expanded(
child: PageView.builder(
controller: _controller,
itemCount: 7,
onPageChanged: (i) => setState(() => _seite = i),
itemBuilder: (_, i) => _RecapSeite(
aktiv: _seite == i,
child: _seitenInhalt(d, i),
),
),
),
_navigation(d),
],
),
),
],
);
}
Widget _kopfzeile(RecapDaten d) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
child: Row(
children: [
GestureDetector(
onTap: () => Navigator.pop(context),
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.08),
shape: BoxShape.circle,
),
child: const Icon(Icons.close, color: Colors.white70, size: 18),
),
),
const Spacer(),
Text(
'MELO RECAP ${d.jahr}',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
letterSpacing: 2,
color: MeloTheme.textSekundaer,
),
),
const Spacer(),
const SizedBox(width: 30),
],
),
);
}
Widget _navigation(RecapDaten d) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Fortschritts-Punkte
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(7, (i) {
final aktiv = i == _seite;
return AnimatedContainer(
duration: const Duration(milliseconds: 250),
margin: const EdgeInsets.symmetric(horizontal: 3),
width: aktiv ? 22 : 8,
height: 8,
decoration: BoxDecoration(
color: aktiv ? MeloTheme.rot : Colors.white24,
borderRadius: BorderRadius.circular(4),
),
);
}),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: _weiter,
style: ElevatedButton.styleFrom(
backgroundColor: MeloTheme.rot,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(26),
),
),
child: Text(
_seite < 6 ? 'Weiter' : 'Fertig',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
),
),
),
],
),
);
}
Widget _seitenInhalt(RecapDaten d, int index) {
switch (index) {
case 0:
return _introSeite(d);
case 1:
return _topSongsSeite(d);
case 2:
return _kuenstlerSeite(d);
case 3:
return _hoerzeitSeite(d);
case 4:
return _tagSeite(d);
case 5:
return _nachtSeite(d);
default:
return _outroSeite(d);
}
}
// ─── Seiten ───────────────────────────────────────
Widget _introSeite(RecapDaten d) {
return _seitenLayout(
kind: 'Einführung',
children: [
const Text('🎧', style: TextStyle(fontSize: 72)),
const SizedBox(height: 24),
const Text(
'Dein Jahr in Musik',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 15, color: MeloTheme.textSekundaer),
),
const SizedBox(height: 8),
Text(
'${d.jahr}',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 96,
fontWeight: FontWeight.w900,
color: Colors.white,
shadows: [Shadow(color: MeloTheme.rot, blurRadius: 40)],
),
),
const SizedBox(height: 12),
const Text(
'Melo hat deine Hörgewohnheiten\nanalysiert. Los geht\'s!',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 15, color: Colors.white70, height: 1.5),
),
],
);
}
Widget _topSongsSeite(RecapDaten d) {
return _seitenLayout(
kind: 'Meistgespielt',
title: 'Deine Top 5',
children: [
for (var i = 0; i < d.topSongs.length; i++) ...[
_topZeile(
rang: i + 1,
titel: d.topSongs[i].song.titel,
untertitel: d.topSongs[i].song.kuenstler,
anzahl: d.topSongs[i].anzahl,
),
if (i < d.topSongs.length - 1) const SizedBox(height: 12),
],
],
);
}
Widget _kuenstlerSeite(RecapDaten d) {
return _seitenLayout(
kind: 'Meistgehört',
title: 'Deine Top 3 Künstler',
children: [
for (var i = 0; i < d.topKuenstler.length; i++) ...[
_kuenstlerZeile(
rang: i + 1,
name: d.topKuenstler[i].name,
anzahl: d.topKuenstler[i].anzahl,
maxAnzahl: d.topKuenstler.first.anzahl,
),
if (i < d.topKuenstler.length - 1) const SizedBox(height: 18),
],
],
);
}
Widget _hoerzeitSeite(RecapDaten d) {
final stunden = d.gesamtSekunden ~/ 3600;
final minuten = (d.gesamtSekunden % 3600) ~/ 60;
return _seitenLayout(
kind: 'Hörzeit',
title: 'So viel Musik hast du gehört',
children: [
const SizedBox(height: 16),
_Zaehler(
ziel: stunden,
style: const TextStyle(
fontSize: 88,
fontWeight: FontWeight.w900,
color: Colors.white,
shadows: [Shadow(color: MeloTheme.rot, blurRadius: 40)],
),
),
const Text('Stunden',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w600, color: MeloTheme.rot)),
const SizedBox(height: 12),
_Zaehler(
ziel: minuten,
style: const TextStyle(
fontSize: 44,
fontWeight: FontWeight.w800,
color: Colors.white70,
),
),
const Text('Minuten',
style: TextStyle(fontSize: 16, color: MeloTheme.textSekundaer)),
],
);
}
Widget _tagSeite(RecapDaten d) {
return _seitenLayout(
kind: 'Dein Rhythmus',
title: 'Dein häufigster Tag',
children: [
const Text('📅', style: TextStyle(fontSize: 64)),
const SizedBox(height: 16),
Text(
d.haeufigsterTag,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 44,
fontWeight: FontWeight.w900,
color: Colors.white,
shadows: [Shadow(color: MeloTheme.rot, blurRadius: 36)],
),
),
const SizedBox(height: 12),
const Text(
'An diesem Tag lief bei dir\nam meisten Musik.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 15, color: Colors.white70, height: 1.5),
),
],
);
}
Widget _nachtSeite(RecapDaten d) {
final prozent = d.nachtProzent.toStringAsFixed(0);
return _seitenLayout(
kind: 'Nachtaktive',
title: 'Läuft bei dir die Nacht?',
children: [
const Text('🌙', style: TextStyle(fontSize: 64)),
const SizedBox(height: 16),
Text(
'$prozent%',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 88,
fontWeight: FontWeight.w900,
color: Colors.white,
shadows: [Shadow(color: MeloTheme.rot, blurRadius: 40)],
),
),
const SizedBox(height: 12),
const Text(
'deiner Musik hast du\nnach 22 Uhr gehört.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 15, color: Colors.white70, height: 1.5),
),
],
);
}
Widget _outroSeite(RecapDaten d) {
return _seitenLayout(
kind: 'Das war\'s',
title: 'Dein Recap ${d.jahr}',
children: [
_Zaehler(
ziel: d.gesamtAnzahl,
style: const TextStyle(
fontSize: 88,
fontWeight: FontWeight.w900,
color: Colors.white,
shadows: [Shadow(color: MeloTheme.rot, blurRadius: 40)],
),
),
const Text('Abspielungen insgesamt',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: MeloTheme.rot)),
const SizedBox(height: 20),
const Text('Danke fürs Zuhören ♥',
style: TextStyle(fontSize: 16, color: Colors.white70)),
const SizedBox(height: 24),
OutlinedButton.icon(
onPressed: () => _teilen(d),
icon: const Icon(Icons.share, size: 18),
label: const Text('Recap teilen'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(color: Colors.white38),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
),
],
);
}
// ─── Bausteine ────────────────────────────────────
Widget _seitenLayout({
required String kind,
String? title,
required List<Widget> children,
}) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
kind.toUpperCase(),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 2.5,
color: MeloTheme.rot,
),
),
if (title != null) ...[
const SizedBox(height: 6),
Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w800,
color: Colors.white,
),
),
],
const SizedBox(height: 32),
...children,
],
),
);
}
Widget _topZeile({
required int rang,
required String titel,
required String untertitel,
required int anzahl,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
),
child: Row(
children: [
Text(
'$rang',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.w900,
color: rang == 1 ? MeloTheme.rot : Colors.white38,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
titel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
Text(
untertitel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, color: MeloTheme.textSekundaer),
),
],
),
),
const SizedBox(width: 10),
Text(
'$anzahl×',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: MeloTheme.rot,
),
),
],
),
);
}
Widget _kuenstlerZeile({
required int rang,
required String name,
required int anzahl,
required int maxAnzahl,
}) {
final breite = maxAnzahl == 0 ? 0.0 : (anzahl / maxAnzahl).clamp(0.15, 1.0);
return Row(
children: [
Text(
'$rang',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w900,
color: rang == 1 ? MeloTheme.rot : Colors.white38,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: breite),
duration: const Duration(milliseconds: 900),
curve: Curves.easeOutCubic,
builder: (_, wert, _) => Container(
height: 8,
width: double.infinity,
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: wert,
child: Container(
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [MeloTheme.rot, Color(0xFF660000)],
),
borderRadius: BorderRadius.circular(4),
),
),
),
),
),
),
const SizedBox(height: 4),
Text(
'$anzahl Abspielungen',
style: const TextStyle(fontSize: 12, color: MeloTheme.textSekundaer),
),
],
),
),
],
);
}
}
/// Hüllen-Widget: blendet eine Seite beim Aktivwerden ein (Fade + Skalierung).
class _RecapSeite extends StatefulWidget {
final bool aktiv;
final Widget child;
const _RecapSeite({required this.aktiv, required this.child});
@override
State<_RecapSeite> createState() => _RecapSeiteState();
}
class _RecapSeiteState extends State<_RecapSeite>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 650),
);
late final Animation<double> _opacity =
CurvedAnimation(parent: _ctrl, curve: Curves.easeOut);
late final Animation<double> _scale = Tween(begin: 0.92, end: 1.0).animate(
CurvedAnimation(parent: _ctrl, curve: Curves.easeOutBack),
);
@override
void initState() {
super.initState();
if (widget.aktiv) _ctrl.forward();
}
@override
void didUpdateWidget(covariant _RecapSeite oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.aktiv && !oldWidget.aktiv) _ctrl.forward(from: 0);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _opacity,
child: ScaleTransition(scale: _scale, child: widget.child),
);
}
}
/// Zählt beim Erscheinen von 0 auf [ziel] hoch (für große Recap-Zahlen).
class _Zaehler extends StatelessWidget {
final int ziel;
final TextStyle style;
const _Zaehler({required this.ziel, required this.style});
@override
Widget build(BuildContext context) {
return TweenAnimationBuilder<int>(
tween: IntTween(begin: 0, end: ziel),
duration: const Duration(milliseconds: 1100),
curve: Curves.easeOutCubic,
builder: (_, wert, _) => Text(
'$wert',
style: style,
),
);
}
}