import 'dart:convert'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_math_fork/flutter_math.dart'; import 'package:gpt_markdown/gpt_markdown.dart'; import 'package:webview_flutter/webview_flutter.dart'; import 'package:conduit/l10n/app_localizations.dart'; import '../../theme/color_tokens.dart'; import '../../theme/theme_extensions.dart'; import 'code_block_header.dart'; import 'package:conduit/core/network/self_signed_image_cache_manager.dart'; import 'package:conduit/core/network/image_header_utils.dart'; typedef MarkdownLinkTapCallback = void Function(String url, String title); class ConduitMarkdown { const ConduitMarkdown._(); static Widget build({ required BuildContext context, required String data, MarkdownLinkTapCallback? onTapLink, Widget Function(Uri uri, String? title, String? alt)? imageBuilderOverride, }) { final theme = context.conduitTheme; final material = Theme.of(context); final baseTextStyle = AppTypography.bodyMediumStyle.copyWith( color: theme.textPrimary, height: 1.45, ); final gptThemeData = GptMarkdownThemeData( brightness: material.brightness, h1: AppTypography.headlineLargeStyle.copyWith(color: theme.textPrimary), h2: AppTypography.headlineMediumStyle.copyWith(color: theme.textPrimary), h3: AppTypography.headlineSmallStyle.copyWith(color: theme.textPrimary), h4: AppTypography.bodyLargeStyle.copyWith(color: theme.textPrimary), h5: baseTextStyle.copyWith(fontWeight: FontWeight.w600), h6: AppTypography.bodySmallStyle.copyWith(color: theme.textSecondary), linkColor: material.colorScheme.primary, linkHoverColor: material.colorScheme.primary.withValues(alpha: 0.7), hrLineColor: theme.dividerColor, hrLineThickness: BorderWidth.small, highlightColor: material.colorScheme.primary.withValues(alpha: 0.2), ); return GptMarkdownTheme( gptThemeData: gptThemeData, child: GptMarkdown( data, style: baseTextStyle, useDollarSignsForLatex: true, onLinkTap: onTapLink, codeBuilder: (context, language, code, closed) => _buildCodeBlock( context: context, code: code, language: language, theme: theme, ), latexBuilder: (context, tex, textStyle, isInline) { final math = Math.tex(tex, textStyle: textStyle); if (isInline) return math; return SingleChildScrollView( scrollDirection: Axis.horizontal, child: math, ); }, imageBuilder: (context, url) { final uri = Uri.tryParse(url); if (uri == null) { return _buildImageError(context, theme); } if (imageBuilderOverride != null) { return imageBuilderOverride(uri, null, null); } return _buildImage(context, uri, theme); }, ), ); } static Widget _buildCodeBlock({ required BuildContext context, required String code, required String language, required ConduitThemeExtension theme, }) { final isDark = Theme.of(context).brightness == Brightness.dark; final normalizedLanguage = language.trim().isEmpty ? 'plaintext' : language.trim(); // Match GitHub/Atom theme colors for code block container final codeBackground = isDark ? const Color(0xFF282c34) // Atom One Dark background : const Color(0xFFfafbfc); // GitHub light background return Container( margin: const EdgeInsets.symmetric(vertical: Spacing.sm), decoration: BoxDecoration( color: codeBackground, borderRadius: BorderRadius.circular(6), ), clipBehavior: Clip.antiAlias, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ CodeBlockHeader( language: normalizedLanguage, onCopy: () async { await Clipboard.setData(ClipboardData(text: code)); if (!context.mounted) { return; } ScaffoldMessenger.of(context).hideCurrentSnackBar(); final l10n = AppLocalizations.of(context); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( l10n?.codeCopiedToClipboard ?? 'Code copied to clipboard.', ), ), ); }, ), SingleChildScrollView( scrollDirection: Axis.horizontal, padding: const EdgeInsets.all(Spacing.md), child: SelectableText( code, style: AppTypography.codeStyle.copyWith( color: theme.codeText, fontFamily: AppTypography.monospaceFontFamily, ), ), ), ], ), ); } static Widget _buildImage( BuildContext context, Uri uri, ConduitThemeExtension theme, ) { if (uri.scheme == 'data') { return _buildBase64Image(uri.toString(), context, theme); } if (uri.scheme.isEmpty || uri.scheme == 'http' || uri.scheme == 'https') { return _buildNetworkImage(uri.toString(), context, theme); } return _buildImageError(context, theme); } static Widget _buildBase64Image( String dataUrl, BuildContext context, ConduitThemeExtension theme, ) { try { final commaIndex = dataUrl.indexOf(','); if (commaIndex == -1) { throw FormatException( AppLocalizations.of(context)?.invalidDataUrl ?? 'Invalid data URL format', ); } final base64String = dataUrl.substring(commaIndex + 1); final imageBytes = base64.decode(base64String); return Container( margin: const EdgeInsets.symmetric(vertical: Spacing.sm), constraints: const BoxConstraints(maxWidth: 480, maxHeight: 480), child: ClipRRect( borderRadius: BorderRadius.circular(AppBorderRadius.md), child: Image.memory( imageBytes, fit: BoxFit.contain, errorBuilder: (context, error, stackTrace) { return _buildImageError(context, theme); }, ), ), ); } catch (_) { return _buildImageError(context, theme); } } static Widget _buildNetworkImage( String url, BuildContext context, ConduitThemeExtension theme, ) { // Read headers and optional self-signed cache manager from Riverpod final container = ProviderScope.containerOf(context, listen: false); final headers = buildImageHeadersFromContainer(container); final cacheManager = container.read(selfSignedImageCacheManagerProvider); return CachedNetworkImage( imageUrl: url, cacheManager: cacheManager, httpHeaders: headers, placeholder: (context, _) => Container( height: 200, decoration: BoxDecoration( color: theme.surfaceBackground.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(AppBorderRadius.md), ), child: Center( child: CircularProgressIndicator( color: theme.loadingIndicator, strokeWidth: 2, ), ), ), errorWidget: (context, url, error) => _buildImageError(context, theme), imageBuilder: (context, imageProvider) => Container( margin: const EdgeInsets.symmetric(vertical: Spacing.sm), decoration: BoxDecoration( borderRadius: BorderRadius.circular(AppBorderRadius.md), image: DecorationImage(image: imageProvider, fit: BoxFit.contain), ), ), ); } static Widget _buildImageError( BuildContext context, ConduitThemeExtension theme, ) { return Container( height: 120, decoration: BoxDecoration( color: theme.surfaceBackground.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(AppBorderRadius.md), border: Border.all( color: theme.cardBorder.withValues(alpha: 0.4), width: BorderWidth.micro, ), ), child: Center( child: Icon(Icons.broken_image_outlined, color: theme.iconSecondary), ), ); } static Widget buildMermaidBlock(BuildContext context, String code) { final conduitTheme = context.conduitTheme; final materialTheme = Theme.of(context); if (MermaidDiagram.isSupported) { return _buildMermaidContainer( context: context, conduitTheme: conduitTheme, materialTheme: materialTheme, code: code, ); } return _buildUnsupportedMermaidContainer( context: context, conduitTheme: conduitTheme, code: code, ); } static Widget _buildMermaidContainer({ required BuildContext context, required ConduitThemeExtension conduitTheme, required ThemeData materialTheme, required String code, }) { final tokens = context.colorTokens; return Container( margin: const EdgeInsets.symmetric(vertical: Spacing.sm), decoration: BoxDecoration( borderRadius: BorderRadius.circular(AppBorderRadius.sm), border: Border.all( color: conduitTheme.cardBorder.withValues(alpha: 0.4), width: BorderWidth.micro, ), ), height: 360, width: double.infinity, child: ClipRRect( borderRadius: BorderRadius.circular(AppBorderRadius.sm), child: MermaidDiagram( code: code, brightness: materialTheme.brightness, colorScheme: materialTheme.colorScheme, tokens: tokens, ), ), ); } static Widget _buildUnsupportedMermaidContainer({ required BuildContext context, required ConduitThemeExtension conduitTheme, required String code, }) { final l10n = AppLocalizations.of(context); final textStyle = AppTypography.bodySmallStyle.copyWith( color: conduitTheme.codeText.withValues(alpha: 0.7), ); return Container( margin: const EdgeInsets.symmetric(vertical: Spacing.sm), padding: const EdgeInsets.all(Spacing.sm), decoration: BoxDecoration( color: conduitTheme.surfaceContainer.withValues(alpha: 0.35), borderRadius: BorderRadius.circular(AppBorderRadius.sm), border: Border.all( color: conduitTheme.cardBorder.withValues(alpha: 0.4), width: BorderWidth.micro, ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( l10n?.mermaidPreviewUnavailable ?? 'Mermaid preview is not available on this platform.', style: textStyle, ), const SizedBox(height: Spacing.xs), SelectableText( code, maxLines: null, textAlign: TextAlign.left, textDirection: TextDirection.ltr, textWidthBasis: TextWidthBasis.parent, style: AppTypography.codeStyle.copyWith( color: conduitTheme.codeText, ), ), ], ), ); } /// Checks if HTML content contains ChartJS code patterns. static bool containsChartJs(String html) { return html.contains('new Chart(') || html.contains('Chart.'); } /// Converts a Color to a hex string for use in HTML/CSS. static String colorToHex(Color color) { int channel(double value) => (value * 255).round().clamp(0, 255); final rgba = (channel(color.r) << 24) | (channel(color.g) << 16) | (channel(color.b) << 8) | channel(color.a); return '#${rgba.toRadixString(16).padLeft(8, '0')}'; } /// Builds a ChartJS block for rendering in a WebView. static Widget buildChartJsBlock(BuildContext context, String htmlContent) { final conduitTheme = context.conduitTheme; final materialTheme = Theme.of(context); if (ChartJsDiagram.isSupported) { return _buildChartJsContainer( context: context, conduitTheme: conduitTheme, materialTheme: materialTheme, htmlContent: htmlContent, ); } return _buildUnsupportedChartJsContainer( context: context, conduitTheme: conduitTheme, ); } static Widget _buildChartJsContainer({ required BuildContext context, required ConduitThemeExtension conduitTheme, required ThemeData materialTheme, required String htmlContent, }) { final tokens = context.colorTokens; return Container( margin: const EdgeInsets.symmetric(vertical: Spacing.sm), decoration: BoxDecoration( borderRadius: BorderRadius.circular(AppBorderRadius.sm), border: Border.all( color: conduitTheme.cardBorder.withValues(alpha: 0.4), width: BorderWidth.micro, ), ), height: 320, width: double.infinity, child: ClipRRect( borderRadius: BorderRadius.circular(AppBorderRadius.sm), child: ChartJsDiagram( htmlContent: htmlContent, brightness: materialTheme.brightness, colorScheme: materialTheme.colorScheme, tokens: tokens, ), ), ); } static Widget _buildUnsupportedChartJsContainer({ required BuildContext context, required ConduitThemeExtension conduitTheme, }) { final l10n = AppLocalizations.of(context); final textStyle = AppTypography.bodySmallStyle.copyWith( color: conduitTheme.codeText.withValues(alpha: 0.7), ); return Container( margin: const EdgeInsets.symmetric(vertical: Spacing.sm), padding: const EdgeInsets.all(Spacing.sm), decoration: BoxDecoration( color: conduitTheme.surfaceContainer.withValues(alpha: 0.35), borderRadius: BorderRadius.circular(AppBorderRadius.sm), border: Border.all( color: conduitTheme.cardBorder.withValues(alpha: 0.4), width: BorderWidth.micro, ), ), child: Text( l10n?.chartPreviewUnavailable ?? 'Chart preview is not available on this platform.', style: textStyle, ), ); } } // ChartJS diagram WebView widget class ChartJsDiagram extends StatefulWidget { const ChartJsDiagram({ super.key, required this.htmlContent, required this.brightness, required this.colorScheme, required this.tokens, }); final String htmlContent; final Brightness brightness; final ColorScheme colorScheme; final AppColorTokens tokens; static bool get isSupported => !kIsWeb; static Future _loadScript() { return _scriptFuture ??= rootBundle.loadString('assets/chartjs.min.js'); } static Future? _scriptFuture; @override State createState() => _ChartJsDiagramState(); } class _ChartJsDiagramState extends State { WebViewController? _controller; String? _script; final Set> _gestureRecognizers = >{ Factory(() => EagerGestureRecognizer()), }; @override void initState() { super.initState(); if (!ChartJsDiagram.isSupported) { return; } ChartJsDiagram._loadScript().then((value) { if (!mounted) { return; } _script = value; _controller = WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) ..setBackgroundColor(Colors.transparent); _loadHtml(); setState(() {}); }); } @override void didUpdateWidget(ChartJsDiagram oldWidget) { super.didUpdateWidget(oldWidget); if (_controller == null || _script == null) { return; } final contentChanged = oldWidget.htmlContent != widget.htmlContent; final themeChanged = oldWidget.brightness != widget.brightness || oldWidget.colorScheme != widget.colorScheme || oldWidget.tokens != widget.tokens; if (contentChanged || themeChanged) { _loadHtml(); } } @override Widget build(BuildContext context) { if (_controller == null) { return const Center(child: CircularProgressIndicator()); } return SizedBox.expand( child: WebViewWidget( controller: _controller!, gestureRecognizers: _gestureRecognizers, ), ); } void _loadHtml() { if (_controller == null || _script == null) { return; } _controller!.loadHtmlString(_buildHtml(widget.htmlContent, _script!)); } String _buildHtml(String htmlContent, String script) { final isDark = widget.brightness == Brightness.dark; final background = ConduitMarkdown.colorToHex( isDark ? widget.tokens.codeBackground : Colors.white, ); final textColor = ConduitMarkdown.colorToHex(widget.tokens.codeText); final gridColor = ConduitMarkdown.colorToHex( isDark ? Colors.white.withValues(alpha: 0.1) : Colors.black.withValues(alpha: 0.1), ); return '''
'''; } } // Mermaid diagram WebView widget class MermaidDiagram extends StatefulWidget { const MermaidDiagram({ super.key, required this.code, required this.brightness, required this.colorScheme, required this.tokens, }); final String code; final Brightness brightness; final ColorScheme colorScheme; final AppColorTokens tokens; static bool get isSupported => !kIsWeb; static Future _loadScript() { return _scriptFuture ??= rootBundle.loadString('assets/mermaid.min.js'); } static Future? _scriptFuture; @override State createState() => _MermaidDiagramState(); } class _MermaidDiagramState extends State { WebViewController? _controller; String? _script; final Set> _gestureRecognizers = >{ Factory(() => EagerGestureRecognizer()), }; @override void initState() { super.initState(); if (!MermaidDiagram.isSupported) { return; } MermaidDiagram._loadScript().then((value) { if (!mounted) { return; } _script = value; _controller = WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) ..setBackgroundColor(Colors.transparent); _loadHtml(); setState(() {}); }); } @override void didUpdateWidget(MermaidDiagram oldWidget) { super.didUpdateWidget(oldWidget); if (_controller == null || _script == null) { return; } final codeChanged = oldWidget.code != widget.code; final themeChanged = oldWidget.brightness != widget.brightness || oldWidget.colorScheme != widget.colorScheme || oldWidget.tokens != widget.tokens; if (codeChanged || themeChanged) { _loadHtml(); } } @override Widget build(BuildContext context) { if (_controller == null) { return const Center(child: CircularProgressIndicator()); } return SizedBox.expand( child: WebViewWidget( controller: _controller!, gestureRecognizers: _gestureRecognizers, ), ); } void _loadHtml() { if (_controller == null || _script == null) { return; } _controller!.loadHtmlString(_buildHtml(widget.code, _script!)); } String _buildHtml(String code, String script) { final theme = widget.brightness == Brightness.dark ? 'dark' : 'default'; final primary = ConduitMarkdown.colorToHex(widget.tokens.brandTone60); final secondary = ConduitMarkdown.colorToHex(widget.tokens.accentTeal60); final background = ConduitMarkdown.colorToHex(widget.tokens.codeBackground); final onBackground = ConduitMarkdown.colorToHex(widget.tokens.codeText); return '''
$code
'''; } }