diff --git a/.gitignore b/.gitignore index fc2df532c..54c5d9157 100644 --- a/.gitignore +++ b/.gitignore @@ -116,3 +116,11 @@ website/public/_pagefind/ # Claude Code CLAUDE.md .claude/settings.local.json + +# Flutter web demos (generated via: cd examples && flutter build web) +# These are built and deployed separately, not committed to git +website/public/demos/ + +# Playwright +website/playwright-report/ +website/test-results/ diff --git a/AGENTS.md b/AGENTS.md index 095e2d9ad..db2da24a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,6 +108,24 @@ melos run test:dart # Dart package tests melos run test:coverage # With coverage report ``` +## Git Conventions + +**Branch naming** (Git Flow prefixes): +- `feat/` - New features +- `fix/` - Bug fixes +- `chore/` - Maintenance (deps, config, cleanup) +- `docs/` - Documentation changes +- `refactor/` - Code restructuring +- `test/` - Test additions/updates + +**Commit messages** (Conventional Commits): +``` +(): + +type: feat, fix, chore, docs, refactor, test, ci +scope: mix, mix_generator, mix_annotations, mix_lint, examples, website +``` + ## Key Files - `melos.yaml` - All script definitions diff --git a/examples/lib/demo_registry.dart b/examples/lib/demo_registry.dart new file mode 100644 index 000000000..733d2de06 --- /dev/null +++ b/examples/lib/demo_registry.dart @@ -0,0 +1,380 @@ +import 'package:flutter/widgets.dart'; + +import 'api/animation/implicit.curved.hover.dart' as hover_scale; +import 'api/animation/implicit.curved.scale.dart' as auto_scale; +import 'api/animation/implicit.spring.translate.dart' as spring_anim; +import 'api/animation/keyframe.switch.dart' as animated_switch; +import 'api/animation/phase.compress.dart' as tap_phase; +import 'api/context_variants/disabled.dart' as disabled; +import 'api/context_variants/focused.dart' as focused; +import 'api/context_variants/hovered.dart' as hovered; +import 'api/context_variants/on_dark_light.dart' as dark_light; +import 'api/context_variants/pressed.dart' as pressed; +import 'api/context_variants/responsive_size.dart' as responsive_size; +import 'api/context_variants/selected.dart' as selected; +import 'api/context_variants/selected_toggle.dart' as selected_toggle; +import 'api/design_tokens/theme_tokens.dart' as theme_tokens; +import 'api/gradients/gradient_linear.dart' as gradient_linear; +import 'api/gradients/gradient_radial.dart' as gradient_radial; +import 'api/gradients/gradient_sweep.dart' as gradient_sweep; +import 'api/text/text_directives.dart' as text_directives; +import 'api/widgets/box/gradient_box.dart' as gradient_box; +import 'api/widgets/box/simple_box.dart' as simple_box; +import 'api/widgets/hbox/icon_label_chip.dart' as icon_label_chip; +import 'api/widgets/icon/styled_icon.dart' as styled_icon; +import 'api/widgets/text/styled_text.dart' as styled_text; +import 'api/widgets/vbox/card_layout.dart' as card_layout; +import 'api/widgets/zbox/layered_boxes.dart' as layered_boxes; + +/// A demo entry with metadata for both multi-view embedding and gallery display. +class DemoEntry { + const DemoEntry({ + required this.id, + required this.title, + required this.description, + required this.category, + required this.builder, + }); + + /// Unique ID used for multi-view embedding (e.g., 'box-basic'). + final String id; + + /// Display title for the gallery (e.g., 'Box - Basic'). + final String title; + + /// Short description of what this demo shows. + final String description; + + /// Category for grouping in the gallery. + final String category; + + /// Widget builder function. + final WidgetBuilder builder; +} + +/// Registry for demo widgets used in both multi-view embedding and the gallery. +/// +/// Single source of truth for all demo definitions, preventing duplication +/// between multi-view mode and gallery mode. +/// +/// Usage from JavaScript (multi-view): +/// ```javascript +/// app.addView({ +/// hostElement: container, +/// initialData: { demoId: 'box-basic' } +/// }); +/// ``` +class DemoRegistry { + DemoRegistry._(); + + /// All available demos with full metadata. + static const String _widgets = 'Widgets'; + static const String _variants = 'Context Variants'; + static const String _gradients = 'Gradients'; + static const String _tokens = 'Design System'; + static const String _animations = 'Animations'; + + static final List _demos = [ + // Widget Examples + DemoEntry( + id: 'box-basic', + title: 'Box - Basic', + description: 'Simple red box with rounded corners', + category: _widgets, + builder: (_) => const simple_box.Example(), + ), + DemoEntry( + id: 'box-gradient', + title: 'Box - Gradient', + description: 'Box with gradient and shadow', + category: _widgets, + builder: (_) => const gradient_box.Example(), + ), + DemoEntry( + id: 'hbox-chip', + title: 'HBox - Horizontal Layout', + description: 'Horizontal flex container with icon and text', + category: _widgets, + builder: (_) => const icon_label_chip.Example(), + ), + DemoEntry( + id: 'vbox-card', + title: 'VBox - Vertical Layout', + description: 'Vertical flex container with styled elements', + category: _widgets, + builder: (_) => const card_layout.Example(), + ), + DemoEntry( + id: 'zbox-layers', + title: 'ZBox - Stack Layout', + description: 'Stacked boxes with different alignments', + category: _widgets, + builder: (_) => const layered_boxes.Example(), + ), + DemoEntry( + id: 'icon-styled', + title: 'Icon - Styled', + description: 'Styled icon with custom size and color', + category: _widgets, + builder: (_) => const styled_icon.Example(), + ), + DemoEntry( + id: 'text-styled', + title: 'Text - Styled', + description: 'Styled text with custom typography', + category: _widgets, + builder: (_) => const styled_text.Example(), + ), + DemoEntry( + id: 'text-directives', + title: 'Text - Directives', + description: + 'Text transformations: uppercase, lowercase, capitalize, etc.', + category: _widgets, + builder: (_) => const text_directives.Example(), + ), + + // Context Variants + DemoEntry( + id: 'variant-hover', + title: 'Hover State', + description: 'Box that changes color on hover', + category: _variants, + builder: (_) => const hovered.Example(), + ), + DemoEntry( + id: 'variant-pressed', + title: 'Press State', + description: 'Box that changes color when pressed', + category: _variants, + builder: (_) => const pressed.Example(), + ), + DemoEntry( + id: 'variant-focused', + title: 'Focus State', + description: 'Boxes that change color when focused', + category: _variants, + builder: (_) => const focused.Example(), + ), + DemoEntry( + id: 'variant-selected', + title: 'Selected State', + description: 'Box that toggles selected state', + category: _variants, + builder: (_) => const selected.Example(), + ), + DemoEntry( + id: 'variant-disabled', + title: 'Disabled State', + description: 'Disabled box with grey color', + category: _variants, + builder: (_) => const disabled.Example(), + ), + DemoEntry( + id: 'variant-dark-light', + title: 'Dark/Light Theme', + description: 'Boxes that adapt to theme changes', + category: _variants, + builder: (_) => const dark_light.Example(), + ), + DemoEntry( + id: 'variant-selected-toggle', + title: 'Selected Toggle', + description: 'Beautiful toggle button with selected state', + category: _variants, + builder: (_) => const selected_toggle.Example(), + ), + DemoEntry( + id: 'variant-responsive', + title: 'Responsive Size', + description: 'Dynamic sizing based on screen width', + category: _variants, + builder: (_) => const responsive_size.Example(), + ), + + // Gradients + DemoEntry( + id: 'gradient-linear', + title: 'Linear Gradient', + description: 'Beautiful purple-to-pink gradient with shadow', + category: _gradients, + builder: (_) => const gradient_linear.Example(), + ), + DemoEntry( + id: 'gradient-radial', + title: 'Radial Gradient', + description: 'Orange radial gradient with focal points', + category: _gradients, + builder: (_) => const gradient_radial.Example(), + ), + DemoEntry( + id: 'gradient-sweep', + title: 'Sweep Gradient', + description: 'Colorful sweep gradient creating rainbow effect', + category: _gradients, + builder: (_) => const gradient_sweep.Example(), + ), + + // Design Tokens + DemoEntry( + id: 'tokens-theme', + title: 'Design Tokens', + description: 'Using design tokens for consistent styling', + category: _tokens, + builder: (_) => const theme_tokens.Example(), + ), + + // Animations + DemoEntry( + id: 'anim-hover-scale', + title: 'Hover Scale Animation', + description: 'Box that scales up smoothly when hovered', + category: _animations, + builder: (_) => const hover_scale.Example(), + ), + DemoEntry( + id: 'anim-auto-scale', + title: 'Auto Scale Animation', + description: 'Box that automatically scales on load', + category: _animations, + builder: (_) => const auto_scale.Example(), + ), + DemoEntry( + id: 'anim-tap-phase', + title: 'Tap Phase Animation', + description: 'Multi-phase animation triggered by tap', + category: _animations, + builder: (_) => const tap_phase.BlockAnimation(), + ), + DemoEntry( + id: 'anim-switch', + title: 'Animated Switch', + description: 'Toggle switch with phase-based animation', + category: _animations, + builder: (_) => const animated_switch.SwitchAnimation(), + ), + DemoEntry( + id: 'anim-spring', + title: 'Spring Animation', + description: 'Bouncy spring physics animation', + category: _animations, + builder: (_) => const spring_anim.Example(), + ), + ]; + + /// Index for fast ID lookup. + static final Map _byId = { + for (final demo in _demos) demo.id: demo, + }; + + /// All registered demos. + static List get all => _demos; + + /// Builds a widget for the given demo ID. + /// + /// Returns an error widget if the demo ID is not found or if the demo + /// widget throws during construction. + static Widget build(String? demoId, BuildContext context) { + if (demoId == null || demoId.isEmpty) { + return const _UnknownDemo(demoId: 'null'); + } + + final entry = _byId[demoId]; + if (entry == null) { + return _UnknownDemo(demoId: demoId); + } + + // Build with error handling for construction errors + try { + return entry.builder(context); + } catch (e, stackTrace) { + debugPrint('Demo "$demoId" construction error: $e'); + return _ErrorDemo(demoId: demoId, error: e, stackTrace: stackTrace); + } + } + + /// Returns a list of all available demo IDs. + static List get availableDemos => _byId.keys.toList(); + + /// Checks if a demo ID exists in the registry. + static bool hasDemo(String demoId) => _byId.containsKey(demoId); + + /// Gets a demo entry by ID. + static DemoEntry? getById(String demoId) => _byId[demoId]; +} + +/// Widget displayed when an unknown demo ID is requested. +class _UnknownDemo extends StatelessWidget { + const _UnknownDemo({required this.demoId}); + + final String demoId; + + @override + Widget build(BuildContext context) { + // Sanitize demoId to prevent excessively long strings + final sanitizedId = demoId.length > 100 + ? '${demoId.substring(0, 100)}...' + : demoId; + + return Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Unknown demo: $sanitizedId\n\n' + 'Available demos:\n' + '${DemoRegistry.availableDemos.join('\n')}', + textAlign: TextAlign.center, + style: const TextStyle(color: Color(0xFFEF4444), fontSize: 14), + ), + ), + ); + } +} + +/// Widget displayed when a demo throws an error. +class _ErrorDemo extends StatelessWidget { + const _ErrorDemo({ + required this.demoId, + required this.error, + this.stackTrace, + }); + + final String demoId; + final Object error; + final StackTrace? stackTrace; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + '⚠️ Demo Error', + style: TextStyle( + color: Color(0xFFEF4444), + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + 'Demo: $demoId', + style: const TextStyle(color: Color(0xFF9CA3AF), fontSize: 12), + ), + const SizedBox(height: 8), + Text( + error.toString(), + textAlign: TextAlign.center, + style: const TextStyle(color: Color(0xFFEF4444), fontSize: 12), + maxLines: 5, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + } +} diff --git a/examples/lib/main.dart b/examples/lib/main.dart index 06599c428..c985e4f07 100644 --- a/examples/lib/main.dart +++ b/examples/lib/main.dart @@ -1,39 +1,24 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'api/animation/implicit.curved.hover.dart' as hover_scale; -import 'api/animation/implicit.curved.scale.dart' as auto_scale; -import 'api/animation/implicit.spring.translate.dart' as spring_anim; -import 'api/animation/keyframe.switch.dart' as animated_switch; -import 'api/animation/phase.compress.dart' as tap_phase; -import 'api/context_variants/disabled.dart' as disabled; -import 'api/context_variants/focused.dart' as focused; -import 'api/context_variants/hovered.dart' as hovered; -import 'api/context_variants/on_dark_light.dart' as dark_light; -import 'api/context_variants/pressed.dart' as pressed; -import 'api/context_variants/responsive_size.dart' as responsive_size; -import 'api/context_variants/selected.dart' as selected; -import 'api/context_variants/selected_toggle.dart' as selected_toggle; -// Animation examples have different class names, will be added separately -import 'api/design_tokens/theme_tokens.dart' as theme_tokens; -// Gradient examples -import 'api/gradients/gradient_linear.dart' as gradient_linear; -import 'api/gradients/gradient_radial.dart' as gradient_radial; -import 'api/gradients/gradient_sweep.dart' as gradient_sweep; -// Text examples -import 'api/text/text_directives.dart' as text_directives; -import 'api/widgets/box/gradient_box.dart' as gradient_box; -// Import all example widgets -import 'api/widgets/box/simple_box.dart' as simple_box; -import 'api/widgets/hbox/icon_label_chip.dart' as icon_label_chip; -import 'api/widgets/icon/styled_icon.dart' as styled_icon; -import 'api/widgets/text/styled_text.dart' as styled_text; -import 'api/widgets/vbox/card_layout.dart' as card_layout; -import 'api/widgets/zbox/layered_boxes.dart' as layered_boxes; import 'components/chip_button.dart'; import 'components/custom_scaffold.dart'; +import 'demo_registry.dart'; +import 'multi_view_app.dart'; + +// Conditional import for web-specific APIs +import 'multi_view_stub.dart' + if (dart.library.js_interop) 'multi_view_web.dart' + as multi_view; void main() { - runApp(const MixExampleApp()); + // On web with multi-view mode, use runWidget for multiple views + // Otherwise use standard runApp for single view (including gallery mode) + if (kIsWeb && multi_view.isMultiViewEnabled) { + runWidget(const MultiViewApp()); + } else { + runApp(const MixExampleApp()); + } } class MixExampleApp extends StatelessWidget { @@ -65,189 +50,30 @@ class ExampleNavigator extends StatefulWidget { class _ExampleNavigatorState extends State { String _selectedCategory = 'All'; - final List _examples = [ - // Widget Examples - ExampleItem( - title: 'Box - Basic', - description: 'Simple red box with rounded corners', - category: 'Widgets', - widget: const simple_box.Example(), - ), - ExampleItem( - title: 'Box - Gradient', - description: 'Box with gradient and shadow', - category: 'Widgets', - widget: const gradient_box.Example(), - ), - ExampleItem( - title: 'HBox - Horizontal Layout', - description: 'Horizontal flex container with icon and text', - category: 'Widgets', - widget: const icon_label_chip.Example(), - ), - ExampleItem( - title: 'VBox - Vertical Layout', - description: 'Vertical flex container with styled elements', - category: 'Widgets', - widget: const card_layout.Example(), - ), - ExampleItem( - title: 'ZBox - Stack Layout', - description: 'Stacked boxes with different alignments', - category: 'Widgets', - widget: const layered_boxes.Example(), - ), - ExampleItem( - title: 'Icon - Styled', - description: 'Styled icon with custom size and color', - category: 'Widgets', - widget: const styled_icon.Example(), - ), - ExampleItem( - title: 'Text - Styled', - description: 'Styled text with custom typography', - category: 'Widgets', - widget: const styled_text.Example(), - ), - ExampleItem( - title: 'Text - Directives', - description: - 'Text transformations: uppercase, lowercase, capitalize, etc.', - category: 'Widgets', - widget: const text_directives.Example(), - ), - - // Context Variants - ExampleItem( - title: 'Hover State', - description: 'Box that changes color on hover', - category: 'Context Variants', - widget: const hovered.Example(), - ), - ExampleItem( - title: 'Press State', - description: 'Box that changes color when pressed', - category: 'Context Variants', - widget: const pressed.Example(), - ), - ExampleItem( - title: 'Focus State', - description: 'Boxes that change color when focused', - category: 'Context Variants', - widget: const focused.Example(), - ), - ExampleItem( - title: 'Selected State', - description: 'Box that toggles selected state', - category: 'Context Variants', - widget: const selected.Example(), - ), - ExampleItem( - title: 'Disabled State', - description: 'Disabled box with grey color', - category: 'Context Variants', - widget: const disabled.Example(), - ), - ExampleItem( - title: 'Dark/Light Theme', - description: 'Boxes that adapt to theme changes', - category: 'Context Variants', - widget: const dark_light.Example(), - ), - ExampleItem( - title: 'Selected Toggle', - description: 'Beautiful toggle button with selected state', - category: 'Context Variants', - widget: const selected_toggle.Example(), - ), - ExampleItem( - title: 'Responsive Size', - description: 'Dynamic sizing based on screen width', - category: 'Context Variants', - widget: const responsive_size.Example(), - ), - - // Gradients - ExampleItem( - title: 'Linear Gradient', - description: 'Beautiful purple-to-pink gradient with shadow', - category: 'Gradients', - widget: const gradient_linear.Example(), - ), - ExampleItem( - title: 'Radial Gradient', - description: 'Orange radial gradient with focal points', - category: 'Gradients', - widget: const gradient_radial.Example(), - ), - ExampleItem( - title: 'Sweep Gradient', - description: 'Colorful sweep gradient creating rainbow effect', - category: 'Gradients', - widget: const gradient_sweep.Example(), - ), + // Use centralized registry as single source of truth + List get _demos => DemoRegistry.all; - // Design Tokens - ExampleItem( - title: 'Design Tokens', - description: 'Using design tokens for consistent styling', - category: 'Design System', - widget: const theme_tokens.Example(), - ), - // Animations - ExampleItem( - title: 'Hover Scale Animation', - description: 'Box that scales up smoothly when hovered', - category: 'Animations', - widget: const hover_scale.Example(), - ), - ExampleItem( - title: 'Auto Scale Animation', - description: 'Box that automatically scales on load', - category: 'Animations', - widget: const auto_scale.Example(), - ), - ExampleItem( - title: 'Tap Phase Animation', - description: 'Multi-phase animation triggered by tap', - category: 'Animations', - widget: const tap_phase.BlockAnimation(), - ), - ExampleItem( - title: 'Animated Switch', - description: 'Toggle switch with phase-based animation', - category: 'Animations', - widget: const animated_switch.SwitchAnimation(), - ), - ExampleItem( - title: 'Spring Animation', - description: 'Bouncy spring physics animation', - category: 'Animations', - widget: const spring_anim.Example(), - ), - ]; - - Widget _buildExampleCard(ExampleItem example) { + Widget _buildExampleCard(DemoEntry demo, BuildContext context) { return Padding( padding: const .all(12), child: Column( crossAxisAlignment: .start, children: [ Text( - example.title, + demo.title, style: const TextStyle(fontSize: 16, fontWeight: .bold), overflow: .ellipsis, maxLines: 2, ), const SizedBox(height: 8), Text( - example.description, + demo.description, style: const TextStyle(color: Colors.grey, fontSize: 12), overflow: .ellipsis, maxLines: 2, ), const SizedBox(height: 12), - Expanded(child: Center(child: example.widget)), + Expanded(child: Center(child: demo.builder(context))), ], ), ); @@ -255,10 +81,10 @@ class _ExampleNavigatorState extends State { @override Widget build(BuildContext context) { - final categories = ['All', ..._examples.map((e) => e.category).toSet()]; - final filteredExamples = _selectedCategory == 'All' - ? _examples - : _examples.where((e) => e.category == _selectedCategory).toList(); + final categories = ['All', ..._demos.map((e) => e.category).toSet()]; + final filteredDemos = _selectedCategory == 'All' + ? _demos + : _demos.where((e) => e.category == _selectedCategory).toList(); return CustomScaffold( appBar: const CustomAppBar(title: 'Mix Examples'), @@ -295,11 +121,11 @@ class _ExampleNavigatorState extends State { childAspectRatio: 0.9, ), itemBuilder: (context, index) { - final example = filteredExamples[index]; + final demo = filteredDemos[index]; - return _buildExampleCard(example); + return _buildExampleCard(demo, context); }, - itemCount: filteredExamples.length, + itemCount: filteredDemos.length, ), ), ], @@ -307,17 +133,3 @@ class _ExampleNavigatorState extends State { ); } } - -class ExampleItem { - final String title; - final String description; - final String category; - final Widget widget; - - const ExampleItem({ - required this.title, - required this.description, - required this.category, - required this.widget, - }); -} diff --git a/examples/lib/multi_view_app.dart b/examples/lib/multi_view_app.dart new file mode 100644 index 000000000..01119596c --- /dev/null +++ b/examples/lib/multi_view_app.dart @@ -0,0 +1,88 @@ +import 'dart:ui' as ui; + +import 'package:flutter/widgets.dart'; + +import 'demo_registry.dart'; +import 'multi_view_stub.dart' + if (dart.library.js_interop) 'multi_view_web.dart' + as multi_view; + +/// Multi-view app wrapper that renders different demos based on view's initialData. +/// +/// When embedded in a web page with multi-view enabled, each view receives +/// its own initialData containing the demoId to display. +/// +/// This widget implements the official Flutter multi-view pattern with +/// WidgetsBindingObserver to properly handle dynamic view additions/removals. +class MultiViewApp extends StatefulWidget { + const MultiViewApp({super.key}); + + @override + State createState() => _MultiViewAppState(); +} + +class _MultiViewAppState extends State + with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeMetrics() { + // Rebuild when views are added or removed. + setState(() {}); + } + + @override + Widget build(BuildContext context) { + // Build a ViewCollection containing all active views. + final views = WidgetsBinding.instance.platformDispatcher.views; + + return ViewCollection( + views: [ + for (final view in views) + View( + view: view, + child: _DemoView(viewId: view.viewId), + ), + ], + ); + } +} + +/// Individual demo view widget that renders the appropriate demo +/// based on the initialData passed from JavaScript. +class _DemoView extends StatelessWidget { + const _DemoView({required this.viewId}); + + final int viewId; + + @override + Widget build(BuildContext context) { + // Get the initialData passed from JavaScript via addView(). + final initialData = multi_view.getInitialData(viewId); + // Defensive typing: JS may pass non-string values + final rawDemoId = initialData?['demoId']; + final demoId = rawDemoId is String ? rawDemoId : rawDemoId?.toString(); + final view = View.of(context); + + return ColoredBox( + color: const Color(0xFF1a1a2e), + child: Directionality( + textDirection: ui.TextDirection.ltr, + child: MediaQuery.fromView( + view: view, + child: DemoRegistry.build(demoId, context), + ), + ), + ); + } +} diff --git a/examples/lib/multi_view_stub.dart b/examples/lib/multi_view_stub.dart new file mode 100644 index 000000000..df907a5f1 --- /dev/null +++ b/examples/lib/multi_view_stub.dart @@ -0,0 +1,10 @@ +/// Stub implementation for non-web platforms. +/// +/// Multi-view mode is only supported on web, so these functions +/// return safe defaults on other platforms. + +/// Whether multi-view mode is enabled (always false on non-web). +bool get isMultiViewEnabled => false; + +/// Get initial data for a view (always null on non-web). +Map? getInitialData(int viewId) => null; diff --git a/examples/lib/multi_view_web.dart b/examples/lib/multi_view_web.dart new file mode 100644 index 000000000..f7268b8e1 --- /dev/null +++ b/examples/lib/multi_view_web.dart @@ -0,0 +1,167 @@ +import 'dart:js_interop'; +import 'dart:ui_web' as ui_web; + +import 'package:flutter/foundation.dart'; + +/// Web implementation for multi-view mode support. +/// +/// These functions provide access to Flutter's multi-view APIs +/// that are only available on web. + +/// Maximum recursion depth for JS→Dart object conversion. +/// Configurable to handle different data structures. +const int kDefaultMaxConversionDepth = 10; + +/// Error thrown when JS→Dart conversion exceeds depth limit. +/// +/// This error is thrown instead of silently returning null when the +/// conversion depth exceeds [maxDepth]. This allows callers to distinguish +/// between null values and conversion failures. +class JsConversionDepthError implements Exception { + /// The depth at which the error occurred. + final int depth; + + /// The maximum allowed depth. + final int maxDepth; + + /// Creates a new [JsConversionDepthError]. + JsConversionDepthError(this.depth, this.maxDepth); + + @override + String toString() => + 'JsConversionDepthError: Exceeded max depth $maxDepth at depth $depth'; +} + +/// Check if the app was initialized with multi-view enabled. +/// +/// This is set by JavaScript via: +/// ```javascript +/// engineInitializer.initializeEngine({ multiViewEnabled: true }); +/// ``` +bool get isMultiViewEnabled { + try { + // Check if we have multiple views or if multi-view was explicitly enabled + // In multi-view mode, Flutter doesn't auto-create a default view + return _isMultiViewModeFromJS; + } catch (e) { + debugPrint('isMultiViewEnabled check failed: $e'); + return false; + } +} + +/// Get the initial data passed to a view via addView(). +/// +/// JavaScript can pass data when adding views: +/// ```javascript +/// app.addView({ +/// hostElement: container, +/// initialData: { demoId: 'box-basic' } +/// }); +/// ``` +/// +/// [maxDepth] controls the maximum recursion depth for nested objects. +/// Defaults to [kDefaultMaxConversionDepth] (10). +/// +/// Throws [JsConversionDepthError] if the object structure exceeds [maxDepth]. +/// Returns null only for null input or non-depth-related conversion failures. +Map? getInitialData(int viewId, {int? maxDepth}) { + try { + final jsData = ui_web.views.getInitialData(viewId); + if (jsData == null) return null; + + return _jsObjectToMap( + jsData, + maxDepth: maxDepth ?? kDefaultMaxConversionDepth, + ); + } on JsConversionDepthError { + // Re-throw depth errors for caller to handle + rethrow; + } catch (e) { + debugPrint('getInitialData failed for viewId $viewId: $e'); + return null; + } +} + +/// Convert a JSAny to a Dart Map. +/// +/// [maxDepth] - Maximum recursion depth (default: [kDefaultMaxConversionDepth]) +/// +/// Throws [JsConversionDepthError] if recursion exceeds maxDepth. +/// Returns null only for null input. +Map? _jsObjectToMap( + JSAny? jsAny, { + int depth = 0, + int maxDepth = kDefaultMaxConversionDepth, +}) { + if (jsAny == null) return null; + if (depth > maxDepth) { + debugPrint('_jsObjectToMap: Max depth $maxDepth exceeded at depth $depth'); + throw JsConversionDepthError(depth, maxDepth); + } + + final result = {}; + + // Get keys using Object.keys() + final jsObject = jsAny as JSObject; + final keys = _getObjectKeys(jsObject); + + for (final key in keys) { + final value = _getProperty(jsObject, key); + result[key] = _jsToValue(value, depth: depth + 1, maxDepth: maxDepth); + } + + return result; +} + +/// Convert a JSAny value to a Dart value. +/// +/// Throws [JsConversionDepthError] if recursion exceeds maxDepth. +Object? _jsToValue( + JSAny? jsValue, { + int depth = 0, + int maxDepth = kDefaultMaxConversionDepth, +}) { + if (jsValue == null) return null; + if (depth > maxDepth) { + throw JsConversionDepthError(depth, maxDepth); + } + if (jsValue.isA()) return (jsValue as JSString).toDart; + if (jsValue.isA()) return (jsValue as JSNumber).toDartDouble; + if (jsValue.isA()) return (jsValue as JSBoolean).toDart; + if (jsValue.isA()) { + return (jsValue as JSArray).toDart + .map((value) => _jsToValue(value, depth: depth + 1, maxDepth: maxDepth)) + .toList(); + } + if (jsValue.isA()) { + return _jsObjectToMap(jsValue, depth: depth + 1, maxDepth: maxDepth); + } + return jsValue.toString(); +} + +@JS('Object.keys') +external JSArray _objectKeys(JSObject obj); + +List _getObjectKeys(JSObject obj) { + return _objectKeys(obj).toDart.map((s) => s.toDart).toList(); +} + +@JS('Reflect.get') +external JSAny? _reflectGet(JSObject target, JSString key); + +JSAny? _getProperty(JSObject obj, String key) { + return _reflectGet(obj, key.toJS); +} + +/// Check from JS if multi-view mode was enabled. +@JS('window.__FLUTTER_MULTI_VIEW_ENABLED__') +external bool? get _multiViewEnabledFlag; + +bool get _isMultiViewModeFromJS { + try { + return _multiViewEnabledFlag ?? false; + } catch (e) { + debugPrint('_isMultiViewModeFromJS check failed: $e'); + return false; + } +} diff --git a/examples/scripts/build_web_demos.sh b/examples/scripts/build_web_demos.sh new file mode 100644 index 000000000..dd779c412 --- /dev/null +++ b/examples/scripts/build_web_demos.sh @@ -0,0 +1,186 @@ +#!/bin/bash +# Build Mix examples for web embedding +# +# Usage: +# ./scripts/build_web_demos.sh # Build and copy to website +# ./scripts/build_web_demos.sh --local # Build only (no copy) +# +# Output: +# - Build artifacts in examples/build/web/ +# - Copied to website/public/demos/ (unless --local) +# +# Note: The demos folder is gitignored. Run this script: +# - Locally before testing the website +# - In CI/CD before deploying the website + +# Exit immediately if any command fails (ensures build errors are not ignored) +set -e + +# ============================================================================ +# Directory Setup +# Derive all paths from the script's location to ensure consistent behavior +# regardless of where the script is invoked from. +# ============================================================================ +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +EXAMPLES_DIR="$(dirname "$SCRIPT_DIR")" # examples/ directory +PROJECT_DIR="$(dirname "$EXAMPLES_DIR")" # project root (mix/) +BUILD_DIR="$EXAMPLES_DIR/build/web" # Flutter web build output +WEBSITE_DEMOS_DIR="$PROJECT_DIR/website/public/demos" # Website public folder + +# Parse command line arguments +LOCAL_ONLY=false +if [ "$1" = "--local" ]; then + LOCAL_ONLY=true +fi + +# Change to examples directory for Flutter commands +cd "$EXAMPLES_DIR" + +# Ensure Flutter is available +if ! command -v flutter &> /dev/null; then + echo "Error: Flutter not found. Please install Flutter or run setup.sh" + exit 1 +fi + +# Verify minimum Flutter version (3.38.0+ required for stable multi-view) +FLUTTER_VERSION=$(flutter --version | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') +REQUIRED_VERSION="3.38.0" + +version_ge() { + local IFS=. + local i ver1=($1) ver2=($2) + for ((i=${#ver1[@]}; i<${#ver2[@]}; i++)); do + ver1[i]=0 + done + for ((i=0; i<${#ver1[@]}; i++)); do + if [[ -z ${ver2[i]} ]]; then + ver2[i]=0 + fi + if ((10#${ver1[i]} > 10#${ver2[i]})); then + return 0 + fi + if ((10#${ver1[i]} < 10#${ver2[i]})); then + return 1 + fi + done + return 0 +} + +if [ -z "$FLUTTER_VERSION" ]; then + echo "Error: Unable to parse Flutter version" + exit 1 +fi + +if ! version_ge "$FLUTTER_VERSION" "$REQUIRED_VERSION"; then + echo "Error: Flutter $REQUIRED_VERSION+ required, found $FLUTTER_VERSION" + exit 1 +fi + +echo "Building Mix examples for web..." +echo "Flutter version: $(flutter --version | head -1)" + +# ============================================================================ +# Build Process +# ============================================================================ + +# Clean previous builds to ensure fresh output (prevents stale artifacts) +rm -rf "$BUILD_DIR" + +# Build Flutter for web in release mode +# Uses CanvasKit renderer by default for best visual fidelity +# --pwa-strategy=none disables service worker generation to prevent 404 errors +# --base-href=/demos/ ensures all relative paths resolve correctly when served from /demos/ +flutter build web --release --pwa-strategy=none --base-href=/demos/ + +# Remove canvaskit (loaded from Google CDN automatically) +# This reduces the build size from ~30MB to ~3MB +rm -rf "$BUILD_DIR/canvaskit" + +# ============================================================================ +# Extract build config for runtime use +# This allows FlutterEmbed.tsx to load config dynamically instead of hard-coding +# the engineRevision, which would drift when Flutter is upgraded. +# ============================================================================ +BOOTSTRAP_JS="$BUILD_DIR/flutter_bootstrap.js" +CONFIG_OUTPUT="$BUILD_DIR/flutter-build-config.json" + +if [ -f "$BOOTSTRAP_JS" ]; then + # Extract engineRevision from flutter_bootstrap.js + # In Flutter 3.38+, the buildConfig JSON is in flutter_bootstrap.js: + # _flutter.buildConfig = {"engineRevision":"abc123...","builds":[...]}; + ENGINE_REVISION=$(grep -oE '"engineRevision":"[^"]+"' "$BOOTSTRAP_JS" | grep -oE '"[^"]+"$' | tr -d '"' | head -1) + + if [ -z "$ENGINE_REVISION" ]; then + echo "Error: Could not extract engineRevision from flutter_bootstrap.js" + exit 1 + fi + + # Generate config JSON for runtime loading + cat > "$CONFIG_OUTPUT" << EOF +{ + "engineRevision": "$ENGINE_REVISION", + "builds": [ + { + "compileTarget": "dart2js", + "renderer": "canvaskit", + "mainJsPath": "main.dart.js" + } + ] +} +EOF + echo "Generated flutter-build-config.json (engineRevision: $ENGINE_REVISION)" +else + echo "Error: flutter_bootstrap.js not found in build output" + exit 1 +fi + +# ============================================================================ +# Patch flutter_bootstrap.js for multi-view embedding +# The default build auto-calls _flutter.loader.load() which prevents us from +# configuring entrypointBaseUrl for proper path resolution when embedding. +# We remove the auto-load call so FlutterMultiView.tsx can call it with config. +# ============================================================================ +BOOTSTRAP_FILE="$BUILD_DIR/flutter_bootstrap.js" +if [ -f "$BOOTSTRAP_FILE" ]; then + # Check if auto-load exists before patching + if grep -q "_flutter\.loader\.load();" "$BOOTSTRAP_FILE"; then + # Remove the trailing _flutter.loader.load(); call + # This allows FlutterMultiView to call load() with entrypointBaseUrl config + sed -i.bak 's/_flutter\.loader\.load();$/\/\/ Auto-load disabled for multi-view embedding/' "$BOOTSTRAP_FILE" + rm -f "$BOOTSTRAP_FILE.bak" + + # Verify patch succeeded + if grep -q "Auto-load disabled" "$BOOTSTRAP_FILE"; then + echo "Patched flutter_bootstrap.js for multi-view embedding" + else + echo "Error: Bootstrap patch verification failed" + exit 1 + fi + else + echo "Error: flutter_bootstrap.js structure changed - auto-load pattern not found" + echo "Multi-view embedding requires patching the auto-load call." + echo "Check if Flutter changed the bootstrap output format." + exit 1 + fi +else + echo "Error: flutter_bootstrap.js not found in build output" + exit 1 +fi + +echo "" +echo "Build complete! Output: $BUILD_DIR" +echo "Build size: $(du -sh "$BUILD_DIR" | cut -f1)" + +if [ "$LOCAL_ONLY" = false ]; then + echo "" + echo "Copying to website/public/demos/..." + rm -rf "$WEBSITE_DEMOS_DIR" + mkdir -p "$WEBSITE_DEMOS_DIR" + cp -r "$BUILD_DIR"/* "$WEBSITE_DEMOS_DIR/" + echo "Done! Demos ready at: $WEBSITE_DEMOS_DIR" +fi + +echo "" +echo "To test locally:" +echo " cd $PROJECT_DIR/website && npm run dev" +echo " Open http://localhost:3000/documentation/widgets/box" diff --git a/examples/web/favicon.png b/examples/web/favicon.png new file mode 100644 index 000000000..7910c7588 Binary files /dev/null and b/examples/web/favicon.png differ diff --git a/examples/web/icons/Icon-192.png b/examples/web/icons/Icon-192.png new file mode 100644 index 000000000..a891f8f0f Binary files /dev/null and b/examples/web/icons/Icon-192.png differ diff --git a/examples/web/icons/Icon-512.png b/examples/web/icons/Icon-512.png new file mode 100644 index 000000000..9479e6aee Binary files /dev/null and b/examples/web/icons/Icon-512.png differ diff --git a/examples/web/icons/Icon-maskable-192.png b/examples/web/icons/Icon-maskable-192.png new file mode 100644 index 000000000..a27a79d06 Binary files /dev/null and b/examples/web/icons/Icon-maskable-192.png differ diff --git a/examples/web/icons/Icon-maskable-512.png b/examples/web/icons/Icon-maskable-512.png new file mode 100644 index 000000000..9479e6aee Binary files /dev/null and b/examples/web/icons/Icon-maskable-512.png differ diff --git a/examples/web/index.html b/examples/web/index.html new file mode 100644 index 000000000..d70ff9437 --- /dev/null +++ b/examples/web/index.html @@ -0,0 +1,169 @@ + + + + + + + + + + + + + Mix Examples + + + + + +
+
+
+
+
+ + + + + diff --git a/examples/web/manifest.json b/examples/web/manifest.json new file mode 100644 index 000000000..1dfecd1e5 --- /dev/null +++ b/examples/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "Mix Examples", + "short_name": "Mix", + "start_url": ".", + "display": "standalone", + "background_color": "#1a1a2e", + "theme_color": "#a78bfa", + "description": "Interactive Flutter examples for the Mix styling framework", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/melos.yaml b/melos.yaml index 931034311..f97de3fb6 100644 --- a/melos.yaml +++ b/melos.yaml @@ -18,7 +18,7 @@ command: dev_dependencies: flutter_lints: ^6.0.0 dart_code_metrics_presets: ^2.24.0 - build_runner: ^2.10.5 + build_runner: ^2.11.0 publish: hooks: pre: melos run gen:build diff --git a/packages/mix/pubspec.yaml b/packages/mix/pubspec.yaml index 209b0a96e..95672bf4b 100644 --- a/packages/mix/pubspec.yaml +++ b/packages/mix/pubspec.yaml @@ -16,7 +16,7 @@ dependencies: dev_dependencies: flutter_lints: ^6.0.0 dart_code_metrics_presets: ^2.24.0 - build_runner: ^2.10.5 + build_runner: ^2.11.0 mix_generator: path: ../mix_generator flutter_test: diff --git a/packages/mix_generator/pubspec.yaml b/packages/mix_generator/pubspec.yaml index 883ec10a5..dbeee9923 100644 --- a/packages/mix_generator/pubspec.yaml +++ b/packages/mix_generator/pubspec.yaml @@ -20,7 +20,7 @@ dependencies: dev_dependencies: test: ^1.24.4 - build_runner: ^2.10.5 + build_runner: ^2.11.0 build_test: ^3.5.5 source_gen_test: ^1.3.4 # Lint diff --git a/website/components/DartPadEmbed.tsx b/website/components/DartPadEmbed.tsx new file mode 100644 index 000000000..c88ce1009 --- /dev/null +++ b/website/components/DartPadEmbed.tsx @@ -0,0 +1,245 @@ +"use client"; + +import React, { useMemo, useState } from "react"; + +interface DartPadEmbedProps { + /** GitHub Gist ID containing the Dart code */ + gistId?: string; + /** Inline Dart code to embed (alternative to gistId) */ + code?: string; + /** Theme: 'dark' for exercises, 'light' for demos */ + theme?: "dark" | "light"; + /** Height of the embed in pixels */ + height?: number; + /** Whether to show the run button */ + run?: boolean; + /** Split percentage between code and output (0-100) */ + split?: number; + /** Whether to use Flutter mode (vs pure Dart) */ + flutter?: boolean; + /** Title shown above the embed */ + title?: string; +} + +/** + * DartPadEmbed - Embeds an interactive DartPad in documentation + * + * Best practices from Dart team: + * - Use light theme for demos (read-only examples) + * - Use dark theme for exercises (user should modify) + * + * @example With Gist ID + * ```tsx + * + * ``` + * + * @example With inline code + * ```tsx + * runApp(MyApp()); + * `} + * flutter + * /> + * ``` + */ +export function DartPadEmbed({ + gistId, + code, + theme = "dark", + height = 500, + run = true, + split = 50, + flutter = true, + title, +}: DartPadEmbedProps) { + const [status, setStatus] = useState<"loading" | "ready" | "error">("loading"); + + // Validate that at least one content source is provided + const hasContent = Boolean(gistId || code); + + const iframeSrc = useMemo(() => { + const baseUrl = flutter + ? "https://dartpad.dev/embed-flutter.html" + : "https://dartpad.dev/embed-dart.html"; + + const params = new URLSearchParams(); + params.set("theme", theme); + params.set("run", run.toString()); + params.set("split", split.toString()); + + if (gistId) { + params.set("id", gistId); + } + + return `${baseUrl}?${params.toString()}`; + }, [gistId, theme, run, split, flutter]); + + const [resolvedSrc, setResolvedSrc] = useState(null); + + // Ensure iframe loads after hydration to avoid missing the load event. + React.useEffect(() => { + setStatus("loading"); + setResolvedSrc(iframeSrc); + }, [iframeSrc]); + + const reloadIframe = React.useCallback(() => { + setResolvedSrc(null); + requestAnimationFrame(() => setResolvedSrc(iframeSrc)); + }, [iframeSrc]); + + // For inline code, we need to use a different approach + // DartPad supports postMessage API for setting code + const iframeRef = React.useRef(null); + + React.useEffect(() => { + if (code && iframeRef.current) { + const iframe = iframeRef.current; + + const sendCode = () => { + try { + if (!iframe.contentWindow) { + console.error("DartPadEmbed: iframe contentWindow not available"); + return; + } + iframe.contentWindow.postMessage( + { + sourceCode: code.trim(), + type: "sourceCode", + }, + "https://dartpad.dev" + ); + } catch (error) { + console.error("DartPadEmbed: Failed to post code to DartPad", error); + } + }; + + const retryTimeouts: Array> = []; + const sendCodeWithRetry = () => { + sendCode(); + retryTimeouts.push(setTimeout(sendCode, 500)); + retryTimeouts.push(setTimeout(sendCode, 1500)); + }; + + // Send immediately and also on load to handle cross-origin iframe readiness + sendCodeWithRetry(); + iframe.addEventListener("load", sendCodeWithRetry); + + return () => { + iframe.removeEventListener("load", sendCodeWithRetry); + retryTimeouts.forEach((timeoutId) => clearTimeout(timeoutId)); + }; + } + }, [code, resolvedSrc]); + + // Show error if no content source provided + if (!hasContent) { + return ( +
+ {title && ( +
{title}
+ )} +
+
+
Missing content source
+
+ Provide either gistId or code prop +
+
+
+
+ ); + } + + // Show error if iframe failed to load + if (status === "error") { + return ( +
+ {title && ( +
{title}
+ )} +
+
+
Failed to load DartPad
+
+ Unable to connect to dartpad.dev. Check your internet connection. +
+ +
+
+
+ ); + } + + return ( +
+ {title && ( +
{title}
+ )} +
+ {resolvedSrc && ( +