import 'dart:collection'; import 'dart:convert'; import 'dart:typed_data'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show Clipboard, ClipboardData; import 'package:gpt_markdown/custom_widgets/markdown_config.dart' show CodeBlockBuilder, GptMarkdownConfig, ImageBuilder; import 'package:gpt_markdown/gpt_markdown.dart'; import 'package:highlight/highlight.dart' as hl; import '../../theme/theme_extensions.dart'; /// Registry used to compose custom markdown components. class ConduitMarkdownRegistry { ConduitMarkdownRegistry._(); static final List _blockComponents = [ DetailsMarkdownComponent(), ]; static final List _inlineComponents = []; static UnmodifiableListView get blockComponents => UnmodifiableListView(_blockComponents); static UnmodifiableListView get inlineComponents => UnmodifiableListView(_inlineComponents); static void registerBlockComponent(MarkdownComponent component) { _blockComponents.add(component); } static void registerInlineComponent(MarkdownComponent component) { _inlineComponents.add(component); } static List composeBlockComponents() { return [..._blockComponents, ...MarkdownComponent.globalComponents]; } static List composeInlineComponents() { return [..._inlineComponents, ...MarkdownComponent.inlineComponents]; } } class ConduitMarkdownTheme { const ConduitMarkdownTheme({ required this.textStyle, required this.themeData, required this.imageBuilder, required this.codeBuilder, required this.blockComponents, required this.inlineComponents, this.followLinkColor = true, }); final TextStyle textStyle; final GptMarkdownThemeData themeData; final ImageBuilder imageBuilder; final CodeBlockBuilder codeBuilder; final List blockComponents; final List inlineComponents; final bool followLinkColor; } class ConduitMarkdownConfig { static ConduitMarkdownTheme resolve(BuildContext context) { final theme = context.conduitTheme; final materialTheme = Theme.of(context); final bodyStyle = AppTypography.bodyMediumStyle.copyWith( color: theme.textPrimary, height: 1.45, ); final codeColor = theme.code?.color ?? theme.textSecondary; final markdownThemeData = GptMarkdownThemeData( brightness: materialTheme.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: AppTypography.bodyMediumStyle.copyWith( color: theme.textSecondary, fontWeight: FontWeight.w600, ), h6: AppTypography.bodySmallStyle.copyWith(color: theme.textSecondary), hrLineColor: theme.dividerColor, highlightColor: theme.surfaceContainer.withValues(alpha: 0.4), linkColor: materialTheme.colorScheme.primary, linkHoverColor: materialTheme.colorScheme.primary.withValues(alpha: 0.8), ); return ConduitMarkdownTheme( textStyle: bodyStyle, themeData: markdownThemeData, imageBuilder: (context, imageUrl) { final uri = Uri.tryParse(imageUrl); if (uri == null) { return _buildImageError(context, context.conduitTheme); } final scheme = uri.scheme; if (scheme == 'data') { return buildBase64Image(imageUrl, context, context.conduitTheme); } if (scheme.isEmpty || scheme == 'http' || scheme == 'https') { return buildNetworkImage(imageUrl, context, context.conduitTheme); } return const SizedBox.shrink(); }, codeBuilder: (context, name, code, closed) { final conduitTheme = context.conduitTheme; final textStyle = AppTypography.codeStyle.copyWith( color: conduitTheme.code?.color ?? codeColor, ); final container = ConduitCodeView( code: code, language: name.trim().isEmpty ? null : name.trim(), baseStyle: textStyle, conduitTheme: conduitTheme, ); return CodeBlockWrapper( code: code, language: name.trim().isEmpty ? null : name.trim(), theme: conduitTheme, child: container, ); }, blockComponents: ConduitMarkdownRegistry.composeBlockComponents(), inlineComponents: ConduitMarkdownRegistry.composeInlineComponents(), ); } static Widget buildBase64Image( String dataUrl, BuildContext context, ConduitThemeExtension theme, ) { try { final commaIndex = dataUrl.indexOf(','); if (commaIndex == -1) { throw Exception('Invalid data URL format'); } final base64String = dataUrl.substring(commaIndex + 1); final imageBytes = base64.decode(base64String); return ConduitMarkdownImage.memory(bytes: imageBytes, theme: theme); } catch (e) { return _buildImageError(context, theme); } } static Widget buildNetworkImage( String url, BuildContext context, ConduitThemeExtension theme, ) { return ConduitMarkdownImage.network(url: url, theme: theme); } static Widget _buildImageError( BuildContext context, ConduitThemeExtension theme, ) { return const SizedBox.shrink(); } } class CodeBlockWrapper extends StatelessWidget { const CodeBlockWrapper({ super.key, required this.child, required this.code, this.language, required this.theme, }); final Widget child; final String code; final String? language; final ConduitThemeExtension theme; @override Widget build(BuildContext context) { return Stack( children: [ child, Positioned( top: 8, right: 8, child: Material( color: theme.surfaceBackground.withValues(alpha: 0.0), child: InkWell( borderRadius: BorderRadius.circular(AppBorderRadius.sm), onTap: () { Clipboard.setData(ClipboardData(text: code)); _showCopiedToast(context); }, child: Container( padding: const EdgeInsets.all(Spacing.xs), decoration: BoxDecoration( color: theme.surfaceBackground.withValues(alpha: 0.8), borderRadius: BorderRadius.circular(AppBorderRadius.sm), ), child: Icon( Icons.copy, size: IconSize.sm, color: theme.iconSecondary, ), ), ), ), ), if (language != null) Positioned( top: 8, left: 8, child: Container( padding: const EdgeInsets.symmetric( horizontal: Spacing.sm, vertical: Spacing.xxs, ), decoration: BoxDecoration( color: theme.surfaceBackground.withValues(alpha: 0.8), borderRadius: BorderRadius.circular(AppBorderRadius.xs), ), child: Text( language!, style: AppTypography.bodySmallStyle.copyWith( color: theme.textSecondary, fontFamily: AppTypography.monospaceFontFamily, ), ), ), ), ], ); } } void _showCopiedToast(BuildContext context) { final messenger = ScaffoldMessenger.maybeOf(context); if (messenger == null) { return; } messenger.hideCurrentSnackBar(); messenger.showSnackBar( SnackBar( content: const Text('Code copied to clipboard.'), behavior: SnackBarBehavior.floating, duration: const Duration(seconds: 2), backgroundColor: context.conduitTheme.surfaceContainer, ), ); } class ConduitCodeView extends StatelessWidget { const ConduitCodeView({ super.key, required this.code, required this.language, required this.baseStyle, required this.conduitTheme, }); final String code; final String? language; final TextStyle baseStyle; final ConduitThemeExtension conduitTheme; @override Widget build(BuildContext context) { final normalizedLanguage = language?.toLowerCase(); hl.Result? result; try { result = hl.highlight.parse( code, language: normalizedLanguage != null && normalizedLanguage.isNotEmpty ? normalizedLanguage : null, autoDetection: normalizedLanguage == null || normalizedLanguage.isEmpty, ); } catch (_) { result = hl.highlight.parse(code, autoDetection: true); } final spans = _buildTextSpans( result.nodes ?? const [], baseStyle, conduitTheme, ); return Container( width: double.infinity, margin: const EdgeInsets.symmetric(vertical: Spacing.xs), padding: const EdgeInsets.all(Spacing.sm), decoration: BoxDecoration( color: conduitTheme.surfaceBackground.withValues(alpha: 0.6), borderRadius: BorderRadius.circular(AppBorderRadius.md), border: Border.all( color: conduitTheme.cardBorder.withValues(alpha: 0.2), width: BorderWidth.micro, ), ), child: SelectableText.rich( TextSpan( style: baseStyle, children: spans.isNotEmpty ? spans : [TextSpan(text: code)], ), ), ); } List _buildTextSpans( List nodes, TextStyle base, ConduitThemeExtension theme, ) { if (nodes.isEmpty) { return const []; } return nodes.map((node) { final style = _styleFor(node.className, base, theme); if ((node.children ?? const []).isNotEmpty) { return TextSpan( style: style, children: _buildTextSpans(node.children!, style, theme), ); } return TextSpan(text: node.value ?? '', style: style); }).toList(); } TextStyle _styleFor( String? className, TextStyle base, ConduitThemeExtension theme, ) { if (className == null || className.isEmpty) { return base; } final colorMap = { 'keyword': theme.info, 'built_in': theme.info, 'type': theme.info, 'literal': theme.warning, 'symbol': theme.warning, 'number': theme.warning, 'string': theme.success, 'subst': theme.textSecondary, 'comment': theme.textSecondary.withValues(alpha: 0.7), 'quote': theme.textSecondary.withValues(alpha: 0.7), 'doctag': theme.info, 'meta': theme.iconSecondary, 'title': theme.info, 'section': theme.info, 'attr': theme.warning, 'attribute': theme.warning, 'name': theme.info, 'selector-tag': theme.info, }; Color? color; for (final entry in colorMap.entries) { if (className.contains(entry.key)) { color = entry.value; break; } } return base.copyWith( color: color ?? base.color ?? theme.code?.color ?? theme.textSecondary, fontStyle: className.contains('comment') ? FontStyle.italic : base.fontStyle, fontWeight: className.contains('keyword') ? FontWeight.w600 : base.fontWeight, ); } } class ConduitMarkdownImage extends StatelessWidget { const ConduitMarkdownImage._({ required this.child, required this.theme, required this.heroTag, required this.semanticLabel, }); static int _heroSequence = 0; static String _nextHeroTag(String base) { final tag = '$base#${_heroSequence++}'; return tag; } factory ConduitMarkdownImage.network({ required String url, required ConduitThemeExtension theme, }) { final lowerUrl = url.toLowerCase(); final isAnimated = lowerUrl.endsWith('.gif') || lowerUrl.endsWith('.webp'); final heroTag = _nextHeroTag('markdown_image_$url'); return ConduitMarkdownImage._( theme: theme, heroTag: heroTag, semanticLabel: 'Markdown image', child: CachedNetworkImage( imageUrl: url, fadeInDuration: const Duration(milliseconds: 200), imageBuilder: (context, provider) { return _InteractiveImage( theme: theme, heroTag: heroTag, child: Image(image: provider, fit: BoxFit.contain), ); }, placeholder: (context, url) => 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) => ConduitMarkdownConfig._buildImageError(context, theme), memCacheHeight: isAnimated ? null : 1024, memCacheWidth: isAnimated ? null : 1024, ), ); } factory ConduitMarkdownImage.memory({ required Uint8List bytes, required ConduitThemeExtension theme, }) { final heroTag = _nextHeroTag('markdown_image_memory'); return ConduitMarkdownImage._( theme: theme, heroTag: heroTag, semanticLabel: 'Embedded markdown image', child: _InteractiveImage( theme: theme, heroTag: heroTag, child: Image.memory(bytes, fit: BoxFit.contain), ), ); } final Widget child; final ConduitThemeExtension theme; final String heroTag; final String semanticLabel; @override Widget build(BuildContext context) { return Semantics(label: semanticLabel, image: true, child: child); } } class _InteractiveImage extends StatelessWidget { const _InteractiveImage({ required this.theme, required this.heroTag, required this.child, }); final ConduitThemeExtension theme; final String heroTag; final Widget child; @override Widget build(BuildContext context) { return GestureDetector( onTap: () => _showImageViewer(context, heroTag, child, theme), child: Hero( tag: heroTag, child: Container( margin: const EdgeInsets.symmetric(vertical: Spacing.sm), decoration: BoxDecoration( borderRadius: BorderRadius.circular(AppBorderRadius.md), boxShadow: [ BoxShadow( color: theme.cardShadow.withValues(alpha: 0.3), blurRadius: 16, offset: const Offset(0, 12), ), ], ), clipBehavior: Clip.antiAlias, child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 500, maxHeight: 500), child: child, ), ), ), ); } } void _showImageViewer( BuildContext context, String heroTag, Widget child, ConduitThemeExtension theme, ) { showDialog( context: context, builder: (dialogContext) { return Dialog( backgroundColor: theme.surfaceBackground.withValues(alpha: 0.85), insetPadding: const EdgeInsets.all(Spacing.md), child: Hero( tag: heroTag, child: ClipRRect( borderRadius: BorderRadius.circular(AppBorderRadius.lg), child: InteractiveViewer(minScale: 0.5, maxScale: 4, child: child), ), ), ); }, ); } class DetailsMarkdownComponent extends BlockMd { @override String get expString => r"]*>[\s\S]*?<\/details>"; @override Widget build(BuildContext context, String text, GptMarkdownConfig config) { final summaryMatch = RegExp( r"]*>([\s\S]*?)<\/summary>", dotAll: true, multiLine: true, ).firstMatch(text); final summary = summaryMatch?.group(1)?.trim() ?? 'Details'; var content = text .replaceFirst(RegExp(r"]*>"), '') .replaceAll(summaryMatch?.group(0) ?? '', '') .replaceFirst(RegExp(r"<\/details>\s*$"), '') .trim(); return ConduitDetailsBlock( summary: summary, content: content, config: config, ); } } class ConduitDetailsBlock extends StatefulWidget { const ConduitDetailsBlock({ super.key, required this.summary, required this.content, required this.config, }); final String summary; final String content; final GptMarkdownConfig config; @override State createState() => _ConduitDetailsBlockState(); } class _ConduitDetailsBlockState extends State { late bool _expanded; late List _contentSpans; @override void initState() { super.initState(); _expanded = false; _contentSpans = MarkdownComponent.generate( context, widget.content, widget.config.copyWith(), true, ); } @override Widget build(BuildContext context) { final theme = context.conduitTheme; final cardColor = theme.surfaceContainer.withValues(alpha: 0.7); return Container( margin: const EdgeInsets.symmetric(vertical: Spacing.sm), decoration: BoxDecoration( color: cardColor, borderRadius: BorderRadius.circular(AppBorderRadius.lg), boxShadow: [ BoxShadow( color: theme.cardShadow.withValues(alpha: 0.25), blurRadius: 20, offset: const Offset(0, 16), ), ], ), child: ClipRRect( borderRadius: BorderRadius.circular(AppBorderRadius.lg), child: ExpansionTile( title: Text( widget.summary, style: AppTypography.headlineSmallStyle.copyWith( color: theme.textPrimary, ), ), tilePadding: const EdgeInsets.symmetric( horizontal: Spacing.md, vertical: Spacing.sm, ), trailing: Icon( _expanded ? Icons.expand_less : Icons.expand_more, color: theme.iconSecondary, ), onExpansionChanged: (isExpanded) { setState(() => _expanded = isExpanded); }, children: [ Padding( padding: const EdgeInsets.symmetric( horizontal: Spacing.md, vertical: Spacing.sm, ), child: widget.config.getRich( TextSpan(style: widget.config.style, children: _contentSpans), ), ), ], ), ), ); } }