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:
@@ -1397,7 +1397,7 @@ class _MeloHomeState extends State<MeloHome> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Jahres-Recap ${DateTime.now().year}',
|
'Recap ${DateTime.now().year}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
@@ -1406,7 +1406,7 @@ class _MeloHomeState extends State<MeloHome> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
const Text(
|
const Text(
|
||||||
'Deine Top-Songs, Künstler & Hörzeit – wie Spotify Wrapped',
|
'Woche · Monat · Jahr – deine Top-Songs, Künstler & Hörzeit',
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(fontSize: 11, color: MeloTheme.textSekundaer),
|
style: TextStyle(fontSize: 11, color: MeloTheme.textSekundaer),
|
||||||
|
|||||||
@@ -21,10 +21,13 @@ class _RecapScreenState extends State<RecapScreen> {
|
|||||||
final PageController _controller = PageController();
|
final PageController _controller = PageController();
|
||||||
int _seite = 0;
|
int _seite = 0;
|
||||||
|
|
||||||
|
/// Gewählter Zeitraum (7 Tage / Monat / Jahr) — Umschalter oben im Recap.
|
||||||
|
RecapZeitraum _zeitraum = RecapZeitraum.jahr;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_future = _service.berechne();
|
_future = _service.berechne(zeitraum: _zeitraum);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -33,6 +36,19 @@ class _RecapScreenState extends State<RecapScreen> {
|
|||||||
super.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() {
|
void _weiter() {
|
||||||
if (_seite < 6) {
|
if (_seite < 6) {
|
||||||
_controller.nextPage(
|
_controller.nextPage(
|
||||||
@@ -46,7 +62,7 @@ class _RecapScreenState extends State<RecapScreen> {
|
|||||||
|
|
||||||
Future<void> _teilen(RecapDaten d) async {
|
Future<void> _teilen(RecapDaten d) async {
|
||||||
final text = StringBuffer()
|
final text = StringBuffer()
|
||||||
..writeln('🎧 Melo Jahres-Recap ${d.jahr}')
|
..writeln('🎧 ${d.shareTitel}')
|
||||||
..writeln('─────────────────')
|
..writeln('─────────────────')
|
||||||
..writeln('🔥 Top 5 Songs:');
|
..writeln('🔥 Top 5 Songs:');
|
||||||
for (var i = 0; i < d.topSongs.length; i++) {
|
for (var i = 0; i < d.topSongs.length; i++) {
|
||||||
@@ -163,6 +179,7 @@ class _RecapScreenState extends State<RecapScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_kopfzeile(d),
|
_kopfzeile(d),
|
||||||
|
_zeitraumUmschalter(),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: PageView.builder(
|
child: PageView.builder(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
@@ -200,7 +217,7 @@ class _RecapScreenState extends State<RecapScreen> {
|
|||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Text(
|
Text(
|
||||||
'MELO RECAP ${d.jahr}',
|
d.kopfzeile,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w700,
|
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) {
|
Widget _navigation(RecapDaten d) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||||
@@ -290,14 +357,14 @@ class _RecapScreenState extends State<RecapScreen> {
|
|||||||
children: [
|
children: [
|
||||||
const Text('🎧', style: TextStyle(fontSize: 72)),
|
const Text('🎧', style: TextStyle(fontSize: 72)),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
const Text(
|
Text(
|
||||||
'Dein Jahr in Musik',
|
d.introUntertitel,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(fontSize: 15, color: MeloTheme.textSekundaer),
|
style: const TextStyle(fontSize: 15, color: MeloTheme.textSekundaer),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'${d.jahr}',
|
d.introTitel,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 96,
|
fontSize: 96,
|
||||||
@@ -442,9 +509,12 @@ class _RecapScreenState extends State<RecapScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _outroSeite(RecapDaten d) {
|
Widget _outroSeite(RecapDaten d) {
|
||||||
|
final titel = d.zeitraum == RecapZeitraum.jahr
|
||||||
|
? 'Dein Recap ${d.jahr}'
|
||||||
|
: 'Dein Recap · ${d.zeitraumLabel}';
|
||||||
return _seitenLayout(
|
return _seitenLayout(
|
||||||
kind: 'Das war\'s',
|
kind: 'Das war\'s',
|
||||||
title: 'Dein Recap ${d.jahr}',
|
title: titel,
|
||||||
children: [
|
children: [
|
||||||
_Zaehler(
|
_Zaehler(
|
||||||
ziel: d.gesamtAnzahl,
|
ziel: d.gesamtAnzahl,
|
||||||
@@ -455,8 +525,16 @@ class _RecapScreenState extends State<RecapScreen> {
|
|||||||
shadows: [Shadow(color: MeloTheme.rot, blurRadius: 40)],
|
shadows: [Shadow(color: MeloTheme.rot, blurRadius: 40)],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Text('Abspielungen insgesamt',
|
Text(
|
||||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: MeloTheme.rot)),
|
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 SizedBox(height: 20),
|
||||||
const Text('Danke fürs Zuhören ♥',
|
const Text('Danke fürs Zuhören ♥',
|
||||||
style: TextStyle(fontSize: 16, color: Colors.white70)),
|
style: TextStyle(fontSize: 16, color: Colors.white70)),
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
_einstellungsKachel(
|
_einstellungsKachel(
|
||||||
icon: Icons.auto_graph,
|
icon: Icons.auto_graph,
|
||||||
titel: 'Jahres-Recap',
|
titel: 'Recap (Woche/Monat/Jahr)',
|
||||||
untertitel: 'Deine Hörstatistiken – wie Spotify Wrapped',
|
untertitel: 'Deine Hörstatistiken – wie Spotify Wrapped',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
|
|||||||
+143
-14
@@ -1,6 +1,18 @@
|
|||||||
import '../database/db_helper.dart';
|
import '../database/db_helper.dart';
|
||||||
import '../models/song.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
|
/// Ein Song mit seiner Abspiel-Anzahl im Recap
|
||||||
class RecapSongStat {
|
class RecapSongStat {
|
||||||
final Song song;
|
final Song song;
|
||||||
@@ -19,7 +31,17 @@ class RecapKuenstlerStat {
|
|||||||
|
|
||||||
/// Aggregierte Recap-Daten (wie Spotify Wrapped)
|
/// Aggregierte Recap-Daten (wie Spotify Wrapped)
|
||||||
class RecapDaten {
|
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;
|
final int jahr;
|
||||||
|
|
||||||
|
/// Anker-Monat (1–12) — nur für [RecapZeitraum.monat] relevant.
|
||||||
|
final int monat;
|
||||||
|
|
||||||
final int gesamtAnzahl; // Gesamtzahl aller Abspielungen
|
final int gesamtAnzahl; // Gesamtzahl aller Abspielungen
|
||||||
final int gesamtSekunden; // Geschätzte Hörzeit (Summe Songdauer je Abspielung)
|
final int gesamtSekunden; // Geschätzte Hörzeit (Summe Songdauer je Abspielung)
|
||||||
final String haeufigsterTag; // z.B. "Freitag"
|
final String haeufigsterTag; // z.B. "Freitag"
|
||||||
@@ -28,7 +50,9 @@ class RecapDaten {
|
|||||||
final List<RecapKuenstlerStat> topKuenstler; // Top 3
|
final List<RecapKuenstlerStat> topKuenstler; // Top 3
|
||||||
|
|
||||||
RecapDaten({
|
RecapDaten({
|
||||||
|
required this.zeitraum,
|
||||||
required this.jahr,
|
required this.jahr,
|
||||||
|
required this.monat,
|
||||||
required this.gesamtAnzahl,
|
required this.gesamtAnzahl,
|
||||||
required this.gesamtSekunden,
|
required this.gesamtSekunden,
|
||||||
required this.haeufigsterTag,
|
required this.haeufigsterTag,
|
||||||
@@ -37,6 +61,48 @@ class RecapDaten {
|
|||||||
required this.topKuenstler,
|
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 {
|
String get hoerzeitFormatiert {
|
||||||
final stunden = gesamtSekunden ~/ 3600;
|
final stunden = gesamtSekunden ~/ 3600;
|
||||||
final minuten = (gesamtSekunden % 3600) ~/ 60;
|
final minuten = (gesamtSekunden % 3600) ~/ 60;
|
||||||
@@ -48,32 +114,93 @@ class RecapDaten {
|
|||||||
/// Aggregiert die lokale Abspiel-Historie zu Recap-Statistiken.
|
/// Aggregiert die lokale Abspiel-Historie zu Recap-Statistiken.
|
||||||
///
|
///
|
||||||
/// Datenquelle ist die lokale `abspiel_historie`-Tabelle (nicht der Server),
|
/// 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 {
|
class RecapService {
|
||||||
final DbHelper _db = DbHelper();
|
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 = [
|
static const List<String> _wochentage = [
|
||||||
'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag',
|
'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag',
|
||||||
'Freitag', 'Samstag', 'Sonntag',
|
'Freitag', 'Samstag', 'Sonntag',
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Berechnet den Recap. Filtert auf [jahr] (Standard: aktuelles Jahr).
|
/// Berechnet den Recap für [zeitraum] (Standard: aktuelles Jahr).
|
||||||
/// Falls im gewählten Jahr keine Daten existieren, wird auf die
|
///
|
||||||
/// Gesamt-Historie zurückgegriffen. Liefert `null` bei leerer History.
|
/// Fallback-Kette: Woche → Monat → Jahr → Gesamt-Historie. Liefert `null`
|
||||||
Future<RecapDaten?> berechne({int? jahr}) async {
|
/// bei komplett leerer History. [jetzt] ist injizierbar (Tests).
|
||||||
final zielJahr = jahr ?? DateTime.now().year;
|
Future<RecapDaten?> berechne({
|
||||||
var rows = await _db.abspielHistorie();
|
RecapZeitraum zeitraum = RecapZeitraum.jahr,
|
||||||
|
DateTime? jetzt,
|
||||||
|
}) async {
|
||||||
|
final j = jetzt ?? DateTime.now();
|
||||||
|
final rows = await _historie();
|
||||||
|
|
||||||
if (rows.isEmpty) return null;
|
if (rows.isEmpty) return null;
|
||||||
|
|
||||||
final gefiltert = rows.where((r) {
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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? ?? '');
|
final t = DateTime.tryParse(r['played_at'] as String? ?? '');
|
||||||
return t != null && t.year == zielJahr;
|
return t != null && !t.isBefore(grenze);
|
||||||
}).toList();
|
};
|
||||||
|
case RecapZeitraum.monat:
|
||||||
// Fallback: wenn im Zieljahr nichts gehört wurde → gesamte History
|
return (r) {
|
||||||
final daten = gefiltert.isNotEmpty ? gefiltert : rows;
|
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 ──
|
// ── Aggregation ──
|
||||||
final songCounts = <int, int>{};
|
final songCounts = <int, int>{};
|
||||||
final songMap = <int, Song>{};
|
final songMap = <int, Song>{};
|
||||||
@@ -133,7 +260,9 @@ class RecapService {
|
|||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
return RecapDaten(
|
return RecapDaten(
|
||||||
jahr: zielJahr,
|
zeitraum: zeitraum,
|
||||||
|
jahr: j.year,
|
||||||
|
monat: j.month,
|
||||||
gesamtAnzahl: gesamtAnzahl,
|
gesamtAnzahl: gesamtAnzahl,
|
||||||
gesamtSekunden: gesamtSekunden,
|
gesamtSekunden: gesamtSekunden,
|
||||||
haeufigsterTag: _wochentage[maxTag],
|
haeufigsterTag: _wochentage[maxTag],
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:melo_app/services/recap_service.dart';
|
||||||
|
|
||||||
|
/// RecapService-Tests mit injiziertem Historie-Loader (kein DB-Zugriff).
|
||||||
|
/// Abgedeckt: Zeitraum-Filter (Woche/Monat/Jahr), Fallback-Kette
|
||||||
|
/// (Woche → Monat → Jahr → Gesamt), Aggregation + Label-Getter.
|
||||||
|
void main() {
|
||||||
|
// Fester Anker-Zeitpunkt: 5. August 2026, 15:00 Uhr
|
||||||
|
final jetzt = DateTime(2026, 8, 5, 15, 0, 0);
|
||||||
|
|
||||||
|
Map<String, dynamic> zeile(
|
||||||
|
int id,
|
||||||
|
String playedAt, {
|
||||||
|
String titel = 'Song',
|
||||||
|
String kuenstler = 'Künstler',
|
||||||
|
int dauer = 200,
|
||||||
|
}) =>
|
||||||
|
{
|
||||||
|
'song_id': id,
|
||||||
|
'played_at': playedAt,
|
||||||
|
'titel': titel,
|
||||||
|
'kuenstler': kuenstler,
|
||||||
|
'dauer_sekunden': dauer,
|
||||||
|
};
|
||||||
|
|
||||||
|
RecapService service(List<Map<String, dynamic>> rows) =>
|
||||||
|
RecapService(historie: () async => rows);
|
||||||
|
|
||||||
|
test('leere History → null (alle Zeiträume)', () async {
|
||||||
|
expect(await service([]).berechne(jetzt: jetzt), isNull);
|
||||||
|
expect(await service([]).berechne(zeitraum: RecapZeitraum.woche, jetzt: jetzt),
|
||||||
|
isNull);
|
||||||
|
expect(
|
||||||
|
await service([])
|
||||||
|
.berechne(zeitraum: RecapZeitraum.monat, jetzt: jetzt),
|
||||||
|
isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Jahr', () {
|
||||||
|
test('nur Einträge des aktuellen Jahres zählen', () async {
|
||||||
|
final rows = [
|
||||||
|
zeile(1, '2026-03-01T10:00:00'),
|
||||||
|
zeile(2, '2026-08-01T10:00:00'),
|
||||||
|
zeile(3, '2025-12-31T23:59:00'), // anderes Jahr → raus
|
||||||
|
];
|
||||||
|
final d = await service(rows).berechne(jetzt: jetzt);
|
||||||
|
expect(d, isNotNull);
|
||||||
|
expect(d!.gesamtAnzahl, 2);
|
||||||
|
expect(d.zeitraum, RecapZeitraum.jahr);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Jahr ohne Daten → Gesamt-Historie (alle Einträge)', () async {
|
||||||
|
final rows = [
|
||||||
|
zeile(1, '2024-01-01T10:00:00'),
|
||||||
|
zeile(2, '2023-06-15T10:00:00'),
|
||||||
|
];
|
||||||
|
final d = await service(rows).berechne(jetzt: jetzt);
|
||||||
|
expect(d, isNotNull);
|
||||||
|
expect(d!.gesamtAnzahl, 2); // Fallback auf Gesamt
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Monat', () {
|
||||||
|
test('nur Einträge des aktuellen Kalendermonats zählen', () async {
|
||||||
|
final rows = [
|
||||||
|
zeile(1, '2026-08-01T10:00:00'),
|
||||||
|
zeile(2, '2026-08-05T08:00:00'),
|
||||||
|
zeile(3, '2026-07-31T23:59:00'), // Juli → raus
|
||||||
|
zeile(4, '2026-01-15T10:00:00'), // Januar → raus
|
||||||
|
];
|
||||||
|
final d =
|
||||||
|
await service(rows).berechne(zeitraum: RecapZeitraum.monat, jetzt: jetzt);
|
||||||
|
expect(d, isNotNull);
|
||||||
|
expect(d!.gesamtAnzahl, 2);
|
||||||
|
expect(d.zeitraum, RecapZeitraum.monat);
|
||||||
|
expect(d.monatsName, 'August');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Monat ohne Daten → nächstgrößerer Zeitraum (Jahr)', () async {
|
||||||
|
final rows = [
|
||||||
|
zeile(1, '2026-03-01T10:00:00'),
|
||||||
|
zeile(2, '2026-05-15T10:00:00'),
|
||||||
|
];
|
||||||
|
final d =
|
||||||
|
await service(rows).berechne(zeitraum: RecapZeitraum.monat, jetzt: jetzt);
|
||||||
|
expect(d, isNotNull);
|
||||||
|
expect(d!.zeitraum, RecapZeitraum.jahr); // Fallback Monat → Jahr
|
||||||
|
expect(d.gesamtAnzahl, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Woche', () {
|
||||||
|
test('nur Einträge der letzten 7 Tage zählen', () async {
|
||||||
|
final rows = [
|
||||||
|
zeile(1, '2026-08-04T10:00:00'), // innerhalb 7 Tage
|
||||||
|
zeile(2, '2026-07-30T12:00:00'), // genau 6 Tage vorher → drin
|
||||||
|
zeile(3, '2026-07-29T12:00:00'), // > 7 Tage → raus
|
||||||
|
zeile(4, '2026-01-01T10:00:00'), // alt → raus
|
||||||
|
];
|
||||||
|
final d =
|
||||||
|
await service(rows).berechne(zeitraum: RecapZeitraum.woche, jetzt: jetzt);
|
||||||
|
expect(d, isNotNull);
|
||||||
|
expect(d!.gesamtAnzahl, 2);
|
||||||
|
expect(d.zeitraum, RecapZeitraum.woche);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Woche ohne Daten → Monat (nächstgrößerer Zeitraum)', () async {
|
||||||
|
// Anker 20.8.: 7-Tage-Grenze = 13.8. → 10.8. liegt im Monat, aber
|
||||||
|
// außerhalb der letzten 7 Tage.
|
||||||
|
final spaeter = DateTime(2026, 8, 20, 15, 0, 0);
|
||||||
|
final rows = [
|
||||||
|
zeile(1, '2026-08-10T10:00:00'),
|
||||||
|
];
|
||||||
|
final d = await service(rows)
|
||||||
|
.berechne(zeitraum: RecapZeitraum.woche, jetzt: spaeter);
|
||||||
|
expect(d, isNotNull);
|
||||||
|
expect(d!.zeitraum, RecapZeitraum.monat);
|
||||||
|
expect(d.gesamtAnzahl, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Woche UND Monat ohne Daten → Jahr', () async {
|
||||||
|
final rows = [
|
||||||
|
zeile(1, '2026-03-01T10:00:00'),
|
||||||
|
];
|
||||||
|
final d =
|
||||||
|
await service(rows).berechne(zeitraum: RecapZeitraum.woche, jetzt: jetzt);
|
||||||
|
expect(d, isNotNull);
|
||||||
|
expect(d!.zeitraum, RecapZeitraum.jahr);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Aggregation', () {
|
||||||
|
test('Top 5 Songs + Top 3 Künstler + Hörzeit + Nacht-%', () async {
|
||||||
|
final rows = [
|
||||||
|
// Song A 3×, Song B 2×, Song C 1×
|
||||||
|
zeile(1, '2026-08-01T10:00:00', titel: 'A', kuenstler: 'K1', dauer: 300),
|
||||||
|
zeile(1, '2026-08-02T10:00:00', titel: 'A', kuenstler: 'K1', dauer: 300),
|
||||||
|
zeile(1, '2026-08-03T23:00:00', titel: 'A', kuenstler: 'K1', dauer: 300), // nach 22 Uhr
|
||||||
|
zeile(2, '2026-08-01T11:00:00', titel: 'B', kuenstler: 'K1', dauer: 240),
|
||||||
|
zeile(2, '2026-08-02T11:00:00', titel: 'B', kuenstler: 'K1', dauer: 240),
|
||||||
|
zeile(3, '2026-08-01T12:00:00', titel: 'C', kuenstler: 'K2', dauer: 180),
|
||||||
|
];
|
||||||
|
final d =
|
||||||
|
await service(rows).berechne(zeitraum: RecapZeitraum.monat, jetzt: jetzt);
|
||||||
|
expect(d, isNotNull);
|
||||||
|
expect(d!.gesamtAnzahl, 6);
|
||||||
|
expect(d.topSongs.length, 3);
|
||||||
|
expect(d.topSongs.first.song.titel, 'A');
|
||||||
|
expect(d.topSongs.first.anzahl, 3);
|
||||||
|
expect(d.topKuenstler.length, 2);
|
||||||
|
expect(d.topKuenstler.first.name, 'K1');
|
||||||
|
expect(d.topKuenstler.first.anzahl, 5);
|
||||||
|
// Hörzeit: 3×300 + 2×240 + 1×180 = 1560s = 26 Min
|
||||||
|
expect(d.gesamtSekunden, 1560);
|
||||||
|
expect(d.hoerzeitFormatiert, '26 Min');
|
||||||
|
// 1 von 6 Abspielungen nach 22 Uhr → 17%
|
||||||
|
expect(d.nachtProzent, 17);
|
||||||
|
expect(d.haeufigsterTag, isNotEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Label-Getter', () {
|
||||||
|
test('Woche: Kopfzeile, Intro, Teilen', () {
|
||||||
|
final d = RecapDaten(
|
||||||
|
zeitraum: RecapZeitraum.woche,
|
||||||
|
jahr: 2026,
|
||||||
|
monat: 8,
|
||||||
|
gesamtAnzahl: 12,
|
||||||
|
gesamtSekunden: 3600,
|
||||||
|
haeufigsterTag: 'Freitag',
|
||||||
|
nachtProzent: 30,
|
||||||
|
topSongs: [],
|
||||||
|
topKuenstler: [],
|
||||||
|
);
|
||||||
|
expect(d.kopfzeile, 'MELO RECAP · 7 TAGE');
|
||||||
|
expect(d.introTitel, '7 Tage');
|
||||||
|
expect(d.introUntertitel, 'Deine Woche in Musik');
|
||||||
|
expect(d.zeitraumLabel, 'Diese Woche');
|
||||||
|
expect(d.shareTitel, 'Melo Wochen-Recap');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Monat: Kopfzeile, Intro, Teilen', () {
|
||||||
|
final d = RecapDaten(
|
||||||
|
zeitraum: RecapZeitraum.monat,
|
||||||
|
jahr: 2026,
|
||||||
|
monat: 8,
|
||||||
|
gesamtAnzahl: 12,
|
||||||
|
gesamtSekunden: 3600,
|
||||||
|
haeufigsterTag: 'Freitag',
|
||||||
|
nachtProzent: 30,
|
||||||
|
topSongs: [],
|
||||||
|
topKuenstler: [],
|
||||||
|
);
|
||||||
|
expect(d.kopfzeile, 'MELO RECAP · AUGUST');
|
||||||
|
expect(d.introTitel, 'August');
|
||||||
|
expect(d.introUntertitel, 'Dein Monat in Musik');
|
||||||
|
expect(d.zeitraumLabel, 'Dein Monat');
|
||||||
|
expect(d.shareTitel, 'Melo Monats-Recap August');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Jahr: Kopfzeile, Intro, Teilen', () {
|
||||||
|
final d = RecapDaten(
|
||||||
|
zeitraum: RecapZeitraum.jahr,
|
||||||
|
jahr: 2026,
|
||||||
|
monat: 8,
|
||||||
|
gesamtAnzahl: 12,
|
||||||
|
gesamtSekunden: 3600,
|
||||||
|
haeufigsterTag: 'Freitag',
|
||||||
|
nachtProzent: 30,
|
||||||
|
topSongs: [],
|
||||||
|
topKuenstler: [],
|
||||||
|
);
|
||||||
|
expect(d.kopfzeile, 'MELO RECAP 2026');
|
||||||
|
expect(d.introTitel, '2026');
|
||||||
|
expect(d.introUntertitel, 'Dein Jahr in Musik');
|
||||||
|
expect(d.zeitraumLabel, 'Dein Jahr');
|
||||||
|
expect(d.shareTitel, 'Melo Jahres-Recap 2026');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user