refactor: optimize renderer
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user