v2.46 — Jahres-Recap (Spotify-Wrapped-Stil) mit Hörstatistiken

## Neuer Recap-Screen
- lib/screens/recap_screen.dart: Full-Screen mit 7 animierten Seiten (Intro, Top 5 Songs, Top 3 Künstler, Hörzeit, häufigster Tag, Nachtaktive %, Gesamtzahl + Teilen)
- Wrapped-Stil: Verlaufs-Gradienten, große Zahlen mit Count-up-Animation, Fade+Scale-Seitenwechsel, Fortschritts-Punkte, Weiter-Button
- Teilen-Button kopiert Recap-Text in die Zwischenablage (kein neues Package)

## Datenbasis
- lib/services/recap_service.dart: aggregiert lokale Abspiel-Historie zu Top-5-Songs, Top-3-Künstler, Hörzeit, Wochentag, Nacht-%, Gesamtzahl
- Fallback auf Gesamt-Historie wenn im Zieljahr keine Daten; freundlicher Leer-Zustand ohne History

## DB + Tracking
- db_helper.dart: Migration v6→v7 mit neuer Tabelle abspiel_historie (song_id, played_at) + Backfill aus wiedergabe_verlauf
- player_service.dart: registriert jeden Abspielvorgang in der Historie

## Einstieg
- Home-Screen: Karte 'Jahres-Recap 2026' unter der Statistik-Card
- Einstellungen: Kachel 'Jahres-Recap' in Sektion Daten & Privatsphäre

flutter analyze: 0 Issues, Tests 4/4
This commit is contained in:
Dustin
2026-08-03 02:54:11 +02:00
parent cd49e80293
commit 20ef788178
6 changed files with 1022 additions and 1 deletions
+68 -1
View File
@@ -20,7 +20,7 @@ class DbHelper {
final pfad = await getDatabasesPath();
return openDatabase(
p.join(pfad, 'melo.db'),
version: 6,
version: 7,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE songs (
@@ -87,6 +87,18 @@ class DbHelper {
FOREIGN KEY (song_id) REFERENCES songs(id) ON DELETE CASCADE
)
''');
await db.execute('''
CREATE TABLE abspiel_historie (
id INTEGER PRIMARY KEY AUTOINCREMENT,
song_id INTEGER NOT NULL,
played_at TEXT NOT NULL,
FOREIGN KEY (song_id) REFERENCES songs(id) ON DELETE CASCADE
)
''');
await db.execute(
'CREATE INDEX IF NOT EXISTS i_hist_song ON abspiel_historie(song_id)');
await db.execute(
'CREATE INDEX IF NOT EXISTS i_hist_played ON abspiel_historie(played_at)');
},
onUpgrade: (db, oldVersion, newVersion) async {
if (oldVersion < 2) {
@@ -142,6 +154,30 @@ class DbHelper {
try { await db.execute('ALTER TABLE songs ADD COLUMN genre TEXT'); } catch (_) {}
try { await db.execute('ALTER TABLE songs ADD COLUMN track TEXT'); } catch (_) {}
}
if (oldVersion < 7) {
try {
await db.execute('''
CREATE TABLE IF NOT EXISTS abspiel_historie (
id INTEGER PRIMARY KEY AUTOINCREMENT,
song_id INTEGER NOT NULL,
played_at TEXT NOT NULL,
FOREIGN KEY (song_id) REFERENCES songs(id) ON DELETE CASCADE
)
''');
await db.execute(
'CREATE INDEX IF NOT EXISTS i_hist_song ON abspiel_historie(song_id)');
await db.execute(
'CREATE INDEX IF NOT EXISTS i_hist_played ON abspiel_historie(played_at)');
// Backfill: bisherige "zuletzt abgespielt"-Einträge als Historie übernehmen
await db.rawInsert('''
INSERT OR IGNORE INTO abspiel_historie (song_id, played_at)
SELECT song_id, zuletzt_abgespielt FROM wiedergabe_verlauf
WHERE zuletzt_abgespielt IS NOT NULL
''');
} catch (_) {
// Historie-Tabelle konnte nicht angelegt werden ignorieren
}
}
},
);
}
@@ -224,6 +260,36 @@ class DbHelper {
return rows.map((r) => Song.fromMap(r)).toList();
}
// ─── Abspiel-Historie (für Jahres-Recap) ─────────
/// Registriert einen Abspielvorgang (Zeitpunkt wird jetzt gesetzt).
/// Dient als Datenbasis für den Jahres-Recap.
Future<void> abspielRegistrieren(int songId) async {
final d = await db;
await d.insert('abspiel_historie', {
'song_id': songId,
'played_at': DateTime.now().toIso8601String(),
});
}
/// Rohe Abspiel-Historie: song_id + played_at (für Recap-Aggregation)
Future<List<Map<String, dynamic>>> abspielHistorie() async {
final d = await db;
final rows = await d.rawQuery('''
SELECT h.song_id, h.played_at, s.titel, s.kuenstler, s.dauer_sekunden
FROM abspiel_historie h
JOIN songs s ON s.id = h.song_id
ORDER BY h.played_at ASC
''');
return rows;
}
/// Löscht die komplette Abspiel-Historie (für "Alle Daten löschen")
Future<void> abspielHistorieLoeschen() async {
final d = await db;
await d.delete('abspiel_historie');
}
/// Markiert einen Song als korrupt
Future<void> alsKorruptMarkieren(int songId) async {
final d = await db;
@@ -463,6 +529,7 @@ class DbHelper {
Future<void> loeschen() async {
final d = await db;
await d.transaction((txn) async {
await txn.delete('abspiel_historie');
await txn.delete('wiedergabe_verlauf');
await txn.delete('song_tags');
await txn.delete('playlist_songs');