From bc58438262c70329c3f475d42407ce859d314624 Mon Sep 17 00:00:00 2001 From: "Hermes (Server)" Date: Sat, 29 Aug 2026 15:55:52 +0200 Subject: [PATCH] =?UTF-8?q?Fix:=20RenderFlex-Overflow=20in=20NowPlayingScr?= =?UTF-8?q?een=20w=C3=A4hrend=20der=20=C3=96ffnen-Geste?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug (Code-Review, verifiziert): _Angaben (Titel/Künstler/Fortschritt/ Controls) hat eine intrinsische Mindesthöhe, die während der Öffnen-Geste (progress zwischen ~0.3 und ~1.0, siehe main.dart Positioned.fromRect mit Rect.lerp Mini-/Vollbild-Player) das noch kleinere, interpolierte Rechteck übersteigt — Flutter meldete dann "RenderFlex overflowed" bei praktisch jeder normalen Öffnen-Geste. Fix: der Inhalt-Slot (in beiden responsiven Zweigen — Row nebeneinander und Column untereinander) steckt jetzt in ClipRect(child: ConstrainedBox(maxHeight: raum.maxHeight - Lücke, child: OverflowBox(maxHeight: infinity, fit: deferToChild, child: _Angaben))). ConstrainedBox deckelt nur, was OverflowBox nach AUSSEN als eigene Größe meldet (verhindert, dass der umgebende Row/Column selbst überläuft); OverflowBox gibt _Angaben davon unabhängig weiterhin unbegrenzte Höhe (verhindert, dass _Angaben intern überläuft) — überschüssiger Inhalt wird vom ClipRect während der kurzen, ohnehin noch recht durchsichtigen Übergangsphase einfach nicht gemalt statt eine Overflow-Warnung auszulösen. Bei ausreichend Platz (Endzustand progress==1) ist raum.maxHeight groß genug, dass nichts geklemmt wird — die Optik bleibt dort unverändert (now_playing_heart_test.dart bestätigt das weiterhin). Test zuerst (RED: "RenderFlex overflowed by 13 pixels" reproduziert, mit NowPlayingScreen in einem echten, interpolierten Zwischenrechteck bei progress≈0.4 auf Basis der HomeShell-Rect-Formel): test/player/ now_playing_screen_overflow_test.dart. Volle Suite (626 Tests) grün. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SM6e73V9ZethPiqHSocxUA --- lib/player/now_playing_screen.dart | 39 +++++- .../now_playing_screen_overflow_test.dart | 128 ++++++++++++++++++ 2 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 test/player/now_playing_screen_overflow_test.dart diff --git a/lib/player/now_playing_screen.dart b/lib/player/now_playing_screen.dart index 20b67cc..ed69032 100644 --- a/lib/player/now_playing_screen.dart +++ b/lib/player/now_playing_screen.dart @@ -3,6 +3,7 @@ import 'dart:ui' show lerpDouble; import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show OverflowBoxFit; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; @@ -61,7 +62,43 @@ class NowPlayingScreen extends StatelessWidget { return LayoutBuilder( builder: (context, raum) { final nebeneinander = raum.maxWidth > raum.maxHeight; - final inhalt = _Angaben(item: item, handler: handler); + // Während der Öffnen-Geste ist das interpolierte + // Rechteck (main.dart, Rect.lerp Mini-/Vollbild- + // Player) oft noch kleiner, als _Angaben an + // Mindesthöhe braucht — ohne diese Kapselung meldet + // Flutter dann "RenderFlex overflowed" (im Row-Zweig + // unten bekäme _Angaben selbst eine zu knappe Höhe + // zugeteilt, im Column-Zweig überschreitet Lücke + + // _Angaben-Eigenhöhe zusammen die verfügbare Höhe). + // ConstrainedBox AUSSEN um OverflowBox deckelt, was + // OverflowBox nach außen als eigene Größe meldet, + // hart auf das von raum bekannte Maß; OverflowBox + // selbst gibt ihrem Kind (_Angaben) über + // maxHeight: infinity davon UNABHÄNGIG weiterhin + // unbegrenzte Höhe — _Angaben behält so seine + // natürliche Größe, der überschüssige Teil wird + // vom ClipRect einfach nicht gemalt statt eine + // Overflow-Warnung auszulösen. Läge die Deckelung + // stattdessen INNEN (zwischen OverflowBox und + // _Angaben), bekäme _Angaben selbst zu wenig Höhe + // und liefe intern über. Bei ausreichend Platz + // (Endzustand progress==1) ist raum.maxHeight groß + // genug, dass nichts geklemmt wird — die Optik + // bleibt dort unverändert. + final inhalt = ClipRect( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: (raum.maxHeight - MeloSpace.lg) + .clamp(0.0, double.infinity), + ), + child: OverflowBox( + alignment: Alignment.center, + maxHeight: double.infinity, + fit: OverflowBoxFit.deferToChild, + child: _Angaben(item: item, handler: handler), + ), + ), + ); return Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: nebeneinander diff --git a/test/player/now_playing_screen_overflow_test.dart b/test/player/now_playing_screen_overflow_test.dart new file mode 100644 index 0000000..2e552de --- /dev/null +++ b/test/player/now_playing_screen_overflow_test.dart @@ -0,0 +1,128 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:drift/drift.dart' show driftRuntimeOptions; +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/library/category_service.dart'; +import 'package:melo/library/database.dart'; +import 'package:melo/library/playlist_service.dart'; +import 'package:melo/player/audio_handler.dart'; +import 'package:melo/player/mini_player.dart'; +import 'package:melo/player/now_playing_screen.dart'; +import 'package:melo/player/player_expansion_controller.dart'; +import 'package:melo/settings/app_settings.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Ersetzt in [MeloAudioHandler.loadPlaylist] den echten just_audio-Zugriff — +/// dieselbe Klasse wie in now_playing_heart_test.dart. +class _FakeAudioHandler extends MeloAudioHandler { + _FakeAudioHandler(MeloDb db) : super(db: db); + + @override + Future loadPlaylist(List items, {int startIndex = 0}) async { + queue.add(items); + if (items.isNotEmpty) mediaItem.add(items[startIndex]); + return 0; + } +} + +void main() { + void stelleKanaeleRuhig() { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/path_provider'), + (call) async => '/tmp/melo-test', + ); + messenger.setMockMethodCallHandler( + const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'), + (call) async => null, + ); + } + + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets( + 'NowPlayingScreen in einem während der Öffnen-Geste noch kleinen, ' + 'interpolierten Rechteck (progress ~0.4) wirft keinen ' + 'RenderFlex-Overflow', (tester) async { + TestWidgetsFlutterBinding.ensureInitialized(); + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; + stelleKanaeleRuhig(); + final db = MeloDb(NativeDatabase.memory()); + final kategorien = CategoryService(db); + final playlists = PlaylistService(db); + final einstellungen = AppSettings(); + await einstellungen.init(); + final handler = _FakeAudioHandler(db); + addTearDown(kategorien.dispose); + addTearDown(playlists.dispose); + + // Ein echtes MediaItem, wie es beim Abspielen im mediaItem-Stream steht + // — NowPlayingScreen zeigt sonst nur "Nichts in Wiedergabe" und hat dann + // gar keinen Inhalt, der überlaufen könnte. + handler.mediaItem.add(const MediaItem( + id: 'song-1', + title: 'Roses', + artist: 'Neoni', + )); + + // Dieselbe Rect-Interpolation wie HomeShell._miniRect/_vollbildRect bei + // progress=0.4 auf der Standard-Testbildschirmgröße (800x600, siehe + // flutter_test-Default) — main.dart positioniert NowPlayingScreen + // während der Öffnen-Geste über genau so ein Positioned.fromRect mit + // Rect.lerp zwischen Mini- und Vollbild-Rechteck. Bei p>0.3 blendet der + // Inhalt laut Spec schon sichtbar ein (main.dart: Opacity((p-0.3)/0.7)), + // das Rechteck ist zu dem Zeitpunkt aber noch weit von der + // Vollbildgröße entfernt. + const p = 0.4; + const screenSize = Size(800, 600); + const miniHoehe = MiniPlayer.hoehe; + final unten = screenSize.height - miniHoehe - kBottomNavigationBarHeight; + final miniRect = Rect.fromLTWH(0, unten, screenSize.width, miniHoehe); + final vollbildRect = + Rect.fromLTWH(0, 0, screenSize.width, screenSize.height); + final rect = Rect.lerp(miniRect, vollbildRect, p)!; + + final expansion = PlayerExpansionController(vsync: tester); + addTearDown(expansion.dispose); + + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value(value: db), + Provider.value(value: handler), + ChangeNotifierProvider.value(value: kategorien), + ChangeNotifierProvider.value(value: playlists), + ChangeNotifierProvider.value(value: einstellungen), + ChangeNotifierProvider.value( + value: expansion), + ], + child: MaterialApp( + home: Scaffold( + body: Stack( + children: [ + Positioned.fromRect( + rect: rect, + child: const NowPlayingScreen(), + ), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + + // Aufräum-Reihenfolge wie in den übrigen Tests dieser Datei-Familie: + // erst den Baum abbauen, dann den Handler disposen (offener Positions- + // Timer, siehe now_playing_heart_test.dart). + await tester.pumpWidget(const SizedBox()); + handler.dispose(); + await tester.pump(const Duration(milliseconds: 1)); + }); +}