Skip to content

Commit 0aaef87

Browse files
authored
fix: dialog mobile overflow — safe area-aware maxHeight (#13) (#14)
* fix: dialog mobile overflow — safe area-aware maxHeight (#13) All three dialog widgets now compute maxHeight from safe area (MediaQuery.viewPaddingOf) instead of raw screen height, and add vertical insetPadding (24px) to prevent edge-to-edge on mobile. * fix: clamp safeHeight to prevent negative maxHeight on tiny viewports
1 parent ca325ff commit 0aaef87

8 files changed

Lines changed: 244 additions & 6 deletions

.claude/rules/widgets.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,6 @@ path: "lib/src/ui/widgets/**/*.dart"
2424
- Modal theme consumption: all dialogs (ConfirmDialog, PasswordConfirmDialog, TwoFactorModal) read tokens from `MagicStarter.manager.modalTheme` at build time — never hardcode dialog classNames; use theme fields (titleClassName, primaryButtonClassName, dangerButtonClassName, etc.)
2525
- Dialog shell: `MagicStarterDialogShell` — exported from barrel; sticky header/footer with scrollable body (`ListView(shrinkWrap: true)`); uses Material `Dialog` shell + Wind UI content; accepts `footerBuilder: Widget Function(BuildContext dialogContext)?` so callers can safely call `Navigator.pop(dialogContext)` with the dialog's own context
2626
- Dialog button layout: all dialog footers use compact right-aligned buttons with `justify-end gap-2 wrap` — never `flex-1` full-width buttons; `wrap` is required alongside `justify-end` to prevent overflow in constrained containers (Wind renders as `Wrap(alignment: WrapAlignment.end)`)
27+
- Dialog safe area: all dialogs compute `safeHeight` via `MediaQuery.viewPaddingOf(context)` — subtract top/bottom insets from screen height, then apply `* 0.85` for maxHeight; vertical `insetPadding: 24` prevents edge-to-edge on mobile
2728
- Wind UI exclusively — no Material widgets except `Icons.*` for icon references and `Dialog` shell in `MagicStarterDialogShell`
2829
- Dark mode: always pair light/dark classes: `bg-white dark:bg-gray-800`

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [Unreleased]
6+
7+
### 🐛 Bug Fixes
8+
- **MagicStarterDialogShell**: Fixed mobile overflow — `maxHeight` now computed from safe area (`MediaQuery.viewPaddingOf`) instead of raw screen height; added vertical `insetPadding` (24px) to prevent dialog from extending to screen edges (#13)
9+
- **MagicStarterPasswordConfirmDialog**: Same safe area fix — replaced hardcoded `maxHeight: 600` with `safeHeight * 0.85`; added vertical `insetPadding`
10+
- **MagicStarterTwoFactorModal**: Same safe area fix — replaced hardcoded `maxHeight: 800` with `safeHeight * 0.85`; added vertical `insetPadding`
11+
512
## [0.0.1-alpha.7] - 2026-03-29
613

714
### ✨ New Features

lib/src/ui/widgets/magic_starter_dialog_shell.dart

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,22 @@ class MagicStarterDialogShell extends StatelessWidget {
4545
Widget build(BuildContext context) {
4646
final theme = MagicStarter.manager.modalTheme;
4747

48+
final viewPadding = MediaQuery.viewPaddingOf(context);
49+
final safeHeight = (MediaQuery.sizeOf(context).height -
50+
viewPadding.top -
51+
viewPadding.bottom)
52+
.clamp(0.0, double.infinity);
53+
4854
return Dialog(
4955
backgroundColor: Colors.transparent,
50-
insetPadding: const EdgeInsets.symmetric(horizontal: 16),
56+
insetPadding: const EdgeInsets.symmetric(
57+
horizontal: 16,
58+
vertical: 24,
59+
),
5160
child: ConstrainedBox(
5261
constraints: BoxConstraints(
5362
maxWidth: theme.maxWidth,
54-
maxHeight: MediaQuery.sizeOf(context).height * 0.85,
63+
maxHeight: safeHeight * 0.85,
5564
),
5665
child: WDiv(
5766
className:

lib/src/ui/widgets/magic_starter_password_confirm_dialog.dart

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,14 +126,22 @@ class _MagicStarterPasswordConfirmDialogState
126126
@override
127127
Widget build(BuildContext context) {
128128
final theme = MagicStarter.manager.modalTheme;
129+
final viewPadding = MediaQuery.viewPaddingOf(context);
130+
final safeHeight = (MediaQuery.sizeOf(context).height -
131+
viewPadding.top -
132+
viewPadding.bottom)
133+
.clamp(0.0, double.infinity);
129134

130135
return Dialog(
131136
backgroundColor: Colors.transparent,
132-
insetPadding: const EdgeInsets.symmetric(horizontal: 16),
137+
insetPadding: const EdgeInsets.symmetric(
138+
horizontal: 16,
139+
vertical: 24,
140+
),
133141
child: ConstrainedBox(
134142
constraints: BoxConstraints(
135143
maxWidth: theme.maxWidth,
136-
maxHeight: 600,
144+
maxHeight: safeHeight * 0.85,
137145
),
138146
child: SingleChildScrollView(
139147
child: WDiv(

lib/src/ui/widgets/magic_starter_two_factor_modal.dart

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -248,14 +248,22 @@ class _MagicStarterTwoFactorModalState
248248
@override
249249
Widget build(BuildContext context) {
250250
final theme = MagicStarter.manager.modalTheme;
251+
final viewPadding = MediaQuery.viewPaddingOf(context);
252+
final safeHeight = (MediaQuery.sizeOf(context).height -
253+
viewPadding.top -
254+
viewPadding.bottom)
255+
.clamp(0.0, double.infinity);
251256

252257
return Dialog(
253258
backgroundColor: Colors.transparent,
254-
insetPadding: const EdgeInsets.symmetric(horizontal: 16),
259+
insetPadding: const EdgeInsets.symmetric(
260+
horizontal: 16,
261+
vertical: 24,
262+
),
255263
child: ConstrainedBox(
256264
constraints: BoxConstraints(
257265
maxWidth: theme.maxWidth,
258-
maxHeight: 800,
266+
maxHeight: safeHeight * 0.85,
259267
),
260268
child: SingleChildScrollView(
261269
child: WDiv(

test/ui/widgets/magic_starter_dialog_shell_test.dart

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,102 @@ void main() {
191191
);
192192
});
193193

194+
group('mobile overflow safety', () {
195+
testWidgets('Dialog has vertical insetPadding', (tester) async {
196+
tester.view.physicalSize = const Size(400, 800);
197+
tester.view.devicePixelRatio = 1.0;
198+
addTearDown(tester.view.resetPhysicalSize);
199+
addTearDown(tester.view.resetDevicePixelRatio);
200+
201+
await tester.pumpWidget(wrap(
202+
const MagicStarterDialogShell(
203+
title: 'Test',
204+
body: Text('body'),
205+
),
206+
));
207+
208+
final dialog = tester.widget<Dialog>(find.byType(Dialog));
209+
final insetPadding = dialog.insetPadding as EdgeInsets;
210+
211+
expect(insetPadding.top, greaterThan(0));
212+
expect(insetPadding.bottom, greaterThan(0));
213+
expect(insetPadding.left, equals(16));
214+
expect(insetPadding.right, equals(16));
215+
});
216+
217+
testWidgets('maxHeight accounts for viewPadding safe area', (tester) async {
218+
tester.view.physicalSize = const Size(400, 800);
219+
tester.view.devicePixelRatio = 1.0;
220+
tester.view.viewPadding = const FakeViewPadding(
221+
top: 44,
222+
bottom: 34,
223+
);
224+
addTearDown(tester.view.resetPhysicalSize);
225+
addTearDown(tester.view.resetDevicePixelRatio);
226+
addTearDown(tester.view.resetViewPadding);
227+
228+
await tester.pumpWidget(wrap(
229+
const MagicStarterDialogShell(
230+
title: 'Test',
231+
body: Text('body'),
232+
),
233+
));
234+
235+
final constrainedBox = tester.widget<ConstrainedBox>(
236+
find.byWidgetPredicate(
237+
(widget) =>
238+
widget is ConstrainedBox &&
239+
widget.constraints.maxHeight != double.infinity &&
240+
widget.constraints.maxWidth != double.infinity,
241+
),
242+
);
243+
final maxHeight = constrainedBox.constraints.maxHeight;
244+
245+
// Screen height = 800, viewPadding top = 44, bottom = 34
246+
// Safe height = 800 - 44 - 34 = 722
247+
// maxHeight should be 722 * 0.85 = 613.7
248+
// Without safe area it would be 800 * 0.85 = 680
249+
expect(maxHeight, lessThan(680));
250+
expect(maxHeight, closeTo(613.7, 1.0));
251+
});
252+
253+
testWidgets('safeHeight is smaller than raw screen height', (tester) async {
254+
tester.view.physicalSize = const Size(400, 600);
255+
tester.view.devicePixelRatio = 1.0;
256+
tester.view.viewPadding = const FakeViewPadding(
257+
top: 44,
258+
bottom: 34,
259+
);
260+
addTearDown(tester.view.resetPhysicalSize);
261+
addTearDown(tester.view.resetDevicePixelRatio);
262+
addTearDown(tester.view.resetViewPadding);
263+
264+
await tester.pumpWidget(wrap(
265+
const MagicStarterDialogShell(
266+
title: 'Test',
267+
body: Text('body'),
268+
),
269+
));
270+
271+
final constrainedBox = tester.widget<ConstrainedBox>(
272+
find.byWidgetPredicate(
273+
(widget) =>
274+
widget is ConstrainedBox &&
275+
widget.constraints.maxHeight != double.infinity &&
276+
widget.constraints.maxWidth != double.infinity,
277+
),
278+
);
279+
final maxHeight = constrainedBox.constraints.maxHeight;
280+
281+
// Screen height = 600, viewPadding top = 44, bottom = 34
282+
// Safe height = 600 - 44 - 34 = 522
283+
// maxHeight should be 522 * 0.85 = 443.7
284+
// Without safe area it would be 600 * 0.85 = 510
285+
expect(maxHeight, lessThan(510));
286+
expect(maxHeight, closeTo(443.7, 1.0));
287+
});
288+
});
289+
194290
testWidgets(
195291
'reads containerClassName from MagicStarter.manager.modalTheme',
196292
(tester) async {

test/ui/widgets/magic_starter_password_confirm_dialog_test.dart

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,54 @@ void main() {
339339
});
340340
});
341341

342+
group('mobile overflow safety', () {
343+
testWidgets('Dialog has vertical insetPadding', (tester) async {
344+
tester.view.physicalSize = const Size(400, 800);
345+
tester.view.devicePixelRatio = 1.0;
346+
addTearDown(tester.view.resetPhysicalSize);
347+
addTearDown(tester.view.resetDevicePixelRatio);
348+
349+
await tester.pumpWidget(wrap(const MagicStarterPasswordConfirmDialog()));
350+
351+
final dialog = tester.widget<Dialog>(find.byType(Dialog));
352+
final insetPadding = dialog.insetPadding as EdgeInsets;
353+
354+
expect(insetPadding.top, greaterThan(0));
355+
expect(insetPadding.bottom, greaterThan(0));
356+
expect(insetPadding.left, equals(16));
357+
expect(insetPadding.right, equals(16));
358+
});
359+
360+
testWidgets('maxHeight accounts for viewPadding safe area', (tester) async {
361+
tester.view.physicalSize = const Size(400, 800);
362+
tester.view.devicePixelRatio = 1.0;
363+
tester.view.viewPadding = const FakeViewPadding(
364+
top: 44,
365+
bottom: 34,
366+
);
367+
addTearDown(tester.view.resetPhysicalSize);
368+
addTearDown(tester.view.resetDevicePixelRatio);
369+
addTearDown(tester.view.resetViewPadding);
370+
371+
await tester.pumpWidget(wrap(const MagicStarterPasswordConfirmDialog()));
372+
373+
final constrainedBox = tester.widget<ConstrainedBox>(
374+
find.byWidgetPredicate(
375+
(widget) =>
376+
widget is ConstrainedBox &&
377+
widget.constraints.maxHeight != double.infinity &&
378+
widget.constraints.maxWidth != double.infinity,
379+
),
380+
);
381+
final maxHeight = constrainedBox.constraints.maxHeight;
382+
383+
// Without safe area: hardcoded 600
384+
// With safe area: (800 - 44 - 34) * 0.85 = 613.7
385+
expect(maxHeight, lessThan(680));
386+
expect(maxHeight, closeTo(613.7, 1.0));
387+
});
388+
});
389+
342390
group('modal theme integration', () {
343391
testWidgets('uses custom containerClassName from modal theme',
344392
(WidgetTester tester) async {

test/ui/widgets/magic_starter_two_factor_modal_test.dart

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,67 @@ void main() {
451451
});
452452
});
453453

454+
// -------------------------------------------------------------------------
455+
// Mobile overflow safety
456+
// -------------------------------------------------------------------------
457+
group('mobile overflow safety', () {
458+
testWidgets('Dialog has vertical insetPadding', (tester) async {
459+
tester.view.physicalSize = const Size(400, 800);
460+
tester.view.devicePixelRatio = 1.0;
461+
addTearDown(tester.view.resetPhysicalSize);
462+
addTearDown(tester.view.resetDevicePixelRatio);
463+
464+
await tester.pumpWidget(wrap(
465+
MagicStarterTwoFactorModal(
466+
setupData: kSetupData,
467+
onConfirm: (_) async => true,
468+
),
469+
));
470+
471+
final dialog = tester.widget<Dialog>(find.byType(Dialog));
472+
final insetPadding = dialog.insetPadding as EdgeInsets;
473+
474+
expect(insetPadding.top, greaterThan(0));
475+
expect(insetPadding.bottom, greaterThan(0));
476+
expect(insetPadding.left, equals(16));
477+
expect(insetPadding.right, equals(16));
478+
});
479+
480+
testWidgets('maxHeight accounts for viewPadding safe area', (tester) async {
481+
tester.view.physicalSize = const Size(400, 800);
482+
tester.view.devicePixelRatio = 1.0;
483+
tester.view.viewPadding = const FakeViewPadding(
484+
top: 44,
485+
bottom: 34,
486+
);
487+
addTearDown(tester.view.resetPhysicalSize);
488+
addTearDown(tester.view.resetDevicePixelRatio);
489+
addTearDown(tester.view.resetViewPadding);
490+
491+
await tester.pumpWidget(wrap(
492+
MagicStarterTwoFactorModal(
493+
setupData: kSetupData,
494+
onConfirm: (_) async => true,
495+
),
496+
));
497+
498+
final constrainedBox = tester.widget<ConstrainedBox>(
499+
find.byWidgetPredicate(
500+
(widget) =>
501+
widget is ConstrainedBox &&
502+
widget.constraints.maxHeight != double.infinity &&
503+
widget.constraints.maxWidth != double.infinity,
504+
),
505+
);
506+
final maxHeight = constrainedBox.constraints.maxHeight;
507+
508+
// Without safe area: hardcoded 800
509+
// With safe area: (800 - 44 - 34) * 0.85 = 613.7
510+
expect(maxHeight, lessThan(800));
511+
expect(maxHeight, closeTo(613.7, 1.0));
512+
});
513+
});
514+
454515
// -------------------------------------------------------------------------
455516
// Modal theme integration
456517
// -------------------------------------------------------------------------

0 commit comments

Comments
 (0)