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
This commit is contained in:
Dustin
2026-08-05 11:10:49 +02:00
parent bc3357bf72
commit 22ce611537
5 changed files with 454 additions and 27 deletions
+2 -2
View File
@@ -1397,7 +1397,7 @@ class _MeloHomeState extends State<MeloHome> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Jahres-Recap ${DateTime.now().year}',
'Recap ${DateTime.now().year}',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
@@ -1406,7 +1406,7 @@ class _MeloHomeState extends State<MeloHome> {
),
const SizedBox(height: 2),
const Text(
'Deine Top-Songs, Künstler & Hörzeit wie Spotify Wrapped',
'Woche · Monat · Jahr deine Top-Songs, Künstler & Hörzeit',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11, color: MeloTheme.textSekundaer),
+88 -10
View File
@@ -21,10 +21,13 @@ class _RecapScreenState extends State<RecapScreen> {
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();
_future = _service.berechne(zeitraum: _zeitraum);
}
@override
@@ -33,6 +36,19 @@ class _RecapScreenState extends State<RecapScreen> {
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(
@@ -46,7 +62,7 @@ class _RecapScreenState extends State<RecapScreen> {
Future<void> _teilen(RecapDaten d) async {
final text = StringBuffer()
..writeln('🎧 Melo Jahres-Recap ${d.jahr}')
..writeln('🎧 ${d.shareTitel}')
..writeln('─────────────────')
..writeln('🔥 Top 5 Songs:');
for (var i = 0; i < d.topSongs.length; i++) {
@@ -163,6 +179,7 @@ class _RecapScreenState extends State<RecapScreen> {
child: Column(
children: [
_kopfzeile(d),
_zeitraumUmschalter(),
Expanded(
child: PageView.builder(
controller: _controller,
@@ -200,7 +217,7 @@ class _RecapScreenState extends State<RecapScreen> {
),
const Spacer(),
Text(
'MELO RECAP ${d.jahr}',
d.kopfzeile,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
@@ -215,6 +232,56 @@ class _RecapScreenState extends State<RecapScreen> {
);
}
/// 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),
@@ -290,14 +357,14 @@ class _RecapScreenState extends State<RecapScreen> {
children: [
const Text('🎧', style: TextStyle(fontSize: 72)),
const SizedBox(height: 24),
const Text(
'Dein Jahr in Musik',
Text(
d.introUntertitel,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 15, color: MeloTheme.textSekundaer),
style: const TextStyle(fontSize: 15, color: MeloTheme.textSekundaer),
),
const SizedBox(height: 8),
Text(
'${d.jahr}',
d.introTitel,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 96,
@@ -442,9 +509,12 @@ class _RecapScreenState extends State<RecapScreen> {
}
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: 'Dein Recap ${d.jahr}',
title: titel,
children: [
_Zaehler(
ziel: d.gesamtAnzahl,
@@ -455,8 +525,16 @@ class _RecapScreenState extends State<RecapScreen> {
shadows: [Shadow(color: MeloTheme.rot, blurRadius: 40)],
),
),
const Text('Abspielungen insgesamt',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: MeloTheme.rot)),
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)),
+1 -1
View File
@@ -233,7 +233,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
_einstellungsKachel(
icon: Icons.auto_graph,
titel: 'Jahres-Recap',
titel: 'Recap (Woche/Monat/Jahr)',
untertitel: 'Deine Hörstatistiken wie Spotify Wrapped',
onTap: () {
Navigator.push(
+143 -14
View File
@@ -1,6 +1,18 @@
import '../database/db_helper.dart';
import '../models/song.dart';
/// Zeitraum für den Recap (Spotify-Wrapped-Stil)
enum RecapZeitraum {
/// Letzte 7 Tage
woche,
/// Aktueller Kalendermonat
monat,
/// Aktuelles Jahr
jahr,
}
/// Ein Song mit seiner Abspiel-Anzahl im Recap
class RecapSongStat {
final Song song;
@@ -19,7 +31,17 @@ class RecapKuenstlerStat {
/// Aggregierte Recap-Daten (wie Spotify Wrapped)
class RecapDaten {
/// Tatsächlich verwendeter Zeitraum (nach Fallback-Kette Woche → Monat →
/// Jahr → Gesamt-Historie). Kann vom angeforderten abweichen, wenn für
/// den gewünschten Zeitraum keine Daten existieren.
final RecapZeitraum zeitraum;
/// Anker-Jahr des Zeitraums (für „Monat“: das Jahr des Monats).
final int jahr;
/// Anker-Monat (112) — nur für [RecapZeitraum.monat] relevant.
final int monat;
final int gesamtAnzahl; // Gesamtzahl aller Abspielungen
final int gesamtSekunden; // Geschätzte Hörzeit (Summe Songdauer je Abspielung)
final String haeufigsterTag; // z.B. "Freitag"
@@ -28,7 +50,9 @@ class RecapDaten {
final List<RecapKuenstlerStat> topKuenstler; // Top 3
RecapDaten({
required this.zeitraum,
required this.jahr,
required this.monat,
required this.gesamtAnzahl,
required this.gesamtSekunden,
required this.haeufigsterTag,
@@ -37,6 +61,48 @@ class RecapDaten {
required this.topKuenstler,
});
static const List<String> _monatsnamen = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
];
String get monatsName => _monatsnamen[(monat - 1).clamp(0, 11)];
/// Überschrift des Recap-Titels, z.B. „MELO RECAP 2026“.
String get kopfzeile => switch (zeitraum) {
RecapZeitraum.woche => 'MELO RECAP · 7 TAGE',
RecapZeitraum.monat => 'MELO RECAP · ${monatsName.toUpperCase()}',
RecapZeitraum.jahr => 'MELO RECAP $jahr',
};
/// Große Zahl/Titel auf der Intro-Seite (Jahr, Monatsname oder „7 Tage“).
String get introTitel => switch (zeitraum) {
RecapZeitraum.woche => '7 Tage',
RecapZeitraum.monat => monatsName,
RecapZeitraum.jahr => '$jahr',
};
/// Untertitel der Intro-Seite („Deine Woche in Musik“ …).
String get introUntertitel => switch (zeitraum) {
RecapZeitraum.woche => 'Deine Woche in Musik',
RecapZeitraum.monat => 'Dein Monat in Musik',
RecapZeitraum.jahr => 'Dein Jahr in Musik',
};
/// Kurzlabel des Zeitraums (für Statistik-Texte).
String get zeitraumLabel => switch (zeitraum) {
RecapZeitraum.woche => 'Diese Woche',
RecapZeitraum.monat => 'Dein Monat',
RecapZeitraum.jahr => 'Dein Jahr',
};
/// Text für die Teilen-Funktion, z.B. „Melo Jahres-Recap 2026“.
String get shareTitel => switch (zeitraum) {
RecapZeitraum.woche => 'Melo Wochen-Recap',
RecapZeitraum.monat => 'Melo Monats-Recap $monatsName',
RecapZeitraum.jahr => 'Melo Jahres-Recap $jahr',
};
String get hoerzeitFormatiert {
final stunden = gesamtSekunden ~/ 3600;
final minuten = (gesamtSekunden % 3600) ~/ 60;
@@ -48,32 +114,93 @@ class RecapDaten {
/// Aggregiert die lokale Abspiel-Historie zu Recap-Statistiken.
///
/// Datenquelle ist die lokale `abspiel_historie`-Tabelle (nicht der Server),
/// da die History lokal aufgezeichnet wird.
/// da die History lokal aufgezeichnet wird. Unterstützt die Zeiträume
/// Woche (letzte 7 Tage), Monat (aktueller Kalendermonat) und Jahr
/// (aktuelles Jahr). Fallback: Wenn im gewünschten Zeitraum keine Daten
/// existieren, wird der nächstgrößere Zeitraum verwendet.
class RecapService {
final DbHelper _db = DbHelper();
/// Injizierbarer Historie-Loader (Tests liefern Fake-Daten; Produktion
/// liest die `abspiel_historie`-Tabelle).
late final Future<List<Map<String, dynamic>>> Function() _historie;
RecapService({Future<List<Map<String, dynamic>>> Function()? historie}) {
_historie = historie ?? _db.abspielHistorie;
}
static const List<String> _wochentage = [
'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag',
'Freitag', 'Samstag', 'Sonntag',
];
/// Berechnet den Recap. Filtert auf [jahr] (Standard: aktuelles Jahr).
/// Falls im gewählten Jahr keine Daten existieren, wird auf die
/// Gesamt-Historie zurückgegriffen. Liefert `null` bei leerer History.
Future<RecapDaten?> berechne({int? jahr}) async {
final zielJahr = jahr ?? DateTime.now().year;
var rows = await _db.abspielHistorie();
/// Berechnet den Recap für [zeitraum] (Standard: aktuelles Jahr).
///
/// Fallback-Kette: Woche → Monat → Jahr → Gesamt-Historie. Liefert `null`
/// bei komplett leerer History. [jetzt] ist injizierbar (Tests).
Future<RecapDaten?> berechne({
RecapZeitraum zeitraum = RecapZeitraum.jahr,
DateTime? jetzt,
}) async {
final j = jetzt ?? DateTime.now();
final rows = await _historie();
if (rows.isEmpty) return null;
final gefiltert = rows.where((r) {
final t = DateTime.tryParse(r['played_at'] as String? ?? '');
return t != null && t.year == zielJahr;
}).toList();
// Zeitraum-Filter in der Fallback-Reihenfolge (Woche → Monat → Jahr →
// Gesamt-Historie): erst der gewünschte Zeitraum, danach der jeweils
// nächstgrößere.
final kette = switch (zeitraum) {
RecapZeitraum.woche => const [
RecapZeitraum.woche, RecapZeitraum.monat, RecapZeitraum.jahr,
],
RecapZeitraum.monat => const [RecapZeitraum.monat, RecapZeitraum.jahr],
RecapZeitraum.jahr => const [RecapZeitraum.jahr],
};
RecapZeitraum genutzterZeitraum = zeitraum;
var daten = <Map<String, dynamic>>[];
for (final z in kette) {
final gefiltert = rows.where(_filterFuer(z, j)).toList();
if (gefiltert.isNotEmpty) {
genutzterZeitraum = z;
daten = gefiltert;
break;
}
}
if (daten.isEmpty) {
// Nichts im (größten) Zeitraum, aber History vorhanden →
// Gesamt-Historie (wie bisher beim Jahres-Fallback).
daten = rows;
}
// Fallback: wenn im Zieljahr nichts gehört wurde → gesamte History
final daten = gefiltert.isNotEmpty ? gefiltert : rows;
return _aggregiere(daten, genutzterZeitraum, j);
}
/// Filter-Funktion für einen Zeitraum.
bool Function(Map<String, dynamic> row) _filterFuer(
RecapZeitraum zeitraum, DateTime j) {
switch (zeitraum) {
case RecapZeitraum.woche:
final grenze = j.subtract(const Duration(days: 7));
return (r) {
final t = DateTime.tryParse(r['played_at'] as String? ?? '');
return t != null && !t.isBefore(grenze);
};
case RecapZeitraum.monat:
return (r) {
final t = DateTime.tryParse(r['played_at'] as String? ?? '');
return t != null && t.year == j.year && t.month == j.month;
};
case RecapZeitraum.jahr:
return (r) {
final t = DateTime.tryParse(r['played_at'] as String? ?? '');
return t != null && t.year == j.year;
};
}
}
RecapDaten _aggregiere(
List<Map<String, dynamic>> daten, RecapZeitraum zeitraum, DateTime j) {
// ── Aggregation ──
final songCounts = <int, int>{};
final songMap = <int, Song>{};
@@ -133,7 +260,9 @@ class RecapService {
.toList();
return RecapDaten(
jahr: zielJahr,
zeitraum: zeitraum,
jahr: j.year,
monat: j.month,
gesamtAnzahl: gesamtAnzahl,
gesamtSekunden: gesamtSekunden,
haeufigsterTag: _wochentage[maxTag],