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 (1–12) — 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 (0–100) final List topSongs; // Top 5 final List 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 _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>> Function() _historie; RecapService({Future>> Function()? historie}) { _historie = historie ?? _db.abspielHistorie; } static const List _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 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 = >[]; 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 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> daten, RecapZeitraum zeitraum, DateTime j) { // ── Aggregation ── final songCounts = {}; final songMap = {}; final kuenstlerCounts = {}; final wochentagCounts = List.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, ); } }