import 'dart:async'; 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_markdown_plus/flutter_markdown_plus.dart'; import 'package:flutter_math_fork/flutter_math.dart'; import 'package:markdown/markdown.dart' as md; 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, bool selectable = true, bool shrinkWrap = false, ScrollPhysics? physics, Widget Function(Uri uri, String? title, String? alt)? imageBuilderOverride, }) { return MarkdownBody( data: data, selectable: selectable, shrinkWrap: shrinkWrap, styleSheet: _buildStyleSheet(context), builders: _buildCustomBuilders(context, onTapLink), // Allow callers to override how markdown images render (e.g., to use // EnhancedImageAttachment in assistant views). Fallback to default. imageBuilder: (uri, title, alt) => imageBuilderOverride != null ? imageBuilderOverride(uri, title, alt) : _ImageBuilder(context).buildFromUri(uri), extensionSet: md.ExtensionSet.gitHubFlavored, onTapLink: onTapLink != null ? (text, href, title) => onTapLink(href ?? '', title) : null, syntaxHighlighter: _CodeSyntaxHighlighter(context), inlineSyntaxes: _buildInlineSyntaxes(), blockSyntaxes: _buildBlockSyntaxes(), ); } static Widget buildBlock({ required BuildContext context, required String data, MarkdownLinkTapCallback? onTapLink, bool selectable = true, Widget Function(Uri uri, String? title, String? alt)? imageBuilderOverride, }) { return build( context: context, data: data, onTapLink: onTapLink, selectable: selectable, shrinkWrap: true, imageBuilderOverride: imageBuilderOverride, ); } static MarkdownStyleSheet _buildStyleSheet(BuildContext context) { final theme = context.conduitTheme; final material = Theme.of(context); final baseBody = AppTypography.bodyMediumStyle.copyWith( color: theme.textPrimary, height: 1.45, ); final secondaryBody = AppTypography.bodySmallStyle.copyWith( color: theme.textSecondary, height: 1.45, ); final codeBackground = theme.surfaceContainer.withValues(alpha: 0.55); final borderColor = theme.cardBorder.withValues(alpha: 0.25); return MarkdownStyleSheet( p: baseBody, 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: baseBody.copyWith(fontWeight: FontWeight.w600), h6: secondaryBody, a: baseBody.copyWith( color: material.colorScheme.primary, decoration: TextDecoration.underline, decorationColor: material.colorScheme.primary, ), code: AppTypography.codeStyle.copyWith( color: theme.codeText, backgroundColor: codeBackground, ), codeblockDecoration: BoxDecoration( color: codeBackground, borderRadius: BorderRadius.circular(AppBorderRadius.sm), border: Border.all(color: borderColor, width: BorderWidth.micro), ), codeblockPadding: const EdgeInsets.all(Spacing.sm), blockquoteDecoration: BoxDecoration( border: Border( left: BorderSide( color: material.colorScheme.primary.withValues(alpha: 0.35), width: BorderWidth.small, ), ), ), blockquotePadding: const EdgeInsets.symmetric( horizontal: Spacing.md, vertical: Spacing.sm, ), blockquote: secondaryBody, listBullet: baseBody, listIndent: Spacing.lg, tableHead: secondaryBody.copyWith(fontWeight: FontWeight.w600), tableBody: secondaryBody, tableBorder: TableBorder.all( color: borderColor, width: BorderWidth.micro, ), tableHeadAlign: TextAlign.start, tableColumnWidth: const FlexColumnWidth(), tableCellsPadding: const EdgeInsets.symmetric( horizontal: Spacing.sm, vertical: Spacing.xs, ), horizontalRuleDecoration: BoxDecoration( border: Border( top: BorderSide(color: theme.dividerColor, width: BorderWidth.small), ), ), ); } static Map _buildCustomBuilders( BuildContext context, MarkdownLinkTapCallback? onTapLink, ) { return { 'code': _CodeBlockBuilder(context), 'mermaid': _MermaidBuilder(context), 'latex': _LatexBuilder(context), 'details': _DetailsBuilder(context), }; } static List _buildInlineSyntaxes() { return [_LatexInlineSyntax()]; } static List _buildBlockSyntaxes() { return [_DetailsBlockSyntax()]; } 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 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( '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, ), ), ], ), ); } } // Code syntax highlighting class _CodeSyntaxHighlighter extends SyntaxHighlighter { _CodeSyntaxHighlighter(this.context); final BuildContext context; @override TextSpan format(String source) { final theme = context.conduitTheme; return TextSpan( style: AppTypography.codeStyle.copyWith(color: theme.codeText), children: [TextSpan(text: source)], ); } } // Custom code block builder with header class _CodeBlockBuilder extends MarkdownElementBuilder { _CodeBlockBuilder(this.context); final BuildContext context; @override Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) { final theme = context.conduitTheme; final isDark = Theme.of(context).brightness == Brightness.dark; final code = element.textContent; final language = element.attributes['class']?.replaceFirst('language-', '') ?? 'plaintext'; 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, ), ), ), ], ), ); } } // Custom image builder class _ImageBuilder extends MarkdownElementBuilder { _ImageBuilder(this.context); final BuildContext context; @override Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) { final url = element.attributes['src'] ?? ''; final uri = Uri.tryParse(url); if (uri == null) { return _buildImageError(context, context.conduitTheme); } return buildFromUri(uri); } /// Public helper used by the Markdown `imageBuilder` callback. Widget buildFromUri(Uri uri) { final theme = context.conduitTheme; 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); } 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); } } 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), ), ), ); } 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), ), ); } } // Mermaid diagram builder class _MermaidBuilder extends MarkdownElementBuilder { _MermaidBuilder(this.context); final BuildContext context; @override Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) { final code = element.textContent; return ConduitMarkdown.buildMermaidBlock(context, code); } } // LaTeX builder class _LatexBuilder extends MarkdownElementBuilder { _LatexBuilder(this.context); final BuildContext context; @override Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) { final isDark = Theme.of(context).brightness == Brightness.dark; final content = element.textContent.trim(); final isInline = element.attributes['isInline'] == 'true'; final baseStyle = (preferredStyle ?? AppTypography.bodyMediumStyle) .copyWith(color: isDark ? Colors.white : Colors.black); if (content.isEmpty) { return Text(element.textContent, style: baseStyle); } final mathWidget = Math.tex( content, mathStyle: MathStyle.text, textStyle: baseStyle, textScaleFactor: 1, onErrorFallback: (error) { return Text(content, style: baseStyle.copyWith(color: Colors.red)); }, ); if (isInline) { return mathWidget; } return Padding( padding: const EdgeInsets.symmetric(vertical: Spacing.xs), child: Center(child: mathWidget), ); } } // LaTeX inline syntax class _LatexInlineSyntax extends md.InlineSyntax { _LatexInlineSyntax() : super( r'(\$\$[\s\S]+?\$\$)|(\$[^\n]+?\$)|(\\\([\s\S]+?\\\))|(\\\[[\s\S]+?\\\])', ); @override bool onMatch(md.InlineParser parser, Match match) { final raw = match.group(0) ?? ''; String content = raw; bool isInline = true; if (raw.startsWith(r'$$') && raw.endsWith(r'$$') && raw.length > 4) { content = raw.substring(2, raw.length - 2); isInline = false; } else if (raw.startsWith(r'$') && raw.endsWith(r'$') && raw.length > 2) { content = raw.substring(1, raw.length - 1); isInline = true; } else if (raw.startsWith(r'\(') && raw.endsWith(r'\)') && raw.length > 4) { content = raw.substring(2, raw.length - 2); isInline = true; } else if (raw.startsWith(r'\[') && raw.endsWith(r'\]') && raw.length > 4) { content = raw.substring(2, raw.length - 2); isInline = false; } final element = md.Element.text('latex', content); element.attributes['isInline'] = isInline.toString(); parser.addNode(element); return true; } } // 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 = _toHex(widget.tokens.brandTone60); final secondary = _toHex(widget.tokens.accentTeal60); final background = _toHex(widget.tokens.codeBackground); final onBackground = _toHex(widget.tokens.codeText); return '''
$code
'''; } String _toHex(Color color) { int channel(double value) { final scaled = (value * 255).round(); if (scaled < 0) { return 0; } if (scaled > 255) { return 255; } return scaled; } final argb = (channel(color.a) << 24) | (channel(color.r) << 16) | (channel(color.g) << 8) | channel(color.b); return '#${argb.toRadixString(16).padLeft(8, '0')}'; } } // Details block syntax for parsing
tags class _DetailsBlockSyntax extends md.BlockSyntax { @override RegExp get pattern => RegExp(r'^]*)?>$'); @override md.Node? parse(md.BlockParser parser) { final match = pattern.firstMatch(parser.current.content); if (match == null) { return null; } // Parse attributes from the opening tag final attributesString = match.group(1) ?? ''; final attributes = _parseAttributes(attributesString); parser.advance(); // Find the matching closing tag String summary = ''; final contentLines = []; while (!parser.isDone) { final line = parser.current.content; // Check for closing tag if (line.trim() == '
') { parser.advance(); break; } // Check for summary tag final summaryMatch = RegExp( r'^(.*?)<\/summary>$', ).firstMatch(line); if (summaryMatch != null) { summary = summaryMatch.group(1) ?? ''; parser.advance(); continue; } contentLines.add(line); parser.advance(); } final element = md.Element('details', [md.Text(contentLines.join('\n'))]); element.attributes['summary'] = summary; element.attributes.addAll(attributes); return element; } Map _parseAttributes(String attributesString) { final attributes = {}; final attrRegex = RegExp(r'(\w+)="([^"]*)"'); for (final match in attrRegex.allMatches(attributesString)) { attributes[match.group(1)!] = match.group(2) ?? ''; } return attributes; } } // Details builder for rendering
elements class _DetailsBuilder extends MarkdownElementBuilder { _DetailsBuilder(this.context); final BuildContext context; @override Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) { // Details elements with type="reasoning" or type="tool_calls" should not be // rendered as markdown during streaming. They are handled by: // - ReasoningParser for reasoning blocks (creates thinking tiles) // - ToolCallsParser for tool_calls blocks (creates tool execution tiles) // Return empty widget to prevent character flashing during streaming. return const SizedBox.shrink(); } }