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..08166e8 100644 --- a/lib/src/ui/widgets/magic_starter_dialog_shell.dart +++ b/lib/src/ui/widgets/magic_starter_dialog_shell.dart @@ -45,13 +45,22 @@ 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) + .clamp(0.0, double.infinity); + 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..4249af8 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,22 @@ 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) + .clamp(0.0, double.infinity); 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..452e85f 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,22 @@ 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) + .clamp(0.0, double.infinity); 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 // -------------------------------------------------------------------------