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, ), ); } }