From 6715189905b7b0ef4d9c653fea6b07bef3ec78ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Thu, 25 Jun 2026 20:32:40 +0300 Subject: [PATCH 01/12] feat: add MagicStarterTokens semantic alias layer Add MagicStarterTokens.defaultAliases: 17 semantic role tokens (surface, fg, primary, border, destructive, success, warning, ...) mapped to light + dark wind className pairs via the bg-/text-/border-color- prefix convention. Components resolve colors through these roles; the brand stays a configurable WindThemeData primary color (bg-primary-600 shade reference). This map is the stable contract design:sync will generate against. --- CHANGELOG.md | 3 + lib/magic_starter.dart | 1 + lib/src/ui/theme/magic_starter_tokens.dart | 168 +++++++++++++++++++ test/ui/theme/magic_starter_tokens_test.dart | 165 ++++++++++++++++++ 4 files changed, 337 insertions(+) create mode 100644 lib/src/ui/theme/magic_starter_tokens.dart create mode 100644 test/ui/theme/magic_starter_tokens_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index e8a238a..a38f4c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added +- **`MagicStarterTokens.defaultAliases`**: semantic token alias map with 17 roles (`surface`, `surface-container`, `surface-container-high`, `fg`, `fg-muted`, `fg-disabled`, `primary`, `on-primary`, `primary-container`, `accent`, `border`, `border-subtle`, `destructive`, `on-destructive`, `destructive-container`, `success`, `warning`). Each role maps to a light+dark wind className pair (`'bg-... dark:bg-...'` / `'text-... dark:text-...'`). Pass as `WindThemeData(aliases: MagicStarterTokens.defaultAliases)` so components resolve against semantic roles rather than palette utilities directly. This map is the stable key contract that `design:sync` (Steps 20-21) will later regenerate from `DESIGN.md`. + ### Fixed - **Create team opens the new team's settings**: `MagicStarterTeamController.activeTeamName` now matches the local `currentTeamId` (when set) against the resolver's `allTeams()` by id, falling back to the resolver's `currentTeam()` when no match exists. Previously `activeTeamId` preferred the local `currentTeamId` notifier (set on create/switch) while `activeTeamName` read only the resolver's `currentTeam()`, so after creating a team the settings view pre-filled the OLD team's name and effectively opened the old team ([#14](https://github.com/fluttersdk/magic_starter/issues/14)). diff --git a/lib/magic_starter.dart b/lib/magic_starter.dart index cc3c2b7..96451ca 100644 --- a/lib/magic_starter.dart +++ b/lib/magic_starter.dart @@ -1,5 +1,6 @@ // Magic Starter plugin exports +export 'src/ui/theme/magic_starter_tokens.dart'; export 'src/cli/starter_artisan_provider.dart'; export 'src/facades/magic_starter.dart'; export 'src/magic_starter_manager.dart'; diff --git a/lib/src/ui/theme/magic_starter_tokens.dart b/lib/src/ui/theme/magic_starter_tokens.dart new file mode 100644 index 0000000..6bc9a99 --- /dev/null +++ b/lib/src/ui/theme/magic_starter_tokens.dart @@ -0,0 +1,168 @@ +/// **Semantic Token Alias Convention** +/// +/// [MagicStarterTokens] provides the default light+dark alias map for the +/// magic_starter component system. Every component resolves colors through +/// these semantic role names rather than palette utilities directly, so a +/// single `DESIGN.md` override (generated by `design:sync`) can re-skin the +/// entire starter kit without touching component code. +/// +/// ## Alias Key Convention +/// +/// Alias keys follow the CSS-property-prefix convention: +/// +/// - **Background roles** use `bg-{role}`: `bg-surface`, `bg-primary`, +/// `bg-destructive`, etc. A component writes `className: 'bg-surface p-4'` +/// and the alias expander replaces the bare `bg-surface` token before the +/// wind parser sees it. +/// - **Text / foreground roles** use `text-{role}`: `text-fg`, `text-fg-muted`, +/// `text-on-primary`, etc. +/// - **Border color roles** use `border-color-{role}`: `border-color-border`, +/// `border-color-border-subtle`. Note: the bare `border` Tailwind utility +/// (add a 1 px border) is separate and unaffected. +/// +/// ## Alias Values +/// +/// Each value is a wind className string containing exactly two tokens: +/// +/// - one bare palette utility (applied in **light** mode), and +/// - one `dark:`-prefixed palette utility (applied in **dark** mode). +/// +/// Example: `'bg-white dark:bg-gray-950'` +/// +/// The `primary-*` shade references (`bg-primary-600`, `text-primary-600`, +/// etc.) resolve against the consumer-supplied `primary` [MaterialColor] in +/// [WindThemeData.colors] — this map never hardcodes the brand color. +/// +/// ## Design:sync Target +/// +/// Steps 20-21 (`design:sync`) will later generate a replacement map from a +/// `DESIGN.md` file using arbitrary-hex values +/// (`'bg-[#f9f9ff] dark:bg-[#0f1419]'`). The key set in [defaultAliases] is +/// the **stable contract** that the generated map must satisfy; do not rename +/// keys without coordinating the `design:sync` emitter. +/// +/// ## Semantic Roles +/// +/// The 17 semantic roles and their key prefixes: +/// +/// | Role | Alias key | +/// |------|-----------| +/// | surface | `bg-surface` | +/// | surface-container | `bg-surface-container` | +/// | surface-container-high | `bg-surface-container-high` | +/// | fg | `text-fg` | +/// | fg-muted | `text-fg-muted` | +/// | fg-disabled | `text-fg-disabled` | +/// | primary | `bg-primary` | +/// | on-primary | `text-on-primary` | +/// | primary-container | `bg-primary-container` | +/// | accent | `bg-accent` | +/// | border | `border-color-border` | +/// | border-subtle | `border-color-border-subtle` | +/// | destructive | `bg-destructive` | +/// | on-destructive | `text-on-destructive` | +/// | destructive-container | `bg-destructive-container` | +/// | success | `bg-success` | +/// | warning | `bg-warning` | +/// +/// ## Usage +/// +/// ```dart +/// final windTheme = WindThemeData( +/// colors: {'primary': myBrandColor}, +/// aliases: MagicStarterTokens.defaultAliases, +/// ); +/// ``` +/// +/// In a component: +/// +/// ```dart +/// WDiv( +/// className: 'bg-surface text-fg border border-[1px] rounded-lg p-4', +/// child: WText('Hello', className: 'text-fg'), +/// ) +/// ``` +final class MagicStarterTokens { + MagicStarterTokens._(); + + /// The default semantic alias map providing light+dark token pairs for all + /// 17 semantic roles used by the magic_starter component system. + /// + /// Keys follow the `bg-{role}` / `text-{role}` / `border-color-{role}` + /// convention so they match the unprefixed tokens used in component + /// className strings. See the class-level documentation for the full + /// key-to-role mapping table. + /// + /// The `primary-*` shade references resolve against the consumer-supplied + /// `primary` [MaterialColor] in [WindThemeData.colors]; the brand color is + /// never hardcoded here. + static const Map defaultAliases = { + // -- Surface roles (bg- prefix) ----------------------------------------- + + /// Main app surface: page and modal backgrounds. + 'bg-surface': 'bg-white dark:bg-gray-950', + + /// Slightly elevated container: cards, panels. + 'bg-surface-container': 'bg-gray-50 dark:bg-gray-900', + + /// More elevated container: nested cards, input backgrounds. + 'bg-surface-container-high': 'bg-gray-100 dark:bg-gray-800', + + // -- Foreground roles (text- prefix) ------------------------------------ + + /// Primary body text. + 'text-fg': 'text-gray-900 dark:text-gray-50', + + /// Secondary / muted text: labels, captions. + 'text-fg-muted': 'text-gray-500 dark:text-gray-400', + + /// Disabled text: placeholder, inactive labels. + 'text-fg-disabled': 'text-gray-300 dark:text-gray-600', + + // -- Primary roles ------------------------------------------------------ + + /// Primary action background: buttons, active states. + /// Resolves against the consumer-supplied `primary` color in WindThemeData. + 'bg-primary': 'bg-primary-600 dark:bg-primary-500', + + /// Text / icon on top of the primary background. + 'text-on-primary': 'text-white dark:text-white', + + /// Light tinted container for primary-adjacent surfaces. + 'bg-primary-container': 'bg-primary-100 dark:bg-primary-900', + + // -- Accent role -------------------------------------------------------- + + /// Secondary accent background for decorative highlights (default: indigo). + 'bg-accent': 'bg-indigo-600 dark:bg-indigo-500', + + // -- Border color roles (border-color- prefix) -------------------------- + // + // Note: these control border COLOR only. The `border` / `border-{width}` + // Tailwind utilities that add border-width are separate bare tokens and + // are not affected by these aliases. + + /// Default border and divider color. + 'border-color-border': 'border-gray-200 dark:border-gray-700', + + /// Subtle divider, less prominent than `border-color-border`. + 'border-color-border-subtle': 'border-gray-100 dark:border-gray-800', + + // -- Semantic state roles (bg- / text- prefix) -------------------------- + + /// Destructive / danger action background. + 'bg-destructive': 'bg-red-600 dark:bg-red-500', + + /// Text / icon on top of the destructive background. + 'text-on-destructive': 'text-white dark:text-white', + + /// Light tinted container for destructive-adjacent surfaces. + 'bg-destructive-container': 'bg-red-100 dark:bg-red-900', + + /// Success / positive confirmation background. + 'bg-success': 'bg-green-600 dark:bg-green-500', + + /// Warning / caution background. + 'bg-warning': 'bg-amber-600 dark:bg-amber-500', + }; +} diff --git a/test/ui/theme/magic_starter_tokens_test.dart b/test/ui/theme/magic_starter_tokens_test.dart new file mode 100644 index 0000000..28a6ab8 --- /dev/null +++ b/test/ui/theme/magic_starter_tokens_test.dart @@ -0,0 +1,165 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; +import 'package:magic_starter/magic_starter.dart'; + +void main() { + // ------------------------------------------------------------------------- + // defaultAliases structure + // ------------------------------------------------------------------------- + + group('MagicStarterTokens.defaultAliases', () { + test('contains all 17 required semantic roles', () { + // Keys follow the bg-/text-/border-color- prefix convention. + const roles = [ + 'bg-surface', + 'bg-surface-container', + 'bg-surface-container-high', + 'text-fg', + 'text-fg-muted', + 'text-fg-disabled', + 'bg-primary', + 'text-on-primary', + 'bg-primary-container', + 'bg-accent', + 'border-color-border', + 'border-color-border-subtle', + 'bg-destructive', + 'text-on-destructive', + 'bg-destructive-container', + 'bg-success', + 'bg-warning', + ]; + + for (final role in roles) { + expect( + MagicStarterTokens.defaultAliases, + containsPair(role, isA()), + reason: 'missing role key: $role', + ); + } + }); + + test('every role value contains a light token and a dark: token', () { + for (final entry in MagicStarterTokens.defaultAliases.entries) { + final value = entry.value; + final tokens = value.split(RegExp(r'\s+')); + + final hasLight = tokens.any((t) => !t.contains(':')); + final hasDark = tokens.any((t) => t.startsWith('dark:')); + + expect( + hasLight, + isTrue, + reason: '${entry.key} is missing a light token in "$value"', + ); + expect( + hasDark, + isTrue, + reason: '${entry.key} is missing a dark: token in "$value"', + ); + } + }); + + test( + 'bg-prefixed keys have bg- values; text-prefixed keys have text- values', + () { + for (final entry in MagicStarterTokens.defaultAliases.entries) { + final key = entry.key; + final value = entry.value; + + if (key.startsWith('bg-')) { + expect( + value, + startsWith('bg-'), + reason: '$key value should start with bg-: got "$value"', + ); + } else if (key.startsWith('text-')) { + expect( + value.contains('text-'), + isTrue, + reason: '$key value should contain text-: got "$value"', + ); + } else if (key.startsWith('border-color-')) { + expect( + value.contains('border-'), + isTrue, + reason: '$key value should contain border-: got "$value"', + ); + } + } + }); + }); + + // ------------------------------------------------------------------------- + // QA: bg-surface text-fg resolves (not a silent no-op) + // ------------------------------------------------------------------------- + + group('alias resolution — bg-surface text-fg', () { + Widget wrap(Widget widget, {Brightness brightness = Brightness.light}) { + return MaterialApp( + home: WindTheme( + data: WindThemeData( + brightness: brightness, + aliases: MagicStarterTokens.defaultAliases, + ), + child: Scaffold(body: widget), + ), + ); + } + + testWidgets('bg-surface resolves (not a silent no-op) in light mode', + (WidgetTester tester) async { + await tester.pumpWidget( + wrap(const WDiv(className: 'bg-surface')), + ); + + // WindParser expands aliases pre-parse. bg-surface -> bg-white which + // the background parser resolves to a Color, so the WDiv renders a + // Container with a non-null decoration. We assert the widget tree builds + // without exception and find a Container (the decoration wrapper). + expect(find.byType(WDiv), findsOneWidget); + expect(find.byType(Container), findsWidgets); + }); + + testWidgets('bg-surface resolves (not a silent no-op) in dark mode', + (WidgetTester tester) async { + await tester.pumpWidget( + wrap( + const WDiv(className: 'bg-surface'), + brightness: Brightness.dark, + ), + ); + + expect(find.byType(WDiv), findsOneWidget); + expect(find.byType(Container), findsWidgets); + }); + + testWidgets('bg-surface text-fg builds without exception', + (WidgetTester tester) async { + await tester.pumpWidget( + wrap( + const WDiv( + className: 'bg-surface', + child: WText('hello', className: 'text-fg'), + ), + ), + ); + + expect(find.byType(WDiv), findsOneWidget); + expect(find.text('hello'), findsOneWidget); + }); + + testWidgets('aliases map wired into WindThemeData is not empty', + (WidgetTester tester) async { + await tester.pumpWidget( + wrap(const SizedBox()), + ); + + final theme = WindTheme.of(tester.element(find.byType(Scaffold))); + expect(theme.data.aliases, isNotEmpty); + expect(theme.data.aliases.containsKey('bg-surface'), isTrue); + expect(theme.data.aliases.containsKey('text-fg'), isTrue); + }); + }); +} From 44055c42be3b97981e66f16f7c0aee86cbd8bab8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Thu, 25 Jun 2026 20:41:15 +0300 Subject: [PATCH 02/12] refactor: migrate MagicStarterCard to WindRecipe atomic component Establish the design-system migration pattern + atomic-folder template: lib/src/ui/components/card/{card.dart, card.recipe.dart, card.preview.dart, index.dart}. Card variant logic moves to a theme-driven buildCardRecipe() WindRecipe; output is byte-identical to the legacy _defaultClassName for every variant x noPadding combo (asserted by an equivalence gate against a legacyDefaultClassName baseline). MagicStarterCard becomes a thin alias subclass; CardVariant + barrel path stay byte-stable. --- CHANGELOG.md | 3 + lib/src/ui/components/card/card.dart | 147 ++++++++++++++++++ lib/src/ui/components/card/card.preview.dart | 39 +++++ lib/src/ui/components/card/card.recipe.dart | 53 +++++++ lib/src/ui/components/card/index.dart | 10 ++ lib/src/ui/widgets/magic_starter_card.dart | 153 +++---------------- test/ui/widgets/magic_starter_card_test.dart | 79 ++++++++++ 7 files changed, 348 insertions(+), 136 deletions(-) create mode 100644 lib/src/ui/components/card/card.dart create mode 100644 lib/src/ui/components/card/card.preview.dart create mode 100644 lib/src/ui/components/card/card.recipe.dart create mode 100644 lib/src/ui/components/card/index.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index a38f4c2..5998b1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Changed +- **Card migrated to the atomic-component folder + `WindRecipe`**: the card now lives at `lib/src/ui/components/card/` in the canonical 4-file shape (`card.dart` → `class Card`, `card.recipe.dart`, `card.preview.dart`, `index.dart`) and resolves its root className through a theme-driven `WindRecipe` instead of inline string interpolation. The recipe output is byte-identical to the previous `_defaultClassName` for every `CardVariant` x `noPadding` combination (gated by an explicit equivalence test). `MagicStarterCard` is retained as a thin re-export alias of `Card`, and `CardVariant` plus the barrel export path (`package:magic_starter/magic_starter.dart`) are unchanged, so existing callers and the widget-test suite are untouched. This establishes the verbatim template for the Wave 4 component migration. + ### Added - **`MagicStarterTokens.defaultAliases`**: semantic token alias map with 17 roles (`surface`, `surface-container`, `surface-container-high`, `fg`, `fg-muted`, `fg-disabled`, `primary`, `on-primary`, `primary-container`, `accent`, `border`, `border-subtle`, `destructive`, `on-destructive`, `destructive-container`, `success`, `warning`). Each role maps to a light+dark wind className pair (`'bg-... dark:bg-...'` / `'text-... dark:text-...'`). Pass as `WindThemeData(aliases: MagicStarterTokens.defaultAliases)` so components resolve against semantic roles rather than palette utilities directly. This map is the stable key contract that `design:sync` (Steps 20-21) will later regenerate from `DESIGN.md`. diff --git a/lib/src/ui/components/card/card.dart b/lib/src/ui/components/card/card.dart new file mode 100644 index 0000000..8a7ba25 --- /dev/null +++ b/lib/src/ui/components/card/card.dart @@ -0,0 +1,147 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import '../../../facades/magic_starter.dart'; +import 'card.recipe.dart'; + +/// Visual style variants for [Card]. +/// +/// - [surface] — Default flat card: white/gray-800 background with a subtle border. +/// - [inset] — Recessed appearance: slightly darker background (gray-50/gray-900) +/// with the same border, useful for secondary or nested content sections. +/// - [elevated] — Raised appearance: white/gray-800 background with a drop +/// shadow instead of a border, making the card float above the page. +enum CardVariant { + /// Default flat card with border (no shadow). + surface, + + /// Slightly recessed card with a darker background and border. + inset, + + /// Floating card with a drop shadow and no border. + elevated, +} + +/// A reusable card component for Magic Starter views. +/// +/// Provides a consistent background, border, and padding through a +/// [WindRecipe] (`card.recipe.dart`) whose output is byte-identical to the +/// pre-migration `MagicStarterCard` for every variant x [noPadding] combo. +/// Optionally includes a title at the top. +/// +/// ### Variant styles +/// +/// Pass [variant] to control the visual appearance of the card: +/// +/// ```dart +/// Card( +/// variant: CardVariant.elevated, +/// child: ..., +/// ) +/// ``` +/// +/// When [noPadding] is `true`, the card omits its default `p-6` padding from +/// the body so that full-bleed content (e.g. list rows that span edge-to-edge) +/// can be placed inside. The title — if provided — always gets its own +/// `px-6 pt-6 pb-3` padding when [noPadding] is active. +/// +/// ### Example — padded card with title: +/// ```dart +/// Card( +/// title: 'Team Settings', +/// child: WFormInput(...), +/// ) +/// ``` +/// +/// ### Example — full-bleed list card: +/// ```dart +/// Card( +/// title: 'Members', +/// noPadding: true, +/// child: WDiv( +/// className: 'flex flex-col', +/// children: rows.map((r) => _buildRow(r)).toList(), +/// ), +/// ) +/// ``` +@immutable +class Card extends StatelessWidget { + /// The optional title to display at the top of the card. + final String? title; + + /// The main content of the card. + final Widget child; + + /// Optional className to override the default card styling entirely. + final String? className; + + /// When `true`, removes the default `p-6 gap-4` padding from the card body + /// so that list rows or other full-bleed content can span edge-to-edge. + /// + /// The title (if any) always receives `px-6 pt-6 pb-3` padding so it + /// aligns visually with padded row content (`px-6`). + final bool noPadding; + + /// The visual style variant for this card. + /// + /// Defaults to [CardVariant.surface] which reproduces the original card + /// appearance (white/gray-800 background, subtle border, no shadow). + final CardVariant variant; + + /// Creates a [Card]. + const Card({ + super.key, + required this.child, + this.title, + this.className, + this.noPadding = false, + this.variant = CardVariant.surface, + }); + + /// Resolves the root className from the card recipe (theme-driven). + /// + /// When [className] is supplied it overrides the recipe output entirely, + /// preserving the original escape hatch behaviour. + String _resolveClassName() { + if (className != null) { + return className!; + } + + final recipe = buildCardRecipe(MagicStarter.cardTheme); + return recipe( + variants: { + kCardVariantAxis: variant.name, + kCardPaddingAxis: + noPadding ? kCardPaddingNoPadding : kCardPaddingPadded, + }, + ); + } + + @override + Widget build(BuildContext context) { + return WDiv( + className: _resolveClassName(), + children: [ + if (title != null) + if (noPadding) + // Full-bleed mode: title needs its own horizontal padding to align + // with row content that uses px-6. + WDiv( + className: + MagicStarter.cardTheme.titleNoPaddingContainerClassName, + child: WText( + title!, + className: MagicStarter.cardTheme.titleClassName, + ), + ) + else + // Padded mode: card already provides p-6, title renders directly. + WText( + title!, + className: MagicStarter.cardTheme.titleClassName, + ), + child, + ], + ); + } +} diff --git a/lib/src/ui/components/card/card.preview.dart b/lib/src/ui/components/card/card.preview.dart new file mode 100644 index 0000000..623a4b4 --- /dev/null +++ b/lib/src/ui/components/card/card.preview.dart @@ -0,0 +1,39 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'card.dart'; + +/// Static variant-matrix preview for [Card]. +/// +/// Renders every [CardVariant] in both the padded and full-bleed (`noPadding`) +/// modes so the catalog (Wave 6 `/preview`) can show the full surface in light +/// and dark. One preview class per file is the canonical Wave 4 contract. +class CardPreview extends StatelessWidget { + /// Creates the card variant-matrix preview. + const CardPreview({super.key}); + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex flex-col gap-6 p-6', + children: [ + for (final variant in CardVariant.values) ...[ + Card( + title: '${variant.name} (padded)', + variant: variant, + child: const WText('Body content', className: 'text-sm'), + ), + Card( + title: '${variant.name} (noPadding)', + variant: variant, + noPadding: true, + child: const WDiv( + className: 'flex flex-col p-6', + child: WText('Full-bleed body', className: 'text-sm'), + ), + ), + ], + ], + ); + } +} diff --git a/lib/src/ui/components/card/card.recipe.dart b/lib/src/ui/components/card/card.recipe.dart new file mode 100644 index 0000000..4479271 --- /dev/null +++ b/lib/src/ui/components/card/card.recipe.dart @@ -0,0 +1,53 @@ +import 'package:magic/magic.dart'; + +import '../../../configuration/magic_starter_theme.dart'; + +/// The variant axis key for the card recipe (`CardVariant..name`). +const String kCardVariantAxis = 'variant'; + +/// The padding axis key for the card recipe (`padded` / `noPadding`). +const String kCardPaddingAxis = 'padding'; + +/// The `padding` axis value for the default padded body. +const String kCardPaddingPadded = 'padded'; + +/// The `padding` axis value for the full-bleed (no body padding) body. +const String kCardPaddingNoPadding = 'noPadding'; + +/// Builds the card [WindRecipe] from a [MagicStarterCardTheme]. +/// +/// The recipe is theme-driven (not a top-level const) because the card tone, +/// radius, and padding classNames are all overridable through +/// `MagicStarter.useCardTheme()` and the test suite swaps them per case. The +/// emission order reproduces the original `_defaultClassName` interpolation +/// VERBATIM so the recipe output stays byte-identical to the pre-migration +/// widget for every variant x padding combination: +/// +/// ```text +/// w-full {variantClass} {borderRadius} {paddingTail} +/// ``` +/// +/// where `paddingTail` is `{paddingClassName} flex flex-col gap-4` in the +/// padded mode and `overflow-hidden flex flex-col` in the full-bleed mode. The +/// border-radius is folded into each variant value (rather than its own axis) +/// so it sits immediately after the tone class, matching the original order. +WindRecipe buildCardRecipe(MagicStarterCardTheme theme) { + return WindRecipe( + base: 'w-full', + variants: { + kCardVariantAxis: { + 'surface': '${theme.surfaceClassName} ${theme.borderRadius}', + 'inset': '${theme.insetClassName} ${theme.borderRadius}', + 'elevated': '${theme.elevatedClassName} ${theme.borderRadius}', + }, + kCardPaddingAxis: { + kCardPaddingPadded: '${theme.paddingClassName} flex flex-col gap-4', + kCardPaddingNoPadding: 'overflow-hidden flex flex-col', + }, + }, + defaultVariants: { + kCardVariantAxis: 'surface', + kCardPaddingAxis: kCardPaddingPadded, + }, + ); +} diff --git a/lib/src/ui/components/card/index.dart b/lib/src/ui/components/card/index.dart new file mode 100644 index 0000000..850cf69 --- /dev/null +++ b/lib/src/ui/components/card/index.dart @@ -0,0 +1,10 @@ +// Card component — folder-local barrel. +// +// Canonical Wave 4 atomic-component shape: the recipe, the component, and the +// preview each live in their own dotted-suffix file; this index re-exports the +// public surface (component + variant enum). The preview is intentionally NOT +// re-exported here — `previews:refresh` (Step 18) discovers `*.preview.dart` +// files directly, and the preview must stay out of the release barrel. + +export 'card.dart' show Card, CardVariant; +export 'card.recipe.dart'; diff --git a/lib/src/ui/widgets/magic_starter_card.dart b/lib/src/ui/widgets/magic_starter_card.dart index 2cfb275..9772a23 100644 --- a/lib/src/ui/widgets/magic_starter_card.dart +++ b/lib/src/ui/widgets/magic_starter_card.dart @@ -1,145 +1,26 @@ -import 'package:flutter/material.dart'; -import 'package:magic/magic.dart'; +import 'package:flutter/widgets.dart'; -import '../../facades/magic_starter.dart'; +import '../components/card/card.dart'; -/// Visual style variants for [MagicStarterCard]. -/// -/// - [surface] — Default flat card: white/gray-800 background with a subtle border. -/// - [inset] — Recessed appearance: slightly darker background (gray-50/gray-900) -/// with the same border, useful for secondary or nested content sections. -/// - [elevated] — Raised appearance: white/gray-800 background with a drop -/// shadow instead of a border, making the card float above the page. -enum CardVariant { - /// Default flat card with border (no shadow). - surface, - - /// Slightly recessed card with a darker background and border. - inset, +export '../components/card/card.dart' show Card, CardVariant; - /// Floating card with a drop shadow and no border. - elevated, -} - -/// A reusable card component for Magic Starter views. -/// -/// Provides a consistent background, border, and padding. -/// Optionally includes a title at the top. -/// -/// ### Variant styles -/// -/// Pass [variant] to control the visual appearance of the card: -/// -/// ```dart -/// MagicStarterCard( -/// variant: CardVariant.elevated, -/// child: ..., -/// ) -/// ``` -/// -/// When [noPadding] is `true`, the card omits its default `p-6` padding from -/// the body so that full-bleed content (e.g. list rows that span edge-to-edge) -/// can be placed inside. The title — if provided — always gets its own -/// `px-6 pt-6 pb-3` padding when [noPadding] is active. +/// Thin backwards-compatible alias for the migrated [Card] component. /// -/// ### Example — padded card with title: -/// ```dart -/// MagicStarterCard( -/// title: 'Team Settings', -/// child: WFormInput(...), -/// ) -/// ``` -/// -/// ### Example — full-bleed list card: -/// ```dart -/// MagicStarterCard( -/// title: 'Members', -/// noPadding: true, -/// child: WDiv( -/// className: 'flex flex-col', -/// children: rows.map((r) => _buildRow(r)).toList(), -/// ), -/// ) -/// ``` +/// The card moved to the canonical atomic-component folder +/// (`lib/src/ui/components/card/`) as part of the design-system migration. +/// This subclass preserves the historic `MagicStarterCard` name, constructor +/// signature, and barrel export path so existing callers and the widget test +/// suite (`find.byType(MagicStarterCard)`) stay untouched. New code should +/// import [Card] directly. @immutable -class MagicStarterCard extends StatelessWidget { - /// The optional title to display at the top of the card. - final String? title; - - /// The main content of the card. - final Widget child; - - /// Optional className to override the default card styling entirely. - final String? className; - - /// When `true`, removes the default `p-6 gap-4` padding from the card body - /// so that list rows or other full-bleed content can span edge-to-edge. - /// - /// The title (if any) always receives `px-6 pt-6 pb-3` padding so it - /// aligns visually with padded row content (`px-6`). - final bool noPadding; - - /// The visual style variant for this card. - /// - /// Defaults to [CardVariant.surface] which reproduces the original card - /// appearance (white/gray-800 background, subtle border, no shadow). - final CardVariant variant; - - /// Creates a [MagicStarterCard]. +class MagicStarterCard extends Card { + /// Creates a [MagicStarterCard] (alias of [Card]). const MagicStarterCard({ super.key, - required this.child, - this.title, - this.className, - this.noPadding = false, - this.variant = CardVariant.surface, + required super.child, + super.title, + super.className, + super.noPadding, + super.variant, }); - - String get _variantClasses { - final theme = MagicStarter.cardTheme; - switch (variant) { - case CardVariant.surface: - return theme.surfaceClassName; - case CardVariant.inset: - return theme.insetClassName; - case CardVariant.elevated: - return theme.elevatedClassName; - } - } - - String get _defaultClassName { - final theme = MagicStarter.cardTheme; - final v = _variantClasses; - return noPadding - ? 'w-full $v ${theme.borderRadius} overflow-hidden flex flex-col' - : 'w-full $v ${theme.borderRadius} ${theme.paddingClassName} flex flex-col gap-4'; - } - - @override - Widget build(BuildContext context) { - return WDiv( - className: className ?? _defaultClassName, - children: [ - if (title != null) - if (noPadding) - // Full-bleed mode: title needs its own horizontal padding to align - // with row content that uses px-6. - WDiv( - className: - MagicStarter.cardTheme.titleNoPaddingContainerClassName, - child: WText( - title!, - className: MagicStarter.cardTheme.titleClassName, - ), - ) - else - // Padded mode: card already provides p-6, title renders directly. - WText( - title!, - className: MagicStarter.cardTheme.titleClassName, - ), - child, - ], - ); - } } diff --git a/test/ui/widgets/magic_starter_card_test.dart b/test/ui/widgets/magic_starter_card_test.dart index d6d93a6..a7c186e 100644 --- a/test/ui/widgets/magic_starter_card_test.dart +++ b/test/ui/widgets/magic_starter_card_test.dart @@ -2,6 +2,27 @@ 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/components/card/card.recipe.dart'; + +/// Reproduces the pre-migration `_defaultClassName` interpolation verbatim. +/// +/// This is the equivalence baseline the WindRecipe MUST match byte-for-byte +/// for every variant x noPadding combination. It mirrors the original widget +/// body at `magic_starter_card.dart:110-116` before the migration. +String legacyDefaultClassName( + MagicStarterCardTheme theme, + CardVariant variant, + bool noPadding, +) { + final v = switch (variant) { + CardVariant.surface => theme.surfaceClassName, + CardVariant.inset => theme.insetClassName, + CardVariant.elevated => theme.elevatedClassName, + }; + return noPadding + ? 'w-full $v ${theme.borderRadius} overflow-hidden flex flex-col' + : 'w-full $v ${theme.borderRadius} ${theme.paddingClassName} flex flex-col gap-4'; +} void main() { setUp(() { @@ -76,6 +97,64 @@ void main() { expect(wDiv.className, contains(customClassName)); }); + // ------------------------------------------------------------------------- + // WindRecipe byte-identical equivalence gate + // ------------------------------------------------------------------------- + + group('card recipe equivalence', () { + test( + 'recipe output is byte-identical to legacy _defaultClassName for every ' + 'variant x noPadding combo (default theme)', () { + const theme = MagicStarterCardTheme(); + final recipe = buildCardRecipe(theme); + + for (final variant in CardVariant.values) { + for (final noPadding in [false, true]) { + final actual = recipe( + variants: { + kCardVariantAxis: variant.name, + kCardPaddingAxis: + noPadding ? kCardPaddingNoPadding : kCardPaddingPadded, + }, + ); + final expected = legacyDefaultClassName(theme, variant, noPadding); + + expect( + actual, + expected, + reason: 'variant=$variant noPadding=$noPadding must match exactly', + ); + } + } + }); + + test('recipe output stays byte-identical under a custom theme', () { + const theme = MagicStarterCardTheme( + surfaceClassName: 'bg-zinc-900 border border-zinc-700', + insetClassName: 'bg-zinc-800 border border-zinc-700', + elevatedClassName: 'bg-zinc-900 shadow-lg', + borderRadius: 'rounded-xl', + paddingClassName: 'p-8', + ); + final recipe = buildCardRecipe(theme); + + for (final variant in CardVariant.values) { + for (final noPadding in [false, true]) { + final actual = recipe( + variants: { + kCardVariantAxis: variant.name, + kCardPaddingAxis: + noPadding ? kCardPaddingNoPadding : kCardPaddingPadded, + }, + ); + final expected = legacyDefaultClassName(theme, variant, noPadding); + + expect(actual, expected); + } + } + }); + }); + // ------------------------------------------------------------------------- // CardVariant tests // ------------------------------------------------------------------------- From 103a5518fb046a331497b686e2750887796262fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Thu, 25 Jun 2026 20:59:52 +0300 Subject: [PATCH 03/12] feat: add structure components (FormField, PageHeader, EmptyState, ErrorState, Navbar) + composites (NotificationDropdown, UserProfileDropdown, TeamSelector, SocialDivider) - Add MagicFormField (slot root/label/hint/error; destructive error tone) - Add PageHeader component (title/subtitle/leading/actions/titleSuffix/inlineActions; byte-identical to MagicStarterPageHeader) - Add EmptyState (slot root/iconWrap/title/description/action; semantic tokens) - Add ErrorState (slot root/iconWrap/title/description/action; destructive tone title) - Add Navbar (responsive brand/children/trailing; hidden sm:flex nav links) - Migrate NotificationDropdown composite (StreamBuilder unread badge preserved) - Migrate UserProfileDropdown composite (triggerBuilder/alignment/logout preserved) - Migrate TeamSelector composite (teamResolver callbacks preserved) - Migrate SocialDivider composite (authTheme tokens preserved) - Convert 4 widget files to thin aliases keeping MagicStarterXxx names stable - All components follow canonical 4-file atomic folder shape (recipe/component/preview/index) - 996 tests pass; flutter analyze zero errors/warnings --- .../components/empty_state/empty_state.dart | 62 +++ .../empty_state/empty_state.preview.dart | 37 ++ .../empty_state/empty_state.recipe.dart | 20 + lib/src/ui/components/empty_state/index.dart | 5 + .../components/error_state/error_state.dart | 65 +++ .../error_state/error_state.preview.dart | 32 ++ .../error_state/error_state.recipe.dart | 20 + lib/src/ui/components/error_state/index.dart | 5 + .../ui/components/form_field/form_field.dart | 75 ++++ .../form_field/form_field.preview.dart | 50 +++ .../form_field/form_field.recipe.dart | 23 ++ lib/src/ui/components/form_field/index.dart | 9 + lib/src/ui/components/navbar/index.dart | 5 + lib/src/ui/components/navbar/navbar.dart | 62 +++ .../ui/components/navbar/navbar.preview.dart | 37 ++ .../ui/components/navbar/navbar.recipe.dart | 18 + .../notification_dropdown/index.dart | 4 + .../notification_dropdown.dart | 378 +++++++++++++++++ .../notification_dropdown.preview.dart | 50 +++ .../notification_dropdown.recipe.dart | 2 + lib/src/ui/components/page_header/index.dart | 4 + .../components/page_header/page_header.dart | 97 +++++ .../page_header/page_header.preview.dart | 47 +++ .../page_header/page_header.recipe.dart | 4 + .../ui/components/social_divider/index.dart | 4 + .../social_divider/social_divider.dart | 44 ++ .../social_divider.preview.dart | 21 + .../social_divider/social_divider.recipe.dart | 2 + .../ui/components/team_selector/index.dart | 4 + .../team_selector/team_selector.dart | 194 +++++++++ .../team_selector/team_selector.preview.dart | 25 ++ .../team_selector/team_selector.recipe.dart | 2 + .../user_profile_dropdown/index.dart | 4 + .../user_profile_dropdown.dart | 244 +++++++++++ .../user_profile_dropdown.preview.dart | 23 ++ .../user_profile_dropdown.recipe.dart | 2 + .../magic_starter_notification_dropdown.dart | 390 +----------------- .../ui/widgets/magic_starter_page_header.dart | 91 +--- .../widgets/magic_starter_social_divider.dart | 40 +- .../widgets/magic_starter_team_selector.dart | 191 +-------- .../magic_starter_user_profile_dropdown.dart | 248 +---------- .../empty_state/empty_state_test.dart | 93 +++++ .../error_state/error_state_test.dart | 98 +++++ .../form_field/form_field_test.dart | 142 +++++++ test/ui/components/navbar/navbar_test.dart | 77 ++++ .../notification_dropdown_test.dart | 128 ++++++ .../page_header/page_header_test.dart | 188 +++++++++ .../social_divider/social_divider_test.dart | 64 +++ .../team_selector/team_selector_test.dart | 74 ++++ .../user_profile_dropdown_test.dart | 171 ++++++++ 50 files changed, 2769 insertions(+), 906 deletions(-) create mode 100644 lib/src/ui/components/empty_state/empty_state.dart create mode 100644 lib/src/ui/components/empty_state/empty_state.preview.dart create mode 100644 lib/src/ui/components/empty_state/empty_state.recipe.dart create mode 100644 lib/src/ui/components/empty_state/index.dart create mode 100644 lib/src/ui/components/error_state/error_state.dart create mode 100644 lib/src/ui/components/error_state/error_state.preview.dart create mode 100644 lib/src/ui/components/error_state/error_state.recipe.dart create mode 100644 lib/src/ui/components/error_state/index.dart create mode 100644 lib/src/ui/components/form_field/form_field.dart create mode 100644 lib/src/ui/components/form_field/form_field.preview.dart create mode 100644 lib/src/ui/components/form_field/form_field.recipe.dart create mode 100644 lib/src/ui/components/form_field/index.dart create mode 100644 lib/src/ui/components/navbar/index.dart create mode 100644 lib/src/ui/components/navbar/navbar.dart create mode 100644 lib/src/ui/components/navbar/navbar.preview.dart create mode 100644 lib/src/ui/components/navbar/navbar.recipe.dart create mode 100644 lib/src/ui/components/notification_dropdown/index.dart create mode 100644 lib/src/ui/components/notification_dropdown/notification_dropdown.dart create mode 100644 lib/src/ui/components/notification_dropdown/notification_dropdown.preview.dart create mode 100644 lib/src/ui/components/notification_dropdown/notification_dropdown.recipe.dart create mode 100644 lib/src/ui/components/page_header/index.dart create mode 100644 lib/src/ui/components/page_header/page_header.dart create mode 100644 lib/src/ui/components/page_header/page_header.preview.dart create mode 100644 lib/src/ui/components/page_header/page_header.recipe.dart create mode 100644 lib/src/ui/components/social_divider/index.dart create mode 100644 lib/src/ui/components/social_divider/social_divider.dart create mode 100644 lib/src/ui/components/social_divider/social_divider.preview.dart create mode 100644 lib/src/ui/components/social_divider/social_divider.recipe.dart create mode 100644 lib/src/ui/components/team_selector/index.dart create mode 100644 lib/src/ui/components/team_selector/team_selector.dart create mode 100644 lib/src/ui/components/team_selector/team_selector.preview.dart create mode 100644 lib/src/ui/components/team_selector/team_selector.recipe.dart create mode 100644 lib/src/ui/components/user_profile_dropdown/index.dart create mode 100644 lib/src/ui/components/user_profile_dropdown/user_profile_dropdown.dart create mode 100644 lib/src/ui/components/user_profile_dropdown/user_profile_dropdown.preview.dart create mode 100644 lib/src/ui/components/user_profile_dropdown/user_profile_dropdown.recipe.dart create mode 100644 test/ui/components/empty_state/empty_state_test.dart create mode 100644 test/ui/components/error_state/error_state_test.dart create mode 100644 test/ui/components/form_field/form_field_test.dart create mode 100644 test/ui/components/navbar/navbar_test.dart create mode 100644 test/ui/components/notification_dropdown/notification_dropdown_test.dart create mode 100644 test/ui/components/page_header/page_header_test.dart create mode 100644 test/ui/components/social_divider/social_divider_test.dart create mode 100644 test/ui/components/team_selector/team_selector_test.dart create mode 100644 test/ui/components/user_profile_dropdown/user_profile_dropdown_test.dart diff --git a/lib/src/ui/components/empty_state/empty_state.dart b/lib/src/ui/components/empty_state/empty_state.dart new file mode 100644 index 0000000..c8d8a7c --- /dev/null +++ b/lib/src/ui/components/empty_state/empty_state.dart @@ -0,0 +1,62 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'empty_state.recipe.dart'; + +/// A centered empty-state placeholder with optional icon, title, description, +/// and action slot. +/// +/// ### Example +/// ```dart +/// EmptyState( +/// icon: Icons.inbox_outlined, +/// title: 'No notifications', +/// description: 'You are all caught up!', +/// action: Button(onPressed: refresh, child: const Text('Refresh')), +/// ) +/// ``` +@immutable +class EmptyState extends StatelessWidget { + /// Optional icon rendered in the icon wrap slot. + final IconData? icon; + + /// Title text — the primary message. + final String title; + + /// Optional secondary description text. + final String? description; + + /// Optional action widget (e.g. a [Button]). + final Widget? action; + + /// Creates an [EmptyState]. + const EmptyState({ + super.key, + required this.title, + this.icon, + this.description, + this.action, + }); + + @override + Widget build(BuildContext context) { + return WDiv( + className: emptyStateRootClassName(), + children: [ + // 1. Optional icon wrap. + if (icon != null) + WDiv( + className: emptyStateIconWrapClassName(), + child: WIcon(icon!, className: emptyStateIconClassName()), + ), + // 2. Title slot. + WText(title, className: emptyStateTitleClassName()), + // 3. Optional description slot. + if (description != null) + WText(description!, className: emptyStateDescriptionClassName()), + // 4. Optional action slot. + if (action != null) action!, + ], + ); + } +} diff --git a/lib/src/ui/components/empty_state/empty_state.preview.dart b/lib/src/ui/components/empty_state/empty_state.preview.dart new file mode 100644 index 0000000..f244449 --- /dev/null +++ b/lib/src/ui/components/empty_state/empty_state.preview.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart' show Icons, ElevatedButton, Text; +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'empty_state.dart'; + +/// Static preview for [EmptyState]. +/// +/// Renders three variations: minimal (title-only), with all slots, and with +/// action. One preview class per file. +class EmptyStatePreview extends StatelessWidget { + const EmptyStatePreview({super.key}); + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex flex-col gap-8 p-6', + children: [ + const EmptyState(title: 'Nothing here yet'), + const EmptyState( + icon: Icons.inbox_outlined, + title: 'No notifications', + description: 'You are all caught up! Check back later.', + ), + EmptyState( + icon: Icons.folder_open_outlined, + title: 'No projects found', + description: 'Create your first project to get started.', + action: ElevatedButton( + onPressed: () {}, + child: const Text('Create project'), + ), + ), + ], + ); + } +} diff --git a/lib/src/ui/components/empty_state/empty_state.recipe.dart b/lib/src/ui/components/empty_state/empty_state.recipe.dart new file mode 100644 index 0000000..97b1a98 --- /dev/null +++ b/lib/src/ui/components/empty_state/empty_state.recipe.dart @@ -0,0 +1,20 @@ +// EmptyState has no variant axes — its layout is static. This file maintains +// the canonical 4-file atomic-component shape. + +/// Root container className for [EmptyState]. +String emptyStateRootClassName() => + 'flex flex-col items-center justify-center gap-4 py-12 px-6 text-center'; + +/// Icon wrapper className. +String emptyStateIconWrapClassName() => + 'w-16 h-16 rounded-full bg-surface-container flex items-center justify-center'; + +/// Icon className. +String emptyStateIconClassName() => 'text-4xl text-fg-muted'; + +/// Title className. +String emptyStateTitleClassName() => + 'text-base font-semibold text-fg'; + +/// Description className. +String emptyStateDescriptionClassName() => 'text-sm text-fg-muted max-w-xs'; diff --git a/lib/src/ui/components/empty_state/index.dart b/lib/src/ui/components/empty_state/index.dart new file mode 100644 index 0000000..a0ae269 --- /dev/null +++ b/lib/src/ui/components/empty_state/index.dart @@ -0,0 +1,5 @@ +// EmptyState component — folder-local barrel. + +export 'empty_state.dart' show EmptyState; +export 'empty_state.recipe.dart'; +export 'empty_state.preview.dart' show EmptyStatePreview; diff --git a/lib/src/ui/components/error_state/error_state.dart b/lib/src/ui/components/error_state/error_state.dart new file mode 100644 index 0000000..f045c99 --- /dev/null +++ b/lib/src/ui/components/error_state/error_state.dart @@ -0,0 +1,65 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'error_state.recipe.dart'; + +/// A centered error-state placeholder with destructive tone. +/// +/// Mirrors [EmptyState] slot structure (root/iconWrap/title/description/action) +/// but applies a destructive visual tone: red tinted icon background and red +/// title text. +/// +/// ### Example +/// ```dart +/// ErrorState( +/// icon: Icons.error_outline, +/// title: 'Something went wrong', +/// description: 'Failed to load your data.', +/// action: Button(onPressed: retry, child: const Text('Retry')), +/// ) +/// ``` +@immutable +class ErrorState extends StatelessWidget { + /// Optional icon rendered in the icon wrap slot. + final IconData? icon; + + /// Title text — the primary error message. + final String title; + + /// Optional secondary description text. + final String? description; + + /// Optional action widget (e.g. a [Button]). + final Widget? action; + + /// Creates an [ErrorState]. + const ErrorState({ + super.key, + required this.title, + this.icon, + this.description, + this.action, + }); + + @override + Widget build(BuildContext context) { + return WDiv( + className: errorStateRootClassName(), + children: [ + // 1. Optional icon wrap (destructive tinted). + if (icon != null) + WDiv( + className: errorStateIconWrapClassName(), + child: WIcon(icon!, className: errorStateIconClassName()), + ), + // 2. Title slot (destructive tone). + WText(title, className: errorStateTitleClassName()), + // 3. Optional description slot. + if (description != null) + WText(description!, className: errorStateDescriptionClassName()), + // 4. Optional action slot. + if (action != null) action!, + ], + ); + } +} diff --git a/lib/src/ui/components/error_state/error_state.preview.dart b/lib/src/ui/components/error_state/error_state.preview.dart new file mode 100644 index 0000000..1116cea --- /dev/null +++ b/lib/src/ui/components/error_state/error_state.preview.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart' show Icons, ElevatedButton, Text; +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'error_state.dart'; + +/// Static preview for [ErrorState]. +/// +/// Renders two variations: minimal (title-only) and full (all slots). One +/// preview class per file. +class ErrorStatePreview extends StatelessWidget { + const ErrorStatePreview({super.key}); + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex flex-col gap-8 p-6', + children: [ + const ErrorState(title: 'Something went wrong'), + ErrorState( + icon: Icons.error_outline, + title: 'Failed to load data', + description: 'Please check your connection and try again.', + action: ElevatedButton( + onPressed: () {}, + child: const Text('Retry'), + ), + ), + ], + ); + } +} diff --git a/lib/src/ui/components/error_state/error_state.recipe.dart b/lib/src/ui/components/error_state/error_state.recipe.dart new file mode 100644 index 0000000..2090951 --- /dev/null +++ b/lib/src/ui/components/error_state/error_state.recipe.dart @@ -0,0 +1,20 @@ +// ErrorState has no variant axes — its layout is static. This file maintains +// the canonical 4-file atomic-component shape. + +/// Root container className for [ErrorState]. +String errorStateRootClassName() => + 'flex flex-col items-center justify-center gap-4 py-12 px-6 text-center'; + +/// Icon wrapper className (destructive tinted background). +String errorStateIconWrapClassName() => + 'w-16 h-16 rounded-full bg-destructive-container flex items-center justify-center'; + +/// Icon className (destructive tone). +String errorStateIconClassName() => 'text-4xl text-red-600 dark:text-red-400'; + +/// Title className (destructive tone). +String errorStateTitleClassName() => + 'text-base font-semibold text-red-700 dark:text-red-400'; + +/// Description className. +String errorStateDescriptionClassName() => 'text-sm text-fg-muted max-w-xs'; diff --git a/lib/src/ui/components/error_state/index.dart b/lib/src/ui/components/error_state/index.dart new file mode 100644 index 0000000..9c7a9e7 --- /dev/null +++ b/lib/src/ui/components/error_state/index.dart @@ -0,0 +1,5 @@ +// ErrorState component — folder-local barrel. + +export 'error_state.dart' show ErrorState; +export 'error_state.recipe.dart'; +export 'error_state.preview.dart' show ErrorStatePreview; diff --git a/lib/src/ui/components/form_field/form_field.dart b/lib/src/ui/components/form_field/form_field.dart new file mode 100644 index 0000000..cd2b05b --- /dev/null +++ b/lib/src/ui/components/form_field/form_field.dart @@ -0,0 +1,75 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'form_field.recipe.dart'; + +/// A labeled form-field wrapper composing label / child / hint / error slots. +/// +/// Provides a consistent vertical layout for form inputs: an optional label +/// above the child, an optional hint below, and an optional inline error (in +/// destructive tone) that replaces the hint when present. +/// +/// ### Example +/// ```dart +/// MagicFormField( +/// label: 'Email', +/// hint: 'We will never share your email', +/// error: controller.errors['email'], +/// child: WFormInput(form: form, name: 'email'), +/// ) +/// ``` +@immutable +class MagicFormField extends StatelessWidget { + /// Optional label displayed above the child input. + final String? label; + + /// The child input or widget placed in the field body. + final Widget child; + + /// Optional helper text displayed below the child. + /// + /// Hidden when [error] is non-null. + final String? hint; + + /// Optional error message displayed in destructive tone below the child. + /// + /// When non-null, the hint is suppressed. + final String? error; + + /// Creates a [MagicFormField]. + const MagicFormField({ + super.key, + required this.child, + this.label, + this.hint, + this.error, + }); + + @override + Widget build(BuildContext context) { + return WDiv( + className: formFieldRootClassName(), + children: [ + // 1. Optional label slot. + if (label != null) + WText( + label!, + className: formFieldLabelClassName(), + ), + // 2. Child input slot. + child, + // 3. Error slot takes priority over hint. + if (error != null) + WText( + error!, + className: formFieldErrorClassName(), + ) + else if (hint != null) + WText( + hint!, + className: formFieldHintClassName(), + ), + ], + ); + } +} diff --git a/lib/src/ui/components/form_field/form_field.preview.dart b/lib/src/ui/components/form_field/form_field.preview.dart new file mode 100644 index 0000000..3f09442 --- /dev/null +++ b/lib/src/ui/components/form_field/form_field.preview.dart @@ -0,0 +1,50 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'form_field.dart'; + +/// Static preview for [MagicFormField]. +/// +/// Renders the four slot combinations: label-only, with hint, with error, +/// and without label. One preview class per file. +class MagicFormFieldPreview extends StatelessWidget { + const MagicFormFieldPreview({super.key}); + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex flex-col gap-6 p-6', + children: [ + MagicFormField( + label: 'Email address', + hint: 'We will never share your email.', + child: WDiv( + className: + 'h-10 rounded-lg bg-surface-container border border-color-border', + ), + ), + MagicFormField( + label: 'Password', + error: 'Password must be at least 8 characters.', + child: WDiv( + className: + 'h-10 rounded-lg bg-surface-container border border-color-border', + ), + ), + MagicFormField( + label: 'Name (no hint/error)', + child: WDiv( + className: + 'h-10 rounded-lg bg-surface-container border border-color-border', + ), + ), + MagicFormField( + child: WDiv( + className: + 'h-10 rounded-lg bg-surface-container border border-color-border', + ), + ), + ], + ); + } +} diff --git a/lib/src/ui/components/form_field/form_field.recipe.dart b/lib/src/ui/components/form_field/form_field.recipe.dart new file mode 100644 index 0000000..2887bf4 --- /dev/null +++ b/lib/src/ui/components/form_field/form_field.recipe.dart @@ -0,0 +1,23 @@ +import 'package:magic/magic.dart'; + +// No variant axes needed for FormField — the layout is static (root/label/hint/error +// slots). The recipe provides the root container className. + +/// Builds the root-container className for [MagicFormField]. +/// +/// Returns a plain string rather than a [WindRecipe] because FormField has no +/// variant axes; the recipe is kept as a named function for visual consistency +/// with the atomic-component shape. +String formFieldRootClassName() => 'flex flex-col gap-1 w-full'; + +/// Label text className. +String formFieldLabelClassName() => + 'text-sm font-medium text-fg'; + +/// Hint text className. +String formFieldHintClassName() => + 'text-xs text-fg-muted'; + +/// Error text className (destructive tone). +String formFieldErrorClassName() => + 'text-xs text-red-600 dark:text-red-400'; diff --git a/lib/src/ui/components/form_field/index.dart b/lib/src/ui/components/form_field/index.dart new file mode 100644 index 0000000..64c80f0 --- /dev/null +++ b/lib/src/ui/components/form_field/index.dart @@ -0,0 +1,9 @@ +// FormField component — folder-local barrel. +// +// The preview is intentionally NOT re-exported here — `previews:refresh` +// discovers `*.preview.dart` files directly, and the preview must stay out of +// the release barrel. + +export 'form_field.dart' show MagicFormField; +export 'form_field.recipe.dart'; +export 'form_field.preview.dart' show MagicFormFieldPreview; diff --git a/lib/src/ui/components/navbar/index.dart b/lib/src/ui/components/navbar/index.dart new file mode 100644 index 0000000..5b2148e --- /dev/null +++ b/lib/src/ui/components/navbar/index.dart @@ -0,0 +1,5 @@ +// Navbar component — folder-local barrel. + +export 'navbar.dart' show Navbar; +export 'navbar.recipe.dart'; +export 'navbar.preview.dart' show NavbarPreview; diff --git a/lib/src/ui/components/navbar/navbar.dart b/lib/src/ui/components/navbar/navbar.dart new file mode 100644 index 0000000..c9d84c1 --- /dev/null +++ b/lib/src/ui/components/navbar/navbar.dart @@ -0,0 +1,62 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'navbar.recipe.dart'; + +/// A responsive top navigation bar. +/// +/// Renders a horizontal bar with an optional [brand] slot on the left, +/// optional [children] (nav links, hidden on mobile via `hidden sm:flex`) in +/// the center, and an optional [trailing] slot on the right. +/// +/// ### Example +/// ```dart +/// Navbar( +/// brand: WText('Acme', className: 'text-lg font-bold text-primary'), +/// trailing: const UserProfileDropdown(), +/// children: [ +/// WText('Dashboard', className: 'text-sm font-medium text-fg'), +/// WText('Projects', className: 'text-sm font-medium text-fg'), +/// ], +/// ) +/// ``` +@immutable +class Navbar extends StatelessWidget { + /// Optional brand/logo widget. + final Widget? brand; + + /// Optional nav-link children (hidden on mobile). + final List children; + + /// Optional trailing widget (e.g. user profile dropdown). + final Widget? trailing; + + /// Creates a [Navbar]. + const Navbar({ + super.key, + required this.children, + this.brand, + this.trailing, + }); + + @override + Widget build(BuildContext context) { + return WDiv( + className: navbarRootClassName(), + children: [ + // 1. Brand slot. + if (brand != null) + WDiv(className: navbarBrandClassName(), child: brand!), + // 2. Children (nav links) — responsive. + if (children.isNotEmpty) + WDiv( + className: navbarChildrenClassName(), + children: children, + ), + // 3. Trailing slot. + if (trailing != null) + WDiv(className: navbarTrailingClassName(), child: trailing!), + ], + ); + } +} diff --git a/lib/src/ui/components/navbar/navbar.preview.dart b/lib/src/ui/components/navbar/navbar.preview.dart new file mode 100644 index 0000000..d3d2f48 --- /dev/null +++ b/lib/src/ui/components/navbar/navbar.preview.dart @@ -0,0 +1,37 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'navbar.dart'; + +/// Static preview for [Navbar]. +/// +/// Renders two variations: brand-only and full (brand + links + trailing). One +/// preview class per file. +class NavbarPreview extends StatelessWidget { + const NavbarPreview({super.key}); + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex flex-col gap-6', + children: [ + Navbar( + brand: const WText('Acme', className: 'text-lg font-bold text-fg'), + children: const [], + ), + Navbar( + brand: const WText('Acme', className: 'text-lg font-bold text-fg'), + trailing: const WDiv( + className: + 'w-8 h-8 rounded-full bg-surface-container flex items-center justify-center', + child: WText('U', className: 'text-sm font-bold text-fg'), + ), + children: [ + const WText('Dashboard', className: 'text-sm font-medium text-fg'), + const WText('Projects', className: 'text-sm font-medium text-fg'), + ], + ), + ], + ); + } +} diff --git a/lib/src/ui/components/navbar/navbar.recipe.dart b/lib/src/ui/components/navbar/navbar.recipe.dart new file mode 100644 index 0000000..a3cbd3f --- /dev/null +++ b/lib/src/ui/components/navbar/navbar.recipe.dart @@ -0,0 +1,18 @@ +// Navbar has no variant axes — its structure is responsive and handled by +// className tokens. This file maintains the canonical 4-file shape. + +/// Root container className for [Navbar]. +String navbarRootClassName() => + 'w-full flex flex-row items-center justify-between ' + 'bg-surface border-b border-color-border px-4 h-14'; + +/// Brand slot container className. +String navbarBrandClassName() => 'flex-shrink-0'; + +/// Children (nav links) container className. +String navbarChildrenClassName() => + 'hidden sm:flex flex-row items-center gap-2 flex-1 px-4'; + +/// Trailing slot container className. +String navbarTrailingClassName() => + 'flex flex-row items-center gap-2 flex-shrink-0'; diff --git a/lib/src/ui/components/notification_dropdown/index.dart b/lib/src/ui/components/notification_dropdown/index.dart new file mode 100644 index 0000000..ca3fc40 --- /dev/null +++ b/lib/src/ui/components/notification_dropdown/index.dart @@ -0,0 +1,4 @@ +// NotificationDropdown component — folder-local barrel. + +export 'notification_dropdown.dart' show NotificationDropdown; +export 'notification_dropdown.preview.dart' show NotificationDropdownPreview; diff --git a/lib/src/ui/components/notification_dropdown/notification_dropdown.dart b/lib/src/ui/components/notification_dropdown/notification_dropdown.dart new file mode 100644 index 0000000..421c730 --- /dev/null +++ b/lib/src/ui/components/notification_dropdown/notification_dropdown.dart @@ -0,0 +1,378 @@ +import 'package:flutter/material.dart' show Icons, CircularProgressIndicator; +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; +import 'package:magic_notifications/magic_notifications.dart'; + +/// Bell icon dropdown with real-time unread badge powered by a notification stream. +/// +/// Uses [WPopover] for overlay mechanics and [StreamBuilder] for live unread +/// counts. StreamBuilder unread badge and callbacks are preserved verbatim from +/// the pre-migration [MagicStarterNotificationDropdown]. +/// +/// ### Example +/// ```dart +/// NotificationDropdown( +/// notificationStream: Notify.notifications(), +/// onMarkAsRead: (id) => Notify.markAsRead(id), +/// onMarkAllAsRead: () => Notify.markAllAsRead(), +/// onNotificationTap: (n) => MagicRoute.to(n.actionUrl ?? '/'), +/// onViewAll: () => MagicRoute.to(MagicStarterConfig.notificationsRoute()), +/// ) +/// ``` +class NotificationDropdown extends StatelessWidget { + static const _typeIcons = { + 'monitor_down': Icons.error_outline, + 'monitor_up': Icons.check_circle_outline, + 'monitor_degraded': Icons.warning_amber_outlined, + }; + static const _defaultTypeIcon = Icons.notifications_none_outlined; + + /// Stream of notifications to display. + final Stream> notificationStream; + + /// Callback when a notification is marked as read. + final Future Function(String id)? onMarkAsRead; + + /// Callback when all notifications are marked as read. + final Future Function()? onMarkAllAsRead; + + /// Callback when a notification is tapped. + final void Function(DatabaseNotification notification)? onNotificationTap; + + /// Callback when the "View all" link is tapped. + final VoidCallback? onViewAll; + + /// Creates a [NotificationDropdown]. + const NotificationDropdown({ + super.key, + required this.notificationStream, + this.onMarkAsRead, + this.onMarkAllAsRead, + this.onNotificationTap, + this.onViewAll, + }); + + @override + Widget build(BuildContext context) { + return StreamBuilder>( + stream: notificationStream, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting && + !snapshot.hasData) { + return _buildLoadingDropdown(); + } + + if (snapshot.hasError) { + return _buildErrorDropdown(); + } + + final notifications = snapshot.data ?? []; + final unreadCount = notifications.where((n) => !n.isRead).length; + + return _buildDropdown(notifications, unreadCount); + }, + ); + } + + Widget _buildDropdown( + List notifications, + int unreadCount, + ) { + return WPopover( + alignment: PopoverAlignment.bottomRight, + className: ''' + w-80 + bg-white dark:bg-gray-800 + border border-gray-200 dark:border-gray-700 + rounded-xl shadow-xl + ''', + maxHeight: 400, + triggerBuilder: (context, isOpen, isHovering) => + _buildTrigger(context, isOpen, isHovering, unreadCount: unreadCount), + contentBuilder: (context, close) => + _buildContent(context, close, notifications, unreadCount), + ); + } + + Widget _buildTrigger( + BuildContext context, + bool isOpen, + bool isHovering, { + required int unreadCount, + }) { + return Stack( + clipBehavior: Clip.none, + children: [ + WDiv( + states: {if (isOpen) 'active', if (isHovering) 'hover'}, + className: ''' + p-2 rounded-lg duration-150 + bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 + active:bg-gray-100 dark:active:bg-gray-800 + ''', + child: WIcon( + Icons.notifications_outlined, + className: 'text-2xl text-gray-500 dark:text-gray-400', + ), + ), + if (unreadCount > 0) + Positioned( + top: 4, + right: 4, + child: WDiv( + className: ''' + min-w-[14px] h-[14px] px-1 rounded-full + bg-red-500 + flex items-center justify-center + ''', + child: WText( + unreadCount > 9 + ? trans('notifications.badge_overflow') + : unreadCount.toString(), + className: 'text-[9px] font-bold text-white', + ), + ), + ), + ], + ); + } + + Widget _buildContent( + BuildContext context, + VoidCallback close, + List notifications, + int unreadCount, + ) { + return WDiv( + className: 'flex flex-col items-stretch', + children: [ + _buildContentHeader(unreadCount), + WDiv( + className: 'flex-1 min-h-0', + child: _buildNotificationsList(context, close, notifications), + ), + if (onViewAll != null) _buildFooter(context, close), + ], + ); + } + + Widget _buildContentHeader(int unreadCount) { + return WDiv( + className: ''' + px-4 py-3 w-full + border-b border-gray-200 dark:border-gray-700 + flex flex-row items-center justify-between + ''', + children: [ + WText( + trans('notifications.title'), + className: 'text-base font-semibold text-gray-900 dark:text-white', + ), + if (unreadCount > 0 && onMarkAllAsRead != null) + WAnchor( + onTap: onMarkAllAsRead, + child: WText( + trans('notifications.mark_all_read'), + className: 'text-xs text-primary hover:text-green-600', + ), + ), + ], + ); + } + + Widget _buildNotificationsList( + BuildContext context, + VoidCallback close, + List notifications, + ) { + if (notifications.isEmpty) { + return WDiv( + className: + 'w-full py-12 flex flex-col items-center justify-center gap-3', + children: [ + WIcon( + Icons.notifications_off_outlined, + className: 'text-4xl text-gray-300 dark:text-gray-600', + ), + WText( + trans('notifications.empty'), + className: 'text-sm text-gray-500 dark:text-gray-400', + ), + ], + ); + } + + return WDiv( + className: 'overflow-y-auto flex flex-col', + children: notifications + .map((n) => _buildNotificationItem(context, n, close)) + .toList(), + ); + } + + Widget _buildNotificationItem( + BuildContext context, + DatabaseNotification notification, + VoidCallback close, + ) { + final IconData icon = _getIconForType(notification.type); + final String iconColor = _getColorForType(notification.type); + + return WAnchor( + onTap: () async { + await onMarkAsRead?.call(notification.id); + onNotificationTap?.call(notification); + close(); + }, + child: WDiv( + className: ''' + flex flex-row items-start gap-3 px-4 py-3 w-full + border-b border-gray-100 dark:border-gray-700 + hover:bg-gray-50 dark:hover:bg-gray-700 + ${notification.isRead ? '' : 'bg-primary/5 dark:bg-primary/10'} + ''', + children: [ + WDiv( + className: ''' + w-8 h-8 rounded-full + bg-gray-100 dark:bg-gray-700 + flex items-center justify-center + ''', + child: WIcon(icon, className: 'text-lg $iconColor'), + ), + WDiv( + className: 'flex-1 flex flex-col min-w-0', + children: [ + WText( + notification.title, + className: ''' + text-sm text-gray-900 dark:text-white truncate + ${notification.isRead ? '' : 'font-semibold'} + ''', + ), + const WSpacer(className: 'h-0.5'), + WText( + notification.body, + className: 'text-xs text-gray-500 dark:text-gray-400', + ), + const WSpacer(className: 'h-0.5'), + WText( + _formatTime(notification.createdAt), + className: 'text-xs text-gray-400 dark:text-gray-500', + ), + ], + ), + if (!notification.isRead) + WDiv( + className: 'w-2 h-2 rounded-full bg-primary mt-2', + child: const SizedBox.shrink(), + ), + ], + ), + ); + } + + Widget _buildFooter(BuildContext context, VoidCallback close) { + return WAnchor( + onTap: () { + onViewAll?.call(); + close(); + }, + child: WDiv( + className: ''' + px-4 py-3 w-full + border-t border-gray-200 dark:border-gray-700 + hover:bg-gray-50 dark:hover:bg-gray-700 + flex items-center justify-center + ''', + child: WText( + trans('notifications.view_all'), + className: 'text-sm font-medium text-primary', + ), + ), + ); + } + + Widget _buildLoadingDropdown() { + return WPopover( + alignment: PopoverAlignment.bottomRight, + className: ''' + w-80 + bg-white dark:bg-gray-800 + border border-gray-200 dark:border-gray-700 + rounded-xl shadow-xl + ''', + maxHeight: 400, + triggerBuilder: (context, isOpen, isHovering) => + _buildTrigger(context, isOpen, isHovering, unreadCount: 0), + contentBuilder: (context, close) => WDiv( + className: 'py-12 flex items-center justify-center', + child: const CircularProgressIndicator(), + ), + ); + } + + Widget _buildErrorDropdown() { + return WPopover( + alignment: PopoverAlignment.bottomRight, + className: ''' + w-80 + bg-white dark:bg-gray-800 + border border-gray-200 dark:border-gray-700 + rounded-xl shadow-xl + ''', + maxHeight: 400, + triggerBuilder: (context, isOpen, isHovering) => + _buildTrigger(context, isOpen, isHovering, unreadCount: 0), + contentBuilder: (context, close) => WDiv( + className: + 'w-full py-12 flex flex-col items-center justify-center gap-3', + children: [ + WIcon(Icons.error_outline, className: 'text-4xl text-red-500'), + WText( + trans('notifications.load_failed'), + className: 'text-sm text-gray-600 dark:text-gray-400', + ), + ], + ), + ); + } + + IconData _getIconForType(String type) { + return _typeIcons[type] ?? _defaultTypeIcon; + } + + String _getColorForType(String type) { + switch (type) { + case 'monitor_down': + return 'text-red-500'; + case 'monitor_up': + return 'text-green-500'; + case 'monitor_degraded': + return 'text-yellow-500'; + default: + return 'text-blue-500'; + } + } + + String _formatTime(DateTime dateTime) { + final now = DateTime.now(); + final difference = now.difference(dateTime); + + if (difference.inMinutes < 1) { + return trans('time.just_now'); + } else if (difference.inHours < 1) { + return trans('time.minutes_ago', {'minutes': difference.inMinutes}); + } else if (difference.inDays < 1) { + return trans('time.hours_ago', {'hours': difference.inHours}); + } else if (difference.inDays < 7) { + return trans('time.days_ago', {'days': difference.inDays}); + } else { + return trans('time.date_format', { + 'day': dateTime.day, + 'month': dateTime.month, + 'year': dateTime.year, + }); + } + } +} diff --git a/lib/src/ui/components/notification_dropdown/notification_dropdown.preview.dart b/lib/src/ui/components/notification_dropdown/notification_dropdown.preview.dart new file mode 100644 index 0000000..49d2eca --- /dev/null +++ b/lib/src/ui/components/notification_dropdown/notification_dropdown.preview.dart @@ -0,0 +1,50 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; +import 'package:magic_notifications/magic_notifications.dart'; + +import 'notification_dropdown.dart'; + +/// Static preview for [NotificationDropdown]. +/// +/// Renders the dropdown with a mock notification stream (empty state). One +/// preview class per file. +class NotificationDropdownPreview extends StatefulWidget { + const NotificationDropdownPreview({super.key}); + + @override + State createState() => + _NotificationDropdownPreviewState(); +} + +class _NotificationDropdownPreviewState + extends State { + final StreamController> _controller = + StreamController>.broadcast(); + + @override + void initState() { + super.initState(); + // Emit empty list so the preview shows the empty state immediately. + _controller.add([]); + } + + @override + void dispose() { + _controller.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex flex-row items-start gap-6 p-6', + children: [ + NotificationDropdown( + notificationStream: _controller.stream, + ), + ], + ); + } +} diff --git a/lib/src/ui/components/notification_dropdown/notification_dropdown.recipe.dart b/lib/src/ui/components/notification_dropdown/notification_dropdown.recipe.dart new file mode 100644 index 0000000..e4ac3bf --- /dev/null +++ b/lib/src/ui/components/notification_dropdown/notification_dropdown.recipe.dart @@ -0,0 +1,2 @@ +// NotificationDropdown has no variant axes. This file maintains the canonical +// 4-file atomic-component shape. diff --git a/lib/src/ui/components/page_header/index.dart b/lib/src/ui/components/page_header/index.dart new file mode 100644 index 0000000..9081384 --- /dev/null +++ b/lib/src/ui/components/page_header/index.dart @@ -0,0 +1,4 @@ +// PageHeader component — folder-local barrel. + +export 'page_header.dart' show PageHeader; +export 'page_header.preview.dart' show PageHeaderPreview; diff --git a/lib/src/ui/components/page_header/page_header.dart b/lib/src/ui/components/page_header/page_header.dart new file mode 100644 index 0000000..14ec788 --- /dev/null +++ b/lib/src/ui/components/page_header/page_header.dart @@ -0,0 +1,97 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import '../../../facades/magic_starter.dart'; + +/// Reusable page header for Magic Starter views. +/// +/// Full-width header with `border-b` separator, responsive `flex-col sm:flex-row` +/// layout, optional [leading], optional [actions], optional [titleSuffix], and +/// an [inlineActions] flag to force a single-row layout. +/// +/// ### Example +/// ```dart +/// PageHeader( +/// title: 'Settings', +/// subtitle: 'Manage your account', +/// actions: [Button(onPressed: save, child: const Text('Save'))], +/// ) +/// ``` +@immutable +class PageHeader extends StatelessWidget { + /// Required title text. + final String title; + + /// Optional subtitle text displayed below the title. + final String? subtitle; + + /// Optional leading widget (e.g. back button). + final Widget? leading; + + /// Optional trailing action widgets. + final List? actions; + + /// Optional widget rendered inline after the title column. + final Widget? titleSuffix; + + /// When `true`, the outer container uses `flex-row` instead of the default + /// responsive `flex-col sm:flex-row` stacked layout. + final bool inlineActions; + + /// Creates a [PageHeader]. + const PageHeader({ + super.key, + required this.title, + this.subtitle, + this.leading, + this.actions, + this.titleSuffix, + this.inlineActions = false, + }); + + @override + Widget build(BuildContext context) { + final leading = this.leading; + return WDiv( + className: inlineActions + ? MagicStarter.pageHeaderTheme.containerInlineClassName + : MagicStarter.pageHeaderTheme.containerClassName, + children: [ + // 1. Title row: optional leading + title column + optional titleSuffix. + WDiv( + className: inlineActions + ? 'flex flex-row items-center gap-3 flex-1 min-w-0' + : 'flex flex-row items-center gap-3 sm:flex-1 min-w-0', + children: [ + if (leading != null) leading, + WDiv( + className: 'flex flex-col gap-1 flex-1 min-w-0', + children: [ + WText( + title, + className: MagicStarter.pageHeaderTheme.titleClassName, + ), + if (subtitle != null) + WText( + subtitle!, + className: MagicStarter.pageHeaderTheme.subtitleClassName, + ), + ], + ), + if (titleSuffix != null) + WDiv( + className: 'flex-shrink-0', + child: titleSuffix!, + ), + ], + ), + // 2. Optional actions row. + if (actions != null && actions!.isNotEmpty) + WDiv( + className: MagicStarter.pageHeaderTheme.actionContainerClassName, + children: actions!, + ), + ], + ); + } +} diff --git a/lib/src/ui/components/page_header/page_header.preview.dart b/lib/src/ui/components/page_header/page_header.preview.dart new file mode 100644 index 0000000..7de6836 --- /dev/null +++ b/lib/src/ui/components/page_header/page_header.preview.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart' show Icons, ElevatedButton, Text; +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'page_header.dart'; + +/// Static preview for [PageHeader]. +/// +/// Renders four layout variants: title-only, with subtitle, with actions, and +/// with inlineActions. One preview class per file. +class PageHeaderPreview extends StatelessWidget { + const PageHeaderPreview({super.key}); + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex flex-col gap-6 p-6', + children: [ + const PageHeader(title: 'Dashboard'), + const PageHeader( + title: 'Projects', + subtitle: 'Manage your projects', + ), + PageHeader( + title: 'Settings', + actions: [ + ElevatedButton( + onPressed: () {}, + child: const Text('Save'), + ), + ], + ), + PageHeader( + title: 'Create', + inlineActions: true, + leading: const Icon(Icons.arrow_back), + actions: [ + ElevatedButton( + onPressed: () {}, + child: const Text('Create'), + ), + ], + ), + ], + ); + } +} diff --git a/lib/src/ui/components/page_header/page_header.recipe.dart b/lib/src/ui/components/page_header/page_header.recipe.dart new file mode 100644 index 0000000..18b659a --- /dev/null +++ b/lib/src/ui/components/page_header/page_header.recipe.dart @@ -0,0 +1,4 @@ +// PageHeader has no variant axes — its layout is driven entirely by the +// MagicStarterPageHeaderTheme tokens and the inlineActions bool. No WindRecipe +// is needed here; this file is a placeholder to keep the canonical 4-file +// atomic-component shape consistent. diff --git a/lib/src/ui/components/social_divider/index.dart b/lib/src/ui/components/social_divider/index.dart new file mode 100644 index 0000000..7364234 --- /dev/null +++ b/lib/src/ui/components/social_divider/index.dart @@ -0,0 +1,4 @@ +// SocialDivider component — folder-local barrel. + +export 'social_divider.dart' show SocialDivider; +export 'social_divider.preview.dart' show SocialDividerPreview; diff --git a/lib/src/ui/components/social_divider/social_divider.dart b/lib/src/ui/components/social_divider/social_divider.dart new file mode 100644 index 0000000..5285fcf --- /dev/null +++ b/lib/src/ui/components/social_divider/social_divider.dart @@ -0,0 +1,44 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import '../../../facades/magic_starter.dart'; + +/// Visual divider with "Or continue with" text. +/// +/// Appears between the primary auth form and social login buttons. +/// Follows the Magic Starter design system with dark-mode support. +/// Reads className tokens from [MagicStarterAuthTheme] so the consumer can +/// override divider styling via `MagicStarter.useAuthTheme()`. +/// +/// ### Example +/// ```dart +/// const SocialDivider() +/// ``` +@immutable +class SocialDivider extends StatelessWidget { + const SocialDivider({super.key}); + + @override + Widget build(BuildContext context) { + return WDiv( + className: MagicStarter.authTheme.socialDividerClassName, + children: [ + WDiv( + className: MagicStarter.authTheme.socialDividerLineClassName, + child: const SizedBox.shrink(), + ), + WDiv( + className: 'px-4', + child: WText( + trans('auth.or_continue_with'), + className: MagicStarter.authTheme.socialDividerTextClassName, + ), + ), + WDiv( + className: MagicStarter.authTheme.socialDividerLineClassName, + child: const SizedBox.shrink(), + ), + ], + ); + } +} diff --git a/lib/src/ui/components/social_divider/social_divider.preview.dart b/lib/src/ui/components/social_divider/social_divider.preview.dart new file mode 100644 index 0000000..21a3d7f --- /dev/null +++ b/lib/src/ui/components/social_divider/social_divider.preview.dart @@ -0,0 +1,21 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'social_divider.dart'; + +/// Static preview for [SocialDivider]. +/// +/// Renders the divider in light/dark. One preview class per file. +class SocialDividerPreview extends StatelessWidget { + const SocialDividerPreview({super.key}); + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex flex-col gap-6 p-6', + children: const [ + SocialDivider(), + ], + ); + } +} diff --git a/lib/src/ui/components/social_divider/social_divider.recipe.dart b/lib/src/ui/components/social_divider/social_divider.recipe.dart new file mode 100644 index 0000000..5dc15a4 --- /dev/null +++ b/lib/src/ui/components/social_divider/social_divider.recipe.dart @@ -0,0 +1,2 @@ +// SocialDivider has no variant axes. This file maintains the canonical 4-file +// atomic-component shape. diff --git a/lib/src/ui/components/team_selector/index.dart b/lib/src/ui/components/team_selector/index.dart new file mode 100644 index 0000000..7b733ae --- /dev/null +++ b/lib/src/ui/components/team_selector/index.dart @@ -0,0 +1,4 @@ +// TeamSelector component — folder-local barrel. + +export 'team_selector.dart' show TeamSelector; +export 'team_selector.preview.dart' show TeamSelectorPreview; diff --git a/lib/src/ui/components/team_selector/team_selector.dart b/lib/src/ui/components/team_selector/team_selector.dart new file mode 100644 index 0000000..a737dd3 --- /dev/null +++ b/lib/src/ui/components/team_selector/team_selector.dart @@ -0,0 +1,194 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart' show Icons; +import 'package:magic/magic.dart'; + +import '../../../configuration/magic_starter_config.dart'; +import '../../../facades/magic_starter.dart'; +import '../../../models/magic_starter_team.dart'; +import '../../../magic_starter_manager.dart'; + +/// Team selector widget. +/// +/// Displays the current team initial and allows switching between teams via a +/// [WPopover] dropdown. Uses the team resolver registered via +/// `MagicStarter.useTeamResolver()`. Preserves all behavior from the +/// pre-migration [MagicStarterTeamSelector]: teamResolver callbacks, team list +/// rendering, settings/create links. +/// +/// ### Example +/// ```dart +/// const TeamSelector() +/// // or compact (icon-only) mode: +/// const TeamSelector(compact: true) +/// ``` +class TeamSelector extends StatelessWidget { + static const _iconCollapse = Icons.unfold_less; + static const _iconExpand = Icons.unfold_more; + + /// When `true`, hides the team name label and expand icon (icon-only mode). + final bool compact; + + const TeamSelector({super.key, this.compact = false}); + + @override + Widget build(BuildContext context) { + final resolver = MagicStarter.teamResolver; + if (resolver == null) return const SizedBox.shrink(); + + final currentTeam = resolver.currentTeam(); + final allTeams = resolver.allTeams(); + + if (allTeams.isEmpty) return const SizedBox.shrink(); + + return WPopover( + alignment: PopoverAlignment.bottomCenter, + className: ''' + w-58 + bg-white dark:bg-gray-800 + border border-gray-200 dark:border-gray-700 + rounded-xl shadow-xl + ''', + triggerBuilder: (context, isOpen, isHovering) => WDiv( + className: + 'flex flex-row items-center gap-2 mx-3 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800', + children: [ + WDiv( + className: + 'w-8 h-8 rounded-lg bg-primary/10 dark:bg-primary/10 flex items-center justify-center', + child: WText( + (currentTeam?.name ?? '?')[0].toUpperCase(), + className: 'text-sm font-bold text-primary', + ), + ), + if (!compact) ...[ + Expanded( + child: WText( + currentTeam?.name ?? trans('teams.select_team'), + className: + 'text-sm font-medium text-gray-900 dark:text-white truncate', + ), + ), + WIcon( + isOpen ? _iconCollapse : _iconExpand, + className: 'text-gray-400 text-lg', + ), + ], + ], + ), + contentBuilder: (context, close) => + _buildTeamList(allTeams, currentTeam, resolver, close), + ); + } + + Widget _buildTeamList( + List teams, + MagicStarterTeam? currentTeam, + MagicStarterTeamResolverConfig resolver, + VoidCallback close, + ) { + return WDiv( + className: 'py-2', + children: [ + WDiv( + className: 'px-3 pb-1', + child: WText( + trans('teams.team').toUpperCase(), + className: + 'text-xs font-bold tracking-wide text-gray-400 dark:text-gray-500', + ), + ), + ...teams.map((team) { + final isActive = team.id == currentTeam?.id; + return WAnchor( + onTap: () { + close(); + if (!isActive) resolver.onSwitch(team.id); + }, + child: WDiv( + states: {if (isActive) 'active'}, + className: + 'w-full px-3 py-2.5 flex flex-row items-center gap-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 active:bg-primary/5', + children: [ + WDiv( + className: + 'w-8 h-8 rounded-lg ${isActive ? "bg-primary/15" : "bg-gray-100 dark:bg-gray-700"} flex items-center justify-center', + child: WText( + (team.name ?? '?')[0].toUpperCase(), + className: + 'text-xs font-bold ${isActive ? "text-primary" : "text-gray-500 dark:text-gray-400"}', + ), + ), + Expanded( + child: WText( + team.name ?? '', + className: + 'text-sm ${isActive ? "font-semibold text-gray-900 dark:text-white" : "font-medium text-gray-700 dark:text-gray-300"} truncate', + ), + ), + if (isActive) + WIcon( + Icons.check_circle, + className: 'text-primary text-lg', + ), + ], + ), + ); + }), + WDiv( + className: + 'my-1.5 mx-3 border-t border-gray-100 dark:border-gray-700', + ), + WAnchor( + onTap: () { + close(); + MagicRoute.to(MagicStarterConfig.teamSettingsRoute()); + }, + child: WDiv( + className: + 'w-full px-3 py-2.5 flex flex-row items-center gap-3 hover:bg-gray-50 dark:hover:bg-gray-700/50', + children: [ + WDiv( + className: + 'w-8 h-8 rounded-lg bg-gray-100 dark:bg-gray-700 flex items-center justify-center', + child: const WIcon( + Icons.settings_outlined, + className: 'text-base text-gray-500 dark:text-gray-400', + ), + ), + WText( + trans('teams.settings'), + className: + 'text-sm font-medium text-gray-700 dark:text-gray-300', + ), + ], + ), + ), + WAnchor( + onTap: () { + close(); + MagicRoute.to(MagicStarterConfig.teamCreateRoute()); + }, + child: WDiv( + className: + 'w-full px-3 py-2.5 flex flex-row items-center gap-3 hover:bg-gray-50 dark:hover:bg-gray-700/50', + children: [ + WDiv( + className: + 'w-8 h-8 rounded-lg bg-primary/10 dark:bg-primary/10 flex items-center justify-center', + child: const WIcon( + Icons.add, + className: 'text-base text-primary', + ), + ), + WText( + trans('teams.create_team'), + className: + 'text-sm font-medium text-gray-700 dark:text-gray-300', + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/src/ui/components/team_selector/team_selector.preview.dart b/lib/src/ui/components/team_selector/team_selector.preview.dart new file mode 100644 index 0000000..9281657 --- /dev/null +++ b/lib/src/ui/components/team_selector/team_selector.preview.dart @@ -0,0 +1,25 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'team_selector.dart'; + +/// Static preview for [TeamSelector]. +/// +/// Renders the selector trigger in default state (no resolver bound, so +/// SizedBox.shrink is shown — to see the full widget, a resolver must be +/// registered). One preview class per file. +class TeamSelectorPreview extends StatelessWidget { + const TeamSelectorPreview({super.key}); + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex flex-row items-start gap-4 p-6', + children: const [ + // No resolver bound — renders an empty placeholder in preview. + TeamSelector(), + TeamSelector(compact: true), + ], + ); + } +} diff --git a/lib/src/ui/components/team_selector/team_selector.recipe.dart b/lib/src/ui/components/team_selector/team_selector.recipe.dart new file mode 100644 index 0000000..aa2b7da --- /dev/null +++ b/lib/src/ui/components/team_selector/team_selector.recipe.dart @@ -0,0 +1,2 @@ +// TeamSelector has no variant axes. This file maintains the canonical 4-file +// atomic-component shape. diff --git a/lib/src/ui/components/user_profile_dropdown/index.dart b/lib/src/ui/components/user_profile_dropdown/index.dart new file mode 100644 index 0000000..0e2a1e7 --- /dev/null +++ b/lib/src/ui/components/user_profile_dropdown/index.dart @@ -0,0 +1,4 @@ +// UserProfileDropdown component — folder-local barrel. + +export 'user_profile_dropdown.dart' show UserProfileDropdown; +export 'user_profile_dropdown.preview.dart' show UserProfileDropdownPreview; diff --git a/lib/src/ui/components/user_profile_dropdown/user_profile_dropdown.dart b/lib/src/ui/components/user_profile_dropdown/user_profile_dropdown.dart new file mode 100644 index 0000000..8b82cd5 --- /dev/null +++ b/lib/src/ui/components/user_profile_dropdown/user_profile_dropdown.dart @@ -0,0 +1,244 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart' show Icons; +import 'package:magic/magic.dart'; + +import '../../../configuration/magic_starter_config.dart'; +import '../../../facades/magic_starter.dart'; +import '../../../http/controllers/magic_starter_auth_controller.dart'; + +/// A dropdown widget for the user profile. +/// +/// Renders the user's avatar, name, and email, along with profile links and +/// a logout action. Preserves all behavior from the pre-migration +/// [MagicStarterUserProfileDropdown]: StreamBuilder unread badge, teamResolver +/// callbacks, avatar theme tokens, logout callback, theme toggle. +/// +/// ### Example +/// ```dart +/// const UserProfileDropdown() +/// // or with custom alignment: +/// const UserProfileDropdown(alignment: PopoverAlignment.topRight) +/// ``` +class UserProfileDropdown extends StatelessWidget { + /// The popover alignment direction. + final PopoverAlignment alignment; + + /// Custom builder for the trigger widget. + /// + /// When null, renders the default circular avatar with user initial. + final Widget Function(BuildContext context, bool isOpen, bool isHovering)? + triggerBuilder; + + const UserProfileDropdown({ + super.key, + this.alignment = PopoverAlignment.bottomRight, + this.triggerBuilder, + }); + + static const _iconLightMode = Icons.light_mode_outlined; + static const _iconDarkMode = Icons.dark_mode_outlined; + + @override + Widget build(BuildContext context) { + return WPopover( + alignment: alignment, + className: ''' + w-72 + bg-white dark:bg-gray-800 + rounded-2xl + shadow-lg + mt-2 + border border-gray-100 dark:border-gray-700 + ''', + triggerBuilder: (context, isOpen, isHovering) => + triggerBuilder?.call(context, isOpen, isHovering) ?? + _buildAvatarTrigger(context, isOpen, isHovering), + contentBuilder: (context, close) => _buildMenu(context, close), + ); + } + + Widget _buildAvatarTrigger( + BuildContext context, + bool isOpen, + bool isHovering, + ) { + final userName = Auth.user()?.get('name') ?? trans('common.user'); + final initial = userName.isNotEmpty ? userName[0].toUpperCase() : 'U'; + final navTheme = MagicStarter.navigationTheme; + + return WDiv( + states: { + if (isOpen) 'active', + if (isHovering) 'hover', + }, + className: ''' + w-8 h-8 + rounded-full + ${navTheme.dropdownAvatarClassName} + flex items-center justify-center + cursor-pointer + shadow-sm + transition-all duration-200 + hover:scale-105 + active:scale-95 + ''', + child: WText( + initial, + className: 'text-sm font-bold text-white', + ), + ); + } + + Widget _buildMenu(BuildContext context, VoidCallback close) { + final userName = Auth.user()?.get('name') ?? trans('common.user'); + final userEmail = Auth.user()?.get('email') ?? ''; + final profileMenuItems = + MagicStarter.navigationConfig?.profileMenuItems ?? []; + + return WDiv( + className: 'flex flex-col py-2 w-full', + children: [ + WDiv( + className: + 'w-full flex flex-col px-4 py-2 mb-1 border-b border-gray-100 dark:border-gray-700', + children: [ + WText( + trans('auth.signed_in_as').toUpperCase(), + className: 'text-[10px] font-bold tracking-widest text-gray-400', + ), + const WSpacer(className: 'h-1'), + WText( + userName, + className: + 'text-sm font-semibold text-gray-900 dark:text-white truncate', + ), + if (userEmail.isNotEmpty) + WText( + userEmail, + className: 'text-xs text-gray-500 dark:text-gray-400 truncate', + ), + ], + ), + const WSpacer(className: 'h-1'), + WDiv( + className: 'flex-1 overflow-y-auto', + children: [ + _buildMenuItem( + icon: Icons.person_outline, + label: trans('auth.profile'), + onTap: () { + close(); + MagicRoute.to(MagicStarterConfig.profileRoute()); + }, + ), + for (final item in profileMenuItems) + _buildMenuItem( + icon: item.icon, + label: trans(item.labelKey), + onTap: () { + close(); + MagicRoute.to(item.path); + }, + ), + if (MagicStarterConfig.hasNotificationFeatures()) + _buildMenuItem( + icon: Icons.notifications_outlined, + label: trans('notifications.settings'), + onTap: () { + close(); + MagicRoute.to( + MagicStarterConfig.notificationPreferencesRoute()); + }, + ), + _buildMenuItem( + icon: context.windIsDark ? _iconLightMode : _iconDarkMode, + label: trans('common.toggle_theme'), + onTap: () => context.windTheme.toggleTheme(), + ), + ], + ), + WDiv( + className: + 'h-[1px] bg-gray-200 dark:bg-gray-700 my-1 mx-2 w-full'), + _buildMenuItem( + icon: Icons.logout, + label: trans('auth.logout'), + isDanger: true, + onTap: () { + close(); + _handleLogout(); + }, + ), + ], + ); + } + + Widget _buildMenuItem({ + required IconData icon, + required String label, + required VoidCallback onTap, + bool isDanger = false, + }) { + return WAnchor( + onTap: onTap, + child: WDiv( + states: {if (isDanger) 'danger'}, + className: ''' + mx-2 px-3 py-2.5 w-full + rounded-lg + hover:bg-gray-50 dark:hover:bg-gray-700/50 + active:bg-gray-100 dark:active:bg-gray-700 + flex items-center gap-3 + cursor-pointer + transition-colors duration-150 + ''', + children: [ + WDiv( + states: {if (isDanger) 'danger'}, + className: ''' + w-8 h-8 + rounded-lg + bg-gray-100 dark:bg-gray-700 + hover:bg-gray-200 dark:hover:bg-gray-600 + danger:bg-red-50 dark:danger:bg-red-900/20 + flex items-center justify-center + transition-colors duration-150 + ''', + child: WIcon( + icon, + states: {if (isDanger) 'danger'}, + className: ''' + text-lg + text-gray-600 dark:text-gray-400 + danger:text-red-600 dark:danger:text-red-500 + ''', + ), + ), + WDiv( + className: 'flex-1 min-w-0', + child: WText( + label, + states: {if (isDanger) 'danger'}, + className: ''' + text-sm font-medium truncate + text-gray-900 dark:text-gray-100 + danger:text-red-600 dark:danger:text-red-500 + ''', + ), + ), + ], + ), + ); + } + + Future _handleLogout() async { + final customLogout = MagicStarter.manager.onLogout; + + if (customLogout != null) { + await customLogout(); + return; + } + + await MagicStarterAuthController.instance.logout(); + } +} diff --git a/lib/src/ui/components/user_profile_dropdown/user_profile_dropdown.preview.dart b/lib/src/ui/components/user_profile_dropdown/user_profile_dropdown.preview.dart new file mode 100644 index 0000000..99b6ef3 --- /dev/null +++ b/lib/src/ui/components/user_profile_dropdown/user_profile_dropdown.preview.dart @@ -0,0 +1,23 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'user_profile_dropdown.dart'; + +/// Static preview for [UserProfileDropdown]. +/// +/// Renders the dropdown trigger in its default and topRight alignment. One +/// preview class per file. +class UserProfileDropdownPreview extends StatelessWidget { + const UserProfileDropdownPreview({super.key}); + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex flex-row items-start gap-6 p-6', + children: const [ + UserProfileDropdown(), + UserProfileDropdown(alignment: PopoverAlignment.topRight), + ], + ); + } +} diff --git a/lib/src/ui/components/user_profile_dropdown/user_profile_dropdown.recipe.dart b/lib/src/ui/components/user_profile_dropdown/user_profile_dropdown.recipe.dart new file mode 100644 index 0000000..e1a0806 --- /dev/null +++ b/lib/src/ui/components/user_profile_dropdown/user_profile_dropdown.recipe.dart @@ -0,0 +1,2 @@ +// UserProfileDropdown has no variant axes. This file maintains the canonical +// 4-file atomic-component shape. diff --git a/lib/src/ui/widgets/magic_starter_notification_dropdown.dart b/lib/src/ui/widgets/magic_starter_notification_dropdown.dart index 5579de4..d000850 100644 --- a/lib/src/ui/widgets/magic_starter_notification_dropdown.dart +++ b/lib/src/ui/widgets/magic_starter_notification_dropdown.dart @@ -1,384 +1,20 @@ -import 'package:flutter/material.dart'; -import 'package:magic/magic.dart'; -import 'package:magic_notifications/magic_notifications.dart'; +// Thin alias — MagicStarterNotificationDropdown is preserved for backward +// compatibility. The implementation now lives in +// components/notification_dropdown/notification_dropdown.dart. -/// Bell icon dropdown with real-time unread badge powered by a notification stream. -/// -/// Uses [WPopover] for overlay mechanics and [StreamBuilder] for live unread counts. -/// This is a standalone widget — NOT registered in ViewRegistry (it's a component, not a page). -/// -/// ### Example Usage -/// ```dart -/// MagicStarterNotificationDropdown( -/// notificationStream: Notify.notifications(), -/// onMarkAsRead: (id) => Notify.markAsRead(id), -/// onMarkAllAsRead: () => Notify.markAllAsRead(), -/// onNotificationTap: (notification) => MagicRoute.to(notification.actionUrl ?? '/'), -/// onViewAll: () => MagicRoute.to(MagicStarterConfig.notificationsRoute()), -/// ) -/// ``` -class MagicStarterNotificationDropdown extends StatelessWidget { - static const _typeIcons = { - 'monitor_down': Icons.error_outline, - 'monitor_up': Icons.check_circle_outline, - 'monitor_degraded': Icons.warning_amber_outlined, - }; - static const _defaultTypeIcon = Icons.notifications_none_outlined; +import '../components/notification_dropdown/notification_dropdown.dart'; - /// Stream of notifications to display. - final Stream> notificationStream; +export '../components/notification_dropdown/notification_dropdown.dart' + show NotificationDropdown; - /// Callback when a notification is marked as read. - final Future Function(String id)? onMarkAsRead; - - /// Callback when all notifications are marked as read. - final Future Function()? onMarkAllAsRead; - - /// Callback when a notification is tapped. - final void Function(DatabaseNotification notification)? onNotificationTap; - - /// Callback when the "View all" link is tapped. - final VoidCallback? onViewAll; - - /// Creates a notification dropdown. +/// Backward-compatible alias for [NotificationDropdown]. +class MagicStarterNotificationDropdown extends NotificationDropdown { const MagicStarterNotificationDropdown({ super.key, - required this.notificationStream, - this.onMarkAsRead, - this.onMarkAllAsRead, - this.onNotificationTap, - this.onViewAll, + required super.notificationStream, + super.onMarkAsRead, + super.onMarkAllAsRead, + super.onNotificationTap, + super.onViewAll, }); - - @override - Widget build(BuildContext context) { - return StreamBuilder>( - stream: notificationStream, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting && - !snapshot.hasData) { - return _buildLoadingDropdown(); - } - - if (snapshot.hasError) { - return _buildErrorDropdown(); - } - - final notifications = snapshot.data ?? []; - final unreadCount = notifications.where((n) => !n.isRead).length; - - return _buildDropdown(notifications, unreadCount); - }, - ); - } - - /// Builds the main dropdown using WPopover. - Widget _buildDropdown( - List notifications, int unreadCount) { - return WPopover( - alignment: PopoverAlignment.bottomRight, - className: ''' - w-80 - bg-white dark:bg-gray-800 - border border-gray-200 dark:border-gray-700 - rounded-xl shadow-xl - ''', - maxHeight: 400, - triggerBuilder: (context, isOpen, isHovering) => - _buildTrigger(context, isOpen, isHovering, unreadCount: unreadCount), - contentBuilder: (context, close) => - _buildContent(context, close, notifications, unreadCount), - ); - } - - /// Builds the bell icon trigger with optional unread badge. - Widget _buildTrigger( - BuildContext context, - bool isOpen, - bool isHovering, { - required int unreadCount, - }) { - return Stack( - clipBehavior: Clip.none, - children: [ - WDiv( - states: {if (isOpen) 'active', if (isHovering) 'hover'}, - className: ''' - p-2 rounded-lg duration-150 - bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 - active:bg-gray-100 dark:active:bg-gray-800 - ''', - child: WIcon( - Icons.notifications_outlined, - className: 'text-2xl text-gray-500 dark:text-gray-400', - ), - ), - if (unreadCount > 0) - Positioned( - top: 4, - right: 4, - child: WDiv( - className: ''' - min-w-[14px] h-[14px] px-1 rounded-full - bg-red-500 - flex items-center justify-center - // animate-bounce duration-500 - ''', - child: WText( - unreadCount > 9 - ? trans('notifications.badge_overflow') - : unreadCount.toString(), - className: 'text-[9px] font-bold text-white', - ), - ), - ), - ], - ); - } - - /// Builds the dropdown panel content. - Widget _buildContent( - BuildContext context, - VoidCallback close, - List notifications, - int unreadCount, - ) { - return WDiv( - className: 'flex flex-col items-stretch', - children: [ - _buildContentHeader(unreadCount), - WDiv( - className: 'flex-1 min-h-0', - child: _buildNotificationsList(context, close, notifications), - ), - if (onViewAll != null) _buildFooter(context, close), - ], - ); - } - - /// Builds the header with title and "Mark all as read" action. - Widget _buildContentHeader(int unreadCount) { - return WDiv( - className: ''' - px-4 py-3 w-full - border-b border-gray-200 dark:border-gray-700 - flex flex-row items-center justify-between - ''', - children: [ - WText( - trans('notifications.title'), - className: 'text-base font-semibold text-gray-900 dark:text-white', - ), - if (unreadCount > 0 && onMarkAllAsRead != null) - WAnchor( - onTap: onMarkAllAsRead, - child: WText( - trans('notifications.mark_all_read'), - className: 'text-xs text-primary hover:text-green-600', - ), - ), - ], - ); - } - - /// Builds the list of notifications or empty state. - Widget _buildNotificationsList( - BuildContext context, - VoidCallback close, - List notifications, - ) { - if (notifications.isEmpty) { - return WDiv( - className: - 'w-full py-12 flex flex-col items-center justify-center gap-3', - children: [ - WIcon( - Icons.notifications_off_outlined, - className: 'text-4xl text-gray-300 dark:text-gray-600', - ), - WText( - trans('notifications.empty'), - className: 'text-sm text-gray-500 dark:text-gray-400', - ), - ], - ); - } - - return WDiv( - className: 'overflow-y-auto flex flex-col', - children: notifications - .map((n) => _buildNotificationItem(context, n, close)) - .toList(), - ); - } - - /// Builds a single notification item. - Widget _buildNotificationItem( - BuildContext context, - DatabaseNotification notification, - VoidCallback close, - ) { - final IconData icon = _getIconForType(notification.type); - final String iconColor = _getColorForType(notification.type); - - return WAnchor( - onTap: () async { - await onMarkAsRead?.call(notification.id); - onNotificationTap?.call(notification); - close(); - }, - child: WDiv( - className: ''' - flex flex-row items-start gap-3 px-4 py-3 w-full - border-b border-gray-100 dark:border-gray-700 - hover:bg-gray-50 dark:hover:bg-gray-700 - ${notification.isRead ? '' : 'bg-primary/5 dark:bg-primary/10'} - ''', - children: [ - WDiv( - className: ''' - w-8 h-8 rounded-full - bg-gray-100 dark:bg-gray-700 - flex items-center justify-center - ''', - child: WIcon(icon, className: 'text-lg $iconColor'), - ), - WDiv( - className: 'flex-1 flex flex-col min-w-0', - children: [ - WText( - notification.title, - className: ''' - text-sm text-gray-900 dark:text-white truncate - ${notification.isRead ? '' : 'font-semibold'} - ''', - ), - const WSpacer(className: 'h-0.5'), - WText( - notification.body, - className: 'text-xs text-gray-500 dark:text-gray-400', - ), - const WSpacer(className: 'h-0.5'), - WText( - _formatTime(notification.createdAt), - className: 'text-xs text-gray-400 dark:text-gray-500', - ), - ], - ), - if (!notification.isRead) - WDiv( - className: 'w-2 h-2 rounded-full bg-primary mt-2', - child: const SizedBox.shrink(), - ), - ], - ), - ); - } - - /// Builds the footer with "View all" action. - Widget _buildFooter(BuildContext context, VoidCallback close) { - return WAnchor( - onTap: () { - onViewAll?.call(); - close(); - }, - child: WDiv( - className: ''' - px-4 py-3 w-full - border-t border-gray-200 dark:border-gray-700 - hover:bg-gray-50 dark:hover:bg-gray-700 - flex items-center justify-center - ''', - child: WText( - trans('notifications.view_all'), - className: 'text-sm font-medium text-primary', - ), - ), - ); - } - - /// Builds the loading state dropdown. - Widget _buildLoadingDropdown() { - return WPopover( - alignment: PopoverAlignment.bottomRight, - className: ''' - w-80 - bg-white dark:bg-gray-800 - border border-gray-200 dark:border-gray-700 - rounded-xl shadow-xl - ''', - maxHeight: 400, - triggerBuilder: (context, isOpen, isHovering) => - _buildTrigger(context, isOpen, isHovering, unreadCount: 0), - contentBuilder: (context, close) => WDiv( - className: 'py-12 flex items-center justify-center', - child: const CircularProgressIndicator(), - ), - ); - } - - /// Builds the error state dropdown. - Widget _buildErrorDropdown() { - return WPopover( - alignment: PopoverAlignment.bottomRight, - className: ''' - w-80 - bg-white dark:bg-gray-800 - border border-gray-200 dark:border-gray-700 - rounded-xl shadow-xl - ''', - maxHeight: 400, - triggerBuilder: (context, isOpen, isHovering) => - _buildTrigger(context, isOpen, isHovering, unreadCount: 0), - contentBuilder: (context, close) => WDiv( - className: - 'w-full py-12 flex flex-col items-center justify-center gap-3', - children: [ - WIcon(Icons.error_outline, className: 'text-4xl text-red-500'), - WText( - trans('notifications.load_failed'), - className: 'text-sm text-gray-600 dark:text-gray-400', - ), - ], - ), - ); - } - - IconData _getIconForType(String type) { - return _typeIcons[type] ?? _defaultTypeIcon; - } - - String _getColorForType(String type) { - switch (type) { - case 'monitor_down': - return 'text-red-500'; - case 'monitor_up': - return 'text-green-500'; - case 'monitor_degraded': - return 'text-yellow-500'; - default: - return 'text-blue-500'; - } - } - - String _formatTime(DateTime dateTime) { - final now = DateTime.now(); - final difference = now.difference(dateTime); - - if (difference.inMinutes < 1) { - return trans('time.just_now'); - } else if (difference.inHours < 1) { - return trans('time.minutes_ago', {'minutes': difference.inMinutes}); - } else if (difference.inDays < 1) { - return trans('time.hours_ago', {'hours': difference.inHours}); - } else if (difference.inDays < 7) { - return trans('time.days_ago', {'days': difference.inDays}); - } else { - return trans('time.date_format', { - 'day': dateTime.day, - 'month': dateTime.month, - 'year': dateTime.year, - }); - } - } } diff --git a/lib/src/ui/widgets/magic_starter_page_header.dart b/lib/src/ui/widgets/magic_starter_page_header.dart index 23ce612..6b8c7ae 100644 --- a/lib/src/ui/widgets/magic_starter_page_header.dart +++ b/lib/src/ui/widgets/magic_starter_page_header.dart @@ -1,83 +1,22 @@ -import 'package:flutter/material.dart'; -import 'package:magic/magic.dart'; +// Thin alias — MagicStarterPageHeader is preserved for backward compatibility. +// The implementation now lives in components/page_header/page_header.dart. -import '../../facades/magic_starter.dart'; +export '../components/page_header/page_header.dart' show PageHeader; -/// Reusable page header for Magic Starter views. -/// -/// Matches the [AppPageHeader] standard: full-width, border-b separator, -/// [p-4 lg:p-6] padding, optional [leading] widget, and optional [actions]. -class MagicStarterPageHeader extends StatelessWidget { - /// Required title text. - final String title; - - /// Optional subtitle text displayed below the title. - final String? subtitle; - - /// Optional leading widget (e.g. back button). - final Widget? leading; - - /// Optional trailing action widgets. - final List? actions; - - /// Optional widget rendered inline after the title column, inside the title+leading row. - final Widget? titleSuffix; - - /// When true, the outer WDiv uses a single-row layout (`flex-row`) instead of - /// the default responsive `flex-col sm:flex-row` stacked layout. - final bool inlineActions; +import '../components/page_header/page_header.dart'; +/// Backward-compatible alias for [PageHeader]. +/// +/// Delegates all construction to [PageHeader] so existing callers and the +/// test suite are unaffected. +class MagicStarterPageHeader extends PageHeader { const MagicStarterPageHeader({ super.key, - required this.title, - this.subtitle, - this.leading, - this.actions, - this.titleSuffix, - this.inlineActions = false, + required super.title, + super.subtitle, + super.leading, + super.actions, + super.titleSuffix, + super.inlineActions, }); - - @override - Widget build(BuildContext context) { - final leading = this.leading; - return WDiv( - className: inlineActions - ? MagicStarter.pageHeaderTheme.containerInlineClassName - : MagicStarter.pageHeaderTheme.containerClassName, - children: [ - WDiv( - className: inlineActions - ? 'flex flex-row items-center gap-3 flex-1 min-w-0' - : 'flex flex-row items-center gap-3 sm:flex-1 min-w-0', - children: [ - if (leading != null) leading, - WDiv( - className: 'flex flex-col gap-1 flex-1 min-w-0', - children: [ - WText( - title, - className: MagicStarter.pageHeaderTheme.titleClassName, - ), - if (subtitle != null) - WText( - subtitle!, - className: MagicStarter.pageHeaderTheme.subtitleClassName, - ), - ], - ), - if (titleSuffix != null) - WDiv( - className: 'flex-shrink-0', - child: titleSuffix!, - ), - ], - ), - if (actions != null && actions!.isNotEmpty) - WDiv( - className: MagicStarter.pageHeaderTheme.actionContainerClassName, - children: actions!, - ), - ], - ); - } } diff --git a/lib/src/ui/widgets/magic_starter_social_divider.dart b/lib/src/ui/widgets/magic_starter_social_divider.dart index 388adf8..523a3d8 100644 --- a/lib/src/ui/widgets/magic_starter_social_divider.dart +++ b/lib/src/ui/widgets/magic_starter_social_divider.dart @@ -1,36 +1,12 @@ -import 'package:flutter/material.dart'; -import 'package:magic/magic.dart'; +// Thin alias — MagicStarterSocialDivider is preserved for backward +// compatibility. The implementation now lives in +// components/social_divider/social_divider.dart. -import '../../facades/magic_starter.dart'; +import '../components/social_divider/social_divider.dart'; -/// Visual divider with "Or continue with" text. -/// -/// Appears between the primary auth form and social login buttons. -/// Follows the Magic Starter design system with dark mode support. -class MagicStarterSocialDivider extends StatelessWidget { - const MagicStarterSocialDivider({super.key}); +export '../components/social_divider/social_divider.dart' show SocialDivider; - @override - Widget build(BuildContext context) { - return WDiv( - className: MagicStarter.authTheme.socialDividerClassName, - children: [ - WDiv( - className: MagicStarter.authTheme.socialDividerLineClassName, - child: const SizedBox.shrink(), - ), - WDiv( - className: 'px-4', - child: WText( - trans('auth.or_continue_with'), - className: MagicStarter.authTheme.socialDividerTextClassName, - ), - ), - WDiv( - className: MagicStarter.authTheme.socialDividerLineClassName, - child: const SizedBox.shrink(), - ), - ], - ); - } +/// Backward-compatible alias for [SocialDivider]. +class MagicStarterSocialDivider extends SocialDivider { + const MagicStarterSocialDivider({super.key}); } diff --git a/lib/src/ui/widgets/magic_starter_team_selector.dart b/lib/src/ui/widgets/magic_starter_team_selector.dart index c4b2cf7..294eae0 100644 --- a/lib/src/ui/widgets/magic_starter_team_selector.dart +++ b/lib/src/ui/widgets/magic_starter_team_selector.dart @@ -1,187 +1,12 @@ -import 'package:flutter/material.dart'; -import 'package:magic/magic.dart'; +// Thin alias — MagicStarterTeamSelector is preserved for backward +// compatibility. The implementation now lives in +// components/team_selector/team_selector.dart. -import '../../configuration/magic_starter_config.dart'; -import '../../facades/magic_starter.dart'; -import '../../models/magic_starter_team.dart'; -import '../../magic_starter_manager.dart'; +import '../components/team_selector/team_selector.dart'; -/// Team selector widget for Magic Starter. -/// -/// Displays the current team and allows switching between teams. -/// Uses the team resolver registered via `MagicStarter.useTeamResolver()`. -class MagicStarterTeamSelector extends StatelessWidget { - static const _iconCollapse = Icons.unfold_less; - static const _iconExpand = Icons.unfold_more; +export '../components/team_selector/team_selector.dart' show TeamSelector; - final bool compact; - - const MagicStarterTeamSelector({super.key, this.compact = false}); - - @override - Widget build(BuildContext context) { - final resolver = MagicStarter.teamResolver; - if (resolver == null) return const SizedBox.shrink(); - - final currentTeam = resolver.currentTeam(); - final allTeams = resolver.allTeams(); - - if (allTeams.isEmpty) return const SizedBox.shrink(); - - return WPopover( - alignment: PopoverAlignment.bottomCenter, - className: ''' - w-58 - bg-white dark:bg-gray-800 - border border-gray-200 dark:border-gray-700 - rounded-xl shadow-xl - ''', - triggerBuilder: (context, isOpen, isHovering) => WDiv( - className: - 'flex flex-row items-center gap-2 mx-3 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800', - children: [ - WDiv( - className: - 'w-8 h-8 rounded-lg bg-primary/10 dark:bg-primary/10 flex items-center justify-center', - child: WText( - (currentTeam?.name ?? '?')[0].toUpperCase(), - className: 'text-sm font-bold text-primary', - ), - ), - if (!compact) ...[ - Expanded( - child: WText( - currentTeam?.name ?? trans('teams.select_team'), - className: - 'text-sm font-medium text-gray-900 dark:text-white truncate', - ), - ), - WIcon( - isOpen ? _iconCollapse : _iconExpand, - className: 'text-gray-400 text-lg', - ), - ], - ], - ), - contentBuilder: (context, close) => - _buildTeamList(allTeams, currentTeam, resolver, close), - ); - } - - Widget _buildTeamList( - List teams, - MagicStarterTeam? currentTeam, - MagicStarterTeamResolverConfig resolver, - VoidCallback close, - ) { - return WDiv( - className: 'py-2', - children: [ - // Section label - WDiv( - className: 'px-3 pb-1', - child: WText( - trans('teams.team').toUpperCase(), - className: - 'text-xs font-bold tracking-wide text-gray-400 dark:text-gray-500', - ), - ), - // Team list - ...teams.map((team) { - final isActive = team.id == currentTeam?.id; - return WAnchor( - onTap: () { - close(); - if (!isActive) resolver.onSwitch(team.id); - }, - child: WDiv( - states: {if (isActive) 'active'}, - className: - 'w-full px-3 py-2.5 flex flex-row items-center gap-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 active:bg-primary/5', - children: [ - WDiv( - className: - 'w-8 h-8 rounded-lg ${isActive ? "bg-primary/15" : "bg-gray-100 dark:bg-gray-700"} flex items-center justify-center', - child: WText( - (team.name ?? '?')[0].toUpperCase(), - className: - 'text-xs font-bold ${isActive ? "text-primary" : "text-gray-500 dark:text-gray-400"}', - ), - ), - Expanded( - child: WText( - team.name ?? '', - className: - 'text-sm ${isActive ? "font-semibold text-gray-900 dark:text-white" : "font-medium text-gray-700 dark:text-gray-300"} truncate', - ), - ), - if (isActive) - WIcon( - Icons.check_circle, - className: 'text-primary text-lg', - ), - ], - ), - ); - }), - // Divider - WDiv( - className: - 'my-1.5 mx-3 border-t border-gray-100 dark:border-gray-700', - ), - // Team Settings - WAnchor( - onTap: () { - close(); - MagicRoute.to(MagicStarterConfig.teamSettingsRoute()); - }, - child: WDiv( - className: - 'w-full px-3 py-2.5 flex flex-row items-center gap-3 hover:bg-gray-50 dark:hover:bg-gray-700/50', - children: [ - WDiv( - className: - 'w-8 h-8 rounded-lg bg-gray-100 dark:bg-gray-700 flex items-center justify-center', - child: const WIcon( - Icons.settings_outlined, - className: 'text-base text-gray-500 dark:text-gray-400', - ), - ), - WText( - trans('teams.settings'), - className: - 'text-sm font-medium text-gray-700 dark:text-gray-300', - ), - ], - ), - ), - // Create New Team - WAnchor( - onTap: () { - close(); - MagicRoute.to(MagicStarterConfig.teamCreateRoute()); - }, - child: WDiv( - className: - 'w-full px-3 py-2.5 flex flex-row items-center gap-3 hover:bg-gray-50 dark:hover:bg-gray-700/50', - children: [ - WDiv( - className: - 'w-8 h-8 rounded-lg bg-primary/10 dark:bg-primary/10 flex items-center justify-center', - child: const WIcon( - Icons.add, - className: 'text-base text-primary', - ), - ), - WText( - trans('teams.create_team'), - className: - 'text-sm font-medium text-gray-700 dark:text-gray-300', - ), - ], - ), - ), - ], - ); - } +/// Backward-compatible alias for [TeamSelector]. +class MagicStarterTeamSelector extends TeamSelector { + const MagicStarterTeamSelector({super.key, super.compact}); } diff --git a/lib/src/ui/widgets/magic_starter_user_profile_dropdown.dart b/lib/src/ui/widgets/magic_starter_user_profile_dropdown.dart index b359cbd..a1b72a4 100644 --- a/lib/src/ui/widgets/magic_starter_user_profile_dropdown.dart +++ b/lib/src/ui/widgets/magic_starter_user_profile_dropdown.dart @@ -1,245 +1,17 @@ -import 'package:flutter/material.dart'; -import 'package:magic/magic.dart'; +// Thin alias — MagicStarterUserProfileDropdown is preserved for backward +// compatibility. The implementation now lives in +// components/user_profile_dropdown/user_profile_dropdown.dart. -import '../../configuration/magic_starter_config.dart'; -import '../../facades/magic_starter.dart'; -import '../../http/controllers/magic_starter_auth_controller.dart'; +import '../components/user_profile_dropdown/user_profile_dropdown.dart'; -/// A dropdown widget for the user profile. -/// -/// Renders the user's avatar, name, and email, along with profile links. -class MagicStarterUserProfileDropdown extends StatelessWidget { - /// The popover alignment direction. - /// - /// Defaults to [PopoverAlignment.bottomRight] for header usage. - /// Use [PopoverAlignment.topRight] for sidebar bottom placement. - final PopoverAlignment alignment; +export '../components/user_profile_dropdown/user_profile_dropdown.dart' + show UserProfileDropdown; - /// Custom builder for the trigger widget. - /// - /// When null, renders the default circular avatar with user initial. - /// The builder receives the same `isOpen` and `isHovering` states. - final Widget Function(BuildContext context, bool isOpen, bool isHovering)? - triggerBuilder; - - /// Creates a user profile dropdown. +/// Backward-compatible alias for [UserProfileDropdown]. +class MagicStarterUserProfileDropdown extends UserProfileDropdown { const MagicStarterUserProfileDropdown({ super.key, - this.alignment = PopoverAlignment.bottomRight, - this.triggerBuilder, + super.alignment, + super.triggerBuilder, }); - - static const _iconLightMode = Icons.light_mode_outlined; - static const _iconDarkMode = Icons.dark_mode_outlined; - - @override - Widget build(BuildContext context) { - return WPopover( - alignment: alignment, - className: ''' - w-72 - bg-white dark:bg-gray-800 - rounded-2xl - shadow-lg - mt-2 - border border-gray-100 dark:border-gray-700 - ''', - triggerBuilder: (context, isOpen, isHovering) => - triggerBuilder?.call(context, isOpen, isHovering) ?? - _buildAvatarTrigger(context, isOpen, isHovering), - contentBuilder: (context, close) => _buildMenu(context, close), - ); - } - - /// Builds the trigger avatar widget. - Widget _buildAvatarTrigger( - BuildContext context, bool isOpen, bool isHovering) { - final userName = Auth.user()?.get('name') ?? trans('common.user'); - final initial = userName.isNotEmpty ? userName[0].toUpperCase() : 'U'; - final navTheme = MagicStarter.navigationTheme; - - return WDiv( - states: { - if (isOpen) 'active', - if (isHovering) 'hover', - }, - className: ''' - w-8 h-8 - rounded-full - ${navTheme.dropdownAvatarClassName} - flex items-center justify-center - cursor-pointer - shadow-sm - transition-all duration-200 - hover:scale-105 - active:scale-95 - ''', - child: WText( - initial, - className: 'text-sm font-bold text-white', - ), - ); - } - - /// Builds the dropdown menu content. - Widget _buildMenu(BuildContext context, VoidCallback close) { - final userName = Auth.user()?.get('name') ?? trans('common.user'); - final userEmail = Auth.user()?.get('email') ?? ''; - final profileMenuItems = - MagicStarter.navigationConfig?.profileMenuItems ?? []; - - return WDiv( - className: 'flex flex-col py-2 w-full', - children: [ - // Fixed header — user info. - WDiv( - className: - 'w-full flex flex-col px-4 py-2 mb-1 border-b border-gray-100 dark:border-gray-700', - children: [ - WText( - trans('auth.signed_in_as').toUpperCase(), - className: 'text-[10px] font-bold tracking-widest text-gray-400', - ), - const WSpacer(className: 'h-1'), - WText( - userName, - className: - 'text-sm font-semibold text-gray-900 dark:text-white truncate', - ), - if (userEmail.isNotEmpty) - WText( - userEmail, - className: 'text-xs text-gray-500 dark:text-gray-400 truncate', - ), - ], - ), - const WSpacer(className: 'h-1'), - // Scrollable menu items. - WDiv( - className: 'flex-1 overflow-y-auto', - children: [ - _buildMenuItem( - icon: Icons.person_outline, - label: trans('auth.profile'), - onTap: () { - close(); - MagicRoute.to(MagicStarterConfig.profileRoute()); - }, - ), - // App-registered profile menu items. - for (final item in profileMenuItems) - _buildMenuItem( - icon: item.icon, - label: trans(item.labelKey), - onTap: () { - close(); - MagicRoute.to(item.path); - }, - ), - - // Auto-injected notification settings when feature is enabled. - if (MagicStarterConfig.hasNotificationFeatures()) - _buildMenuItem( - icon: Icons.notifications_outlined, - label: trans('notifications.settings'), - onTap: () { - close(); - MagicRoute.to( - MagicStarterConfig.notificationPreferencesRoute()); - }, - ), - // Theme toggle — does not close dropdown on tap. - _buildMenuItem( - icon: context.windIsDark ? _iconLightMode : _iconDarkMode, - label: trans('common.toggle_theme'), - onTap: () => context.windTheme.toggleTheme(), - ), - ], - ), - // Fixed footer — divider + logout. - WDiv( - className: 'h-[1px] bg-gray-200 dark:bg-gray-700 my-1 mx-2 w-full'), - _buildMenuItem( - icon: Icons.logout, - label: trans('auth.logout'), - isDanger: true, - onTap: () { - close(); - _handleLogout(); - }, - ), - ], - ); - } - - /// Builds a single menu item. - Widget _buildMenuItem({ - required IconData icon, - required String label, - required VoidCallback onTap, - bool isDanger = false, - }) { - return WAnchor( - onTap: onTap, - child: WDiv( - states: {if (isDanger) 'danger'}, - className: ''' - mx-2 px-3 py-2.5 w-full - rounded-lg - hover:bg-gray-50 dark:hover:bg-gray-700/50 - active:bg-gray-100 dark:active:bg-gray-700 - flex items-center gap-3 - cursor-pointer - transition-colors duration-150 - ''', - children: [ - WDiv( - states: {if (isDanger) 'danger'}, - className: ''' - w-8 h-8 - rounded-lg - bg-gray-100 dark:bg-gray-700 - hover:bg-gray-200 dark:hover:bg-gray-600 - danger:bg-red-50 dark:danger:bg-red-900/20 - flex items-center justify-center - transition-colors duration-150 - ''', - child: WIcon( - icon, - states: {if (isDanger) 'danger'}, - className: ''' - text-lg - text-gray-600 dark:text-gray-400 - danger:text-red-600 dark:danger:text-red-500 - ''', - ), - ), - WDiv( - className: 'flex-1 min-w-0', - child: WText( - label, - states: {if (isDanger) 'danger'}, - className: ''' - text-sm font-medium truncate - text-gray-900 dark:text-gray-100 - danger:text-red-600 dark:danger:text-red-500 - ''', - ), - ), - ], - ), - ); - } - - /// Handles the logout action. - Future _handleLogout() async { - final customLogout = MagicStarter.manager.onLogout; - - if (customLogout != null) { - await customLogout(); - return; - } - - await MagicStarterAuthController.instance.logout(); - } } diff --git a/test/ui/components/empty_state/empty_state_test.dart b/test/ui/components/empty_state/empty_state_test.dart new file mode 100644 index 0000000..b8c8121 --- /dev/null +++ b/test/ui/components/empty_state/empty_state_test.dart @@ -0,0 +1,93 @@ +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/components/empty_state/index.dart'; + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + Magic.singleton('magic_starter', () => MagicStarterManager()); + }); + + tearDown(() { + MagicApp.reset(); + Magic.flush(); + }); + + Widget wrap(Widget widget) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold( + body: SingleChildScrollView(child: widget), + ), + ), + ); + } + + testWidgets('EmptyState renders title', (tester) async { + await tester.pumpWidget( + wrap(const EmptyState(title: 'No items found')), + ); + expect(find.text('No items found'), findsOneWidget); + }); + + testWidgets('EmptyState renders description when provided', (tester) async { + await tester.pumpWidget( + wrap( + const EmptyState( + title: 'No items', + description: 'Start by creating one', + ), + ), + ); + expect(find.text('Start by creating one'), findsOneWidget); + }); + + testWidgets('EmptyState renders icon when provided', (tester) async { + await tester.pumpWidget( + wrap( + const EmptyState( + title: 'No items', + icon: Icons.inbox_outlined, + ), + ), + ); + expect(find.byIcon(Icons.inbox_outlined), findsOneWidget); + }); + + testWidgets('EmptyState renders action widget when provided', (tester) async { + const actionKey = Key('empty-action'); + await tester.pumpWidget( + wrap( + EmptyState( + title: 'No items', + action: ElevatedButton( + key: actionKey, + onPressed: () {}, + child: const Text('Create'), + ), + ), + ), + ); + expect(find.byKey(actionKey), findsOneWidget); + }); + + testWidgets('EmptyState does not render description when omitted', + (tester) async { + await tester.pumpWidget( + wrap(const EmptyState(title: 'Nothing here')), + ); + // Only title WText present + final texts = tester.widgetList(find.byType(WText)).toList(); + expect(texts.any((t) => t.data == 'Nothing here'), isTrue); + }); + + testWidgets('EmptyState preview renders without error', (tester) async { + await tester.pumpWidget(wrap(const EmptyStatePreview())); + await tester.pump(); + expect(find.byType(EmptyStatePreview), findsOneWidget); + }); +} diff --git a/test/ui/components/error_state/error_state_test.dart b/test/ui/components/error_state/error_state_test.dart new file mode 100644 index 0000000..a887592 --- /dev/null +++ b/test/ui/components/error_state/error_state_test.dart @@ -0,0 +1,98 @@ +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/components/error_state/index.dart'; + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + Magic.singleton('magic_starter', () => MagicStarterManager()); + }); + + tearDown(() { + MagicApp.reset(); + Magic.flush(); + }); + + Widget wrap(Widget widget) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: widget), + ), + ); + } + + testWidgets('ErrorState renders title', (tester) async { + await tester.pumpWidget( + wrap(const ErrorState(title: 'Something went wrong')), + ); + expect(find.text('Something went wrong'), findsOneWidget); + }); + + testWidgets('ErrorState renders description when provided', (tester) async { + await tester.pumpWidget( + wrap( + const ErrorState( + title: 'Error', + description: 'Please try again later', + ), + ), + ); + expect(find.text('Please try again later'), findsOneWidget); + }); + + testWidgets('ErrorState renders icon when provided', (tester) async { + await tester.pumpWidget( + wrap( + const ErrorState( + title: 'Error', + icon: Icons.error_outline, + ), + ), + ); + expect(find.byIcon(Icons.error_outline), findsOneWidget); + }); + + testWidgets('ErrorState renders action widget when provided', (tester) async { + const actionKey = Key('error-action'); + await tester.pumpWidget( + wrap( + ErrorState( + title: 'Error', + action: ElevatedButton( + key: actionKey, + onPressed: () {}, + child: const Text('Retry'), + ), + ), + ), + ); + expect(find.byKey(actionKey), findsOneWidget); + }); + + testWidgets('ErrorState title uses destructive tone', (tester) async { + await tester.pumpWidget( + wrap(const ErrorState(title: 'Failed')), + ); + final texts = tester.widgetList(find.byType(WText)).toList(); + // Title should use a destructive/error tone class + expect( + texts.any( + (t) => + t.data == 'Failed' && + (t.className?.contains('red') == true || + t.className?.contains('destructive') == true), + ), + isTrue, + ); + }); + + testWidgets('ErrorState preview renders without error', (tester) async { + await tester.pumpWidget(wrap(const ErrorStatePreview())); + await tester.pump(); + expect(find.byType(ErrorStatePreview), findsOneWidget); + }); +} diff --git a/test/ui/components/form_field/form_field_test.dart b/test/ui/components/form_field/form_field_test.dart new file mode 100644 index 0000000..6ca4705 --- /dev/null +++ b/test/ui/components/form_field/form_field_test.dart @@ -0,0 +1,142 @@ +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/components/form_field/index.dart'; + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + Magic.singleton('magic_starter', () => MagicStarterManager()); + }); + + tearDown(() { + MagicApp.reset(); + Magic.flush(); + }); + + Widget wrap(Widget widget) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: SingleChildScrollView(child: widget)), + ), + ); + } + + // --------------------------------------------------------------------------- + // Slot / structure tests + // --------------------------------------------------------------------------- + + testWidgets('MagicFormField renders label when provided', (tester) async { + await tester.pumpWidget( + wrap( + MagicFormField( + label: 'Email', + child: const WDiv(className: 'h-10'), + ), + ), + ); + expect(find.text('Email'), findsOneWidget); + }); + + testWidgets('MagicFormField renders child widget', (tester) async { + const childKey = Key('form-field-child'); + await tester.pumpWidget( + wrap( + MagicFormField( + label: 'Name', + child: SizedBox(key: childKey, height: 10), + ), + ), + ); + expect(find.byKey(childKey), findsOneWidget); + }); + + testWidgets('MagicFormField renders hint when provided', (tester) async { + await tester.pumpWidget( + wrap( + MagicFormField( + label: 'Password', + hint: 'Must be at least 8 characters', + child: const WDiv(className: 'h-10'), + ), + ), + ); + expect(find.text('Must be at least 8 characters'), findsOneWidget); + }); + + testWidgets('MagicFormField does not render hint when omitted', (tester) async { + await tester.pumpWidget( + wrap( + MagicFormField( + label: 'Name', + child: const WDiv(className: 'h-10'), + ), + ), + ); + // Only label WText present; no hint + final texts = tester.widgetList(find.byType(WText)).toList(); + expect(texts.length, 1); + }); + + testWidgets('MagicFormField renders error when provided', (tester) async { + await tester.pumpWidget( + wrap( + MagicFormField( + label: 'Email', + error: 'Invalid email address', + child: const WDiv(className: 'h-10'), + ), + ), + ); + expect(find.text('Invalid email address'), findsOneWidget); + }); + + testWidgets('MagicFormField error text uses destructive tone class', + (tester) async { + await tester.pumpWidget( + wrap( + MagicFormField( + label: 'Email', + error: 'Required field', + child: const WDiv(className: 'h-10'), + ), + ), + ); + final texts = tester.widgetList(find.byType(WText)).toList(); + // The error WText should contain a destructive/error tone class + final errorText = texts.last; + expect( + errorText.className, + anyOf(contains('text-red'), contains('destructive'), contains('error')), + ); + }); + + testWidgets('MagicFormField does not render error when omitted', (tester) async { + await tester.pumpWidget( + wrap( + MagicFormField( + label: 'Name', + child: const WDiv(className: 'h-10'), + ), + ), + ); + expect(find.text('Required'), findsNothing); + }); + + testWidgets('MagicFormField renders without label when label is null', + (tester) async { + await tester.pumpWidget( + wrap(MagicFormField(child: const WDiv(className: 'h-10'))), + ); + expect(find.byType(MagicFormField), findsOneWidget); + }); + + testWidgets('MagicFormField preview renders without error', (tester) async { + await tester.pumpWidget(wrap(const MagicFormFieldPreview())); + await tester.pump(); + expect(find.byType(MagicFormFieldPreview), findsOneWidget); + }); +} diff --git a/test/ui/components/navbar/navbar_test.dart b/test/ui/components/navbar/navbar_test.dart new file mode 100644 index 0000000..7d137fe --- /dev/null +++ b/test/ui/components/navbar/navbar_test.dart @@ -0,0 +1,77 @@ +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/components/navbar/index.dart'; + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + Magic.singleton('magic_starter', () => MagicStarterManager()); + }); + + tearDown(() { + MagicApp.reset(); + Magic.flush(); + }); + + Widget wrap(Widget widget) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold( + body: SingleChildScrollView(child: widget), + ), + ), + ); + } + + testWidgets('Navbar renders brand when provided', (tester) async { + await tester.pumpWidget( + wrap( + Navbar( + brand: const Text('My App'), + children: const [], + ), + ), + ); + expect(find.text('My App'), findsOneWidget); + }); + + testWidgets('Navbar renders children', (tester) async { + const childKey = Key('navbar-child'); + await tester.pumpWidget( + wrap( + Navbar( + children: [ + SizedBox(key: childKey, width: 10), + ], + ), + ), + ); + expect(find.byKey(childKey), findsOneWidget); + }); + + testWidgets('Navbar renders trailing widget when provided', (tester) async { + const trailingKey = Key('navbar-trailing'); + await tester.pumpWidget( + wrap( + Navbar( + trailing: SizedBox(key: trailingKey, width: 10), + children: const [], + ), + ), + ); + expect(find.byKey(trailingKey), findsOneWidget); + }); + + testWidgets('Navbar preview renders without error', (tester) async { + // Use a wider surface so the responsive navbar preview does not overflow. + await tester.binding.setSurfaceSize(const Size(1200, 800)); + await tester.pumpWidget(wrap(const NavbarPreview())); + await tester.pump(); + expect(find.byType(NavbarPreview), findsOneWidget); + await tester.binding.setSurfaceSize(null); + }); +} diff --git a/test/ui/components/notification_dropdown/notification_dropdown_test.dart b/test/ui/components/notification_dropdown/notification_dropdown_test.dart new file mode 100644 index 0000000..1826bcc --- /dev/null +++ b/test/ui/components/notification_dropdown/notification_dropdown_test.dart @@ -0,0 +1,128 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; +import 'package:magic_notifications/magic_notifications.dart'; +import 'package:magic_starter/magic_starter.dart'; +import 'package:magic_starter/src/ui/components/notification_dropdown/index.dart'; + +void main() { + late StreamController> streamController; + + setUp(() { + MagicApp.reset(); + Magic.flush(); + Magic.singleton('magic_starter', () => MagicStarterManager()); + streamController = + StreamController>.broadcast(); + Magic.singleton('log', () => LogManager()); + Config.set('logging', { + 'default': 'console', + 'channels': { + 'console': {'driver': 'console', 'level': 'debug'}, + }, + }); + }); + + tearDown(() { + streamController.close(); + }); + + DatabaseNotification makeNotification({bool isRead = false}) { + final now = DateTime.now(); + return DatabaseNotification.fromMap({ + 'id': 'test-id-${now.millisecondsSinceEpoch}', + 'type': 'monitor_down', + 'data': { + 'title': 'Test Notification', + 'body': 'Test body', + 'action_url': null, + }, + 'read_at': isRead ? now.toIso8601String() : null, + 'created_at': now.toIso8601String(), + }); + } + + Widget wrap(Widget widget) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: widget), + ), + ); + } + + // --------------------------------------------------------------------------- + // Behavior equivalence gate — mirrors magic_starter_notification_dropdown_test + // --------------------------------------------------------------------------- + + testWidgets('renders bell icon', (tester) async { + await tester.pumpWidget(wrap(NotificationDropdown( + notificationStream: streamController.stream, + ))); + expect(find.byIcon(Icons.notifications_outlined), findsOneWidget); + + streamController.add([]); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.byIcon(Icons.notifications_outlined), findsOneWidget); + }); + + testWidgets('shows unread badge when unread count > 0', (tester) async { + await tester.pumpWidget(wrap(NotificationDropdown( + notificationStream: streamController.stream, + ))); + + streamController.add([ + makeNotification(isRead: false), + makeNotification(isRead: false), + makeNotification(isRead: true), + ]); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text('2'), findsOneWidget); + }); + + testWidgets('hides badge when all notifications are read', (tester) async { + await tester.pumpWidget(wrap(NotificationDropdown( + notificationStream: streamController.stream, + ))); + + streamController.add([ + makeNotification(isRead: true), + makeNotification(isRead: true), + ]); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsNothing); + expect(find.text('2'), findsNothing); + }); + + testWidgets('renders empty state in popover content', (tester) async { + await tester.pumpWidget(wrap(NotificationDropdown( + notificationStream: streamController.stream, + ))); + + streamController.add([]); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + await tester.tap(find.byIcon(Icons.notifications_outlined)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text('notifications.empty'), findsOneWidget); + expect(find.byIcon(Icons.notifications_off_outlined), findsOneWidget); + }); + + testWidgets('NotificationDropdown preview renders without error', (tester) async { + await tester.pumpWidget(wrap(const NotificationDropdownPreview())); + await tester.pump(); + expect(find.byType(NotificationDropdownPreview), findsOneWidget); + }); +} diff --git a/test/ui/components/page_header/page_header_test.dart b/test/ui/components/page_header/page_header_test.dart new file mode 100644 index 0000000..507728c --- /dev/null +++ b/test/ui/components/page_header/page_header_test.dart @@ -0,0 +1,188 @@ +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/components/page_header/index.dart'; + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + Magic.singleton('magic_starter', () => MagicStarterManager()); + }); + + tearDown(() { + MagicApp.reset(); + Magic.flush(); + }); + + Widget wrap(Widget widget) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: widget), + ), + ); + } + + // --------------------------------------------------------------------------- + // Behavior equivalence gate — mirrors magic_starter_page_header_test.dart + // --------------------------------------------------------------------------- + + testWidgets('renders required title', (tester) async { + await tester.pumpWidget( + wrap(const PageHeader(title: 'My Page')), + ); + expect(find.text('My Page'), findsOneWidget); + }); + + testWidgets('renders subtitle when provided', (tester) async { + await tester.pumpWidget( + wrap( + const PageHeader( + title: 'Projects', + subtitle: 'Manage your projects', + ), + ), + ); + expect(find.text('Projects'), findsOneWidget); + expect(find.text('Manage your projects'), findsOneWidget); + }); + + testWidgets('does not render subtitle when omitted', (tester) async { + await tester.pumpWidget( + wrap(const PageHeader(title: 'Projects')), + ); + final texts = tester.widgetList(find.byType(WText)).toList(); + expect(texts.length, 1); + expect(texts.first.data, 'Projects'); + }); + + testWidgets('renders leading widget when provided', (tester) async { + const leadingKey = Key('back-btn'); + await tester.pumpWidget( + wrap( + const PageHeader( + title: 'Detail', + leading: Icon(Icons.arrow_back, key: leadingKey), + ), + ), + ); + expect(find.byKey(leadingKey), findsOneWidget); + }); + + testWidgets('renders actions list when provided', (tester) async { + const actionKey = Key('action-btn'); + await tester.pumpWidget( + wrap( + PageHeader( + title: 'Projects', + actions: [ + ElevatedButton( + key: actionKey, + onPressed: () {}, + child: const Text('New'), + ), + ], + ), + ), + ); + expect(find.byKey(actionKey), findsOneWidget); + }); + + testWidgets('outer WDiv has responsive sm:flex-row class', (tester) async { + await tester.pumpWidget( + wrap(const PageHeader(title: 'Responsive')), + ); + final outerDiv = tester.widget(find.byType(WDiv).first); + expect(outerDiv.className, contains('sm:flex-row')); + }); + + testWidgets('titleSuffix renders inline after title when provided', + (tester) async { + const suffixKey = Key('test_suffix'); + await tester.pumpWidget( + wrap( + PageHeader( + title: 'My Page', + titleSuffix: Container(key: suffixKey), + ), + ), + ); + expect(find.byKey(suffixKey), findsOneWidget); + }); + + testWidgets('inlineActions: true outer WDiv className contains flex-row', + (tester) async { + await tester.pumpWidget( + wrap( + PageHeader( + title: 'Inline', + inlineActions: true, + actions: [ + ElevatedButton(onPressed: () {}, child: const Text('Go')), + ], + ), + ), + ); + final outerDiv = tester.widget(find.byType(WDiv).first); + expect(outerDiv.className, contains('flex-row')); + expect(outerDiv.className, isNot(contains('flex-col'))); + }); + + testWidgets('inlineActions: false (default) retains flex-col sm:flex-row', + (tester) async { + await tester.pumpWidget( + wrap( + PageHeader( + title: 'Default Layout', + actions: [ + ElevatedButton(onPressed: () {}, child: const Text('Go')), + ], + ), + ), + ); + final outerDiv = tester.widget(find.byType(WDiv).first); + expect(outerDiv.className, contains('flex-col')); + expect(outerDiv.className, contains('sm:flex-row')); + }); + + // --------------------------------------------------------------------------- + // Theme consumption + // --------------------------------------------------------------------------- + + group('theme consumption', () { + testWidgets('custom titleClassName is used', (tester) async { + MagicStarter.manager.pageHeaderTheme = const MagicStarterPageHeaderTheme( + titleClassName: 'custom-header-title', + ); + await tester.pumpWidget( + wrap(const PageHeader(title: 'My Page')), + ); + final titleText = tester.widgetList(find.byType(WText)).first; + expect(titleText.className, contains('custom-header-title')); + }); + + testWidgets('custom subtitleClassName is used', (tester) async { + MagicStarter.manager.pageHeaderTheme = const MagicStarterPageHeaderTheme( + subtitleClassName: 'custom-header-subtitle', + ); + await tester.pumpWidget( + wrap( + const PageHeader( + title: 'My Page', + subtitle: 'A subtitle', + ), + ), + ); + final texts = tester.widgetList(find.byType(WText)).toList(); + expect(texts[1].className, contains('custom-header-subtitle')); + }); + }); + + testWidgets('PageHeader preview renders without error', (tester) async { + await tester.pumpWidget(wrap(const PageHeaderPreview())); + await tester.pump(); + expect(find.byType(PageHeaderPreview), findsOneWidget); + }); +} diff --git a/test/ui/components/social_divider/social_divider_test.dart b/test/ui/components/social_divider/social_divider_test.dart new file mode 100644 index 0000000..52afe5c --- /dev/null +++ b/test/ui/components/social_divider/social_divider_test.dart @@ -0,0 +1,64 @@ +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/components/social_divider/index.dart'; + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + Magic.singleton('magic_starter', () => MagicStarterManager()); + }); + + tearDown(() { + MagicApp.reset(); + Magic.flush(); + }); + + Widget wrap(Widget widget) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: widget), + ), + ); + } + + // --------------------------------------------------------------------------- + // Behavior equivalence gate — mirrors magic_starter_social_divider_test.dart + // --------------------------------------------------------------------------- + + testWidgets('SocialDivider renders divider with translated text', (tester) async { + await tester.pumpWidget(wrap(const SocialDivider())); + // trans() returns the key when no translation is loaded + expect(find.text('auth.or_continue_with'), findsOneWidget); + }); + + testWidgets('SocialDivider contains WDiv and WText elements', (tester) async { + await tester.pumpWidget(wrap(const SocialDivider())); + expect(find.byType(WDiv), findsWidgets); + expect(find.byType(WText), findsOneWidget); + }); + + // --------------------------------------------------------------------------- + // Theme consumption + // --------------------------------------------------------------------------- + + group('theme consumption', () { + testWidgets('custom socialDividerTextClassName is applied', (tester) async { + MagicStarter.manager.authTheme = const MagicStarterAuthTheme( + socialDividerTextClassName: 'custom-divider-text', + ); + await tester.pumpWidget(wrap(const SocialDivider())); + final wText = tester.widget(find.byType(WText)); + expect(wText.className, contains('custom-divider-text')); + }); + }); + + testWidgets('SocialDivider preview renders without error', (tester) async { + await tester.pumpWidget(wrap(const SocialDividerPreview())); + await tester.pump(); + expect(find.byType(SocialDividerPreview), findsOneWidget); + }); +} diff --git a/test/ui/components/team_selector/team_selector_test.dart b/test/ui/components/team_selector/team_selector_test.dart new file mode 100644 index 0000000..d5e06b2 --- /dev/null +++ b/test/ui/components/team_selector/team_selector_test.dart @@ -0,0 +1,74 @@ +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/components/team_selector/index.dart'; + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + Magic.singleton('magic_starter', () => MagicStarterManager()); + }); + + tearDown(() { + MagicApp.reset(); + Magic.flush(); + }); + + Widget wrap(Widget widget) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: widget), + ), + ); + } + + testWidgets('TeamSelector returns empty when no resolver registered', + (tester) async { + await tester.pumpWidget(wrap(const TeamSelector())); + await tester.pump(); + // When no resolver, renders SizedBox.shrink + expect(find.byType(WPopover), findsNothing); + }); + + testWidgets('TeamSelector renders team initial when resolver has teams', + (tester) async { + final teams = [ + MagicStarterTeam.fromMap({'id': 1, 'name': 'Acme Corp', 'personal_team': false}), + MagicStarterTeam.fromMap({'id': 2, 'name': 'Beta Inc', 'personal_team': false}), + ]; + MagicStarter.useTeamResolver( + currentTeam: () => teams.first, + allTeams: () => teams, + onSwitch: (id) async {}, + ); + + await tester.pumpWidget(wrap(const TeamSelector())); + await tester.pump(); + + // The first letter of 'Acme Corp' is shown as the team initial + expect(find.text('A'), findsOneWidget); + }); + + testWidgets('TeamSelector returns empty when teams list is empty', + (tester) async { + MagicStarter.useTeamResolver( + currentTeam: () => null, + allTeams: () => [], + onSwitch: (id) async {}, + ); + + await tester.pumpWidget(wrap(const TeamSelector())); + await tester.pump(); + + expect(find.byType(WPopover), findsNothing); + }); + + testWidgets('TeamSelector preview renders without error', (tester) async { + await tester.pumpWidget(wrap(const TeamSelectorPreview())); + await tester.pump(); + expect(find.byType(TeamSelectorPreview), findsOneWidget); + }); +} diff --git a/test/ui/components/user_profile_dropdown/user_profile_dropdown_test.dart b/test/ui/components/user_profile_dropdown/user_profile_dropdown_test.dart new file mode 100644 index 0000000..1d7197b --- /dev/null +++ b/test/ui/components/user_profile_dropdown/user_profile_dropdown_test.dart @@ -0,0 +1,171 @@ +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/components/user_profile_dropdown/index.dart'; + +class MockGuard implements Guard { + Authenticatable? _user; + + @override + Future login(Map data, Authenticatable user) async { + _user = user; + } + + @override + Future logout() async { + _user = null; + } + + @override + bool check() => _user != null; + + @override + bool get guest => !check(); + + @override + T? user() => _user as T?; + + @override + dynamic id() => _user?.authIdentifier; + + @override + void setUser(Authenticatable user) => _user = user; + + @override + Future hasToken() async => false; + + @override + Future getToken() async => null; + + @override + Future refreshToken() async => true; + + @override + Future restore() async {} + + @override + ValueNotifier get stateNotifier => ValueNotifier(0); +} + +class MockRouter implements MagicRouter { + @override + void push(String path) {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + late MockGuard mockGuard; + + setUp(() { + MagicApp.reset(); + Magic.flush(); + Magic.singleton('log', () => LogManager()); + Config.set('logging', { + 'default': 'console', + 'channels': { + 'console': {'driver': 'console', 'level': 'debug'}, + }, + }); + + mockGuard = MockGuard(); + Magic.singleton('auth', () => AuthManager()); + Auth.manager.forgetGuards(); + Auth.manager.extend('mock', (_) => mockGuard); + Config.set('auth.defaults.guard', 'mock'); + Config.set('auth.guards', { + 'mock': {'driver': 'mock'}, + }); + + Magic.singleton('magic_starter', () => MagicStarterManager()); + MagicStarter.useNavigation(mainItems: [], profileMenuItems: []); + Magic.singleton('router', () => MockRouter()); + }); + + tearDown(() { + Auth.manager.forgetGuards(); + MagicStarter.manager.onLogout = null; + }); + + Widget wrap(Widget widget) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: widget), + ), + ); + } + + // --------------------------------------------------------------------------- + // Behavior equivalence gate — mirrors magic_starter_user_profile_dropdown_test + // --------------------------------------------------------------------------- + + testWidgets('renders avatar with user initial when authenticated', + (tester) async { + mockGuard.setUser(MagicStarterAuthUser.fromMap({ + 'id': 1, + 'name': 'John Doe', + 'email': 'john@example.com', + })); + await tester.pumpWidget(wrap(const UserProfileDropdown())); + await tester.pumpAndSettle(); + expect(find.text('J'), findsOneWidget); + }); + + testWidgets('renders fallback initial when no user', (tester) async { + await tester.pumpWidget(wrap(const UserProfileDropdown())); + await tester.pumpAndSettle(); + expect(find.text('C'), findsOneWidget); + }); + + testWidgets('tapping avatar opens dropdown with user info', (tester) async { + mockGuard.setUser(MagicStarterAuthUser.fromMap({ + 'id': 1, + 'name': 'John Doe', + 'email': 'john@example.com', + })); + await tester.pumpWidget(wrap(const UserProfileDropdown())); + await tester.pumpAndSettle(); + + await tester.tap(find.text('J')); + await tester.pumpAndSettle(); + + expect(find.text('John Doe'), findsOneWidget); + expect(find.text('john@example.com'), findsOneWidget); + }); + + testWidgets('uses default bottomRight alignment', (tester) async { + await tester.pumpWidget(wrap(const UserProfileDropdown())); + await tester.pumpAndSettle(); + final popover = tester.widget(find.byType(WPopover)); + expect(popover.alignment, PopoverAlignment.bottomRight); + }); + + testWidgets('accepts custom alignment parameter', (tester) async { + await tester.pumpWidget(wrap( + const UserProfileDropdown(alignment: PopoverAlignment.topRight), + )); + await tester.pumpAndSettle(); + final popover = tester.widget(find.byType(WPopover)); + expect(popover.alignment, PopoverAlignment.topRight); + }); + + testWidgets('uses custom triggerBuilder when provided', (tester) async { + await tester.pumpWidget(wrap( + UserProfileDropdown( + triggerBuilder: (context, isOpen, isHovering) => + const Text('Custom Trigger'), + ), + )); + await tester.pumpAndSettle(); + expect(find.text('Custom Trigger'), findsOneWidget); + }); + + testWidgets('UserProfileDropdown preview renders without error', (tester) async { + await tester.pumpWidget(wrap(const UserProfileDropdownPreview())); + await tester.pump(); + expect(find.byType(UserProfileDropdownPreview), findsOneWidget); + }); +} From 3d2b652664548e4f0ffdae593573230b88619d7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Thu, 25 Jun 2026 21:17:10 +0300 Subject: [PATCH 04/12] feat: build the generic design-system component library (atomic folders) Add the form-input (Button, Input, Textarea, Checkbox, Switch, Radio), display (Badge, Typography, Skeleton), selection (Select, Combobox, SegmentedControl, Tabs, Accordion), and overlay (Dialog, ConfirmDialog, BottomSheet, Toast, Tooltip, DropdownMenu) component families as WindRecipe-driven atomic folders on the Step 8 template, building on the semantic token layer. Migrate the existing dialogs/timezone-select to the component system as thin aliases (public names + barrel paths stay stable). Export the new components from the package barrel; collision-prone names (Switch, Dialog, ...) are hidden from Material in tests that need both. --- CHANGELOG.md | 10 + lib/magic_starter.dart | 28 ++ .../ui/components/accordion/accordion.dart | 152 +++++++++++ .../accordion/accordion.preview.dart | 54 ++++ .../accordion/accordion.recipe.dart | 29 ++ lib/src/ui/components/accordion/index.dart | 8 + lib/src/ui/components/badge/badge.dart | 90 +++++++ .../ui/components/badge/badge.preview.dart | 34 +++ lib/src/ui/components/badge/badge.recipe.dart | 38 +++ lib/src/ui/components/badge/index.dart | 10 + .../components/bottom_sheet/bottom_sheet.dart | 138 ++++++++++ .../bottom_sheet/bottom_sheet.preview.dart | 52 ++++ .../bottom_sheet/bottom_sheet.recipe.dart | 30 +++ lib/src/ui/components/bottom_sheet/index.dart | 7 + lib/src/ui/components/button/button.dart | 96 +++++++ .../ui/components/button/button.preview.dart | 46 ++++ .../ui/components/button/button.recipe.dart | 74 +++++ lib/src/ui/components/button/index.dart | 16 ++ lib/src/ui/components/checkbox/checkbox.dart | 60 +++++ .../components/checkbox/checkbox.preview.dart | 54 ++++ .../components/checkbox/checkbox.recipe.dart | 13 + lib/src/ui/components/checkbox/index.dart | 8 + lib/src/ui/components/combobox/combobox.dart | 90 +++++++ .../components/combobox/combobox.preview.dart | 51 ++++ .../components/combobox/combobox.recipe.dart | 29 ++ lib/src/ui/components/combobox/index.dart | 8 + .../confirm_dialog/confirm_dialog.dart | 214 +++++++++++++++ .../confirm_dialog.preview.dart | 30 +++ .../confirm_dialog/confirm_dialog.recipe.dart | 26 ++ .../ui/components/confirm_dialog/index.dart | 9 + lib/src/ui/components/dialog/dialog.dart | 149 ++++++++++ .../ui/components/dialog/dialog.preview.dart | 55 ++++ .../ui/components/dialog/dialog.recipe.dart | 38 +++ lib/src/ui/components/dialog/index.dart | 10 + .../dropdown_menu/dropdown_menu.dart | 133 +++++++++ .../dropdown_menu/dropdown_menu.preview.dart | 66 +++++ .../dropdown_menu/dropdown_menu.recipe.dart | 13 + .../ui/components/dropdown_menu/index.dart | 7 + .../empty_state/empty_state.recipe.dart | 3 +- .../form_field/form_field.recipe.dart | 9 +- lib/src/ui/components/input/index.dart | 8 + lib/src/ui/components/input/input.dart | 158 +++++++++++ .../ui/components/input/input.preview.dart | 37 +++ lib/src/ui/components/input/input.recipe.dart | 37 +++ lib/src/ui/components/radio/index.dart | 8 + lib/src/ui/components/radio/radio.dart | 76 ++++++ .../ui/components/radio/radio.preview.dart | 72 +++++ lib/src/ui/components/radio/radio.recipe.dart | 26 ++ .../components/segmented_control/index.dart | 9 + .../segmented_control/segmented_control.dart | 84 ++++++ .../segmented_control.preview.dart | 35 +++ .../segmented_control.recipe.dart | 50 ++++ lib/src/ui/components/select/index.dart | 11 + lib/src/ui/components/select/select.dart | 82 ++++++ .../ui/components/select/select.preview.dart | 60 +++++ .../ui/components/select/select.recipe.dart | 27 ++ lib/src/ui/components/skeleton/index.dart | 10 + lib/src/ui/components/skeleton/skeleton.dart | 83 ++++++ .../components/skeleton/skeleton.preview.dart | 51 ++++ .../components/skeleton/skeleton.recipe.dart | 33 +++ lib/src/ui/components/switch/index.dart | 8 + lib/src/ui/components/switch/switch.dart | 67 +++++ .../ui/components/switch/switch.preview.dart | 54 ++++ .../ui/components/switch/switch.recipe.dart | 23 ++ lib/src/ui/components/tabs/index.dart | 8 + lib/src/ui/components/tabs/tabs.dart | 77 ++++++ lib/src/ui/components/tabs/tabs.preview.dart | 48 ++++ lib/src/ui/components/tabs/tabs.recipe.dart | 26 ++ lib/src/ui/components/textarea/index.dart | 9 + lib/src/ui/components/textarea/textarea.dart | 104 +++++++ .../components/textarea/textarea.preview.dart | 38 +++ .../components/textarea/textarea.recipe.dart | 37 +++ lib/src/ui/components/toast/index.dart | 7 + lib/src/ui/components/toast/toast.dart | 74 +++++ .../ui/components/toast/toast.preview.dart | 28 ++ lib/src/ui/components/toast/toast.recipe.dart | 27 ++ lib/src/ui/components/tooltip/index.dart | 7 + lib/src/ui/components/tooltip/tooltip.dart | 65 +++++ .../components/tooltip/tooltip.preview.dart | 49 ++++ .../ui/components/tooltip/tooltip.recipe.dart | 6 + lib/src/ui/components/typography/index.dart | 10 + .../ui/components/typography/typography.dart | 79 ++++++ .../typography/typography.preview.dart | 37 +++ .../typography/typography.recipe.dart | 34 +++ .../user_profile_dropdown.dart | 3 +- .../widgets/magic_starter_confirm_dialog.dart | 170 +++--------- .../widgets/magic_starter_dialog_shell.dart | 120 +-------- .../magic_starter_timezone_select.dart | 20 +- .../components/accordion/accordion_test.dart | 144 ++++++++++ test/ui/components/badge/badge_test.dart | 148 ++++++++++ .../bottom_sheet/bottom_sheet_test.dart | 113 ++++++++ test/ui/components/button/button_test.dart | 189 +++++++++++++ .../ui/components/checkbox/checkbox_test.dart | 80 ++++++ .../ui/components/combobox/combobox_test.dart | 76 ++++++ .../confirm_dialog/confirm_dialog_test.dart | 254 ++++++++++++++++++ test/ui/components/dialog/dialog_test.dart | 141 ++++++++++ .../dropdown_menu/dropdown_menu_test.dart | 121 +++++++++ .../empty_state/empty_state_test.dart | 1 - .../error_state/error_state_test.dart | 1 - .../form_field/form_field_test.dart | 7 +- test/ui/components/input/input_test.dart | 80 ++++++ test/ui/components/navbar/navbar_test.dart | 1 - .../notification_dropdown_test.dart | 6 +- test/ui/components/radio/radio_test.dart | 108 ++++++++ .../segmented_control_test.dart | 99 +++++++ test/ui/components/select/select_test.dart | 97 +++++++ .../ui/components/skeleton/skeleton_test.dart | 148 ++++++++++ .../social_divider/social_divider_test.dart | 3 +- test/ui/components/switch/switch_test.dart | 92 +++++++ test/ui/components/tabs/tabs_test.dart | 114 ++++++++ .../team_selector/team_selector_test.dart | 6 +- .../ui/components/textarea/textarea_test.dart | 79 ++++++ test/ui/components/toast/toast_test.dart | 99 +++++++ test/ui/components/tooltip/tooltip_test.dart | 64 +++++ .../typography/typography_test.dart | 156 +++++++++++ .../user_profile_dropdown_test.dart | 3 +- ...er_notification_preferences_view_test.dart | 2 +- ...rter_profile_settings_newsletter_test.dart | 2 +- .../magic_starter_dialog_shell_test.dart | 2 +- ..._starter_password_confirm_dialog_test.dart | 2 +- .../magic_starter_two_factor_modal_test.dart | 2 +- 121 files changed, 6344 insertions(+), 275 deletions(-) create mode 100644 lib/src/ui/components/accordion/accordion.dart create mode 100644 lib/src/ui/components/accordion/accordion.preview.dart create mode 100644 lib/src/ui/components/accordion/accordion.recipe.dart create mode 100644 lib/src/ui/components/accordion/index.dart create mode 100644 lib/src/ui/components/badge/badge.dart create mode 100644 lib/src/ui/components/badge/badge.preview.dart create mode 100644 lib/src/ui/components/badge/badge.recipe.dart create mode 100644 lib/src/ui/components/badge/index.dart create mode 100644 lib/src/ui/components/bottom_sheet/bottom_sheet.dart create mode 100644 lib/src/ui/components/bottom_sheet/bottom_sheet.preview.dart create mode 100644 lib/src/ui/components/bottom_sheet/bottom_sheet.recipe.dart create mode 100644 lib/src/ui/components/bottom_sheet/index.dart create mode 100644 lib/src/ui/components/button/button.dart create mode 100644 lib/src/ui/components/button/button.preview.dart create mode 100644 lib/src/ui/components/button/button.recipe.dart create mode 100644 lib/src/ui/components/button/index.dart create mode 100644 lib/src/ui/components/checkbox/checkbox.dart create mode 100644 lib/src/ui/components/checkbox/checkbox.preview.dart create mode 100644 lib/src/ui/components/checkbox/checkbox.recipe.dart create mode 100644 lib/src/ui/components/checkbox/index.dart create mode 100644 lib/src/ui/components/combobox/combobox.dart create mode 100644 lib/src/ui/components/combobox/combobox.preview.dart create mode 100644 lib/src/ui/components/combobox/combobox.recipe.dart create mode 100644 lib/src/ui/components/combobox/index.dart create mode 100644 lib/src/ui/components/confirm_dialog/confirm_dialog.dart create mode 100644 lib/src/ui/components/confirm_dialog/confirm_dialog.preview.dart create mode 100644 lib/src/ui/components/confirm_dialog/confirm_dialog.recipe.dart create mode 100644 lib/src/ui/components/confirm_dialog/index.dart create mode 100644 lib/src/ui/components/dialog/dialog.dart create mode 100644 lib/src/ui/components/dialog/dialog.preview.dart create mode 100644 lib/src/ui/components/dialog/dialog.recipe.dart create mode 100644 lib/src/ui/components/dialog/index.dart create mode 100644 lib/src/ui/components/dropdown_menu/dropdown_menu.dart create mode 100644 lib/src/ui/components/dropdown_menu/dropdown_menu.preview.dart create mode 100644 lib/src/ui/components/dropdown_menu/dropdown_menu.recipe.dart create mode 100644 lib/src/ui/components/dropdown_menu/index.dart create mode 100644 lib/src/ui/components/input/index.dart create mode 100644 lib/src/ui/components/input/input.dart create mode 100644 lib/src/ui/components/input/input.preview.dart create mode 100644 lib/src/ui/components/input/input.recipe.dart create mode 100644 lib/src/ui/components/radio/index.dart create mode 100644 lib/src/ui/components/radio/radio.dart create mode 100644 lib/src/ui/components/radio/radio.preview.dart create mode 100644 lib/src/ui/components/radio/radio.recipe.dart create mode 100644 lib/src/ui/components/segmented_control/index.dart create mode 100644 lib/src/ui/components/segmented_control/segmented_control.dart create mode 100644 lib/src/ui/components/segmented_control/segmented_control.preview.dart create mode 100644 lib/src/ui/components/segmented_control/segmented_control.recipe.dart create mode 100644 lib/src/ui/components/select/index.dart create mode 100644 lib/src/ui/components/select/select.dart create mode 100644 lib/src/ui/components/select/select.preview.dart create mode 100644 lib/src/ui/components/select/select.recipe.dart create mode 100644 lib/src/ui/components/skeleton/index.dart create mode 100644 lib/src/ui/components/skeleton/skeleton.dart create mode 100644 lib/src/ui/components/skeleton/skeleton.preview.dart create mode 100644 lib/src/ui/components/skeleton/skeleton.recipe.dart create mode 100644 lib/src/ui/components/switch/index.dart create mode 100644 lib/src/ui/components/switch/switch.dart create mode 100644 lib/src/ui/components/switch/switch.preview.dart create mode 100644 lib/src/ui/components/switch/switch.recipe.dart create mode 100644 lib/src/ui/components/tabs/index.dart create mode 100644 lib/src/ui/components/tabs/tabs.dart create mode 100644 lib/src/ui/components/tabs/tabs.preview.dart create mode 100644 lib/src/ui/components/tabs/tabs.recipe.dart create mode 100644 lib/src/ui/components/textarea/index.dart create mode 100644 lib/src/ui/components/textarea/textarea.dart create mode 100644 lib/src/ui/components/textarea/textarea.preview.dart create mode 100644 lib/src/ui/components/textarea/textarea.recipe.dart create mode 100644 lib/src/ui/components/toast/index.dart create mode 100644 lib/src/ui/components/toast/toast.dart create mode 100644 lib/src/ui/components/toast/toast.preview.dart create mode 100644 lib/src/ui/components/toast/toast.recipe.dart create mode 100644 lib/src/ui/components/tooltip/index.dart create mode 100644 lib/src/ui/components/tooltip/tooltip.dart create mode 100644 lib/src/ui/components/tooltip/tooltip.preview.dart create mode 100644 lib/src/ui/components/tooltip/tooltip.recipe.dart create mode 100644 lib/src/ui/components/typography/index.dart create mode 100644 lib/src/ui/components/typography/typography.dart create mode 100644 lib/src/ui/components/typography/typography.preview.dart create mode 100644 lib/src/ui/components/typography/typography.recipe.dart create mode 100644 test/ui/components/accordion/accordion_test.dart create mode 100644 test/ui/components/badge/badge_test.dart create mode 100644 test/ui/components/bottom_sheet/bottom_sheet_test.dart create mode 100644 test/ui/components/button/button_test.dart create mode 100644 test/ui/components/checkbox/checkbox_test.dart create mode 100644 test/ui/components/combobox/combobox_test.dart create mode 100644 test/ui/components/confirm_dialog/confirm_dialog_test.dart create mode 100644 test/ui/components/dialog/dialog_test.dart create mode 100644 test/ui/components/dropdown_menu/dropdown_menu_test.dart create mode 100644 test/ui/components/input/input_test.dart create mode 100644 test/ui/components/radio/radio_test.dart create mode 100644 test/ui/components/segmented_control/segmented_control_test.dart create mode 100644 test/ui/components/select/select_test.dart create mode 100644 test/ui/components/skeleton/skeleton_test.dart create mode 100644 test/ui/components/switch/switch_test.dart create mode 100644 test/ui/components/tabs/tabs_test.dart create mode 100644 test/ui/components/textarea/textarea_test.dart create mode 100644 test/ui/components/toast/toast_test.dart create mode 100644 test/ui/components/tooltip/tooltip_test.dart create mode 100644 test/ui/components/typography/typography_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 5998b1e..a10eb27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added +- **Wave 4 design-system component library**: 23 new generic UI components are now part of the public barrel (`package:magic_starter/magic_starter.dart`). Each component lives in the canonical atomic-component folder shape (`.dart`, `.recipe.dart`, `.preview.dart`, `index.dart`) under `lib/src/ui/components/`. + - **Form controls**: `Button` (with `ButtonIntent`, `ButtonSize`, `buttonRecipe`), `Input` (with `InputState`, `inputRecipe`), `Textarea` (with `TextareaState`, `textareaRecipe`), `Checkbox`, `Switch`, `Radio`, `Select` (with `selectRecipe`), `Combobox` (with `comboboxRecipe`). + - **Feedback and display**: `Badge` (with `BadgeTone`), `Typography` (with `TypographyVariant`), `Skeleton` (with `SkeletonShape`), `Toast` (with `ToastVariant`), `Tooltip`, `EmptyState`, `ErrorState`. + - **Layout and navigation**: `Accordion` (with `AccordionItem`, `accordionRecipe`), `SegmentedControl` (with `SegmentedControlSize`, `segmentedControlRecipe`), `Tabs` (with `tabsRecipe`), `Navbar`, `Dropdown` menu (`DropdownMenu`, `DropdownMenuItem`). + - **Overlay**: `Dialog`, `BottomSheet`. + - **Composition**: `MagicFormField` (label, hint, error wrapper). + - Previously migrated components (`Card`, `PageHeader`, `SocialDivider`, `NotificationDropdown`, `UserProfileDropdown`, `TeamSelector`, `ConfirmDialog`) were already barrel-reachable through their existing alias exports and are unchanged. + - **Breaking collision note**: the barrel now exports `Switch`, `Dialog`, `Checkbox`, `Radio`, `Badge`, `Typography`, `BottomSheet`, `Tooltip`, `DropdownMenu`, and `DropdownMenuItem`. Tests and app code that import both `package:flutter/material.dart` and `package:magic_starter/magic_starter.dart` must add a `hide` clause on the material import (or the barrel import) to resolve the ambiguity. + ### Changed - **Card migrated to the atomic-component folder + `WindRecipe`**: the card now lives at `lib/src/ui/components/card/` in the canonical 4-file shape (`card.dart` → `class Card`, `card.recipe.dart`, `card.preview.dart`, `index.dart`) and resolves its root className through a theme-driven `WindRecipe` instead of inline string interpolation. The recipe output is byte-identical to the previous `_defaultClassName` for every `CardVariant` x `noPadding` combination (gated by an explicit equivalence test). `MagicStarterCard` is retained as a thin re-export alias of `Card`, and `CardVariant` plus the barrel export path (`package:magic_starter/magic_starter.dart`) are unchanged, so existing callers and the widget-test suite are untouched. This establishes the verbatim template for the Wave 4 component migration. diff --git a/lib/magic_starter.dart b/lib/magic_starter.dart index 96451ca..57810e9 100644 --- a/lib/magic_starter.dart +++ b/lib/magic_starter.dart @@ -50,3 +50,31 @@ export 'src/ui/widgets/magic_starter_page_header.dart'; export 'src/ui/widgets/magic_starter_dialog_shell.dart'; export 'src/ui/widgets/magic_starter_hide_bottom_nav.dart'; export 'src/ui/views/teams/magic_starter_team_invitation_accept_view.dart'; + +// Design-system components (Wave 4 atomic-component library). +// Migrated components (card, page_header, social_divider, notification_dropdown, +// user_profile_dropdown, team_selector, confirm_dialog) are already reachable +// through their existing alias exports above and are intentionally excluded here. +export 'src/ui/components/button/index.dart'; +export 'src/ui/components/input/index.dart'; +export 'src/ui/components/textarea/index.dart'; +export 'src/ui/components/checkbox/index.dart'; +export 'src/ui/components/switch/index.dart'; +export 'src/ui/components/radio/index.dart'; +export 'src/ui/components/badge/index.dart'; +export 'src/ui/components/typography/index.dart'; +export 'src/ui/components/skeleton/index.dart'; +export 'src/ui/components/select/index.dart'; +export 'src/ui/components/combobox/index.dart'; +export 'src/ui/components/segmented_control/index.dart'; +export 'src/ui/components/tabs/index.dart'; +export 'src/ui/components/accordion/index.dart'; +export 'src/ui/components/dialog/index.dart'; +export 'src/ui/components/bottom_sheet/index.dart'; +export 'src/ui/components/toast/index.dart'; +export 'src/ui/components/tooltip/index.dart'; +export 'src/ui/components/dropdown_menu/index.dart'; +export 'src/ui/components/form_field/index.dart'; +export 'src/ui/components/navbar/index.dart'; +export 'src/ui/components/empty_state/index.dart'; +export 'src/ui/components/error_state/index.dart'; diff --git a/lib/src/ui/components/accordion/accordion.dart b/lib/src/ui/components/accordion/accordion.dart new file mode 100644 index 0000000..2cd118e --- /dev/null +++ b/lib/src/ui/components/accordion/accordion.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'accordion.recipe.dart'; + +// Icon for collapsed/expanded state — extracted as static const for web tree-shaking. +const _kIconExpanded = Icons.keyboard_arrow_up; +const _kIconCollapsed = Icons.keyboard_arrow_down; + +/// A single item within an [Accordion]. +/// +/// Carries the [title] shown in the trigger row and the [body] widget displayed +/// in the collapsible panel. +@immutable +class AccordionItem { + /// The title text displayed in the trigger row. + final String title; + + /// The widget rendered inside the collapsible panel. + final Widget body; + + /// Creates an [AccordionItem]. + const AccordionItem({ + required this.title, + required this.body, + }); +} + +/// A recipe-driven accordion component for Magic Starter. +/// +/// Renders a vertical stack of collapsible items. Each item has a trigger +/// header and a collapsible panel. Only one item can be expanded at a time +/// (single-open). Expanding an already-open item collapses it. +/// +/// ### Example Usage: +/// +/// ```dart +/// Accordion( +/// items: [ +/// AccordionItem( +/// title: 'What is Magic Starter?', +/// body: WText('A Flutter starter kit built on the Magic framework.'), +/// ), +/// AccordionItem( +/// title: 'What features are included?', +/// body: WText('13 opt-in features including auth, teams, and notifications.'), +/// ), +/// ], +/// ) +/// ``` +class Accordion extends StatefulWidget { + /// The list of accordion items to render. + final List items; + + /// Per-slot className overrides appended after the recipe output. + final Map? classNames; + + /// Creates an [Accordion] widget. + const Accordion({ + super.key, + required this.items, + this.classNames, + }); + + @override + State createState() => _AccordionState(); +} + +class _AccordionState extends State { + /// Index of the currently expanded item, or `-1` when all are collapsed. + int _expandedIndex = -1; + + @override + Widget build(BuildContext context) { + // 1. Resolve slot classNames from the recipe. + final slots = accordionRecipe(classNames: widget.classNames); + + // 2. Build the root container. + // LayoutBuilder provides a bounded width so child items can use + // flex-row safely. This mirrors how Card handles full-bleed content. + return WDiv( + className: slots['root'], + child: LayoutBuilder( + builder: (context, constraints) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: List.generate( + widget.items.length, + (index) => _buildItem(index, slots), + ), + ); + }, + ), + ); + } + + /// Builds a single accordion item at [index]. + Widget _buildItem(int index, Map slots) { + final bool isExpanded = _expandedIndex == index; + final AccordionItem item = widget.items[index]; + + return WDiv( + className: slots['item'], + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + // Header row — tappable trigger. + _buildTrigger(index, isExpanded, item.title, slots), + // Collapsible panel — only rendered when this item is expanded. + if (isExpanded) + WDiv( + className: slots['panel'], + child: item.body, + ), + ], + ), + ); + } + + /// Builds the tappable trigger row that toggles [index]'s expansion state. + Widget _buildTrigger( + int index, + bool isExpanded, + String title, + Map slots, + ) { + return WAnchor( + onTap: () { + setState(() { + // Toggle: collapse if already open, expand otherwise. + _expandedIndex = isExpanded ? -1 : index; + }); + }, + child: WDiv( + className: slots['trigger'], + children: [ + WDiv( + className: 'flex-1', + child: WText(title), + ), + Icon( + isExpanded ? _kIconExpanded : _kIconCollapsed, + size: 18, + ), + ], + ), + ); + } +} diff --git a/lib/src/ui/components/accordion/accordion.preview.dart b/lib/src/ui/components/accordion/accordion.preview.dart new file mode 100644 index 0000000..2cd4f00 --- /dev/null +++ b/lib/src/ui/components/accordion/accordion.preview.dart @@ -0,0 +1,54 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'accordion.dart'; + +/// Static variant-matrix preview for [Accordion]. +/// +/// Renders a three-item accordion so the catalog can exercise the expand/ +/// collapse interaction and light/dark themes. One preview class per file is +/// the canonical Wave 4 contract. +class AccordionPreview extends StatelessWidget { + /// Creates the accordion variant-matrix preview. + const AccordionPreview({super.key}); + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex flex-col gap-6 p-6', + children: [ + WText( + 'Accordion — default', + className: 'text-sm font-medium text-fg-muted', + ), + const Accordion( + items: [ + AccordionItem( + title: 'What is Magic Starter?', + body: WText( + 'A Flutter starter kit built on the Magic framework, ' + 'providing 13 opt-in features out of the box.', + ), + ), + AccordionItem( + title: 'What features are included?', + body: WText( + 'Auth, registration, 2FA, profile, profile photos, teams, ' + 'sessions, notifications, email verification, social login, ' + 'phone OTP, and guest auth.', + ), + ), + AccordionItem( + title: 'How do I customise views?', + body: WText( + 'Use the view registry: MagicStarter.view.register() or ' + 'MagicStarter.view.slot() to override any view or insert ' + 'content into named slots.', + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/src/ui/components/accordion/accordion.recipe.dart b/lib/src/ui/components/accordion/accordion.recipe.dart new file mode 100644 index 0000000..944053e --- /dev/null +++ b/lib/src/ui/components/accordion/accordion.recipe.dart @@ -0,0 +1,29 @@ +import 'package:magic/magic.dart'; + +/// Builds the slot recipe for [Accordion] from semantic tokens. +/// +/// Slots: +/// - `root` — outer container holding all accordion items. +/// - `item` — each individual accordion item (wrapper for header+panel). +/// - `header` — the header row containing [trigger]. +/// - `trigger` — the tappable title row (WAnchor-backed in the component). +/// - `panel` — the collapsible content area, shown only when expanded. +/// +/// There are no variant axes in v1; the slot classNames come entirely from +/// semantic tokens. Callers may override individual slots via [classNames]. +Map accordionRecipe({ + Map? variants, + Map? classNames, +}) { + const recipe = WindSlotRecipe( + slots: { + 'root': 'w-full border border-color-border rounded-lg overflow-hidden', + 'item': 'bg-surface', + 'header': 'flex flex-row items-center', + 'trigger': + 'flex flex-row items-center justify-between px-4 py-3 text-sm font-medium text-fg cursor-pointer hover:bg-surface-container', + 'panel': 'px-4 pb-4 text-sm text-fg-muted', + }, + ); + return recipe(variants: variants, classNames: classNames); +} diff --git a/lib/src/ui/components/accordion/index.dart b/lib/src/ui/components/accordion/index.dart new file mode 100644 index 0000000..f9f6d62 --- /dev/null +++ b/lib/src/ui/components/accordion/index.dart @@ -0,0 +1,8 @@ +// Accordion component — folder-local barrel. +// +// Canonical Wave 4 atomic-component shape. The preview is intentionally NOT +// re-exported here — `previews:refresh` discovers `*.preview.dart` files +// directly, and the preview must stay out of the release barrel. + +export 'accordion.dart' show Accordion, AccordionItem; +export 'accordion.recipe.dart' show accordionRecipe; diff --git a/lib/src/ui/components/badge/badge.dart b/lib/src/ui/components/badge/badge.dart new file mode 100644 index 0000000..03cc32c --- /dev/null +++ b/lib/src/ui/components/badge/badge.dart @@ -0,0 +1,90 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'badge.recipe.dart'; + +/// Visual tone variants for [Badge]. +/// +/// Each tone maps to a semantic token pair (background + foreground) defined +/// in [MagicStarterTokens.defaultAliases] so the badge re-skins automatically +/// when the consumer supplies a custom alias map. +/// +/// - [neutral] — Muted surface fill; low-emphasis label. +/// - [primary] — Brand-colored fill; high-emphasis status. +/// - [accent] — Accent-colored fill; secondary emphasis. +/// - [success] — Green fill; positive / confirmed state. +/// - [warning] — Amber fill; caution / pending state. +/// - [destructive] — Red fill; error / blocked state. +/// - [outline] — Transparent with a visible border; low-prominence label. +enum BadgeTone { + /// Muted surface: low-emphasis label. + neutral, + + /// Brand-primary fill: high-emphasis status. + primary, + + /// Accent fill: secondary emphasis. + accent, + + /// Green fill: positive / confirmed state. + success, + + /// Amber fill: caution / pending state. + warning, + + /// Red fill: error / blocked state. + destructive, + + /// Transparent background with border: low-prominence label. + outline, +} + +/// A small inline status label built on [WBadge]. +/// +/// Uses a [WindRecipe] (`badge.recipe.dart`) to emit semantic token class +/// names for each [tone]; no raw hex or `Colors.*` anywhere. +/// +/// ```dart +/// Badge('Active', tone: BadgeTone.success) +/// Badge('Error', tone: BadgeTone.destructive) +/// Badge('Pending') // defaults to BadgeTone.neutral +/// ``` +@immutable +class Badge extends StatelessWidget { + /// The label text displayed inside the badge. + final String label; + + /// The visual tone controlling background and text color. + /// + /// Defaults to [BadgeTone.neutral]. + final BadgeTone tone; + + /// Optional className override; bypasses the recipe entirely when supplied. + final String? className; + + /// Creates a [Badge]. + const Badge( + this.label, { + super.key, + this.tone = BadgeTone.neutral, + this.className, + }); + + /// Resolves the className from the recipe or the caller override. + String _resolveClassName() { + if (className != null) { + return className!; + } + return badgeRecipe( + variants: {kBadgeToneAxis: tone.name}, + ); + } + + @override + Widget build(BuildContext context) { + return WBadge( + label, + className: _resolveClassName(), + ); + } +} diff --git a/lib/src/ui/components/badge/badge.preview.dart b/lib/src/ui/components/badge/badge.preview.dart new file mode 100644 index 0000000..ff36f3c --- /dev/null +++ b/lib/src/ui/components/badge/badge.preview.dart @@ -0,0 +1,34 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'badge.dart'; + +/// Static variant-matrix preview for [Badge]. +/// +/// Renders every [BadgeTone] in a column so the catalog can show the full +/// surface in light and dark. One preview class per file is the canonical +/// Wave 4 contract. +class BadgePreview extends StatelessWidget { + /// Creates the badge variant-matrix preview. + const BadgePreview({super.key}); + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex flex-col gap-3 p-6', + children: [ + for (final tone in BadgeTone.values) + WDiv( + className: 'flex flex-row items-center gap-3', + children: [ + Badge(tone.name, tone: tone), + WText( + tone.name, + className: 'text-sm text-fg-muted', + ), + ], + ), + ], + ); + } +} diff --git a/lib/src/ui/components/badge/badge.recipe.dart b/lib/src/ui/components/badge/badge.recipe.dart new file mode 100644 index 0000000..17ab6b7 --- /dev/null +++ b/lib/src/ui/components/badge/badge.recipe.dart @@ -0,0 +1,38 @@ +import 'package:magic/magic.dart'; + +/// The tone axis key for the badge recipe (`BadgeTone..name`). +const String kBadgeToneAxis = 'tone'; + +/// Builds the badge [WindRecipe] using semantic tokens from [MagicStarterTokens]. +/// +/// The recipe is a top-level const because the badge has no theme-override hook +/// (no `MagicStarter.useBadgeTheme()`); tones read straight from the semantic +/// alias map. +/// +/// Emission order: `base ++ tone-variant`. +/// +/// Tone -> semantic token mapping: +/// - neutral: `bg-surface-container-high text-fg` +/// - primary: `bg-primary text-on-primary` +/// - accent: `bg-accent text-on-primary` +/// - success: `bg-success text-on-destructive` (white on green, same as on-destructive) +/// - warning: `bg-warning text-on-destructive` (white on amber) +/// - destructive: `bg-destructive text-on-destructive` +/// - outline: border only via `border border-color-border text-fg` (no fill) +const WindRecipe badgeRecipe = WindRecipe( + base: 'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium', + variants: { + kBadgeToneAxis: { + 'neutral': 'bg-surface-container-high text-fg', + 'primary': 'bg-primary text-on-primary', + 'accent': 'bg-accent text-on-primary', + 'success': 'bg-success text-on-destructive', + 'warning': 'bg-warning text-on-destructive', + 'destructive': 'bg-destructive text-on-destructive', + 'outline': 'border border-color-border text-fg', + }, + }, + defaultVariants: { + kBadgeToneAxis: 'neutral', + }, +); diff --git a/lib/src/ui/components/badge/index.dart b/lib/src/ui/components/badge/index.dart new file mode 100644 index 0000000..3bf93dd --- /dev/null +++ b/lib/src/ui/components/badge/index.dart @@ -0,0 +1,10 @@ +// Badge component — folder-local barrel. +// +// Canonical Wave 4 atomic-component shape: the recipe, the component, and the +// preview each live in their own dotted-suffix file; this index re-exports the +// public surface (component + tone enum). The preview is intentionally NOT +// re-exported here — `previews:refresh` discovers `*.preview.dart` files +// directly, and the preview must stay out of the release barrel. + +export 'badge.dart' show Badge, BadgeTone; +export 'badge.recipe.dart'; diff --git a/lib/src/ui/components/bottom_sheet/bottom_sheet.dart b/lib/src/ui/components/bottom_sheet/bottom_sheet.dart new file mode 100644 index 0000000..0ff98a6 --- /dev/null +++ b/lib/src/ui/components/bottom_sheet/bottom_sheet.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart' as m show Colors, showModalBottomSheet; +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import '../../../facades/magic_starter.dart'; + +/// A reusable bottom-sheet component with Wind UI chrome driven by +/// `MagicStarter.manager.modalTheme` tokens. +/// +/// Provides an optional header (title + description), a scrollable body, and +/// an optional sticky footer. The bottom sheet slides up from the screen edge +/// with a rounded top radius. +/// +/// All classNames are read from the modal theme at build time; override via +/// `MagicStarter.useModalTheme()` before the first bottom sheet is shown. +/// +/// ### Example +/// ```dart +/// await BottomSheet.show( +/// context, +/// title: 'Select action', +/// body: Column(children: [...]), +/// ); +/// ``` +@immutable +class BottomSheet extends StatelessWidget { + /// Optional heading rendered at the top of the sheet. + final String? title; + + /// Optional sub-heading rendered below [title]. + final String? description; + + /// Content widget rendered in the scrollable body area. + final Widget body; + + /// Optional builder for the sticky footer; receives the sheet's own + /// [BuildContext]. + final Widget Function(BuildContext sheetContext)? footerBuilder; + + /// Creates a [BottomSheet]. + const BottomSheet({ + super.key, + this.title, + this.description, + required this.body, + this.footerBuilder, + }); + + /// Opens the bottom sheet and resolves when it is dismissed. + static Future show( + BuildContext context, { + String? title, + String? description, + required Widget body, + Widget Function(BuildContext sheetContext)? footerBuilder, + }) { + return m.showModalBottomSheet( + context: context, + backgroundColor: m.Colors.transparent, + isScrollControlled: true, + builder: (_) => BottomSheet( + title: title, + description: description, + body: body, + footerBuilder: footerBuilder, + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = MagicStarter.manager.modalTheme; + + // 1. Constrain sheet height to 85% of screen minus safe area. + final viewPadding = MediaQuery.viewPaddingOf(context); + final maxHeight = (MediaQuery.sizeOf(context).height - + viewPadding.top - + viewPadding.bottom) * + 0.85; + + // 2. Build the sheet panel with rounded top corners. + return ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxHeight), + child: WDiv( + className: + '${theme.containerClassName} w-full overflow-hidden rounded-t-2xl rounded-b-none', + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 3. Drag handle indicator. + Center( + child: WDiv( + className: + 'w-9 h-1 bg-gray-300 dark:bg-gray-600 rounded-full mt-3 mb-1', + ), + ), + // 4. Header section: title + description. + if (title != null || description != null) + WDiv( + className: theme.headerClassName, + children: [ + if (title != null) + WText(title!, className: theme.titleClassName), + if (description != null) + WText(description!, className: theme.descriptionClassName), + ], + ), + // 5. Scrollable body. + Flexible( + child: ListView( + shrinkWrap: true, + padding: EdgeInsets.zero, + children: [ + WDiv( + className: theme.bodyClassName, + child: body, + ), + ], + ), + ), + // 6. Sticky footer. + if (footerBuilder != null) + Builder( + builder: (sheetContext) => WDiv( + key: const Key('bottom_sheet_footer'), + className: theme.footerClassName, + child: footerBuilder!(sheetContext), + ), + ), + // 7. Bottom safe-area padding. + SizedBox(height: viewPadding.bottom), + ], + ), + ), + ); + } +} diff --git a/lib/src/ui/components/bottom_sheet/bottom_sheet.preview.dart b/lib/src/ui/components/bottom_sheet/bottom_sheet.preview.dart new file mode 100644 index 0000000..78e3b84 --- /dev/null +++ b/lib/src/ui/components/bottom_sheet/bottom_sheet.preview.dart @@ -0,0 +1,52 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'bottom_sheet.dart'; + +/// Static preview for the [BottomSheet] component. +/// +/// Renders the sheet panel inline (not via showModalBottomSheet) so the +/// preview catalog can display it in light and dark. One preview class per +/// file is the canonical Wave 4 contract. +class BottomSheetPreview extends StatelessWidget { + /// Creates the bottom sheet preview. + const BottomSheetPreview({super.key}); + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex flex-col gap-6 p-6', + children: [ + // Sheet with title + description + footer. + BottomSheet( + title: 'Select an option', + description: 'Choose the action you want to perform.', + body: WDiv( + className: 'flex flex-col gap-3', + children: const [ + WText('Option A', className: 'text-sm'), + WText('Option B', className: 'text-sm'), + WText('Option C', className: 'text-sm'), + ], + ), + footerBuilder: (_) => WDiv( + className: 'flex flex-row justify-end gap-2 wrap', + children: [ + WDiv( + className: 'px-4 py-2 rounded-lg bg-primary text-white text-sm', + child: const WText('Confirm'), + ), + ], + ), + ), + // Sheet body only. + const BottomSheet( + body: WText( + 'A minimal bottom sheet with no title or footer.', + className: 'text-sm', + ), + ), + ], + ); + } +} diff --git a/lib/src/ui/components/bottom_sheet/bottom_sheet.recipe.dart b/lib/src/ui/components/bottom_sheet/bottom_sheet.recipe.dart new file mode 100644 index 0000000..221b703 --- /dev/null +++ b/lib/src/ui/components/bottom_sheet/bottom_sheet.recipe.dart @@ -0,0 +1,30 @@ +import 'package:magic/magic.dart'; + +import '../../../configuration/magic_starter_theme.dart'; + +/// Slot keys for the [BottomSheet] component's Wind slot recipe. +const String kBottomSheetSlotPanel = 'panel'; +const String kBottomSheetSlotHeader = 'header'; +const String kBottomSheetSlotTitle = 'title'; +const String kBottomSheetSlotDescription = 'description'; +const String kBottomSheetSlotBody = 'body'; +const String kBottomSheetSlotFooter = 'footer'; + +/// Builds the bottom-sheet [WindSlotRecipe] from a [MagicStarterModalTheme]. +/// +/// Returns per-slot className strings that map to modal theme fields. The +/// recipe is theme-driven (not a top-level const) so theme overrides work +/// in tests and runtime customisation. +WindSlotRecipe buildBottomSheetSlotRecipe(MagicStarterModalTheme theme) { + return WindSlotRecipe( + slots: { + kBottomSheetSlotPanel: + '${theme.containerClassName} w-full rounded-t-2xl rounded-b-none overflow-hidden', + kBottomSheetSlotHeader: theme.headerClassName, + kBottomSheetSlotTitle: theme.titleClassName, + kBottomSheetSlotDescription: theme.descriptionClassName, + kBottomSheetSlotBody: theme.bodyClassName, + kBottomSheetSlotFooter: theme.footerClassName, + }, + ); +} diff --git a/lib/src/ui/components/bottom_sheet/index.dart b/lib/src/ui/components/bottom_sheet/index.dart new file mode 100644 index 0000000..b5d258d --- /dev/null +++ b/lib/src/ui/components/bottom_sheet/index.dart @@ -0,0 +1,7 @@ +// BottomSheet component — folder-local barrel. +// +// Canonical Wave 4 atomic-component shape. The preview is NOT re-exported; +// `previews:refresh` discovers `*.preview.dart` files directly. + +export 'bottom_sheet.dart' show BottomSheet; +export 'bottom_sheet.recipe.dart'; diff --git a/lib/src/ui/components/button/button.dart b/lib/src/ui/components/button/button.dart new file mode 100644 index 0000000..3aa1668 --- /dev/null +++ b/lib/src/ui/components/button/button.dart @@ -0,0 +1,96 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'button.recipe.dart'; + +/// A reusable button component for Magic Starter views. +/// +/// Composes [WButton] with a [WindRecipe] to provide consistent styling across +/// all intents and sizes. Semantic tokens (Step 7) drive colors so a +/// `DESIGN.md` override re-skins every button without touching this file. +/// +/// ### Variant styles +/// +/// ```dart +/// Button( +/// intent: ButtonIntent.destructive, +/// size: ButtonSize.lg, +/// onPressed: _deleteAccount, +/// child: const WText('Delete account'), +/// ) +/// ``` +/// +/// ### Loading state +/// +/// ```dart +/// Button( +/// onPressed: _submit, +/// isLoading: controller.isLoading, +/// child: const WText('Submit'), +/// ) +/// ``` +@immutable +class Button extends StatelessWidget { + /// The button content. + final Widget child; + + /// Called when the button is tapped and not loading/disabled. + final VoidCallback? onPressed; + + /// Visual intent of the button. + final ButtonIntent intent; + + /// Size of the button. + final ButtonSize size; + + /// Whether the button shows a loading spinner. + final bool isLoading; + + /// Whether the button is disabled. + final bool disabled; + + /// Optional className override that bypasses the recipe entirely. + final String? className; + + /// An explicit accessible label for icon-only buttons. + final String? semanticLabel; + + /// Creates a [Button]. + const Button({ + super.key, + required this.child, + this.onPressed, + this.intent = ButtonIntent.primary, + this.size = ButtonSize.md, + this.isLoading = false, + this.disabled = false, + this.className, + this.semanticLabel, + }); + + /// Resolves the className from the recipe or the caller override. + String _resolveClassName() { + if (className != null) { + return className!; + } + + return buttonRecipe( + variants: { + kButtonIntentAxis: intent.name, + kButtonSizeAxis: size.name, + }, + ); + } + + @override + Widget build(BuildContext context) { + return WButton( + onTap: onPressed, + isLoading: isLoading, + disabled: disabled, + className: _resolveClassName(), + semanticLabel: semanticLabel, + child: child, + ); + } +} diff --git a/lib/src/ui/components/button/button.preview.dart b/lib/src/ui/components/button/button.preview.dart new file mode 100644 index 0000000..16fd731 --- /dev/null +++ b/lib/src/ui/components/button/button.preview.dart @@ -0,0 +1,46 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'button.dart'; +import 'button.recipe.dart'; + +/// Static variant-matrix preview for [Button]. +/// +/// Renders every [ButtonIntent] x [ButtonSize] combination in a scrollable +/// column so the catalog (`/preview`) can display the full surface in both +/// light and dark modes. +class ButtonPreview extends StatelessWidget { + /// Creates the button variant-matrix preview. + const ButtonPreview({super.key}); + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex flex-col gap-6 p-6', + children: [ + for (final intent in ButtonIntent.values) + WDiv( + className: 'flex flex-col gap-3', + children: [ + WText( + intent.name, + className: 'text-sm font-semibold text-fg-muted', + ), + WDiv( + className: 'flex flex-row gap-3', + children: [ + for (final size in ButtonSize.values) + Button( + intent: intent, + size: size, + onPressed: () {}, + child: WText(size.name), + ), + ], + ), + ], + ), + ], + ); + } +} diff --git a/lib/src/ui/components/button/button.recipe.dart b/lib/src/ui/components/button/button.recipe.dart new file mode 100644 index 0000000..6eb41da --- /dev/null +++ b/lib/src/ui/components/button/button.recipe.dart @@ -0,0 +1,74 @@ +import 'package:magic/magic.dart'; + +/// The intent axis key for the button recipe. +const String kButtonIntentAxis = 'intent'; + +/// The size axis key for the button recipe. +const String kButtonSizeAxis = 'size'; + +/// Visual intent variants for [Button]. +/// +/// - [primary] — Brand-colored filled button for the main call-to-action. +/// - [secondary] — Neutral surface button for secondary actions. +/// - [ghost] — Transparent button for low-emphasis actions. +/// - [destructive] — Red filled button for dangerous or irreversible actions. +enum ButtonIntent { + /// Main call-to-action: brand-primary background. + primary, + + /// Secondary action: neutral surface background. + secondary, + + /// Low-emphasis action: transparent background. + ghost, + + /// Dangerous action: destructive red background. + destructive, +} + +/// Size variants for [Button]. +/// +/// - [sm] — Compact button for toolbars and dense layouts. +/// - [md] — Default button size for most contexts. +/// - [lg] — Larger button for prominent hero actions. +enum ButtonSize { + /// Compact button: smaller padding and text. + sm, + + /// Default button: standard padding and text. + md, + + /// Large button: generous padding and larger text. + lg, +} + +/// The button [WindRecipe] (const — no theme override hook needed for this +/// component; styling is token-driven via semantic aliases). +/// +/// Emission order: `base ++ intent-classes ++ size-classes ++ compound`. +const WindRecipe buttonRecipe = WindRecipe( + base: 'inline-flex items-center justify-center font-medium rounded-lg ' + 'transition-colors disabled:opacity-50 disabled:cursor-not-allowed ' + 'focus:outline-none focus:ring-2 focus:ring-offset-1', + variants: { + kButtonIntentAxis: { + 'primary': + 'bg-primary text-on-primary hover:opacity-90 focus:ring-color-border', + 'secondary': + 'bg-surface-container-high text-fg border border-color-border ' + 'hover:bg-surface-container-high hover:opacity-80', + 'ghost': 'bg-transparent text-fg hover:bg-surface-container-high', + 'destructive': 'bg-destructive text-on-destructive hover:opacity-90 ' + 'focus:ring-bg-destructive', + }, + kButtonSizeAxis: { + 'sm': 'px-3 py-1.5 text-sm', + 'md': 'px-4 py-2 text-sm', + 'lg': 'px-5 py-3 text-base', + }, + }, + defaultVariants: { + kButtonIntentAxis: 'primary', + kButtonSizeAxis: 'md', + }, +); diff --git a/lib/src/ui/components/button/index.dart b/lib/src/ui/components/button/index.dart new file mode 100644 index 0000000..8d58693 --- /dev/null +++ b/lib/src/ui/components/button/index.dart @@ -0,0 +1,16 @@ +// Button component — folder-local barrel. +// +// Canonical Wave 4 atomic-component shape: the recipe, the component, and the +// preview each live in their own dotted-suffix file; this index re-exports the +// public surface (component + variant enums). The preview is intentionally NOT +// re-exported here — `previews:refresh` discovers `*.preview.dart` directly, +// and the preview must stay out of the release barrel. + +export 'button.dart' show Button; +export 'button.recipe.dart' + show + ButtonIntent, + ButtonSize, + buttonRecipe, + kButtonIntentAxis, + kButtonSizeAxis; diff --git a/lib/src/ui/components/checkbox/checkbox.dart b/lib/src/ui/components/checkbox/checkbox.dart new file mode 100644 index 0000000..c753ae2 --- /dev/null +++ b/lib/src/ui/components/checkbox/checkbox.dart @@ -0,0 +1,60 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'checkbox.recipe.dart'; + +/// A reusable checkbox component for Magic Starter forms. +/// +/// Wraps [WCheckbox] with a [WindRecipe] that applies semantic tokens so the +/// box background and border follow the brand without hardcoded colors. +/// +/// ### Usage +/// +/// ```dart +/// Checkbox( +/// value: _accepted, +/// onChanged: (v) => setState(() => _accepted = v), +/// ) +/// ``` +@immutable +class Checkbox extends StatelessWidget { + /// Whether the checkbox is checked. + final bool value; + + /// Called when the checkbox value changes. + final ValueChanged? onChanged; + + /// Whether the checkbox is disabled. + final bool disabled; + + /// Optional className override that bypasses the recipe entirely. + final String? className; + + /// Creates a [Checkbox]. + const Checkbox({ + super.key, + required this.value, + this.onChanged, + this.disabled = false, + this.className, + }); + + /// Resolves the className from the recipe or the caller override. + String _resolveClassName() { + if (className != null) { + return className!; + } + + return checkboxRecipe(); + } + + @override + Widget build(BuildContext context) { + return WCheckbox( + value: value, + onChanged: onChanged, + disabled: disabled, + className: _resolveClassName(), + ); + } +} diff --git a/lib/src/ui/components/checkbox/checkbox.preview.dart b/lib/src/ui/components/checkbox/checkbox.preview.dart new file mode 100644 index 0000000..a1fc7e1 --- /dev/null +++ b/lib/src/ui/components/checkbox/checkbox.preview.dart @@ -0,0 +1,54 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'checkbox.dart'; + +/// Static variant-matrix preview for [Checkbox]. +/// +/// Renders checked and unchecked states in both enabled and disabled modes. +class CheckboxPreview extends StatelessWidget { + /// Creates the checkbox variant-matrix preview. + const CheckboxPreview({super.key}); + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex flex-col gap-6 p-6', + children: [ + WDiv( + className: 'flex flex-col gap-3', + children: [ + WText('Enabled', className: 'text-sm font-semibold text-fg-muted'), + WDiv( + className: 'flex flex-row gap-4 items-center', + children: [ + Checkbox(value: false, onChanged: (_) {}), + WText('Unchecked', className: 'text-sm text-fg'), + Checkbox(value: true, onChanged: (_) {}), + WText('Checked', className: 'text-sm text-fg'), + ], + ), + ], + ), + WDiv( + className: 'flex flex-col gap-3', + children: [ + WText( + 'Disabled', + className: 'text-sm font-semibold text-fg-muted', + ), + WDiv( + className: 'flex flex-row gap-4 items-center', + children: [ + const Checkbox(value: false, disabled: true), + WText('Unchecked', className: 'text-sm text-fg-disabled'), + const Checkbox(value: true, disabled: true), + WText('Checked', className: 'text-sm text-fg-disabled'), + ], + ), + ], + ), + ], + ); + } +} diff --git a/lib/src/ui/components/checkbox/checkbox.recipe.dart b/lib/src/ui/components/checkbox/checkbox.recipe.dart new file mode 100644 index 0000000..4c1ec95 --- /dev/null +++ b/lib/src/ui/components/checkbox/checkbox.recipe.dart @@ -0,0 +1,13 @@ +import 'package:magic/magic.dart'; + +/// The checkbox [WindRecipe] (const). +/// +/// Returns the className for the [WCheckbox] outer box. The `checked:` state +/// prefix is driven by [WCheckbox]'s own active-states set; this recipe only +/// needs to supply the base box styling and the semantic token overrides. +const WindRecipe checkboxRecipe = WindRecipe( + base: 'w-5 h-5 rounded border border-color-border ' + 'checked:bg-primary checked:border-bg-primary ' + 'disabled:opacity-50 disabled:cursor-not-allowed ' + 'focus:ring-2 focus:ring-bg-primary', +); diff --git a/lib/src/ui/components/checkbox/index.dart b/lib/src/ui/components/checkbox/index.dart new file mode 100644 index 0000000..40a0587 --- /dev/null +++ b/lib/src/ui/components/checkbox/index.dart @@ -0,0 +1,8 @@ +// Checkbox component — folder-local barrel. +// +// The preview is intentionally NOT re-exported here — `previews:refresh` +// discovers `*.preview.dart` directly, and the preview must stay out of the +// release barrel. + +export 'checkbox.dart' show Checkbox; +export 'checkbox.recipe.dart'; diff --git a/lib/src/ui/components/combobox/combobox.dart b/lib/src/ui/components/combobox/combobox.dart new file mode 100644 index 0000000..f4d9158 --- /dev/null +++ b/lib/src/ui/components/combobox/combobox.dart @@ -0,0 +1,90 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'combobox.recipe.dart'; + +/// A searchable single-select combobox component for Magic Starter. +/// +/// Extends [Select]'s recipe-driven approach with `searchable: true` wired into +/// the underlying [WSelect], so the user can type to filter options. Supports +/// async [onSearch] for remote filtering. +/// +/// ### Example Usage: +/// +/// ```dart +/// Combobox( +/// value: _selected, +/// options: countries, +/// onChange: (v) => setState(() => _selected = v), +/// ) +/// ``` +/// +/// ### Async search: +/// +/// ```dart +/// Combobox( +/// value: _selected, +/// options: _options, +/// onChange: (v) => setState(() => _selected = v), +/// onSearch: (query) => _fetchOptions(query), +/// ) +/// ``` +@immutable +class Combobox extends StatelessWidget { + /// Currently selected value, or `null` when nothing is selected. + final T? value; + + /// The list of available options (initial set; [onSearch] may update it). + final List> options; + + /// Called when the user selects an option. + final ValueChanged? onChange; + + /// Optional async callback for remote option filtering. + final Future>> Function(String query)? onSearch; + + /// Optional placeholder text shown when [value] is `null`. + final String? placeholder; + + /// Optional search field placeholder. + final String? searchPlaceholder; + + /// Whether the combobox is disabled. + final bool disabled; + + /// Per-slot className overrides appended after the recipe output. + final Map? classNames; + + /// Creates a [Combobox] widget. + const Combobox({ + super.key, + required this.options, + this.value, + this.onChange, + this.onSearch, + this.placeholder, + this.searchPlaceholder, + this.disabled = false, + this.classNames, + }); + + @override + Widget build(BuildContext context) { + // 1. Resolve slot classNames from the recipe. + final slots = comboboxRecipe(classNames: classNames); + + // 2. Delegate to WSelect with searchable:true and recipe-driven classNames. + return WSelect( + value: value, + options: options, + onChange: onChange, + placeholder: placeholder ?? 'Search options...', + searchable: true, + searchPlaceholder: searchPlaceholder ?? 'Search...', + onSearch: onSearch, + disabled: disabled, + className: slots['trigger'], + menuClassName: slots['popup'], + ); + } +} diff --git a/lib/src/ui/components/combobox/combobox.preview.dart b/lib/src/ui/components/combobox/combobox.preview.dart new file mode 100644 index 0000000..fb36df1 --- /dev/null +++ b/lib/src/ui/components/combobox/combobox.preview.dart @@ -0,0 +1,51 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'combobox.dart'; + +/// Static variant-matrix preview for [Combobox]. +/// +/// Shows a Combobox in its default and pre-selected states so the catalog can +/// exercise light and dark themes. One preview class per file is the canonical +/// Wave 4 contract. +class ComboboxPreview extends StatelessWidget { + /// Creates the combobox variant-matrix preview. + const ComboboxPreview({super.key}); + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex flex-col gap-6 p-6', + children: [ + WText( + 'Combobox — default (searchable)', + className: 'text-sm font-medium text-fg-muted', + ), + Combobox( + value: null, + options: const [ + SelectOption(value: 'apple', label: 'Apple'), + SelectOption(value: 'banana', label: 'Banana'), + SelectOption(value: 'cherry', label: 'Cherry'), + SelectOption(value: 'date', label: 'Date'), + ], + onChange: (_) {}, + placeholder: 'Search fruit...', + ), + WText( + 'Combobox — pre-selected value', + className: 'text-sm font-medium text-fg-muted', + ), + Combobox( + value: 'banana', + options: const [ + SelectOption(value: 'apple', label: 'Apple'), + SelectOption(value: 'banana', label: 'Banana'), + SelectOption(value: 'cherry', label: 'Cherry'), + ], + onChange: (_) {}, + ), + ], + ); + } +} diff --git a/lib/src/ui/components/combobox/combobox.recipe.dart b/lib/src/ui/components/combobox/combobox.recipe.dart new file mode 100644 index 0000000..c45d754 --- /dev/null +++ b/lib/src/ui/components/combobox/combobox.recipe.dart @@ -0,0 +1,29 @@ +import 'package:magic/magic.dart'; + +/// Builds the slot recipe for [Combobox] from semantic tokens. +/// +/// Shares the same slot shape as [selectRecipe] (trigger/popup/item) but the +/// trigger carries a search-input affordance (slightly different padding) and +/// the popup always includes an inline search field via `WSelect(searchable: +/// true)`. +/// +/// Slots: +/// - `trigger` — the closed-state trigger with search-ready padding. +/// - `popup` — the dropdown overlay container. +/// - `item` — each option row inside the dropdown. +Map comboboxRecipe({ + Map? variants, + Map? classNames, +}) { + const recipe = WindSlotRecipe( + slots: { + 'trigger': + 'w-full px-3 py-2 rounded-lg bg-surface-container-high border border-color-border text-fg flex items-center justify-between gap-2', + 'popup': + 'bg-surface border border-color-border rounded-lg shadow-md overflow-hidden', + 'item': + 'px-3 py-2 text-sm text-fg hover:bg-surface-container cursor-pointer', + }, + ); + return recipe(variants: variants, classNames: classNames); +} diff --git a/lib/src/ui/components/combobox/index.dart b/lib/src/ui/components/combobox/index.dart new file mode 100644 index 0000000..bbcf159 --- /dev/null +++ b/lib/src/ui/components/combobox/index.dart @@ -0,0 +1,8 @@ +// Combobox component — folder-local barrel. +// +// Canonical Wave 4 atomic-component shape. The preview is intentionally NOT +// re-exported here — `previews:refresh` discovers `*.preview.dart` files +// directly, and the preview must stay out of the release barrel. + +export 'combobox.dart' show Combobox; +export 'combobox.recipe.dart' show comboboxRecipe; diff --git a/lib/src/ui/components/confirm_dialog/confirm_dialog.dart b/lib/src/ui/components/confirm_dialog/confirm_dialog.dart new file mode 100644 index 0000000..0a9f411 --- /dev/null +++ b/lib/src/ui/components/confirm_dialog/confirm_dialog.dart @@ -0,0 +1,214 @@ +import 'package:flutter/material.dart' as m show Colors, showDialog, Dialog; +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import '../../../facades/magic_starter.dart'; +import 'confirm_dialog.recipe.dart'; + +/// Visual style variants for [ConfirmDialog]. +/// +/// - [primary] — Default confirmation: primary-colored confirm button. +/// - [danger] — Destructive action: red confirm button. +/// - [warning] — Cautionary action: amber confirm button. +enum ConfirmDialogVariant { + /// Default confirmation with primary-colored button. + primary, + + /// Destructive action with red button. + danger, + + /// Cautionary action with amber button. + warning, +} + +/// A reusable confirm/cancel dialog built on the Material dialog shell. +/// +/// Supports three visual variants ([ConfirmDialogVariant]) and an optional +/// async [onConfirm] callback with double-click protection via `_isLoading`. +/// +/// Returns `true` on confirm, `false` on cancel. +/// +/// ### Example +/// ```dart +/// final confirmed = await ConfirmDialog.show( +/// context, +/// title: 'Delete team?', +/// description: 'This cannot be undone.', +/// variant: ConfirmDialogVariant.danger, +/// onConfirm: () async => controller.deleteTeam(), +/// ); +/// ``` +class ConfirmDialog extends StatefulWidget { + /// Dialog heading text. + final String title; + + /// Optional supporting text rendered below the title. + final String? description; + + /// Label for the confirm button. Defaults to `trans('common.confirm')`. + final String? confirmLabel; + + /// Label for the cancel button. Defaults to `trans('common.cancel')`. + final String? cancelLabel; + + /// Visual variant that controls the confirm button colour. + final ConfirmDialogVariant variant; + + /// Optional async callback invoked when the user taps the confirm button. + final Future Function()? onConfirm; + + /// Creates a [ConfirmDialog]. + const ConfirmDialog({ + super.key, + required this.title, + this.description, + this.confirmLabel, + this.cancelLabel, + this.variant = ConfirmDialogVariant.primary, + this.onConfirm, + }); + + /// Opens the dialog and returns `true` if confirmed, `false` if cancelled. + static Future show( + BuildContext context, { + required String title, + String? description, + String? confirmLabel, + String? cancelLabel, + ConfirmDialogVariant variant = ConfirmDialogVariant.primary, + Future Function()? onConfirm, + }) { + return m + .showDialog( + context: context, + barrierDismissible: false, + builder: (_) => ConfirmDialog( + title: title, + description: description, + confirmLabel: confirmLabel, + cancelLabel: cancelLabel, + variant: variant, + onConfirm: onConfirm, + ), + ) + .then((v) => v ?? false); + } + + @override + State createState() => _ConfirmDialogState(); +} + +class _ConfirmDialogState extends State { + bool _isLoading = false; + + Future _onConfirm() async { + if (_isLoading) return; + + setState(() => _isLoading = true); + + try { + await widget.onConfirm?.call(); + } catch (e, stackTrace) { + Log.error('[ConfirmDialog._onConfirm] $e\n$stackTrace'); + if (!mounted) return; + setState(() => _isLoading = false); + return; + } + + if (!mounted) return; + + Navigator.of(context).pop(true); + } + + void _onCancel() { + if (_isLoading) return; + Navigator.of(context).pop(false); + } + + @override + Widget build(BuildContext context) { + final theme = MagicStarter.manager.modalTheme; + final confirmLabel = widget.confirmLabel ?? trans('common.confirm'); + final cancelLabel = widget.cancelLabel ?? trans('common.cancel'); + final confirmClassName = + resolveConfirmButtonClassName(widget.variant, theme); + + // 1. Compute safe height, subtracting system insets. + final viewPadding = MediaQuery.viewPaddingOf(context); + final safeHeight = (MediaQuery.sizeOf(context).height - + viewPadding.top - + viewPadding.bottom) + .clamp(0.0, double.infinity); + + // 2. Build dialog shell with footer carrying compact right-aligned buttons. + return m.Dialog( + backgroundColor: m.Colors.transparent, + insetPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 24, + ), + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: theme.maxWidth, + maxHeight: safeHeight * 0.85, + ), + child: WDiv( + className: '${theme.containerClassName} w-full overflow-hidden', + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 3. Header: title + optional description. + WDiv( + className: theme.headerClassName, + children: [ + WText(widget.title, className: theme.titleClassName), + if (widget.description != null) + WText( + widget.description!, + className: theme.descriptionClassName, + ), + ], + ), + // 4. Empty body (SizedBox.shrink keeps the slot present). + Flexible( + child: ListView( + shrinkWrap: true, + padding: EdgeInsets.zero, + children: [ + WDiv( + className: theme.bodyClassName, + child: const SizedBox.shrink(), + ), + ], + ), + ), + // 5. Footer: compact right-aligned cancel + confirm buttons. + WDiv( + className: theme.footerClassName, + child: WDiv( + className: 'flex flex-row justify-end gap-2 wrap', + children: [ + WAnchor( + onTap: _isLoading ? null : _onCancel, + child: WDiv( + className: theme.secondaryButtonClassName, + child: WText(cancelLabel), + ), + ), + WButton( + onTap: _isLoading ? null : _onConfirm, + isLoading: _isLoading, + className: confirmClassName, + child: WText(confirmLabel), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/ui/components/confirm_dialog/confirm_dialog.preview.dart b/lib/src/ui/components/confirm_dialog/confirm_dialog.preview.dart new file mode 100644 index 0000000..f2695f6 --- /dev/null +++ b/lib/src/ui/components/confirm_dialog/confirm_dialog.preview.dart @@ -0,0 +1,30 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'confirm_dialog.dart'; + +/// Static variant-matrix preview for [ConfirmDialog]. +/// +/// Renders each [ConfirmDialogVariant] inline (not via showDialog) so the +/// preview catalog can display the full variant surface in light and dark. +/// One preview class per file is the canonical Wave 4 contract. +class ConfirmDialogPreview extends StatelessWidget { + /// Creates the confirm dialog variant-matrix preview. + const ConfirmDialogPreview({super.key}); + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex flex-col gap-6 p-6', + children: [ + for (final variant in ConfirmDialogVariant.values) + ConfirmDialog( + title: '${variant.name} confirm', + description: + 'This action demonstrates the ${variant.name} variant.', + variant: variant, + ), + ], + ); + } +} diff --git a/lib/src/ui/components/confirm_dialog/confirm_dialog.recipe.dart b/lib/src/ui/components/confirm_dialog/confirm_dialog.recipe.dart new file mode 100644 index 0000000..a839e7d --- /dev/null +++ b/lib/src/ui/components/confirm_dialog/confirm_dialog.recipe.dart @@ -0,0 +1,26 @@ +import '../../../configuration/magic_starter_theme.dart'; +import 'confirm_dialog.dart' show ConfirmDialogVariant; + +/// Resolves the confirm button className for a given [ConfirmDialogVariant] +/// using the modal theme. +/// +/// This function mirrors the `_resolveConfirmClassName()` method that existed +/// in the pre-migration `MagicStarterConfirmDialog`, extracted as a +/// theme-driven recipe helper so tests can assert it in isolation. +/// +/// ```dart +/// final cls = resolveConfirmButtonClassName( +/// ConfirmDialogVariant.danger, +/// MagicStarter.manager.modalTheme, +/// ); +/// ``` +String resolveConfirmButtonClassName( + ConfirmDialogVariant variant, + MagicStarterModalTheme theme, +) { + return switch (variant) { + ConfirmDialogVariant.primary => theme.primaryButtonClassName, + ConfirmDialogVariant.danger => theme.dangerButtonClassName, + ConfirmDialogVariant.warning => theme.warningButtonClassName, + }; +} diff --git a/lib/src/ui/components/confirm_dialog/index.dart b/lib/src/ui/components/confirm_dialog/index.dart new file mode 100644 index 0000000..4946306 --- /dev/null +++ b/lib/src/ui/components/confirm_dialog/index.dart @@ -0,0 +1,9 @@ +// ConfirmDialog component — folder-local barrel. +// +// Canonical Wave 4 atomic-component shape: recipe, component, and preview each +// live in their own dotted-suffix file. This index re-exports the public +// surface (component + variant enum + recipe helper). The preview is +// intentionally NOT re-exported here. + +export 'confirm_dialog.dart' show ConfirmDialog, ConfirmDialogVariant; +export 'confirm_dialog.recipe.dart'; diff --git a/lib/src/ui/components/dialog/dialog.dart b/lib/src/ui/components/dialog/dialog.dart new file mode 100644 index 0000000..e658b35 --- /dev/null +++ b/lib/src/ui/components/dialog/dialog.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart' as m show Colors, showDialog, Dialog; +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import '../../../facades/magic_starter.dart'; + +/// A reusable dialog component providing consistent Wind UI chrome driven by +/// `MagicStarter.manager.modalTheme` tokens. +/// +/// Wraps the Material dialog shell with a sticky header (title + description), +/// a scrollable body, and an optional sticky footer. Safe-area computation +/// subtracts system insets so the dialog never clips behind notches or home +/// indicators on mobile. +/// +/// All classNames are read from the modal theme at build time; override via +/// `MagicStarter.useModalTheme()` before the first dialog is shown. +/// +/// ### Example +/// ```dart +/// await Dialog.show( +/// context, +/// title: 'Confirm deletion', +/// body: const WText('This cannot be undone.'), +/// footerBuilder: (ctx) => WButton( +/// onTap: () => Navigator.of(ctx).pop(), +/// child: const WText('OK'), +/// ), +/// ); +/// ``` +@immutable +class Dialog extends StatelessWidget { + /// Optional heading rendered in the sticky header section. + final String? title; + + /// Optional sub-heading rendered below [title]. + final String? description; + + /// Content widget rendered in the scrollable body area. + final Widget body; + + /// Optional builder for the sticky footer; receives the dialog's own + /// [BuildContext] so callers can safely access + /// `Navigator.of(dialogContext)`. + final Widget Function(BuildContext dialogContext)? footerBuilder; + + /// Creates a [Dialog]. + const Dialog({ + super.key, + this.title, + this.description, + required this.body, + this.footerBuilder, + }); + + /// Opens the dialog and resolves when it is dismissed. + static Future show( + BuildContext context, { + String? title, + String? description, + required Widget body, + Widget Function(BuildContext dialogContext)? footerBuilder, + }) { + return m.showDialog( + context: context, + builder: (_) => Dialog( + title: title, + description: description, + body: body, + footerBuilder: footerBuilder, + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = MagicStarter.manager.modalTheme; + + // 1. Compute safe height, subtracting system insets. + final viewPadding = MediaQuery.viewPaddingOf(context); + final safeHeight = (MediaQuery.sizeOf(context).height - + viewPadding.top - + viewPadding.bottom) + .clamp(0.0, double.infinity); + + // 2. Build Material dialog shell with Wind UI content inside. + return m.Dialog( + backgroundColor: m.Colors.transparent, + insetPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 24, + ), + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: theme.maxWidth, + maxHeight: safeHeight * 0.85, + ), + child: WDiv( + className: '${theme.containerClassName} w-full overflow-hidden', + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 3. Header section: title + description. + if (title != null || description != null) + WDiv( + className: theme.headerClassName, + children: [ + if (title != null) + WText( + title!, + className: theme.titleClassName, + ), + if (description != null) + WText( + description!, + className: theme.descriptionClassName, + ), + ], + ), + // 4. Scrollable body: ListView collapses to content height. + Flexible( + child: ListView( + shrinkWrap: true, + padding: EdgeInsets.zero, + children: [ + WDiv( + className: theme.bodyClassName, + child: body, + ), + ], + ), + ), + // 5. Sticky footer: Builder gives footer access to dialog + // context so Navigator.of(dialogContext) works correctly. + if (footerBuilder != null) + Builder( + builder: (dialogContext) => WDiv( + key: const Key('magic_starter_dialog_shell_footer'), + className: theme.footerClassName, + child: footerBuilder!(dialogContext), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/ui/components/dialog/dialog.preview.dart b/lib/src/ui/components/dialog/dialog.preview.dart new file mode 100644 index 0000000..9d1e847 --- /dev/null +++ b/lib/src/ui/components/dialog/dialog.preview.dart @@ -0,0 +1,55 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'dialog.dart'; + +/// Static preview for the [Dialog] component. +/// +/// Renders a representative dialog inline (not via showDialog) so the preview +/// catalog can display the shell in light and dark without an overlay. One +/// preview class per file is the canonical Wave 4 contract. +class DialogPreview extends StatelessWidget { + /// Creates the dialog preview. + const DialogPreview({super.key}); + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex flex-col gap-6 p-6', + children: [ + // Dialog with title, description, and footer. + Dialog( + title: 'Confirm deletion', + description: 'This action cannot be undone.', + body: const WText( + 'Are you sure you want to delete this item?', + className: 'text-sm', + ), + footerBuilder: (_) => WDiv( + className: 'flex flex-row justify-end gap-2 wrap', + children: [ + WDiv( + className: + 'px-4 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 text-sm', + child: const WText('Cancel'), + ), + WDiv( + className: + 'px-4 py-2 rounded-lg bg-destructive text-white text-sm', + child: const WText('Delete'), + ), + ], + ), + ), + // Dialog with title only (no description, no footer). + const Dialog( + title: 'Informational', + body: WText( + 'This dialog has only a title and body content.', + className: 'text-sm', + ), + ), + ], + ); + } +} diff --git a/lib/src/ui/components/dialog/dialog.recipe.dart b/lib/src/ui/components/dialog/dialog.recipe.dart new file mode 100644 index 0000000..602959b --- /dev/null +++ b/lib/src/ui/components/dialog/dialog.recipe.dart @@ -0,0 +1,38 @@ +import 'package:magic/magic.dart'; + +import '../../../configuration/magic_starter_theme.dart'; + +/// Slot keys for the [Dialog] component's Wind slot recipe. +/// +/// The dialog uses a [WindSlotRecipe] to emit per-slot classNames driven +/// by `MagicStarterModalTheme`. Callers that need raw className strings +/// (e.g. for sub-widget previews or custom shells) can call +/// [buildDialogSlotRecipe] directly. +const String kDialogSlotContainer = 'container'; +const String kDialogSlotHeader = 'header'; +const String kDialogSlotTitle = 'title'; +const String kDialogSlotDescription = 'description'; +const String kDialogSlotBody = 'body'; +const String kDialogSlotFooter = 'footer'; + +/// Builds the dialog [WindSlotRecipe] from a [MagicStarterModalTheme]. +/// +/// The recipe is theme-driven (not a top-level const) because all classNames +/// are overridable via `MagicStarter.useModalTheme()`. Returns per-slot +/// className strings that map directly to the modal theme fields. +/// +/// Slot keys: [kDialogSlotContainer], [kDialogSlotHeader], +/// [kDialogSlotTitle], [kDialogSlotDescription], [kDialogSlotBody], +/// [kDialogSlotFooter]. +WindSlotRecipe buildDialogSlotRecipe(MagicStarterModalTheme theme) { + return WindSlotRecipe( + slots: { + kDialogSlotContainer: theme.containerClassName, + kDialogSlotHeader: theme.headerClassName, + kDialogSlotTitle: theme.titleClassName, + kDialogSlotDescription: theme.descriptionClassName, + kDialogSlotBody: theme.bodyClassName, + kDialogSlotFooter: theme.footerClassName, + }, + ); +} diff --git a/lib/src/ui/components/dialog/index.dart b/lib/src/ui/components/dialog/index.dart new file mode 100644 index 0000000..5cfb56c --- /dev/null +++ b/lib/src/ui/components/dialog/index.dart @@ -0,0 +1,10 @@ +// Dialog component — folder-local barrel. +// +// Canonical Wave 4 atomic-component shape: recipe, component, and preview each +// live in their own dotted-suffix file. This index re-exports the public +// surface (component + recipe helpers). The preview is intentionally NOT +// re-exported here — `previews:refresh` (Step 18) discovers `*.preview.dart` +// files directly, and the preview must stay out of the release barrel. + +export 'dialog.dart' show Dialog; +export 'dialog.recipe.dart'; diff --git a/lib/src/ui/components/dropdown_menu/dropdown_menu.dart b/lib/src/ui/components/dropdown_menu/dropdown_menu.dart new file mode 100644 index 0000000..e0f8ff3 --- /dev/null +++ b/lib/src/ui/components/dropdown_menu/dropdown_menu.dart @@ -0,0 +1,133 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +/// A single item entry for [DropdownMenu]. +@immutable +class DropdownMenuItem { + /// The display label for this menu item. + final String label; + + /// Optional callback invoked when the item is tapped. + final VoidCallback? onTap; + + /// When `true`, the item is rendered in a muted style and cannot be tapped. + final bool disabled; + + /// Optional leading icon or widget rendered before the label. + final Widget? leading; + + /// Optional className override for this item's container. + final String? className; + + /// Creates a [DropdownMenuItem]. + const DropdownMenuItem({ + required this.label, + this.onTap, + this.disabled = false, + this.leading, + this.className, + }); +} + +/// A reusable dropdown-menu component that composes on [WPopover]. +/// +/// The trigger [child] opens a popover panel containing the provided [items]. +/// Each item can carry an [onTap] callback, a disabled flag, and an optional +/// leading widget. +/// +/// **WPopover dismiss race note**: the `_suppressNextTapOutside` guard in +/// [WPopover] handles the real-click dismiss race (wind/w_popover.dart +/// lines 231-240). The menu uses `enableTriggerOnTap: true` (WPopover's +/// default) so tapping the trigger reliably toggles the panel; the same-frame +/// outside-tap dismiss is suppressed internally by WPopover and does not need +/// any workaround here. +/// +/// ### Example +/// ```dart +/// DropdownMenu( +/// child: const WText('Options'), +/// items: [ +/// DropdownMenuItem(label: 'Edit', onTap: controller.edit), +/// DropdownMenuItem(label: 'Delete', onTap: controller.delete), +/// ], +/// ) +/// ``` +@immutable +class DropdownMenu extends StatelessWidget { + /// The trigger widget that opens/closes the menu. + final Widget child; + + /// The menu items to display in the popover panel. + final List items; + + /// Optional className for the popover panel container. + final String? className; + + /// Popover alignment relative to the trigger. + /// Defaults to [PopoverAlignment.bottomLeft]. + final PopoverAlignment alignment; + + /// Creates a [DropdownMenu]. + const DropdownMenu({ + super.key, + required this.child, + required this.items, + this.className, + this.alignment = PopoverAlignment.bottomLeft, + }); + + @override + Widget build(BuildContext context) { + // 1. Resolve panel className from argument or default semantic-token style. + final panelClassName = className ?? + 'min-w-40 bg-surface border border-color-border rounded-lg shadow-lg py-1 overflow-hidden'; + + // 2. Build WPopover with the trigger and item list as content. + return WPopover( + alignment: alignment, + className: panelClassName, + enableTriggerOnTap: true, + triggerBuilder: (_, __, ___) => child, + contentBuilder: (_, close) => _buildItems(close), + ); + } + + Widget _buildItems(VoidCallback close) { + return WDiv( + className: 'flex flex-col', + children: [ + for (final item in items) _buildItem(item, close), + ], + ); + } + + Widget _buildItem(DropdownMenuItem item, VoidCallback close) { + // 3. Disabled items: muted style, no tap handler. + if (item.disabled) { + return WDiv( + className: item.className ?? + 'flex flex-row items-center gap-2 px-4 py-2 text-sm text-fg-disabled', + children: [ + if (item.leading != null) item.leading!, + WText(item.label), + ], + ); + } + + // 4. Active items: WAnchor for interactivity. + return WAnchor( + onTap: () { + item.onTap?.call(); + close(); + }, + child: WDiv( + className: item.className ?? + 'flex flex-row items-center gap-2 px-4 py-2 text-sm text-fg hover:bg-surface-container', + children: [ + if (item.leading != null) item.leading!, + WText(item.label), + ], + ), + ); + } +} diff --git a/lib/src/ui/components/dropdown_menu/dropdown_menu.preview.dart b/lib/src/ui/components/dropdown_menu/dropdown_menu.preview.dart new file mode 100644 index 0000000..58c7c02 --- /dev/null +++ b/lib/src/ui/components/dropdown_menu/dropdown_menu.preview.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'dropdown_menu.dart'; + +/// Static preview for the [DropdownMenu] component. +/// +/// Renders a dropdown with a representative set of items (normal, disabled, +/// with leading icon) so the preview catalog can display the surface in light +/// and dark. One preview class per file is the canonical Wave 4 contract. +class DropdownMenuPreview extends StatelessWidget { + /// Creates the dropdown-menu preview. + const DropdownMenuPreview({super.key}); + + static const _iconEdit = Icons.edit; + static const _iconDelete = Icons.delete; + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex flex-row gap-8 p-6', + children: [ + // Standard menu. + DropdownMenu( + items: [ + DropdownMenuItem( + label: 'Edit', + leading: const WIcon(_iconEdit, className: 'text-fg text-sm'), + onTap: () {}, + ), + const DropdownMenuItem(label: 'View details', onTap: null), + DropdownMenuItem( + label: 'Delete', + leading: const WIcon( + _iconDelete, + className: 'text-destructive text-sm', + ), + onTap: () {}, + ), + ], + child: WDiv( + className: + 'px-4 py-2 rounded-lg bg-surface border border-color-border text-sm', + child: const WText('Options'), + ), + ), + // Menu with a disabled item. + DropdownMenu( + items: [ + const DropdownMenuItem(label: 'Available action', onTap: null), + const DropdownMenuItem( + label: 'Unavailable action', + disabled: true, + ), + ], + child: WDiv( + className: + 'px-4 py-2 rounded-lg bg-surface border border-color-border text-sm', + child: const WText('With disabled'), + ), + ), + ], + ); + } +} diff --git a/lib/src/ui/components/dropdown_menu/dropdown_menu.recipe.dart b/lib/src/ui/components/dropdown_menu/dropdown_menu.recipe.dart new file mode 100644 index 0000000..bdd6d89 --- /dev/null +++ b/lib/src/ui/components/dropdown_menu/dropdown_menu.recipe.dart @@ -0,0 +1,13 @@ +/// Default className constants for the [DropdownMenu] component slots. +/// +/// These constants centralise the default token classes so they can be +/// referenced in tests and overridden by callers without coupling to the +/// [DropdownMenu] widget internals. +const String kDropdownMenuPanelClassName = + 'min-w-40 bg-surface border border-color-border rounded-lg shadow-lg py-1 overflow-hidden'; + +const String kDropdownMenuItemClassName = + 'flex flex-row items-center gap-2 px-4 py-2 text-sm text-fg hover:bg-surface-container'; + +const String kDropdownMenuItemDisabledClassName = + 'flex flex-row items-center gap-2 px-4 py-2 text-sm text-fg-disabled'; diff --git a/lib/src/ui/components/dropdown_menu/index.dart b/lib/src/ui/components/dropdown_menu/index.dart new file mode 100644 index 0000000..0c7551c --- /dev/null +++ b/lib/src/ui/components/dropdown_menu/index.dart @@ -0,0 +1,7 @@ +// DropdownMenu component — folder-local barrel. +// +// Canonical Wave 4 atomic-component shape. The preview is NOT re-exported; +// `previews:refresh` discovers `*.preview.dart` files directly. + +export 'dropdown_menu.dart' show DropdownMenu, DropdownMenuItem; +export 'dropdown_menu.recipe.dart'; diff --git a/lib/src/ui/components/empty_state/empty_state.recipe.dart b/lib/src/ui/components/empty_state/empty_state.recipe.dart index 97b1a98..4672b36 100644 --- a/lib/src/ui/components/empty_state/empty_state.recipe.dart +++ b/lib/src/ui/components/empty_state/empty_state.recipe.dart @@ -13,8 +13,7 @@ String emptyStateIconWrapClassName() => String emptyStateIconClassName() => 'text-4xl text-fg-muted'; /// Title className. -String emptyStateTitleClassName() => - 'text-base font-semibold text-fg'; +String emptyStateTitleClassName() => 'text-base font-semibold text-fg'; /// Description className. String emptyStateDescriptionClassName() => 'text-sm text-fg-muted max-w-xs'; diff --git a/lib/src/ui/components/form_field/form_field.recipe.dart b/lib/src/ui/components/form_field/form_field.recipe.dart index 2887bf4..8d41b72 100644 --- a/lib/src/ui/components/form_field/form_field.recipe.dart +++ b/lib/src/ui/components/form_field/form_field.recipe.dart @@ -11,13 +11,10 @@ import 'package:magic/magic.dart'; String formFieldRootClassName() => 'flex flex-col gap-1 w-full'; /// Label text className. -String formFieldLabelClassName() => - 'text-sm font-medium text-fg'; +String formFieldLabelClassName() => 'text-sm font-medium text-fg'; /// Hint text className. -String formFieldHintClassName() => - 'text-xs text-fg-muted'; +String formFieldHintClassName() => 'text-xs text-fg-muted'; /// Error text className (destructive tone). -String formFieldErrorClassName() => - 'text-xs text-red-600 dark:text-red-400'; +String formFieldErrorClassName() => 'text-xs text-red-600 dark:text-red-400'; diff --git a/lib/src/ui/components/input/index.dart b/lib/src/ui/components/input/index.dart new file mode 100644 index 0000000..d9d2fee --- /dev/null +++ b/lib/src/ui/components/input/index.dart @@ -0,0 +1,8 @@ +// Input component — folder-local barrel. +// +// The preview is intentionally NOT re-exported here — `previews:refresh` +// discovers `*.preview.dart` directly, and the preview must stay out of the +// release barrel. + +export 'input.dart' show Input; +export 'input.recipe.dart' show InputState, inputRecipe, kInputStateAxis; diff --git a/lib/src/ui/components/input/input.dart b/lib/src/ui/components/input/input.dart new file mode 100644 index 0000000..780433a --- /dev/null +++ b/lib/src/ui/components/input/input.dart @@ -0,0 +1,158 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'input.recipe.dart'; + +/// A reusable text input component for Magic Starter forms. +/// +/// Wraps [WInput] with a [WindRecipe] that applies semantic tokens for +/// background and border. Use [state] to signal validation errors. +/// +/// ### Basic usage +/// +/// ```dart +/// Input( +/// placeholder: 'Email address', +/// type: InputType.email, +/// onChanged: (v) => controller.email = v, +/// ) +/// ``` +/// +/// ### Error state +/// +/// ```dart +/// Input( +/// placeholder: 'Email address', +/// state: InputState.error, +/// onChanged: (v) => controller.email = v, +/// ) +/// ``` +@immutable +class Input extends StatelessWidget { + /// The controlled value of the input. + final String? value; + + /// Called when the user changes the input value. + final ValueChanged? onChanged; + + /// The keyboard type and obscure-text behavior. + final InputType type; + + /// Visual state of the input. + final InputState state; + + /// Placeholder text shown when the input is empty. + final String? placeholder; + + /// Whether the input is enabled. + final bool enabled; + + /// Whether the input is read-only. + final bool readOnly; + + /// The action button on the keyboard. + final TextInputAction? textInputAction; + + /// Called when the user submits the input. + final ValueChanged? onSubmitted; + + /// Called when the input loses focus. + final VoidCallback? onEditingComplete; + + /// Called when the input is tapped. + final VoidCallback? onTap; + + /// Called when the user taps outside the input. + final TapRegionCallback? onTapOutside; + + /// Maximum number of lines. + final int? maxLines; + + /// Minimum number of lines. + final int minLines; + + /// External focus node. + final FocusNode? focusNode; + + /// External text editing controller. + final TextEditingController? controller; + + /// Input formatters. + final List? inputFormatters; + + /// Optional className override that bypasses the recipe entirely. + final String? className; + + /// Widget displayed before the input text. + final Widget? prefix; + + /// Widget displayed after the input text. + final Widget? suffix; + + /// Accessible label for the input. + final String? semanticLabel; + + /// Creates an [Input]. + const Input({ + super.key, + this.value, + this.onChanged, + this.type = InputType.text, + this.state = InputState.normal, + this.placeholder, + this.enabled = true, + this.readOnly = false, + this.textInputAction, + this.onSubmitted, + this.onEditingComplete, + this.onTap, + this.onTapOutside, + this.maxLines, + this.minLines = 1, + this.focusNode, + this.controller, + this.inputFormatters, + this.className, + this.prefix, + this.suffix, + this.semanticLabel, + }); + + /// Resolves the className from the recipe or the caller override. + String _resolveClassName() { + if (className != null) { + return className!; + } + + return inputRecipe( + variants: {kInputStateAxis: state.name}, + ); + } + + @override + Widget build(BuildContext context) { + return WInput( + value: value, + onChanged: onChanged, + type: type, + className: _resolveClassName(), + placeholder: placeholder, + enabled: enabled, + readOnly: readOnly, + textInputAction: textInputAction, + onSubmitted: onSubmitted, + onEditingComplete: onEditingComplete, + onTap: onTap, + onTapOutside: onTapOutside, + maxLines: maxLines, + minLines: minLines, + focusNode: focusNode, + controller: controller, + inputFormatters: inputFormatters, + prefix: prefix, + suffix: suffix, + semanticLabel: semanticLabel, + ); + } +} diff --git a/lib/src/ui/components/input/input.preview.dart b/lib/src/ui/components/input/input.preview.dart new file mode 100644 index 0000000..9d2030b --- /dev/null +++ b/lib/src/ui/components/input/input.preview.dart @@ -0,0 +1,37 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'input.dart'; +import 'input.recipe.dart'; + +/// Static variant-matrix preview for [Input]. +/// +/// Renders every [InputState] so the catalog (`/preview`) can show the full +/// surface in both light and dark. +class InputPreview extends StatelessWidget { + /// Creates the input variant-matrix preview. + const InputPreview({super.key}); + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex flex-col gap-6 p-6', + children: [ + for (final state in InputState.values) + WDiv( + className: 'flex flex-col gap-2', + children: [ + WText( + state.name, + className: 'text-sm font-semibold text-fg-muted', + ), + Input( + state: state, + placeholder: 'Enter text (${state.name})', + ), + ], + ), + ], + ); + } +} diff --git a/lib/src/ui/components/input/input.recipe.dart b/lib/src/ui/components/input/input.recipe.dart new file mode 100644 index 0000000..ba32f5e --- /dev/null +++ b/lib/src/ui/components/input/input.recipe.dart @@ -0,0 +1,37 @@ +import 'package:magic/magic.dart'; + +/// The state axis key for the input recipe. +const String kInputStateAxis = 'state'; + +/// Visual state variants for [Input]. +/// +/// - [normal] — Default resting state. +/// - [error] — Validation-failed state; applies destructive border color. +enum InputState { + /// Default resting state. + normal, + + /// Validation-failed: applies destructive border and ring. + error, +} + +/// The input [WindRecipe] (const — no theme override hook needed). +/// +/// Semantic tokens drive background and border so a `DESIGN.md` override +/// re-skins all inputs without touching this file. +const WindRecipe inputRecipe = WindRecipe( + base: 'w-full rounded-lg border text-fg text-sm ' + 'focus:outline-none focus:ring-2 ' + 'disabled:opacity-50 disabled:cursor-not-allowed', + variants: { + kInputStateAxis: { + 'normal': 'bg-surface-container-high border-color-border ' + 'focus:border-color-border focus:ring-bg-primary', + 'error': 'bg-surface-container-high border-bg-destructive ' + 'focus:ring-bg-destructive', + }, + }, + defaultVariants: { + kInputStateAxis: 'normal', + }, +); diff --git a/lib/src/ui/components/radio/index.dart b/lib/src/ui/components/radio/index.dart new file mode 100644 index 0000000..7ed7dde --- /dev/null +++ b/lib/src/ui/components/radio/index.dart @@ -0,0 +1,8 @@ +// Radio component — folder-local barrel. +// +// The preview is intentionally NOT re-exported here — `previews:refresh` +// discovers `*.preview.dart` directly, and the preview must stay out of the +// release barrel. + +export 'radio.dart' show Radio; +export 'radio.recipe.dart'; diff --git a/lib/src/ui/components/radio/radio.dart b/lib/src/ui/components/radio/radio.dart new file mode 100644 index 0000000..f0c6fb0 --- /dev/null +++ b/lib/src/ui/components/radio/radio.dart @@ -0,0 +1,76 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'radio.recipe.dart'; + +/// A reusable radio button component for Magic Starter forms. +/// +/// Wraps [WRadio] with [WindRecipe] tokens that explicitly override wind's +/// built-in defaults (`border-gray-300`, `bg-blue-500`) so the radio shell and +/// indicator use semantic aliases instead of hardcoded palette colors. +/// +/// ### Usage +/// +/// ```dart +/// // Inside a group: every radio shares the same groupValue. +/// Radio( +/// value: 'email', +/// groupValue: _channel, +/// onChanged: (v) => setState(() => _channel = v), +/// ) +/// ``` +@immutable +class Radio extends StatelessWidget { + /// The value this radio button represents. + final T value; + + /// The currently selected value in the group. + final T? groupValue; + + /// Called with [value] when this radio is tapped while not selected. + final ValueChanged? onChanged; + + /// Whether the radio is disabled. + final bool disabled; + + /// Optional className override for the shell, bypassing the recipe. + final String? className; + + /// Optional className override for the indicator dot, bypassing the recipe. + final String? indicatorClassName; + + /// Accessible label for the radio (required for unlabelled usage). + final String? semanticLabel; + + /// Creates a [Radio]. + const Radio({ + super.key, + required this.value, + required this.groupValue, + required this.onChanged, + this.disabled = false, + this.className, + this.indicatorClassName, + this.semanticLabel, + }); + + /// Resolves the shell className from the recipe or the caller override. + String _resolveClassName() => className ?? radioShellRecipe(); + + /// Resolves the indicator className from the recipe or the caller override. + String _resolveIndicatorClassName() => + indicatorClassName ?? radioIndicatorRecipe(); + + @override + Widget build(BuildContext context) { + return WRadio( + value: value, + groupValue: groupValue, + onChanged: onChanged, + disabled: disabled, + className: _resolveClassName(), + indicatorClassName: _resolveIndicatorClassName(), + semanticLabel: semanticLabel, + ); + } +} diff --git a/lib/src/ui/components/radio/radio.preview.dart b/lib/src/ui/components/radio/radio.preview.dart new file mode 100644 index 0000000..c1186ef --- /dev/null +++ b/lib/src/ui/components/radio/radio.preview.dart @@ -0,0 +1,72 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'radio.dart'; + +/// Static variant-matrix preview for [Radio]. +/// +/// Renders selected and unselected states in both enabled and disabled modes. +class RadioPreview extends StatelessWidget { + /// Creates the radio variant-matrix preview. + const RadioPreview({super.key}); + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex flex-col gap-6 p-6', + children: [ + WDiv( + className: 'flex flex-col gap-3', + children: [ + WText('Enabled', className: 'text-sm font-semibold text-fg-muted'), + WDiv( + className: 'flex flex-row gap-4 items-center', + children: [ + Radio( + value: 'a', + groupValue: 'b', + onChanged: (_) {}, + ), + WText('Unselected', className: 'text-sm text-fg'), + Radio( + value: 'a', + groupValue: 'a', + onChanged: (_) {}, + ), + WText('Selected', className: 'text-sm text-fg'), + ], + ), + ], + ), + WDiv( + className: 'flex flex-col gap-3', + children: [ + WText( + 'Disabled', + className: 'text-sm font-semibold text-fg-muted', + ), + WDiv( + className: 'flex flex-row gap-4 items-center', + children: [ + Radio( + value: 'a', + groupValue: 'b', + onChanged: (_) {}, + disabled: true, + ), + WText('Unselected', className: 'text-sm text-fg-disabled'), + Radio( + value: 'a', + groupValue: 'a', + onChanged: (_) {}, + disabled: true, + ), + WText('Selected', className: 'text-sm text-fg-disabled'), + ], + ), + ], + ), + ], + ); + } +} diff --git a/lib/src/ui/components/radio/radio.recipe.dart b/lib/src/ui/components/radio/radio.recipe.dart new file mode 100644 index 0000000..12071aa --- /dev/null +++ b/lib/src/ui/components/radio/radio.recipe.dart @@ -0,0 +1,26 @@ +import 'package:magic/magic.dart'; + +/// The radio shell [WindRecipe] (const). +/// +/// Returns the className for the [WRadio] outer ring. The `selected:` and +/// `disabled:` state prefixes are driven by [WRadio]'s active-states set. +/// +/// We MUST supply explicit token classNames here (including the `selected:` +/// overrides) because [WRadio] ships default tone tokens (`border-gray-300`, +/// `bg-blue-500`) that would bypass semantic aliases. Passing a className +/// entirely overrides the wind primitive defaults. +const WindRecipe radioShellRecipe = WindRecipe( + base: 'w-5 h-5 rounded-full border border-color-border ' + 'items-center justify-center ' + 'selected:border-color-border ' + 'selected:bg-primary-container ' + 'disabled:opacity-50 disabled:cursor-not-allowed ' + 'hover:border-bg-primary', +); + +/// The radio indicator [WindRecipe] (const). +/// +/// Returns the className for the filled center dot shown when selected. +const WindRecipe radioIndicatorRecipe = WindRecipe( + base: 'w-2.5 h-2.5 rounded-full bg-primary', +); diff --git a/lib/src/ui/components/segmented_control/index.dart b/lib/src/ui/components/segmented_control/index.dart new file mode 100644 index 0000000..119cafb --- /dev/null +++ b/lib/src/ui/components/segmented_control/index.dart @@ -0,0 +1,9 @@ +// SegmentedControl component — folder-local barrel. +// +// Canonical Wave 4 atomic-component shape. The preview is intentionally NOT +// re-exported here — `previews:refresh` discovers `*.preview.dart` files +// directly, and the preview must stay out of the release barrel. + +export 'segmented_control.dart' show SegmentedControl; +export 'segmented_control.recipe.dart' + show segmentedControlRecipe, SegmentedControlSize; diff --git a/lib/src/ui/components/segmented_control/segmented_control.dart b/lib/src/ui/components/segmented_control/segmented_control.dart new file mode 100644 index 0000000..c1ac54a --- /dev/null +++ b/lib/src/ui/components/segmented_control/segmented_control.dart @@ -0,0 +1,84 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'segmented_control.recipe.dart'; + +/// A tab-bar-style segmented control component for Magic Starter. +/// +/// Renders a horizontal row of segments where exactly one is active at a time. +/// The active segment activates the `selected:` state prefix in its className +/// so callers can supply `selected:bg-surface selected:text-fg` tokens (the +/// defaults come from the slot recipe). +/// +/// ### Example Usage: +/// +/// ```dart +/// SegmentedControl( +/// options: const ['Monthly', 'Annual'], +/// selectedIndex: _selected, +/// onChanged: (i) => setState(() => _selected = i), +/// ) +/// ``` +@immutable +class SegmentedControl extends StatelessWidget { + /// The label displayed for each segment, in display order. + final List options; + + /// The zero-based index of the currently selected segment. + final int selectedIndex; + + /// Called when the user taps a segment, with its zero-based index. + final ValueChanged? onChanged; + + /// Visual size variant; defaults to [SegmentedControlSize.md]. + final SegmentedControlSize size; + + /// Per-slot className overrides appended after the recipe output. + final Map? classNames; + + /// Creates a [SegmentedControl] widget. + const SegmentedControl({ + super.key, + required this.options, + required this.selectedIndex, + this.onChanged, + this.size = SegmentedControlSize.md, + this.classNames, + }); + + @override + Widget build(BuildContext context) { + // 1. Resolve slot classNames from the recipe for the requested size. + final slots = segmentedControlRecipe( + variants: {kSegmentedControlSizeAxis: size.name}, + classNames: classNames, + ); + + // 2. Build the root container with a segment per option. + return WDiv( + className: slots['root'], + children: List.generate(options.length, (index) { + return _buildSegment(index, slots['item'] ?? ''); + }), + ); + } + + /// Builds a single segment at [index], activating the `selected:` state for + /// the currently selected index. + Widget _buildSegment(int index, String itemClassName) { + final bool isSelected = index == selectedIndex; + final Set segmentStates = { + if (isSelected) 'selected', + }; + + return WAnchor( + onTap: () => onChanged?.call(index), + states: segmentStates, + child: WDiv( + className: itemClassName, + states: segmentStates, + child: WText(options[index]), + ), + ); + } +} diff --git a/lib/src/ui/components/segmented_control/segmented_control.preview.dart b/lib/src/ui/components/segmented_control/segmented_control.preview.dart new file mode 100644 index 0000000..be8a2ba --- /dev/null +++ b/lib/src/ui/components/segmented_control/segmented_control.preview.dart @@ -0,0 +1,35 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'segmented_control.dart'; +import 'segmented_control.recipe.dart'; + +/// Static variant-matrix preview for [SegmentedControl]. +/// +/// Renders every size variant so the catalog shows the full surface in light +/// and dark. One preview class per file is the canonical Wave 4 contract. +class SegmentedControlPreview extends StatelessWidget { + /// Creates the segmented-control variant-matrix preview. + const SegmentedControlPreview({super.key}); + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex flex-col gap-6 p-6', + children: [ + for (final size in SegmentedControlSize.values) ...[ + WText( + 'SegmentedControl — ${size.name}', + className: 'text-sm font-medium text-fg-muted', + ), + SegmentedControl( + options: const ['Day', 'Week', 'Month'], + selectedIndex: 1, + size: size, + onChanged: (_) {}, + ), + ], + ], + ); + } +} diff --git a/lib/src/ui/components/segmented_control/segmented_control.recipe.dart b/lib/src/ui/components/segmented_control/segmented_control.recipe.dart new file mode 100644 index 0000000..1337cea --- /dev/null +++ b/lib/src/ui/components/segmented_control/segmented_control.recipe.dart @@ -0,0 +1,50 @@ +import 'package:magic/magic.dart'; + +/// Size axis key for [SegmentedControl]. +const String kSegmentedControlSizeAxis = 'size'; + +/// Visual size variants for [SegmentedControl]. +enum SegmentedControlSize { + /// Compact size: smaller padding and text. + sm, + + /// Default size: standard padding and text. + md, +} + +/// Builds the slot recipe for [SegmentedControl] from semantic tokens. +/// +/// Slots: +/// - `root` — the outer container row. +/// - `item` — each individual segment button. +/// +/// Variant axes: +/// - `size`: `sm` | `md` (default `md`). +Map segmentedControlRecipe({ + Map? variants, + Map? classNames, +}) { + const recipe = WindSlotRecipe( + slots: { + 'root': 'inline-flex rounded-lg bg-surface-container-high p-1 gap-1', + 'item': + 'px-3 py-1.5 rounded-md text-sm font-medium text-fg-muted cursor-pointer selected:bg-surface selected:text-fg selected:shadow-sm transition-colors', + }, + variants: { + kSegmentedControlSizeAxis: { + 'sm': { + 'root': '', + 'item': 'px-2 py-1 text-sm', + }, + 'md': { + 'root': '', + 'item': 'px-3 py-1.5 text-sm', + }, + }, + }, + defaultVariants: { + kSegmentedControlSizeAxis: 'md', + }, + ); + return recipe(variants: variants, classNames: classNames); +} diff --git a/lib/src/ui/components/select/index.dart b/lib/src/ui/components/select/index.dart new file mode 100644 index 0000000..58faba0 --- /dev/null +++ b/lib/src/ui/components/select/index.dart @@ -0,0 +1,11 @@ +// Select component — folder-local barrel. +// +// Canonical Wave 4 atomic-component shape: the recipe, the component, and the +// preview each live in their own dotted-suffix file; this index re-exports the +// public surface (component only — no variant enum for Select). The preview is +// intentionally NOT re-exported here — `previews:refresh` discovers +// `*.preview.dart` files directly, and the preview must stay out of the +// release barrel. + +export 'select.dart' show Select; +export 'select.recipe.dart' show selectRecipe; diff --git a/lib/src/ui/components/select/select.dart b/lib/src/ui/components/select/select.dart new file mode 100644 index 0000000..d01d629 --- /dev/null +++ b/lib/src/ui/components/select/select.dart @@ -0,0 +1,82 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'select.recipe.dart'; + +/// A styled single-select dropdown component for Magic Starter. +/// +/// Wraps [WSelect] with semantic-token classNames resolved from [selectRecipe]. +/// The caller supplies [options], a controlled [value], and an [onChange] +/// callback; all visual tokens come from the slot recipe. +/// +/// ### Example Usage: +/// +/// ```dart +/// Select( +/// value: _selected, +/// options: countries, +/// onChange: (v) => setState(() => _selected = v), +/// ) +/// ``` +/// +/// ### Slot override: +/// +/// Pass [classNames] to override individual slot classNames (the override is +/// appended last, per the WindSlotRecipe caller-append contract): +/// +/// ```dart +/// Select( +/// value: _selected, +/// options: options, +/// onChange: (_) {}, +/// classNames: {'trigger': 'border-red-500'}, +/// ) +/// ``` +@immutable +class Select extends StatelessWidget { + /// Currently selected value, or `null` when nothing is selected. + final T? value; + + /// The list of available options. + final List> options; + + /// Called when the user selects an option. + final ValueChanged? onChange; + + /// Optional placeholder shown when [value] is `null`. + final String? placeholder; + + /// Whether the dropdown is disabled. + final bool disabled; + + /// Per-slot className overrides appended after the recipe output. + final Map? classNames; + + /// Creates a [Select] widget. + const Select({ + super.key, + required this.options, + this.value, + this.onChange, + this.placeholder, + this.disabled = false, + this.classNames, + }); + + @override + Widget build(BuildContext context) { + // 1. Resolve slot classNames from the recipe. + final slots = selectRecipe(classNames: classNames); + + // 2. Delegate to WSelect with recipe-driven classNames. + return WSelect( + value: value, + options: options, + onChange: onChange, + placeholder: placeholder ?? 'Select an option', + disabled: disabled, + className: slots['trigger'], + menuClassName: slots['popup'], + ); + } +} diff --git a/lib/src/ui/components/select/select.preview.dart b/lib/src/ui/components/select/select.preview.dart new file mode 100644 index 0000000..2f13cea --- /dev/null +++ b/lib/src/ui/components/select/select.preview.dart @@ -0,0 +1,60 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'select.dart'; + +/// Static variant-matrix preview for [Select]. +/// +/// Shows a Select with populated options in both the default state and a +/// pre-selected state so the catalog can exercise light and dark themes. +/// One preview class per file is the canonical Wave 4 contract. +class SelectPreview extends StatelessWidget { + /// Creates the select variant-matrix preview. + const SelectPreview({super.key}); + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex flex-col gap-6 p-6', + children: [ + WText( + 'Select — default (no value)', + className: 'text-sm font-medium text-fg-muted', + ), + Select( + value: null, + options: const [ + SelectOption(value: 'option_a', label: 'Option A'), + SelectOption(value: 'option_b', label: 'Option B'), + SelectOption(value: 'option_c', label: 'Option C'), + ], + onChange: (_) {}, + ), + WText( + 'Select — pre-selected value', + className: 'text-sm font-medium text-fg-muted', + ), + Select( + value: 'option_b', + options: const [ + SelectOption(value: 'option_a', label: 'Option A'), + SelectOption(value: 'option_b', label: 'Option B'), + SelectOption(value: 'option_c', label: 'Option C'), + ], + onChange: (_) {}, + ), + WText( + 'Select — disabled', + className: 'text-sm font-medium text-fg-muted', + ), + Select( + value: null, + options: const [], + onChange: (_) {}, + disabled: true, + placeholder: 'Disabled select', + ), + ], + ); + } +} diff --git a/lib/src/ui/components/select/select.recipe.dart b/lib/src/ui/components/select/select.recipe.dart new file mode 100644 index 0000000..c75eaee --- /dev/null +++ b/lib/src/ui/components/select/select.recipe.dart @@ -0,0 +1,27 @@ +import 'package:magic/magic.dart'; + +/// Builds the slot recipe for [Select] from semantic tokens. +/// +/// Slots: +/// - `trigger` — the closed-state trigger button. +/// - `popup` — the dropdown overlay container. +/// - `item` — each option row inside the dropdown. +/// +/// The recipe reads semantic token aliases (Step 7) so a `design:sync`-generated +/// theme override re-skins the entire select without touching this file. +Map selectRecipe({ + Map? variants, + Map? classNames, +}) { + const recipe = WindSlotRecipe( + slots: { + 'trigger': + 'w-full px-3 py-2 rounded-lg bg-surface-container-high border border-color-border text-fg flex items-center justify-between gap-2', + 'popup': + 'bg-surface border border-color-border rounded-lg shadow-md overflow-hidden', + 'item': + 'px-3 py-2 text-sm text-fg hover:bg-surface-container cursor-pointer', + }, + ); + return recipe(variants: variants, classNames: classNames); +} diff --git a/lib/src/ui/components/skeleton/index.dart b/lib/src/ui/components/skeleton/index.dart new file mode 100644 index 0000000..638bf23 --- /dev/null +++ b/lib/src/ui/components/skeleton/index.dart @@ -0,0 +1,10 @@ +// Skeleton component — folder-local barrel. +// +// Canonical Wave 4 atomic-component shape: the recipe, the component, and the +// preview each live in their own dotted-suffix file; this index re-exports the +// public surface (component + shape enum). The preview is intentionally NOT +// re-exported here — `previews:refresh` discovers `*.preview.dart` files +// directly, and the preview must stay out of the release barrel. + +export 'skeleton.dart' show Skeleton, SkeletonShape; +export 'skeleton.recipe.dart'; diff --git a/lib/src/ui/components/skeleton/skeleton.dart b/lib/src/ui/components/skeleton/skeleton.dart new file mode 100644 index 0000000..9113fe6 --- /dev/null +++ b/lib/src/ui/components/skeleton/skeleton.dart @@ -0,0 +1,83 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'skeleton.recipe.dart'; + +/// Shape variants for [Skeleton]. +/// +/// - [block] — Rectangular block placeholder (e.g. image, card). +/// - [text] — Short inline text-line placeholder. +/// - [circle] — Circular avatar or icon placeholder. +enum SkeletonShape { + /// Rectangular block: `rounded-md`. + block, + + /// Inline text line: `rounded`. + text, + + /// Circle avatar / icon: `rounded-full`. + circle, +} + +/// A loading placeholder built on [WDiv]. +/// +/// Renders a pulsing muted block using the semantic token +/// `bg-surface-container-high` (the nearest semantic equivalent to +/// `bg-muted` — there is no dedicated `bg-muted` alias in +/// [MagicStarterTokens.defaultAliases]). The `animate-pulse` className drives +/// the shimmer animation via wind's animation parser. +/// +/// Callers supply [width] and [height] to size the placeholder; [shape] +/// controls the border-radius via the recipe. +/// +/// ```dart +/// Skeleton(width: 200, height: 80) // block (default) +/// Skeleton(shape: SkeletonShape.circle, width: 48, height: 48) +/// Skeleton(shape: SkeletonShape.text, width: 160, height: 16) +/// ``` +@immutable +class Skeleton extends StatelessWidget { + /// The visual shape controlling border-radius. + /// + /// Defaults to [SkeletonShape.block]. + final SkeletonShape shape; + + /// The width of the skeleton placeholder in logical pixels. + /// + /// When `null` the placeholder fills its parent's width. + final double? width; + + /// The height of the skeleton placeholder in logical pixels. + final double? height; + + /// Optional className override; bypasses the recipe entirely when supplied. + final String? className; + + /// Creates a [Skeleton] widget. + const Skeleton({ + super.key, + this.shape = SkeletonShape.block, + this.width, + this.height, + this.className, + }); + + /// Resolves the final className from the recipe or caller override. + String _resolveClassName() { + if (className != null) { + return className!; + } + return skeletonRecipe( + variants: {kSkeletonShapeAxis: shape.name}, + ); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: width, + height: height, + child: WDiv(className: _resolveClassName()), + ); + } +} diff --git a/lib/src/ui/components/skeleton/skeleton.preview.dart b/lib/src/ui/components/skeleton/skeleton.preview.dart new file mode 100644 index 0000000..cdf96d1 --- /dev/null +++ b/lib/src/ui/components/skeleton/skeleton.preview.dart @@ -0,0 +1,51 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'skeleton.dart'; + +/// Static variant-matrix preview for [Skeleton]. +/// +/// Renders every [SkeletonShape] with representative dimensions so the catalog +/// can show the pulsing effect in light and dark. One preview class per file +/// is the canonical Wave 4 contract. +class SkeletonPreview extends StatelessWidget { + /// Creates the skeleton variant-matrix preview. + const SkeletonPreview({super.key}); + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex flex-col gap-6 p-6', + children: [ + // Block: image/card placeholder + WDiv( + className: 'flex flex-col gap-2', + children: [ + WText('block', className: 'text-xs text-fg-muted'), + const Skeleton(shape: SkeletonShape.block, width: 240, height: 80), + ], + ), + + // Text: paragraph line placeholders + WDiv( + className: 'flex flex-col gap-2', + children: [ + WText('text', className: 'text-xs text-fg-muted'), + const Skeleton(shape: SkeletonShape.text, width: 200, height: 14), + const Skeleton(shape: SkeletonShape.text, width: 160, height: 14), + const Skeleton(shape: SkeletonShape.text, width: 120, height: 14), + ], + ), + + // Circle: avatar placeholder + WDiv( + className: 'flex flex-col gap-2', + children: [ + WText('circle', className: 'text-xs text-fg-muted'), + const Skeleton(shape: SkeletonShape.circle, width: 48, height: 48), + ], + ), + ], + ); + } +} diff --git a/lib/src/ui/components/skeleton/skeleton.recipe.dart b/lib/src/ui/components/skeleton/skeleton.recipe.dart new file mode 100644 index 0000000..975b014 --- /dev/null +++ b/lib/src/ui/components/skeleton/skeleton.recipe.dart @@ -0,0 +1,33 @@ +import 'package:magic/magic.dart'; + +/// The shape axis key for the skeleton recipe (`SkeletonShape..name`). +const String kSkeletonShapeAxis = 'shape'; + +/// Builds the skeleton [WindRecipe] using semantic tokens. +/// +/// The recipe is a top-level const because the skeleton has no theme-override +/// hook. +/// +/// Base carries the pulse animation and muted surface fill via the semantic +/// token `bg-surface-container-high` (the nearest equivalent to `bg-muted` — +/// there is no `bg-muted` alias in [MagicStarterTokens.defaultAliases]). +/// +/// Emission order: `base ++ shape-variant`. +/// +/// Shape -> additional className: +/// - block: `rounded-md` — rectangular block placeholder +/// - text: `rounded` — short inline text line placeholder +/// - circle: `rounded-full` — avatar / icon circle placeholder +const WindRecipe skeletonRecipe = WindRecipe( + base: 'animate-pulse bg-surface-container-high', + variants: { + kSkeletonShapeAxis: { + 'block': 'rounded-md', + 'text': 'rounded', + 'circle': 'rounded-full', + }, + }, + defaultVariants: { + kSkeletonShapeAxis: 'block', + }, +); diff --git a/lib/src/ui/components/switch/index.dart b/lib/src/ui/components/switch/index.dart new file mode 100644 index 0000000..f80282f --- /dev/null +++ b/lib/src/ui/components/switch/index.dart @@ -0,0 +1,8 @@ +// Switch component — folder-local barrel. +// +// The preview is intentionally NOT re-exported here — `previews:refresh` +// discovers `*.preview.dart` directly, and the preview must stay out of the +// release barrel. + +export 'switch.dart' show Switch; +export 'switch.recipe.dart'; diff --git a/lib/src/ui/components/switch/switch.dart b/lib/src/ui/components/switch/switch.dart new file mode 100644 index 0000000..c5af3d7 --- /dev/null +++ b/lib/src/ui/components/switch/switch.dart @@ -0,0 +1,67 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'switch.recipe.dart'; + +/// A reusable toggle switch component for Magic Starter forms. +/// +/// Wraps [WSwitch] with [WindRecipe] tokens that apply semantic colors so the +/// track and thumb follow the brand without hardcoded values. +/// +/// ### Usage +/// +/// ```dart +/// Switch( +/// value: _enabled, +/// onChanged: (v) => setState(() => _enabled = v), +/// ) +/// ``` +@immutable +class Switch extends StatelessWidget { + /// Whether the switch is on. + final bool value; + + /// Called when the switch value changes. + final ValueChanged? onChanged; + + /// Whether the switch is disabled. + final bool disabled; + + /// Optional className override for the track, bypassing the recipe. + final String? className; + + /// Optional className override for the thumb, bypassing the recipe. + final String? thumbClassName; + + /// Accessible label for the switch (required for icon-only usage). + final String? semanticLabel; + + /// Creates a [Switch]. + const Switch({ + super.key, + required this.value, + required this.onChanged, + this.disabled = false, + this.className, + this.thumbClassName, + this.semanticLabel, + }); + + /// Resolves the track className from the recipe or the caller override. + String _resolveClassName() => className ?? switchTrackRecipe(); + + /// Resolves the thumb className from the recipe or the caller override. + String _resolveThumbClassName() => thumbClassName ?? switchThumbRecipe(); + + @override + Widget build(BuildContext context) { + return WSwitch( + value: value, + onChanged: onChanged, + disabled: disabled, + className: _resolveClassName(), + thumbClassName: _resolveThumbClassName(), + semanticLabel: semanticLabel, + ); + } +} diff --git a/lib/src/ui/components/switch/switch.preview.dart b/lib/src/ui/components/switch/switch.preview.dart new file mode 100644 index 0000000..032fc59 --- /dev/null +++ b/lib/src/ui/components/switch/switch.preview.dart @@ -0,0 +1,54 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'switch.dart'; + +/// Static variant-matrix preview for [Switch]. +/// +/// Renders on/off states in both enabled and disabled modes. +class SwitchPreview extends StatelessWidget { + /// Creates the switch variant-matrix preview. + const SwitchPreview({super.key}); + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex flex-col gap-6 p-6', + children: [ + WDiv( + className: 'flex flex-col gap-3', + children: [ + WText('Enabled', className: 'text-sm font-semibold text-fg-muted'), + WDiv( + className: 'flex flex-row gap-4 items-center', + children: [ + Switch(value: false, onChanged: (_) {}), + WText('Off', className: 'text-sm text-fg'), + Switch(value: true, onChanged: (_) {}), + WText('On', className: 'text-sm text-fg'), + ], + ), + ], + ), + WDiv( + className: 'flex flex-col gap-3', + children: [ + WText( + 'Disabled', + className: 'text-sm font-semibold text-fg-muted', + ), + WDiv( + className: 'flex flex-row gap-4 items-center', + children: [ + const Switch(value: false, onChanged: null, disabled: true), + WText('Off', className: 'text-sm text-fg-disabled'), + const Switch(value: true, onChanged: null, disabled: true), + WText('On', className: 'text-sm text-fg-disabled'), + ], + ), + ], + ), + ], + ); + } +} diff --git a/lib/src/ui/components/switch/switch.recipe.dart b/lib/src/ui/components/switch/switch.recipe.dart new file mode 100644 index 0000000..953bd46 --- /dev/null +++ b/lib/src/ui/components/switch/switch.recipe.dart @@ -0,0 +1,23 @@ +import 'package:magic/magic.dart'; + +/// The switch track [WindRecipe] (const). +/// +/// Returns the className for the [WSwitch] track (outer pill). The `checked:` +/// state prefix is driven by [WSwitch]'s active-states set; the recipe only +/// supplies base shape and semantic token color overrides. +const WindRecipe switchTrackRecipe = WindRecipe( + base: 'w-11 h-6 rounded-full border-2 ' + 'bg-surface-container-high border-color-border ' + 'checked:bg-primary checked:border-bg-primary ' + 'disabled:opacity-50 disabled:cursor-not-allowed ' + 'focus:ring-2 focus:ring-bg-primary', +); + +/// The switch thumb [WindRecipe] (const). +/// +/// Returns the className for the [WSwitch] thumb (inner circle). +const WindRecipe switchThumbRecipe = WindRecipe( + base: 'w-4 h-4 rounded-full bg-surface ' + 'translate-x-0 checked:translate-x-5 ' + 'shadow transition-transform', +); diff --git a/lib/src/ui/components/tabs/index.dart b/lib/src/ui/components/tabs/index.dart new file mode 100644 index 0000000..0a1da1a --- /dev/null +++ b/lib/src/ui/components/tabs/index.dart @@ -0,0 +1,8 @@ +// Tabs component — folder-local barrel. +// +// Canonical Wave 4 atomic-component shape. The preview is intentionally NOT +// re-exported here — `previews:refresh` discovers `*.preview.dart` files +// directly, and the preview must stay out of the release barrel. + +export 'tabs.dart' show Tabs; +export 'tabs.recipe.dart' show tabsRecipe; diff --git a/lib/src/ui/components/tabs/tabs.dart b/lib/src/ui/components/tabs/tabs.dart new file mode 100644 index 0000000..7fda918 --- /dev/null +++ b/lib/src/ui/components/tabs/tabs.dart @@ -0,0 +1,77 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'tabs.recipe.dart'; + +/// A recipe-driven tabs component for Magic Starter. +/// +/// Wraps [WTabs] with semantic-token classNames resolved from [tabsRecipe]. +/// This is a controlled widget: [selectedIndex] must be managed by the caller. +/// Use [onChanged] to react to tab taps. +/// +/// ### Example Usage: +/// +/// ```dart +/// Tabs( +/// tabs: const ['Overview', 'Details', 'Settings'], +/// selectedIndex: _selectedTab, +/// onChanged: (i) => setState(() => _selectedTab = i), +/// panelBuilder: (i) => _panels[i], +/// ) +/// ``` +/// +/// ### Slot override: +/// +/// ```dart +/// Tabs( +/// tabs: const ['A', 'B'], +/// selectedIndex: 0, +/// onChanged: (_) {}, +/// panelBuilder: (i) => Text('Panel $i'), +/// classNames: {'tab': 'px-6 py-3'}, +/// ) +/// ``` +@immutable +class Tabs extends StatelessWidget { + /// The labels rendered for each tab, in display order. + final List tabs; + + /// The zero-based index of the currently selected tab. + final int selectedIndex; + + /// Called when the user taps a tab, with its zero-based index. + final ValueChanged? onChanged; + + /// Builder that returns the panel content for the currently selected tab. + final Widget Function(int index) panelBuilder; + + /// Per-slot className overrides appended after the recipe output. + final Map? classNames; + + /// Creates a [Tabs] widget. + const Tabs({ + super.key, + required this.tabs, + required this.selectedIndex, + required this.panelBuilder, + this.onChanged, + this.classNames, + }); + + @override + Widget build(BuildContext context) { + // 1. Resolve slot classNames from the recipe. + final slots = tabsRecipe(classNames: classNames); + + // 2. Delegate to WTabs with recipe-driven slot classNames. + return WTabs( + tabs: tabs, + selectedIndex: selectedIndex, + onChanged: onChanged, + panelBuilder: panelBuilder, + listClassName: slots['list'], + tabClassName: slots['tab'], + panelClassName: slots['panel'], + ); + } +} diff --git a/lib/src/ui/components/tabs/tabs.preview.dart b/lib/src/ui/components/tabs/tabs.preview.dart new file mode 100644 index 0000000..84b9266 --- /dev/null +++ b/lib/src/ui/components/tabs/tabs.preview.dart @@ -0,0 +1,48 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'tabs.dart'; + +/// Static variant-matrix preview for [Tabs]. +/// +/// Renders a Tabs widget with a three-tab configuration so the catalog can +/// exercise light and dark themes and interaction states. One preview class per +/// file is the canonical Wave 4 contract. +class TabsPreview extends StatefulWidget { + /// Creates the tabs variant-matrix preview. + const TabsPreview({super.key}); + + @override + State createState() => _TabsPreviewState(); +} + +class _TabsPreviewState extends State { + int _selected = 0; + + static const List _tabs = ['Overview', 'Details', 'Settings']; + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex flex-col gap-6 p-6', + children: [ + WText( + 'Tabs — interactive', + className: 'text-sm font-medium text-fg-muted', + ), + Tabs( + tabs: _tabs, + selectedIndex: _selected, + onChanged: (i) => setState(() => _selected = i), + panelBuilder: (i) => WDiv( + className: 'p-2', + child: WText( + '${_tabs[i]} panel content', + className: 'text-sm text-fg', + ), + ), + ), + ], + ); + } +} diff --git a/lib/src/ui/components/tabs/tabs.recipe.dart b/lib/src/ui/components/tabs/tabs.recipe.dart new file mode 100644 index 0000000..7886699 --- /dev/null +++ b/lib/src/ui/components/tabs/tabs.recipe.dart @@ -0,0 +1,26 @@ +import 'package:magic/magic.dart'; + +/// Builds the slot recipe for [Tabs] from semantic tokens. +/// +/// Slots: +/// - `list` — the horizontal tab-list row with the border separator. +/// - `tab` — each individual tab item (supports `selected:` state prefix). +/// - `panel` — the content panel shown below the tab list. +/// +/// The `selected:` state tokens on `tab` activate only on the currently +/// selected tab, driven by [WTabs]'s state injection. The caller may extend +/// the per-slot classNames via the [classNames] override map. +Map tabsRecipe({ + Map? variants, + Map? classNames, +}) { + const recipe = WindSlotRecipe( + slots: { + 'list': 'flex flex-row border-b border-color-border', + 'tab': + 'px-4 py-2 text-sm font-medium text-fg-muted cursor-pointer selected:text-fg selected:border-b-2 selected:border-color-border', + 'panel': 'pt-4', + }, + ); + return recipe(variants: variants, classNames: classNames); +} diff --git a/lib/src/ui/components/textarea/index.dart b/lib/src/ui/components/textarea/index.dart new file mode 100644 index 0000000..6f59eb9 --- /dev/null +++ b/lib/src/ui/components/textarea/index.dart @@ -0,0 +1,9 @@ +// Textarea component — folder-local barrel. +// +// The preview is intentionally NOT re-exported here — `previews:refresh` +// discovers `*.preview.dart` directly, and the preview must stay out of the +// release barrel. + +export 'textarea.dart' show Textarea; +export 'textarea.recipe.dart' + show TextareaState, textareaRecipe, kTextareaStateAxis; diff --git a/lib/src/ui/components/textarea/textarea.dart b/lib/src/ui/components/textarea/textarea.dart new file mode 100644 index 0000000..975b869 --- /dev/null +++ b/lib/src/ui/components/textarea/textarea.dart @@ -0,0 +1,104 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'textarea.recipe.dart'; + +/// A reusable multiline text input component for Magic Starter forms. +/// +/// Wraps [WInput] in [InputType.multiline] mode with a [WindRecipe] that +/// applies semantic tokens. Use [state] to signal validation errors. +/// +/// ### Basic usage +/// +/// ```dart +/// Textarea( +/// placeholder: 'Enter a description', +/// minLines: 3, +/// maxLines: 8, +/// onChanged: (v) => controller.description = v, +/// ) +/// ``` +@immutable +class Textarea extends StatelessWidget { + /// The controlled value of the textarea. + final String? value; + + /// Called when the user changes the textarea value. + final ValueChanged? onChanged; + + /// Visual state of the textarea. + final TextareaState state; + + /// Placeholder text shown when the textarea is empty. + final String? placeholder; + + /// Whether the textarea is enabled. + final bool enabled; + + /// Whether the textarea is read-only. + final bool readOnly; + + /// Maximum number of lines before scrolling. + final int? maxLines; + + /// Minimum number of visible lines. + final int minLines; + + /// External focus node. + final FocusNode? focusNode; + + /// External text editing controller. + final TextEditingController? controller; + + /// Optional className override that bypasses the recipe entirely. + final String? className; + + /// Accessible label for the textarea. + final String? semanticLabel; + + /// Creates a [Textarea]. + const Textarea({ + super.key, + this.value, + this.onChanged, + this.state = TextareaState.normal, + this.placeholder, + this.enabled = true, + this.readOnly = false, + this.maxLines, + this.minLines = 3, + this.focusNode, + this.controller, + this.className, + this.semanticLabel, + }); + + /// Resolves the className from the recipe or the caller override. + String _resolveClassName() { + if (className != null) { + return className!; + } + + return textareaRecipe( + variants: {kTextareaStateAxis: state.name}, + ); + } + + @override + Widget build(BuildContext context) { + return WInput( + value: value, + onChanged: onChanged, + type: InputType.multiline, + className: _resolveClassName(), + placeholder: placeholder, + enabled: enabled, + readOnly: readOnly, + maxLines: maxLines, + minLines: minLines, + focusNode: focusNode, + controller: controller, + semanticLabel: semanticLabel, + ); + } +} diff --git a/lib/src/ui/components/textarea/textarea.preview.dart b/lib/src/ui/components/textarea/textarea.preview.dart new file mode 100644 index 0000000..297ad92 --- /dev/null +++ b/lib/src/ui/components/textarea/textarea.preview.dart @@ -0,0 +1,38 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'textarea.dart'; +import 'textarea.recipe.dart'; + +/// Static variant-matrix preview for [Textarea]. +/// +/// Renders every [TextareaState] so the catalog (`/preview`) can show the full +/// surface in both light and dark. +class TextareaPreview extends StatelessWidget { + /// Creates the textarea variant-matrix preview. + const TextareaPreview({super.key}); + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex flex-col gap-6 p-6', + children: [ + for (final state in TextareaState.values) + WDiv( + className: 'flex flex-col gap-2', + children: [ + WText( + state.name, + className: 'text-sm font-semibold text-fg-muted', + ), + Textarea( + state: state, + placeholder: 'Enter text (${state.name})', + minLines: 3, + ), + ], + ), + ], + ); + } +} diff --git a/lib/src/ui/components/textarea/textarea.recipe.dart b/lib/src/ui/components/textarea/textarea.recipe.dart new file mode 100644 index 0000000..1ba5602 --- /dev/null +++ b/lib/src/ui/components/textarea/textarea.recipe.dart @@ -0,0 +1,37 @@ +import 'package:magic/magic.dart'; + +/// The state axis key for the textarea recipe. +const String kTextareaStateAxis = 'state'; + +/// Visual state variants for [Textarea]. +/// +/// - [normal] — Default resting state. +/// - [error] — Validation-failed state; applies destructive border color. +enum TextareaState { + /// Default resting state. + normal, + + /// Validation-failed: applies destructive border and ring. + error, +} + +/// The textarea [WindRecipe] (const — no theme override hook needed). +/// +/// Mirrors [inputRecipe] but does not set `maxLines` (the [Textarea] widget +/// configures multiline on [WInput] separately). +const WindRecipe textareaRecipe = WindRecipe( + base: 'w-full rounded-lg border text-fg text-sm resize-none ' + 'focus:outline-none focus:ring-2 ' + 'disabled:opacity-50 disabled:cursor-not-allowed', + variants: { + kTextareaStateAxis: { + 'normal': 'bg-surface-container-high border-color-border ' + 'focus:border-color-border focus:ring-bg-primary', + 'error': 'bg-surface-container-high border-bg-destructive ' + 'focus:ring-bg-destructive', + }, + }, + defaultVariants: { + kTextareaStateAxis: 'normal', + }, +); diff --git a/lib/src/ui/components/toast/index.dart b/lib/src/ui/components/toast/index.dart new file mode 100644 index 0000000..851ffd7 --- /dev/null +++ b/lib/src/ui/components/toast/index.dart @@ -0,0 +1,7 @@ +// Toast component — folder-local barrel. +// +// Canonical Wave 4 atomic-component shape. The preview is NOT re-exported; +// `previews:refresh` discovers `*.preview.dart` files directly. + +export 'toast.dart' show Toast, ToastVariant; +export 'toast.recipe.dart'; diff --git a/lib/src/ui/components/toast/toast.dart b/lib/src/ui/components/toast/toast.dart new file mode 100644 index 0000000..29cff9b --- /dev/null +++ b/lib/src/ui/components/toast/toast.dart @@ -0,0 +1,74 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'toast.recipe.dart'; + +/// Visual style variants for [Toast]. +/// +/// Each variant maps to a semantic token that the design system provides: +/// - [info] — neutral, surface tone. +/// - [success] — green/success tone. +/// - [warning] — amber/warning tone. +/// - [error] — destructive/red tone. +enum ToastVariant { + /// Neutral informational toast. + info, + + /// Success/positive feedback toast. + success, + + /// Cautionary/warning toast. + warning, + + /// Error/failure toast. + error, +} + +/// A reusable inline toast notification widget. +/// +/// Renders a styled banner using semantic tokens via [buildToastRecipe]. +/// In-app transient toasts are typically triggered via `Magic.toast()` +/// (the magic framework's overlay API); this widget is the presentational +/// layer that can also be composed inline in previews or custom overlays. +/// +/// ### Example +/// ```dart +/// Toast( +/// message: 'Profile updated successfully', +/// variant: ToastVariant.success, +/// ) +/// ``` +@immutable +class Toast extends StatelessWidget { + /// The message text to display in the toast. + final String message; + + /// Visual variant controlling the background/text tone. + final ToastVariant variant; + + /// Optional className override. When provided, replaces the recipe output. + final String? className; + + /// Creates a [Toast]. + const Toast({ + super.key, + required this.message, + this.variant = ToastVariant.info, + this.className, + }); + + @override + Widget build(BuildContext context) { + // 1. Resolve className from recipe (or caller override). + final resolvedClassName = className ?? + buildToastRecipe()( + variants: {kToastVariantAxis: variant.name}, + ); + + // 2. Render the toast banner. + return WDiv( + className: resolvedClassName, + child: WText(message, className: 'text-sm font-medium'), + ); + } +} diff --git a/lib/src/ui/components/toast/toast.preview.dart b/lib/src/ui/components/toast/toast.preview.dart new file mode 100644 index 0000000..f15b59a --- /dev/null +++ b/lib/src/ui/components/toast/toast.preview.dart @@ -0,0 +1,28 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'toast.dart'; + +/// Static variant-matrix preview for [Toast]. +/// +/// Renders each [ToastVariant] in sequence so the preview catalog can show +/// the full tone surface in light and dark. One preview class per file is +/// the canonical Wave 4 contract. +class ToastPreview extends StatelessWidget { + /// Creates the toast variant-matrix preview. + const ToastPreview({super.key}); + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex flex-col gap-4 p-6', + children: [ + for (final variant in ToastVariant.values) + Toast( + message: '${variant.name}: example toast notification', + variant: variant, + ), + ], + ); + } +} diff --git a/lib/src/ui/components/toast/toast.recipe.dart b/lib/src/ui/components/toast/toast.recipe.dart new file mode 100644 index 0000000..a179c7a --- /dev/null +++ b/lib/src/ui/components/toast/toast.recipe.dart @@ -0,0 +1,27 @@ +import 'package:magic/magic.dart'; + +import 'toast.dart' show ToastVariant; + +/// The variant axis key for the toast recipe (`ToastVariant..name`). +const String kToastVariantAxis = 'variant'; + +/// Builds the toast [WindRecipe] using semantic tokens. +/// +/// The recipe maps each [ToastVariant] to a bg-* semantic token so the toast +/// tone follows the design-system color ramp without hardcoded hex values. +/// +/// Emission order: `base ++ variant ++ caller` — no compound variants. +WindRecipe buildToastRecipe() { + return const WindRecipe( + base: 'flex flex-row items-center gap-3 px-4 py-3 rounded-lg shadow-md', + variants: { + kToastVariantAxis: { + 'info': 'bg-surface border border-color-border text-fg', + 'success': 'bg-success text-white', + 'warning': 'bg-warning text-white', + 'error': 'bg-destructive text-white', + }, + }, + defaultVariants: {kToastVariantAxis: 'info'}, + ); +} diff --git a/lib/src/ui/components/tooltip/index.dart b/lib/src/ui/components/tooltip/index.dart new file mode 100644 index 0000000..c332989 --- /dev/null +++ b/lib/src/ui/components/tooltip/index.dart @@ -0,0 +1,7 @@ +// Tooltip component — folder-local barrel. +// +// Canonical Wave 4 atomic-component shape. The preview is NOT re-exported; +// `previews:refresh` discovers `*.preview.dart` files directly. + +export 'tooltip.dart' show Tooltip; +export 'tooltip.recipe.dart'; diff --git a/lib/src/ui/components/tooltip/tooltip.dart b/lib/src/ui/components/tooltip/tooltip.dart new file mode 100644 index 0000000..b4b0755 --- /dev/null +++ b/lib/src/ui/components/tooltip/tooltip.dart @@ -0,0 +1,65 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +/// A reusable tooltip component that wraps [WPopover] to display a brief +/// label on hover (desktop) or long-press (mobile). +/// +/// The tooltip content is shown above the [child] trigger by default. +/// All styling is className-driven (semantic tokens). +/// +/// **WPopover dismiss race note**: the `_suppressNextTapOutside` guard in +/// [WPopover] handles the real-click dismiss race (see `wind/w_popover.dart` +/// lines 231-240). Tooltip uses `enableTriggerOnTap: false` and a +/// [PopoverController] for programmatic show/hide so the synthetic-tap +/// behavior remains intact and no real-click race can be introduced. +/// +/// ### Example +/// ```dart +/// Tooltip( +/// content: const WText('Saves your current draft'), +/// child: const WText('Save'), +/// ) +/// ``` +@immutable +class Tooltip extends StatelessWidget { + /// The widget that acts as the anchor/trigger for the tooltip. + final Widget child; + + /// The tooltip content widget shown in the popover panel. + final Widget content; + + /// Optional className for the tooltip panel. Defaults to a standard dark + /// popover style using semantic tokens. + final String? className; + + /// Popover alignment relative to the trigger. Defaults to [PopoverAlignment.topCenter]. + final PopoverAlignment alignment; + + /// Creates a [Tooltip]. + const Tooltip({ + super.key, + required this.child, + required this.content, + this.className, + this.alignment = PopoverAlignment.topCenter, + }); + + @override + Widget build(BuildContext context) { + // 1. Resolve panel className from argument or default token classes. + final panelClassName = className ?? + 'bg-gray-900 dark:bg-gray-700 text-white text-xs px-2 py-1 rounded max-w-xs'; + + // 2. Wrap the trigger in WPopover for hover/tap-driven display. + // enableTriggerOnTap: true lets tapping the trigger show/hide the + // tooltip on touch screens; the _suppressNextTapOutside guard in + // WPopover prevents the same-frame dismiss race. + return WPopover( + alignment: alignment, + className: panelClassName, + enableTriggerOnTap: true, + triggerBuilder: (_, __, ___) => child, + contentBuilder: (_, close) => content, + ); + } +} diff --git a/lib/src/ui/components/tooltip/tooltip.preview.dart b/lib/src/ui/components/tooltip/tooltip.preview.dart new file mode 100644 index 0000000..6aa9814 --- /dev/null +++ b/lib/src/ui/components/tooltip/tooltip.preview.dart @@ -0,0 +1,49 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'tooltip.dart'; + +/// Static preview for the [Tooltip] component. +/// +/// Renders tooltips with different alignment options in a matrix so the +/// preview catalog can display them in light and dark. One preview class +/// per file is the canonical Wave 4 contract. +class TooltipPreview extends StatelessWidget { + /// Creates the tooltip preview. + const TooltipPreview({super.key}); + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex flex-row gap-8 p-12 justify-center items-center', + children: [ + Tooltip( + content: const WText('Tooltip above'), + alignment: PopoverAlignment.topCenter, + child: WDiv( + className: + 'px-4 py-2 rounded-lg bg-surface border border-color-border text-sm', + child: const WText('Hover (top)'), + ), + ), + Tooltip( + content: const WText('Tooltip below'), + alignment: PopoverAlignment.bottomCenter, + child: WDiv( + className: + 'px-4 py-2 rounded-lg bg-surface border border-color-border text-sm', + child: const WText('Hover (bottom)'), + ), + ), + Tooltip( + content: const WText('Custom styled tooltip'), + className: 'bg-primary text-white text-xs px-2 py-1 rounded', + child: WDiv( + className: 'px-4 py-2 rounded-lg bg-primary text-white text-sm', + child: const WText('Hover (primary)'), + ), + ), + ], + ); + } +} diff --git a/lib/src/ui/components/tooltip/tooltip.recipe.dart b/lib/src/ui/components/tooltip/tooltip.recipe.dart new file mode 100644 index 0000000..5fe0f27 --- /dev/null +++ b/lib/src/ui/components/tooltip/tooltip.recipe.dart @@ -0,0 +1,6 @@ +/// Default className for the [Tooltip] popover panel. +/// +/// Uses semantic tokens for background and text so the tooltip adapts to +/// light and dark theme without hardcoded hex values. +const String kTooltipDefaultPanelClassName = + 'bg-gray-900 dark:bg-gray-700 text-white text-xs px-2 py-1 rounded max-w-xs'; diff --git a/lib/src/ui/components/typography/index.dart b/lib/src/ui/components/typography/index.dart new file mode 100644 index 0000000..bcf598c --- /dev/null +++ b/lib/src/ui/components/typography/index.dart @@ -0,0 +1,10 @@ +// Typography component — folder-local barrel. +// +// Canonical Wave 4 atomic-component shape: the recipe, the component, and the +// preview each live in their own dotted-suffix file; this index re-exports the +// public surface (component + variant enum). The preview is intentionally NOT +// re-exported here — `previews:refresh` discovers `*.preview.dart` files +// directly, and the preview must stay out of the release barrel. + +export 'typography.dart' show Typography, TypographyVariant; +export 'typography.recipe.dart'; diff --git a/lib/src/ui/components/typography/typography.dart b/lib/src/ui/components/typography/typography.dart new file mode 100644 index 0000000..dd599bb --- /dev/null +++ b/lib/src/ui/components/typography/typography.dart @@ -0,0 +1,79 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'typography.recipe.dart'; + +/// Visual variants for [Typography]. +/// +/// - [h1] — Display heading: largest size, bold weight. +/// - [h2] — Section heading: large size, bold weight. +/// - [h3] — Sub-section heading: medium size, semibold weight. +/// - [body] — Default body text: base size, normal weight, relaxed leading. +/// - [caption] — Supporting caption text: smaller size, muted foreground. +enum TypographyVariant { + /// Display heading: `text-4xl font-bold`. + h1, + + /// Section heading: `text-3xl font-bold`. + h2, + + /// Sub-section heading: `text-2xl font-semibold`. + h3, + + /// Body text: `text-base font-normal`. + body, + + /// Caption / label text: `text-sm text-fg-muted`. + caption, +} + +/// A polymorphic text component built on [WText]. +/// +/// Resolves its className through a [WindRecipe] (`typography.recipe.dart`) +/// so every variant reads from the semantic token alias map. No raw hex or +/// `Colors.*` anywhere. +/// +/// ```dart +/// Typography('Page Title', variant: TypographyVariant.h1) +/// Typography('Some paragraph text.') // defaults to body +/// Typography('Created at 09:00', variant: TypographyVariant.caption) +/// ``` +@immutable +class Typography extends StatelessWidget { + /// The text content to display. + final String data; + + /// The typographic scale variant controlling size, weight, and leading. + /// + /// Defaults to [TypographyVariant.body]. + final TypographyVariant variant; + + /// Optional className override; bypasses the recipe entirely when supplied. + final String? className; + + /// Creates a [Typography] widget. + const Typography( + this.data, { + super.key, + this.variant = TypographyVariant.body, + this.className, + }); + + /// Resolves the final className from the recipe or caller override. + String _resolveClassName() { + if (className != null) { + return className!; + } + return typographyRecipe( + variants: {kTypographyVariantAxis: variant.name}, + ); + } + + @override + Widget build(BuildContext context) { + return WText( + data, + className: _resolveClassName(), + ); + } +} diff --git a/lib/src/ui/components/typography/typography.preview.dart b/lib/src/ui/components/typography/typography.preview.dart new file mode 100644 index 0000000..b118915 --- /dev/null +++ b/lib/src/ui/components/typography/typography.preview.dart @@ -0,0 +1,37 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'typography.dart'; + +/// Static variant-matrix preview for [Typography]. +/// +/// Renders every [TypographyVariant] so the catalog can show the full scale +/// in light and dark. One preview class per file is the canonical Wave 4 +/// contract. +class TypographyPreview extends StatelessWidget { + /// Creates the typography variant-matrix preview. + const TypographyPreview({super.key}); + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex flex-col gap-4 p-6', + children: [ + for (final variant in TypographyVariant.values) + WDiv( + className: 'flex flex-col gap-1', + children: [ + Typography( + 'The quick brown fox — ${variant.name}', + variant: variant, + ), + WText( + variant.name, + className: 'text-xs text-fg-muted', + ), + ], + ), + ], + ); + } +} diff --git a/lib/src/ui/components/typography/typography.recipe.dart b/lib/src/ui/components/typography/typography.recipe.dart new file mode 100644 index 0000000..254824d --- /dev/null +++ b/lib/src/ui/components/typography/typography.recipe.dart @@ -0,0 +1,34 @@ +import 'package:magic/magic.dart'; + +/// The variant axis key for the typography recipe +/// (`TypographyVariant..name`). +const String kTypographyVariantAxis = 'variant'; + +/// Builds the typography [WindRecipe] using semantic tokens. +/// +/// The recipe is a top-level const because the typography component has no +/// theme-override hook; variants read from semantic tokens and scale values. +/// +/// Emission order: `base ++ variant-classes`. +/// +/// Variant -> className mapping (all inherit `text-fg` from base): +/// - h1: `text-4xl font-bold leading-tight` +/// - h2: `text-3xl font-bold leading-tight` +/// - h3: `text-2xl font-semibold leading-snug` +/// - body: `text-base font-normal leading-relaxed` +/// - caption: `text-sm font-normal text-fg-muted` +const WindRecipe typographyRecipe = WindRecipe( + base: 'text-fg', + variants: { + kTypographyVariantAxis: { + 'h1': 'text-4xl font-bold leading-tight', + 'h2': 'text-3xl font-bold leading-tight', + 'h3': 'text-2xl font-semibold leading-snug', + 'body': 'text-base font-normal leading-relaxed', + 'caption': 'text-sm font-normal text-fg-muted', + }, + }, + defaultVariants: { + kTypographyVariantAxis: 'body', + }, +); diff --git a/lib/src/ui/components/user_profile_dropdown/user_profile_dropdown.dart b/lib/src/ui/components/user_profile_dropdown/user_profile_dropdown.dart index 8b82cd5..e9163bf 100644 --- a/lib/src/ui/components/user_profile_dropdown/user_profile_dropdown.dart +++ b/lib/src/ui/components/user_profile_dropdown/user_profile_dropdown.dart @@ -158,8 +158,7 @@ class UserProfileDropdown extends StatelessWidget { ], ), WDiv( - className: - 'h-[1px] bg-gray-200 dark:bg-gray-700 my-1 mx-2 w-full'), + className: 'h-[1px] bg-gray-200 dark:bg-gray-700 my-1 mx-2 w-full'), _buildMenuItem( icon: Icons.logout, label: trans('auth.logout'), diff --git a/lib/src/ui/widgets/magic_starter_confirm_dialog.dart b/lib/src/ui/widgets/magic_starter_confirm_dialog.dart index 74c0bac..0722205 100644 --- a/lib/src/ui/widgets/magic_starter_confirm_dialog.dart +++ b/lib/src/ui/widgets/magic_starter_confirm_dialog.dart @@ -1,59 +1,38 @@ -import 'package:flutter/material.dart'; -import 'package:magic/magic.dart'; +import 'package:flutter/material.dart' as m show BuildContext, showDialog; -import '../../facades/magic_starter.dart'; -import 'magic_starter_dialog_shell.dart'; +import '../components/confirm_dialog/confirm_dialog.dart'; -/// Visual style variants for [MagicStarterConfirmDialog]. -enum ConfirmDialogVariant { - /// Default confirmation with primary-colored button. - primary, +export '../components/confirm_dialog/confirm_dialog.dart' + show ConfirmDialogVariant; - /// Destructive action with red button. - danger, - - /// Cautionary action with amber button. - warning, -} - -/// A reusable confirm/cancel dialog built on [MagicStarterDialogShell]. -/// -/// Supports three visual variants ([ConfirmDialogVariant]) and an optional -/// async [onConfirm] callback with double-click protection via [_isLoading]. +/// Thin backwards-compatible alias for the migrated [ConfirmDialog] component. /// -/// Returns `true` on confirm, `false` on cancel. -class MagicStarterConfirmDialog extends StatefulWidget { - /// Dialog heading text. - final String title; - - /// Optional supporting text rendered below the title. - final String? description; - - /// Label for the confirm button. Defaults to `trans('common.confirm')`. - final String? confirmLabel; - - /// Label for the cancel button. Defaults to `trans('common.cancel')`. - final String? cancelLabel; - - /// Visual variant that controls the confirm button colour. - final ConfirmDialogVariant variant; - - /// Optional async callback invoked when the user taps the confirm button. - final Future Function()? onConfirm; - +/// The confirm dialog moved to the canonical atomic-component folder +/// (`lib/src/ui/components/confirm_dialog/`) as part of the design-system +/// migration. This subclass preserves the historic `MagicStarterConfirmDialog` +/// name, constructor signature, [show] factory, and barrel export path so +/// existing callers and the widget test suite stay untouched. New code should +/// import [ConfirmDialog] directly. +class MagicStarterConfirmDialog extends ConfirmDialog { + /// Creates a [MagicStarterConfirmDialog] (alias of [ConfirmDialog]). const MagicStarterConfirmDialog({ super.key, - required this.title, - this.description, - this.confirmLabel, - this.cancelLabel, - this.variant = ConfirmDialogVariant.primary, - this.onConfirm, + required super.title, + super.description, + super.confirmLabel, + super.cancelLabel, + super.variant, + super.onConfirm, }); - /// Opens the dialog and returns `true` if confirmed, `false` if cancelled. + /// Opens a [MagicStarterConfirmDialog] and returns `true` if confirmed, + /// `false` if cancelled. + /// + /// Overrides [ConfirmDialog.show] to create a [MagicStarterConfirmDialog] + /// instance so `find.byType(MagicStarterConfirmDialog)` works in existing + /// tests and callers. static Future show( - BuildContext context, { + m.BuildContext context, { required String title, String? description, String? confirmLabel, @@ -61,90 +40,19 @@ class MagicStarterConfirmDialog extends StatefulWidget { ConfirmDialogVariant variant = ConfirmDialogVariant.primary, Future Function()? onConfirm, }) { - return showDialog( - context: context, - barrierDismissible: false, - builder: (_) => MagicStarterConfirmDialog( - title: title, - description: description, - confirmLabel: confirmLabel, - cancelLabel: cancelLabel, - variant: variant, - onConfirm: onConfirm, - ), - ).then((v) => v ?? false); - } - - @override - State createState() => - _MagicStarterConfirmDialogState(); -} - -class _MagicStarterConfirmDialogState extends State { - bool _isLoading = false; - - Future _onConfirm() async { - if (_isLoading) return; - - setState(() => _isLoading = true); - - try { - await widget.onConfirm?.call(); - } catch (e, stackTrace) { - Log.error('[MagicStarterConfirmDialog._onConfirm] $e\n$stackTrace'); - if (!mounted) return; - setState(() => _isLoading = false); - return; - } - - if (!mounted) return; - - Navigator.of(context).pop(true); - } - - void _onCancel() { - if (_isLoading) return; - Navigator.of(context).pop(false); - } - - String _resolveConfirmClassName() { - final theme = MagicStarter.manager.modalTheme; - - return switch (widget.variant) { - ConfirmDialogVariant.primary => theme.primaryButtonClassName, - ConfirmDialogVariant.danger => theme.dangerButtonClassName, - ConfirmDialogVariant.warning => theme.warningButtonClassName, - }; - } - - @override - Widget build(BuildContext context) { - final theme = MagicStarter.manager.modalTheme; - final confirmLabel = widget.confirmLabel ?? trans('common.confirm'); - final cancelLabel = widget.cancelLabel ?? trans('common.cancel'); - - return MagicStarterDialogShell( - title: widget.title, - description: widget.description, - body: const SizedBox.shrink(), - footerBuilder: (_) => WDiv( - className: 'flex flex-row justify-end gap-2 wrap', - children: [ - WAnchor( - onTap: _isLoading ? null : _onCancel, - child: WDiv( - className: theme.secondaryButtonClassName, - child: WText(cancelLabel), - ), - ), - WButton( - onTap: _isLoading ? null : _onConfirm, - isLoading: _isLoading, - className: _resolveConfirmClassName(), - child: WText(confirmLabel), + return m + .showDialog( + context: context, + barrierDismissible: false, + builder: (_) => MagicStarterConfirmDialog( + title: title, + description: description, + confirmLabel: confirmLabel, + cancelLabel: cancelLabel, + variant: variant, + onConfirm: onConfirm, ), - ], - ), - ); + ) + .then((v) => v ?? false); } } diff --git a/lib/src/ui/widgets/magic_starter_dialog_shell.dart b/lib/src/ui/widgets/magic_starter_dialog_shell.dart index 77ba6b1..65bffaf 100644 --- a/lib/src/ui/widgets/magic_starter_dialog_shell.dart +++ b/lib/src/ui/widgets/magic_starter_dialog_shell.dart @@ -1,113 +1,19 @@ -import 'package:flutter/material.dart'; -import 'package:magic/magic.dart'; +import '../components/dialog/dialog.dart'; -import '../../facades/magic_starter.dart'; - -/// 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. +/// Thin backwards-compatible alias for the migrated [Dialog] component. /// -/// **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; - - /// 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; - +/// `MagicStarterDialogShell` moved to the canonical atomic-component folder +/// (`lib/src/ui/components/dialog/`) under the name [Dialog]. This subclass +/// preserves the historic `MagicStarterDialogShell` name and constructor +/// signature so existing callers, tests, and the barrel stay untouched. +/// New code should import [Dialog] directly. +class MagicStarterDialogShell extends Dialog { + /// Creates a [MagicStarterDialogShell] (alias of [Dialog]). const MagicStarterDialogShell({ super.key, - this.title, - this.description, - required this.body, - this.footerBuilder, + super.title, + super.description, + required super.body, + super.footerBuilder, }); - - @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, - vertical: 24, - ), - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: theme.maxWidth, - maxHeight: safeHeight * 0.85, - ), - child: WDiv( - className: '${theme.containerClassName} w-full overflow-hidden', - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (title != null || description != null) - WDiv( - className: theme.headerClassName, - children: [ - if (title != null) - WText( - title!, - className: theme.titleClassName, - ), - if (description != null) - WText( - description!, - className: theme.descriptionClassName, - ), - ], - ), - Flexible( - child: ListView( - shrinkWrap: true, - padding: EdgeInsets.zero, - children: [ - WDiv( - className: theme.bodyClassName, - child: body, - ), - ], - ), - ), - if (footerBuilder != null) - Builder( - builder: (dialogContext) => WDiv( - key: const Key('magic_starter_dialog_shell_footer'), - className: theme.footerClassName, - child: footerBuilder!(dialogContext), - ), - ), - ], - ), - ), - ), - ); - } } diff --git a/lib/src/ui/widgets/magic_starter_timezone_select.dart b/lib/src/ui/widgets/magic_starter_timezone_select.dart index 78aed58..2eebe44 100644 --- a/lib/src/ui/widgets/magic_starter_timezone_select.dart +++ b/lib/src/ui/widgets/magic_starter_timezone_select.dart @@ -3,6 +3,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:magic/magic.dart'; +import '../components/select/select.recipe.dart'; + /// Searchable timezone select widget for Magic Starter. /// /// Fetches available timezones from the `GET /timezones` API endpoint @@ -198,6 +200,9 @@ class _MagicStarterTimezoneSelectState @override Widget build(BuildContext context) { + // Resolve the select slot recipe for semantic-token defaults. + final slots = selectRecipe(); + if (_isInitializing) { return WDiv( children: [ @@ -205,11 +210,10 @@ class _MagicStarterTimezoneSelectState WText( widget.label!, className: widget.labelClassName ?? - 'text-sm font-medium text-gray-700 dark:text-gray-300 mb-1', + 'text-sm font-medium text-fg-muted mb-1', ), WDiv( - className: - 'w-full px-3 py-3 rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 flex items-center justify-center', + className: widget.className ?? slots['trigger'], child: const SizedBox( width: 20, height: 20, @@ -227,14 +231,12 @@ class _MagicStarterTimezoneSelectState searchable: true, onSearch: _handleSearch, label: widget.label, - labelClassName: widget.labelClassName ?? - 'text-sm font-medium text-gray-700 dark:text-gray-300 mb-1', + labelClassName: + widget.labelClassName ?? 'text-sm font-medium text-fg-muted mb-1', searchPlaceholder: widget.placeholder ?? trans('profile.timezone_search'), placeholder: widget.placeholder ?? trans('profile.timezone_select'), - className: widget.className ?? - 'w-full px-3 py-3 rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-900 dark:text-white focus:border-primary', - menuClassName: widget.menuClassName ?? - 'bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700', + className: widget.className ?? slots['trigger'], + menuClassName: widget.menuClassName ?? slots['popup'], ); } } diff --git a/test/ui/components/accordion/accordion_test.dart b/test/ui/components/accordion/accordion_test.dart new file mode 100644 index 0000000..f909f92 --- /dev/null +++ b/test/ui/components/accordion/accordion_test.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; +import 'package:magic_starter/magic_starter.dart'; + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + Magic.singleton('magic_starter', () => MagicStarterManager()); + }); + + tearDown(() { + MagicApp.reset(); + Magic.flush(); + }); + + Widget wrap(Widget widget) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: widget), + ), + ); + } + + // --------------------------------------------------------------------------- + // Recipe slot-class assertions + // --------------------------------------------------------------------------- + + group('accordion recipe', () { + test('root slot is non-empty', () { + final cls = accordionRecipe(); + expect(cls['root'], isNotEmpty); + }); + + test('item slot is non-empty', () { + final cls = accordionRecipe(); + expect(cls['item'], isNotEmpty); + }); + + test('header slot is non-empty', () { + final cls = accordionRecipe(); + expect(cls['header'], isNotEmpty); + }); + + test('trigger slot is non-empty', () { + final cls = accordionRecipe(); + expect(cls['trigger'], isNotEmpty); + }); + + test('panel slot is non-empty', () { + final cls = accordionRecipe(); + expect(cls['panel'], isNotEmpty); + }); + + test('all five required slots are present', () { + final cls = accordionRecipe(); + expect( + cls.keys, + containsAll(['root', 'item', 'header', 'trigger', 'panel']), + ); + }); + }); + + // --------------------------------------------------------------------------- + // Widget interaction tests + // --------------------------------------------------------------------------- + + group('Accordion widget', () { + testWidgets('renders item titles', (tester) async { + await tester.pumpWidget( + wrap( + Accordion( + items: const [ + AccordionItem(title: 'Section 1', body: Text('Content 1')), + AccordionItem(title: 'Section 2', body: Text('Content 2')), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Section 1'), findsOneWidget); + expect(find.text('Section 2'), findsOneWidget); + }); + + testWidgets('content is hidden by default (collapsed)', (tester) async { + await tester.pumpWidget( + wrap( + Accordion( + items: const [ + AccordionItem(title: 'Section 1', body: Text('Content 1')), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Content 1'), findsNothing); + }); + + testWidgets('tapping header expands the panel', (tester) async { + await tester.pumpWidget( + wrap( + Accordion( + items: const [ + AccordionItem(title: 'Section 1', body: Text('Content 1')), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Section 1')); + await tester.pumpAndSettle(); + + expect(find.text('Content 1'), findsOneWidget); + }); + + testWidgets('tapping expanded header collapses the panel', (tester) async { + await tester.pumpWidget( + wrap( + Accordion( + items: const [ + AccordionItem(title: 'Section 1', body: Text('Content 1')), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + // Expand. + await tester.tap(find.text('Section 1')); + await tester.pumpAndSettle(); + expect(find.text('Content 1'), findsOneWidget); + + // Collapse. + await tester.tap(find.text('Section 1')); + await tester.pumpAndSettle(); + expect(find.text('Content 1'), findsNothing); + }); + }); +} diff --git a/test/ui/components/badge/badge_test.dart b/test/ui/components/badge/badge_test.dart new file mode 100644 index 0000000..4fc9702 --- /dev/null +++ b/test/ui/components/badge/badge_test.dart @@ -0,0 +1,148 @@ +// hide Flutter's material Badge to avoid the name conflict with our Badge. +import 'package:flutter/material.dart' hide Badge; +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/components/badge/badge.preview.dart'; + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + Magic.singleton('magic_starter', () => MagicStarterManager()); + }); + + tearDown(() { + MagicApp.reset(); + Magic.flush(); + }); + + Widget wrap(Widget widget) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: widget), + ), + ); + } + + // --------------------------------------------------------------------------- + // Recipe variant-class assertions (TDD red: these must fail before impl) + // --------------------------------------------------------------------------- + + group('badge recipe', () { + test('neutral tone emits bg-surface-container-high token', () { + final cls = badgeRecipe( + variants: {'tone': BadgeTone.neutral.name}, + ); + expect(cls, contains('bg-surface-container-high')); + }); + + test('primary tone emits bg-primary token', () { + final cls = badgeRecipe( + variants: {'tone': BadgeTone.primary.name}, + ); + expect(cls, contains('bg-primary')); + }); + + test('accent tone emits bg-accent token', () { + final cls = badgeRecipe( + variants: {'tone': BadgeTone.accent.name}, + ); + expect(cls, contains('bg-accent')); + }); + + test('success tone emits bg-success token', () { + final cls = badgeRecipe( + variants: {'tone': BadgeTone.success.name}, + ); + expect(cls, contains('bg-success')); + }); + + test('warning tone emits bg-warning token', () { + final cls = badgeRecipe( + variants: {'tone': BadgeTone.warning.name}, + ); + expect(cls, contains('bg-warning')); + }); + + test('destructive tone emits bg-destructive token', () { + final cls = badgeRecipe( + variants: {'tone': BadgeTone.destructive.name}, + ); + expect(cls, contains('bg-destructive')); + }); + + test('outline tone emits border token and no solid background', () { + final cls = badgeRecipe( + variants: {'tone': BadgeTone.outline.name}, + ); + expect(cls, contains('border')); + expect(cls, isNot(contains('bg-primary'))); + expect(cls, isNot(contains('bg-success'))); + }); + + test('default variant produces neutral tone when nothing is passed', () { + final cls = badgeRecipe(); + expect(cls, contains('bg-surface-container-high')); + }); + + test('emission order: base precedes variant classes', () { + final cls = badgeRecipe( + variants: {'tone': BadgeTone.primary.name}, + ); + final baseIdx = cls.indexOf('inline-flex'); + final variantIdx = cls.indexOf('bg-primary'); + expect(baseIdx, lessThan(variantIdx)); + }); + }); + + // --------------------------------------------------------------------------- + // Widget tests + // --------------------------------------------------------------------------- + + testWidgets('Badge renders label text', (tester) async { + await tester.pumpWidget( + wrap(const Badge('Active')), + ); + expect(find.text('Active'), findsOneWidget); + }); + + testWidgets('Badge default tone is neutral', (tester) async { + await tester.pumpWidget( + wrap(const Badge('Label')), + ); + final wBadge = tester.widget(find.byType(WBadge)); + expect(wBadge.className, contains('bg-surface-container-high')); + }); + + testWidgets('Badge applies primary tone className', (tester) async { + await tester.pumpWidget( + wrap(const Badge('Label', tone: BadgeTone.primary)), + ); + final wBadge = tester.widget(find.byType(WBadge)); + expect(wBadge.className, contains('bg-primary')); + }); + + testWidgets('Badge applies outline tone with border', (tester) async { + await tester.pumpWidget( + wrap(const Badge('Label', tone: BadgeTone.outline)), + ); + final wBadge = tester.widget(find.byType(WBadge)); + expect(wBadge.className, contains('border')); + expect(wBadge.className, isNot(contains('bg-primary'))); + }); + + testWidgets('Badge light+dark preview renders without error', (tester) async { + await tester.pumpWidget(wrap(const BadgePreview())); + await tester.pump(); + expect(find.byType(BadgePreview), findsOneWidget); + }); + + testWidgets('BadgePreview renders all tones', (tester) async { + await tester.pumpWidget(wrap(const BadgePreview())); + await tester.pump(); + final badges = tester.widgetList(find.byType(WBadge)); + expect(badges.length, greaterThanOrEqualTo(BadgeTone.values.length)); + }); +} diff --git a/test/ui/components/bottom_sheet/bottom_sheet_test.dart b/test/ui/components/bottom_sheet/bottom_sheet_test.dart new file mode 100644 index 0000000..d8ef166 --- /dev/null +++ b/test/ui/components/bottom_sheet/bottom_sheet_test.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart' hide BottomSheet; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; +import 'package:magic_starter/magic_starter.dart'; + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + Magic.singleton('magic_starter', () => MagicStarterManager()); + Magic.singleton('log', () => LogManager()); + Config.set('logging', { + 'default': 'console', + 'channels': { + 'console': {'driver': 'console', 'level': 'debug'}, + }, + }); + Config.set('wind.colors.primary', 'indigo'); + }); + + Widget wrap(Widget widget) { + final themeData = WindThemeData( + colors: {'primary': Colors.indigo}, + ); + return WindTheme( + data: themeData, + child: MaterialApp( + theme: themeData.toThemeData(), + home: Scaffold(body: widget), + ), + ); + } + + group('BottomSheet', () { + testWidgets('renders title when provided', (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 BottomSheet( + title: 'Sheet Title', + body: Text('sheet body'), + ), + )); + + expect(find.text('Sheet Title'), findsOneWidget); + }); + + testWidgets('renders body content', (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 BottomSheet( + body: Text('unique sheet content'), + ), + )); + + expect(find.text('unique sheet content'), findsOneWidget); + }); + + testWidgets('static show() opens bottom sheet', (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( + Builder( + builder: (context) => ElevatedButton( + onPressed: () => BottomSheet.show( + context, + title: 'Bottom Sheet', + body: const Text('sheet body'), + ), + child: const Text('Open'), + ), + ), + )); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('Bottom Sheet'), findsOneWidget); + expect(find.text('sheet body'), findsOneWidget); + }); + + testWidgets('renders footer via footerBuilder', (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( + BottomSheet( + body: const Text('body'), + footerBuilder: (_) => const Text('footer content'), + ), + )); + + expect(find.text('footer content'), findsOneWidget); + }); + }); + + // Verify BottomSheet is re-exported from index.dart + test('BottomSheet is re-exported from index.dart', () { + expect(BottomSheet, isNotNull); + }); +} diff --git a/test/ui/components/button/button_test.dart b/test/ui/components/button/button_test.dart new file mode 100644 index 0000000..e2b2b4d --- /dev/null +++ b/test/ui/components/button/button_test.dart @@ -0,0 +1,189 @@ +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/components/button/button.preview.dart'; + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + Magic.singleton('magic_starter', () => MagicStarterManager()); + }); + + tearDown(() { + MagicApp.reset(); + Magic.flush(); + }); + + Widget wrap(Widget widget) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: widget), + ), + ); + } + + // --------------------------------------------------------------------------- + // Recipe variant-class assertions (TDD red: these must fail before impl) + // --------------------------------------------------------------------------- + + group('button recipe', () { + test('primary intent emits bg-primary token', () { + final cls = buttonRecipe( + variants: { + 'intent': ButtonIntent.primary.name, + 'size': ButtonSize.md.name + }, + ); + expect(cls, contains('bg-primary')); + }); + + test('secondary intent emits bg-surface-container-high token', () { + final cls = buttonRecipe( + variants: { + 'intent': ButtonIntent.secondary.name, + 'size': ButtonSize.md.name + }, + ); + expect(cls, contains('bg-surface-container-high')); + }); + + test('ghost intent emits transparent background base', () { + final cls = buttonRecipe( + variants: { + 'intent': ButtonIntent.ghost.name, + 'size': ButtonSize.md.name + }, + ); + expect(cls, isNot(contains('bg-primary'))); + // Ghost starts with transparent but may have hover:bg-* — that is expected. + expect(cls, contains('bg-transparent')); + }); + + test('destructive intent emits bg-destructive token', () { + final cls = buttonRecipe( + variants: { + 'intent': ButtonIntent.destructive.name, + 'size': ButtonSize.md.name + }, + ); + expect(cls, contains('bg-destructive')); + }); + + test('sm size emits text-sm', () { + final cls = buttonRecipe( + variants: { + 'intent': ButtonIntent.primary.name, + 'size': ButtonSize.sm.name + }, + ); + expect(cls, contains('text-sm')); + }); + + test('lg size emits text-base or text-lg', () { + final cls = buttonRecipe( + variants: { + 'intent': ButtonIntent.primary.name, + 'size': ButtonSize.lg.name + }, + ); + // lg size should have some text size token + expect(cls, isNotEmpty); + }); + + test('default variants produce primary+md when nothing is passed', () { + final cls = buttonRecipe(); + expect(cls, contains('bg-primary')); + }); + + test('emission order: base precedes variant classes', () { + final cls = buttonRecipe( + variants: { + 'intent': ButtonIntent.primary.name, + 'size': ButtonSize.md.name + }, + ); + // base contains inline-flex; primary bg-primary + final baseIdx = cls.indexOf('inline-flex'); + final variantIdx = cls.indexOf('bg-primary'); + expect(baseIdx, lessThan(variantIdx)); + }); + }); + + // --------------------------------------------------------------------------- + // Widget tests + // --------------------------------------------------------------------------- + + testWidgets('Button renders child', (tester) async { + const childKey = Key('btn-child'); + await tester.pumpWidget( + wrap( + Button( + onPressed: () {}, + child: const SizedBox(key: childKey), + ), + ), + ); + expect(find.byKey(childKey), findsOneWidget); + }); + + testWidgets('Button applies primary className by default', (tester) async { + await tester.pumpWidget( + wrap( + Button( + onPressed: () {}, + child: const SizedBox(), + ), + ), + ); + final btn = tester.widget(find.byType(WButton)); + expect(btn.className, contains('bg-primary')); + }); + + testWidgets('Button applies destructive className for destructive intent', + (tester) async { + await tester.pumpWidget( + wrap( + Button( + intent: ButtonIntent.destructive, + onPressed: () {}, + child: const SizedBox(), + ), + ), + ); + final btn = tester.widget(find.byType(WButton)); + expect(btn.className, contains('bg-destructive')); + }); + + testWidgets('Button respects isLoading prop', (tester) async { + await tester.pumpWidget( + wrap( + Button( + onPressed: () {}, + isLoading: true, + child: const SizedBox(), + ), + ), + ); + final btn = tester.widget(find.byType(WButton)); + expect(btn.isLoading, isTrue); + }); + + testWidgets('Button light+dark preview renders without error', + (tester) async { + await tester.pumpWidget(wrap(const ButtonPreview())); + await tester.pump(); + expect(find.byType(ButtonPreview), findsOneWidget); + }); + + testWidgets('ButtonPreview renders all intents', (tester) async { + await tester.pumpWidget(wrap(const ButtonPreview())); + await tester.pump(); + + // One WButton per intent x size combination should exist. + final buttons = tester.widgetList(find.byType(WButton)); + expect(buttons.length, greaterThanOrEqualTo(ButtonIntent.values.length)); + }); +} diff --git a/test/ui/components/checkbox/checkbox_test.dart b/test/ui/components/checkbox/checkbox_test.dart new file mode 100644 index 0000000..9615c04 --- /dev/null +++ b/test/ui/components/checkbox/checkbox_test.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart' hide Checkbox; +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/components/checkbox/checkbox.preview.dart'; + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + Magic.singleton('magic_starter', () => MagicStarterManager()); + }); + + tearDown(() { + MagicApp.reset(); + Magic.flush(); + }); + + Widget wrap(Widget widget) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: widget), + ), + ); + } + + // --------------------------------------------------------------------------- + // Recipe variant-class assertions + // --------------------------------------------------------------------------- + + group('checkbox recipe', () { + test('recipe emits rounded and border tokens', () { + final cls = checkboxRecipe(); + expect(cls, contains('rounded')); + expect(cls, contains('border')); + }); + + test('recipe emits bg-primary-container or checked: token', () { + final cls = checkboxRecipe(); + // The base recipe should include checked: state prefixed tokens. + expect(cls, isNotEmpty); + }); + }); + + // --------------------------------------------------------------------------- + // Widget tests + // --------------------------------------------------------------------------- + + testWidgets('Checkbox renders a WCheckbox', (tester) async { + await tester.pumpWidget( + wrap(Checkbox(value: false, onChanged: (_) {})), + ); + expect(find.byType(WCheckbox), findsOneWidget); + }); + + testWidgets('Checkbox reflects value prop on WCheckbox', (tester) async { + await tester.pumpWidget( + wrap(Checkbox(value: true, onChanged: (_) {})), + ); + final widget = tester.widget(find.byType(WCheckbox)); + expect(widget.value, isTrue); + }); + + testWidgets('Checkbox fires onChanged when tapped', (tester) async { + bool? newValue; + await tester.pumpWidget( + wrap(Checkbox(value: false, onChanged: (v) => newValue = v)), + ); + await tester.tap(find.byType(WCheckbox)); + await tester.pump(); + expect(newValue, isTrue); + }); + + testWidgets('Checkbox preview renders without error', (tester) async { + await tester.pumpWidget(wrap(const CheckboxPreview())); + await tester.pump(); + expect(find.byType(CheckboxPreview), findsOneWidget); + }); +} diff --git a/test/ui/components/combobox/combobox_test.dart b/test/ui/components/combobox/combobox_test.dart new file mode 100644 index 0000000..f8437a7 --- /dev/null +++ b/test/ui/components/combobox/combobox_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; +import 'package:magic_starter/magic_starter.dart'; + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + Magic.singleton('magic_starter', () => MagicStarterManager()); + }); + + tearDown(() { + MagicApp.reset(); + Magic.flush(); + }); + + Widget wrap(Widget widget) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: widget), + ), + ); + } + + // --------------------------------------------------------------------------- + // Recipe slot-class assertions + // --------------------------------------------------------------------------- + + group('combobox recipe', () { + test('trigger slot contains bg-surface-container-high', () { + final cls = comboboxRecipe(); + expect(cls['trigger'], contains('bg-surface-container-high')); + }); + + test('popup slot contains bg-surface', () { + final cls = comboboxRecipe(); + expect(cls['popup'], contains('bg-surface')); + }); + + test('item slot is non-empty', () { + final cls = comboboxRecipe(); + expect(cls['item'], isNotEmpty); + }); + + test('all three required slots are present', () { + final cls = comboboxRecipe(); + expect(cls.keys, containsAll(['trigger', 'popup', 'item'])); + }); + }); + + // --------------------------------------------------------------------------- + // Widget interaction tests + // --------------------------------------------------------------------------- + + group('Combobox widget', () { + testWidgets('renders WSelect with searchable=true', (tester) async { + await tester.pumpWidget( + wrap( + Combobox( + value: null, + options: const [SelectOption(value: 'a', label: 'Option A')], + onChange: (_) {}, + ), + ), + ); + await tester.pumpAndSettle(); + + final select = tester.widget>( + find.byType(WSelect), + ); + expect(select.searchable, isTrue); + }); + }); +} diff --git a/test/ui/components/confirm_dialog/confirm_dialog_test.dart b/test/ui/components/confirm_dialog/confirm_dialog_test.dart new file mode 100644 index 0000000..abcfeb8 --- /dev/null +++ b/test/ui/components/confirm_dialog/confirm_dialog_test.dart @@ -0,0 +1,254 @@ +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/components/confirm_dialog/index.dart'; + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + Magic.singleton('magic_starter', () => MagicStarterManager()); + Magic.singleton('log', () => LogManager()); + Config.set('logging', { + 'default': 'console', + 'channels': { + 'console': {'driver': 'console', 'level': 'debug'}, + }, + }); + Config.set('wind.colors.primary', 'indigo'); + }); + + Widget wrap(Widget widget) { + final themeData = WindThemeData( + colors: { + 'primary': Colors.indigo, + 'danger': Colors.red, + 'warning': Colors.amber, + }, + ); + return WindTheme( + data: themeData, + child: MaterialApp( + theme: themeData.toThemeData(), + home: Scaffold( + body: SizedBox( + width: 1200, + height: 800, + child: widget, + ), + ), + ), + ); + } + + // --------------------------------------------------------------------------- + // Recipe variant-class assertions (TDD red) + // --------------------------------------------------------------------------- + + group('confirm dialog recipe', () { + test('primary variant emits primaryButtonClassName', () { + final theme = const MagicStarterModalTheme(); + final cls = resolveConfirmButtonClassName( + ConfirmDialogVariant.primary, + theme, + ); + expect(cls, equals(theme.primaryButtonClassName)); + }); + + test('danger variant emits dangerButtonClassName', () { + final theme = const MagicStarterModalTheme(); + final cls = resolveConfirmButtonClassName( + ConfirmDialogVariant.danger, + theme, + ); + expect(cls, equals(theme.dangerButtonClassName)); + }); + + test('warning variant emits warningButtonClassName', () { + final theme = const MagicStarterModalTheme(); + final cls = resolveConfirmButtonClassName( + ConfirmDialogVariant.warning, + theme, + ); + expect(cls, equals(theme.warningButtonClassName)); + }); + }); + + // --------------------------------------------------------------------------- + // Rendering + // --------------------------------------------------------------------------- + + group('ConfirmDialog rendering', () { + testWidgets('renders title text', (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 ConfirmDialog(title: 'Are you sure?'), + )); + + expect(find.text('Are you sure?'), findsOneWidget); + }); + + testWidgets('renders description when 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 ConfirmDialog( + title: 'Delete?', + description: 'This cannot be undone.', + ), + )); + + expect(find.text('This cannot be undone.'), findsOneWidget); + }); + + testWidgets('renders confirm and cancel buttons with default labels', + (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 ConfirmDialog(title: 'Confirm?'))); + + expect(find.text('common.confirm'), findsOneWidget); + expect(find.text('common.cancel'), findsOneWidget); + }); + + testWidgets('default variant is primary', (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 ConfirmDialog(title: 'Confirm?'))); + + final dialog = tester.widget( + find.byType(ConfirmDialog), + ); + expect(dialog.variant, ConfirmDialogVariant.primary); + }); + + testWidgets('danger variant is set', (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 ConfirmDialog( + title: 'Delete?', + variant: ConfirmDialogVariant.danger, + ), + )); + + final dialog = tester.widget(find.byType(ConfirmDialog)); + expect(dialog.variant, ConfirmDialogVariant.danger); + }); + }); + + // --------------------------------------------------------------------------- + // Return values + // --------------------------------------------------------------------------- + + group('ConfirmDialog return values', () { + testWidgets('returns false on cancel', (tester) async { + tester.view.physicalSize = const Size(1200, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + bool result = true; + + await tester.pumpWidget(wrap( + Builder( + builder: (context) => ElevatedButton( + onPressed: () async { + result = await ConfirmDialog.show(context, title: 'Delete?'); + }, + child: const Text('Show'), + ), + ), + )); + + await tester.tap(find.text('Show')); + await tester.pumpAndSettle(); + await tester.tap(find.text('common.cancel')); + await tester.pumpAndSettle(); + + expect(result, isFalse); + }); + + testWidgets('returns true on confirm', (tester) async { + tester.view.physicalSize = const Size(1200, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + bool result = false; + + await tester.pumpWidget(wrap( + Builder( + builder: (context) => ElevatedButton( + onPressed: () async { + result = await ConfirmDialog.show( + context, + title: 'Delete?', + onConfirm: () async {}, + ); + }, + child: const Text('Show'), + ), + ), + )); + + await tester.tap(find.text('Show')); + await tester.pumpAndSettle(); + await tester.tap(find.text('common.confirm')); + await tester.pumpAndSettle(); + + expect(result, isTrue); + }); + }); + + // --------------------------------------------------------------------------- + // Button layout + // --------------------------------------------------------------------------- + + testWidgets('footer buttons are compact and right-aligned', (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 ConfirmDialog(title: 'Confirm?'))); + + final footerWrapFinder = find + .ancestor( + of: find.text('common.cancel'), + matching: find.byType(Wrap), + ) + .first; + + final wrapWidget = tester.widget(footerWrapFinder); + expect(wrapWidget.alignment, WrapAlignment.end); + }); + + // --------------------------------------------------------------------------- + // ConfirmDialogVariant enum stability (import-path contract) + // --------------------------------------------------------------------------- + + test('ConfirmDialogVariant has primary, danger, warning values', () { + expect(ConfirmDialogVariant.values.length, 3); + expect(ConfirmDialogVariant.primary, isNotNull); + expect(ConfirmDialogVariant.danger, isNotNull); + expect(ConfirmDialogVariant.warning, isNotNull); + }); +} diff --git a/test/ui/components/dialog/dialog_test.dart b/test/ui/components/dialog/dialog_test.dart new file mode 100644 index 0000000..d63ee40 --- /dev/null +++ b/test/ui/components/dialog/dialog_test.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart' hide Dialog; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; +import 'package:magic_starter/magic_starter.dart'; + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + Magic.singleton('magic_starter', () => MagicStarterManager()); + Magic.singleton('log', () => LogManager()); + Config.set('logging', { + 'default': 'console', + 'channels': { + 'console': {'driver': 'console', 'level': 'debug'}, + }, + }); + Config.set('wind.colors.primary', 'indigo'); + }); + + Widget wrap(Widget widget) { + final themeData = WindThemeData( + colors: { + 'primary': Colors.indigo, + }, + ); + return WindTheme( + data: themeData, + child: MaterialApp( + theme: themeData.toThemeData(), + home: Scaffold( + body: SizedBox( + width: 1200, + height: 800, + child: widget, + ), + ), + ), + ); + } + + group('Dialog', () { + testWidgets('renders title when 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 Dialog( + title: 'Test Dialog', + body: Text('body content'), + ), + )); + + expect(find.text('Test Dialog'), findsOneWidget); + }); + + testWidgets('renders body content', (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 Dialog( + body: Text('unique body text'), + ), + )); + + expect(find.text('unique body text'), findsOneWidget); + }); + + testWidgets('renders footer via footerBuilder', (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( + Dialog( + body: const Text('body'), + footerBuilder: (_) => const Text('footer widget'), + ), + )); + + expect(find.text('footer widget'), findsOneWidget); + }); + + testWidgets('static show() opens dialog', (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( + Builder( + builder: (context) => ElevatedButton( + onPressed: () => Dialog.show( + context, + title: 'Opened Dialog', + body: const Text('dialog body'), + ), + child: const Text('Open'), + ), + ), + )); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.byType(Dialog), findsWidgets); + expect(find.text('Opened Dialog'), findsOneWidget); + }); + + testWidgets('reads containerClassName from modal theme', (tester) async { + tester.view.physicalSize = const Size(1200, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + MagicStarter.useModalTheme( + const MagicStarterModalTheme( + containerClassName: 'rounded-3xl bg-blue-50', + ), + ); + + await tester.pumpWidget(wrap( + const Dialog(body: Text('themed')), + )); + + expect(find.byType(Dialog), findsOneWidget); + }); + }); + + // Verify Dialog is re-exported from index.dart + test('Dialog is re-exported from index.dart', () { + // The import of index.dart at the top of this file proves re-export. + expect(Dialog, isNotNull); + }); +} diff --git a/test/ui/components/dropdown_menu/dropdown_menu_test.dart b/test/ui/components/dropdown_menu/dropdown_menu_test.dart new file mode 100644 index 0000000..0fd9b8e --- /dev/null +++ b/test/ui/components/dropdown_menu/dropdown_menu_test.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart' + hide DropdownMenu, DropdownMenuItem, DropdownMenuEntry; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; +import 'package:magic_starter/magic_starter.dart'; + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + Magic.singleton('magic_starter', () => MagicStarterManager()); + }); + + Widget wrap(Widget widget) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: Center(child: widget)), + ), + ); + } + + group('DropdownMenu', () { + testWidgets('renders trigger child', (tester) async { + await tester.pumpWidget(wrap( + DropdownMenu( + items: const [DropdownMenuItem(label: 'Item 1')], + child: const Text('open menu'), + ), + )); + + expect(find.text('open menu'), findsOneWidget); + }); + + testWidgets('items are not visible when closed', (tester) async { + await tester.pumpWidget(wrap( + DropdownMenu( + items: const [DropdownMenuItem(label: 'Hidden Item')], + child: const Text('open menu'), + ), + )); + + expect(find.text('Hidden Item'), findsNothing); + }); + + testWidgets('tapping trigger opens the menu items', (tester) async { + await tester.pumpWidget(wrap( + DropdownMenu( + items: const [DropdownMenuItem(label: 'Visible Item')], + child: const Text('open menu'), + ), + )); + + await tester.tap(find.text('open menu')); + await tester.pump(); + + expect(find.text('Visible Item'), findsOneWidget); + }); + + testWidgets('tapping an item invokes onTap callback', (tester) async { + bool tapped = false; + + await tester.pumpWidget(wrap( + DropdownMenu( + items: [ + DropdownMenuItem( + label: 'Tap me', + onTap: () => tapped = true, + ), + ], + child: const Text('open menu'), + ), + )); + + await tester.tap(find.text('open menu')); + await tester.pump(); + await tester.tap(find.text('Tap me')); + await tester.pump(); + + expect(tapped, isTrue); + }); + + testWidgets('DropdownMenuItem disabled label is rendered in menu', + (tester) async { + await tester.pumpWidget(wrap( + DropdownMenu( + items: const [ + DropdownMenuItem(label: 'Normal'), + DropdownMenuItem(label: 'Disabled', disabled: true), + ], + child: const Text('open menu'), + ), + )); + + await tester.tap(find.text('open menu')); + await tester.pump(); + + // Both labels should be visible; the disabled item is rendered but + // without a tap handler (no WAnchor wrapper). + expect(find.text('Normal'), findsOneWidget); + expect(find.text('Disabled'), findsOneWidget); + }); + }); + + // --------------------------------------------------------------------------- + // DropdownMenuItem data class + // --------------------------------------------------------------------------- + + test('DropdownMenuItem holds label, onTap, disabled', () { + const item = DropdownMenuItem(label: 'Test', disabled: false); + // Data-class field assertions use the type directly (not tester.widget). + expect(item.label, 'Test'); + expect(item.disabled, isFalse); + expect(item.onTap, isNull); + }); + + // Verify DropdownMenu is re-exported from index.dart + test('DropdownMenu is re-exported from index.dart', () { + expect(DropdownMenu, isNotNull); + }); +} diff --git a/test/ui/components/empty_state/empty_state_test.dart b/test/ui/components/empty_state/empty_state_test.dart index b8c8121..85a932a 100644 --- a/test/ui/components/empty_state/empty_state_test.dart +++ b/test/ui/components/empty_state/empty_state_test.dart @@ -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/components/empty_state/index.dart'; void main() { setUp(() { diff --git a/test/ui/components/error_state/error_state_test.dart b/test/ui/components/error_state/error_state_test.dart index a887592..b5f623a 100644 --- a/test/ui/components/error_state/error_state_test.dart +++ b/test/ui/components/error_state/error_state_test.dart @@ -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/components/error_state/index.dart'; void main() { setUp(() { diff --git a/test/ui/components/form_field/form_field_test.dart b/test/ui/components/form_field/form_field_test.dart index 6ca4705..12eef55 100644 --- a/test/ui/components/form_field/form_field_test.dart +++ b/test/ui/components/form_field/form_field_test.dart @@ -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/components/form_field/index.dart'; void main() { setUp(() { @@ -67,7 +66,8 @@ void main() { expect(find.text('Must be at least 8 characters'), findsOneWidget); }); - testWidgets('MagicFormField does not render hint when omitted', (tester) async { + testWidgets('MagicFormField does not render hint when omitted', + (tester) async { await tester.pumpWidget( wrap( MagicFormField( @@ -114,7 +114,8 @@ void main() { ); }); - testWidgets('MagicFormField does not render error when omitted', (tester) async { + testWidgets('MagicFormField does not render error when omitted', + (tester) async { await tester.pumpWidget( wrap( MagicFormField( diff --git a/test/ui/components/input/input_test.dart b/test/ui/components/input/input_test.dart new file mode 100644 index 0000000..9d65146 --- /dev/null +++ b/test/ui/components/input/input_test.dart @@ -0,0 +1,80 @@ +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/components/input/input.preview.dart'; + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + Magic.singleton('magic_starter', () => MagicStarterManager()); + }); + + tearDown(() { + MagicApp.reset(); + Magic.flush(); + }); + + Widget wrap(Widget widget) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: widget), + ), + ); + } + + // --------------------------------------------------------------------------- + // Recipe variant-class assertions + // --------------------------------------------------------------------------- + + group('input recipe', () { + test('default state emits bg-surface-container-high token', () { + final cls = inputRecipe( + variants: {'state': InputState.normal.name}, + ); + expect(cls, contains('bg-surface-container-high')); + }); + + test('error state emits border-destructive or similar error token', () { + final cls = inputRecipe( + variants: {'state': InputState.error.name}, + ); + // Error state should apply error styling class + expect(cls, contains('border')); + }); + + test('default variant produces normal state when nothing is passed', () { + final cls = inputRecipe(); + expect(cls, contains('bg-surface-container-high')); + }); + }); + + // --------------------------------------------------------------------------- + // Widget tests + // --------------------------------------------------------------------------- + + testWidgets('Input renders a WInput or WFormInput widget', (tester) async { + await tester.pumpWidget( + wrap(const Input(placeholder: 'Enter text')), + ); + // Input wraps WInput + expect(find.byType(WInput), findsOneWidget); + }); + + testWidgets('Input applies bg-surface-container-high in normal state', + (tester) async { + await tester.pumpWidget( + wrap(const Input(placeholder: 'Enter text')), + ); + final widget = tester.widget(find.byType(WInput)); + expect(widget.className, contains('bg-surface-container-high')); + }); + + testWidgets('Input preview renders without error', (tester) async { + await tester.pumpWidget(wrap(const InputPreview())); + await tester.pump(); + expect(find.byType(InputPreview), findsOneWidget); + }); +} diff --git a/test/ui/components/navbar/navbar_test.dart b/test/ui/components/navbar/navbar_test.dart index 7d137fe..136f465 100644 --- a/test/ui/components/navbar/navbar_test.dart +++ b/test/ui/components/navbar/navbar_test.dart @@ -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/components/navbar/index.dart'; void main() { setUp(() { diff --git a/test/ui/components/notification_dropdown/notification_dropdown_test.dart b/test/ui/components/notification_dropdown/notification_dropdown_test.dart index 1826bcc..6eba24d 100644 --- a/test/ui/components/notification_dropdown/notification_dropdown_test.dart +++ b/test/ui/components/notification_dropdown/notification_dropdown_test.dart @@ -14,8 +14,7 @@ void main() { MagicApp.reset(); Magic.flush(); Magic.singleton('magic_starter', () => MagicStarterManager()); - streamController = - StreamController>.broadcast(); + streamController = StreamController>.broadcast(); Magic.singleton('log', () => LogManager()); Config.set('logging', { 'default': 'console', @@ -120,7 +119,8 @@ void main() { expect(find.byIcon(Icons.notifications_off_outlined), findsOneWidget); }); - testWidgets('NotificationDropdown preview renders without error', (tester) async { + testWidgets('NotificationDropdown preview renders without error', + (tester) async { await tester.pumpWidget(wrap(const NotificationDropdownPreview())); await tester.pump(); expect(find.byType(NotificationDropdownPreview), findsOneWidget); diff --git a/test/ui/components/radio/radio_test.dart b/test/ui/components/radio/radio_test.dart new file mode 100644 index 0000000..e03d6b3 --- /dev/null +++ b/test/ui/components/radio/radio_test.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart' hide Radio; +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/components/radio/radio.preview.dart'; + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + Magic.singleton('magic_starter', () => MagicStarterManager()); + }); + + tearDown(() { + MagicApp.reset(); + Magic.flush(); + }); + + Widget wrap(Widget widget) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: widget), + ), + ); + } + + // --------------------------------------------------------------------------- + // Recipe variant-class assertions + // --------------------------------------------------------------------------- + + group('radio recipe', () { + test('shell recipe emits rounded-full token', () { + final cls = radioShellRecipe(); + expect(cls, contains('rounded-full')); + }); + + test('shell recipe emits border token', () { + final cls = radioShellRecipe(); + expect(cls, contains('border')); + }); + + test('shell recipe includes selected: state prefix for primary color', () { + final cls = radioShellRecipe(); + expect(cls, contains('selected:border-color-border')); + }); + + test('indicator recipe emits bg-primary token', () { + final cls = radioIndicatorRecipe(); + expect(cls, contains('bg-primary')); + }); + }); + + // --------------------------------------------------------------------------- + // Widget tests + // --------------------------------------------------------------------------- + + testWidgets('Radio renders a WRadio widget', (tester) async { + await tester.pumpWidget( + wrap( + Radio( + value: 'a', + groupValue: 'a', + onChanged: (_) {}, + ), + ), + ); + expect(find.byType(WRadio), findsOneWidget); + }); + + testWidgets('Radio reflects selected state when value == groupValue', + (tester) async { + await tester.pumpWidget( + wrap( + Radio( + value: 'a', + groupValue: 'a', + onChanged: (_) {}, + ), + ), + ); + final widget = tester.widget>(find.byType(WRadio)); + expect(widget.value, equals(widget.groupValue)); + }); + + testWidgets('Radio fires onChanged when tapped while not selected', + (tester) async { + String? selected; + await tester.pumpWidget( + wrap( + Radio( + value: 'b', + groupValue: 'a', + onChanged: (v) => selected = v, + ), + ), + ); + await tester.tap(find.byType(WRadio)); + await tester.pump(); + expect(selected, equals('b')); + }); + + testWidgets('Radio preview renders without error', (tester) async { + await tester.pumpWidget(wrap(const RadioPreview())); + await tester.pump(); + expect(find.byType(RadioPreview), findsOneWidget); + }); +} diff --git a/test/ui/components/segmented_control/segmented_control_test.dart b/test/ui/components/segmented_control/segmented_control_test.dart new file mode 100644 index 0000000..63db663 --- /dev/null +++ b/test/ui/components/segmented_control/segmented_control_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; +import 'package:magic_starter/magic_starter.dart'; + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + Magic.singleton('magic_starter', () => MagicStarterManager()); + }); + + tearDown(() { + MagicApp.reset(); + Magic.flush(); + }); + + Widget wrap(Widget widget) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: widget), + ), + ); + } + + // --------------------------------------------------------------------------- + // Recipe slot-class assertions + // --------------------------------------------------------------------------- + + group('segmented control recipe', () { + test('sm size emits text-sm', () { + final cls = segmentedControlRecipe( + variants: {'size': SegmentedControlSize.sm.name}, + ); + expect(cls['item'], contains('text-sm')); + }); + + test('md size emits text-sm or text-base', () { + final cls = segmentedControlRecipe( + variants: {'size': SegmentedControlSize.md.name}, + ); + // md size item should have some text-size token + expect(cls['item'], isNotEmpty); + }); + + test('root slot contains bg-surface-container-high', () { + final cls = segmentedControlRecipe(); + expect(cls['root'], contains('bg-surface-container-high')); + }); + + test('all two required slots are present', () { + final cls = segmentedControlRecipe(); + expect(cls.keys, containsAll(['root', 'item'])); + }); + }); + + // --------------------------------------------------------------------------- + // Widget tests + // --------------------------------------------------------------------------- + + group('SegmentedControl widget', () { + testWidgets('renders option labels', (tester) async { + await tester.pumpWidget( + wrap( + SegmentedControl( + options: const ['Option A', 'Option B', 'Option C'], + selectedIndex: 0, + onChanged: (_) {}, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Option A'), findsOneWidget); + expect(find.text('Option B'), findsOneWidget); + expect(find.text('Option C'), findsOneWidget); + }); + + testWidgets('calls onChanged when a segment is tapped', (tester) async { + int? changed; + await tester.pumpWidget( + wrap( + SegmentedControl( + options: const ['Option A', 'Option B'], + selectedIndex: 0, + onChanged: (i) => changed = i, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Option B')); + await tester.pumpAndSettle(); + + expect(changed, equals(1)); + }); + }); +} diff --git a/test/ui/components/select/select_test.dart b/test/ui/components/select/select_test.dart new file mode 100644 index 0000000..0986fa0 --- /dev/null +++ b/test/ui/components/select/select_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; +import 'package:magic_starter/magic_starter.dart'; + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + Magic.singleton('magic_starter', () => MagicStarterManager()); + }); + + tearDown(() { + MagicApp.reset(); + Magic.flush(); + }); + + Widget wrap(Widget widget) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: widget), + ), + ); + } + + // --------------------------------------------------------------------------- + // Recipe slot-class assertions (TDD: write failing tests first) + // --------------------------------------------------------------------------- + + group('select recipe', () { + test('trigger slot contains bg-surface-container-high', () { + final cls = selectRecipe(); + expect(cls['trigger'], contains('bg-surface-container-high')); + }); + + test('popup slot contains bg-surface', () { + final cls = selectRecipe(); + expect(cls['popup'], contains('bg-surface')); + }); + + test('item slot is non-empty', () { + final cls = selectRecipe(); + expect(cls['item'], isNotEmpty); + }); + + test('all three required slots are present', () { + final cls = selectRecipe(); + expect(cls.keys, containsAll(['trigger', 'popup', 'item'])); + }); + }); + + // --------------------------------------------------------------------------- + // Widget interaction tests + // --------------------------------------------------------------------------- + + group('Select widget', () { + testWidgets('renders and shows WSelect', (tester) async { + await tester.pumpWidget( + wrap( + Select( + value: null, + options: const [SelectOption(value: 'a', label: 'Option A')], + onChange: (_) {}, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(WSelect), findsOneWidget); + }); + + testWidgets('calls onChange when option is selected', (tester) async { + String? selected; + await tester.pumpWidget( + wrap( + Select( + value: null, + options: const [SelectOption(value: 'a', label: 'Option A')], + onChange: (v) => selected = v, + ), + ), + ); + await tester.pumpAndSettle(); + + // Tap the trigger to open. + await tester.tap(find.byType(WSelect)); + await tester.pumpAndSettle(); + + // Tap the option. + await tester.tap(find.text('Option A')); + await tester.pumpAndSettle(); + + expect(selected, equals('a')); + }); + }); +} diff --git a/test/ui/components/skeleton/skeleton_test.dart b/test/ui/components/skeleton/skeleton_test.dart new file mode 100644 index 0000000..cfaaf1a --- /dev/null +++ b/test/ui/components/skeleton/skeleton_test.dart @@ -0,0 +1,148 @@ +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/components/skeleton/skeleton.preview.dart'; + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + Magic.singleton('magic_starter', () => MagicStarterManager()); + }); + + tearDown(() { + MagicApp.reset(); + Magic.flush(); + }); + + Widget wrap(Widget widget) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: widget), + ), + ); + } + + // --------------------------------------------------------------------------- + // Recipe variant-class assertions (TDD red: these must fail before impl) + // --------------------------------------------------------------------------- + + group('skeleton recipe', () { + test('block shape emits rounded-md token', () { + final cls = skeletonRecipe( + variants: {'shape': SkeletonShape.block.name}, + ); + expect(cls, contains('rounded-md')); + }); + + test('text shape emits rounded token', () { + final cls = skeletonRecipe( + variants: {'shape': SkeletonShape.text.name}, + ); + expect(cls, contains('rounded')); + }); + + test('circle shape emits rounded-full token', () { + final cls = skeletonRecipe( + variants: {'shape': SkeletonShape.circle.name}, + ); + expect(cls, contains('rounded-full')); + }); + + test('all shapes emit bg-surface-container-high (muted fill)', () { + for (final shape in SkeletonShape.values) { + final cls = skeletonRecipe( + variants: {'shape': shape.name}, + ); + expect( + cls, + contains('bg-surface-container-high'), + reason: '${shape.name} must use semantic muted fill', + ); + } + }); + + test('default shape is block', () { + final cls = skeletonRecipe(); + expect(cls, contains('rounded-md')); + }); + + test('emission order: base precedes variant classes', () { + final cls = skeletonRecipe( + variants: {'shape': SkeletonShape.block.name}, + ); + expect(cls, isNotEmpty); + final baseIdx = cls.indexOf('animate-pulse'); + expect(baseIdx, isNot(equals(-1))); + }); + }); + + // --------------------------------------------------------------------------- + // Widget tests + // --------------------------------------------------------------------------- + + testWidgets('Skeleton block shape renders without error', (tester) async { + await tester.pumpWidget( + wrap( + const Skeleton( + shape: SkeletonShape.block, + width: 200, + height: 80, + ), + ), + ); + expect(find.byType(WDiv), findsWidgets); + }); + + testWidgets('Skeleton circle shape renders without error', (tester) async { + await tester.pumpWidget( + wrap( + const Skeleton( + shape: SkeletonShape.circle, + width: 48, + height: 48, + ), + ), + ); + final wDiv = tester.widget(find.byType(WDiv).first); + expect(wDiv.className, contains('rounded-full')); + }); + + testWidgets('Skeleton text shape renders without error', (tester) async { + await tester.pumpWidget( + wrap( + const Skeleton( + shape: SkeletonShape.text, + width: 160, + height: 16, + ), + ), + ); + expect(find.byType(WDiv), findsWidgets); + }); + + testWidgets('Skeleton default shape is block', (tester) async { + await tester.pumpWidget( + wrap(const Skeleton(width: 100, height: 20)), + ); + final wDiv = tester.widget(find.byType(WDiv).first); + expect(wDiv.className, contains('rounded-md')); + expect(wDiv.className, isNot(contains('rounded-full'))); + }); + + testWidgets('Skeleton light+dark preview renders without error', + (tester) async { + await tester.pumpWidget(wrap(const SkeletonPreview())); + await tester.pump(); + expect(find.byType(SkeletonPreview), findsOneWidget); + }); + + testWidgets('SkeletonPreview renders all shapes', (tester) async { + await tester.pumpWidget(wrap(const SkeletonPreview())); + await tester.pump(); + // Each shape renders at least one WDiv + expect(find.byType(WDiv), findsWidgets); + }); +} diff --git a/test/ui/components/social_divider/social_divider_test.dart b/test/ui/components/social_divider/social_divider_test.dart index 52afe5c..ef6eba7 100644 --- a/test/ui/components/social_divider/social_divider_test.dart +++ b/test/ui/components/social_divider/social_divider_test.dart @@ -29,7 +29,8 @@ void main() { // Behavior equivalence gate — mirrors magic_starter_social_divider_test.dart // --------------------------------------------------------------------------- - testWidgets('SocialDivider renders divider with translated text', (tester) async { + testWidgets('SocialDivider renders divider with translated text', + (tester) async { await tester.pumpWidget(wrap(const SocialDivider())); // trans() returns the key when no translation is loaded expect(find.text('auth.or_continue_with'), findsOneWidget); diff --git a/test/ui/components/switch/switch_test.dart b/test/ui/components/switch/switch_test.dart new file mode 100644 index 0000000..d200dcb --- /dev/null +++ b/test/ui/components/switch/switch_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart' hide Switch; +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/components/switch/switch.preview.dart'; + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + Magic.singleton('magic_starter', () => MagicStarterManager()); + }); + + tearDown(() { + MagicApp.reset(); + Magic.flush(); + }); + + Widget wrap(Widget widget) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: widget), + ), + ); + } + + // --------------------------------------------------------------------------- + // Recipe variant-class assertions + // --------------------------------------------------------------------------- + + group('switch recipe', () { + test('track recipe emits rounded-full token', () { + final cls = switchTrackRecipe(); + expect(cls, contains('rounded-full')); + }); + + test('track recipe emits checked: state prefix token for bg-primary', () { + final cls = switchTrackRecipe(); + expect(cls, contains('checked:bg-primary')); + }); + + test('thumb recipe emits rounded-full token', () { + final cls = switchThumbRecipe(); + expect(cls, contains('rounded-full')); + }); + }); + + // --------------------------------------------------------------------------- + // Widget tests + // --------------------------------------------------------------------------- + + testWidgets('Switch renders a WSwitch', (tester) async { + await tester.pumpWidget( + wrap(Switch(value: false, onChanged: (_) {})), + ); + expect(find.byType(WSwitch), findsOneWidget); + }); + + testWidgets('Switch reflects value prop on WSwitch', (tester) async { + await tester.pumpWidget( + wrap(Switch(value: true, onChanged: (_) {})), + ); + final widget = tester.widget(find.byType(WSwitch)); + expect(widget.value, isTrue); + }); + + testWidgets('Switch fires onChanged when tapped', (tester) async { + bool? newValue; + await tester.pumpWidget( + wrap(Switch(value: false, onChanged: (v) => newValue = v)), + ); + await tester.tap(find.byType(WSwitch)); + await tester.pump(); + expect(newValue, isTrue); + }); + + testWidgets('Switch applies track className with bg-surface-container-high', + (tester) async { + await tester.pumpWidget( + wrap(Switch(value: false, onChanged: (_) {})), + ); + final widget = tester.widget(find.byType(WSwitch)); + expect(widget.className, contains('bg-surface-container-high')); + }); + + testWidgets('Switch preview renders without error', (tester) async { + await tester.pumpWidget(wrap(const SwitchPreview())); + await tester.pump(); + expect(find.byType(SwitchPreview), findsOneWidget); + }); +} diff --git a/test/ui/components/tabs/tabs_test.dart b/test/ui/components/tabs/tabs_test.dart new file mode 100644 index 0000000..464cfa8 --- /dev/null +++ b/test/ui/components/tabs/tabs_test.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; +import 'package:magic_starter/magic_starter.dart'; + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + Magic.singleton('magic_starter', () => MagicStarterManager()); + }); + + tearDown(() { + MagicApp.reset(); + Magic.flush(); + }); + + Widget wrap(Widget widget) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: widget), + ), + ); + } + + // --------------------------------------------------------------------------- + // Recipe slot-class assertions + // --------------------------------------------------------------------------- + + group('tabs recipe', () { + test('list slot contains border-b', () { + final cls = tabsRecipe(); + expect(cls['list'], contains('border-b')); + }); + + test('tab slot contains px-', () { + final cls = tabsRecipe(); + expect(cls['tab'], contains('px-')); + }); + + test('panel slot is non-empty', () { + final cls = tabsRecipe(); + expect(cls['panel'], isNotEmpty); + }); + + test('all three required slots are present', () { + final cls = tabsRecipe(); + expect(cls.keys, containsAll(['list', 'tab', 'panel'])); + }); + }); + + // --------------------------------------------------------------------------- + // Widget interaction tests + // --------------------------------------------------------------------------- + + group('Tabs widget', () { + testWidgets('renders first panel by default', (tester) async { + await tester.pumpWidget( + wrap( + Tabs( + tabs: const ['Tab 1', 'Tab 2'], + selectedIndex: 0, + onChanged: (_) {}, + panelBuilder: (i) => + i == 0 ? const Text('Panel 1') : const Text('Panel 2'), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Panel 1'), findsOneWidget); + expect(find.text('Panel 2'), findsNothing); + }); + + testWidgets('calls onChanged when a tab is tapped', (tester) async { + int? changed; + await tester.pumpWidget( + wrap( + Tabs( + tabs: const ['Tab 1', 'Tab 2'], + selectedIndex: 0, + onChanged: (i) => changed = i, + panelBuilder: (i) => Text('Panel $i'), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Tab 2')); + await tester.pumpAndSettle(); + + expect(changed, equals(1)); + }); + + testWidgets('renders second panel when selectedIndex is 1', (tester) async { + await tester.pumpWidget( + wrap( + Tabs( + tabs: const ['Tab 1', 'Tab 2'], + selectedIndex: 1, + onChanged: (_) {}, + panelBuilder: (i) => + i == 0 ? const Text('Panel 1') : const Text('Panel 2'), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Panel 2'), findsOneWidget); + expect(find.text('Panel 1'), findsNothing); + }); + }); +} diff --git a/test/ui/components/team_selector/team_selector_test.dart b/test/ui/components/team_selector/team_selector_test.dart index d5e06b2..ac4019d 100644 --- a/test/ui/components/team_selector/team_selector_test.dart +++ b/test/ui/components/team_selector/team_selector_test.dart @@ -36,8 +36,10 @@ void main() { testWidgets('TeamSelector renders team initial when resolver has teams', (tester) async { final teams = [ - MagicStarterTeam.fromMap({'id': 1, 'name': 'Acme Corp', 'personal_team': false}), - MagicStarterTeam.fromMap({'id': 2, 'name': 'Beta Inc', 'personal_team': false}), + MagicStarterTeam.fromMap( + {'id': 1, 'name': 'Acme Corp', 'personal_team': false}), + MagicStarterTeam.fromMap( + {'id': 2, 'name': 'Beta Inc', 'personal_team': false}), ]; MagicStarter.useTeamResolver( currentTeam: () => teams.first, diff --git a/test/ui/components/textarea/textarea_test.dart b/test/ui/components/textarea/textarea_test.dart new file mode 100644 index 0000000..7c27745 --- /dev/null +++ b/test/ui/components/textarea/textarea_test.dart @@ -0,0 +1,79 @@ +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/components/textarea/textarea.preview.dart'; + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + Magic.singleton('magic_starter', () => MagicStarterManager()); + }); + + tearDown(() { + MagicApp.reset(); + Magic.flush(); + }); + + Widget wrap(Widget widget) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: widget), + ), + ); + } + + // --------------------------------------------------------------------------- + // Recipe variant-class assertions + // --------------------------------------------------------------------------- + + group('textarea recipe', () { + test('default state emits bg-surface-container-high token', () { + final cls = textareaRecipe( + variants: {'state': TextareaState.normal.name}, + ); + expect(cls, contains('bg-surface-container-high')); + }); + + test('error state emits error-related styling', () { + final cls = textareaRecipe( + variants: {'state': TextareaState.error.name}, + ); + expect(cls, contains('border')); + }); + + test('default variant produces normal state when nothing is passed', () { + final cls = textareaRecipe(); + expect(cls, contains('bg-surface-container-high')); + }); + }); + + // --------------------------------------------------------------------------- + // Widget tests + // --------------------------------------------------------------------------- + + testWidgets('Textarea renders a WInput widget in multiline mode', + (tester) async { + await tester.pumpWidget( + wrap(const Textarea(placeholder: 'Enter text')), + ); + expect(find.byType(WInput), findsOneWidget); + }); + + testWidgets('Textarea applies bg-surface-container-high in normal state', + (tester) async { + await tester.pumpWidget( + wrap(const Textarea(placeholder: 'Enter text')), + ); + final widget = tester.widget(find.byType(WInput)); + expect(widget.className, contains('bg-surface-container-high')); + }); + + testWidgets('Textarea preview renders without error', (tester) async { + await tester.pumpWidget(wrap(const TextareaPreview())); + await tester.pump(); + expect(find.byType(TextareaPreview), findsOneWidget); + }); +} diff --git a/test/ui/components/toast/toast_test.dart b/test/ui/components/toast/toast_test.dart new file mode 100644 index 0000000..3b1c2a0 --- /dev/null +++ b/test/ui/components/toast/toast_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; +import 'package:magic_starter/magic_starter.dart'; + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + Magic.singleton('magic_starter', () => MagicStarterManager()); + }); + + Widget wrap(Widget widget) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: widget), + ), + ); + } + + // --------------------------------------------------------------------------- + // Recipe variant-class assertions (TDD red) + // --------------------------------------------------------------------------- + + group('toast recipe', () { + test('default variant emits bg-surface token', () { + final cls = buildToastRecipe()(variants: { + kToastVariantAxis: ToastVariant.info.name, + }); + expect(cls, contains('bg-surface')); + }); + + test('success variant emits bg-success token', () { + final cls = buildToastRecipe()(variants: { + kToastVariantAxis: ToastVariant.success.name, + }); + expect(cls, contains('bg-success')); + }); + + test('error variant emits bg-destructive token', () { + final cls = buildToastRecipe()(variants: { + kToastVariantAxis: ToastVariant.error.name, + }); + expect(cls, contains('bg-destructive')); + }); + + test('warning variant emits bg-warning token', () { + final cls = buildToastRecipe()(variants: { + kToastVariantAxis: ToastVariant.warning.name, + }); + expect(cls, contains('bg-warning')); + }); + }); + + // --------------------------------------------------------------------------- + // Rendering + // --------------------------------------------------------------------------- + + group('Toast widget', () { + testWidgets('renders message text', (tester) async { + await tester.pumpWidget(wrap( + const Toast(message: 'Operation succeeded'), + )); + + expect(find.text('Operation succeeded'), findsOneWidget); + }); + + testWidgets('default variant is info', (tester) async { + await tester.pumpWidget(wrap( + const Toast(message: 'Hello'), + )); + + final toast = tester.widget(find.byType(Toast)); + expect(toast.variant, ToastVariant.info); + }); + + testWidgets('success variant is set', (tester) async { + await tester.pumpWidget(wrap( + const Toast(message: 'Done', variant: ToastVariant.success), + )); + + final toast = tester.widget(find.byType(Toast)); + expect(toast.variant, ToastVariant.success); + }); + }); + + // --------------------------------------------------------------------------- + // ToastVariant enum stability + // --------------------------------------------------------------------------- + + test('ToastVariant has info, success, warning, error values', () { + expect(ToastVariant.values.length, 4); + expect(ToastVariant.info, isNotNull); + expect(ToastVariant.success, isNotNull); + expect(ToastVariant.warning, isNotNull); + expect(ToastVariant.error, isNotNull); + }); +} diff --git a/test/ui/components/tooltip/tooltip_test.dart b/test/ui/components/tooltip/tooltip_test.dart new file mode 100644 index 0000000..be7c9d4 --- /dev/null +++ b/test/ui/components/tooltip/tooltip_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart' hide Tooltip; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; +import 'package:magic_starter/magic_starter.dart'; + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + Magic.singleton('magic_starter', () => MagicStarterManager()); + }); + + Widget wrap(Widget widget) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: Center(child: widget)), + ), + ); + } + + group('Tooltip', () { + testWidgets('renders trigger child', (tester) async { + await tester.pumpWidget(wrap( + Tooltip( + content: const Text('tooltip text'), + child: const Text('hover me'), + ), + )); + + expect(find.text('hover me'), findsOneWidget); + }); + + testWidgets('tooltip content is not visible when closed', (tester) async { + await tester.pumpWidget(wrap( + Tooltip( + content: const Text('hidden tooltip'), + child: const Text('hover me'), + ), + )); + + // Content should not be in the tree when popover is closed. + expect(find.text('hidden tooltip'), findsNothing); + }); + + testWidgets('has className prop', (tester) async { + await tester.pumpWidget(wrap( + Tooltip( + content: const Text('tip'), + className: 'bg-gray-900 text-white', + child: const Text('trigger'), + ), + )); + + final tooltip = tester.widget(find.byType(Tooltip)); + expect(tooltip.className, 'bg-gray-900 text-white'); + }); + }); + + // Verify Tooltip is re-exported from index.dart + test('Tooltip is re-exported from index.dart', () { + expect(Tooltip, isNotNull); + }); +} diff --git a/test/ui/components/typography/typography_test.dart b/test/ui/components/typography/typography_test.dart new file mode 100644 index 0000000..648609d --- /dev/null +++ b/test/ui/components/typography/typography_test.dart @@ -0,0 +1,156 @@ +// hide Flutter's material Typography to avoid the name conflict with ours. +import 'package:flutter/material.dart' hide Typography; +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/components/typography/typography.preview.dart'; + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + Magic.singleton('magic_starter', () => MagicStarterManager()); + }); + + tearDown(() { + MagicApp.reset(); + Magic.flush(); + }); + + Widget wrap(Widget widget) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: widget), + ), + ); + } + + // --------------------------------------------------------------------------- + // Recipe variant-class assertions (TDD red: these must fail before impl) + // --------------------------------------------------------------------------- + + group('typography recipe', () { + test('h1 variant emits text-4xl or larger size token', () { + final cls = typographyRecipe( + variants: {'variant': TypographyVariant.h1.name}, + ); + expect(cls, contains('text-4xl')); + }); + + test('h2 variant emits text-3xl size token', () { + final cls = typographyRecipe( + variants: {'variant': TypographyVariant.h2.name}, + ); + expect(cls, contains('text-3xl')); + }); + + test('h3 variant emits text-2xl size token', () { + final cls = typographyRecipe( + variants: {'variant': TypographyVariant.h3.name}, + ); + expect(cls, contains('text-2xl')); + }); + + test('body variant emits text-base size token', () { + final cls = typographyRecipe( + variants: {'variant': TypographyVariant.body.name}, + ); + expect(cls, contains('text-base')); + }); + + test('caption variant emits text-sm or text-xs size token', () { + final cls = typographyRecipe( + variants: {'variant': TypographyVariant.caption.name}, + ); + // caption should be smaller than body + final hasSm = cls.contains('text-sm'); + final hasXs = cls.contains('text-xs'); + expect(hasSm || hasXs, isTrue); + }); + + test('default variant produces body when nothing is passed', () { + final cls = typographyRecipe(); + expect(cls, contains('text-base')); + }); + + test('heading variants contain text-fg token for foreground color', () { + for (final v in [ + TypographyVariant.h1, + TypographyVariant.h2, + TypographyVariant.h3, + ]) { + final cls = typographyRecipe( + variants: {'variant': v.name}, + ); + expect(cls, contains('text-fg'), + reason: '${v.name} should carry text-fg'); + } + }); + + test('emission order: base precedes variant classes', () { + final cls = typographyRecipe( + variants: {'variant': TypographyVariant.h1.name}, + ); + // base class appears before the variant text-4xl token + final baseIdx = cls.indexOf('text-fg'); + final variantIdx = cls.indexOf('text-4xl'); + // If base contains text-fg and variant contains text-4xl, base must come first + // (or both may be in base — either is acceptable; what matters is no crash) + expect(cls, isNotEmpty); + expect(baseIdx, isNot(equals(-1))); + expect(variantIdx, isNot(equals(-1))); + }); + }); + + // --------------------------------------------------------------------------- + // Widget tests + // --------------------------------------------------------------------------- + + testWidgets('Typography renders text content', (tester) async { + await tester.pumpWidget( + wrap(const Typography('Hello', variant: TypographyVariant.body)), + ); + expect(find.text('Hello'), findsOneWidget); + }); + + testWidgets('Typography default variant is body', (tester) async { + await tester.pumpWidget( + wrap(const Typography('Hello')), + ); + final wText = tester.widget(find.byType(WText).first); + expect(wText.className, contains('text-base')); + }); + + testWidgets('Typography h1 applies text-4xl className', (tester) async { + await tester.pumpWidget( + wrap(const Typography('Heading', variant: TypographyVariant.h1)), + ); + final wText = tester.widget(find.byType(WText).first); + expect(wText.className, contains('text-4xl')); + }); + + testWidgets('Typography caption applies smaller text size', (tester) async { + await tester.pumpWidget( + wrap(const Typography('Caption', variant: TypographyVariant.caption)), + ); + final wText = tester.widget(find.byType(WText).first); + final hasSmall = wText.className!.contains('text-sm') || + wText.className!.contains('text-xs'); + expect(hasSmall, isTrue); + }); + + testWidgets('Typography light+dark preview renders without error', + (tester) async { + await tester.pumpWidget(wrap(const TypographyPreview())); + await tester.pump(); + expect(find.byType(TypographyPreview), findsOneWidget); + }); + + testWidgets('TypographyPreview renders all variants', (tester) async { + await tester.pumpWidget(wrap(const TypographyPreview())); + await tester.pump(); + final texts = tester.widgetList(find.byType(WText)); + expect(texts.length, greaterThanOrEqualTo(TypographyVariant.values.length)); + }); +} diff --git a/test/ui/components/user_profile_dropdown/user_profile_dropdown_test.dart b/test/ui/components/user_profile_dropdown/user_profile_dropdown_test.dart index 1d7197b..9931829 100644 --- a/test/ui/components/user_profile_dropdown/user_profile_dropdown_test.dart +++ b/test/ui/components/user_profile_dropdown/user_profile_dropdown_test.dart @@ -163,7 +163,8 @@ void main() { expect(find.text('Custom Trigger'), findsOneWidget); }); - testWidgets('UserProfileDropdown preview renders without error', (tester) async { + testWidgets('UserProfileDropdown preview renders without error', + (tester) async { await tester.pumpWidget(wrap(const UserProfileDropdownPreview())); await tester.pump(); expect(find.byType(UserProfileDropdownPreview), findsOneWidget); diff --git a/test/ui/views/notifications/magic_starter_notification_preferences_view_test.dart b/test/ui/views/notifications/magic_starter_notification_preferences_view_test.dart index 6042719..b8b44e2 100644 --- a/test/ui/views/notifications/magic_starter_notification_preferences_view_test.dart +++ b/test/ui/views/notifications/magic_starter_notification_preferences_view_test.dart @@ -1,7 +1,7 @@ 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/magic_starter.dart' hide Switch; class MockNetworkDriver implements NetworkDriver { MagicResponse? nextResponse; diff --git a/test/ui/views/profile/magic_starter_profile_settings_newsletter_test.dart b/test/ui/views/profile/magic_starter_profile_settings_newsletter_test.dart index 36cb232..7909c38 100644 --- a/test/ui/views/profile/magic_starter_profile_settings_newsletter_test.dart +++ b/test/ui/views/profile/magic_starter_profile_settings_newsletter_test.dart @@ -1,7 +1,7 @@ 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/magic_starter.dart' hide Switch; // --------------------------------------------------------------------------- // Mock NetworkDriver — intercepts all Http facade calls diff --git a/test/ui/widgets/magic_starter_dialog_shell_test.dart b/test/ui/widgets/magic_starter_dialog_shell_test.dart index e453071..30687f2 100644 --- a/test/ui/widgets/magic_starter_dialog_shell_test.dart +++ b/test/ui/widgets/magic_starter_dialog_shell_test.dart @@ -1,7 +1,7 @@ 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/magic_starter.dart' hide Dialog; void main() { setUp(() 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 d0bf039..2820e7e 100644 --- a/test/ui/widgets/magic_starter_password_confirm_dialog_test.dart +++ b/test/ui/widgets/magic_starter_password_confirm_dialog_test.dart @@ -1,7 +1,7 @@ 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/magic_starter.dart' hide Dialog; /// Resolves the password input inside [MagicStarterPasswordConfirmDialog]. /// 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 50c55ab..0213527 100644 --- a/test/ui/widgets/magic_starter_two_factor_modal_test.dart +++ b/test/ui/widgets/magic_starter_two_factor_modal_test.dart @@ -1,7 +1,7 @@ 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/magic_starter.dart' hide Dialog; /// Widget tests for [MagicStarterTwoFactorModal]. /// From 3c260dfc61703ad095acc13c0c554a0870d588b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Thu, 25 Jun 2026 21:37:46 +0300 Subject: [PATCH 05/12] refactor: rewrite views + layouts onto the design-system components Rewrite the auth, profile, notifications, and teams views plus the app and guest layouts to compose the new Button/Card/Switch/PageHeader/ SocialDivider/etc. components instead of inline W-widgets. Views are now Wind-exclusive (material imports scoped to 'show Icons' + the few needed Material shells), so the unprefixed component names no longer collide. Behavior, registry keys, controller contracts, gate abilities, refreshNotifier, and notification polling are all preserved. --- CHANGELOG.md | 1 + .../ui/layouts/magic_starter_app_layout.dart | 3 +- .../layouts/magic_starter_guest_layout.dart | 3 +- .../magic_starter_forgot_password_view.dart | 12 ++++--- .../views/auth/magic_starter_login_view.dart | 16 +++++----- .../auth/magic_starter_otp_verify_view.dart | 11 ++++--- .../auth/magic_starter_register_view.dart | 12 ++++--- .../magic_starter_reset_password_view.dart | 17 +++++----- ...gic_starter_two_factor_challenge_view.dart | 7 +++-- ...starter_notification_preferences_view.dart | 30 +++++++++--------- ...magic_starter_notifications_list_view.dart | 6 ++-- .../magic_starter_profile_settings_view.dart | 31 +++++++++---------- .../teams/magic_starter_team_create_view.dart | 10 +++--- ...c_starter_team_invitation_accept_view.dart | 3 +- .../magic_starter_team_settings_view.dart | 21 +++++++------ .../magic_starter_login_view_social_test.dart | 6 ++-- ...gic_starter_register_view_social_test.dart | 6 ++-- ...er_notification_preferences_view_test.dart | 10 +++--- ...rter_profile_settings_newsletter_test.dart | 4 +-- 19 files changed, 113 insertions(+), 96 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a10eb27..556312a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ All notable changes to this project will be documented in this file. - **Breaking collision note**: the barrel now exports `Switch`, `Dialog`, `Checkbox`, `Radio`, `Badge`, `Typography`, `BottomSheet`, `Tooltip`, `DropdownMenu`, and `DropdownMenuItem`. Tests and app code that import both `package:flutter/material.dart` and `package:magic_starter/magic_starter.dart` must add a `hide` clause on the material import (or the barrel import) to resolve the ambiguity. ### Changed +- **Wave 5 view rewrite**: the auth (login, register, forgot, reset, two-factor-challenge, otp-verify), profile, notifications (list + preferences matrix), and teams (create, settings, invitation-accept) views plus both layouts (`MagicStarterAppLayout`, `MagicStarterGuestLayout`) now compose the new design-system components (`Button`, `Card`, `Switch`, `PageHeader`, `SocialDivider`, etc.) instead of inline W-widgets. Views are now Wind-exclusive: bare `package:flutter/material.dart` imports were replaced with `widgets.dart` + `material show Icons` (and the few genuinely-needed Material shells via `show`), so the new component names no longer collide. Behavior, registry keys (`auth.*`, `profile.*`, `notifications.*`, `teams.*`, `layout.app`, `layout.guest`), controller contracts, gate abilities, `refreshNotifier`, and notification polling are all preserved; only the presentation layer changed. - **Card migrated to the atomic-component folder + `WindRecipe`**: the card now lives at `lib/src/ui/components/card/` in the canonical 4-file shape (`card.dart` → `class Card`, `card.recipe.dart`, `card.preview.dart`, `index.dart`) and resolves its root className through a theme-driven `WindRecipe` instead of inline string interpolation. The recipe output is byte-identical to the previous `_defaultClassName` for every `CardVariant` x `noPadding` combination (gated by an explicit equivalence test). `MagicStarterCard` is retained as a thin re-export alias of `Card`, and `CardVariant` plus the barrel export path (`package:magic_starter/magic_starter.dart`) are unchanged, so existing callers and the widget-test suite are untouched. This establishes the verbatim template for the Wave 4 component migration. ### Added diff --git a/lib/src/ui/layouts/magic_starter_app_layout.dart b/lib/src/ui/layouts/magic_starter_app_layout.dart index dab88d0..2feb31b 100644 --- a/lib/src/ui/layouts/magic_starter_app_layout.dart +++ b/lib/src/ui/layouts/magic_starter_app_layout.dart @@ -1,4 +1,5 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' show Drawer, Scaffold, Icons; +import 'package:flutter/widgets.dart'; import 'package:magic/magic.dart'; import '../../configuration/magic_starter_config.dart'; diff --git a/lib/src/ui/layouts/magic_starter_guest_layout.dart b/lib/src/ui/layouts/magic_starter_guest_layout.dart index 3271d78..d6a870e 100644 --- a/lib/src/ui/layouts/magic_starter_guest_layout.dart +++ b/lib/src/ui/layouts/magic_starter_guest_layout.dart @@ -1,4 +1,5 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' show Scaffold; +import 'package:flutter/widgets.dart'; import 'package:magic/magic.dart'; import '../../facades/magic_starter.dart'; diff --git a/lib/src/ui/views/auth/magic_starter_forgot_password_view.dart b/lib/src/ui/views/auth/magic_starter_forgot_password_view.dart index 10c3ea5..e0c2f4c 100644 --- a/lib/src/ui/views/auth/magic_starter_forgot_password_view.dart +++ b/lib/src/ui/views/auth/magic_starter_forgot_password_view.dart @@ -1,5 +1,7 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/widgets.dart'; import 'package:magic/magic.dart'; +import 'package:magic_starter/magic_starter.dart' show Button; import '../../../facades/magic_starter.dart'; import '../../../http/controllers/magic_starter_auth_controller.dart'; @@ -16,6 +18,8 @@ class MagicStarterForgotPasswordView class _MagicStarterForgotPasswordViewState extends MagicStatefulViewState< MagicStarterAuthController, MagicStarterForgotPasswordView> { + static const _iconCheck = Icons.check_circle_outline; + late final form = MagicFormData( {'email': ''}, controller: controller, @@ -61,7 +65,7 @@ class _MagicStarterForgotPasswordViewState extends MagicStatefulViewState< className: 'w-16 h-16 rounded-full bg-green-50 dark:bg-green-900/20 flex items-center justify-center', child: WIcon( - Icons.check_circle_outline, + _iconCheck, className: 'text-[32px] text-green-600 dark:text-green-400', ), ), @@ -123,9 +127,9 @@ class _MagicStarterForgotPasswordViewState extends MagicStatefulViewState< labelClassName: MagicStarter.formTheme.labelClassName, ), const WSpacer(className: 'h-6'), - WButton( + Button( isLoading: isLoading, - onTap: _submit, + onPressed: _submit, className: MagicStarter.formTheme.primaryButtonClassName, child: WText( trans('auth.send_reset_link'), diff --git a/lib/src/ui/views/auth/magic_starter_login_view.dart b/lib/src/ui/views/auth/magic_starter_login_view.dart index 13ae313..10d3eb7 100644 --- a/lib/src/ui/views/auth/magic_starter_login_view.dart +++ b/lib/src/ui/views/auth/magic_starter_login_view.dart @@ -1,12 +1,14 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/widgets.dart'; import 'package:magic/magic.dart'; +import 'package:magic_starter/magic_starter.dart' + show Button, SocialDivider; import '../../../configuration/magic_starter_config.dart'; import '../../../facades/magic_starter.dart'; import '../../../http/controllers/magic_starter_auth_controller.dart'; import '../../../http/controllers/magic_starter_guest_auth_controller.dart'; import '../../widgets/magic_starter_auth_form_card.dart'; -import '../../widgets/magic_starter_social_divider.dart'; /// Login view with dynamic identity field support. /// @@ -134,16 +136,16 @@ class _MagicStarterLoginViewState extends MagicStatefulViewState< ], ), const WSpacer(className: 'h-6'), - WButton( + Button( isLoading: isLoading, - onTap: _submit, + onPressed: _submit, className: MagicStarter.formTheme.primaryButtonClassName, child: WText(trans('auth.login_title'), className: 'text-center'), ), if (MagicStarterConfig.hasGuestAuthFeatures()) ...[ const WSpacer(className: 'h-4'), - WButton( - onTap: MagicStarterGuestAuthController.instance.doGuestLogin, + Button( + onPressed: MagicStarterGuestAuthController.instance.doGuestLogin, isLoading: isLoading, className: MagicStarter.authTheme.guestButtonClassName, child: WText( @@ -155,7 +157,7 @@ class _MagicStarterLoginViewState extends MagicStatefulViewState< if (formFooterSlot != null) formFooterSlot, if (MagicStarterConfig.hasSocialLoginFeatures() && MagicStarter.hasSocialLogin) ...[ - const MagicStarterSocialDivider(), + const SocialDivider(), MagicStarter.socialLoginBuilder!(context, isLoading), ], if (MagicStarterConfig.hasRegistrationFeatures()) ...[ diff --git a/lib/src/ui/views/auth/magic_starter_otp_verify_view.dart b/lib/src/ui/views/auth/magic_starter_otp_verify_view.dart index 0e1b0b7..6d24e0c 100644 --- a/lib/src/ui/views/auth/magic_starter_otp_verify_view.dart +++ b/lib/src/ui/views/auth/magic_starter_otp_verify_view.dart @@ -1,6 +1,7 @@ -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; import 'package:magic/magic.dart'; +import 'package:magic_starter/magic_starter.dart' show Button; import '../../../facades/magic_starter.dart'; import '../../../http/controllers/magic_starter_otp_controller.dart'; @@ -127,9 +128,9 @@ class _MagicStarterOtpVerifyViewState extends MagicStatefulViewState< labelClassName: MagicStarter.formTheme.labelClassName, ), const WSpacer(className: 'h-6'), - WButton( + Button( isLoading: isLoading, - onTap: _submitPhone, + onPressed: _submitPhone, className: MagicStarter.formTheme.primaryButtonClassName, child: WText( trans('magic_starter.otp.send_code_button'), @@ -203,9 +204,9 @@ class _MagicStarterOtpVerifyViewState extends MagicStatefulViewState< labelClassName: MagicStarter.formTheme.labelClassName, ), const WSpacer(className: 'h-6'), - WButton( + Button( isLoading: isLoading, - onTap: _submitCode, + onPressed: _submitCode, className: MagicStarter.formTheme.primaryButtonClassName, child: WText( trans('magic_starter.otp.verify_button'), diff --git a/lib/src/ui/views/auth/magic_starter_register_view.dart b/lib/src/ui/views/auth/magic_starter_register_view.dart index 4d1bcda..df8a89a 100644 --- a/lib/src/ui/views/auth/magic_starter_register_view.dart +++ b/lib/src/ui/views/auth/magic_starter_register_view.dart @@ -1,11 +1,13 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/widgets.dart'; import 'package:magic/magic.dart'; +import 'package:magic_starter/magic_starter.dart' + show Button, SocialDivider; import '../../../configuration/magic_starter_config.dart'; import '../../../facades/magic_starter.dart'; import '../../../http/controllers/magic_starter_auth_controller.dart'; import '../../widgets/magic_starter_auth_form_card.dart'; -import '../../widgets/magic_starter_social_divider.dart'; /// Registration view with dynamic identity field support. /// @@ -178,9 +180,9 @@ class _MagicStarterRegisterViewState extends MagicStatefulViewState< const WSpacer(className: 'h-6'), // Submit - WButton( + Button( isLoading: isLoading, - onTap: _submit, + onPressed: _submit, className: MagicStarter.formTheme.primaryButtonClassName, child: WText( trans('auth.register_title'), @@ -193,7 +195,7 @@ class _MagicStarterRegisterViewState extends MagicStatefulViewState< // Social login slot if (MagicStarterConfig.hasSocialLoginFeatures() && MagicStarter.hasSocialLogin) ...[ - const MagicStarterSocialDivider(), + const SocialDivider(), MagicStarter.socialLoginBuilder!(context, isLoading), ], diff --git a/lib/src/ui/views/auth/magic_starter_reset_password_view.dart b/lib/src/ui/views/auth/magic_starter_reset_password_view.dart index cc63c21..9b71379 100644 --- a/lib/src/ui/views/auth/magic_starter_reset_password_view.dart +++ b/lib/src/ui/views/auth/magic_starter_reset_password_view.dart @@ -1,5 +1,7 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/widgets.dart'; import 'package:magic/magic.dart'; +import 'package:magic_starter/magic_starter.dart' show Button; import '../../../facades/magic_starter.dart'; import '../../../http/controllers/magic_starter_auth_controller.dart'; @@ -16,6 +18,10 @@ class MagicStarterResetPasswordView class _MagicStarterResetPasswordViewState extends MagicStatefulViewState< MagicStarterAuthController, MagicStarterResetPasswordView> { + static const _iconVisible = Icons.visibility; + static const _iconHidden = Icons.visibility_off; + static const _iconCheck = Icons.check_circle_outline; + late final _token = MagicRouter.instance.pathParameter('token') ?? ''; late final form = MagicFormData( { @@ -29,9 +35,6 @@ class _MagicStarterResetPasswordViewState extends MagicStatefulViewState< bool _obscurePassword = true; bool _obscureConfirmation = true; - static const _iconVisible = Icons.visibility; - static const _iconHidden = Icons.visibility_off; - @override void onInit() { final email = MagicRouter.instance.queryParameter('email'); @@ -82,7 +85,7 @@ class _MagicStarterResetPasswordViewState extends MagicStatefulViewState< className: 'w-16 h-16 rounded-full bg-green-50 dark:bg-green-900/20 flex items-center justify-center', child: WIcon( - Icons.check_circle_outline, + _iconCheck, className: 'text-[32px] text-green-600 dark:text-green-400', ), ), @@ -190,9 +193,9 @@ class _MagicStarterResetPasswordViewState extends MagicStatefulViewState< const WSpacer(className: 'h-6'), // Submit - WButton( + Button( isLoading: isLoading, - onTap: _submit, + onPressed: _submit, className: MagicStarter.formTheme.primaryButtonClassName, child: WText( trans('auth.reset_password_button'), diff --git a/lib/src/ui/views/auth/magic_starter_two_factor_challenge_view.dart b/lib/src/ui/views/auth/magic_starter_two_factor_challenge_view.dart index 8c1dcad..54fb235 100644 --- a/lib/src/ui/views/auth/magic_starter_two_factor_challenge_view.dart +++ b/lib/src/ui/views/auth/magic_starter_two_factor_challenge_view.dart @@ -1,6 +1,7 @@ import 'dart:async'; -import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:magic/magic.dart'; +import 'package:magic_starter/magic_starter.dart' show Button; import '../../../facades/magic_starter.dart'; import '../../../http/controllers/magic_starter_auth_controller.dart'; @@ -99,9 +100,9 @@ class _MagicStarterTwoFactorChallengeViewState extends MagicStatefulViewState< labelClassName: MagicStarter.formTheme.labelClassName, ), // Submit button - WButton( + Button( isLoading: isLoading, - onTap: _submit, + onPressed: _submit, className: MagicStarter.formTheme.primaryButtonClassName, child: WText(trans('auth.verify'), className: 'text-center'), ), diff --git a/lib/src/ui/views/notifications/magic_starter_notification_preferences_view.dart b/lib/src/ui/views/notifications/magic_starter_notification_preferences_view.dart index 8a0b19c..af15df9 100644 --- a/lib/src/ui/views/notifications/magic_starter_notification_preferences_view.dart +++ b/lib/src/ui/views/notifications/magic_starter_notification_preferences_view.dart @@ -1,9 +1,12 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' show Icons, CircularProgressIndicator; +import 'package:flutter/widgets.dart'; import 'package:magic/magic.dart'; -import '../../widgets/magic_starter_page_header.dart'; -import '../../widgets/magic_starter_card.dart'; + import '../../../facades/magic_starter.dart'; import '../../../http/controllers/magic_starter_notification_controller.dart'; +import '../../components/switch/switch.dart'; +import '../../widgets/magic_starter_card.dart'; +import '../../widgets/magic_starter_page_header.dart'; /// Notification preferences view for Magic Starter. /// @@ -172,19 +175,16 @@ class _MagicStarterNotificationPreferencesViewState ), ], ), - Switch.adaptive( + Switch( value: isEnabled, - activeThumbColor: Theme.of(context).colorScheme.onPrimary, - activeTrackColor: Theme.of(context).colorScheme.primary, - onChanged: isLocked - ? null - : (newValue) { - controller.updateTypePreference( - type, - channel, - newValue, - ); - }, + disabled: isLocked, + onChanged: (newValue) { + controller.updateTypePreference( + type, + channel, + newValue, + ); + }, ), ], ); diff --git a/lib/src/ui/views/notifications/magic_starter_notifications_list_view.dart b/lib/src/ui/views/notifications/magic_starter_notifications_list_view.dart index 19e58d9..357ef32 100644 --- a/lib/src/ui/views/notifications/magic_starter_notifications_list_view.dart +++ b/lib/src/ui/views/notifications/magic_starter_notifications_list_view.dart @@ -1,12 +1,12 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' show Icons, CircularProgressIndicator; +import 'package:flutter/widgets.dart'; import 'package:magic/magic.dart'; import 'package:magic_notifications/magic_notifications.dart'; +import '../../../facades/magic_starter.dart'; import '../../widgets/magic_starter_card.dart'; import '../../widgets/magic_starter_page_header.dart'; -import '../../../facades/magic_starter.dart'; - /// Full-page view for listing all notifications with mark as read, /// delete, pagination, and view all functionality. /// Uses server-side pagination for efficiency. diff --git a/lib/src/ui/views/profile/magic_starter_profile_settings_view.dart b/lib/src/ui/views/profile/magic_starter_profile_settings_view.dart index 4f81127..cfbbf5f 100644 --- a/lib/src/ui/views/profile/magic_starter_profile_settings_view.dart +++ b/lib/src/ui/views/profile/magic_starter_profile_settings_view.dart @@ -1,10 +1,12 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' show Icons; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; import 'package:magic/magic.dart'; import '../../../configuration/magic_starter_config.dart'; import '../../../facades/magic_starter.dart'; import '../../../http/controllers/magic_starter_profile_controller.dart'; +import '../../components/switch/switch.dart'; import '../../widgets/magic_starter_card.dart'; import '../../widgets/magic_starter_page_header.dart'; import '../../widgets/magic_starter_confirm_dialog.dart'; @@ -1002,23 +1004,18 @@ class _MagicStarterProfileSettingsViewState extends MagicStatefulViewState< ), ], ), - Switch.adaptive( + Switch( value: isSubscribed, - activeThumbColor: - Theme.of(context).colorScheme.onPrimary, - activeTrackColor: - Theme.of(context).colorScheme.primary, - onChanged: isLoading - ? null - : (newValue) async { - await _trackLoading( - _newsletterLoading, - () => newsletterController - .updateNewsletterSubscription( - subscribe: newValue, - ), - ); - }, + disabled: isLoading, + onChanged: (newValue) async { + await _trackLoading( + _newsletterLoading, + () => newsletterController + .updateNewsletterSubscription( + subscribe: newValue, + ), + ); + }, ), ], ), diff --git a/lib/src/ui/views/teams/magic_starter_team_create_view.dart b/lib/src/ui/views/teams/magic_starter_team_create_view.dart index 8494f20..e136df0 100644 --- a/lib/src/ui/views/teams/magic_starter_team_create_view.dart +++ b/lib/src/ui/views/teams/magic_starter_team_create_view.dart @@ -1,11 +1,11 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:magic/magic.dart'; import '../../../configuration/magic_starter_config.dart'; import '../../../facades/magic_starter.dart'; import '../../../http/controllers/magic_starter_team_controller.dart'; -import '../../widgets/magic_starter_page_header.dart'; -import '../../widgets/magic_starter_card.dart'; +import '../../components/card/card.dart'; +import '../../components/page_header/page_header.dart'; class MagicStarterTeamCreateView extends MagicStatefulView { @@ -52,7 +52,7 @@ class _MagicStarterTeamCreateViewState extends MagicStatefulViewState< className: 'p-4 lg:p-6 flex flex-col gap-6', children: [ if (headerSlot != null) headerSlot, - MagicStarterPageHeader( + PageHeader( title: trans('teams.create_team'), subtitle: trans('teams.create_team_subtitle'), ), @@ -67,7 +67,7 @@ class _MagicStarterTeamCreateViewState extends MagicStatefulViewState< return MagicForm( formData: form, - child: MagicStarterCard( + child: Card( child: WDiv( className: 'flex flex-col gap-4', children: [ diff --git a/lib/src/ui/views/teams/magic_starter_team_invitation_accept_view.dart b/lib/src/ui/views/teams/magic_starter_team_invitation_accept_view.dart index afc5718..9144b59 100644 --- a/lib/src/ui/views/teams/magic_starter_team_invitation_accept_view.dart +++ b/lib/src/ui/views/teams/magic_starter_team_invitation_accept_view.dart @@ -1,4 +1,5 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/widgets.dart'; import 'package:magic/magic.dart'; import '../../../configuration/magic_starter_config.dart'; diff --git a/lib/src/ui/views/teams/magic_starter_team_settings_view.dart b/lib/src/ui/views/teams/magic_starter_team_settings_view.dart index ef39a5f..914903a 100644 --- a/lib/src/ui/views/teams/magic_starter_team_settings_view.dart +++ b/lib/src/ui/views/teams/magic_starter_team_settings_view.dart @@ -1,12 +1,13 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/widgets.dart'; import 'package:magic/magic.dart'; import '../../../configuration/magic_starter_config.dart'; import '../../../facades/magic_starter.dart'; import '../../../http/controllers/magic_starter_team_controller.dart'; -import '../../widgets/magic_starter_card.dart'; +import '../../components/card/card.dart'; +import '../../components/page_header/page_header.dart'; import '../../widgets/magic_starter_confirm_dialog.dart'; -import '../../widgets/magic_starter_page_header.dart'; class MagicStarterTeamSettingsView extends MagicStatefulView { @@ -86,7 +87,7 @@ class _MagicStarterTeamSettingsViewState extends MagicStatefulViewState< className: 'p-4 lg:p-6 flex flex-col gap-6', children: [ if (headerSlot != null) headerSlot, - MagicStarterPageHeader( + PageHeader( title: trans('teams.settings'), subtitle: trans('teams.settings_subtitle'), ), @@ -105,7 +106,7 @@ class _MagicStarterTeamSettingsViewState extends MagicStatefulViewState< return MagicForm( formData: form, - child: MagicStarterCard( + child: Card( title: trans('teams.general_settings'), child: WDiv( className: 'flex flex-col gap-4', @@ -146,7 +147,7 @@ class _MagicStarterTeamSettingsViewState extends MagicStatefulViewState< valueListenable: controller.members, builder: (context, members, _) { if (members.isEmpty) { - return MagicStarterCard( + return Card( child: WDiv( className: 'w-full flex flex-col items-center gap-2 py-4', children: [ @@ -163,7 +164,7 @@ class _MagicStarterTeamSettingsViewState extends MagicStatefulViewState< ); } - return MagicStarterCard( + return Card( title: trans('teams.current_members'), noPadding: true, child: WDiv( @@ -181,7 +182,7 @@ class _MagicStarterTeamSettingsViewState extends MagicStatefulViewState< valueListenable: controller.invitations, builder: (context, invitations, _) { if (invitations.isEmpty) { - return MagicStarterCard( + return Card( child: WDiv( className: 'w-full flex flex-col items-center gap-2 py-4', children: [ @@ -198,7 +199,7 @@ class _MagicStarterTeamSettingsViewState extends MagicStatefulViewState< ); } - return MagicStarterCard( + return Card( title: trans('teams.pending_invitations'), noPadding: true, child: WDiv( @@ -219,7 +220,7 @@ class _MagicStarterTeamSettingsViewState extends MagicStatefulViewState< return MagicForm( formData: inviteForm, - child: MagicStarterCard( + child: Card( title: trans('teams.invite_member'), child: WDiv( className: 'flex flex-col gap-4', diff --git a/test/ui/views/auth/magic_starter_login_view_social_test.dart b/test/ui/views/auth/magic_starter_login_view_social_test.dart index 5fc6bcb..052d630 100644 --- a/test/ui/views/auth/magic_starter_login_view_social_test.dart +++ b/test/ui/views/auth/magic_starter_login_view_social_test.dart @@ -29,7 +29,7 @@ void main() { // Feature is false by default, no builder registered await tester.pumpWidget(wrap(const MagicStarterLoginView())); - expect(find.byType(MagicStarterSocialDivider), findsNothing); + expect(find.byType(SocialDivider), findsNothing); }); testWidgets( @@ -39,7 +39,7 @@ void main() { await tester.pumpWidget(wrap(const MagicStarterLoginView())); - expect(find.byType(MagicStarterSocialDivider), findsNothing); + expect(find.byType(SocialDivider), findsNothing); }); testWidgets( @@ -52,7 +52,7 @@ void main() { await tester.pumpWidget(wrap(const MagicStarterLoginView())); - expect(find.byType(MagicStarterSocialDivider), findsOneWidget); + expect(find.byType(SocialDivider), findsOneWidget); expect(find.byKey(const Key('social-buttons')), findsOneWidget); }); diff --git a/test/ui/views/auth/magic_starter_register_view_social_test.dart b/test/ui/views/auth/magic_starter_register_view_social_test.dart index d4abbac..0da6ea6 100644 --- a/test/ui/views/auth/magic_starter_register_view_social_test.dart +++ b/test/ui/views/auth/magic_starter_register_view_social_test.dart @@ -28,7 +28,7 @@ void main() { (tester) async { await tester.pumpWidget(wrap(const MagicStarterRegisterView())); - expect(find.byType(MagicStarterSocialDivider), findsNothing); + expect(find.byType(SocialDivider), findsNothing); }); testWidgets( @@ -38,7 +38,7 @@ void main() { await tester.pumpWidget(wrap(const MagicStarterRegisterView())); - expect(find.byType(MagicStarterSocialDivider), findsNothing); + expect(find.byType(SocialDivider), findsNothing); }); testWidgets( @@ -51,7 +51,7 @@ void main() { await tester.pumpWidget(wrap(const MagicStarterRegisterView())); - expect(find.byType(MagicStarterSocialDivider), findsOneWidget); + expect(find.byType(SocialDivider), findsOneWidget); expect(find.byKey(const Key('social-buttons')), findsOneWidget); }); diff --git a/test/ui/views/notifications/magic_starter_notification_preferences_view_test.dart b/test/ui/views/notifications/magic_starter_notification_preferences_view_test.dart index b8b44e2..908d581 100644 --- a/test/ui/views/notifications/magic_starter_notification_preferences_view_test.dart +++ b/test/ui/views/notifications/magic_starter_notification_preferences_view_test.dart @@ -1,7 +1,7 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide Switch; import 'package:flutter_test/flutter_test.dart'; import 'package:magic/magic.dart'; -import 'package:magic_starter/magic_starter.dart' hide Switch; +import 'package:magic_starter/magic_starter.dart'; class MockNetworkDriver implements NetworkDriver { MagicResponse? nextResponse; @@ -150,7 +150,7 @@ void main() { expect(find.text(trans('notifications.channel_email')), findsOneWidget); expect(find.text('Slack'), findsOneWidget); - // Check for WCheckbox toggles + // Check for design-system Switch toggles expect(find.byType(Switch), findsNWidgets(2)); }); @@ -177,8 +177,10 @@ void main() { .pumpWidget(wrap(const MagicStarterNotificationPreferencesView())); await tester.pumpAndSettle(); + // The design-system Switch uses disabled:true for locked channels + // rather than setting onChanged to null. final switchWidget = tester.widget(find.byType(Switch)); - expect(switchWidget.onChanged, isNull); + expect(switchWidget.disabled, isTrue); }); }); diff --git a/test/ui/views/profile/magic_starter_profile_settings_newsletter_test.dart b/test/ui/views/profile/magic_starter_profile_settings_newsletter_test.dart index 7909c38..157e319 100644 --- a/test/ui/views/profile/magic_starter_profile_settings_newsletter_test.dart +++ b/test/ui/views/profile/magic_starter_profile_settings_newsletter_test.dart @@ -1,7 +1,7 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide Switch; import 'package:flutter_test/flutter_test.dart'; import 'package:magic/magic.dart'; -import 'package:magic_starter/magic_starter.dart' hide Switch; +import 'package:magic_starter/magic_starter.dart'; // --------------------------------------------------------------------------- // Mock NetworkDriver — intercepts all Http facade calls From 41d8baa9fcd542553db6ec3cd671f4d786c73bd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Thu, 25 Jun 2026 22:28:17 +0300 Subject: [PATCH 06/12] feat: add design.md.stub for consumer DESIGN.md scaffolding Ship a DESIGN.md template covering all 17 semantic roles, typography on the 4px logical scale, rounded/spacing, and key component entries with {{ placeholder }} tokens. Consumers copy it, fill in brand hex + font, run design:lint to validate, then design:sync to generate the wind theme. --- assets/stubs/design.md.stub | 176 ++++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 assets/stubs/design.md.stub diff --git a/assets/stubs/design.md.stub b/assets/stubs/design.md.stub new file mode 100644 index 0000000..283b826 --- /dev/null +++ b/assets/stubs/design.md.stub @@ -0,0 +1,176 @@ +--- +name: {{ app_name }} +description: > + Design system for {{ app_name }}. Single-brand, Wind semantic tokens, + M3-role palette. Generate the Wind theme with: + dart run {{ app_package }}:artisan design:sync +colors: + surface: + light: "#FFFFFF" + dark: "#030712" + surface-container: + light: "#F9FAFB" + dark: "#111827" + surface-container-high: + light: "#F3F4F6" + dark: "#1F2937" + fg: + light: "#111827" + dark: "#F9FAFB" + fg-muted: + light: "#6B7280" + dark: "#9CA3AF" + fg-disabled: + light: "#D1D5DB" + dark: "#4B5563" + primary: + light: "#{{ primary_hex_light }}" + dark: "#{{ primary_hex_dark }}" + on-primary: + light: "#FFFFFF" + dark: "#FFFFFF" + primary-container: + light: "#{{ primary_container_light }}" + dark: "#{{ primary_container_dark }}" + accent: + light: "#{{ accent_hex_light }}" + dark: "#{{ accent_hex_dark }}" + border: + light: "#E5E7EB" + dark: "#374151" + border-subtle: + light: "#F3F4F6" + dark: "#1F2937" + destructive: + light: "#DC2626" + dark: "#EF4444" + on-destructive: + light: "#FFFFFF" + dark: "#FFFFFF" + destructive-container: + light: "#FEE2E2" + dark: "#7F1D1D" + success: + light: "#15803D" + dark: "#16A34A" + warning: + light: "#D97706" + dark: "#B45309" +typography: + display: + fontFamily: {{ font_family }} + fontSize: 36px + fontWeight: "700" + lineHeight: 44px + letterSpacing: -0.02em + headline-lg: + fontFamily: {{ font_family }} + fontSize: 28px + fontWeight: "700" + lineHeight: 36px + letterSpacing: -0.01em + headline-md: + fontFamily: {{ font_family }} + fontSize: 22px + fontWeight: "600" + lineHeight: 30px + title-lg: + fontFamily: {{ font_family }} + fontSize: 18px + fontWeight: "600" + lineHeight: 26px + body-lg: + fontFamily: {{ font_family }} + fontSize: 16px + fontWeight: "400" + lineHeight: 26px + body-md: + fontFamily: {{ font_family }} + fontSize: 14px + fontWeight: "400" + lineHeight: 22px + label-md: + fontFamily: {{ font_family }} + fontSize: 14px + fontWeight: "600" + lineHeight: 20px + letterSpacing: 0.01em + label-sm: + fontFamily: {{ font_family }} + fontSize: 12px + fontWeight: "500" + lineHeight: 16px +rounded: + sm: 4px + DEFAULT: 8px + md: 12px + lg: 16px + xl: 24px + full: 9999px +spacing: + xs: 4px + sm: 8px + md: 16px + lg: 24px + xl: 40px + gutter: 16px + section: 32px +components: + button-primary: + backgroundColor: "{colors.primary}" + textColor: "{colors.on-primary}" + rounded: "{rounded.md}" + padding: "{spacing.md}" + button-destructive: + backgroundColor: "{colors.destructive}" + textColor: "{colors.on-destructive}" + rounded: "{rounded.md}" + padding: "{spacing.md}" + card-surface: + backgroundColor: "{colors.surface-container}" + rounded: "{rounded.lg}" + padding: "{spacing.lg}" + input-field: + backgroundColor: "{colors.surface-container-high}" + rounded: "{rounded.DEFAULT}" + padding: "{spacing.md}" +--- + +## Overview + +[Describe the brand personality, product purpose, and style direction for +{{ app_name }}. What does this design system represent?] + +For responsive and accessible usage patterns, see +[docs/design-culture/](docs/design-culture/). + +## Colors + +[Explain the palette rationale: why these colors, what roles they serve, how +light and dark modes differ. Reference WCAG contrast requirements for +on-X / X pairs.] + +## Typography + +[Describe the chosen font and why it fits the brand. List the scale levels and +their intended usage contexts (display, body, label).] + +## Layout + +[Describe the responsive grid: breakpoints, column model, gutter and section +spacing. Reference the wind-responsive doc for breakpoint-specific guidance.] + +## Elevation & Depth + +[Describe the elevation model: tonal backgrounds, shadows (if any), and how +depth signals hierarchy to the user.] + +## Shapes + +[Map corner-radius values to use cases: inputs, buttons, cards, dialogs, +badges.] + +## Components + +[List the key components and their token bindings. Variant matrices are visible +at `/preview` in debug builds. Run `design:lint` to validate token usage.] From 782ea41f4ca2a7b134d762cc69467205c7d346d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Fri, 26 Jun 2026 01:31:14 +0300 Subject: [PATCH 07/12] docs: complete post-change sync for the design-system work Add the missing design.md.stub CHANGELOG entry, a README Design-System Components section (MagicStarterTokens + the component families + the import-collision note), and doc/basics/components.md (per the doc/basics per-feature convention). Update the CLAUDE.md test count; apply dart format. --- CHANGELOG.md | 1 + CLAUDE.md | 2 +- README.md | 48 +++- doc/basics/components.md | 231 ++++++++++++++++++ .../views/auth/magic_starter_login_view.dart | 6 +- .../auth/magic_starter_register_view.dart | 3 +- 6 files changed, 283 insertions(+), 8 deletions(-) create mode 100644 doc/basics/components.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 556312a..1ed337f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] ### Added +- **`design.md.stub`**: a `DESIGN.md` template shipped at `assets/stubs/design.md.stub` covering all 17 semantic roles (`surface`, `fg`, `primary`, `border`, `destructive`, `success`, `warning`, and their variants), typography on the 4px logical scale, rounded/spacing scales, and key component entries with `{{ placeholder }}` tokens. Consumers copy it into their project root, fill in brand hex values and fonts, then run `design:lint` to validate and `design:sync` to generate the Wind theme. Pairs with `MagicStarterTokens.defaultAliases` as the stable key contract. - **Wave 4 design-system component library**: 23 new generic UI components are now part of the public barrel (`package:magic_starter/magic_starter.dart`). Each component lives in the canonical atomic-component folder shape (`.dart`, `.recipe.dart`, `.preview.dart`, `index.dart`) under `lib/src/ui/components/`. - **Form controls**: `Button` (with `ButtonIntent`, `ButtonSize`, `buttonRecipe`), `Input` (with `InputState`, `inputRecipe`), `Textarea` (with `TextareaState`, `textareaRecipe`), `Checkbox`, `Switch`, `Radio`, `Select` (with `selectRecipe`), `Combobox` (with `comboboxRecipe`). - **Feedback and display**: `Badge` (with `BadgeTone`), `Typography` (with `TypographyVariant`), `Skeleton` (with `SkeletonShape`), `Toast` (with `ToastVariant`), `Tooltip`, `EmptyState`, `ErrorState`. diff --git a/CLAUDE.md b/CLAUDE.md index 8411eac..282f1d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ Flutter starter kit for the Magic Framework. Pre-built Auth, Profile, Teams & No | Command | Description | |---------|-------------| -| `flutter test --coverage` | Run all tests (~52 files, ~752 cases) with coverage | +| `flutter test --coverage` | Run all tests (~62 files, ~996 cases) with coverage | | `flutter test test/http/controllers/` | Run controller tests only | | `flutter test --name "pattern"` | Run tests matching pattern | | `flutter analyze --no-fatal-infos` | Static analysis (flutter_lints ^6.0) | diff --git a/README.md b/README.md index 32b0f7e..de8b908 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Stop rebuilding authentication, profile management, and team features from scrat | :bell: | **Notifications** | Real-time polling, mark read/unread, preference matrix | | :iphone: | **OTP Login** | Phone-based guest authentication with send/verify flow | | :art: | **Wind UI** | Tailwind-like className system — no Material widgets, dark mode built-in | -| :package: | **Reusable Widgets** | PageHeader, Card (3 variants), ConfirmDialog (3 variants), PasswordConfirmDialog, TwoFactorModal — all standalone | +| :package: | **Design-System Components** | 29 atomic components (Button, Input, Badge, Dialog, Toast, Tabs, Accordion, and more) plus `MagicStarterTokens` semantic alias layer | | :gear: | **13 Feature Toggles** | All opt-in, configure only what you need | | :jigsaw: | **View Registry** | Override any screen or layout from the host app | | :hammer_and_wrench: | **CLI Tools** | install, configure, doctor, publish, uninstall | @@ -191,9 +191,53 @@ MagicStarter.useSidebarFooter((context) { --- +## Design-System Components + +Magic Starter ships a full atomic design-system component library exported from `package:magic_starter/magic_starter.dart`. Every component lives in a canonical 4-file atomic folder (`.dart`, `.recipe.dart`, `.preview.dart`, `index.dart`) under `lib/src/ui/components/` and is driven by a `WindRecipe` that reads from `MagicStarterTokens.defaultAliases`. + +### MagicStarterTokens + +`MagicStarterTokens.defaultAliases` is a map of 17 semantic roles to light+dark Wind className pairs: + +| Role | Example className pair | +|------|----------------------| +| `surface` | `bg-white dark:bg-gray-950` | +| `fg` | `text-gray-900 dark:text-gray-50` | +| `primary` | `bg-primary-600 dark:bg-primary-500` | +| `destructive` | `bg-red-600 dark:bg-red-500` | +| `border` | `border-gray-200 dark:border-gray-800` | + +Pass the map when configuring your Wind theme so all components resolve against semantic roles rather than raw palette utilities: + +```dart +WindApp( + theme: WindThemeData( + aliases: MagicStarterTokens.defaultAliases, + ), + child: const MyApp(), +) +``` + +> **Breaking import note**: the barrel now exports `Switch`, `Dialog`, `Checkbox`, `Radio`, `Badge`, `Typography`, `BottomSheet`, `Tooltip`, `DropdownMenu`, and `DropdownMenuItem`. If you import both `package:flutter/material.dart` and `package:magic_starter/magic_starter.dart`, add a `hide` clause on the conflicting names. + +### Component families + +| Family | Components | +|--------|-----------| +| Form controls | `Button`, `Input`, `Textarea`, `Checkbox`, `Switch`, `Radio`, `Select`, `Combobox` | +| Display | `Badge`, `Typography`, `Skeleton`, `Toast`, `Tooltip`, `EmptyState`, `ErrorState` | +| Selection / navigation | `SegmentedControl`, `Tabs`, `Accordion`, `Navbar`, `DropdownMenu` | +| Overlay | `Dialog`, `BottomSheet` | +| Composition | `MagicFormField`, `Card`, `PageHeader`, `SocialDivider` | +| App chrome | `NotificationDropdown`, `UserProfileDropdown`, `TeamSelector` | + +All components accept Wind `className` strings and resolve colors through the semantic alias layer when configured. + +--- + ## Reusable Widgets -Magic Starter exports a set of standalone UI widgets that consumer apps can use directly — no internal controller coupling required. +Magic Starter also exports a set of standalone UI widgets that consumer apps can use directly. No internal controller coupling required. ### MagicStarterPageHeader diff --git a/doc/basics/components.md b/doc/basics/components.md new file mode 100644 index 0000000..b6368bb --- /dev/null +++ b/doc/basics/components.md @@ -0,0 +1,231 @@ +# Design-System Components + +- [Introduction](#introduction) +- [Semantic Token Layer](#semantic-token-layer) + - [MagicStarterTokens](#magicstartertokens) + - [Wiring into WindThemeData](#wiring-into-windthemedata) + - [Token Reference](#token-reference) +- [Atomic Folder Shape](#atomic-folder-shape) +- [Component Families](#component-families) + - [Form Controls](#form-controls) + - [Display and Feedback](#display-and-feedback) + - [Selection and Navigation](#selection-and-navigation) + - [Overlay](#overlay) + - [Composition and App Chrome](#composition-and-app-chrome) +- [Import Collisions](#import-collisions) +- [design.md.stub](#designmdstub) + + +## Introduction + +Magic Starter ships a complete atomic design-system component library under `lib/src/ui/components/`. Every component is driven by a `WindRecipe` that resolves colors through the `MagicStarterTokens` semantic alias layer, so a single brand configuration flows into all built-in screens and every component your host app composes directly. + +All 29 components (plus the migrated aliases) are exported from `package:magic_starter/magic_starter.dart`. + + +## Semantic Token Layer + + +### MagicStarterTokens + +`MagicStarterTokens` is a single `const` class with one public member: `defaultAliases`. It is a `Map` of 17 semantic roles to light+dark Wind className pairs following the `bg-/text-/border-color-` prefix convention: + +```dart +final aliases = MagicStarterTokens.defaultAliases; +// { +// 'surface': 'bg-white dark:bg-gray-950', +// 'surface-container': 'bg-gray-50 dark:bg-gray-900', +// 'surface-container-high': 'bg-gray-100 dark:bg-gray-800', +// 'fg': 'text-gray-900 dark:text-gray-50', +// 'fg-muted': 'text-gray-500 dark:text-gray-400', +// 'fg-disabled': 'text-gray-300 dark:text-gray-600', +// 'primary': 'bg-primary-600 dark:bg-primary-500', +// 'on-primary': 'text-white dark:text-white', +// 'primary-container': 'bg-primary-50 dark:bg-primary-950', +// 'accent': 'bg-accent-600 dark:bg-accent-500', +// 'border': 'border-gray-200 dark:border-gray-800', +// 'border-subtle': 'border-gray-100 dark:border-gray-900', +// 'destructive': 'bg-red-600 dark:bg-red-500', +// 'on-destructive': 'text-white dark:text-white', +// 'destructive-container': 'bg-red-50 dark:bg-red-950', +// 'success': 'bg-green-600 dark:bg-green-500', +// 'warning': 'bg-amber-500 dark:bg-amber-400', +// } +``` + + +### Wiring into WindThemeData + +Pass `MagicStarterTokens.defaultAliases` as the `aliases` argument when constructing your `WindThemeData`: + +```dart +WindApp( + theme: WindThemeData( + aliases: MagicStarterTokens.defaultAliases, + ), + child: const MyApp(), +) +``` + +Components that reference a semantic role (e.g. `bg-surface`, `text-fg`, `bg-primary`) will resolve through this map at render time. Without the aliases wired in, the semantic class names are silently skipped by Wind, so your brand color and surface tokens will not apply. + + +### Token Reference + +| Role | Semantic meaning | Maps to (default) | +|------|-----------------|-------------------| +| `surface` | Page/card background | `bg-white dark:bg-gray-950` | +| `surface-container` | Subtle recessed area | `bg-gray-50 dark:bg-gray-900` | +| `surface-container-high` | Elevated container | `bg-gray-100 dark:bg-gray-800` | +| `fg` | Primary text | `text-gray-900 dark:text-gray-50` | +| `fg-muted` | Secondary/supporting text | `text-gray-500 dark:text-gray-400` | +| `fg-disabled` | Disabled state text | `text-gray-300 dark:text-gray-600` | +| `primary` | Brand primary fill | `bg-primary-600 dark:bg-primary-500` | +| `on-primary` | Text on primary fill | `text-white dark:text-white` | +| `primary-container` | Tinted primary area | `bg-primary-50 dark:bg-primary-950` | +| `accent` | Secondary brand fill | `bg-accent-600 dark:bg-accent-500` | +| `border` | Standard border | `border-gray-200 dark:border-gray-800` | +| `border-subtle` | Faint separator | `border-gray-100 dark:border-gray-900` | +| `destructive` | Danger / delete fill | `bg-red-600 dark:bg-red-500` | +| `on-destructive` | Text on danger fill | `text-white dark:text-white` | +| `destructive-container` | Tinted danger area | `bg-red-50 dark:bg-red-950` | +| `success` | Positive / confirmed | `bg-green-600 dark:bg-green-500` | +| `warning` | Caution / review | `bg-amber-500 dark:bg-amber-400` | + +To override individual roles, spread `defaultAliases` and replace specific keys: + +```dart +WindThemeData( + aliases: { + ...MagicStarterTokens.defaultAliases, + 'primary': 'bg-indigo-600 dark:bg-indigo-500', + 'on-primary': 'text-white dark:text-white', + }, +) +``` + + +## Atomic Folder Shape + +Every component lives in a 4-file atomic folder: + +``` +lib/src/ui/components// + .dart # The widget class (StatelessWidget or StatefulWidget) + .recipe.dart # WindRecipe function: resolves className from theme tokens + .preview.dart # Standalone preview widget for interactive inspection + index.dart # Barrel: re-exports the public API for this component +``` + +The `.recipe.dart` file contains a top-level function (e.g. `buttonRecipe`, `cardRecipe`) that accepts variant/state arguments and returns a Wind className string. The widget delegates its className derivation to the recipe so the visual contract is inspectable and testable in isolation. + + +## Component Families + + +### Form Controls + +| Component | Enums / Helpers | Notes | +|-----------|----------------|-------| +| `Button` | `ButtonIntent` (primary/secondary/ghost/destructive), `ButtonSize` (sm/md/lg), `buttonRecipe` | Use `ButtonIntent.destructive` for delete / danger actions | +| `Input` | `InputState` (idle/focused/error/disabled), `inputRecipe` | Pairs with `MagicFormField` for label + error display | +| `Textarea` | `TextareaState`, `textareaRecipe` | Multi-line input, same state model as `Input` | +| `Checkbox` | | Boolean toggle; Wind-only, no Material dependency | +| `Switch` | | Toggle; replaces Flutter's `Switch` in Wind layouts | +| `Radio` | | Single-select option | +| `Select` | `selectRecipe` | Dropdown single-select backed by an item list | +| `Combobox` | `comboboxRecipe` | Searchable single-select with filter input | + + +### Display and Feedback + +| Component | Enums / Helpers | Notes | +|-----------|----------------|-------| +| `Badge` | `BadgeTone` (neutral/primary/success/warning/destructive) | Inline label chip | +| `Typography` | `TypographyVariant` (h1-h6/body/caption/overline) | Semantic text wrapper | +| `Skeleton` | `SkeletonShape` (line/rect/circle) | Placeholder loading block | +| `Toast` | `ToastVariant` (info/success/warning/error) | Transient feedback overlay | +| `Tooltip` | | Hover/long-press hint bubble | +| `EmptyState` | | Illustrated empty-list placeholder | +| `ErrorState` | | Full-screen or inline error with retry | + + +### Selection and Navigation + +| Component | Enums / Helpers | Notes | +|-----------|----------------|-------| +| `SegmentedControl` | `SegmentedControlSize`, `segmentedControlRecipe` | Inline tab switcher | +| `Tabs` | `tabsRecipe` | Full tab bar with content panels | +| `Accordion` | `AccordionItem`, `accordionRecipe` | Collapsible section list | +| `Navbar` | | Horizontal top navigation bar | +| `DropdownMenu` | `DropdownMenuItem` | Contextual action menu | + + +### Overlay + +| Component | Notes | +|-----------|-------| +| `Dialog` | Modal dialog shell; reads `MagicStarterModalTheme` tokens | +| `BottomSheet` | Slide-up sheet; reads `MagicStarterModalTheme` tokens | + + +### Composition and App Chrome + +| Component | Notes | +|-----------|-------| +| `MagicFormField` | Label + input + hint + error layout wrapper | +| `Card` | Surface/inset/elevated variants; `MagicStarterCard` is a stable alias | +| `PageHeader` | Full-width responsive header; `MagicStarterPageHeader` is a stable alias | +| `SocialDivider` | "Or continue with" separator; `MagicStarterSocialDivider` is a stable alias | +| `NotificationDropdown` | Bell-icon dropdown with live unread badge; `MagicStarterNotificationDropdown` is a stable alias | +| `UserProfileDropdown` | Avatar menu with profile links and logout; `MagicStarterUserProfileDropdown` is a stable alias | +| `TeamSelector` | Current-team switcher; `MagicStarterTeamSelector` is a stable alias | + + +## Import Collisions + +The barrel now exports names that collide with `package:flutter/material.dart`: + +``` +Switch, Dialog, Checkbox, Radio, Badge, Typography, BottomSheet, Tooltip, +DropdownMenu, DropdownMenuItem +``` + +In files that import both, add a `hide` clause on the material import: + +```dart +import 'package:flutter/material.dart' show Icons; // import only Icons +import 'package:magic_starter/magic_starter.dart'; +``` + +Or hide the specific colliding names: + +```dart +import 'package:flutter/material.dart' hide Switch, Dialog, Checkbox; +import 'package:magic_starter/magic_starter.dart'; +``` + +Widget tests that need both Material and magic_starter types must do the same. + + +## design.md.stub + +`assets/stubs/design.md.stub` is a `DESIGN.md` template that covers all 17 semantic roles, typography on the 4px logical scale, rounded/spacing scales, and key component entries with `{{ placeholder }}` tokens. + +Consumers copy it into their project root: + +```bash +cp $(flutter pub cache list magic_starter)/assets/stubs/design.md.stub DESIGN.md +``` + +Then fill in brand hex values and fonts, and run the CLI commands: + +```bash +# Validate your DESIGN.md against the schema +dart run :artisan design:lint + +# Generate the Wind theme from your DESIGN.md +dart run :artisan design:sync +``` + +`design:sync` regenerates the `WindThemeData(aliases: ...)` map, replacing `MagicStarterTokens.defaultAliases` with your brand-specific values so every component reflects your brand automatically. diff --git a/lib/src/ui/views/auth/magic_starter_login_view.dart b/lib/src/ui/views/auth/magic_starter_login_view.dart index 10d3eb7..2f249ff 100644 --- a/lib/src/ui/views/auth/magic_starter_login_view.dart +++ b/lib/src/ui/views/auth/magic_starter_login_view.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart' show Icons; import 'package:flutter/widgets.dart'; import 'package:magic/magic.dart'; -import 'package:magic_starter/magic_starter.dart' - show Button, SocialDivider; +import 'package:magic_starter/magic_starter.dart' show Button, SocialDivider; import '../../../configuration/magic_starter_config.dart'; import '../../../facades/magic_starter.dart'; @@ -145,7 +144,8 @@ class _MagicStarterLoginViewState extends MagicStatefulViewState< if (MagicStarterConfig.hasGuestAuthFeatures()) ...[ const WSpacer(className: 'h-4'), Button( - onPressed: MagicStarterGuestAuthController.instance.doGuestLogin, + onPressed: + MagicStarterGuestAuthController.instance.doGuestLogin, isLoading: isLoading, className: MagicStarter.authTheme.guestButtonClassName, child: WText( diff --git a/lib/src/ui/views/auth/magic_starter_register_view.dart b/lib/src/ui/views/auth/magic_starter_register_view.dart index df8a89a..8d7efd6 100644 --- a/lib/src/ui/views/auth/magic_starter_register_view.dart +++ b/lib/src/ui/views/auth/magic_starter_register_view.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart' show Icons; import 'package:flutter/widgets.dart'; import 'package:magic/magic.dart'; -import 'package:magic_starter/magic_starter.dart' - show Button, SocialDivider; +import 'package:magic_starter/magic_starter.dart' show Button, SocialDivider; import '../../../configuration/magic_starter_config.dart'; import '../../../facades/magic_starter.dart'; From 992127a04d62503b28770e04ceefa887b7771530 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Fri, 26 Jun 2026 03:27:11 +0300 Subject: [PATCH 08/12] fix(switch): position thumb via flex justify, drop no-op translate-x The switch track recipe now uses `flex items-center justify-start checked:justify-end` with `px-0.5` instead of `translate-x-*` (a no-op in Wind, so the thumb never moved). Pairs with the WSwitch fix that drops `relative` from the track so it stays a flex Row. --- .../ui/components/switch/switch.recipe.dart | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/lib/src/ui/components/switch/switch.recipe.dart b/lib/src/ui/components/switch/switch.recipe.dart index 953bd46..21e3eed 100644 --- a/lib/src/ui/components/switch/switch.recipe.dart +++ b/lib/src/ui/components/switch/switch.recipe.dart @@ -3,21 +3,25 @@ import 'package:magic/magic.dart'; /// The switch track [WindRecipe] (const). /// /// Returns the className for the [WSwitch] track (outer pill). The `checked:` -/// state prefix is driven by [WSwitch]'s active-states set; the recipe only -/// supplies base shape and semantic token color overrides. +/// state prefix is driven by [WSwitch]'s active-states set; the recipe supplies +/// the base shape, the semantic token colors, and the thumb POSITION. +/// +/// The thumb is a flex child of the track, so it moves left-to-right via +/// `justify-start` -> `checked:justify-end` (Wind supports flex alignment). It +/// deliberately does NOT use `translate-x-*`: Wind has no transform parser, so +/// a translate-based thumb would never move. const WindRecipe switchTrackRecipe = WindRecipe( - base: 'w-11 h-6 rounded-full border-2 ' - 'bg-surface-container-high border-color-border ' - 'checked:bg-primary checked:border-bg-primary ' - 'disabled:opacity-50 disabled:cursor-not-allowed ' - 'focus:ring-2 focus:ring-bg-primary', + base: 'w-11 h-6 rounded-full px-0.5 ' + 'flex items-center justify-start checked:justify-end ' + 'bg-surface-container-high checked:bg-primary ' + 'disabled:opacity-50', ); /// The switch thumb [WindRecipe] (const). /// -/// Returns the className for the [WSwitch] thumb (inner circle). +/// Returns the className for the [WSwitch] thumb (inner circle). Position is +/// owned by the track's `justify-*` (see [switchTrackRecipe]); the thumb only +/// supplies its shape and color. const WindRecipe switchThumbRecipe = WindRecipe( - base: 'w-4 h-4 rounded-full bg-surface ' - 'translate-x-0 checked:translate-x-5 ' - 'shadow transition-transform', + base: 'w-5 h-5 rounded-full bg-surface shadow', ); From b78a5b4f6fd852d8a487eb399bc600cb790c64ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Fri, 26 Jun 2026 04:06:12 +0300 Subject: [PATCH 09/12] fix(button): default Button shrinks to content instead of full-width Dropped `justify-center` from the buttonRecipe base. In Wind that maps to WButton's Container alignment, which forces the button to fill its constraints (every default Button stacked one-per-row in a variant row). A default button now shrink-wraps its content (its `inline-flex` intent), verified 130px vs 800px. Form/modal buttons are unaffected: they pass a className override (the form theme ships `w-full`) that bypasses the recipe base. --- CHANGELOG.md | 1 + lib/src/ui/components/button/button.recipe.dart | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ed337f..2bcfe3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ All notable changes to this project will be documented in this file. - **`MagicStarterTokens.defaultAliases`**: semantic token alias map with 17 roles (`surface`, `surface-container`, `surface-container-high`, `fg`, `fg-muted`, `fg-disabled`, `primary`, `on-primary`, `primary-container`, `accent`, `border`, `border-subtle`, `destructive`, `on-destructive`, `destructive-container`, `success`, `warning`). Each role maps to a light+dark wind className pair (`'bg-... dark:bg-...'` / `'text-... dark:text-...'`). Pass as `WindThemeData(aliases: MagicStarterTokens.defaultAliases)` so components resolve against semantic roles rather than palette utilities directly. This map is the stable key contract that `design:sync` (Steps 20-21) will later regenerate from `DESIGN.md`. ### Fixed +- **`Button` no longer forces full-width**: the `buttonRecipe` base dropped `justify-center`, which in Wind maps to WButton's `Container` alignment and made every default `Button()` expand to fill its constraints (stacking one-per-row in a variant row). A default button now shrinks to its content (its `inline-flex` intent), with the label centered by the shrink-wrapped padding box. Form/modal buttons are unaffected: they pass a `className` override (the form theme ships `w-full`), which bypasses the recipe base entirely. - **Create team opens the new team's settings**: `MagicStarterTeamController.activeTeamName` now matches the local `currentTeamId` (when set) against the resolver's `allTeams()` by id, falling back to the resolver's `currentTeam()` when no match exists. Previously `activeTeamId` preferred the local `currentTeamId` notifier (set on create/switch) while `activeTeamName` read only the resolver's `currentTeam()`, so after creating a team the settings view pre-filled the OLD team's name and effectively opened the old team ([#14](https://github.com/fluttersdk/magic_starter/issues/14)). ### Removed diff --git a/lib/src/ui/components/button/button.recipe.dart b/lib/src/ui/components/button/button.recipe.dart index 6eb41da..271e7f3 100644 --- a/lib/src/ui/components/button/button.recipe.dart +++ b/lib/src/ui/components/button/button.recipe.dart @@ -47,7 +47,13 @@ enum ButtonSize { /// /// Emission order: `base ++ intent-classes ++ size-classes ++ compound`. const WindRecipe buttonRecipe = WindRecipe( - base: 'inline-flex items-center justify-center font-medium rounded-lg ' + // No `justify-center`: in Wind that maps to WButton's Container alignment, + // which forces the button to expand and fill its constraints (full-width). + // A default Button shrinks to its content (the `inline-flex` intent); the + // single-child label is centered by the shrink-wrapped padding box. Callers + // opt into full-width by passing a `className` override (e.g. form themes + // ship `w-full`), which bypasses this base entirely. + base: 'inline-flex items-center font-medium rounded-lg ' 'transition-colors disabled:opacity-50 disabled:cursor-not-allowed ' 'focus:outline-none focus:ring-2 focus:ring-offset-1', variants: { From 3fe990265d9477ae416a3f2ea32004e00cd42fe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Fri, 26 Jun 2026 12:20:19 +0300 Subject: [PATCH 10/12] feat(previews): dev-only previews.dart barrel for all 30 components Add package:magic_starter/previews.dart, a dev-only barrel that exposes every component preview (Button..UserProfileDropdown, TeamSelector, NotificationDropdown, dialogs, etc.) as (label, slug, builder) records via starterComponentPreviews(). Kept separate from the magic_starter.dart release barrel so the atomic-component 'previews stay out of release' rule holds; the records come from a function (no top-level const widget refs), so a consumer calling it behind a kReleaseMode/PREVIEW_ENABLED guard tree-shakes the whole set. Lets a consumer catalog surface the full component library without recreating previews. --- CHANGELOG.md | 1 + lib/previews.dart | 165 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 lib/previews.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bcfe3f..ff46663 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] ### Added +- **`package:magic_starter/previews.dart`** (dev-only barrel): exposes all 30 component previews as `(label, slug, builder)` records via `starterComponentPreviews()`, so a consumer's dev-only preview catalog can surface the full component set (Button, Badge, ..., UserProfileDropdown, TeamSelector, NotificationDropdown) without duplication. Kept SEPARATE from the `magic_starter.dart` release barrel (the atomic-component contract keeps `*.preview.dart` out of release); the records are returned from a function (not a top-level const holding widget refs), so a consumer that only calls it behind a `kReleaseMode`/`PREVIEW_ENABLED` guard tree-shakes the whole set from release. - **`design.md.stub`**: a `DESIGN.md` template shipped at `assets/stubs/design.md.stub` covering all 17 semantic roles (`surface`, `fg`, `primary`, `border`, `destructive`, `success`, `warning`, and their variants), typography on the 4px logical scale, rounded/spacing scales, and key component entries with `{{ placeholder }}` tokens. Consumers copy it into their project root, fill in brand hex values and fonts, then run `design:lint` to validate and `design:sync` to generate the Wind theme. Pairs with `MagicStarterTokens.defaultAliases` as the stable key contract. - **Wave 4 design-system component library**: 23 new generic UI components are now part of the public barrel (`package:magic_starter/magic_starter.dart`). Each component lives in the canonical atomic-component folder shape (`.dart`, `.recipe.dart`, `.preview.dart`, `index.dart`) under `lib/src/ui/components/`. - **Form controls**: `Button` (with `ButtonIntent`, `ButtonSize`, `buttonRecipe`), `Input` (with `InputState`, `inputRecipe`), `Textarea` (with `TextareaState`, `textareaRecipe`), `Checkbox`, `Switch`, `Radio`, `Select` (with `selectRecipe`), `Combobox` (with `comboboxRecipe`). diff --git a/lib/previews.dart b/lib/previews.dart new file mode 100644 index 0000000..c33630d --- /dev/null +++ b/lib/previews.dart @@ -0,0 +1,165 @@ +/// **Dev-only barrel of magic_starter component previews.** +/// +/// This is intentionally SEPARATE from the `magic_starter.dart` release barrel: +/// per the atomic-component contract, `*.preview.dart` files never join the +/// release barrel. A consumer's dev-only preview catalog imports THIS library +/// instead, behind a `kReleaseMode` / `PREVIEW_ENABLED` guard, so release builds +/// that do not reach it tree-shake every preview (and the components they pull +/// in only for preview). +/// +/// [starterComponentPreviews] returns the full set as `(label, slug, builder)` +/// records from a FUNCTION (never a top-level const holding widget refs — the +/// dart-lang/sdk#33920 foot-gun), so the set stays droppable when unreferenced. +library; + +import 'package:flutter/widgets.dart'; + +import 'src/ui/components/accordion/accordion.preview.dart'; +import 'src/ui/components/badge/badge.preview.dart'; +import 'src/ui/components/bottom_sheet/bottom_sheet.preview.dart'; +import 'src/ui/components/button/button.preview.dart'; +import 'src/ui/components/card/card.preview.dart'; +import 'src/ui/components/checkbox/checkbox.preview.dart'; +import 'src/ui/components/combobox/combobox.preview.dart'; +import 'src/ui/components/confirm_dialog/confirm_dialog.preview.dart'; +import 'src/ui/components/dialog/dialog.preview.dart'; +import 'src/ui/components/dropdown_menu/dropdown_menu.preview.dart'; +import 'src/ui/components/empty_state/empty_state.preview.dart'; +import 'src/ui/components/error_state/error_state.preview.dart'; +import 'src/ui/components/form_field/form_field.preview.dart'; +import 'src/ui/components/input/input.preview.dart'; +import 'src/ui/components/navbar/navbar.preview.dart'; +import 'src/ui/components/notification_dropdown/notification_dropdown.preview.dart'; +import 'src/ui/components/page_header/page_header.preview.dart'; +import 'src/ui/components/radio/radio.preview.dart'; +import 'src/ui/components/segmented_control/segmented_control.preview.dart'; +import 'src/ui/components/select/select.preview.dart'; +import 'src/ui/components/skeleton/skeleton.preview.dart'; +import 'src/ui/components/social_divider/social_divider.preview.dart'; +import 'src/ui/components/switch/switch.preview.dart'; +import 'src/ui/components/tabs/tabs.preview.dart'; +import 'src/ui/components/team_selector/team_selector.preview.dart'; +import 'src/ui/components/textarea/textarea.preview.dart'; +import 'src/ui/components/toast/toast.preview.dart'; +import 'src/ui/components/tooltip/tooltip.preview.dart'; +import 'src/ui/components/typography/typography.preview.dart'; +import 'src/ui/components/user_profile_dropdown/user_profile_dropdown.preview.dart'; + +/// One registered starter component preview: a human [label], a URL-safe +/// [slug] (the component folder name), and a [builder] that renders the +/// component's variant matrix. +typedef StarterComponentPreview = ({ + String label, + String slug, + WidgetBuilder builder, +}); + +/// Every magic_starter component preview, as `(label, slug, builder)` records. +/// +/// Dev-only: call this only from a preview catalog behind a release guard. +List starterComponentPreviews() { + return [ + ( + label: 'Accordion', + slug: 'accordion', + builder: (_) => const AccordionPreview() + ), + (label: 'Badge', slug: 'badge', builder: (_) => const BadgePreview()), + ( + label: 'Bottom Sheet', + slug: 'bottom_sheet', + builder: (_) => const BottomSheetPreview() + ), + (label: 'Button', slug: 'button', builder: (_) => const ButtonPreview()), + (label: 'Card', slug: 'card', builder: (_) => const CardPreview()), + ( + label: 'Checkbox', + slug: 'checkbox', + builder: (_) => const CheckboxPreview() + ), + ( + label: 'Combobox', + slug: 'combobox', + builder: (_) => const ComboboxPreview() + ), + ( + label: 'Confirm Dialog', + slug: 'confirm_dialog', + builder: (_) => const ConfirmDialogPreview() + ), + (label: 'Dialog', slug: 'dialog', builder: (_) => const DialogPreview()), + ( + label: 'Dropdown Menu', + slug: 'dropdown_menu', + builder: (_) => const DropdownMenuPreview() + ), + ( + label: 'Empty State', + slug: 'empty_state', + builder: (_) => const EmptyStatePreview() + ), + ( + label: 'Error State', + slug: 'error_state', + builder: (_) => const ErrorStatePreview() + ), + ( + label: 'Form Field', + slug: 'form_field', + builder: (_) => const MagicFormFieldPreview() + ), + (label: 'Input', slug: 'input', builder: (_) => const InputPreview()), + (label: 'Navbar', slug: 'navbar', builder: (_) => const NavbarPreview()), + ( + label: 'Notification Dropdown', + slug: 'notification_dropdown', + builder: (_) => const NotificationDropdownPreview() + ), + ( + label: 'Page Header', + slug: 'page_header', + builder: (_) => const PageHeaderPreview() + ), + (label: 'Radio', slug: 'radio', builder: (_) => const RadioPreview()), + ( + label: 'Segmented Control', + slug: 'segmented_control', + builder: (_) => const SegmentedControlPreview() + ), + (label: 'Select', slug: 'select', builder: (_) => const SelectPreview()), + ( + label: 'Skeleton', + slug: 'skeleton', + builder: (_) => const SkeletonPreview() + ), + ( + label: 'Social Divider', + slug: 'social_divider', + builder: (_) => const SocialDividerPreview() + ), + (label: 'Switch', slug: 'switch', builder: (_) => const SwitchPreview()), + (label: 'Tabs', slug: 'tabs', builder: (_) => const TabsPreview()), + ( + label: 'Team Selector', + slug: 'team_selector', + builder: (_) => const TeamSelectorPreview() + ), + ( + label: 'Textarea', + slug: 'textarea', + builder: (_) => const TextareaPreview() + ), + (label: 'Toast', slug: 'toast', builder: (_) => const ToastPreview()), + (label: 'Tooltip', slug: 'tooltip', builder: (_) => const TooltipPreview()), + ( + label: 'Typography', + slug: 'typography', + builder: (_) => const TypographyPreview() + ), + ( + label: 'User Profile Dropdown', + slug: 'user_profile_dropdown', + builder: (_) => const UserProfileDropdownPreview() + ), + ]; +} From bc8b62f3a913e98d0a827b3fbe6386aadf8ce56b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Fri, 26 Jun 2026 13:05:05 +0300 Subject: [PATCH 11/12] fix(teams): switch to the new team on create (server-side current-team) doCreate only set the local currentTeamId after POST /teams; the backend current_team_id stayed on the previous team, so the resolver-driven sidebar name + active highlight showed the OLD team while local state and the member fetch pointed at the new one (REPORT #14, reproduced e2e: create opened the old team's settings). doCreate now PUTs /user/current-team with the new id before Auth.restore() (Jetstream create-then-switch); server, resolver, sidebar, and settings now agree. Verified end-to-end via dusk (telescope shows POST /teams -> PUT /user/current-team; sidebar + settings show the new team). --- CHANGELOG.md | 1 + .../magic_starter_team_controller.dart | 18 +++++++++++++++++- .../magic_starter_team_controller_test.dart | 11 +++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff46663..444f47b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ All notable changes to this project will be documented in this file. - **`MagicStarterTokens.defaultAliases`**: semantic token alias map with 17 roles (`surface`, `surface-container`, `surface-container-high`, `fg`, `fg-muted`, `fg-disabled`, `primary`, `on-primary`, `primary-container`, `accent`, `border`, `border-subtle`, `destructive`, `on-destructive`, `destructive-container`, `success`, `warning`). Each role maps to a light+dark wind className pair (`'bg-... dark:bg-...'` / `'text-... dark:text-...'`). Pass as `WindThemeData(aliases: MagicStarterTokens.defaultAliases)` so components resolve against semantic roles rather than palette utilities directly. This map is the stable key contract that `design:sync` (Steps 20-21) will later regenerate from `DESIGN.md`. ### Fixed +- **Creating a team now switches to it.** `MagicStarterTeamController.doCreate` only set the local `currentTeamId` notifier after `POST /teams`; the backend's `current_team_id` stayed on the previous team, so the resolver-driven sidebar name and active-team highlight showed the OLD team while local state and the member fetch pointed at the new one (REPORT #14, confirmed via e2e: create opened the old team's settings). `doCreate` now calls `PUT /user/current-team` with the new id before `Auth.restore()` (Jetstream create-then-switch), so the server, resolver, sidebar, and settings all agree on the new team. - **`Button` no longer forces full-width**: the `buttonRecipe` base dropped `justify-center`, which in Wind maps to WButton's `Container` alignment and made every default `Button()` expand to fill its constraints (stacking one-per-row in a variant row). A default button now shrinks to its content (its `inline-flex` intent), with the label centered by the shrink-wrapped padding box. Form/modal buttons are unaffected: they pass a `className` override (the form theme ships `w-full`), which bypasses the recipe base entirely. - **Create team opens the new team's settings**: `MagicStarterTeamController.activeTeamName` now matches the local `currentTeamId` (when set) against the resolver's `allTeams()` by id, falling back to the resolver's `currentTeam()` when no match exists. Previously `activeTeamId` preferred the local `currentTeamId` notifier (set on create/switch) while `activeTeamName` read only the resolver's `currentTeam()`, so after creating a team the settings view pre-filled the OLD team's name and effectively opened the old team ([#14](https://github.com/fluttersdk/magic_starter/issues/14)). diff --git a/lib/src/http/controllers/magic_starter_team_controller.dart b/lib/src/http/controllers/magic_starter_team_controller.dart index 405242a..ebc1998 100644 --- a/lib/src/http/controllers/magic_starter_team_controller.dart +++ b/lib/src/http/controllers/magic_starter_team_controller.dart @@ -169,7 +169,23 @@ class MagicStarterTeamController extends MagicController final createdTeamId = data?['id']; if (createdTeamId != null) { - currentTeamId.value = createdTeamId; + // Switch the backend's current team to the newly created one + // (Jetstream create-then-switch). Without this the server keeps + // current_team_id on the previous team, so the resolver-driven sidebar + // name + active highlight show the OLD team while local state and the + // member fetch point at the new one (inconsistent state, REPORT #14). + final switchResponse = await Http.put( + '/user/current-team', + data: {'team_id': createdTeamId}, + ); + if (switchResponse.successful) { + currentTeamId.value = createdTeamId; + } else { + Log.error( + '[MagicStarterTeamController.doCreate] team $createdTeamId created ' + 'but current-team switch failed (${switchResponse.statusCode})', + ); + } } await Auth.restore(); diff --git a/test/http/controllers/magic_starter_team_controller_test.dart b/test/http/controllers/magic_starter_team_controller_test.dart index 1aeb729..f0b0520 100644 --- a/test/http/controllers/magic_starter_team_controller_test.dart +++ b/test/http/controllers/magic_starter_team_controller_test.dart @@ -276,6 +276,12 @@ void main() { }, }, ); + // doCreate switches the backend current team to the new one. + mockDriver.stubResponse( + '/user/current-team', + statusCode: 200, + data: {'data': {}}, + ); final result = await controller.doCreate(name: 'New Team'); @@ -323,6 +329,11 @@ void main() { }, }, ); + mockDriver.stubResponse( + '/user/current-team', + statusCode: 200, + data: {'data': {}}, + ); await controller.doCreate(name: 'New Team'); From 29f7b5f627deefefbb3134b9d3bba9cb8ea08f40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Fri, 26 Jun 2026 14:57:24 +0300 Subject: [PATCH 12/12] fix(components): address PR #78 review (release boundary, semantic tokens, Wind-only previews) - Stop re-exporting *.preview.dart from 9 component index.dart barrels; the main barrel re-exports every index.dart, so this leaked dev-only previews into the release barrel. previews:refresh + the previews.dart dev barrel discover previews directly; preview tests now import the preview file directly. Fixes the form_field comment/export contradiction Copilot flagged and the same leak in 8 sibling components. - Tooltip: default panel + kTooltipDefaultPanelClassName use semantic alias tokens (bg-surface-container-high / text-fg / border-color-border) instead of hardcoded gray; corrected the doc comment to the real enableTriggerOnTap:true behavior (no PopoverController). - BottomSheet drag handle: semantic bg-surface-container-high, not bg-gray-*. - PageHeader/EmptyState/ErrorState previews render actions with the design-system Button + WText instead of Material ElevatedButton/Text. All 996 tests pass; analyze clean. --- CHANGELOG.md | 5 +++++ .../components/bottom_sheet/bottom_sheet.dart | 4 +++- .../empty_state/empty_state.preview.dart | 7 ++++--- lib/src/ui/components/empty_state/index.dart | 1 - .../error_state/error_state.preview.dart | 7 ++++--- lib/src/ui/components/error_state/index.dart | 1 - lib/src/ui/components/form_field/index.dart | 1 - lib/src/ui/components/navbar/index.dart | 1 - .../notification_dropdown/index.dart | 1 - lib/src/ui/components/page_header/index.dart | 1 - .../page_header/page_header.preview.dart | 11 ++++++----- .../ui/components/social_divider/index.dart | 1 - .../ui/components/team_selector/index.dart | 1 - lib/src/ui/components/tooltip/tooltip.dart | 19 ++++++++++--------- .../ui/components/tooltip/tooltip.recipe.dart | 9 ++++++--- .../user_profile_dropdown/index.dart | 1 - .../empty_state/empty_state_test.dart | 1 + .../error_state/error_state_test.dart | 1 + .../form_field/form_field_test.dart | 1 + test/ui/components/navbar/navbar_test.dart | 1 + .../notification_dropdown_test.dart | 2 +- .../page_header/page_header_test.dart | 2 +- .../social_divider/social_divider_test.dart | 2 +- .../team_selector/team_selector_test.dart | 2 +- .../user_profile_dropdown_test.dart | 2 +- 25 files changed, 47 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 444f47b..d1446f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,11 @@ All notable changes to this project will be documented in this file. - **`MagicStarterTokens.defaultAliases`**: semantic token alias map with 17 roles (`surface`, `surface-container`, `surface-container-high`, `fg`, `fg-muted`, `fg-disabled`, `primary`, `on-primary`, `primary-container`, `accent`, `border`, `border-subtle`, `destructive`, `on-destructive`, `destructive-container`, `success`, `warning`). Each role maps to a light+dark wind className pair (`'bg-... dark:bg-...'` / `'text-... dark:text-...'`). Pass as `WindThemeData(aliases: MagicStarterTokens.defaultAliases)` so components resolve against semantic roles rather than palette utilities directly. This map is the stable key contract that `design:sync` (Steps 20-21) will later regenerate from `DESIGN.md`. ### Fixed +- **PR #78 review (release boundary + semantic tokens + Wind-only previews):** + - Component `index.dart` barrels no longer re-export their `*.preview.dart` (9 components: error_state, empty_state, navbar, form_field, notification_dropdown, social_divider, page_header, user_profile_dropdown, team_selector). Previews are dev-only and must stay out of the release barrel (`magic_starter.dart` re-exports every `index.dart`); `previews:refresh` and the `previews.dart` dev barrel discover `*.preview.dart` directly, so the exports leaked previews into release for no benefit. Preview tests now import the preview file directly. + - `Tooltip` default panel + `kTooltipDefaultPanelClassName` use semantic alias tokens (`bg-surface-container-high text-fg border border-color-border`) instead of hardcoded gray palette utilities, so tooltips re-skin via `MagicStarterTokens` / `design:sync`. The `Tooltip` doc comment was corrected to describe the actual `enableTriggerOnTap: true` behavior (it does not use a `PopoverController`). + - `BottomSheet` drag handle uses `bg-surface-container-high` instead of `bg-gray-300 dark:bg-gray-600`. + - `PageHeader` / `EmptyState` / `ErrorState` previews render their action with the design-system `Button` + `WText` instead of Material `ElevatedButton` / `Text`, keeping previews Wind-only and dropping the Material import churn. - **Creating a team now switches to it.** `MagicStarterTeamController.doCreate` only set the local `currentTeamId` notifier after `POST /teams`; the backend's `current_team_id` stayed on the previous team, so the resolver-driven sidebar name and active-team highlight showed the OLD team while local state and the member fetch pointed at the new one (REPORT #14, confirmed via e2e: create opened the old team's settings). `doCreate` now calls `PUT /user/current-team` with the new id before `Auth.restore()` (Jetstream create-then-switch), so the server, resolver, sidebar, and settings all agree on the new team. - **`Button` no longer forces full-width**: the `buttonRecipe` base dropped `justify-center`, which in Wind maps to WButton's `Container` alignment and made every default `Button()` expand to fill its constraints (stacking one-per-row in a variant row). A default button now shrinks to its content (its `inline-flex` intent), with the label centered by the shrink-wrapped padding box. Form/modal buttons are unaffected: they pass a `className` override (the form theme ships `w-full`), which bypasses the recipe base entirely. - **Create team opens the new team's settings**: `MagicStarterTeamController.activeTeamName` now matches the local `currentTeamId` (when set) against the resolver's `allTeams()` by id, falling back to the resolver's `currentTeam()` when no match exists. Previously `activeTeamId` preferred the local `currentTeamId` notifier (set on create/switch) while `activeTeamName` read only the resolver's `currentTeam()`, so after creating a team the settings view pre-filled the OLD team's name and effectively opened the old team ([#14](https://github.com/fluttersdk/magic_starter/issues/14)). diff --git a/lib/src/ui/components/bottom_sheet/bottom_sheet.dart b/lib/src/ui/components/bottom_sheet/bottom_sheet.dart index 0ff98a6..16edf04 100644 --- a/lib/src/ui/components/bottom_sheet/bottom_sheet.dart +++ b/lib/src/ui/components/bottom_sheet/bottom_sheet.dart @@ -91,8 +91,10 @@ class BottomSheet extends StatelessWidget { // 3. Drag handle indicator. Center( child: WDiv( + // Semantic alias so the handle re-skins with the theme instead + // of hardcoded gray palette utilities. className: - 'w-9 h-1 bg-gray-300 dark:bg-gray-600 rounded-full mt-3 mb-1', + 'w-9 h-1 bg-surface-container-high rounded-full mt-3 mb-1', ), ), // 4. Header section: title + description. diff --git a/lib/src/ui/components/empty_state/empty_state.preview.dart b/lib/src/ui/components/empty_state/empty_state.preview.dart index f244449..08e70e6 100644 --- a/lib/src/ui/components/empty_state/empty_state.preview.dart +++ b/lib/src/ui/components/empty_state/empty_state.preview.dart @@ -1,7 +1,8 @@ -import 'package:flutter/material.dart' show Icons, ElevatedButton, Text; +import 'package:flutter/material.dart' show Icons; import 'package:flutter/widgets.dart'; import 'package:magic/magic.dart'; +import '../button/button.dart'; import 'empty_state.dart'; /// Static preview for [EmptyState]. @@ -26,9 +27,9 @@ class EmptyStatePreview extends StatelessWidget { icon: Icons.folder_open_outlined, title: 'No projects found', description: 'Create your first project to get started.', - action: ElevatedButton( + action: Button( onPressed: () {}, - child: const Text('Create project'), + child: const WText('Create project'), ), ), ], diff --git a/lib/src/ui/components/empty_state/index.dart b/lib/src/ui/components/empty_state/index.dart index a0ae269..ad78e7a 100644 --- a/lib/src/ui/components/empty_state/index.dart +++ b/lib/src/ui/components/empty_state/index.dart @@ -2,4 +2,3 @@ export 'empty_state.dart' show EmptyState; export 'empty_state.recipe.dart'; -export 'empty_state.preview.dart' show EmptyStatePreview; diff --git a/lib/src/ui/components/error_state/error_state.preview.dart b/lib/src/ui/components/error_state/error_state.preview.dart index 1116cea..b839901 100644 --- a/lib/src/ui/components/error_state/error_state.preview.dart +++ b/lib/src/ui/components/error_state/error_state.preview.dart @@ -1,7 +1,8 @@ -import 'package:flutter/material.dart' show Icons, ElevatedButton, Text; +import 'package:flutter/material.dart' show Icons; import 'package:flutter/widgets.dart'; import 'package:magic/magic.dart'; +import '../button/button.dart'; import 'error_state.dart'; /// Static preview for [ErrorState]. @@ -21,9 +22,9 @@ class ErrorStatePreview extends StatelessWidget { icon: Icons.error_outline, title: 'Failed to load data', description: 'Please check your connection and try again.', - action: ElevatedButton( + action: Button( onPressed: () {}, - child: const Text('Retry'), + child: const WText('Retry'), ), ), ], diff --git a/lib/src/ui/components/error_state/index.dart b/lib/src/ui/components/error_state/index.dart index 9c7a9e7..536a0f5 100644 --- a/lib/src/ui/components/error_state/index.dart +++ b/lib/src/ui/components/error_state/index.dart @@ -2,4 +2,3 @@ export 'error_state.dart' show ErrorState; export 'error_state.recipe.dart'; -export 'error_state.preview.dart' show ErrorStatePreview; diff --git a/lib/src/ui/components/form_field/index.dart b/lib/src/ui/components/form_field/index.dart index 64c80f0..f7fb5fe 100644 --- a/lib/src/ui/components/form_field/index.dart +++ b/lib/src/ui/components/form_field/index.dart @@ -6,4 +6,3 @@ export 'form_field.dart' show MagicFormField; export 'form_field.recipe.dart'; -export 'form_field.preview.dart' show MagicFormFieldPreview; diff --git a/lib/src/ui/components/navbar/index.dart b/lib/src/ui/components/navbar/index.dart index 5b2148e..2fed7d0 100644 --- a/lib/src/ui/components/navbar/index.dart +++ b/lib/src/ui/components/navbar/index.dart @@ -2,4 +2,3 @@ export 'navbar.dart' show Navbar; export 'navbar.recipe.dart'; -export 'navbar.preview.dart' show NavbarPreview; diff --git a/lib/src/ui/components/notification_dropdown/index.dart b/lib/src/ui/components/notification_dropdown/index.dart index ca3fc40..cb4c7e5 100644 --- a/lib/src/ui/components/notification_dropdown/index.dart +++ b/lib/src/ui/components/notification_dropdown/index.dart @@ -1,4 +1,3 @@ // NotificationDropdown component — folder-local barrel. export 'notification_dropdown.dart' show NotificationDropdown; -export 'notification_dropdown.preview.dart' show NotificationDropdownPreview; diff --git a/lib/src/ui/components/page_header/index.dart b/lib/src/ui/components/page_header/index.dart index 9081384..21c7adb 100644 --- a/lib/src/ui/components/page_header/index.dart +++ b/lib/src/ui/components/page_header/index.dart @@ -1,4 +1,3 @@ // PageHeader component — folder-local barrel. export 'page_header.dart' show PageHeader; -export 'page_header.preview.dart' show PageHeaderPreview; diff --git a/lib/src/ui/components/page_header/page_header.preview.dart b/lib/src/ui/components/page_header/page_header.preview.dart index 7de6836..53a1837 100644 --- a/lib/src/ui/components/page_header/page_header.preview.dart +++ b/lib/src/ui/components/page_header/page_header.preview.dart @@ -1,7 +1,8 @@ -import 'package:flutter/material.dart' show Icons, ElevatedButton, Text; +import 'package:flutter/material.dart' show Icons; import 'package:flutter/widgets.dart'; import 'package:magic/magic.dart'; +import '../button/button.dart'; import 'page_header.dart'; /// Static preview for [PageHeader]. @@ -24,9 +25,9 @@ class PageHeaderPreview extends StatelessWidget { PageHeader( title: 'Settings', actions: [ - ElevatedButton( + Button( onPressed: () {}, - child: const Text('Save'), + child: const WText('Save'), ), ], ), @@ -35,9 +36,9 @@ class PageHeaderPreview extends StatelessWidget { inlineActions: true, leading: const Icon(Icons.arrow_back), actions: [ - ElevatedButton( + Button( onPressed: () {}, - child: const Text('Create'), + child: const WText('Create'), ), ], ), diff --git a/lib/src/ui/components/social_divider/index.dart b/lib/src/ui/components/social_divider/index.dart index 7364234..047bd2b 100644 --- a/lib/src/ui/components/social_divider/index.dart +++ b/lib/src/ui/components/social_divider/index.dart @@ -1,4 +1,3 @@ // SocialDivider component — folder-local barrel. export 'social_divider.dart' show SocialDivider; -export 'social_divider.preview.dart' show SocialDividerPreview; diff --git a/lib/src/ui/components/team_selector/index.dart b/lib/src/ui/components/team_selector/index.dart index 7b733ae..2bb689f 100644 --- a/lib/src/ui/components/team_selector/index.dart +++ b/lib/src/ui/components/team_selector/index.dart @@ -1,4 +1,3 @@ // TeamSelector component — folder-local barrel. export 'team_selector.dart' show TeamSelector; -export 'team_selector.preview.dart' show TeamSelectorPreview; diff --git a/lib/src/ui/components/tooltip/tooltip.dart b/lib/src/ui/components/tooltip/tooltip.dart index b4b0755..c18fb87 100644 --- a/lib/src/ui/components/tooltip/tooltip.dart +++ b/lib/src/ui/components/tooltip/tooltip.dart @@ -1,6 +1,8 @@ import 'package:flutter/widgets.dart'; import 'package:magic/magic.dart'; +import 'tooltip.recipe.dart'; + /// A reusable tooltip component that wraps [WPopover] to display a brief /// label on hover (desktop) or long-press (mobile). /// @@ -8,10 +10,10 @@ import 'package:magic/magic.dart'; /// All styling is className-driven (semantic tokens). /// /// **WPopover dismiss race note**: the `_suppressNextTapOutside` guard in -/// [WPopover] handles the real-click dismiss race (see `wind/w_popover.dart` -/// lines 231-240). Tooltip uses `enableTriggerOnTap: false` and a -/// [PopoverController] for programmatic show/hide so the synthetic-tap -/// behavior remains intact and no real-click race can be introduced. +/// [WPopover] handles the real-click dismiss race (see `wind/w_popover.dart`). +/// Tooltip sets `enableTriggerOnTap: true` so tapping the trigger toggles the +/// tooltip on touch screens; WPopover's guard swallows the same-frame +/// outside-tap so opening does not immediately dismiss it. /// /// ### Example /// ```dart @@ -28,8 +30,8 @@ class Tooltip extends StatelessWidget { /// The tooltip content widget shown in the popover panel. final Widget content; - /// Optional className for the tooltip panel. Defaults to a standard dark - /// popover style using semantic tokens. + /// Optional className for the tooltip panel. Defaults to + /// [kTooltipDefaultPanelClassName] (a semantic-token surface panel). final String? className; /// Popover alignment relative to the trigger. Defaults to [PopoverAlignment.topCenter]. @@ -46,9 +48,8 @@ class Tooltip extends StatelessWidget { @override Widget build(BuildContext context) { - // 1. Resolve panel className from argument or default token classes. - final panelClassName = className ?? - 'bg-gray-900 dark:bg-gray-700 text-white text-xs px-2 py-1 rounded max-w-xs'; + // 1. Resolve panel className from argument or the default semantic tokens. + final panelClassName = className ?? kTooltipDefaultPanelClassName; // 2. Wrap the trigger in WPopover for hover/tap-driven display. // enableTriggerOnTap: true lets tapping the trigger show/hide the diff --git a/lib/src/ui/components/tooltip/tooltip.recipe.dart b/lib/src/ui/components/tooltip/tooltip.recipe.dart index 5fe0f27..06f5b53 100644 --- a/lib/src/ui/components/tooltip/tooltip.recipe.dart +++ b/lib/src/ui/components/tooltip/tooltip.recipe.dart @@ -1,6 +1,9 @@ /// Default className for the [Tooltip] popover panel. /// -/// Uses semantic tokens for background and text so the tooltip adapts to -/// light and dark theme without hardcoded hex values. +/// Uses semantic alias tokens (`bg-surface-container-high`, `text-fg`, +/// `border-color-border`) so the tooltip re-skins with `MagicStarterTokens` +/// and `design:sync` output, in both light and dark theme, with no hardcoded +/// palette utilities. const String kTooltipDefaultPanelClassName = - 'bg-gray-900 dark:bg-gray-700 text-white text-xs px-2 py-1 rounded max-w-xs'; + 'bg-surface-container-high text-fg border border-color-border ' + 'text-xs px-2 py-1 rounded max-w-xs'; diff --git a/lib/src/ui/components/user_profile_dropdown/index.dart b/lib/src/ui/components/user_profile_dropdown/index.dart index 0e2a1e7..1fad53c 100644 --- a/lib/src/ui/components/user_profile_dropdown/index.dart +++ b/lib/src/ui/components/user_profile_dropdown/index.dart @@ -1,4 +1,3 @@ // UserProfileDropdown component — folder-local barrel. export 'user_profile_dropdown.dart' show UserProfileDropdown; -export 'user_profile_dropdown.preview.dart' show UserProfileDropdownPreview; diff --git a/test/ui/components/empty_state/empty_state_test.dart b/test/ui/components/empty_state/empty_state_test.dart index 85a932a..34f5ec2 100644 --- a/test/ui/components/empty_state/empty_state_test.dart +++ b/test/ui/components/empty_state/empty_state_test.dart @@ -2,6 +2,7 @@ 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/components/empty_state/empty_state.preview.dart'; void main() { setUp(() { diff --git a/test/ui/components/error_state/error_state_test.dart b/test/ui/components/error_state/error_state_test.dart index b5f623a..a4e7d59 100644 --- a/test/ui/components/error_state/error_state_test.dart +++ b/test/ui/components/error_state/error_state_test.dart @@ -2,6 +2,7 @@ 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/components/error_state/error_state.preview.dart'; void main() { setUp(() { diff --git a/test/ui/components/form_field/form_field_test.dart b/test/ui/components/form_field/form_field_test.dart index 12eef55..499af5e 100644 --- a/test/ui/components/form_field/form_field_test.dart +++ b/test/ui/components/form_field/form_field_test.dart @@ -2,6 +2,7 @@ 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/components/form_field/form_field.preview.dart'; void main() { setUp(() { diff --git a/test/ui/components/navbar/navbar_test.dart b/test/ui/components/navbar/navbar_test.dart index 136f465..d065706 100644 --- a/test/ui/components/navbar/navbar_test.dart +++ b/test/ui/components/navbar/navbar_test.dart @@ -2,6 +2,7 @@ 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/components/navbar/navbar.preview.dart'; void main() { setUp(() { diff --git a/test/ui/components/notification_dropdown/notification_dropdown_test.dart b/test/ui/components/notification_dropdown/notification_dropdown_test.dart index 6eba24d..2a526ed 100644 --- a/test/ui/components/notification_dropdown/notification_dropdown_test.dart +++ b/test/ui/components/notification_dropdown/notification_dropdown_test.dart @@ -5,7 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:magic/magic.dart'; import 'package:magic_notifications/magic_notifications.dart'; import 'package:magic_starter/magic_starter.dart'; -import 'package:magic_starter/src/ui/components/notification_dropdown/index.dart'; +import 'package:magic_starter/src/ui/components/notification_dropdown/notification_dropdown.preview.dart'; void main() { late StreamController> streamController; diff --git a/test/ui/components/page_header/page_header_test.dart b/test/ui/components/page_header/page_header_test.dart index 507728c..544b094 100644 --- a/test/ui/components/page_header/page_header_test.dart +++ b/test/ui/components/page_header/page_header_test.dart @@ -2,7 +2,7 @@ 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/components/page_header/index.dart'; +import 'package:magic_starter/src/ui/components/page_header/page_header.preview.dart'; void main() { setUp(() { diff --git a/test/ui/components/social_divider/social_divider_test.dart b/test/ui/components/social_divider/social_divider_test.dart index ef6eba7..b54c17d 100644 --- a/test/ui/components/social_divider/social_divider_test.dart +++ b/test/ui/components/social_divider/social_divider_test.dart @@ -2,7 +2,7 @@ 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/components/social_divider/index.dart'; +import 'package:magic_starter/src/ui/components/social_divider/social_divider.preview.dart'; void main() { setUp(() { diff --git a/test/ui/components/team_selector/team_selector_test.dart b/test/ui/components/team_selector/team_selector_test.dart index ac4019d..bdccdca 100644 --- a/test/ui/components/team_selector/team_selector_test.dart +++ b/test/ui/components/team_selector/team_selector_test.dart @@ -2,7 +2,7 @@ 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/components/team_selector/index.dart'; +import 'package:magic_starter/src/ui/components/team_selector/team_selector.preview.dart'; void main() { setUp(() { diff --git a/test/ui/components/user_profile_dropdown/user_profile_dropdown_test.dart b/test/ui/components/user_profile_dropdown/user_profile_dropdown_test.dart index 9931829..7fb6707 100644 --- a/test/ui/components/user_profile_dropdown/user_profile_dropdown_test.dart +++ b/test/ui/components/user_profile_dropdown/user_profile_dropdown_test.dart @@ -2,7 +2,7 @@ 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/components/user_profile_dropdown/index.dart'; +import 'package:magic_starter/src/ui/components/user_profile_dropdown/user_profile_dropdown.preview.dart'; class MockGuard implements Guard { Authenticatable? _user;