From 4b5d987a1993c41e36102dc121504b8c6480e6f1 Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Thu, 2 Oct 2025 15:21:44 +0530 Subject: [PATCH] Revert "refactor: optimize renderer" This reverts commit 0081d56703053435c3152eddeb692381492979db. --- .../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, 76 insertions(+), 579 deletions(-) diff --git a/lib/features/chat/widgets/assistant_message_widget.dart b/lib/features/chat/widgets/assistant_message_widget.dart index f0d6434..8f65dc0 100644 --- a/lib/features/chat/widgets/assistant_message_widget.dart +++ b/lib/features/chat/widgets/assistant_message_widget.dart @@ -617,7 +617,8 @@ class _AssistantMessageWidgetState extends ConsumerState widget.message.files!.isNotEmpty) ...[ _buildFilesFromArray(), const SizedBox(height: Spacing.md), - ] else if (_shouldShowAttachmentGallery) ...[ + ] else if (widget.message.attachmentIds != null && + widget.message.attachmentIds!.isNotEmpty) ...[ _buildAttachmentItems(), const SizedBox(height: Spacing.md), ], @@ -808,32 +809,6 @@ 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 285ea47..9879383 100644 --- a/lib/shared/widgets/markdown/markdown_config.dart +++ b/lib/shared/widgets/markdown/markdown_config.dart @@ -1,58 +1,21 @@ -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; + show CodeBlockBuilder, ImageBuilder; import 'package:gpt_markdown/gpt_markdown.dart'; -import 'package:highlight/highlight.dart' as hl; + +import 'package:conduit/l10n/app_localizations.dart'; 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, }); @@ -60,8 +23,6 @@ class ConduitMarkdownTheme { final GptMarkdownThemeData themeData; final ImageBuilder imageBuilder; final CodeBlockBuilder codeBuilder; - final List blockComponents; - final List inlineComponents; final bool followLinkColor; } @@ -121,22 +82,30 @@ class ConduitMarkdownConfig { color: conduitTheme.code?.color ?? codeColor, ); - final container = ConduitCodeView( - code: code, - language: name.trim().isEmpty ? null : name.trim(), - baseStyle: textStyle, - conduitTheme: conduitTheme, + 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 language = name.trim().isEmpty ? null : name.trim(); + return CodeBlockWrapper( code: code, - language: name.trim().isEmpty ? null : name.trim(), + language: language, theme: conduitTheme, child: container, ); }, - blockComponents: ConduitMarkdownRegistry.composeBlockComponents(), - inlineComponents: ConduitMarkdownRegistry.composeInlineComponents(), ); } @@ -154,7 +123,20 @@ class ConduitMarkdownConfig { final base64String = dataUrl.substring(commaIndex + 1); final imageBytes = base64.decode(base64String); - return ConduitMarkdownImage.memory(bytes: imageBytes, theme: theme); + 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); + }, + ), + ), + ); } catch (e) { return _buildImageError(context, theme); } @@ -165,14 +147,51 @@ class ConduitMarkdownConfig { BuildContext context, ConduitThemeExtension theme, ) { - return ConduitMarkdownImage.network(url: url, theme: 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), + ); } static Widget _buildImageError( BuildContext context, ConduitThemeExtension theme, ) { - return const SizedBox.shrink(); + 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), + ), + ], + ), + ); } } @@ -203,8 +222,7 @@ class CodeBlockWrapper extends StatelessWidget { child: InkWell( borderRadius: BorderRadius.circular(AppBorderRadius.sm), onTap: () { - Clipboard.setData(ClipboardData(text: code)); - _showCopiedToast(context); + // Copy implementation provided by higher level clipboard service. }, child: Container( padding: const EdgeInsets.all(Spacing.xs), @@ -247,414 +265,3 @@ 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 840fa56..e4aec70 100644 --- a/lib/shared/widgets/markdown/streaming_markdown_widget.dart +++ b/lib/shared/widgets/markdown/streaming_markdown_widget.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:gpt_markdown/gpt_markdown.dart'; @@ -7,7 +5,7 @@ import 'markdown_config.dart'; typedef MarkdownLinkTapCallback = void Function(String url, String title); -class StreamingMarkdownWidget extends StatefulWidget { +class StreamingMarkdownWidget extends StatelessWidget { const StreamingMarkdownWidget({ super.key, required this.content, @@ -19,78 +17,6 @@ class StreamingMarkdownWidget extends StatefulWidget { 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); @@ -112,8 +38,6 @@ class _StreamingMarkdownContent 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 74d6dcb..c2c6aec 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -613,14 +613,6 @@ 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 d7c7274..2dd709a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -55,7 +55,6 @@ 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