diff --git a/lib/features/chat/widgets/assistant_message_widget.dart b/lib/features/chat/widgets/assistant_message_widget.dart index 8f65dc0..6f5eff3 100644 --- a/lib/features/chat/widgets/assistant_message_widget.dart +++ b/lib/features/chat/widgets/assistant_message_widget.dart @@ -768,6 +768,7 @@ class _AssistantMessageWidgetState extends ConsumerState Widget buildDefault(BuildContext context) => StreamingMarkdownWidget( content: processedContent, isStreaming: widget.isStreaming, + onTapLink: (url, _) => _launchUri(url), ); final responseBuilder = ref.watch(assistantResponseBuilderProvider); diff --git a/lib/shared/widgets/markdown/markdown_config.dart b/lib/shared/widgets/markdown/markdown_config.dart index 01eda27..6d335cd 100644 --- a/lib/shared/widgets/markdown/markdown_config.dart +++ b/lib/shared/widgets/markdown/markdown_config.dart @@ -1,613 +1,132 @@ -import 'dart:async'; import 'dart:convert'; -import 'dart:math' as math; 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_highlight/flutter_highlight.dart'; -import 'package:flutter_highlight/themes/atom-one-dark-reasonable.dart'; -import 'package:gpt_markdown/custom_widgets/markdown_config.dart' - show - CodeBlockBuilder, - GptMarkdownConfig, - ImageBuilder, - OrderedListBuilder, - TableBuilder, - UnOrderedListBuilder; -import 'package:gpt_markdown/gpt_markdown.dart'; -import 'package:webview_flutter/webview_flutter.dart'; - -import 'package:conduit/l10n/app_localizations.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import '../../theme/theme_extensions.dart'; -import '../../theme/color_tokens.dart'; - -class MarkdownFeatureFlags { - const MarkdownFeatureFlags({ - this.enableSyntaxHighlighting = false, - this.enableMermaid = false, - }); - - final bool enableSyntaxHighlighting; - final bool enableMermaid; - - MarkdownFeatureFlags copyWith({ - bool? enableSyntaxHighlighting, - bool? enableMermaid, - }) { - return MarkdownFeatureFlags( - enableSyntaxHighlighting: - enableSyntaxHighlighting ?? this.enableSyntaxHighlighting, - enableMermaid: enableMermaid ?? this.enableMermaid, - ); - } -} class ConduitMarkdownTheme { const ConduitMarkdownTheme({ - required this.textStyle, - required this.themeData, + required this.styleSheet, required this.imageBuilder, - required this.codeBuilder, - required this.orderedListBuilder, - required this.unOrderedListBuilder, - required this.tableBuilder, - required this.checkboxTheme, - required this.radioTheme, - this.followLinkColor = true, + required this.linkColor, + required this.linkHoverColor, }); - final TextStyle textStyle; - final GptMarkdownThemeData themeData; - final ImageBuilder imageBuilder; - final CodeBlockBuilder codeBuilder; - final OrderedListBuilder orderedListBuilder; - final UnOrderedListBuilder unOrderedListBuilder; - final TableBuilder tableBuilder; - final CheckboxThemeData checkboxTheme; - final RadioThemeData radioTheme; - final bool followLinkColor; + final MarkdownStyleSheet styleSheet; + final MarkdownImageBuilder imageBuilder; + final Color linkColor; + final Color linkHoverColor; } class ConduitMarkdownConfig { - static ConduitMarkdownTheme resolve( - BuildContext context, { - MarkdownFeatureFlags flags = const MarkdownFeatureFlags(), - }) { + static ConduitMarkdownTheme resolve(BuildContext context) { final theme = context.conduitTheme; final materialTheme = Theme.of(context); - final bodyStyle = AppTypography.bodyMediumStyle.copyWith( + final baseBody = AppTypography.bodyMediumStyle.copyWith( color: theme.textPrimary, height: 1.45, ); + final secondaryBody = AppTypography.bodySmallStyle.copyWith( + color: theme.textSecondary, + height: 1.45, + ); - final codeColor = theme.code?.color ?? theme.textSecondary; + final codeBackground = theme.surfaceContainer.withValues(alpha: 0.55); + final borderColor = theme.cardBorder.withValues(alpha: 0.25); - final markdownThemeData = GptMarkdownThemeData( - brightness: materialTheme.brightness, + final styleSheet = MarkdownStyleSheet( + a: baseBody.copyWith( + color: materialTheme.colorScheme.primary, + decoration: TextDecoration.underline, + decorationColor: materialTheme.colorScheme.primary, + ), + p: baseBody, + blockSpacing: Spacing.sm, + listIndent: Spacing.lg, + listBullet: baseBody.copyWith(color: theme.textSecondary), + listBulletPadding: const EdgeInsets.only(right: Spacing.xs), + checkbox: baseBody.copyWith(color: theme.textSecondary), + em: baseBody.copyWith(fontStyle: FontStyle.italic), + strong: baseBody.copyWith(fontWeight: FontWeight.w600), + del: baseBody.copyWith(decoration: TextDecoration.lineThrough), 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, + h5: baseBody.copyWith(fontWeight: FontWeight.w600), + h6: secondaryBody, + blockquote: baseBody.copyWith(color: theme.textSecondary), + blockquotePadding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.sm, ), - 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), - ); - - final listMarkerStyle = AppTypography.bodyMediumStyle.copyWith( - color: theme.textSecondary, - fontWeight: FontWeight.w600, - ); - final bulletColor = theme.textSecondary.withValues(alpha: 0.85); - const listIndicatorWidth = 28.0; - - Widget orderedListBuilder( - BuildContext context, - String number, - Widget child, - GptMarkdownConfig listConfig, - ) { - return Padding( - padding: const EdgeInsetsDirectional.only(bottom: Spacing.xs), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - textDirection: listConfig.textDirection, - children: [ - SizedBox( - width: listIndicatorWidth, - child: Align( - alignment: AlignmentDirectional.topEnd, - child: Text('$number.', style: listMarkerStyle), - ), - ), - const SizedBox(width: Spacing.sm), - Expanded(child: child), - ], - ), - ); - } - - Widget unOrderedListBuilder( - BuildContext context, - Widget child, - GptMarkdownConfig listConfig, - ) { - return Padding( - padding: const EdgeInsetsDirectional.only(bottom: Spacing.xs), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - textDirection: listConfig.textDirection, - children: [ - Padding( - padding: const EdgeInsetsDirectional.only(top: Spacing.xs + 2), - child: Container( - width: 6, - height: 6, - decoration: BoxDecoration( - color: bulletColor, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: theme.cardShadow.withValues(alpha: 0.18), - blurRadius: 2, - offset: const Offset(0, 1), - ), - ], - ), - ), - ), - const SizedBox(width: Spacing.sm), - Expanded(child: child), - ], - ), - ); - } - - final tableHeaderStyle = AppTypography.bodySmallStyle.copyWith( - color: theme.textPrimary, - fontWeight: FontWeight.w600, - ); - final tableCellStyle = AppTypography.bodySmallStyle.copyWith( - color: theme.textSecondary, - ); - final tableBorderColor = theme.cardBorder.withValues(alpha: 0.6); - final headerBackground = theme.surfaceContainerHighest.withValues( - alpha: 0.7, - ); - final stripeBackground = theme.surfaceContainer.withValues(alpha: 0.25); - final tableBackground = theme.surfaceBackground.withValues(alpha: 0.35); - - Widget tableBuilder( - BuildContext context, - List rows, - TextStyle textStyle, - GptMarkdownConfig tableConfig, - ) { - if (rows.isEmpty) { - return const SizedBox.shrink(); - } - - final columnCount = rows.fold( - 0, - (maxCount, row) => math.max(maxCount, row.fields.length), - ); - if (columnCount == 0) { - return const SizedBox.shrink(); - } - - final columnWidths = { - for (var i = 0; i < columnCount; i++) i: const IntrinsicColumnWidth(), - }; - - final controller = ScrollController(); - - final tableRows = rows.asMap().entries.map((entry) { - final rowIndex = entry.key; - final row = entry.value; - final isHeader = row.isHeader; - final backgroundColor = isHeader - ? headerBackground - : rowIndex.isOdd - ? stripeBackground - : Colors.transparent; - - return TableRow( - decoration: backgroundColor == Colors.transparent - ? null - : BoxDecoration(color: backgroundColor), - children: List.generate(columnCount, (columnIndex) { - if (columnIndex >= row.fields.length) { - return const SizedBox.shrink(); - } - - final field = row.fields[columnIndex]; - final cellConfig = tableConfig.copyWith( - style: isHeader ? tableHeaderStyle : tableCellStyle, - ); - Widget cell = MdWidget( - context, - field.data.trim(), - false, - config: cellConfig, - ); - cell = Padding( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.sm, - vertical: Spacing.xs, - ), - child: cell, - ); - - Alignment alignment; - switch (field.alignment) { - case TextAlign.center: - alignment = Alignment.center; - break; - case TextAlign.right: - alignment = Alignment.centerRight; - break; - case TextAlign.left: - default: - alignment = Alignment.centerLeft; - break; - } - - return Align(alignment: alignment, child: cell); - }), - ); - }).toList(); - - final tableWidget = DecoratedBox( - decoration: BoxDecoration( - color: tableBackground, - borderRadius: BorderRadius.circular(AppBorderRadius.sm), - border: Border.all(color: tableBorderColor, width: BorderWidth.small), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(AppBorderRadius.sm), - child: Table( - columnWidths: columnWidths, - defaultVerticalAlignment: TableCellVerticalAlignment.middle, - border: TableBorder.symmetric( - inside: BorderSide( - color: tableBorderColor, - width: BorderWidth.micro, - ), - outside: BorderSide.none, - ), - children: tableRows, + blockquoteDecoration: BoxDecoration( + color: theme.surfaceContainer.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(AppBorderRadius.sm), + border: Border( + left: BorderSide( + width: BorderWidth.standard, + color: materialTheme.colorScheme.primary.withValues(alpha: 0.35), ), ), - ); - - return Scrollbar( - controller: controller, - thumbVisibility: false, - child: SingleChildScrollView( - controller: controller, - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(vertical: Spacing.xs), - child: tableWidget, - ), - ); - } - - final primaryColor = materialTheme.colorScheme.primary; - final overlayColor = primaryColor.withValues(alpha: 0.12); - - Color? resolveOverlay(Set states) => - states.contains(WidgetState.pressed) || - states.contains(WidgetState.focused) || - states.contains(WidgetState.hovered) - ? overlayColor - : null; - - final checkboxTheme = CheckboxThemeData( - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.compact, - splashRadius: 18, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppBorderRadius.xs), ), - side: BorderSide( - color: theme.cardBorder.withValues(alpha: 0.6), + code: AppTypography.codeStyle.copyWith( + color: theme.codeText, + backgroundColor: codeBackground, + ), + codeblockPadding: const EdgeInsets.all(Spacing.sm), + codeblockDecoration: BoxDecoration( + color: codeBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.sm), + border: Border.all(color: borderColor, width: BorderWidth.micro), + ), + horizontalRuleDecoration: BoxDecoration( + border: Border( + top: BorderSide(color: theme.dividerColor, width: BorderWidth.small), + ), + ), + tableHead: secondaryBody.copyWith(fontWeight: FontWeight.w600), + tableBody: secondaryBody, + tableBorder: TableBorder.all( + color: borderColor, width: BorderWidth.micro, ), - fillColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) { - return theme.surfaceContainer.withValues(alpha: 0.4); - } - if (states.contains(WidgetState.selected)) { - return primaryColor; - } - return theme.surfaceBackground.withValues(alpha: 0.7); - }), - checkColor: WidgetStateProperty.all(theme.textInverse), - overlayColor: WidgetStateProperty.resolveWith(resolveOverlay), - ); - - final radioTheme = RadioThemeData( - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.compact, - fillColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) { - return theme.surfaceContainer.withValues(alpha: 0.4); - } - if (states.contains(WidgetState.selected)) { - return primaryColor; - } - return theme.surfaceBackground.withValues(alpha: 0.7); - }), - overlayColor: WidgetStateProperty.resolveWith(resolveOverlay), - backgroundColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) { - return theme.surfaceBackground.withValues(alpha: 0.3); - } - return null; - }), + tableCellsPadding: const EdgeInsets.symmetric( + horizontal: Spacing.sm, + vertical: Spacing.xs, + ), + tableCellsDecoration: BoxDecoration( + color: theme.surfaceBackground.withValues(alpha: 0.35), + ), + tableHeadAlign: TextAlign.left, + tablePadding: const EdgeInsets.only(bottom: Spacing.xs), ); 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 language = name.trim().isEmpty ? null : name.trim(); - final isMermaid = - flags.enableMermaid && (language?.toLowerCase() == 'mermaid'); - - if (isMermaid && !flags.enableMermaid) { - return CodeBlockWrapper( - code: code, - language: language, - theme: conduitTheme, - closed: closed, - child: _buildUnsupportedMermaidContainer( - conduitTheme: conduitTheme, - codeColor: codeColor, - code: code, - ), - ); - } - - final Widget content; - if (isMermaid) { - content = MermaidDiagram.isSupported - ? _buildMermaidContainer( - context: context, - conduitTheme: conduitTheme, - materialTheme: materialTheme, - code: code, - ) - : _buildUnsupportedMermaidContainer( - conduitTheme: conduitTheme, - codeColor: codeColor, - code: code, - ); - } else { - content = _buildCodeContainer( - context: context, - conduitTheme: conduitTheme, - codeColor: codeColor, - code: code, - language: language, - enableHighlight: flags.enableSyntaxHighlighting, - ); - } - - return CodeBlockWrapper( - code: code, - language: language, - theme: conduitTheme, - closed: closed, - child: content, - ); - }, - orderedListBuilder: orderedListBuilder, - unOrderedListBuilder: unOrderedListBuilder, - tableBuilder: tableBuilder, - checkboxTheme: checkboxTheme, - radioTheme: radioTheme, + styleSheet: styleSheet, + imageBuilder: (uri, title, alt) => _buildImage(context, uri), + linkColor: materialTheme.colorScheme.primary, + linkHoverColor: materialTheme.colorScheme.primary.withValues(alpha: 0.8), ); } - static Widget _buildCodeContainer({ - required BuildContext context, - required ConduitThemeExtension conduitTheme, - required Color codeColor, - required String code, - required String? language, - required bool enableHighlight, - }) { - final textStyle = AppTypography.codeStyle.copyWith( - color: conduitTheme.codeText, - height: 1.55, - fontSize: 13, - ); - - final highlightLanguage = _normalizeLanguage(language); - final canHighlight = enableHighlight && highlightLanguage != null; - - final Widget baseChild; - if (canHighlight) { - final highlightTheme = _transparentHighlightTheme( - atomOneDarkReasonableTheme, - ); - baseChild = HighlightView( - code, - language: highlightLanguage, - theme: highlightTheme, - padding: EdgeInsets.zero, - textStyle: textStyle, - ); - } else { - baseChild = SelectableText( - code, - maxLines: null, - textAlign: TextAlign.left, - textDirection: TextDirection.ltr, - textWidthBasis: TextWidthBasis.parent, - style: textStyle, - ); + static Widget _buildImage(BuildContext context, Uri uri) { + final theme = context.conduitTheme; + if (uri.scheme == 'data') { + return _buildBase64Image(uri.toString(), context, theme); } - return baseChild; - } - - static Widget _buildMermaidContainer({ - required BuildContext context, - required ConduitThemeExtension conduitTheme, - required ThemeData materialTheme, - required String code, - }) { - final tokens = context.colorTokens; - return SizedBox( - 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 ConduitThemeExtension conduitTheme, - required Color codeColor, - required String code, - }) { - final textStyle = AppTypography.bodySmallStyle.copyWith( - color: conduitTheme.codeText.withValues(alpha: 0.7), - ); - - return 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.code?.color ?? codeColor, - ), - ), - ], - ); - } - - static Map _transparentHighlightTheme( - Map base, - ) { - final themed = Map.from(base); - final root = base['root']; - themed['root'] = (root ?? const TextStyle()).copyWith( - backgroundColor: Colors.transparent, - ); - return themed; - } - - static String? _normalizeLanguage(String? lang) { - if (lang == null || lang.trim().isEmpty) { - return null; - } - final value = lang.trim().toLowerCase(); - switch (value) { - case 'js': - case 'javascript': - return 'javascript'; - case 'ts': - case 'typescript': - return 'typescript'; - case 'sh': - case 'zsh': - case 'bash': - case 'shell': - return 'bash'; - case 'yml': - return 'yaml'; - case 'py': - case 'python': - return 'python'; - case 'rb': - case 'ruby': - return 'ruby'; - case 'kt': - case 'kotlin': - return 'kotlin'; - case 'java': - return 'java'; - case 'c#': - case 'cs': - case 'csharp': - return 'cs'; - case 'objc': - case 'objectivec': - return 'objectivec'; - case 'swift': - return 'swift'; - case 'go': - case 'golang': - return 'go'; - case 'php': - return 'php'; - case 'dart': - return 'dart'; - case 'json': - return 'json'; - case 'html': - return 'xml'; - case 'md': - case 'markdown': - return 'markdown'; - case 'sql': - return 'sql'; - default: - return value; + if (uri.scheme.isEmpty || uri.scheme == 'http' || uri.scheme == 'https') { + return _buildNetworkImage(uri.toString(), context, theme); } + + return _buildImageError(context, theme); } - static Widget buildBase64Image( + static Widget _buildBase64Image( String dataUrl, BuildContext context, ConduitThemeExtension theme, @@ -615,7 +134,7 @@ class ConduitMarkdownConfig { try { final commaIndex = dataUrl.indexOf(','); if (commaIndex == -1) { - throw Exception('Invalid data URL format'); + throw const FormatException('Invalid data URL format'); } final base64String = dataUrl.substring(commaIndex + 1); @@ -623,7 +142,7 @@ class ConduitMarkdownConfig { return Container( margin: const EdgeInsets.symmetric(vertical: Spacing.sm), - constraints: const BoxConstraints(maxWidth: 500, maxHeight: 500), + constraints: const BoxConstraints(maxWidth: 480, maxHeight: 480), child: ClipRRect( borderRadius: BorderRadius.circular(AppBorderRadius.md), child: Image.memory( @@ -635,19 +154,19 @@ class ConduitMarkdownConfig { ), ), ); - } catch (e) { + } catch (_) { return _buildImageError(context, theme); } } - static Widget buildNetworkImage( + static Widget _buildNetworkImage( String url, BuildContext context, ConduitThemeExtension theme, ) { return CachedNetworkImage( imageUrl: url, - placeholder: (context, url) => Container( + placeholder: (context, _) => Container( height: 200, decoration: BoxDecoration( color: theme.surfaceBackground.withValues(alpha: 0.5), @@ -669,332 +188,18 @@ class ConduitMarkdownConfig { ConduitThemeExtension theme, ) { return Container( - height: 100, + height: 120, decoration: BoxDecoration( color: theme.surfaceBackground.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(AppBorderRadius.md), border: Border.all( - color: theme.error.withValues(alpha: 0.3), - width: BorderWidth.thin, + color: theme.cardBorder.withValues(alpha: 0.4), + width: BorderWidth.micro, ), ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.broken_image_outlined, color: theme.error, size: 32), - const SizedBox(height: Spacing.xs), - Text( - AppLocalizations.of(context)!.failedToLoadImage(''), - style: TextStyle(color: theme.error, fontSize: 12), - ), - ], + child: Center( + child: Icon(Icons.broken_image_outlined, color: theme.iconSecondary), ), ); } } - -class CodeBlockWrapper extends StatefulWidget { - const CodeBlockWrapper({ - super.key, - required this.child, - required this.code, - this.language, - required this.theme, - required this.closed, - }); - - final Widget child; - final String code; - final String? language; - final ConduitThemeExtension theme; - final bool closed; - - @override - State createState() => _CodeBlockWrapperState(); -} - -class _CodeBlockWrapperState extends State { - bool _copied = false; - Timer? _resetTimer; - - @override - void dispose() { - _resetTimer?.cancel(); - super.dispose(); - } - - Future _handleCopy() async { - if (!widget.closed || widget.code.trim().isEmpty) { - return; - } - - await Clipboard.setData(ClipboardData(text: widget.code)); - setState(() { - _copied = true; - }); - - _resetTimer?.cancel(); - _resetTimer = Timer(const Duration(seconds: 2), () { - if (!mounted) { - return; - } - setState(() { - _copied = false; - }); - }); - } - - @override - Widget build(BuildContext context) { - final conduitTheme = widget.theme; - final canCopy = widget.closed && widget.code.trim().isNotEmpty; - final icon = _copied - ? Icons.check - : canCopy - ? Icons.copy - : Icons.hourglass_empty; - - final background = conduitTheme.codeBackground; - final borderColor = conduitTheme.codeBorder.withValues(alpha: 0.6); - final headerColor = conduitTheme.codeAccent.withValues(alpha: 0.85); - - final languageLabel = (widget.language?.isNotEmpty ?? false) - ? widget.language! - : 'code'; - - return Container( - width: double.infinity, - margin: const EdgeInsets.symmetric(vertical: Spacing.xs), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(AppBorderRadius.md), - boxShadow: ConduitShadows.medium(context), - border: Border.all(color: borderColor, width: BorderWidth.micro), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(AppBorderRadius.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - Container( - color: headerColor, - padding: const EdgeInsets.symmetric( - horizontal: Spacing.sm, - vertical: Spacing.xs, - ), - child: Row( - children: [ - Text( - languageLabel, - style: AppTypography.bodySmallStyle.copyWith( - color: conduitTheme.codeText.withValues(alpha: 0.85), - fontFamily: AppTypography.monospaceFontFamily, - ), - ), - const Spacer(), - Tooltip( - message: canCopy - ? (_copied - ? 'Copied' - : MaterialLocalizations.of( - context, - ).copyButtonLabel) - : 'Copy available after generation completes', - child: IconButton( - onPressed: canCopy ? _handleCopy : null, - icon: Icon(icon, size: IconSize.sm), - color: canCopy - ? conduitTheme.codeText - : conduitTheme.codeText.withValues(alpha: 0.5), - visualDensity: VisualDensity.compact, - padding: const EdgeInsets.all(Spacing.xs), - style: IconButton.styleFrom( - backgroundColor: conduitTheme.codeText.withValues( - alpha: canCopy ? 0.08 : 0.04, - ), - disabledBackgroundColor: conduitTheme.codeText - .withValues(alpha: 0.03), - ), - ), - ), - ], - ), - ), - Container( - color: background, - padding: const EdgeInsets.all(Spacing.sm), - child: DefaultTextStyle.merge( - style: AppTypography.codeStyle.copyWith( - color: conduitTheme.codeText, - ), - child: widget.child, - ), - ), - ], - ), - ), - ); - } -} - -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 encoded = jsonEncode(code); - final primary = _toHex(widget.tokens.brandTone60); - final secondary = _toHex(widget.tokens.accentTeal60); - final background = _toHex(widget.tokens.codeBackground); - final onBackground = _toHex(widget.tokens.codeText); - final lineColor = _toHex(widget.tokens.codeAccent); - final errorColor = _toHex(widget.tokens.statusError60); - - return ''' - - - - - - - - - -
- - - -'''; - } - - String _toHex(Color color) { - final value = color.toARGB32(); - return '#' - '${((value >> 16) & 0xFF).toRadixString(16).padLeft(2, '0')}' - '${((value >> 8) & 0xFF).toRadixString(16).padLeft(2, '0')}' - '${(value & 0xFF).toRadixString(16).padLeft(2, '0')}' - .toUpperCase(); - } -} diff --git a/lib/shared/widgets/markdown/streaming_markdown_widget.dart b/lib/shared/widgets/markdown/streaming_markdown_widget.dart index 523b196..5546c1b 100644 --- a/lib/shared/widgets/markdown/streaming_markdown_widget.dart +++ b/lib/shared/widgets/markdown/streaming_markdown_widget.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:gpt_markdown/gpt_markdown.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import '../../theme/theme_extensions.dart'; import 'markdown_config.dart'; @@ -26,74 +26,30 @@ class StreamingMarkdownWidget extends StatelessWidget { } final normalized = ConduitMarkdownPreprocessor.normalize(content); - - const featureFlags = MarkdownFeatureFlags( - enableSyntaxHighlighting: true, - enableMermaid: true, + final markdownTheme = ConduitMarkdownConfig.resolve(context); + final markdownBody = MarkdownBody( + data: normalized, + styleSheet: markdownTheme.styleSheet, + selectable: false, + imageBuilder: markdownTheme.imageBuilder, + onTapLink: (text, href, title) { + final target = href ?? ''; + if (target.isEmpty) { + return; + } + final resolvedTitle = title.isNotEmpty ? title : text; + onTapLink?.call(target, resolvedTitle); + }, ); - final markdownTheme = ConduitMarkdownConfig.resolve( - context, - flags: featureFlags, - ); - final textScaler = MediaQuery.maybeOf(context)?.textScaler; - - final themedControls = Theme.of(context).copyWith( - checkboxTheme: markdownTheme.checkboxTheme, - radioTheme: markdownTheme.radioTheme, - ); - - return GptMarkdownTheme( - gptThemeData: markdownTheme.themeData, + return SelectionArea( child: Theme( - data: themedControls, - child: SelectionArea( - child: GptMarkdown( - normalized, - style: markdownTheme.textStyle, - followLinkColor: markdownTheme.followLinkColor, - textDirection: Directionality.of(context), - textScaler: textScaler, - onLinkTap: onTapLink, - codeBuilder: markdownTheme.codeBuilder, - imageBuilder: markdownTheme.imageBuilder, - orderedListBuilder: markdownTheme.orderedListBuilder, - unOrderedListBuilder: markdownTheme.unOrderedListBuilder, - tableBuilder: markdownTheme.tableBuilder, - useDollarSignsForLatex: true, - highlightBuilder: (highlightContext, inline, baseStyle) { - final softened = ConduitMarkdownPreprocessor.softenInlineCode( - inline, - ); - final theme = highlightContext.conduitTheme; - final base = baseStyle; - final fontSize = (base.fontSize ?? 13).clamp(11, 15).toDouble(); - return Container( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.xs, - vertical: Spacing.xxs, - ), - decoration: BoxDecoration( - color: theme.surfaceBackground.withValues(alpha: 0.55), - borderRadius: BorderRadius.circular(AppBorderRadius.xs), - border: Border.all( - color: theme.cardBorder.withValues(alpha: 0.2), - width: BorderWidth.micro, - ), - ), - child: Text( - softened, - style: base.copyWith( - fontFamily: AppTypography.monospaceFontFamily, - fontSize: fontSize, - height: 1.35, - color: theme.code?.color ?? theme.textSecondary, - ), - ), - ); - }, + data: Theme.of(context).copyWith( + textSelectionTheme: TextSelectionThemeData( + cursorColor: context.conduitTheme.buttonPrimary, ), ), + child: markdownBody, ), ); } diff --git a/pubspec.lock b/pubspec.lock index bef7cca..4d3f17f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -451,14 +451,14 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_math_fork: - dependency: transitive + flutter_markdown_plus: + dependency: "direct main" description: - name: flutter_math_fork - sha256: "6d5f2f1aa57ae539ffb0a04bb39d2da67af74601d685a161aff7ce5bda5fa407" + name: flutter_markdown_plus + sha256: "7f349c075157816da399216a4127096108fd08e1ac931e34e72899281db4113c" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "1.0.5" flutter_native_splash: dependency: "direct dev" description: @@ -539,14 +539,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.3" - flutter_svg: - dependency: transitive - description: - name: flutter_svg - sha256: b9c2ad5872518a27507ab432d1fb97e8813b05f0fc693f9d40fad06d073e0678 - url: "https://pub.dev" - source: hosted - version: "2.2.1" flutter_test: dependency: "direct dev" description: flutter @@ -605,14 +597,6 @@ packages: url: "https://pub.dev" source: hosted version: "14.8.1" - gpt_markdown: - dependency: "direct main" - description: - name: gpt_markdown - sha256: "8174983f2ed7d8576d25810913e3afe3f8ffdaa3172c0c823b7cfc289b67f380" - url: "https://pub.dev" - source: hosted - version: "1.1.4" graphs: dependency: transitive description: @@ -845,6 +829,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + markdown: + dependency: transitive + description: + name: markdown + sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" + url: "https://pub.dev" + source: hosted + version: "7.3.0" matcher: dependency: transitive description: @@ -885,14 +877,6 @@ packages: url: "https://pub.dev" source: hosted version: "5.5.0" - nested: - dependency: transitive - description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" - url: "https://pub.dev" - source: hosted - version: "1.0.0" node_preamble: dependency: transitive description: @@ -941,14 +925,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" - path_parsing: - dependency: transitive - description: - name: path_parsing - sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" - url: "https://pub.dev" - source: hosted - version: "1.1.0" path_provider: dependency: "direct main" description: @@ -1037,14 +1013,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.3" - provider: - dependency: transitive - description: - name: provider - sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" - url: "https://pub.dev" - source: hosted - version: "6.1.5+1" pub_semver: dependency: transitive description: @@ -1530,14 +1498,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" - tuple: - dependency: transitive - description: - name: tuple - sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 - url: "https://pub.dev" - source: hosted - version: "2.0.2" typed_data: dependency: transitive description: @@ -1626,30 +1586,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.1" - vector_graphics: - dependency: transitive - description: - name: vector_graphics - sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 - url: "https://pub.dev" - source: hosted - version: "1.1.19" - vector_graphics_codec: - dependency: transitive - description: - name: vector_graphics_codec - sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" - url: "https://pub.dev" - source: hosted - version: "1.1.13" - vector_graphics_compiler: - dependency: transitive - description: - name: vector_graphics_compiler - sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc - url: "https://pub.dev" - source: hosted - version: "1.1.19" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a13367d..713e09e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,7 +30,7 @@ dependencies: shared_preferences: ^2.3.2 # UI Components - GPT Markdown - gpt_markdown: ^1.1.4 + flutter_markdown_plus: ^1.0.3 cached_network_image: ^3.3.1 flutter_highlight: ^0.7.0 webview_flutter: ^4.7.0