## 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
798 lines
24 KiB
Dart
798 lines
24 KiB
Dart
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<RecapScreen> createState() => _RecapScreenState();
|
||
}
|
||
|
||
class _RecapScreenState extends State<RecapScreen> {
|
||
final RecapService _service = RecapService();
|
||
late Future<RecapDaten?> _future;
|
||
final PageController _controller = PageController();
|
||
int _seite = 0;
|
||
|
||
/// Gewählter Zeitraum (7 Tage / Monat / Jahr) — Umschalter oben im Recap.
|
||
RecapZeitraum _zeitraum = RecapZeitraum.jahr;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_future = _service.berechne(zeitraum: _zeitraum);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_controller.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
/// Zeitraum wechseln: Recap neu berechnen + zurück zur ersten Seite.
|
||
void _zeitraumSetzen(RecapZeitraum z) {
|
||
if (z == _zeitraum) return;
|
||
setState(() {
|
||
_zeitraum = z;
|
||
_future = _service.berechne(zeitraum: z);
|
||
_seite = 0;
|
||
});
|
||
if (_controller.hasClients) {
|
||
_controller.jumpToPage(0);
|
||
}
|
||
}
|
||
|
||
void _weiter() {
|
||
if (_seite < 6) {
|
||
_controller.nextPage(
|
||
duration: const Duration(milliseconds: 450),
|
||
curve: Curves.easeInOutCubic,
|
||
);
|
||
} else {
|
||
Navigator.pop(context);
|
||
}
|
||
}
|
||
|
||
Future<void> _teilen(RecapDaten d) async {
|
||
final text = StringBuffer()
|
||
..writeln('🎧 ${d.shareTitel}')
|
||
..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<RecapDaten?>(
|
||
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),
|
||
_zeitraumUmschalter(),
|
||
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(
|
||
d.kopfzeile,
|
||
style: const TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w700,
|
||
letterSpacing: 2,
|
||
color: MeloTheme.textSekundaer,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
const SizedBox(width: 30),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Zeitraum-Umschalter (7 Tage / Monat / Jahr) oben im Recap.
|
||
Widget _zeitraumUmschalter() {
|
||
const optionen = [
|
||
(RecapZeitraum.woche, '7 Tage'),
|
||
(RecapZeitraum.monat, 'Monat'),
|
||
(RecapZeitraum.jahr, 'Jahr'),
|
||
];
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
|
||
child: Container(
|
||
padding: const EdgeInsets.all(4),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white.withValues(alpha: 0.06),
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(color: Colors.white.withValues(alpha: 0.1)),
|
||
),
|
||
child: Row(
|
||
children: optionen.map((o) {
|
||
final aktiv = _zeitraum == o.$1;
|
||
return Expanded(
|
||
child: GestureDetector(
|
||
onTap: () => _zeitraumSetzen(o.$1),
|
||
child: AnimatedContainer(
|
||
duration: const Duration(milliseconds: 200),
|
||
padding: const EdgeInsets.symmetric(vertical: 9),
|
||
decoration: BoxDecoration(
|
||
color: aktiv ? MeloTheme.rot : Colors.transparent,
|
||
borderRadius: BorderRadius.circular(9),
|
||
),
|
||
child: Center(
|
||
child: Text(
|
||
o.$2,
|
||
style: TextStyle(
|
||
color: aktiv
|
||
? Colors.white
|
||
: MeloTheme.textSekundaer,
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}).toList(),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
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),
|
||
Text(
|
||
d.introUntertitel,
|
||
textAlign: TextAlign.center,
|
||
style: const TextStyle(fontSize: 15, color: MeloTheme.textSekundaer),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
d.introTitel,
|
||
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) {
|
||
final titel = d.zeitraum == RecapZeitraum.jahr
|
||
? 'Dein Recap ${d.jahr}'
|
||
: 'Dein Recap · ${d.zeitraumLabel}';
|
||
return _seitenLayout(
|
||
kind: 'Das war\'s',
|
||
title: titel,
|
||
children: [
|
||
_Zaehler(
|
||
ziel: d.gesamtAnzahl,
|
||
style: const TextStyle(
|
||
fontSize: 88,
|
||
fontWeight: FontWeight.w900,
|
||
color: Colors.white,
|
||
shadows: [Shadow(color: MeloTheme.rot, blurRadius: 40)],
|
||
),
|
||
),
|
||
Text(
|
||
switch (d.zeitraum) {
|
||
RecapZeitraum.woche => 'Abspielungen in dieser Woche',
|
||
RecapZeitraum.monat => 'Abspielungen in diesem Monat',
|
||
RecapZeitraum.jahr => 'Abspielungen insgesamt',
|
||
},
|
||
textAlign: TextAlign.center,
|
||
style: const 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<Widget> 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<double>(
|
||
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<double> _opacity =
|
||
CurvedAnimation(parent: _ctrl, curve: Curves.easeOut);
|
||
late final Animation<double> _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<int>(
|
||
tween: IntTween(begin: 0, end: ziel),
|
||
duration: const Duration(milliseconds: 1100),
|
||
curve: Curves.easeOutCubic,
|
||
builder: (_, wert, _) => Text(
|
||
'$wert',
|
||
style: style,
|
||
),
|
||
);
|
||
}
|
||
}
|