diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c837db..af12d9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,34 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## [Unreleased] +### ✨ Staggered List-Enter-Animation: Listen faden beim ersten Erscheinen gestaffelt ein (2026-08-29) + +- ✅ **Teil 1 einer Animations-Serie** (inspiriert von Xiaomis HyperOS- + Design-Sprache — fließende statt abrupte Bewegung): neues Widget + `EinblendItem` (`lib/shared/einblend_item.dart`) blendet ein Listenelement + beim ersten Erscheinen mit Opacity 0→1 und leichtem Versatz (`MeloSpace.sm` + → 0) ein, Dauer `MeloMotion.normal` (220ms), Kurve `MeloMotion.curve`. + Jedes Element startet um `min(index, 12) × 25ms` versetzt — ab Index 12 + ohne zusätzliche Verzögerung, damit lange Listen nicht träge wirken. Läuft + genau einmal je Element-Lebenszeit (nicht bei jedem Rebuild) und + respektiert Reduce-Motion (`MeloMotion.ruhig`): dann erscheint das Element + sofort im Endzustand, ohne Verzögerung oder Bewegung. +- 🔧 Eingebunden in `SongList` (`lib/library/song_list.dart`) und in beide + Listen von `artist_list.dart` (Künstler-Übersicht sowie die Zeilen der + Künstler-Detailseite — Alben, Titel und Zwischenüberschriften). Die + wiederverwendete `SongZeile` selbst bleibt unverändert, damit Suche und + Playlist-Detail (die den Wrapper nicht bekommen sollen) unangetastet + bleiben. `my_music_screen.dart` rendert keine eigene Liste (nur + eingebettete `SortableSongList`/`ArtistListScreen`/`CategoryListScreen`) + — dort war nichts einzubinden. +- ✅ **Getestet (RED→GREEN):** neuer Widget-Test + `test/shared/einblend_item_test.dart` prüft Start bei Opacity 0, Ziel bei + 1,0 nach `pumpAndSettle()`, sofortigen Endzustand bei + `disableAnimations: true` sowie den Deckel bei Index 12 (Index 50 startet + nicht später als Index 12). Bestehende Tests zu `song_list.dart` liefen + bereits mit `pumpAndSettle()` und blieben unverändert grün. Volle Suite + (604 Tests) grün, `flutter analyze` ohne Befund. + ### 🔑 Manuelle Baka-Anmeldung übernahm das neue Passwort nie dauerhaft; Auto-Login-Fehler wurde verworfen (2026-08-29) - 🐛 **Wer sich nach einer fehlgeschlagenen Auto-Anmeldung manuell im diff --git a/lib/library/artist_list.dart b/lib/library/artist_list.dart index a69ba7b..9b9840c 100644 --- a/lib/library/artist_list.dart +++ b/lib/library/artist_list.dart @@ -5,6 +5,7 @@ import '../settings/app_settings.dart'; import '../shared/auswahl.dart'; import '../shared/auswahl_leiste.dart'; import '../shared/cover.dart'; +import '../shared/einblend_item.dart'; import '../shared/theme.dart'; import '../shared/titel_listen_screen.dart'; import 'category_service.dart'; @@ -44,7 +45,10 @@ class _ArtistListScreenState extends State { itemCount: artists.length, itemBuilder: (context, i) { final artist = artists[i]; - return _KuenstlerZeile(name: artist, songs: grouped[artist]!); + return EinblendItem( + index: i, + child: _KuenstlerZeile(name: artist, songs: grouped[artist]!), + ); }, ); }, @@ -167,25 +171,31 @@ class _KuenstlerScreenState extends State { body: ListView.builder( itemCount: vorspann + widget.songs.length, itemBuilder: (context, i) { - if (zeigeAlben) { - if (i == 0) return const _Kopf('Alben'); - if (i <= albumListe.length) { - final eintrag = albumListe[i - 1]; - return _AlbumZeile(name: eintrag.key, songs: eintrag.value); + Widget zeile() { + if (zeigeAlben) { + if (i == 0) return const _Kopf('Alben'); + if (i <= albumListe.length) { + final eintrag = albumListe[i - 1]; + return _AlbumZeile(name: eintrag.key, songs: eintrag.value); + } + if (i == albumListe.length + 1) { + return const Divider(height: 1); + } + if (i == albumListe.length + 2) return const _Kopf('Alle Titel'); } - if (i == albumListe.length + 1) return const Divider(height: 1); - if (i == albumListe.length + 2) return const _Kopf('Alle Titel'); + final index = i - vorspann; + final song = widget.songs[index]; + return SongZeile( + song: song, + warteschlange: widget.songs, + index: index, + auswahlModus: _waehltAus, + ausgewaehlt: _auswahl.contains(song.id), + onAuswahlWechsel: () => _wechsleAuswahl(song.id), + ); } - final index = i - vorspann; - final song = widget.songs[index]; - return SongZeile( - song: song, - warteschlange: widget.songs, - index: index, - auswahlModus: _waehltAus, - ausgewaehlt: _auswahl.contains(song.id), - onAuswahlWechsel: () => _wechsleAuswahl(song.id), - ); + + return EinblendItem(index: i, child: zeile()); }, ), ), diff --git a/lib/library/song_list.dart b/lib/library/song_list.dart index b51045d..1e714af 100644 --- a/lib/library/song_list.dart +++ b/lib/library/song_list.dart @@ -7,6 +7,7 @@ import '../player/now_playing_screen.dart'; import '../playlists/create_playlist_dialog.dart'; import '../settings/app_settings.dart'; import '../shared/cover.dart'; +import '../shared/einblend_item.dart'; import '../shared/favorite_button.dart'; import '../shared/lauf_balken.dart'; import 'category_service.dart'; @@ -94,15 +95,18 @@ class SongList extends StatelessWidget { controller: controller, itemExtent: festeHoehe ? songZeilenHoeheFuer(context) : null, itemCount: songs.length, - itemBuilder: (context, i) => SongZeile( - song: songs[i], - warteschlange: songs, + itemBuilder: (context, i) => EinblendItem( index: i, - auswahlModus: gewaehlt != null && gewaehlt.isNotEmpty, - ausgewaehlt: gewaehlt?.contains(songs[i].id) ?? false, - onAuswahlWechsel: onAuswahlWechsel == null - ? null - : () => onAuswahlWechsel!(songs[i].id), + child: SongZeile( + song: songs[i], + warteschlange: songs, + index: i, + auswahlModus: gewaehlt != null && gewaehlt.isNotEmpty, + ausgewaehlt: gewaehlt?.contains(songs[i].id) ?? false, + onAuswahlWechsel: onAuswahlWechsel == null + ? null + : () => onAuswahlWechsel!(songs[i].id), + ), ), ); } diff --git a/lib/shared/einblend_item.dart b/lib/shared/einblend_item.dart new file mode 100644 index 0000000..7de33fa --- /dev/null +++ b/lib/shared/einblend_item.dart @@ -0,0 +1,91 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import 'theme.dart'; + +/// Blendet [child] beim ersten Erscheinen leicht gestaffelt ein — statt +/// abrupt aufzupoppen, faded und rutscht es ein kleines Stück von unten +/// heran. Je weiter hinten in der Liste, desto später beginnt es, damit die +/// Liste als Ganzes fließt statt gleichzeitig aufzublitzen. +/// +/// Läuft genau einmal je Element-Lebenszeit: ein Rebuild (etwa weil sich der +/// Song-Titel ändert) startet die Animation nicht erneut. +class EinblendItem extends StatefulWidget { + const EinblendItem({super.key, required this.child, required this.index}); + + final Widget child; + + /// Position in der Liste — bestimmt die Verzögerung vor dem Start. + final int index; + + @override + State createState() => _EinblendItemState(); +} + +class _EinblendItemState extends State + with SingleTickerProviderStateMixin { + /// Ab Index 12 keine zusätzliche Verzögerung mehr — sonst wirkte eine + /// lange Liste beim ersten Erscheinen träge, weil die letzten Zeilen erst + /// spät anfingen. + static const _deckel = 12; + static const _versatzProItem = Duration(milliseconds: 25); + + late final AnimationController _controller = AnimationController( + vsync: this, + duration: MeloMotion.normal, + ); + late final Animation _kurve = CurvedAnimation( + parent: _controller, + curve: MeloMotion.curve, + ); + + bool _gestartet = false; + + // Kein Start in initState: die Entscheidung hängt an MediaQuery + // (Reduce-Motion), und darauf darf man dort noch nicht zugreifen — siehe + // LaufBalken. didChangeDependencies läuft unmittelbar danach und ein + // _gestartet-Merker verhindert, dass ein späterer Aufruf (z. B. durch + // eine geänderte Textgröße) die Animation erneut anstößt. + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_gestartet) return; + _gestartet = true; + + if (MeloMotion.ruhig(context, MeloMotion.normal) == Duration.zero) { + _controller.value = 1; + return; + } + + final verzoegerung = _versatzProItem * math.min(widget.index, _deckel); + if (verzoegerung == Duration.zero) { + _controller.forward(); + } else { + Future.delayed(verzoegerung, () { + if (mounted) _controller.forward(); + }); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _kurve, + builder: (context, child) => Opacity( + opacity: _kurve.value, + child: Transform.translate( + offset: Offset(0, MeloSpace.sm * (1 - _kurve.value)), + child: child, + ), + ), + child: widget.child, + ); + } +} diff --git a/test/shared/einblend_item_test.dart b/test/shared/einblend_item_test.dart new file mode 100644 index 0000000..9353db0 --- /dev/null +++ b/test/shared/einblend_item_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/shared/einblend_item.dart'; + +void main() { + Widget wrap(Widget child, {bool ruhig = false}) => MaterialApp( + home: MediaQuery( + data: MediaQueryData(disableAnimations: ruhig), + child: Scaffold(body: child), + ), + ); + + double opacityVon(WidgetTester tester, Finder einblendItem) => tester + .widget( + find.descendant(of: einblendItem, matching: find.byType(Opacity))) + .opacity; + + double versatzYVon(WidgetTester tester, Finder einblendItem) => tester + .widget( + find.descendant(of: einblendItem, matching: find.byType(Transform))) + .transform + .getTranslation() + .y; + + testWidgets('Opacity startet bei 0 und erreicht 1,0 nach dem Einblenden', + (tester) async { + await tester.pumpWidget(wrap( + const EinblendItem(index: 0, child: Text('Titel')), + )); + final item = find.byType(EinblendItem); + + expect(opacityVon(tester, item), 0); + + await tester.pumpAndSettle(); + + expect(opacityVon(tester, item), 1); + expect(versatzYVon(tester, item), 0); + }); + + testWidgets( + 'bei abgeschalteten Systemanimationen erscheint das Element sofort', + (tester) async { + await tester.pumpWidget(wrap( + const EinblendItem(index: 3, child: Text('Titel')), + ruhig: true, + )); + final item = find.byType(EinblendItem); + + // Bewusst kein weiterer pump() davor: die Reduce-Motion-Einstellung + // darf nicht erst nach einer Zeitverzögerung greifen. + expect(opacityVon(tester, item), 1); + expect(versatzYVon(tester, item), 0); + }); + + testWidgets('der Deckel bremst die Verzögerung ab Index 12', + (tester) async { + await tester.pumpWidget(wrap( + const Column( + children: [ + EinblendItem(index: 12, child: Text('Zwölf')), + EinblendItem(index: 50, child: Text('Fünfzig')), + ], + ), + )); + + // Deckel: min(index, 12) * 25ms → beide starten spätestens nach 300ms. + // Ohne Deckel bräuchte Index 50 1250ms Verzögerung und wäre hier noch + // bei Opacity 0. + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(const Duration(milliseconds: 220)); + + for (final item in find.byType(EinblendItem).evaluate()) { + expect(opacityVon(tester, find.byWidget(item.widget)), 1); + } + }); +}