Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/rules/widgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions lib/src/ui/widgets/magic_starter_dialog_shell.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 10 additions & 2 deletions lib/src/ui/widgets/magic_starter_password_confirm_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
12 changes: 10 additions & 2 deletions lib/src/ui/widgets/magic_starter_two_factor_modal.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
96 changes: 96 additions & 0 deletions test/ui/widgets/magic_starter_dialog_shell_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<Dialog>(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<ConstrainedBox>(
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<ConstrainedBox>(
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 {
Expand Down
48 changes: 48 additions & 0 deletions test/ui/widgets/magic_starter_password_confirm_dialog_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<Dialog>(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<ConstrainedBox>(
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 {
Expand Down
61 changes: 61 additions & 0 deletions test/ui/widgets/magic_starter_two_factor_modal_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<Dialog>(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<ConstrainedBox>(
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
// -------------------------------------------------------------------------
Expand Down
Loading