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/ui_schema/navigation/ui_schema_navigation.dart'; import 'package:social_app/core/theme/design_tokens.dart'; import 'package:social_app/shared/widgets/toast/toast.dart'; import 'package:social_app/shared/widgets/toast/toast_type.dart'; const _titleFontSize = AppSpacing.lg + 1; const _bodyFontSize = AppSpacing.md; const _captionFontSize = AppSpacing.sm + AppSpacing.xs / 2; const _codeFontSize = AppSpacing.sm + AppSpacing.xs; const _buttonFontSize = AppSpacing.sm + AppSpacing.xs; const _statusLabelTokenPrefix = 'ui.status.'; class UiSchemaRenderer { final BuildContext context; final ColorScheme colorScheme; 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); } return _renderLayoutNode(root); } Widget _renderLayoutNode(Map node) { final type = _asString(node['type']); return switch (type) { 'stack' => _renderStack(node), 'grid' => _renderGrid(node), _ => _fallback(L10n.current.uiSchemaUnsupportedLayout(type)), }; } Widget _renderNode(Map node) { final type = _asString(node['type']); if (node['visible'] == false) { return const SizedBox.shrink(); } return switch (type) { 'text' => _renderText(node), 'icon' => _renderIcon(node), 'badge' => _renderBadge(node), 'button' => _renderButton(node), 'kv' => _renderKv(node), 'divider' => _renderDivider(node), 'stack' => _renderStack(node), 'grid' => _renderGrid(node), _ => _fallback(L10n.current.uiSchemaUnknownNode(type)), }; } Widget _renderStack(Map node) { final children = _asList( node['children'], ).whereType>().map(_renderNode).toList(); final gap = _asDouble(node['gap'], fallback: AppSpacing.sm); final direction = _asString(node['direction'], fallback: 'vertical'); Widget content; if (direction == 'horizontal') { content = Wrap( direction: Axis.horizontal, spacing: gap, runSpacing: gap, crossAxisAlignment: WrapCrossAlignment.center, children: children, ); } else { content = Column( crossAxisAlignment: CrossAxisAlignment.start, children: _withGap(children, gap), ); } return _wrapSurface(node, content); } Widget _renderGrid(Map node) { final children = _asList( node['children'], ).whereType>().map(_renderNode).toList(); final columns = _asInt(node['columns'], fallback: 2).clamp(1, 3); final gap = _asDouble(node['gap'], fallback: AppSpacing.sm); return _wrapSurface( node, GridView.count( crossAxisCount: columns, crossAxisSpacing: gap, mainAxisSpacing: gap, childAspectRatio: 1.6, physics: const NeverScrollableScrollPhysics(), shrinkWrap: true, children: children, ), ); } Widget _renderText(Map node) { final role = _asString(node['role'], fallback: 'body'); final status = _asString(node['status']); final style = switch (role) { 'title' => TextStyle( fontSize: _titleFontSize, fontWeight: FontWeight.w600, color: colorScheme.onSurface, height: 1.25, ), 'subtitle' => TextStyle( fontSize: AppSpacing.md, fontWeight: FontWeight.w600, color: colorScheme.onSurface, ), 'caption' => TextStyle( fontSize: _captionFontSize, color: colorScheme.onSurfaceVariant, height: 1.4, ), 'code' => TextStyle( fontSize: _codeFontSize, color: colorScheme.onSurfaceVariant, fontFamily: 'monospace', ), _ => TextStyle( fontSize: _bodyFontSize, color: colorScheme.onSurfaceVariant, height: 1.4, ), }; return Text( _asString(node['content']), maxLines: _asIntOrNull(node['maxLines']), overflow: TextOverflow.ellipsis, style: style.copyWith(color: _statusTextColor(status, style.color)), ); } Widget _renderIcon(Map node) { final value = _asString(node['value']); if (_asString(node['source']) == 'emoji' && value.isNotEmpty) { return Text(value, style: const TextStyle(fontSize: 18)); } return Icon(Icons.bubble_chart_rounded, color: _statusTextColor('', null)); } Widget _renderBadge(Map node) { final status = _asString(node['status']); final resolvedLabel = _resolveStatusLabel( rawLabel: _asString(node['label']), status: status, ); final statusDotColor = _statusBorder(status); return Padding( padding: const EdgeInsets.only(left: AppSpacing.xs / 2), child: Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ Container( width: AppSpacing.xs, height: AppSpacing.xs, decoration: BoxDecoration( color: statusDotColor, shape: BoxShape.circle, ), ), const SizedBox(width: AppSpacing.xs), Text( resolvedLabel, style: TextStyle( fontSize: _captionFontSize, fontWeight: FontWeight.w600, color: colorScheme.onSurfaceVariant, ), ), ], ), ); } 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.primaryContainer : isGhost ? colorScheme.surface : isDanger ? colorScheme.errorContainer : colorScheme.surfaceContainerLow; final foregroundColor = isPrimary ? colorScheme.onPrimaryContainer : isDanger ? colorScheme.onErrorContainer : isGhost ? colorScheme.onSurfaceVariant : colorScheme.onSurfaceVariant; final borderColor = isPrimary ? colorScheme.primary.withValues(alpha: 0.3) : isGhost ? colorScheme.outlineVariant.withValues(alpha: 0.4) : isDanger ? colorScheme.error.withValues(alpha: 0.35) : colorScheme.outlineVariant.withValues(alpha: 0.4); return ElevatedButton( onPressed: disabled ? null : () async { await _handleAction(action); }, style: ElevatedButton.styleFrom( elevation: 0, padding: const EdgeInsets.symmetric( horizontal: AppSpacing.md + AppSpacing.xs, vertical: AppSpacing.sm, ), minimumSize: const Size(0, AppSpacing.xxl + AppSpacing.xs), backgroundColor: backgroundColor, foregroundColor: foregroundColor, 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: isPrimary ? FontWeight.w700 : FontWeight.w600, ), ), ); } 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) { debugPrint('UiSchemaRenderer: failed to launch URL: $rawUrl'); Toast.show( context, L10n.current.uiSchemaUrlOpenFailed, type: ToastType.error, ); } } catch (error) { debugPrint('UiSchemaRenderer: URL launch error for $rawUrl: $error'); 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) { debugPrint('UiSchemaRenderer: navigation failed for $target: $error'); 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) { final items = _asList( node['items'], ).whereType>().toList(); if (items.isEmpty) { return const SizedBox.shrink(); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: _withGap( items.map((item) { final label = _asString( item['label'], fallback: _asString(item['key']), ); final value = item['value']?.toString() ?? '-'; return Container( width: double.infinity, padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs), decoration: BoxDecoration( border: Border( bottom: BorderSide( color: colorScheme.outlineVariant.withValues(alpha: 0.2), ), ), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( flex: 3, child: Text( label, style: TextStyle( fontSize: _captionFontSize, color: colorScheme.onSurfaceVariant.withValues( alpha: 0.72, ), ), ), ), const SizedBox(width: AppSpacing.sm), Expanded( flex: 5, child: Text( value, style: TextStyle( fontSize: _bodyFontSize, color: colorScheme.onSurface, fontWeight: FontWeight.w400, ), ), ), ], ), ); }).toList(), AppSpacing.xs, ), ); } Widget _renderDivider(Map node) { final inset = _asDouble(node['inset'], fallback: 0); return Padding( padding: EdgeInsets.symmetric(horizontal: inset), child: Divider(height: 1, color: colorScheme.outlineVariant), ); } Widget _wrapSurface(Map node, Widget child) { final appearance = _asString(node['appearance'], fallback: 'plain'); final status = _asString(node['status']); if (appearance == 'plain') { return child; } final bg = switch (appearance) { 'section' => colorScheme.surfaceContainerLow, 'card' => status == 'success' ? colorScheme.primaryContainer.withValues(alpha: 0.05) : colorScheme.surfaceContainerLow.withValues(alpha: 0.5), _ => _statusBackground(status), }; final borderColor = switch (status) { 'success' => colorScheme.primary.withValues(alpha: 0.1), 'warning' => colorScheme.outlineVariant, 'error' => colorScheme.error.withValues(alpha: 0.22), _ => colorScheme.outlineVariant, }; return Container( width: double.infinity, padding: const EdgeInsets.all(AppSpacing.md), decoration: BoxDecoration( color: bg, borderRadius: BorderRadius.circular(AppRadius.xl), border: Border.all(color: borderColor.withValues(alpha: 0.4)), ), 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 (int i = 1; i < widgets.length; i++) ...[ SizedBox(height: gap), widgets[i], ], ]; } Color _statusBackground(String status) { return switch (status) { 'success' => colorScheme.primaryContainer.withValues(alpha: 0.3), 'warning' => colorScheme.secondaryContainer, 'error' => colorScheme.errorContainer, 'pending' => colorScheme.primaryContainer, _ => colorScheme.surfaceContainerHighest, }; } Color _statusBorder(String status) { return switch (status) { 'success' => colorScheme.primary, 'warning' => colorScheme.secondary, 'error' => colorScheme.error, 'pending' => colorScheme.primary, _ => colorScheme.outlineVariant, }; } Color? _statusTextColor(String status, Color? fallback) { return switch (status) { 'success' => colorScheme.onSurfaceVariant, 'warning' => colorScheme.onSecondaryContainer, 'error' => colorScheme.onErrorContainer, 'pending' => colorScheme.onPrimaryContainer, _ => fallback, }; } 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; } }