refactor: optimize renderer

This commit is contained in:
cogwheel0
2025-10-02 14:41:17 +05:30
parent 7a880b507c
commit 0081d56703
5 changed files with 579 additions and 76 deletions

View File

@@ -617,8 +617,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
widget.message.files!.isNotEmpty) ...[ widget.message.files!.isNotEmpty) ...[
_buildFilesFromArray(), _buildFilesFromArray(),
const SizedBox(height: Spacing.md), const SizedBox(height: Spacing.md),
] else if (widget.message.attachmentIds != null && ] else if (_shouldShowAttachmentGallery) ...[
widget.message.attachmentIds!.isNotEmpty) ...[
_buildAttachmentItems(), _buildAttachmentItems(),
const SizedBox(height: Spacing.md), const SizedBox(height: Spacing.md),
], ],
@@ -809,6 +808,32 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
return content; 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() { Widget _buildAttachmentItems() {
if (widget.message.attachmentIds == null || if (widget.message.attachmentIds == null ||
widget.message.attachmentIds!.isEmpty) { widget.message.attachmentIds!.isEmpty) {

View File

@@ -1,21 +1,58 @@
import 'dart:collection';
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show Clipboard, ClipboardData;
import 'package:gpt_markdown/custom_widgets/markdown_config.dart' 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:gpt_markdown/gpt_markdown.dart';
import 'package:highlight/highlight.dart' as hl;
import 'package:conduit/l10n/app_localizations.dart';
import '../../theme/theme_extensions.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 { class ConduitMarkdownTheme {
const ConduitMarkdownTheme({ const ConduitMarkdownTheme({
required this.textStyle, required this.textStyle,
required this.themeData, required this.themeData,
required this.imageBuilder, required this.imageBuilder,
required this.codeBuilder, required this.codeBuilder,
required this.blockComponents,
required this.inlineComponents,
this.followLinkColor = true, this.followLinkColor = true,
}); });
@@ -23,6 +60,8 @@ class ConduitMarkdownTheme {
final GptMarkdownThemeData themeData; final GptMarkdownThemeData themeData;
final ImageBuilder imageBuilder; final ImageBuilder imageBuilder;
final CodeBlockBuilder codeBuilder; final CodeBlockBuilder codeBuilder;
final List<MarkdownComponent> blockComponents;
final List<MarkdownComponent> inlineComponents;
final bool followLinkColor; final bool followLinkColor;
} }
@@ -82,30 +121,22 @@ class ConduitMarkdownConfig {
color: conduitTheme.code?.color ?? codeColor, color: conduitTheme.code?.color ?? codeColor,
); );
final container = Container( final container = ConduitCodeView(
width: double.infinity, code: code,
margin: const EdgeInsets.symmetric(vertical: Spacing.xs), language: name.trim().isEmpty ? null : name.trim(),
padding: const EdgeInsets.all(Spacing.sm), baseStyle: textStyle,
decoration: BoxDecoration( conduitTheme: conduitTheme,
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( return CodeBlockWrapper(
code: code, code: code,
language: language, language: name.trim().isEmpty ? null : name.trim(),
theme: conduitTheme, theme: conduitTheme,
child: container, child: container,
); );
}, },
blockComponents: ConduitMarkdownRegistry.composeBlockComponents(),
inlineComponents: ConduitMarkdownRegistry.composeInlineComponents(),
); );
} }
@@ -123,20 +154,7 @@ class ConduitMarkdownConfig {
final base64String = dataUrl.substring(commaIndex + 1); final base64String = dataUrl.substring(commaIndex + 1);
final imageBytes = base64.decode(base64String); final imageBytes = base64.decode(base64String);
return Container( return ConduitMarkdownImage.memory(bytes: imageBytes, theme: theme);
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) { } catch (e) {
return _buildImageError(context, theme); return _buildImageError(context, theme);
} }
@@ -147,51 +165,14 @@ class ConduitMarkdownConfig {
BuildContext context, BuildContext context,
ConduitThemeExtension theme, ConduitThemeExtension theme,
) { ) {
return CachedNetworkImage( return ConduitMarkdownImage.network(url: url, theme: theme);
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( static Widget _buildImageError(
BuildContext context, BuildContext context,
ConduitThemeExtension theme, ConduitThemeExtension theme,
) { ) {
return Container( return const SizedBox.shrink();
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),
),
],
),
);
} }
} }
@@ -222,7 +203,8 @@ class CodeBlockWrapper extends StatelessWidget {
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(AppBorderRadius.sm), borderRadius: BorderRadius.circular(AppBorderRadius.sm),
onTap: () { onTap: () {
// Copy implementation provided by higher level clipboard service. Clipboard.setData(ClipboardData(text: code));
_showCopiedToast(context);
}, },
child: Container( child: Container(
padding: const EdgeInsets.all(Spacing.xs), 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 <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,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gpt_markdown/gpt_markdown.dart'; import 'package:gpt_markdown/gpt_markdown.dart';
@@ -5,7 +7,7 @@ import 'markdown_config.dart';
typedef MarkdownLinkTapCallback = void Function(String url, String title); typedef MarkdownLinkTapCallback = void Function(String url, String title);
class StreamingMarkdownWidget extends StatelessWidget { class StreamingMarkdownWidget extends StatefulWidget {
const StreamingMarkdownWidget({ const StreamingMarkdownWidget({
super.key, super.key,
required this.content, required this.content,
@@ -17,6 +19,78 @@ class StreamingMarkdownWidget extends StatelessWidget {
final bool isStreaming; final bool isStreaming;
final MarkdownLinkTapCallback? onTapLink; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final markdownTheme = ConduitMarkdownConfig.resolve(context); final markdownTheme = ConduitMarkdownConfig.resolve(context);
@@ -38,6 +112,8 @@ class StreamingMarkdownWidget extends StatelessWidget {
onLinkTap: onTapLink, onLinkTap: onTapLink,
codeBuilder: markdownTheme.codeBuilder, codeBuilder: markdownTheme.codeBuilder,
imageBuilder: markdownTheme.imageBuilder, imageBuilder: markdownTheme.imageBuilder,
components: markdownTheme.blockComponents,
inlineComponents: markdownTheme.inlineComponents,
), ),
); );
} }

View File

@@ -613,6 +613,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.2" 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: hive_ce:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@@ -55,6 +55,7 @@ dependencies:
package_info_plus: ^9.0.0 package_info_plus: ^9.0.0
url_launcher: ^6.3.0 url_launcher: ^6.3.0
intl: ^0.20.2 intl: ^0.20.2
highlight: ^0.7.0
# Icons & Theming # Icons & Theming
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8