Revert "refactor: optimize renderer"

This reverts commit 0081d56703.
This commit is contained in:
cogwheel0
2025-10-02 15:21:44 +05:30
parent 42fa109086
commit 4b5d987a19
5 changed files with 76 additions and 579 deletions

View File

@@ -617,7 +617,8 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
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<AssistantMessageWidget>
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) {

View File

@@ -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<MarkdownComponent> _blockComponents = [
DetailsMarkdownComponent(),
];
static final List<MarkdownComponent> _inlineComponents = [];
static UnmodifiableListView<MarkdownComponent> get blockComponents =>
UnmodifiableListView(_blockComponents);
static UnmodifiableListView<MarkdownComponent> get inlineComponents =>
UnmodifiableListView(_inlineComponents);
static void registerBlockComponent(MarkdownComponent component) {
_blockComponents.add(component);
}
static void registerInlineComponent(MarkdownComponent component) {
_inlineComponents.add(component);
}
static List<MarkdownComponent> composeBlockComponents() {
return [..._blockComponents, ...MarkdownComponent.globalComponents];
}
static List<MarkdownComponent> 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<MarkdownComponent> blockComponents;
final List<MarkdownComponent> 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 <hl.Node>[],
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<TextSpan> _buildTextSpans(
List<hl.Node> 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 = <String, Color>{
'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<void>(
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"<details[^>]*>[\s\S]*?<\/details>";
@override
Widget build(BuildContext context, String text, GptMarkdownConfig config) {
final summaryMatch = RegExp(
r"<summary[^>]*>([\s\S]*?)<\/summary>",
dotAll: true,
multiLine: true,
).firstMatch(text);
final summary = summaryMatch?.group(1)?.trim() ?? 'Details';
var content = text
.replaceFirst(RegExp(r"<details[^>]*>"), '')
.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<ConduitDetailsBlock> createState() => _ConduitDetailsBlockState();
}
class _ConduitDetailsBlockState extends State<ConduitDetailsBlock> {
late bool _expanded;
late List<InlineSpan> _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),
),
),
],
),
),
);
}
}

View File

@@ -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<StreamingMarkdownWidget> createState() =>
_StreamingMarkdownWidgetState();
}
class _StreamingMarkdownWidgetState extends State<StreamingMarkdownWidget> {
late final ValueNotifier<String> _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<String>(
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,
),
);
}

View File

@@ -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:

View File

@@ -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