From 0081d56703053435c3152eddeb692381492979db Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Thu, 2 Oct 2025 14:41:17 +0530 Subject: [PATCH] refactor: optimize renderer --- .../widgets/assistant_message_widget.dart | 29 +- .../widgets/markdown/markdown_config.dart | 539 +++++++++++++++--- .../markdown/streaming_markdown_widget.dart | 78 ++- pubspec.lock | 8 + pubspec.yaml | 1 + 5 files changed, 579 insertions(+), 76 deletions(-) diff --git a/lib/features/chat/widgets/assistant_message_widget.dart b/lib/features/chat/widgets/assistant_message_widget.dart index 8f65dc0..f0d6434 100644 --- a/lib/features/chat/widgets/assistant_message_widget.dart +++ b/lib/features/chat/widgets/assistant_message_widget.dart @@ -617,8 +617,7 @@ class _AssistantMessageWidgetState extends ConsumerState widget.message.files!.isNotEmpty) ...[ _buildFilesFromArray(), const SizedBox(height: Spacing.md), - ] else if (widget.message.attachmentIds != null && - widget.message.attachmentIds!.isNotEmpty) ...[ + ] else if (_shouldShowAttachmentGallery) ...[ _buildAttachmentItems(), const SizedBox(height: Spacing.md), ], @@ -809,6 +808,32 @@ class _AssistantMessageWidgetState extends ConsumerState return content; } + bool get _shouldShowAttachmentGallery { + final attachments = widget.message.attachmentIds; + if (attachments == null || attachments.isEmpty) { + return false; + } + + final body = widget.message.content; + if (body.trim().isEmpty) { + return true; + } + + // Only render the gallery when attachments are not already rendered inline. + final hasNonInline = attachments.any((id) { + if (id.startsWith('data:image/')) { + return !body.contains(id); + } + if (id.startsWith('http')) { + return !body.contains(id); + } + // Non-image attachments should still render in the gallery. + return true; + }); + + return hasNonInline; + } + Widget _buildAttachmentItems() { if (widget.message.attachmentIds == null || widget.message.attachmentIds!.isEmpty) { diff --git a/lib/shared/widgets/markdown/markdown_config.dart b/lib/shared/widgets/markdown/markdown_config.dart index 9879383..285ea47 100644 --- a/lib/shared/widgets/markdown/markdown_config.dart +++ b/lib/shared/widgets/markdown/markdown_config.dart @@ -1,21 +1,58 @@ +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, ImageBuilder; + show CodeBlockBuilder, GptMarkdownConfig, ImageBuilder; import 'package:gpt_markdown/gpt_markdown.dart'; - -import 'package:conduit/l10n/app_localizations.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, }); @@ -23,6 +60,8 @@ class ConduitMarkdownTheme { final GptMarkdownThemeData themeData; final ImageBuilder imageBuilder; final CodeBlockBuilder codeBuilder; + final List blockComponents; + final List inlineComponents; final bool followLinkColor; } @@ -82,30 +121,22 @@ class ConduitMarkdownConfig { color: conduitTheme.code?.color ?? codeColor, ); - final container = 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(code, style: textStyle), + final container = ConduitCodeView( + code: code, + language: name.trim().isEmpty ? null : name.trim(), + baseStyle: textStyle, + conduitTheme: conduitTheme, ); - final language = name.trim().isEmpty ? null : name.trim(); - return CodeBlockWrapper( code: code, - language: language, + language: name.trim().isEmpty ? null : name.trim(), theme: conduitTheme, child: container, ); }, + blockComponents: ConduitMarkdownRegistry.composeBlockComponents(), + inlineComponents: ConduitMarkdownRegistry.composeInlineComponents(), ); } @@ -123,20 +154,7 @@ class ConduitMarkdownConfig { final base64String = dataUrl.substring(commaIndex + 1); final imageBytes = base64.decode(base64String); - return Container( - margin: const EdgeInsets.symmetric(vertical: Spacing.sm), - constraints: const BoxConstraints(maxWidth: 500, maxHeight: 500), - child: ClipRRect( - borderRadius: BorderRadius.circular(AppBorderRadius.md), - child: Image.memory( - imageBytes, - fit: BoxFit.contain, - errorBuilder: (context, error, stackTrace) { - return _buildImageError(context, theme); - }, - ), - ), - ); + return ConduitMarkdownImage.memory(bytes: imageBytes, theme: theme); } catch (e) { return _buildImageError(context, theme); } @@ -147,51 +165,14 @@ class ConduitMarkdownConfig { BuildContext context, ConduitThemeExtension theme, ) { - return CachedNetworkImage( - imageUrl: url, - 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) => _buildImageError(context, theme), - ); + return ConduitMarkdownImage.network(url: url, theme: theme); } static Widget _buildImageError( BuildContext context, ConduitThemeExtension theme, ) { - return Container( - height: 100, - 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, - ), - ), - 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), - ), - ], - ), - ); + return const SizedBox.shrink(); } } @@ -222,7 +203,8 @@ class CodeBlockWrapper extends StatelessWidget { child: InkWell( borderRadius: BorderRadius.circular(AppBorderRadius.sm), onTap: () { - // Copy implementation provided by higher level clipboard service. + Clipboard.setData(ClipboardData(text: code)); + _showCopiedToast(context); }, child: Container( padding: const EdgeInsets.all(Spacing.xs), @@ -265,3 +247,414 @@ class CodeBlockWrapper extends StatelessWidget { ); } } + +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), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/shared/widgets/markdown/streaming_markdown_widget.dart b/lib/shared/widgets/markdown/streaming_markdown_widget.dart index e4aec70..840fa56 100644 --- a/lib/shared/widgets/markdown/streaming_markdown_widget.dart +++ b/lib/shared/widgets/markdown/streaming_markdown_widget.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:gpt_markdown/gpt_markdown.dart'; @@ -5,7 +7,7 @@ import 'markdown_config.dart'; typedef MarkdownLinkTapCallback = void Function(String url, String title); -class StreamingMarkdownWidget extends StatelessWidget { +class StreamingMarkdownWidget extends StatefulWidget { const StreamingMarkdownWidget({ super.key, required this.content, @@ -17,6 +19,78 @@ class StreamingMarkdownWidget extends StatelessWidget { final bool isStreaming; final MarkdownLinkTapCallback? onTapLink; + @override + State createState() => + _StreamingMarkdownWidgetState(); +} + +class _StreamingMarkdownWidgetState extends State { + late final ValueNotifier _contentNotifier; + late String _currentContent; + Timer? _debounce; + String? _pendingContent; + + @override + void initState() { + super.initState(); + _currentContent = widget.content; + _contentNotifier = ValueNotifier(widget.content); + } + + @override + void didUpdateWidget(covariant StreamingMarkdownWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.content == _currentContent) { + return; + } + + // Coalesce rapid streaming updates so we only rebuild markdown a few times. + _pendingContent = widget.content; + _debounce ??= Timer(const Duration(milliseconds: 45), () { + if (!mounted) { + return; + } + final next = _pendingContent ?? widget.content; + _currentContent = next; + _contentNotifier.value = next; + _pendingContent = null; + _debounce = null; + }); + } + + @override + void dispose() { + _debounce?.cancel(); + _contentNotifier.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: _contentNotifier, + builder: (context, value, _) { + return _StreamingMarkdownContent( + content: value, + isStreaming: widget.isStreaming, + onTapLink: widget.onTapLink, + ); + }, + ); + } +} + +class _StreamingMarkdownContent extends StatelessWidget { + const _StreamingMarkdownContent({ + required this.content, + required this.isStreaming, + required this.onTapLink, + }); + + final String content; + final bool isStreaming; + final MarkdownLinkTapCallback? onTapLink; + @override Widget build(BuildContext context) { final markdownTheme = ConduitMarkdownConfig.resolve(context); @@ -38,6 +112,8 @@ class StreamingMarkdownWidget extends StatelessWidget { onLinkTap: onTapLink, codeBuilder: markdownTheme.codeBuilder, imageBuilder: markdownTheme.imageBuilder, + components: markdownTheme.blockComponents, + inlineComponents: markdownTheme.inlineComponents, ), ); } diff --git a/pubspec.lock b/pubspec.lock index c2c6aec..74d6dcb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -613,6 +613,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + highlight: + dependency: "direct main" + description: + name: highlight + sha256: "5353a83ffe3e3eca7df0abfb72dcf3fa66cc56b953728e7113ad4ad88497cf21" + url: "https://pub.dev" + source: hosted + version: "0.7.0" hive_ce: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 2dd709a..d7c7274 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -55,6 +55,7 @@ dependencies: package_info_plus: ^9.0.0 url_launcher: ^6.3.0 intl: ^0.20.2 + highlight: ^0.7.0 # Icons & Theming cupertino_icons: ^1.0.8