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
3 changes: 2 additions & 1 deletion .claude/rules/widgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ path: "lib/src/ui/widgets/**/*.dart"
- Confirm dialog: `MagicStarterConfirmDialog` with `static Future<bool> show(BuildContext context, {required String title, String? description, String? confirmLabel, String? cancelLabel, ConfirmDialogVariant variant, Future<void> Function()? onConfirm})`; variant enum `ConfirmDialogVariant.primary` (default), `.danger`, `.warning` — controls confirm button styling
- Confirm dialog variant usage: `ConfirmDialogVariant.danger` for destructive actions (delete team, revoke session), `.warning` for caution (leave team), `.primary` for neutral confirmations
- 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` is internal-only (NOT exported from barrel) — sticky header/footer with scrollable body; uses Material Dialog shell + Wind UI content; all exported dialogs compose on top of it
- 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)`)
- 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`
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@

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

## [Unreleased]
## [0.0.1-alpha.5] - 2026-03-29

### Changed
- **MagicStarterDialogShell**: Now exported publicly from the barrel (`package:magic_starter/magic_starter.dart`) — consumer apps can compose custom dialogs on top of it
- **MagicStarterDialogShell**: `footer` parameter replaced with `footerBuilder` (`Widget Function(BuildContext dialogContext)?`) — provides the dialog's own `BuildContext` so callers can call `Navigator.pop(dialogContext)` without needing an outer context

### Fixed
- **MagicStarterConfirmDialog** and **MagicStarterPasswordConfirmDialog**: Buttons are now compact and right-aligned (`justify-end gap-2 wrap`) — previously rendered as full-width (`flex-1`) buttons that stretched across the footer
- **MagicStarterDialogShell**: Body no longer creates a gap between scrollable content and the footer when content is shorter than the available height — switched from `SingleChildScrollView` to `ListView(shrinkWrap: true)`

## [0.0.1-alpha.4] - 2026-03-29

Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Flutter starter kit for the Magic Framework. Pre-built Auth, Profile, Teams & Notifications — 13 opt-in features, every screen overridable via Wind UI.

**Version:** 0.0.1-alpha.4 · **Dart:** >=3.6.0 · **Flutter:** >=3.27.0
**Version:** 0.0.1-alpha.5 · **Dart:** >=3.6.0 · **Flutter:** >=3.27.0

## Commands

Expand Down Expand Up @@ -109,7 +109,7 @@ Every feature, fix, or refactor must go through the red-green-refactor cycle:
## Skills & Extensions

- `fluttersdk:magic-framework` — Magic Framework patterns: facades, service providers, IoC, Eloquent ORM, controllers, routing. Use for ANY code touching Magic APIs.
- `fluttersdk:magic-starter-widgets` — Reusable standalone widgets exported from `package:magic_starter/magic_starter.dart`: `MagicStarterCard` (with `CardVariant` enum: surface/inset/elevated), `MagicStarterPageHeader` (title, subtitle, leading, actions), `MagicStarterConfirmDialog` (with `ConfirmDialogVariant` enum: primary/danger/warning), `MagicStarterPasswordConfirmDialog`, `MagicStarterTwoFactorModal`. All accept plain callbacks — no internal controller coupling required. All modals read `MagicStarterModalTheme` tokens at build time.
- `fluttersdk:magic-starter-widgets` — Reusable standalone widgets exported from `package:magic_starter/magic_starter.dart`: `MagicStarterCard` (with `CardVariant` enum: surface/inset/elevated), `MagicStarterPageHeader` (title, subtitle, leading, actions), `MagicStarterConfirmDialog` (with `ConfirmDialogVariant` enum: primary/danger/warning), `MagicStarterPasswordConfirmDialog`, `MagicStarterTwoFactorModal`, `MagicStarterDialogShell` (sticky header/footer + scrollable body shell; accepts `footerBuilder: Widget Function(BuildContext dialogContext)?` so callers can `Navigator.pop(dialogContext)` safely). All accept plain callbacks — no internal controller coupling required. All modals read `MagicStarterModalTheme` tokens at build time.

## CI

Expand Down
1 change: 1 addition & 0 deletions lib/magic_starter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,5 @@ export 'src/ui/widgets/magic_starter_confirm_dialog.dart';
export 'src/ui/widgets/magic_starter_two_factor_modal.dart';
export 'src/ui/widgets/magic_starter_timezone_select.dart';
export 'src/ui/widgets/magic_starter_page_header.dart';
export 'src/ui/widgets/magic_starter_dialog_shell.dart';
export 'src/ui/views/teams/magic_starter_team_invitation_accept_view.dart';
Comment thread
anilcancakir marked this conversation as resolved.
30 changes: 12 additions & 18 deletions lib/src/ui/widgets/magic_starter_confirm_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -127,27 +127,21 @@ class _MagicStarterConfirmDialogState extends State<MagicStarterConfirmDialog> {
title: widget.title,
description: widget.description,
body: const SizedBox.shrink(),
footer: WDiv(
className: 'flex flex-row gap-2 w-full',
footerBuilder: (_) => WDiv(
className: 'flex flex-row justify-end gap-2 wrap',
children: [
WDiv(
className: 'flex-1',
child: WAnchor(
onTap: _isLoading ? null : _onCancel,
child: WDiv(
className: theme.secondaryButtonClassName,
child: WText(cancelLabel),
),
WAnchor(
onTap: _isLoading ? null : _onCancel,
child: WDiv(
className: theme.secondaryButtonClassName,
child: WText(cancelLabel),
),
),
WDiv(
className: 'flex-1',
child: WButton(
onTap: _isLoading ? null : _onConfirm,
isLoading: _isLoading,
className: 'w-full ${_resolveConfirmClassName()}',
child: WText(confirmLabel),
),
WButton(
onTap: _isLoading ? null : _onConfirm,
isLoading: _isLoading,
className: _resolveConfirmClassName(),
child: WText(confirmLabel),
),
],
),
Expand Down
56 changes: 42 additions & 14 deletions lib/src/ui/widgets/magic_starter_dialog_shell.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,42 @@ import 'package:magic/magic.dart';

import '../../facades/magic_starter.dart';

/// Internal dialog shell — NOT exported from barrel.
/// Provides consistent Dialog chrome with sticky header/footer and scrollable body.
/// Reusable dialog shell providing consistent chrome for all Magic Starter
/// dialogs — sticky header, scrollable body, and optional sticky footer.
///
/// All visual tokens (container, header, title, description, body, footer
/// classNames, and `maxWidth`) are read from
/// `MagicStarter.manager.modalTheme` at build time. Set a custom theme via
/// `MagicStarter.useModalTheme()` before the first dialog is shown.
///
/// **Parameters:**
/// - [title] — optional heading rendered in the sticky header section.
/// - [description] — optional sub-heading rendered below [title].
/// - [body] — required content widget rendered in the scrollable body area.
/// - [footerBuilder] — optional builder for the sticky footer; receives the
/// dialog's own [BuildContext] so callers can access inherited widgets
/// (e.g. navigator) scoped to the dialog tree.
///
/// **Layout caveat:** the body is wrapped in a `ListView(shrinkWrap: true)`,
/// which collapses to content height. If [body] itself contains a nested
/// `ListView`, give it an explicit height constraint to avoid unbounded layout
/// errors.
class MagicStarterDialogShell extends StatelessWidget {
final String? title;
final String? description;
final Widget body;
final Widget? footer;

/// Builder for the sticky footer section. Receives the dialog's own
/// [BuildContext] so callers can access inherited widgets (e.g. theme,
/// navigator) scoped to the dialog tree.
final Widget Function(BuildContext dialogContext)? footerBuilder;

const MagicStarterDialogShell({
super.key,
this.title,
this.description,
required this.body,
this.footer,
this.footerBuilder,
});

@override
Expand Down Expand Up @@ -55,18 +77,24 @@ class MagicStarterDialogShell extends StatelessWidget {
],
),
Flexible(
child: SingleChildScrollView(
child: WDiv(
className: theme.bodyClassName,
child: body,
),
child: ListView(
shrinkWrap: true,
padding: EdgeInsets.zero,
children: [
WDiv(
className: theme.bodyClassName,
child: body,
),
],
),
),
if (footer != null)
WDiv(
key: const Key('magic_starter_dialog_shell_footer'),
className: theme.footerClassName,
child: footer,
if (footerBuilder != null)
Builder(
builder: (dialogContext) => WDiv(
key: const Key('magic_starter_dialog_shell_footer'),
className: theme.footerClassName,
child: footerBuilder!(dialogContext),
),
),
],
),
Expand Down
30 changes: 11 additions & 19 deletions lib/src/ui/widgets/magic_starter_password_confirm_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -171,28 +171,20 @@ class _MagicStarterPasswordConfirmDialogState
// Footer
WDiv(
className:
'${theme.footerClassName} flex flex-row gap-2 w-full',
'${theme.footerClassName} flex flex-row justify-end gap-2 wrap',
children: [
WDiv(
className: 'flex-1',
child: WAnchor(
onTap: _isLoading ? null : _onCancel,
child: WDiv(
className:
'${theme.secondaryButtonClassName} text-center',
child: WText(trans('common.cancel')),
),
WAnchor(
onTap: _isLoading ? null : _onCancel,
child: WDiv(
className: theme.secondaryButtonClassName,
child: WText(trans('common.cancel')),
),
),
WDiv(
className: 'flex-1',
child: WButton(
onTap: _isLoading ? null : _onConfirm,
isLoading: _isLoading,
className:
'w-full ${theme.primaryButtonClassName} text-center',
child: WText(trans('common.confirm')),
),
WButton(
onTap: _isLoading ? null : _onConfirm,
isLoading: _isLoading,
className: theme.primaryButtonClassName,
child: WText(trans('common.confirm')),
),
],
),
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: magic_starter
description: Starter kit for Magic Framework. Auth, Profile, Teams, Notifications — 13 opt-in features with overridable views.
version: 0.0.1-alpha.4
version: 0.0.1-alpha.5
homepage: https://magic.fluttersdk.com/starter
documentation: https://magic.fluttersdk.com/packages/starter/getting-started/installation
repository: https://github.com/fluttersdk/magic_starter
Expand Down
54 changes: 54 additions & 0 deletions test/ui/widgets/magic_starter_confirm_dialog_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,60 @@ void main() {
},
);

// ---------------------------------------------------------------------------
// Button layout
// ---------------------------------------------------------------------------

testWidgets(
'footer buttons are compact and right-aligned — no flex-1 wrappers',
(WidgetTester tester) async {
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
addTearDown(() => tester.view.resetDevicePixelRatio());

await tester.pumpWidget(
wrap(
const MagicStarterConfirmDialog(title: 'Confirm?'),
),
);

// Locate the specific footer Wrap that contains the cancel button.
final footerWrapFinder = find
.ancestor(
of: find.text('common.cancel'),
matching: find.byType(Wrap),
)
.first;

final wrapWidget = tester.widget<Wrap>(footerWrapFinder);

// Wrap alignment must be end (right-aligned).
expect(wrapWidget.alignment, WrapAlignment.end);

// Within the footer container, there must be no flex-1 WDiv wrappers.
final flex1WrapperFinder = find.descendant(
of: footerWrapFinder,
matching: find.byWidgetPredicate(
(widget) =>
widget is WDiv && (widget.className?.contains('flex-1') ?? false),
),
);
expect(flex1WrapperFinder, findsNothing);

// And no WButton in the footer should be forced to full width.
final fullWidthButtonFinder = find.descendant(
of: footerWrapFinder,
matching: find.byWidgetPredicate(
(widget) =>
widget is WButton &&
(widget.className?.contains('w-full') ?? false),
),
);
expect(fullWidthButtonFinder, findsNothing);
},
);

// ---------------------------------------------------------------------------
// Theme integration
// ---------------------------------------------------------------------------
Expand Down
80 changes: 73 additions & 7 deletions test/ui/widgets/magic_starter_dialog_shell_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:magic/magic.dart';
import 'package:magic_starter/magic_starter.dart';
import 'package:magic_starter/src/ui/widgets/magic_starter_dialog_shell.dart';

void main() {
setUp(() async {
Expand Down Expand Up @@ -90,23 +89,25 @@ void main() {
expect(find.text('unique body content'), findsOneWidget);
});

testWidgets('renders footer widget when provided', (tester) async {
testWidgets('renders footer widget when footerBuilder is provided',
(tester) async {
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);

await tester.pumpWidget(wrap(
const MagicStarterDialogShell(
body: Text('body content'),
footer: Text('footer content'),
MagicStarterDialogShell(
body: const Text('body content'),
footerBuilder: (_) => const Text('footer content'),
),
));

expect(find.text('footer content'), findsOneWidget);
});

testWidgets('footer section absent when footer is null', (tester) async {
testWidgets('footer section absent when footerBuilder is null',
(tester) async {
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
Expand All @@ -118,13 +119,78 @@ void main() {
),
));

// The footer placeholder key must not be present when footer is null.
// The footer placeholder key must not be present when footerBuilder is null.
expect(
find.byKey(const Key('magic_starter_dialog_shell_footer')),
findsNothing,
);
});

testWidgets('footerBuilder callback receives a valid BuildContext',
(tester) async {
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);

BuildContext? capturedContext;

await tester.pumpWidget(wrap(
MagicStarterDialogShell(
body: const Text('body content'),
footerBuilder: (dialogContext) {
capturedContext = dialogContext;
return const Text('footer with context');
},
),
));

expect(find.text('footer with context'), findsOneWidget);
expect(capturedContext, isNotNull);
// Verify the context is a valid mounted context by reading media query.
expect(MediaQuery.maybeOf(capturedContext!), isNotNull);
});

testWidgets('body uses ListView so it shrinks to content height',
(tester) async {
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);

await tester.pumpWidget(wrap(
MagicStarterDialogShell(
body: const Text('short body'),
footerBuilder: (_) => const Text('footer below body'),
),
));

// Both widgets must be rendered.
expect(find.text('short body'), findsOneWidget);
expect(find.text('footer below body'), findsOneWidget);

// Geometry assertion: footer must sit directly below the body content
// without an expanding gap. The distance between body bottom and footer
// top should be small (only theme padding, not leftover Flexible space).
final bodyBottom = tester.getBottomLeft(find.text('short body')).dy;
final footerTop = tester.getTopLeft(find.text('footer below body')).dy;
final gap = footerTop - bodyBottom;

// Gap should be modest (theme padding) — not hundreds of pixels.
expect(gap, lessThan(80));

// Confirm no SingleChildScrollView is a descendant of the dialog shell
// (the body is now wrapped by ListView, not SingleChildScrollView).
final shellFinder = find.byType(MagicStarterDialogShell);
expect(
find.descendant(
of: shellFinder,
matching: find.byType(SingleChildScrollView),
),
findsNothing,
);
Comment thread
anilcancakir marked this conversation as resolved.
});

testWidgets(
'reads containerClassName from MagicStarter.manager.modalTheme',
(tester) async {
Expand Down
Loading
Loading