Fix: RenderFlex-Overflow in NowPlayingScreen während der Öffnen-Geste
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SM6e73V9ZethPiqHSocxUA
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
0fe624d1e8
commit
bc58438262
@@ -3,6 +3,7 @@ import 'dart:ui' show lerpDouble;
|
|||||||
|
|
||||||
import 'package:audio_service/audio_service.dart';
|
import 'package:audio_service/audio_service.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart' show OverflowBoxFit;
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
@@ -61,7 +62,43 @@ class NowPlayingScreen extends StatelessWidget {
|
|||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, raum) {
|
builder: (context, raum) {
|
||||||
final nebeneinander = raum.maxWidth > raum.maxHeight;
|
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(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
child: nebeneinander
|
child: nebeneinander
|
||||||
|
|||||||
@@ -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<int> loadPlaylist(List<MediaItem> 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<MeloDb>.value(value: db),
|
||||||
|
Provider<MeloAudioHandler>.value(value: handler),
|
||||||
|
ChangeNotifierProvider<CategoryService>.value(value: kategorien),
|
||||||
|
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
|
||||||
|
ChangeNotifierProvider<AppSettings>.value(value: einstellungen),
|
||||||
|
ChangeNotifierProvider<PlayerExpansionController>.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));
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user