## 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
275 lines
8.9 KiB
Dart
275 lines
8.9 KiB
Dart
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<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,
|
||
);
|
||
}
|
||
}
|