From ef8aa5c9da2bc372e84f4add436c67c3f0015316 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 31 Mar 2026 02:30:27 +0300 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20dialog=20mobile=20overflow=20?= =?UTF-8?q?=E2=80=94=20safe=20area-aware=20maxHeight=20(#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .claude/rules/widgets.md | 1 + CHANGELOG.md | 7 ++ .../widgets/magic_starter_dialog_shell.dart | 12 ++- ...magic_starter_password_confirm_dialog.dart | 11 ++- .../magic_starter_two_factor_modal.dart | 11 ++- .../magic_starter_dialog_shell_test.dart | 96 +++++++++++++++++++ ..._starter_password_confirm_dialog_test.dart | 48 ++++++++++ .../magic_starter_two_factor_modal_test.dart | 61 ++++++++++++ 8 files changed, 241 insertions(+), 6 deletions(-) diff --git a/.claude/rules/widgets.md b/.claude/rules/widgets.md index 9673581..3aa16f0 100644 --- a/.claude/rules/widgets.md +++ b/.claude/rules/widgets.md @@ -24,5 +24,6 @@ path: "lib/src/ui/widgets/**/*.dart" - 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.) - 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 - 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)`) +- 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 - Wind UI exclusively — no Material widgets except `Icons.*` for icon references and `Dialog` shell in `MagicStarterDialogShell` - Dark mode: always pair light/dark classes: `bg-white dark:bg-gray-800` diff --git a/CHANGELOG.md b/CHANGELOG.md index e171c2a..e4c87f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project will be documented in this file. +## [Unreleased] + +### 🐛 Bug Fixes +- **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) +- **MagicStarterPasswordConfirmDialog**: Same safe area fix — replaced hardcoded `maxHeight: 600` with `safeHeight * 0.85`; added vertical `insetPadding` +- **MagicStarterTwoFactorModal**: Same safe area fix — replaced hardcoded `maxHeight: 800` with `safeHeight * 0.85`; added vertical `insetPadding` + ## [0.0.1-alpha.7] - 2026-03-29 ### ✨ New Features diff --git a/lib/src/ui/widgets/magic_starter_dialog_shell.dart b/lib/src/ui/widgets/magic_starter_dialog_shell.dart index af0297b..7da5ec5 100644 --- a/lib/src/ui/widgets/magic_starter_dialog_shell.dart +++ b/lib/src/ui/widgets/magic_starter_dialog_shell.dart @@ -45,13 +45,21 @@ class MagicStarterDialogShell extends StatelessWidget { Widget build(BuildContext context) { final theme = MagicStarter.manager.modalTheme; + final viewPadding = MediaQuery.viewPaddingOf(context); + final safeHeight = MediaQuery.sizeOf(context).height - + viewPadding.top - + viewPadding.bottom; + return Dialog( backgroundColor: Colors.transparent, - insetPadding: const EdgeInsets.symmetric(horizontal: 16), + insetPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 24, + ), child: ConstrainedBox( constraints: BoxConstraints( maxWidth: theme.maxWidth, - maxHeight: MediaQuery.sizeOf(context).height * 0.85, + maxHeight: safeHeight * 0.85, ), child: WDiv( className: diff --git a/lib/src/ui/widgets/magic_starter_password_confirm_dialog.dart b/lib/src/ui/widgets/magic_starter_password_confirm_dialog.dart index 3ce731e..0e75e9e 100644 --- a/lib/src/ui/widgets/magic_starter_password_confirm_dialog.dart +++ b/lib/src/ui/widgets/magic_starter_password_confirm_dialog.dart @@ -126,14 +126,21 @@ class _MagicStarterPasswordConfirmDialogState @override Widget build(BuildContext context) { final theme = MagicStarter.manager.modalTheme; + final viewPadding = MediaQuery.viewPaddingOf(context); + final safeHeight = MediaQuery.sizeOf(context).height - + viewPadding.top - + viewPadding.bottom; return Dialog( backgroundColor: Colors.transparent, - insetPadding: const EdgeInsets.symmetric(horizontal: 16), + insetPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 24, + ), child: ConstrainedBox( constraints: BoxConstraints( maxWidth: theme.maxWidth, - maxHeight: 600, + maxHeight: safeHeight * 0.85, ), child: SingleChildScrollView( child: WDiv( diff --git a/lib/src/ui/widgets/magic_starter_two_factor_modal.dart b/lib/src/ui/widgets/magic_starter_two_factor_modal.dart index 79eba48..435327b 100644 --- a/lib/src/ui/widgets/magic_starter_two_factor_modal.dart +++ b/lib/src/ui/widgets/magic_starter_two_factor_modal.dart @@ -248,14 +248,21 @@ class _MagicStarterTwoFactorModalState @override Widget build(BuildContext context) { final theme = MagicStarter.manager.modalTheme; + final viewPadding = MediaQuery.viewPaddingOf(context); + final safeHeight = MediaQuery.sizeOf(context).height - + viewPadding.top - + viewPadding.bottom; return Dialog( backgroundColor: Colors.transparent, - insetPadding: const EdgeInsets.symmetric(horizontal: 16), + insetPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 24, + ), child: ConstrainedBox( constraints: BoxConstraints( maxWidth: theme.maxWidth, - maxHeight: 800, + maxHeight: safeHeight * 0.85, ), child: SingleChildScrollView( child: WDiv( diff --git a/test/ui/widgets/magic_starter_dialog_shell_test.dart b/test/ui/widgets/magic_starter_dialog_shell_test.dart index 78be874..f0e9037 100644 --- a/test/ui/widgets/magic_starter_dialog_shell_test.dart +++ b/test/ui/widgets/magic_starter_dialog_shell_test.dart @@ -191,6 +191,102 @@ void main() { ); }); + group('mobile overflow safety', () { + testWidgets('Dialog has vertical insetPadding', (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(wrap( + const MagicStarterDialogShell( + title: 'Test', + body: Text('body'), + ), + )); + + final dialog = tester.widget(find.byType(Dialog)); + final insetPadding = dialog.insetPadding as EdgeInsets; + + expect(insetPadding.top, greaterThan(0)); + expect(insetPadding.bottom, greaterThan(0)); + expect(insetPadding.left, equals(16)); + expect(insetPadding.right, equals(16)); + }); + + testWidgets('maxHeight accounts for viewPadding safe area', (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + tester.view.viewPadding = const FakeViewPadding( + top: 44, + bottom: 34, + ); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetViewPadding); + + await tester.pumpWidget(wrap( + const MagicStarterDialogShell( + title: 'Test', + body: Text('body'), + ), + )); + + final constrainedBox = tester.widget( + find.byWidgetPredicate( + (widget) => + widget is ConstrainedBox && + widget.constraints.maxHeight != double.infinity && + widget.constraints.maxWidth != double.infinity, + ), + ); + final maxHeight = constrainedBox.constraints.maxHeight; + + // Screen height = 800, viewPadding top = 44, bottom = 34 + // Safe height = 800 - 44 - 34 = 722 + // maxHeight should be 722 * 0.85 = 613.7 + // Without safe area it would be 800 * 0.85 = 680 + expect(maxHeight, lessThan(680)); + expect(maxHeight, closeTo(613.7, 1.0)); + }); + + testWidgets('safeHeight is smaller than raw screen height', (tester) async { + tester.view.physicalSize = const Size(400, 600); + tester.view.devicePixelRatio = 1.0; + tester.view.viewPadding = const FakeViewPadding( + top: 44, + bottom: 34, + ); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetViewPadding); + + await tester.pumpWidget(wrap( + const MagicStarterDialogShell( + title: 'Test', + body: Text('body'), + ), + )); + + final constrainedBox = tester.widget( + find.byWidgetPredicate( + (widget) => + widget is ConstrainedBox && + widget.constraints.maxHeight != double.infinity && + widget.constraints.maxWidth != double.infinity, + ), + ); + final maxHeight = constrainedBox.constraints.maxHeight; + + // Screen height = 600, viewPadding top = 44, bottom = 34 + // Safe height = 600 - 44 - 34 = 522 + // maxHeight should be 522 * 0.85 = 443.7 + // Without safe area it would be 600 * 0.85 = 510 + expect(maxHeight, lessThan(510)); + expect(maxHeight, closeTo(443.7, 1.0)); + }); + }); + testWidgets( 'reads containerClassName from MagicStarter.manager.modalTheme', (tester) async { diff --git a/test/ui/widgets/magic_starter_password_confirm_dialog_test.dart b/test/ui/widgets/magic_starter_password_confirm_dialog_test.dart index 1d76e21..7de4786 100644 --- a/test/ui/widgets/magic_starter_password_confirm_dialog_test.dart +++ b/test/ui/widgets/magic_starter_password_confirm_dialog_test.dart @@ -339,6 +339,54 @@ void main() { }); }); + group('mobile overflow safety', () { + testWidgets('Dialog has vertical insetPadding', (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(wrap(const MagicStarterPasswordConfirmDialog())); + + final dialog = tester.widget(find.byType(Dialog)); + final insetPadding = dialog.insetPadding as EdgeInsets; + + expect(insetPadding.top, greaterThan(0)); + expect(insetPadding.bottom, greaterThan(0)); + expect(insetPadding.left, equals(16)); + expect(insetPadding.right, equals(16)); + }); + + testWidgets('maxHeight accounts for viewPadding safe area', (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + tester.view.viewPadding = const FakeViewPadding( + top: 44, + bottom: 34, + ); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetViewPadding); + + await tester.pumpWidget(wrap(const MagicStarterPasswordConfirmDialog())); + + final constrainedBox = tester.widget( + find.byWidgetPredicate( + (widget) => + widget is ConstrainedBox && + widget.constraints.maxHeight != double.infinity && + widget.constraints.maxWidth != double.infinity, + ), + ); + final maxHeight = constrainedBox.constraints.maxHeight; + + // Without safe area: hardcoded 600 + // With safe area: (800 - 44 - 34) * 0.85 = 613.7 + expect(maxHeight, lessThan(680)); + expect(maxHeight, closeTo(613.7, 1.0)); + }); + }); + group('modal theme integration', () { testWidgets('uses custom containerClassName from modal theme', (WidgetTester tester) async { diff --git a/test/ui/widgets/magic_starter_two_factor_modal_test.dart b/test/ui/widgets/magic_starter_two_factor_modal_test.dart index 59c84c6..d2c5337 100644 --- a/test/ui/widgets/magic_starter_two_factor_modal_test.dart +++ b/test/ui/widgets/magic_starter_two_factor_modal_test.dart @@ -451,6 +451,67 @@ void main() { }); }); + // ------------------------------------------------------------------------- + // Mobile overflow safety + // ------------------------------------------------------------------------- + group('mobile overflow safety', () { + testWidgets('Dialog has vertical insetPadding', (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(wrap( + MagicStarterTwoFactorModal( + setupData: kSetupData, + onConfirm: (_) async => true, + ), + )); + + final dialog = tester.widget(find.byType(Dialog)); + final insetPadding = dialog.insetPadding as EdgeInsets; + + expect(insetPadding.top, greaterThan(0)); + expect(insetPadding.bottom, greaterThan(0)); + expect(insetPadding.left, equals(16)); + expect(insetPadding.right, equals(16)); + }); + + testWidgets('maxHeight accounts for viewPadding safe area', (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + tester.view.viewPadding = const FakeViewPadding( + top: 44, + bottom: 34, + ); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetViewPadding); + + await tester.pumpWidget(wrap( + MagicStarterTwoFactorModal( + setupData: kSetupData, + onConfirm: (_) async => true, + ), + )); + + final constrainedBox = tester.widget( + find.byWidgetPredicate( + (widget) => + widget is ConstrainedBox && + widget.constraints.maxHeight != double.infinity && + widget.constraints.maxWidth != double.infinity, + ), + ); + final maxHeight = constrainedBox.constraints.maxHeight; + + // Without safe area: hardcoded 800 + // With safe area: (800 - 44 - 34) * 0.85 = 613.7 + expect(maxHeight, lessThan(800)); + expect(maxHeight, closeTo(613.7, 1.0)); + }); + }); + // ------------------------------------------------------------------------- // Modal theme integration // ------------------------------------------------------------------------- From c645674eaada5d4955eae422299dab29c0b02c72 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 31 Mar 2026 02:36:34 +0300 Subject: [PATCH 2/2] fix: clamp safeHeight to prevent negative maxHeight on tiny viewports --- lib/src/ui/widgets/magic_starter_dialog_shell.dart | 7 ++++--- .../ui/widgets/magic_starter_password_confirm_dialog.dart | 7 ++++--- lib/src/ui/widgets/magic_starter_two_factor_modal.dart | 7 ++++--- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/lib/src/ui/widgets/magic_starter_dialog_shell.dart b/lib/src/ui/widgets/magic_starter_dialog_shell.dart index 7da5ec5..08166e8 100644 --- a/lib/src/ui/widgets/magic_starter_dialog_shell.dart +++ b/lib/src/ui/widgets/magic_starter_dialog_shell.dart @@ -46,9 +46,10 @@ class MagicStarterDialogShell extends StatelessWidget { final theme = MagicStarter.manager.modalTheme; final viewPadding = MediaQuery.viewPaddingOf(context); - final safeHeight = MediaQuery.sizeOf(context).height - - viewPadding.top - - viewPadding.bottom; + final safeHeight = (MediaQuery.sizeOf(context).height - + viewPadding.top - + viewPadding.bottom) + .clamp(0.0, double.infinity); return Dialog( backgroundColor: Colors.transparent, diff --git a/lib/src/ui/widgets/magic_starter_password_confirm_dialog.dart b/lib/src/ui/widgets/magic_starter_password_confirm_dialog.dart index 0e75e9e..4249af8 100644 --- a/lib/src/ui/widgets/magic_starter_password_confirm_dialog.dart +++ b/lib/src/ui/widgets/magic_starter_password_confirm_dialog.dart @@ -127,9 +127,10 @@ class _MagicStarterPasswordConfirmDialogState Widget build(BuildContext context) { final theme = MagicStarter.manager.modalTheme; final viewPadding = MediaQuery.viewPaddingOf(context); - final safeHeight = MediaQuery.sizeOf(context).height - - viewPadding.top - - viewPadding.bottom; + final safeHeight = (MediaQuery.sizeOf(context).height - + viewPadding.top - + viewPadding.bottom) + .clamp(0.0, double.infinity); return Dialog( backgroundColor: Colors.transparent, diff --git a/lib/src/ui/widgets/magic_starter_two_factor_modal.dart b/lib/src/ui/widgets/magic_starter_two_factor_modal.dart index 435327b..452e85f 100644 --- a/lib/src/ui/widgets/magic_starter_two_factor_modal.dart +++ b/lib/src/ui/widgets/magic_starter_two_factor_modal.dart @@ -249,9 +249,10 @@ class _MagicStarterTwoFactorModalState Widget build(BuildContext context) { final theme = MagicStarter.manager.modalTheme; final viewPadding = MediaQuery.viewPaddingOf(context); - final safeHeight = MediaQuery.sizeOf(context).height - - viewPadding.top - - viewPadding.bottom; + final safeHeight = (MediaQuery.sizeOf(context).height - + viewPadding.top - + viewPadding.bottom) + .clamp(0.0, double.infinity); return Dialog( backgroundColor: Colors.transparent,