diff --git a/lib/widgets/pluri_root_header.dart b/lib/widgets/pluri_root_header.dart index cae32ae..f87efa5 100644 --- a/lib/widgets/pluri_root_header.dart +++ b/lib/widgets/pluri_root_header.dart @@ -29,41 +29,59 @@ class PluriRootHeader extends StatelessWidget { /// needs nothing extra here). final List actions; + /// Fix `safearea-top-inset`: this is the CONTENT row's height only — + /// NOT this widget's total rendered height. `app.dart`'s root + /// `SafeArea(top: false, ...)` deliberately excludes the top inset (so + /// each root's own full-bleed background paints genuinely edge-to-edge + /// behind the status bar), which left this header's title/actions row + /// with zero top-inset awareness — flush at y=0 under the status bar / + /// camera cutout on every device. This widget now adds + /// `MediaQuery.paddingOf(context).top` ABOVE this content height itself + /// (see [build]), so the total rendered height is + /// `height + MediaQuery.paddingOf(context).top`. Callers doing + /// total-height math (none currently do — checked every `PluriRootHeader` + /// call site) must add that inset separately; this constant's MEANING + /// (content height) is unchanged. static const double height = 56; @override Widget build(BuildContext context) { final type = context.pluriType; final l10n = AppLocalizations.of(context); - return SizedBox( - height: height, - child: Padding( - // S5: the prototype's own header padding is title-tier on the - // left, row-tier on the right (t4 e.g. Alarmas - // `padding:0 12px 0 20px`). - padding: const EdgeInsets.fromLTRB( - PluriLayout.titleHorizontal, - 0, - PluriLayout.rowHorizontal, - 0, - ), - child: Row( - children: [ - Expanded( - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: type.sectionTitle, + final topInset = MediaQuery.paddingOf(context).top; + return Padding( + padding: EdgeInsets.only(top: topInset), + child: SizedBox( + height: height, + child: Padding( + key: const ValueKey('pluri-root-header-content'), + // S5: the prototype's own header padding is title-tier on the + // left, row-tier on the right (t4 e.g. Alarmas + // `padding:0 12px 0 20px`). + padding: const EdgeInsets.fromLTRB( + PluriLayout.titleHorizontal, + 0, + PluriLayout.rowHorizontal, + 0, + ), + child: Row( + children: [ + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: type.sectionTitle, + ), ), - ), - ...actions, - IconButton( - icon: const Icon(Icons.bedtime_outlined), - tooltip: l10n.sleepTimer, - onPressed: onSleepTimer, - ), - ], + ...actions, + IconButton( + icon: const Icon(Icons.bedtime_outlined), + tooltip: l10n.sleepTimer, + onPressed: onSleepTimer, + ), + ], + ), ), ), ); diff --git a/test/widgets/pluri_root_header_test.dart b/test/widgets/pluri_root_header_test.dart index eba0885..29c7f74 100644 --- a/test/widgets/pluri_root_header_test.dart +++ b/test/widgets/pluri_root_header_test.dart @@ -12,12 +12,23 @@ import 'package:pluriwave/widgets/pluri_root_header.dart'; /// sleep-timer action that used to live on `app.dart`'s single global /// `AppBar` stays reachable from every tab. void main() { - Widget host(Widget child) { + // [topInset] simulates `MediaQuery.paddingOf(context).top` (status bar / + // camera cutout) — the same technique already used by + // `pantalla_alarma_sonando_scaffold_test.dart`'s `MediaQuery` override via + // `MaterialApp.builder`. + Widget host(Widget child, {double topInset = 0}) { return MaterialApp( theme: PluriWaveTheme.dark(), locale: const Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, + builder: + (context, app) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(padding: EdgeInsets.only(top: topInset)), + child: app!, + ), home: Scaffold(body: child), ); } @@ -42,7 +53,13 @@ void main() { host(PluriRootHeader(title: 'Settings', onSleepTimer: () {})), ); - final padding = tester.widget(find.byType(Padding).first); + // Keyed lookup (not `find.byType(Padding).first`): the top-inset fix + // wraps this content padding in an outer `Padding(top: topInset)`, so + // `.first` would no longer reliably resolve to the content row's own + // padding. + final padding = tester.widget( + find.byKey(const ValueKey('pluri-root-header-content')), + ); final insets = padding.padding as EdgeInsets; expect(insets.left, PluriLayout.titleHorizontal); expect(insets.right, PluriLayout.rowHorizontal); @@ -97,4 +114,92 @@ void main() { reason: 'actions render before (to the left of) the bedtime button', ); }); + + group('fix top-inset: total height and content position track ' + 'MediaQuery.paddingOf(context).top, instead of always sitting flush ' + 'at y=0 under the status bar', () { + // Vertical text centering inside the 56px content row means the + // title's own top-left never sits exactly AT the header's top-left + // (even at topInset=0) — so these tests compare each inset's title + // position against the topInset=0 BASELINE, isolating exactly the + // inset's own contribution instead of asserting a brittle absolute + // offset. + Future tituloTopPara(WidgetTester tester, double topInset) async { + await tester.pumpWidget( + host( + PluriRootHeader(title: 'Settings', onSleepTimer: () {}), + topInset: topInset, + ), + ); + return tester.getTopLeft(find.text('Settings')).dy; + } + + testWidgets( + 'topInset=0 (e.g. desktop/no cutout): total height stays the plain ' + '56px content height', + (tester) async { + await tester.pumpWidget( + host(PluriRootHeader(title: 'Settings', onSleepTimer: () {})), + ); + + expect(tester.getSize(find.byType(PluriRootHeader)).height, 56); + }, + ); + + testWidgets( + 'topInset=24 (typical status bar): total height becomes 56+24=80, ' + 'and the title shifts down by exactly the inset relative to the ' + 'topInset=0 baseline', + (tester) async { + final base = await tituloTopPara(tester, 0); + final conInset = await tituloTopPara(tester, 24); + + expect(tester.getSize(find.byType(PluriRootHeader)).height, 80); + expect(conInset - base, 24); + }, + ); + + testWidgets( + 'topInset=44 (taller status bar): total height becomes 56+44=100, ' + 'and the title still shifts down by exactly the inset — never ' + 'clipped', + (tester) async { + final base = await tituloTopPara(tester, 0); + final conInset = await tituloTopPara(tester, 44); + + expect(tester.getSize(find.byType(PluriRootHeader)).height, 100); + expect(conInset - base, 44); + }, + ); + + testWidgets( + 'topInset=60 (notch/camera-cutout simulation): total height becomes ' + '56+60=116, and the title still fully clears the inset — never ' + 'overlapping it', + (tester) async { + final base = await tituloTopPara(tester, 0); + final conInset = await tituloTopPara(tester, 60); + + expect(tester.getSize(find.byType(PluriRootHeader)).height, 116); + expect(conInset - base, 60); + }, + ); + + testWidgets( + 'PluriRootHeader.height stays the CONTENT height (56) regardless of ' + 'inset — callers doing total-height math must separately add ' + 'MediaQuery.paddingOf(context).top', + (tester) async { + await tester.pumpWidget( + host( + PluriRootHeader(title: 'Settings', onSleepTimer: () {}), + topInset: 44, + ), + ); + + expect(PluriRootHeader.height, 56); + expect(tester.getSize(find.byType(PluriRootHeader)).height, 100); + }, + ); + }); }