import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:social_app/core/l10n/l10n.dart'; import 'package:social_app/core/logging/logger.dart'; import 'package:social_app/core/theme/design_tokens.dart'; import 'package:social_app/core/ui_schema/navigation/ui_schema_navigation.dart'; import 'package:social_app/shared/widgets/toast/toast.dart'; import 'package:social_app/shared/widgets/toast/toast_type.dart'; const _titleFontSize = AppSpacing.xl + AppSpacing.xs; const _subtitleFontSize = AppSpacing.lg + AppSpacing.xs / 2; const _bodyFontSize = AppSpacing.md + AppSpacing.xs / 2; const _captionFontSize = AppSpacing.md - AppSpacing.xs / 2; const _codeFontSize = AppSpacing.md; const _buttonFontSize = AppSpacing.md; const _statusLabelTokenPrefix = 'ui.status.'; class UiSchemaRenderer { final BuildContext context; final ColorScheme colorScheme; final Logger _logger = getLogger('shared.widgets.ui_schema.renderer'); UiSchemaRenderer(this.context, this.colorScheme); Widget renderSchema(Map? schema) { if (schema == null || schema.isEmpty) { return const SizedBox.shrink(); } final root = _asMap(schema['root']); if (root == null) { return _fallback(L10n.current.uiSchemaInvalid); } final schemaStatus = _nodeStatus(root, _asString(schema['status'])); final hero = _extractHeroHeader(root); final content = _buildRootContent( root: root, schemaStatus: schemaStatus, hero: hero, ); return _buildRootShell(status: schemaStatus, child: content); } Widget _buildRootContent({ required Map root, required String schemaStatus, required _HeroHeader hero, }) { final children = hero.remainingChildren; final gap = _asDouble(root['gap'], fallback: AppSpacing.lg); final blocks = []; if (hero.hasContent) { blocks.add(_buildHeroHeader(hero: hero, status: schemaStatus)); } if (children.isNotEmpty) { blocks.add( _renderLayoutContent( root, schemaStatus: schemaStatus, depth: 0, overrideChildren: children, ), ); } if (blocks.isEmpty) { return const SizedBox.shrink(); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: _withGap(blocks, gap), ); } Widget _buildRootShell({required String status, required Widget child}) { final borderColor = _statusColor(status).withValues(alpha: 0.2); final ambientColor = colorScheme.shadow.withValues(alpha: 0.08); final highlightColor = _statusTint(status).withValues(alpha: 0.22); return Container( width: double.infinity, padding: const EdgeInsets.all(AppSpacing.xl), decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ colorScheme.surface.withValues(alpha: 0.98), colorScheme.surfaceContainerLow.withValues(alpha: 0.96), highlightColor, ], ), borderRadius: BorderRadius.circular(AppRadius.xxl), border: Border.all(color: borderColor), boxShadow: [ BoxShadow( color: ambientColor, blurRadius: AppSpacing.xxl, offset: const Offset(0, AppSpacing.sm), ), BoxShadow( color: colorScheme.surface.withValues(alpha: 0.75), blurRadius: AppSpacing.sm, offset: const Offset(0, -1), ), ], ), child: child, ); } _HeroHeader _extractHeroHeader(Map root) { if (_asString(root['type']) != 'stack' || _asString(root['direction'], fallback: 'vertical') != 'vertical') { return _HeroHeader.empty(root); } final children = _visibleChildren(root); if (children.isEmpty) { return _HeroHeader.empty(root); } Map? titleNode; Map? badgeNode; Map? subtitleNode; Map? iconNode; var consumed = 0; final first = children.first; final firstType = _asString(first['type']); if (firstType == 'stack' && _asString(first['direction'], fallback: 'horizontal') == 'horizontal') { final rowChildren = _visibleChildren(first); titleNode = _firstTextByRole(rowChildren, const {'title', 'subtitle'}); badgeNode = _firstNodeByType(rowChildren, 'badge'); iconNode = _firstNodeByType(rowChildren, 'icon'); if (titleNode != null) { consumed = 1; } } else if (firstType == 'text' && _isTextRole(first, const {'title', 'subtitle'})) { titleNode = first; consumed = 1; if (children.length > consumed && _asString(children[consumed]['type']) == 'badge') { badgeNode = children[consumed]; consumed += 1; } } if (titleNode == null) { return _HeroHeader.empty(root); } if (children.length > consumed && _asString(children[consumed]['type']) == 'text' && _isTextRole(children[consumed], const {'body', 'caption'})) { subtitleNode = children[consumed]; consumed += 1; } return _HeroHeader( titleNode: titleNode, badgeNode: badgeNode, subtitleNode: subtitleNode, iconNode: iconNode, remainingChildren: children.skip(consumed).toList(), ); } Widget _buildHeroHeader({required _HeroHeader hero, required String status}) { final titleWidget = hero.titleNode == null ? const SizedBox.shrink() : _renderText(hero.titleNode!, inheritedStatus: status, emphasize: true); final badgeWidget = hero.badgeNode == null ? null : _renderBadge(hero.badgeNode!, inheritedStatus: status); final subtitleWidget = hero.subtitleNode == null ? null : _renderText(hero.subtitleNode!, inheritedStatus: status); final glyph = hero.iconNode == null ? _buildHeroGlyph(status: status) : _renderIcon(hero.iconNode!, inheritedStatus: status, heroStyle: true); final titleRowChildren = [titleWidget]; if (badgeWidget != null) { titleRowChildren.add(badgeWidget); } return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ glyph, const SizedBox(width: AppSpacing.lg), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Wrap( spacing: AppSpacing.sm, runSpacing: AppSpacing.sm, crossAxisAlignment: WrapCrossAlignment.center, children: titleRowChildren, ), if (subtitleWidget != null) ...[ const SizedBox(height: AppSpacing.sm), subtitleWidget, ], ], ), ), ], ); } Widget _buildHeroGlyph({required String status}) { final tint = _statusColor(status); return Container( width: AppSpacing.xxl + AppSpacing.xl, height: AppSpacing.xxl + AppSpacing.xl, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ tint.withValues(alpha: 0.92), tint.withValues(alpha: 0.72), ], ), borderRadius: BorderRadius.circular(AppRadius.lg), boxShadow: [ BoxShadow( color: tint.withValues(alpha: 0.22), blurRadius: AppSpacing.lg, offset: const Offset(0, AppSpacing.xs), ), ], ), child: Icon( _statusLeadingIcon(status), size: AppSpacing.xl, color: colorScheme.onPrimary, ), ); } Widget _renderNode( Map node, { required String schemaStatus, required int depth, }) { if (node['visible'] == false) { return const SizedBox.shrink(); } final type = _asString(node['type']); return switch (type) { 'text' => _renderText(node, inheritedStatus: schemaStatus), 'icon' => _renderIcon(node, inheritedStatus: schemaStatus), 'badge' => _renderBadge(node, inheritedStatus: schemaStatus), 'button' => _renderButton(node), 'kv' => _renderKv(node, inheritedStatus: schemaStatus), 'divider' => _renderDivider(node), 'stack' => _renderStack(node, schemaStatus: schemaStatus, depth: depth), 'grid' => _renderGrid(node, schemaStatus: schemaStatus, depth: depth), _ => _fallback(L10n.current.uiSchemaUnknownNode(type)), }; } Widget _renderLayoutContent( Map node, { required String schemaStatus, required int depth, List>? overrideChildren, }) { final type = _asString(node['type']); return switch (type) { 'stack' => _buildStackContent( node, schemaStatus: schemaStatus, depth: depth, overrideChildren: overrideChildren, ), 'grid' => _buildGridContent( node, schemaStatus: schemaStatus, depth: depth, overrideChildren: overrideChildren, ), _ => _fallback(L10n.current.uiSchemaUnsupportedLayout(type)), }; } Widget _renderStack( Map node, { required String schemaStatus, required int depth, }) { final content = _buildStackContent( node, schemaStatus: schemaStatus, depth: depth, ); return _wrapSurface( node, child: content, inheritedStatus: schemaStatus, depth: depth, ); } Widget _buildStackContent( Map node, { required String schemaStatus, required int depth, List>? overrideChildren, }) { final children = (overrideChildren ?? _visibleChildren(node)) .map((child) => _renderNode(child, schemaStatus: schemaStatus, depth: depth + 1)) .toList(); final gap = _asDouble(node['gap'], fallback: AppSpacing.md); final direction = _asString(node['direction'], fallback: 'vertical'); if (direction == 'horizontal') { final shouldWrap = node['wrap'] == true || _looksLikeActionRow(node); if (shouldWrap) { return Wrap( spacing: gap, runSpacing: gap, alignment: _wrapAlignment(_asString(node['justify'], fallback: 'start')), crossAxisAlignment: WrapCrossAlignment.center, children: children, ); } return Row( mainAxisAlignment: _mainAxisAlignment( _asString(node['justify'], fallback: 'start'), ), crossAxisAlignment: _crossAxisAlignment( _asString(node['align'], fallback: 'center'), ), children: _withHorizontalGap(children, gap), ); } final appearance = _asString(node['appearance'], fallback: 'plain'); final isSection = appearance == 'section'; final sectionTitle = isSection && (overrideChildren ?? _visibleChildren(node)).isNotEmpty && _asString((overrideChildren ?? _visibleChildren(node)).first['type']) == 'text' && _isTextRole((overrideChildren ?? _visibleChildren(node)).first, const {'title'}) ? (overrideChildren ?? _visibleChildren(node)).first : null; if (sectionTitle != null && children.isNotEmpty) { final promotedTitle = _buildSectionHeader(sectionTitle, schemaStatus); final remaining = children.skip(1).toList(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: _withGap([promotedTitle, ...remaining], gap), ); } return Column( crossAxisAlignment: _columnCrossAxisAlignment( _asString(node['align'], fallback: 'start'), ), children: _withGap(children, gap), ); } Widget _renderGrid( Map node, { required String schemaStatus, required int depth, }) { final content = _buildGridContent( node, schemaStatus: schemaStatus, depth: depth, ); return _wrapSurface( node, child: content, inheritedStatus: schemaStatus, depth: depth, ); } Widget _buildGridContent( Map node, { required String schemaStatus, required int depth, List>? overrideChildren, }) { final children = (overrideChildren ?? _visibleChildren(node)) .map((child) => _renderNode(child, schemaStatus: schemaStatus, depth: depth + 1)) .toList(); if (children.isEmpty) { return const SizedBox.shrink(); } final gap = _asDouble(node['gap'], fallback: AppSpacing.md); final columns = _asInt(node['columns'], fallback: 2).clamp(1, 3); return LayoutBuilder( builder: (context, constraints) { final totalGap = gap * (columns - 1); final itemWidth = columns == 1 ? constraints.maxWidth : (constraints.maxWidth - totalGap) / columns; return Wrap( spacing: gap, runSpacing: gap, children: children .map( (child) => SizedBox( width: itemWidth, child: _buildGridCell(child, schemaStatus), ), ) .toList(), ); }, ); } Widget _buildGridCell(Widget child, String status) { return Container( padding: const EdgeInsets.symmetric( horizontal: AppSpacing.md, vertical: AppSpacing.lg, ), decoration: BoxDecoration( color: colorScheme.surface.withValues(alpha: 0.84), borderRadius: BorderRadius.circular(AppRadius.lg), border: Border.all( color: _statusColor(status).withValues(alpha: 0.12), ), ), child: child, ); } Widget _buildSectionHeader(Map node, String schemaStatus) { return Row( children: [ Container( width: AppSpacing.sm, height: AppSpacing.xl, decoration: BoxDecoration( color: _statusColor(_nodeStatus(node, schemaStatus)), borderRadius: BorderRadius.circular(AppRadius.full), ), ), const SizedBox(width: AppSpacing.md), Expanded( child: _renderText(node, inheritedStatus: schemaStatus, emphasize: true), ), ], ); } Widget _renderText( Map node, { required String inheritedStatus, bool emphasize = false, }) { final content = _asString(node['content']); if (content.isEmpty) { return const SizedBox.shrink(); } final role = _asString(node['role'], fallback: 'body'); final status = _nodeStatus(node, inheritedStatus); final style = switch (role) { 'title' => TextStyle( fontSize: _titleFontSize, fontWeight: FontWeight.w700, height: 1.2, color: colorScheme.onSurface, ), 'subtitle' => TextStyle( fontSize: _subtitleFontSize, fontWeight: FontWeight.w600, height: 1.25, color: colorScheme.onSurface, ), 'caption' => TextStyle( fontSize: _captionFontSize, fontWeight: emphasize ? FontWeight.w600 : FontWeight.w500, height: 1.35, color: colorScheme.onSurfaceVariant, ), 'code' => TextStyle( fontSize: _codeFontSize, fontFamily: 'monospace', fontWeight: FontWeight.w500, height: 1.3, color: colorScheme.onSurfaceVariant, ), _ => TextStyle( fontSize: _bodyFontSize, fontWeight: emphasize ? FontWeight.w600 : FontWeight.w500, height: 1.4, color: colorScheme.onSurfaceVariant, ), }; return Text( content, maxLines: _asIntOrNull(node['maxLines']), overflow: TextOverflow.ellipsis, style: style.copyWith(color: _statusTextColor(status, style.color)), ); } Widget _renderIcon( Map node, { required String inheritedStatus, bool heroStyle = false, }) { final status = _nodeStatus(node, inheritedStatus); final source = _asString(node['source'], fallback: 'icon'); final value = _asString(node['value']); final size = _asDouble( node['size'], fallback: heroStyle ? AppSpacing.xl : AppSpacing.lg + AppSpacing.xs, ); if (source == 'emoji' && value.isNotEmpty) { return Text(value, style: TextStyle(fontSize: size)); } final icon = _iconForValue(value, fallbackStatus: status); final tint = _statusColor(status); final containerSize = heroStyle ? AppSpacing.xxl + AppSpacing.xl : AppSpacing.xxl + AppSpacing.sm; return Container( width: containerSize, height: containerSize, decoration: BoxDecoration( color: colorScheme.surface.withValues(alpha: 0.92), shape: BoxShape.circle, border: Border.all(color: tint.withValues(alpha: 0.2)), ), child: Icon(icon, size: size, color: tint), ); } Widget _renderBadge( Map node, { required String inheritedStatus, }) { final status = _nodeStatus(node, inheritedStatus); final resolvedLabel = _resolveStatusLabel( rawLabel: _asString(node['label']), status: status, ); final tint = _statusColor(status); return Container( padding: const EdgeInsets.symmetric( horizontal: AppSpacing.md, vertical: AppSpacing.sm, ), decoration: BoxDecoration( color: tint.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(AppRadius.md), border: Border.all(color: tint.withValues(alpha: 0.2)), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( _statusBadgeIcon(status), size: AppSpacing.md + AppSpacing.xs, color: tint, ), const SizedBox(width: AppSpacing.xs + AppSpacing.xs / 2), Text( resolvedLabel, style: TextStyle( fontSize: _captionFontSize, fontWeight: FontWeight.w700, color: tint, ), ), ], ), ); } Widget _renderButton(Map node) { final style = _asString(node['style'], fallback: 'secondary'); final action = _asMap(node['action']); final disabled = node['disabled'] == true; final isPrimary = style == 'primary'; final isGhost = style == 'ghost'; final isDanger = style == 'danger'; final backgroundColor = isPrimary ? colorScheme.primary : isDanger ? colorScheme.errorContainer : isGhost ? colorScheme.surface.withValues(alpha: 0.88) : colorScheme.primaryContainer.withValues(alpha: 0.55); final foregroundColor = isPrimary ? colorScheme.onPrimary : isDanger ? colorScheme.onErrorContainer : isGhost ? colorScheme.onSurfaceVariant : colorScheme.onPrimaryContainer; final borderColor = isPrimary ? colorScheme.primary.withValues(alpha: 0.18) : isDanger ? colorScheme.error.withValues(alpha: 0.2) : colorScheme.primary.withValues(alpha: 0.14); return ElevatedButton( onPressed: disabled ? null : () async { await _handleAction(action); }, style: ElevatedButton.styleFrom( elevation: 0, backgroundColor: backgroundColor, foregroundColor: foregroundColor, disabledBackgroundColor: colorScheme.surfaceContainer, disabledForegroundColor: colorScheme.onSurfaceVariant, padding: const EdgeInsets.symmetric( horizontal: AppSpacing.lg, vertical: AppSpacing.md, ), minimumSize: const Size(0, AppSpacing.xxl + AppSpacing.sm), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadius.full), side: BorderSide(color: borderColor), ), ), child: Text( _asString(node['label'], fallback: L10n.current.uiSchemaActionFallback), style: TextStyle( fontSize: _buttonFontSize, fontWeight: FontWeight.w700, ), ), ); } Future _handleAction(Map? action) async { if (action == null || action.isEmpty) { Toast.show( context, L10n.current.uiSchemaActionNotImplemented, type: ToastType.warning, ); return; } final type = _asString(action['type']); switch (type) { case 'navigation': _handleNavigationAction(action); return; case 'url': await _handleUrlAction(action); return; case 'copy': _handleCopyAction(action); return; case 'event': case 'tool': case 'payload': default: Toast.show( context, L10n.current.uiSchemaActionNotImplemented, type: ToastType.info, ); } } Future _handleUrlAction(Map action) async { final rawUrl = _asString(action['url']).trim(); if (!_isValidExternalUrl(rawUrl)) { Toast.show( context, L10n.current.uiSchemaUrlInvalid, type: ToastType.error, ); return; } final uri = Uri.tryParse(rawUrl); if (uri == null) { Toast.show( context, L10n.current.uiSchemaUrlInvalid, type: ToastType.error, ); return; } final target = _asString(action['target'], fallback: '_blank'); final mode = target == '_self' ? LaunchMode.platformDefault : LaunchMode.externalApplication; try { final launched = await launchUrl(uri, mode: mode); if (!context.mounted) { return; } if (!launched) { _logger.error( message: 'UI schema URL launch returned false', error: StateError('launchUrl returned false'), stackTrace: StackTrace.current, ); Toast.show( context, L10n.current.uiSchemaUrlOpenFailed, type: ToastType.error, ); } } catch (error, stackTrace) { _logger.error( message: 'UI schema URL launch failed', error: error, stackTrace: stackTrace, ); if (!context.mounted) { return; } Toast.show( context, L10n.current.uiSchemaUrlOpenFailed, type: ToastType.error, ); } } void _handleNavigationAction(Map action) { final path = _asString(action['path']).trim(); if (!isValidInternalNavigationPath(path)) { Toast.show( context, L10n.current.uiSchemaNavigationInvalidPath, type: ToastType.error, ); return; } final params = _asMap(action['params']); if (params != null && !_areScalarNavigationParams(params)) { Toast.show( context, L10n.current.uiSchemaNavigationInvalidParams, type: ToastType.error, ); return; } final target = buildUiSchemaNavigationTarget(path: path, params: params); try { context.push(target); } catch (error, stackTrace) { _logger.error( message: 'UI schema navigation failed', error: error, stackTrace: stackTrace, ); Toast.show( context, L10n.current.uiSchemaNavigationInvalidPath, type: ToastType.error, ); } } void _handleCopyAction(Map action) { final content = _asString(action['content']); if (content.isEmpty) { Toast.show( context, L10n.current.uiSchemaActionNotImplemented, type: ToastType.warning, ); return; } Clipboard.setData(ClipboardData(text: content)); final successMessage = _asString( action['successMessage'], fallback: L10n.current.commonCopySuccess, ); Toast.show(context, successMessage, type: ToastType.success); } static bool _isValidExternalUrl(String url) { if (url.isEmpty) return false; final uri = Uri.tryParse(url); if (uri == null || !uri.hasScheme) return false; final scheme = uri.scheme.toLowerCase(); if (scheme == 'http' || scheme == 'https') { if (uri.host.isEmpty || uri.userInfo.isNotEmpty) { return false; } if (_isLocalOrPrivateHost(uri.host)) { return false; } return true; } return scheme == 'mailto' || scheme == 'tel' || scheme == 'sms'; } static bool _areScalarNavigationParams(Map params) { for (final value in params.values) { if (value is String || value is num || value is bool) { continue; } return false; } return true; } static bool _isLocalOrPrivateHost(String host) { final normalizedHost = host.toLowerCase(); if (normalizedHost.contains(':')) { return true; } if (int.tryParse(normalizedHost) != null) { return true; } if (normalizedHost == 'localhost' || normalizedHost == '127.0.0.1' || normalizedHost == '::1') { return true; } final ipv4 = normalizedHost.split('.'); if (ipv4.length != 4) { return false; } final octets = []; for (final part in ipv4) { final parsed = int.tryParse(part); if (parsed == null || parsed < 0 || parsed > 255) { return false; } octets.add(parsed); } final first = octets[0]; final second = octets[1]; if (first == 10 || first == 127 || first == 0) { return true; } if (first == 169 && second == 254) { return true; } if (first == 192 && second == 168) { return true; } if (first == 172 && second >= 16 && second <= 31) { return true; } return false; } Widget _renderKv( Map node, { required String inheritedStatus, }) { final items = _asList(node['items']).whereType>().toList(); if (items.isEmpty) { return const SizedBox.shrink(); } final columns = _asInt(node['columns'], fallback: 1).clamp(1, 2); final gap = AppSpacing.md; return LayoutBuilder( builder: (context, constraints) { final totalGap = gap * (columns - 1); final itemWidth = columns == 1 ? constraints.maxWidth : (constraints.maxWidth - totalGap) / columns; return Wrap( spacing: gap, runSpacing: gap, children: items .map( (item) => SizedBox( width: itemWidth, child: _buildKvItem(item, inheritedStatus), ), ) .toList(), ); }, ); } Widget _buildKvItem(Map item, String inheritedStatus) { final label = _asString(item['label'], fallback: _asString(item['key'])); final value = item['value']?.toString() ?? '-'; final copyable = item['copyable'] == true; return Container( padding: const EdgeInsets.all(AppSpacing.md), decoration: BoxDecoration( color: colorScheme.surface.withValues(alpha: 0.88), borderRadius: BorderRadius.circular(AppRadius.lg), border: Border.all( color: _statusColor(inheritedStatus).withValues(alpha: 0.1), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label, style: TextStyle( fontSize: _captionFontSize, fontWeight: FontWeight.w600, color: colorScheme.onSurfaceVariant, ), ), const SizedBox(height: AppSpacing.sm), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Text( value, style: TextStyle( fontSize: _bodyFontSize, fontWeight: FontWeight.w600, color: colorScheme.onSurface, height: 1.35, ), ), ), if (copyable) ...[ const SizedBox(width: AppSpacing.sm), InkWell( borderRadius: BorderRadius.circular(AppRadius.full), onTap: () { _handleCopyAction({'content': value}); }, child: Padding( padding: const EdgeInsets.all(AppSpacing.xs), child: Icon( Icons.content_copy_rounded, size: AppSpacing.md + AppSpacing.xs, color: colorScheme.onSurfaceVariant, ), ), ), ], ], ), ], ), ); } Widget _renderDivider(Map node) { final inset = _asDouble(node['inset'], fallback: 0); return Padding( padding: EdgeInsets.symmetric(horizontal: inset), child: Container( height: 1, decoration: BoxDecoration( gradient: LinearGradient( colors: [ colorScheme.primary.withValues(alpha: 0.0), colorScheme.primary.withValues(alpha: 0.14), colorScheme.primary.withValues(alpha: 0.0), ], ), ), ), ); } Widget _wrapSurface( Map node, { required Widget child, required String inheritedStatus, required int depth, }) { final appearance = _asString(node['appearance'], fallback: 'plain'); if (appearance == 'plain') { return child; } final status = _nodeStatus(node, inheritedStatus); final tint = _statusColor(status); final isSection = appearance == 'section'; final background = isSection ? colorScheme.surface.withValues(alpha: 0.55) : colorScheme.surfaceContainerLow.withValues(alpha: 0.78); final shadowColor = colorScheme.shadow.withValues( alpha: isSection ? 0.03 : 0.06, ); return Container( width: double.infinity, padding: EdgeInsets.all(isSection ? AppSpacing.lg : AppSpacing.xl), decoration: BoxDecoration( color: background, borderRadius: BorderRadius.circular( isSection ? AppRadius.xl : AppRadius.xxl, ), border: Border.all(color: tint.withValues(alpha: 0.12)), boxShadow: [ BoxShadow( color: shadowColor, blurRadius: depth <= 1 ? AppSpacing.xl : AppSpacing.md, offset: const Offset(0, AppSpacing.xs), ), ], ), child: child, ); } Widget _fallback(String text) { return Container( padding: const EdgeInsets.all(AppSpacing.md), decoration: BoxDecoration( color: colorScheme.secondaryContainer, border: Border.all(color: colorScheme.secondary), borderRadius: BorderRadius.circular(AppRadius.md), ), child: Text( text, style: TextStyle( fontSize: _buttonFontSize, color: colorScheme.onSecondaryContainer, ), ), ); } List _withGap(List widgets, double gap) { if (widgets.isEmpty) { return const []; } return [ widgets.first, for (var i = 1; i < widgets.length; i++) ...[ SizedBox(height: gap), widgets[i], ], ]; } List _withHorizontalGap(List widgets, double gap) { if (widgets.isEmpty) { return const []; } return [ widgets.first, for (var i = 1; i < widgets.length; i++) ...[ SizedBox(width: gap), widgets[i], ], ]; } List> _visibleChildren(Map node) { return _asList(node['children']) .whereType>() .where((child) => child['visible'] != false) .toList(); } Map? _firstNodeByType( List> nodes, String type, ) { for (final node in nodes) { if (_asString(node['type']) == type) { return node; } } return null; } Map? _firstTextByRole( List> nodes, Set roles, ) { for (final node in nodes) { if (_asString(node['type']) == 'text' && _isTextRole(node, roles)) { return node; } } return null; } bool _isTextRole(Map node, Set roles) { return roles.contains(_asString(node['role'], fallback: 'body')); } bool _looksLikeActionRow(Map node) { final children = _visibleChildren(node); return children.isNotEmpty && children.every((child) => _asString(child['type']) == 'button'); } MainAxisAlignment _mainAxisAlignment(String justify) { return switch (justify) { 'center' => MainAxisAlignment.center, 'end' => MainAxisAlignment.end, 'space-between' => MainAxisAlignment.spaceBetween, _ => MainAxisAlignment.start, }; } CrossAxisAlignment _crossAxisAlignment(String align) { return switch (align) { 'start' => CrossAxisAlignment.start, 'end' => CrossAxisAlignment.end, 'stretch' => CrossAxisAlignment.stretch, _ => CrossAxisAlignment.center, }; } CrossAxisAlignment _columnCrossAxisAlignment(String align) { return switch (align) { 'center' => CrossAxisAlignment.center, 'end' => CrossAxisAlignment.end, 'stretch' => CrossAxisAlignment.stretch, _ => CrossAxisAlignment.start, }; } WrapAlignment _wrapAlignment(String justify) { return switch (justify) { 'center' => WrapAlignment.center, 'end' => WrapAlignment.end, 'space-between' => WrapAlignment.spaceBetween, _ => WrapAlignment.start, }; } Color _statusTint(String status) { return switch (status) { 'success' => colorScheme.primaryContainer, 'warning' => colorScheme.secondaryContainer, 'error' => colorScheme.errorContainer, 'pending' => colorScheme.primaryContainer, _ => colorScheme.primaryContainer, }; } Color _statusColor(String status) { return switch (status) { 'success' => colorScheme.primary, 'warning' => colorScheme.secondary, 'error' => colorScheme.error, 'pending' => colorScheme.tertiary, _ => colorScheme.primary, }; } Color? _statusTextColor(String status, Color? fallback) { return switch (status) { 'success' => colorScheme.onSurface, 'warning' => colorScheme.onSurface, 'error' => colorScheme.onErrorContainer, 'pending' => colorScheme.onTertiaryContainer, _ => fallback, }; } IconData _statusLeadingIcon(String status) { return switch (status) { 'success' => Icons.task_alt_rounded, 'warning' => Icons.priority_high_rounded, 'error' => Icons.error_outline_rounded, 'pending' => Icons.hourglass_bottom_rounded, _ => Icons.bolt_rounded, }; } IconData _statusBadgeIcon(String status) { return switch (status) { 'success' => Icons.check_circle_outline_rounded, 'warning' => Icons.info_outline_rounded, 'error' => Icons.error_outline_rounded, 'pending' => Icons.schedule_rounded, _ => Icons.fiber_manual_record_rounded, }; } IconData _iconForValue(String value, {required String fallbackStatus}) { final normalized = value.trim().toLowerCase(); return switch (normalized) { 'calendar' || 'event' || 'schedule' => Icons.calendar_month_rounded, 'delete' || 'remove' => Icons.event_busy_rounded, 'summary' || 'document' => Icons.article_outlined, 'list' || 'items' => Icons.view_list_rounded, 'success' => Icons.task_alt_rounded, 'warning' => Icons.priority_high_rounded, 'error' => Icons.error_outline_rounded, _ => _statusLeadingIcon(fallbackStatus), }; } String _nodeStatus(Map node, String fallback) { final status = _asString(node['status']).trim().toLowerCase(); if (status == 'partial') { return 'warning'; } return status.isEmpty ? fallback : status; } String _resolveStatusLabel({required String rawLabel, required String status}) { final normalizedStatus = status.trim().toLowerCase(); final normalizedLabel = rawLabel.trim().toLowerCase(); final bareLabel = normalizedLabel.startsWith(_statusLabelTokenPrefix) ? normalizedLabel.substring(_statusLabelTokenPrefix.length) : normalizedLabel; final isTokenLabel = normalizedLabel.startsWith(_statusLabelTokenPrefix); final isLegacyStatusLabel = bareLabel == normalizedStatus; if (isTokenLabel || isLegacyStatusLabel) { return _tryLocalizedStatusLabel(bareLabel) ?? rawLabel; } return rawLabel; } String? _tryLocalizedStatusLabel(String status) { final l10n = L10n.current; return switch (status) { 'success' => l10n.uiSchemaStatusSuccess, 'warning' => l10n.uiSchemaStatusWarning, 'error' => l10n.uiSchemaStatusError, 'pending' => l10n.uiSchemaStatusPending, 'info' => l10n.uiSchemaStatusInfo, _ => null, }; } static Map? _asMap(Object? value) { if (value is Map) { return value; } if (value is Map) { return Map.from(value); } return null; } static List _asList(Object? value) { return value is List ? value : const []; } static String _asString(Object? value, {String fallback = ''}) { return value is String ? value : fallback; } static int _asInt(Object? value, {int fallback = 0}) { if (value is int) { return value; } if (value is double) { return value.toInt(); } if (value is String) { return int.tryParse(value) ?? fallback; } return fallback; } static int? _asIntOrNull(Object? value) { if (value == null) { return null; } return _asInt(value); } static double _asDouble(Object? value, {double fallback = 0}) { if (value is double) { return value; } if (value is int) { return value.toDouble(); } if (value is String) { return double.tryParse(value) ?? fallback; } return fallback; } } class _HeroHeader { final Map? titleNode; final Map? badgeNode; final Map? subtitleNode; final Map? iconNode; final List> remainingChildren; const _HeroHeader({ required this.titleNode, required this.badgeNode, required this.subtitleNode, required this.iconNode, required this.remainingChildren, }); factory _HeroHeader.empty(Map root) { final children = (root['children'] as List?) ?.whereType>() .where((child) => child['visible'] != false) .toList() ?? const []; return _HeroHeader( titleNode: null, badgeNode: null, subtitleNode: null, iconNode: null, remainingChildren: children, ); } bool get hasContent => titleNode != null; }