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
Dustin 22ce611537 v2.53 — Sprint E: Recap-Zeiträume (Woche/Monat/Jahr)
## Recap-Zeiträume
- recap_service.dart: RecapZeitraum-Enum (woche/monat/jahr), berechne(zeitraum:, jetzt:) mit injizierbarem Historie-Loader (Tests ohne DB), Fallback-Kette Woche → Monat → Jahr → Gesamt-Historie, Zeitraum-Getter (kopfzeile, introTitel, introUntertitel, zeitraumLabel, shareTitel, Monatsname)
- recap_screen.dart: Zeitraum-Umschalter (7 Tage / Monat / Jahr) oben im Recap, dynamische Texte („Deine Woche in Musik“, „Abspielungen in dieser Woche“, „Melo Wochen-Recap“), Seiten-Reset bei Wechsel
- home_screen.dart: Recap-Karte „Recap 2026“ + „Woche · Monat · Jahr“-Untertitel
- settings_screen.dart: Kachel „Recap (Woche/Monat/Jahr)“
- test/recap_service_test.dart: 12 neue Tests (Filter, Fallback-Kette, Aggregation, Label-Getter)
- flutter analyze 0 Issues, Tests 123/123
2026-08-05 11:10:49 +02:00

798 lines
24 KiB
Dart
Raw Permalink 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;
/// Gewählter Zeitraum (7 Tage / Monat / Jahr) — Umschalter oben im Recap.
RecapZeitraum _zeitraum = RecapZeitraum.jahr;
@override
void initState() {
super.initState();
_future = _service.berechne(zeitraum: _zeitraum);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
/// Zeitraum wechseln: Recap neu berechnen + zurück zur ersten Seite.
void _zeitraumSetzen(RecapZeitraum z) {
if (z == _zeitraum) return;
setState(() {
_zeitraum = z;
_future = _service.berechne(zeitraum: z);
_seite = 0;
});
if (_controller.hasClients) {
_controller.jumpToPage(0);
}
}
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('🎧 ${d.shareTitel}')
..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),
_zeitraumUmschalter(),
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(
d.kopfzeile,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
letterSpacing: 2,
color: MeloTheme.textSekundaer,
),
),
const Spacer(),
const SizedBox(width: 30),
],
),
);
}
/// Zeitraum-Umschalter (7 Tage / Monat / Jahr) oben im Recap.
Widget _zeitraumUmschalter() {
const optionen = [
(RecapZeitraum.woche, '7 Tage'),
(RecapZeitraum.monat, 'Monat'),
(RecapZeitraum.jahr, 'Jahr'),
];
return Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white.withValues(alpha: 0.1)),
),
child: Row(
children: optionen.map((o) {
final aktiv = _zeitraum == o.$1;
return Expanded(
child: GestureDetector(
onTap: () => _zeitraumSetzen(o.$1),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 9),
decoration: BoxDecoration(
color: aktiv ? MeloTheme.rot : Colors.transparent,
borderRadius: BorderRadius.circular(9),
),
child: Center(
child: Text(
o.$2,
style: TextStyle(
color: aktiv
? Colors.white
: MeloTheme.textSekundaer,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
),
),
),
);
}).toList(),
),
),
);
}
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),
Text(
d.introUntertitel,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 15, color: MeloTheme.textSekundaer),
),
const SizedBox(height: 8),
Text(
d.introTitel,
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) {
final titel = d.zeitraum == RecapZeitraum.jahr
? 'Dein Recap ${d.jahr}'
: 'Dein Recap · ${d.zeitraumLabel}';
return _seitenLayout(
kind: 'Das war\'s',
title: titel,
children: [
_Zaehler(
ziel: d.gesamtAnzahl,
style: const TextStyle(
fontSize: 88,
fontWeight: FontWeight.w900,
color: Colors.white,
shadows: [Shadow(color: MeloTheme.rot, blurRadius: 40)],
),
),
Text(
switch (d.zeitraum) {
RecapZeitraum.woche => 'Abspielungen in dieser Woche',
RecapZeitraum.monat => 'Abspielungen in diesem Monat',
RecapZeitraum.jahr => 'Abspielungen insgesamt',
},
textAlign: TextAlign.center,
style: const 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,
),
);
}
}