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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ graph TB
end
subgraph presentation ["lib/presentation"]
pages["pages"]
widgets["widgets/ ×103"]
widgets["widgets/ ×106"]
end
subgraph playground ["lib/playground"]
pg_page["PlaygroundPage"]
Expand All @@ -101,7 +101,7 @@ graph TB

## Features

### Components (103 types)
### Components (106 types)

| Category | Components |
|----------|-----------|
Expand Down Expand Up @@ -133,7 +133,7 @@ graph TB
- **Form Validation** — declarative `required`, `minLength`, `maxLength`, `pattern` rules from JSON
- **Entrance Animations** — `fadeIn`, `slideUp`, `slideLeft`, `scale` per-component via `props.animation`
- **Error Boundary** — graceful error handling per component, prevents cascading failures
- **Accessibility** — `Semantics` labels on all interactive and leaf components
- **Accessibility** — `Semantics` labels on interactive components (buttons, chips, inputs, switches, checkboxes), text variants, media (images, icons, avatars, badges, progress indicators, dividers), and interactive wrappers (inkWell, gestureDetector)
- **Responsive Layout** — breakpoint system (compact / medium / expanded) with `responsive`, `expanded`, `flexible`
- **Page Transitions** — animated navigation with fade, slide-up, and horizontal slide routes
- **Mock Backend** — standalone Dart Shelf server serving contracts via REST API
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ flowchart LR

---

## Component Types (103 total)
## Component Types (106 total)

### Core Layout Components (12)

Expand Down
1 change: 1 addition & 0 deletions docs/COMPONENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ All animated types accept `duration` (ms) and `curve`.
| `animatedPositioned` | yes (1) | `top`, `bottom`, `left`, `right`, `width`, `height` |
| `animatedSize` | yes (1) | `alignment` |
| `animatedScale` | yes (1) | `scale`, `alignment` |
| `fadeTransition` | yes (1) | `opacity` |

---

Expand Down
46 changes: 27 additions & 19 deletions lib/core/error/error_boundary.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,8 @@ import 'package:flutter/material.dart';
/// Catches build-time errors in a subtree and renders a graceful fallback
/// instead of crashing the entire widget tree.
///
/// Usage in [ComponentParser]:
/// ```dart
/// return ErrorBoundary(
/// nodeType: node.type,
/// child: builtWidget,
/// );
/// ```
/// Wraps the child build in a try/catch within its own [Builder] to
/// intercept synchronous exceptions during widget construction.
class ErrorBoundary extends StatefulWidget {
final Widget child;
final String nodeType;
Expand All @@ -27,37 +22,50 @@ class ErrorBoundary extends StatefulWidget {
class _ErrorBoundaryState extends State<ErrorBoundary> {
Object? _error;

@override
void didUpdateWidget(ErrorBoundary oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.child != widget.child || oldWidget.nodeType != widget.nodeType) {
_error = null;
}
}

@override
Widget build(BuildContext context) {
if (_error != null) {
return _ErrorFallback(nodeType: widget.nodeType, error: _error!);
}

return _ErrorCatcher(
onError: (error) {
if (mounted) setState(() => _error = error);
},
child: widget.child,
);
try {
return _SafeBuilder(
builder: (_) => widget.child,
onError: (error) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _error = error);
});
},
);
} catch (e) {
return _ErrorFallback(nodeType: widget.nodeType, error: e);
}
}
}

