From 20ef7881783529c38dfe77400d2cd7c46a58e950 Mon Sep 17 00:00:00 2001 From: Dustin Date: Mon, 3 Aug 2026 02:54:11 +0200 Subject: [PATCH] =?UTF-8?q?v2.46=20=E2=80=94=20Jahres-Recap=20(Spotify-Wra?= =?UTF-8?q?pped-Stil)=20mit=20H=C3=B6rstatistiken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- lib/database/db_helper.dart | 69 ++- lib/screens/home_screen.dart | 69 +++ lib/screens/recap_screen.dart | 719 +++++++++++++++++++++++++++++++ lib/screens/settings_screen.dart | 13 + lib/services/player_service.dart | 8 + lib/services/recap_service.dart | 145 +++++++ 6 files changed, 1022 insertions(+), 1 deletion(-) create mode 100644 lib/screens/recap_screen.dart create mode 100644 lib/services/recap_service.dart diff --git a/lib/database/db_helper.dart b/lib/database/db_helper.dart index 16800b6..083d3fe 100644 --- a/lib/database/db_helper.dart +++ b/lib/database/db_helper.dart @@ -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 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>> 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 abspielHistorieLoeschen() async { + final d = await db; + await d.delete('abspiel_historie'); + } + /// Markiert einen Song als korrupt Future alsKorruptMarkieren(int songId) async { final d = await db; @@ -463,6 +529,7 @@ class DbHelper { Future 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'); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 8f75c88..81b2bfa 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -13,6 +13,7 @@ import 'download_screen.dart'; import 'cloud_screen.dart'; import 'settings_screen.dart'; import 'login_screen.dart'; +import 'recap_screen.dart'; import '../services/cloud_service.dart'; import '../services/auth_service.dart'; import '../config/app_config.dart'; @@ -312,6 +313,8 @@ class _MeloHomeState extends State { gesamtMin: gesamtMin, anzahlFavoriten: _vm.favoritenIds.length, ), + // ─── Jahres-Recap (wie Spotify Wrapped) ─── + _recapKarte(), // ─── EIN/AUS: Hidden Message "Seit 2008" ─── // Entferne die Kommentarzeichen um die Botschaft zu aktivieren: if (_vm.zeigeBotschaft) _botschaftBanner(), @@ -462,6 +465,72 @@ class _MeloHomeState extends State { ); } + /// Karte „Jahres-Recap“ – öffnet den Wrapped-artigen Recap-Screen + Widget _recapKarte() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const RecapScreen()), + ); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF3A0000), Color(0xFF1A0000), Color(0xFF0D0D0D)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: MeloTheme.rot.withValues(alpha: 0.45)), + ), + child: Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: MeloTheme.rot.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(12), + ), + child: const Center( + child: Text('🎧', style: TextStyle(fontSize: 20)), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Jahres-Recap ${DateTime.now().year}', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + const SizedBox(height: 2), + const Text( + 'Deine Top-Songs, Künstler & Hörzeit – wie Spotify Wrapped', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 11, color: MeloTheme.textSekundaer), + ), + ], + ), + ), + const Icon(Icons.chevron_right, color: MeloTheme.rot, size: 22), + ], + ), + ), + ), + ); + } + /// Banner "💌 Seit 2008" – erscheint nach 10 Playbacks Widget _botschaftBanner() { return Padding( diff --git a/lib/screens/recap_screen.dart b/lib/screens/recap_screen.dart new file mode 100644 index 0000000..f36571a --- /dev/null +++ b/lib/screens/recap_screen.dart @@ -0,0 +1,719 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../services/recap_service.dart'; +import '../utils/farb_theme.dart'; + +/// Jahres-Recap-Screen (im Stil von Spotify Wrapped / YT Music Recap). +/// +/// Blättert durch animierte Seiten mit Hörstatistiken aus der lokalen +/// `abspiel_historie`-Tabelle. Keine externen Packages – Animationen +/// laufen über Flutter-Bordmittel (AnimationController, TweenAnimationBuilder). +class RecapScreen extends StatefulWidget { + const RecapScreen({super.key}); + + @override + State createState() => _RecapScreenState(); +} + +class _RecapScreenState extends State { + final RecapService _service = RecapService(); + late Future _future; + final PageController _controller = PageController(); + int _seite = 0; + + @override + void initState() { + super.initState(); + _future = _service.berechne(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _weiter() { + if (_seite < 6) { + _controller.nextPage( + duration: const Duration(milliseconds: 450), + curve: Curves.easeInOutCubic, + ); + } else { + Navigator.pop(context); + } + } + + Future _teilen(RecapDaten d) async { + final text = StringBuffer() + ..writeln('🎧 Melo Jahres-Recap ${d.jahr}') + ..writeln('─────────────────') + ..writeln('🔥 Top 5 Songs:'); + for (var i = 0; i < d.topSongs.length; i++) { + text.writeln(' ${i + 1}. ${d.topSongs[i].song.titel} – ' + '${d.topSongs[i].song.kuenstler} (${d.topSongs[i].anzahl}×)'); + } + text + ..writeln('') + ..writeln('⭐ Top 3 Künstler:'); + for (var i = 0; i < d.topKuenstler.length; i++) { + text.writeln(' ${i + 1}. ${d.topKuenstler[i].name} ' + '(${d.topKuenstler[i].anzahl}×)'); + } + text + ..writeln('') + ..writeln('⏱ Hörzeit: ${d.hoerzeitFormatiert}') + ..writeln('📅 Häufigster Tag: ${d.haeufigsterTag}') + ..writeln('🌙 Nach 22 Uhr: ${d.nachtProzent.toStringAsFixed(0)}%') + ..writeln('🎵 Gesamt: ${d.gesamtAnzahl} Abspielungen'); + await Clipboard.setData(ClipboardData(text: text.toString())); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('📋 Recap in die Zwischenablage kopiert')), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: MeloTheme.schwarz, + body: FutureBuilder( + future: _future, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center( + child: CircularProgressIndicator(color: MeloTheme.rot), + ); + } + final daten = snapshot.data; + if (daten == null) return _leerZustand(); + return _recapAnsicht(daten); + }, + ), + ); + } + + /// Freundlicher Leer-Zustand ohne Hör-Historie + Widget _leerZustand() { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('🎧', style: TextStyle(fontSize: 64)), + const SizedBox(height: 20), + const Text( + 'Noch keine Hör-Historie', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + const SizedBox(height: 12), + const Text( + 'Sobald du Musik abspielst, sammelt Melo deine Statistiken. ' + 'Schau bald wieder vorbei – dann bekommst du deinen persönlichen ' + 'Jahres-Recap!', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 14, color: MeloTheme.textSekundaer, height: 1.5), + ), + const SizedBox(height: 28), + ElevatedButton( + onPressed: () => Navigator.pop(context), + style: ElevatedButton.styleFrom( + backgroundColor: MeloTheme.rot, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(30), + ), + ), + child: const Text('Schließen'), + ), + ], + ), + ), + ); + } + + Widget _recapAnsicht(RecapDaten d) { + return Stack( + children: [ + // Verlaufs-Gradient-Hintergrund + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + const Color(0xFF0D0D0D), + const Color(0xFF2A0000), + const Color(0xFF1A0000), + ], + ), + ), + ), + ), + SafeArea( + child: Column( + children: [ + _kopfzeile(d), + Expanded( + child: PageView.builder( + controller: _controller, + itemCount: 7, + onPageChanged: (i) => setState(() => _seite = i), + itemBuilder: (_, i) => _RecapSeite( + aktiv: _seite == i, + child: _seitenInhalt(d, i), + ), + ), + ), + _navigation(d), + ], + ), + ), + ], + ); + } + + Widget _kopfzeile(RecapDaten d) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), + child: Row( + children: [ + GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.08), + shape: BoxShape.circle, + ), + child: const Icon(Icons.close, color: Colors.white70, size: 18), + ), + ), + const Spacer(), + Text( + 'MELO RECAP ${d.jahr}', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 2, + color: MeloTheme.textSekundaer, + ), + ), + const Spacer(), + const SizedBox(width: 30), + ], + ), + ); + } + + Widget _navigation(RecapDaten d) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Fortschritts-Punkte + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(7, (i) { + final aktiv = i == _seite; + return AnimatedContainer( + duration: const Duration(milliseconds: 250), + margin: const EdgeInsets.symmetric(horizontal: 3), + width: aktiv ? 22 : 8, + height: 8, + decoration: BoxDecoration( + color: aktiv ? MeloTheme.rot : Colors.white24, + borderRadius: BorderRadius.circular(4), + ), + ); + }), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: _weiter, + style: ElevatedButton.styleFrom( + backgroundColor: MeloTheme.rot, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(26), + ), + ), + child: Text( + _seite < 6 ? 'Weiter' : 'Fertig', + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700), + ), + ), + ), + ], + ), + ); + } + + Widget _seitenInhalt(RecapDaten d, int index) { + switch (index) { + case 0: + return _introSeite(d); + case 1: + return _topSongsSeite(d); + case 2: + return _kuenstlerSeite(d); + case 3: + return _hoerzeitSeite(d); + case 4: + return _tagSeite(d); + case 5: + return _nachtSeite(d); + default: + return _outroSeite(d); + } + } + + // ─── Seiten ─────────────────────────────────────── + + Widget _introSeite(RecapDaten d) { + return _seitenLayout( + kind: 'Einführung', + children: [ + const Text('🎧', style: TextStyle(fontSize: 72)), + const SizedBox(height: 24), + const Text( + 'Dein Jahr in Musik', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 15, color: MeloTheme.textSekundaer), + ), + const SizedBox(height: 8), + Text( + '${d.jahr}', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 96, + fontWeight: FontWeight.w900, + color: Colors.white, + shadows: [Shadow(color: MeloTheme.rot, blurRadius: 40)], + ), + ), + const SizedBox(height: 12), + const Text( + 'Melo hat deine Hörgewohnheiten\nanalysiert. Los geht\'s!', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 15, color: Colors.white70, height: 1.5), + ), + ], + ); + } + + Widget _topSongsSeite(RecapDaten d) { + return _seitenLayout( + kind: 'Meistgespielt', + title: 'Deine Top 5', + children: [ + for (var i = 0; i < d.topSongs.length; i++) ...[ + _topZeile( + rang: i + 1, + titel: d.topSongs[i].song.titel, + untertitel: d.topSongs[i].song.kuenstler, + anzahl: d.topSongs[i].anzahl, + ), + if (i < d.topSongs.length - 1) const SizedBox(height: 12), + ], + ], + ); + } + + Widget _kuenstlerSeite(RecapDaten d) { + return _seitenLayout( + kind: 'Meistgehört', + title: 'Deine Top 3 Künstler', + children: [ + for (var i = 0; i < d.topKuenstler.length; i++) ...[ + _kuenstlerZeile( + rang: i + 1, + name: d.topKuenstler[i].name, + anzahl: d.topKuenstler[i].anzahl, + maxAnzahl: d.topKuenstler.first.anzahl, + ), + if (i < d.topKuenstler.length - 1) const SizedBox(height: 18), + ], + ], + ); + } + + Widget _hoerzeitSeite(RecapDaten d) { + final stunden = d.gesamtSekunden ~/ 3600; + final minuten = (d.gesamtSekunden % 3600) ~/ 60; + return _seitenLayout( + kind: 'Hörzeit', + title: 'So viel Musik hast du gehört', + children: [ + const SizedBox(height: 16), + _Zaehler( + ziel: stunden, + style: const TextStyle( + fontSize: 88, + fontWeight: FontWeight.w900, + color: Colors.white, + shadows: [Shadow(color: MeloTheme.rot, blurRadius: 40)], + ), + ), + const Text('Stunden', + style: TextStyle(fontSize: 22, fontWeight: FontWeight.w600, color: MeloTheme.rot)), + const SizedBox(height: 12), + _Zaehler( + ziel: minuten, + style: const TextStyle( + fontSize: 44, + fontWeight: FontWeight.w800, + color: Colors.white70, + ), + ), + const Text('Minuten', + style: TextStyle(fontSize: 16, color: MeloTheme.textSekundaer)), + ], + ); + } + + Widget _tagSeite(RecapDaten d) { + return _seitenLayout( + kind: 'Dein Rhythmus', + title: 'Dein häufigster Tag', + children: [ + const Text('📅', style: TextStyle(fontSize: 64)), + const SizedBox(height: 16), + Text( + d.haeufigsterTag, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 44, + fontWeight: FontWeight.w900, + color: Colors.white, + shadows: [Shadow(color: MeloTheme.rot, blurRadius: 36)], + ), + ), + const SizedBox(height: 12), + const Text( + 'An diesem Tag lief bei dir\nam meisten Musik.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 15, color: Colors.white70, height: 1.5), + ), + ], + ); + } + + Widget _nachtSeite(RecapDaten d) { + final prozent = d.nachtProzent.toStringAsFixed(0); + return _seitenLayout( + kind: 'Nachtaktive', + title: 'Läuft bei dir die Nacht?', + children: [ + const Text('🌙', style: TextStyle(fontSize: 64)), + const SizedBox(height: 16), + Text( + '$prozent%', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 88, + fontWeight: FontWeight.w900, + color: Colors.white, + shadows: [Shadow(color: MeloTheme.rot, blurRadius: 40)], + ), + ), + const SizedBox(height: 12), + const Text( + 'deiner Musik hast du\nnach 22 Uhr gehört.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 15, color: Colors.white70, height: 1.5), + ), + ], + ); + } + + Widget _outroSeite(RecapDaten d) { + return _seitenLayout( + kind: 'Das war\'s', + title: 'Dein Recap ${d.jahr}', + children: [ + _Zaehler( + ziel: d.gesamtAnzahl, + style: const TextStyle( + fontSize: 88, + fontWeight: FontWeight.w900, + color: Colors.white, + shadows: [Shadow(color: MeloTheme.rot, blurRadius: 40)], + ), + ), + const Text('Abspielungen insgesamt', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: MeloTheme.rot)), + const SizedBox(height: 20), + const Text('Danke fürs Zuhören ♥', + style: TextStyle(fontSize: 16, color: Colors.white70)), + const SizedBox(height: 24), + OutlinedButton.icon( + onPressed: () => _teilen(d), + icon: const Icon(Icons.share, size: 18), + label: const Text('Recap teilen'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: Colors.white38), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + ), + ), + ], + ); + } + + // ─── Bausteine ──────────────────────────────────── + + Widget _seitenLayout({ + required String kind, + String? title, + required List children, + }) { + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + kind.toUpperCase(), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 2.5, + color: MeloTheme.rot, + ), + ), + if (title != null) ...[ + const SizedBox(height: 6), + Text( + title, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + ], + const SizedBox(height: 32), + ...children, + ], + ), + ); + } + + Widget _topZeile({ + required int rang, + required String titel, + required String untertitel, + required int anzahl, + }) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.white.withValues(alpha: 0.08)), + ), + child: Row( + children: [ + Text( + '$rang', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w900, + color: rang == 1 ? MeloTheme.rot : Colors.white38, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + titel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + Text( + untertitel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12, color: MeloTheme.textSekundaer), + ), + ], + ), + ), + const SizedBox(width: 10), + Text( + '$anzahl×', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + color: MeloTheme.rot, + ), + ), + ], + ), + ); + } + + Widget _kuenstlerZeile({ + required int rang, + required String name, + required int anzahl, + required int maxAnzahl, + }) { + final breite = maxAnzahl == 0 ? 0.0 : (anzahl / maxAnzahl).clamp(0.15, 1.0); + return Row( + children: [ + Text( + '$rang', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w900, + color: rang == 1 ? MeloTheme.rot : Colors.white38, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: TweenAnimationBuilder( + tween: Tween(begin: 0, end: breite), + duration: const Duration(milliseconds: 900), + curve: Curves.easeOutCubic, + builder: (_, wert, _) => Container( + height: 8, + width: double.infinity, + alignment: Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: wert, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [MeloTheme.rot, Color(0xFF660000)], + ), + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ), + ), + ), + const SizedBox(height: 4), + Text( + '$anzahl Abspielungen', + style: const TextStyle(fontSize: 12, color: MeloTheme.textSekundaer), + ), + ], + ), + ), + ], + ); + } +} + +/// Hüllen-Widget: blendet eine Seite beim Aktivwerden ein (Fade + Skalierung). +class _RecapSeite extends StatefulWidget { + final bool aktiv; + final Widget child; + + const _RecapSeite({required this.aktiv, required this.child}); + + @override + State<_RecapSeite> createState() => _RecapSeiteState(); +} + +class _RecapSeiteState extends State<_RecapSeite> + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 650), + ); + late final Animation _opacity = + CurvedAnimation(parent: _ctrl, curve: Curves.easeOut); + late final Animation _scale = Tween(begin: 0.92, end: 1.0).animate( + CurvedAnimation(parent: _ctrl, curve: Curves.easeOutBack), + ); + + @override + void initState() { + super.initState(); + if (widget.aktiv) _ctrl.forward(); + } + + @override + void didUpdateWidget(covariant _RecapSeite oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.aktiv && !oldWidget.aktiv) _ctrl.forward(from: 0); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: _opacity, + child: ScaleTransition(scale: _scale, child: widget.child), + ); + } +} + +/// Zählt beim Erscheinen von 0 auf [ziel] hoch (für große Recap-Zahlen). +class _Zaehler extends StatelessWidget { + final int ziel; + final TextStyle style; + + const _Zaehler({required this.ziel, required this.style}); + + @override + Widget build(BuildContext context) { + return TweenAnimationBuilder( + tween: IntTween(begin: 0, end: ziel), + duration: const Duration(milliseconds: 1100), + curve: Curves.easeOutCubic, + builder: (_, wert, _) => Text( + '$wert', + style: style, + ), + ); + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index c50fe45..552653d 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -3,6 +3,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../config/app_config.dart'; import '../utils/farb_theme.dart'; import 'cloud_screen.dart'; +import 'recap_screen.dart'; import '../services/cloud_service.dart'; /// Vollwertiger Einstellungen-Screen – kein Popup mehr @@ -79,6 +80,18 @@ class _SettingsScreenState extends State { // ─── Sektion: Daten ─── _sektionHeader('Daten & Privatsphäre'), + _einstellungsKachel( + icon: Icons.auto_graph, + titel: 'Jahres-Recap', + untertitel: 'Deine Hörstatistiken – wie Spotify Wrapped', + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const RecapScreen()), + ); + }, + ), + const SizedBox(height: 8), Container( decoration: BoxDecoration( color: MeloTheme.dunkel1, diff --git a/lib/services/player_service.dart b/lib/services/player_service.dart index c5f0408..d94e91b 100644 --- a/lib/services/player_service.dart +++ b/lib/services/player_service.dart @@ -67,6 +67,14 @@ class PlayerService { if (position > 0) await _p.seek(Duration(seconds: position)); await _p.play(); _songWechsel.add(song); + // Abspiel-Historie für den Jahres-Recap registrieren + if (song.id != null) { + try { + await DbHelper().abspielRegistrieren(song.id!); + } catch (e) { + debugPrint('Abspiel-Historie fehlgeschlagen: $e'); + } + } } catch (e) { debugPrint('Fehler beim Abspielen: $e'); MeloLogger().fehler('player_abspiel_fehler', e); diff --git a/lib/services/recap_service.dart b/lib/services/recap_service.dart new file mode 100644 index 0000000..e29b8f5 --- /dev/null +++ b/lib/services/recap_service.dart @@ -0,0 +1,145 @@ +import '../database/db_helper.dart'; +import '../models/song.dart'; + +/// 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 { + final int jahr; + 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.jahr, + required this.gesamtAnzahl, + required this.gesamtSekunden, + required this.haeufigsterTag, + required this.nachtProzent, + required this.topSongs, + required this.topKuenstler, + }); + + 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. +class RecapService { + final DbHelper _db = DbHelper(); + + static const List _wochentage = [ + 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', + 'Freitag', 'Samstag', 'Sonntag', + ]; + + /// Berechnet den Recap. Filtert auf [jahr] (Standard: aktuelles Jahr). + /// Falls im gewählten Jahr keine Daten existieren, wird auf die + /// Gesamt-Historie zurückgegriffen. Liefert `null` bei leerer History. + Future berechne({int? jahr}) async { + final zielJahr = jahr ?? DateTime.now().year; + var rows = await _db.abspielHistorie(); + + if (rows.isEmpty) return null; + + final gefiltert = rows.where((r) { + final t = DateTime.tryParse(r['played_at'] as String? ?? ''); + return t != null && t.year == zielJahr; + }).toList(); + + // Fallback: wenn im Zieljahr nichts gehört wurde → gesamte History + final daten = gefiltert.isNotEmpty ? gefiltert : rows; + + // ── 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( + jahr: zielJahr, + gesamtAnzahl: gesamtAnzahl, + gesamtSekunden: gesamtSekunden, + haeufigsterTag: _wochentage[maxTag], + nachtProzent: nachtProzent, + topSongs: topSongs, + topKuenstler: topKuenstler, + ); + } +}