fix(ui): add top-inset awareness to PluriRootHeader
PluriRootHeader rendered its 56px title/actions row flush at y=0 on every device, since app.dart's root SafeArea(top: false) deliberately excludes the top inset (so each root's full-bleed background paints edge-to-edge behind the status bar) but the header itself never added MediaQuery.paddingOf(context).top anywhere. The header now wraps its existing 56px content row in an outer top padding equal to that inset, so total rendered height is height + topInset while `height` keeps meaning the content row's own height (verified no call site did total-height math against the old fixed constant).
This commit is contained in:
@@ -29,41 +29,59 @@ class PluriRootHeader extends StatelessWidget {
|
||||
/// needs nothing extra here).
|
||||
final List<Widget> 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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<Padding>(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<Padding>(
|
||||
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<double> 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user