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/services/recap_service.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

275 lines
8.9 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 '../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;
final int anzahl;
RecapSongStat(this.song, this.anzahl);
}
/// Ein Künstler mit seiner Abspiel-Anzahl im Recap
class RecapKuenstlerStat {
final String name;
final int anzahl;
RecapKuenstlerStat(this.name, this.anzahl);
}
/// 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"
final double nachtProzent; // % der Abspielungen nach 22 Uhr (0100)
final List<RecapSongStat> topSongs; // Top 5
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,
required this.nachtProzent,
required this.topSongs,
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;
if (stunden > 0) return '$stunden Std $minuten Min';
return '$minuten Min';
}
}
/// Aggregiert die lokale Abspiel-Historie zu Recap-Statistiken.
///
/// Datenquelle ist die lokale `abspiel_historie`-Tabelle (nicht der Server),
/// 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 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;
// 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? ?? '');
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>{};
final kuenstlerCounts = <String, int>{};
final wochentagCounts = List<int>.filled(7, 0);
var nachtCount = 0;
var gesamtSekunden = 0;
for (final r in daten) {
final songId = r['song_id'] as int;
songCounts[songId] = (songCounts[songId] ?? 0) + 1;
songMap[songId] ??= Song.fromMap({
'id': songId,
'titel': r['titel'] ?? '',
'kuenstler': r['kuenstler'] ?? 'Unbekannt',
'dauer_sekunden': r['dauer_sekunden'] ?? 0,
'datei_pfad': '',
});
final kuenstler = (r['kuenstler'] as String?) ?? 'Unbekannt';
kuenstlerCounts[kuenstler] = (kuenstlerCounts[kuenstler] ?? 0) + 1;
final t = DateTime.tryParse(r['played_at'] as String? ?? '');
if (t != null) {
wochentagCounts[t.weekday - 1]++;
if (t.hour >= 22) nachtCount++;
}
gesamtSekunden += (r['dauer_sekunden'] as int?) ?? 0;
}
final gesamtAnzahl = daten.length;
final nachtProzent = gesamtAnzahl == 0
? 0.0
: (nachtCount * 100 / gesamtAnzahl).roundToDouble();
// Häufigster Wochentag (bei Gleichstand der erste)
var maxTag = 0;
for (var i = 1; i < 7; i++) {
if (wochentagCounts[i] > wochentagCounts[maxTag]) maxTag = i;
}
// Top 5 Songs
final sortierteSongs = songCounts.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
final topSongs = sortierteSongs
.take(5)
.map((e) => RecapSongStat(songMap[e.key]!, e.value))
.toList();
// Top 3 Künstler
final sortierteKuenstler = kuenstlerCounts.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
final topKuenstler = sortierteKuenstler
.take(3)
.map((e) => RecapKuenstlerStat(e.key, e.value))
.toList();
return RecapDaten(
zeitraum: zeitraum,
jahr: j.year,
monat: j.month,
gesamtAnzahl: gesamtAnzahl,
gesamtSekunden: gesamtSekunden,
haeufigsterTag: _wochentage[maxTag],
nachtProzent: nachtProzent,
topSongs: topSongs,
topKuenstler: topKuenstler,
);
}
}