class _ErrorCatcher extends StatelessWidget {
final Widget child;
class _SafeBuilder extends StatelessWidget {
final Widget Function(BuildContext) builder;
final void Function(Object error) onError;

const _ErrorCatcher({required this.child, required this.onError});
const _SafeBuilder({required this.builder, required this.onError});

@override
Widget build(BuildContext context) {
Widget result;
try {
result = child;
return builder(context);
} catch (e) {
debugPrint('ErrorBoundary caught: $e');
onError(e);
return const SizedBox.shrink();
}
return result;
}
}

Expand Down
21 changes: 19 additions & 2 deletions lib/core/parser/component_parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ class ComponentParser {
_registry.register('rotatedBox', buildServerRotatedBox);
_registry.register('coloredBox', buildServerColoredBox);
_registry.register('baseline', buildServerBaseline);
_registry.register('visibility', buildServerVisibility);

// Layout – decorators
_registry.register('material', buildServerMaterial);
Expand Down Expand Up @@ -133,6 +134,7 @@ class ComponentParser {
_registry.register('animatedPadding', buildServerAnimatedPadding);
_registry.register('animatedPositioned', buildServerAnimatedPositioned);
_registry.register('animatedSize', buildServerAnimatedSize);
_registry.register('animatedScale', buildServerAnimatedScale);
_registry.register('fadeTransition', buildServerFadeTransition);

// Layout – tiles
Expand Down Expand Up @@ -174,6 +176,7 @@ class ComponentParser {
_registry.register('listTile', buildServerListTile);
_registry.register('popupMenuButton', buildServerPopupMenuButton);
_registry.register('searchBar', buildServerSearchBar);
_registry.register('searchAnchor', buildServerSearchAnchor);
_registry.register('dataTable', buildServerDataTable);

// Leaf – input
Expand Down Expand Up @@ -360,11 +363,16 @@ class ComponentParser {
Widget Function(ComponentNode) buildChild,
) {
final padding = _parsePadding(node.props['padding']);
final margin = _parsePadding(node.props['margin']);
final elevation = (node.props['elevation'] as num?)?.toDouble() ?? 1;
final color = parseHexColor(node.props['color'] as String?);
final borderRadius = (node.props['borderRadius'] as num?)?.toDouble() ?? 12;

return Card(
Widget card = Card(
elevation: elevation,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
color: color,
margin: margin,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(borderRadius)),
child: Padding(
padding: padding ?? EdgeInsets.zero,
child: node.children.isNotEmpty
Expand All @@ -376,6 +384,15 @@ class ComponentParser {
: const SizedBox.shrink(),
),
);

if (node.action != null) {
card = GestureDetector(
onTap: () => handleAction(context, node.action),
child: card,
);
}

return card;
}

Widget _buildListView(
Expand Down
6 changes: 3 additions & 3 deletions lib/core/validator/contract_validator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class ContractValidator {
'constrainedBox', 'fractionalSizedBox', 'safeArea', 'intrinsicHeight',
'intrinsicWidth', 'limitedBox', 'overflowBox', 'offstage', 'ignorePointer',
'absorbPointer', 'clipRRect', 'clipOval', 'opacity', 'rotatedBox',
'coloredBox', 'baseline',
'coloredBox', 'baseline', 'visibility',
// Decorators
'material', 'hero', 'indexedStack', 'decoratedBox', 'transform',
'backdropFilter', 'banner',
Expand All @@ -29,7 +29,7 @@ class ContractValidator {
// Animated
'animatedContainer', 'animatedOpacity', 'animatedCrossFade',
'animatedSwitcher', 'animatedAlign', 'animatedPadding',
'animatedPositioned', 'animatedSize', 'fadeTransition',
'animatedPositioned', 'animatedSize', 'animatedScale', 'fadeTransition',
// Tiles & tables
'expansionTile', 'table', 'tableRow', 'tableCell', 'defaultTextStyle',
};
Expand All @@ -44,7 +44,7 @@ class ContractValidator {
'image', 'divider', 'verticalDivider', 'icon', 'chip', 'progress',
'linearProgressIndicator', 'circularProgressIndicator',
'badge', 'placeholder', 'circleAvatar', 'listTile',
'popupMenuButton', 'searchBar', 'dataTable',
'popupMenuButton', 'searchBar', 'searchAnchor', 'dataTable',
// Input
'input', 'spacer',
// Interactive input
Expand Down
7 changes: 7 additions & 0 deletions lib/presentation/dynamic_screen_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import '../core/models/screen_contract.dart';
import '../core/network/api_client.dart';
import '../core/parser/component_parser.dart';
import '../core/theme/theme_contract.dart';
import '../core/validator/contract_validator.dart';
import 'widgets/server_button.dart';

/// A page that fetches a screen contract by [screenId] and renders
Expand Down Expand Up @@ -91,6 +92,12 @@ class _DynamicScreenPageState extends InputCollectorState<DynamicScreenPage> {
}

final contract = snapshot.data!;

final issues = ContractValidator().validate(contract);
for (final issue in issues) {
debugPrint('[ContractValidator] $issue');
}

final parser = ComponentParser(
onInputChanged: (id, value) => _inputValues[id] = value,
expressionContext: ExpressionContext(contract.context),
Expand Down
23 changes: 23 additions & 0 deletions lib/presentation/widgets/server_animated_widgets.dart
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,29 @@ Widget buildServerAnimatedSize(
);
}

Widget buildServerAnimatedScale(
ComponentNode node,
BuildContext context,
Widget Function(ComponentNode) buildChild,
) {
final scale = (node.props['scale'] as num?)?.toDouble() ?? 1.0;
final duration = parseDuration(node.props['duration']);
final curve = parseCurve(node.props['curve'] as String?);
final alignment = parseAlignment(node.props['alignment'] as String?);

return TweenAnimationBuilder<double>(
tween: Tween(begin: 1.0, end: scale.clamp(0.0, 10.0)),
duration: duration,
curve: curve,
builder: (context, value, child) => Transform.scale(
scale: value,
alignment: alignment,
child: child,
),
child: buildSingleChild(node, buildChild),
);
}

Widget buildServerFadeTransition(
ComponentNode node,
BuildContext context,
Expand Down
20 changes: 13 additions & 7 deletions lib/presentation/widgets/server_badge.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,20 @@ Widget buildServerBadge(
final child = node.children.isNotEmpty ? buildChild(node.children.first) : const SizedBox.shrink();

if (isSmall) {
return Badge(backgroundColor: bgColor, child: child);
return Semantics(
label: 'Badge',
child: Badge(backgroundColor: bgColor, child: child),
);
}

return Badge(
label: label != null
? Text(label, style: TextStyle(color: textColor ?? Colors.white, fontSize: 11))
: null,
backgroundColor: bgColor,
child: child,
return Semantics(
label: label != null ? 'Badge: $label' : 'Badge',
child: Badge(
label: label != null
? Text(label, style: TextStyle(color: textColor ?? Colors.white, fontSize: 11))
: null,
backgroundColor: bgColor,
child: child,
),
);
}
14 changes: 12 additions & 2 deletions lib/presentation/widgets/server_button.dart
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,10 @@ void handleAction(BuildContext context, ActionDef? action) {

switch (action.type) {
case 'navigate':
if (action.targetScreenId != null) {
if (action.targetScreenId != null && action.targetScreenId!.isNotEmpty) {
Navigator.of(context).pushNamed('/screen/${action.targetScreenId}');
} else {
debugPrint('navigate action missing targetScreenId');
}
case 'goBack':
if (Navigator.of(context).canPop()) Navigator.of(context).pop();
Expand All @@ -67,7 +69,15 @@ void handleAction(BuildContext context, ActionDef? action) {
if (url.isNotEmpty) {
final uri = Uri.tryParse(url);
if (uri != null) {
launchUrl(uri, mode: LaunchMode.externalApplication);
launchUrl(uri, mode: LaunchMode.externalApplication).catchError((e) {
debugPrint('Failed to open URL "$url": $e');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not open $url')),
);
}
return false;
});
}
}
case 'showDialog':
Expand Down
55 changes: 38 additions & 17 deletions lib/presentation/widgets/server_chip.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'package:flutter/material.dart';

import '../../core/models/screen_contract.dart';
import '../../core/utils/color_utils.dart';
import 'server_button.dart';

Widget buildServerChip(
ComponentNode node,
Expand All @@ -15,27 +16,47 @@ Widget buildServerChip(
final outlined = node.props['outlined'] as bool? ?? false;

if (outlined) {
return OutlinedButton(
onPressed: node.action != null ? () {} : null,
style: OutlinedButton.styleFrom(
foregroundColor: textColor,
side: bgColor != null ? BorderSide(color: bgColor) : null,
shape: const StadiumBorder(),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
return Semantics(
button: node.action != null,
label: label,
child: OutlinedButton(
onPressed: node.action != null ? () => handleAction(context, node.action) : null,
style: OutlinedButton.styleFrom(
foregroundColor: textColor,
side: bgColor != null ? BorderSide(color: bgColor) : null,
shape: const StadiumBorder(),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: Text(label),
),
child: Text(label),
);
}

return Chip(
avatar: avatar != null
? CircleAvatar(
backgroundColor: Colors.transparent,
child: Text(avatar, style: const TextStyle(fontSize: 14)),
return Semantics(
label: label,
child: node.action != null
? ActionChip(
avatar: avatar != null
? CircleAvatar(
backgroundColor: Colors.transparent,
child: Text(avatar, style: const TextStyle(fontSize: 14)),
)
: null,
label: Text(label),
backgroundColor: bgColor,
labelStyle: textColor != null ? TextStyle(color: textColor) : null,
onPressed: () => handleAction(context, node.action),
)
: null,
label: Text(label),
backgroundColor: bgColor,
labelStyle: textColor != null ? TextStyle(color: textColor) : null,
: Chip(
avatar: avatar != null
? CircleAvatar(
backgroundColor: Colors.transparent,
child: Text(avatar, style: const TextStyle(fontSize: 14)),
)
: null,
label: Text(label),
backgroundColor: bgColor,
labelStyle: textColor != null ? TextStyle(color: textColor) : null,
),
);
}
Loading
Loading