diff --git a/lib/features/chat/widgets/documentation_message_widget.dart b/lib/features/chat/widgets/documentation_message_widget.dart index 8891e12..724a15a 100644 --- a/lib/features/chat/widgets/documentation_message_widget.dart +++ b/lib/features/chat/widgets/documentation_message_widget.dart @@ -1,12 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/cupertino.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'dart:async'; -import 'package:gpt_markdown/gpt_markdown.dart'; import 'dart:io' show Platform; import '../../../shared/theme/theme_extensions.dart'; +import '../../../shared/widgets/markdown/streaming_markdown_widget.dart'; import '../../../core/utils/reasoning_parser.dart'; class DocumentationMessageWidget extends ConsumerStatefulWidget { @@ -409,175 +408,13 @@ class _DocumentationMessageWidgetState return const SizedBox.shrink(); } - final codeFence = RegExp( - r"```([\w\-\+\.#]*)\n([\s\S]*?)```", - multiLine: true, - ); - final widgets = []; - int lastIndex = 0; - for (final match in codeFence.allMatches(content)) { - if (match.start > lastIndex) { - final textSegment = content.substring(lastIndex, match.start); - widgets.add( - MediaQuery( - data: MediaQuery.of(context).copyWith(textScaler: const TextScaler.linear(1.0)), - child: GptMarkdown( - textSegment, - style: AppTypography.chatMessageStyle.copyWith( - color: context.conduitTheme.textPrimary, - ), - ), - ), - ); - } - - final language = match.group(1)?.trim().isEmpty == true - ? null - : match.group(1)!.trim(); - final code = match.group(2) ?? ''; - widgets.add(_buildCodeBlock(code, language)); - lastIndex = match.end; - } - - if (lastIndex < content.length) { - final tail = content.substring(lastIndex); - widgets.add( - MediaQuery( - data: MediaQuery.of(context).copyWith(textScaler: const TextScaler.linear(1.0)), - child: GptMarkdown( - tail, - style: AppTypography.chatMessageStyle.copyWith( - color: context.conduitTheme.textPrimary, - ), - ), - ), - ); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: widgets - .map( - (w) => Padding( - padding: const EdgeInsets.only(bottom: Spacing.xs), - child: w, - ), - ) - .toList(), + return StreamingMarkdownWidget( + staticContent: content, + isStreaming: widget.isStreaming, ); } - Widget _buildCodeBlock(String code, String? language) { - return Container( - decoration: BoxDecoration( - color: context.conduitTheme.surfaceBackground.withValues(alpha: 0.06), - borderRadius: BorderRadius.circular(AppBorderRadius.md), - border: Border.all( - color: context.conduitTheme.dividerColor.withValues(alpha: 0.7), - width: BorderWidth.thin, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.sm, - vertical: Spacing.xs, - ), - child: Row( - children: [ - Icon( - Platform.isIOS - ? CupertinoIcons.chevron_left_slash_chevron_right - : Icons.code, - size: 14, - color: context.conduitTheme.iconSecondary, - ), - const SizedBox(width: Spacing.xs), - Expanded( - child: Text( - language?.toUpperCase() ?? 'CODE', - style: TextStyle( - fontSize: AppTypography.labelSmall, - color: context.conduitTheme.textSecondary, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, - ), - ), - ), - GestureDetector( - onTap: () => _copyToClipboard(code), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.xs, - vertical: Spacing.xxs, - ), - decoration: BoxDecoration( - color: context.conduitTheme.surfaceBackground.withValues( - alpha: 0.2, - ), - borderRadius: BorderRadius.circular(AppBorderRadius.xs), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Platform.isIOS - ? CupertinoIcons.doc_on_clipboard - : Icons.copy, - size: 14, - color: context.conduitTheme.iconSecondary, - ), - const SizedBox(width: Spacing.xs), - Text( - 'Copy', - style: TextStyle( - fontSize: AppTypography.labelSmall, - color: context.conduitTheme.textSecondary, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ), - ], - ), - ), - Container( - padding: const EdgeInsets.all(Spacing.sm), - decoration: BoxDecoration( - borderRadius: const BorderRadius.vertical( - bottom: Radius.circular(AppBorderRadius.md), - ), - ), - child: SelectableText( - code.trimRight(), - style: TextStyle( - color: context.conduitTheme.textSecondary, - fontFamily: AppTypography.monospaceFontFamily, - fontSize: AppTypography.bodySmall, - height: 1.5, - ), - ), - ), - ], - ), - ); - } - void _copyToClipboard(String text) { - Clipboard.setData(ClipboardData(text: text)); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text('Code copied'), - backgroundColor: context.conduitTheme.buttonPrimary, - ), - ); - } - } // Removed lightweight streaming text; we now stream markdown with throttling diff --git a/lib/shared/widgets/markdown/markdown_config.dart b/lib/shared/widgets/markdown/markdown_config.dart new file mode 100644 index 0000000..0fb0473 --- /dev/null +++ b/lib/shared/widgets/markdown/markdown_config.dart @@ -0,0 +1,197 @@ +import 'package:flutter/material.dart'; +import 'package:markdown_widget/markdown_widget.dart'; +import 'package:flutter_highlight/themes/atom-one-dark.dart'; +import 'package:flutter_highlight/themes/atom-one-light.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:url_launcher/url_launcher_string.dart'; +import 'package:conduit/shared/theme/app_theme.dart'; +import 'package:conduit/shared/theme/theme_extensions.dart'; + +class ConduitMarkdownConfig { + static MarkdownConfig getConfig({ + required bool isDark, + required BuildContext context, + bool isStreaming = false, + }) { + final theme = context.conduitTheme; + + return (isDark ? MarkdownConfig.darkConfig : MarkdownConfig.defaultConfig).copy( + configs: [ + // Code block config + PreConfig( + theme: isDark ? atomOneDarkTheme : atomOneLightTheme, + decoration: BoxDecoration( + color: theme.surfaceBackground.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all( + color: theme.dividerColor.withValues(alpha: 0.7), + width: BorderWidth.thin, + ), + ), + padding: const EdgeInsets.all(Spacing.md), + textStyle: AppTypography.chatCodeStyle, + wrapper: (child, text, language) => CodeBlockWrapper( + code: text, + language: language, + theme: theme, + child: child, + ), + ), + + // Link config + LinkConfig( + style: TextStyle( + color: AppTheme.brandPrimary, + decoration: TextDecoration.underline, + ), + onTap: (url) async { + if (await canLaunchUrlString(url)) { + launchUrlString(url, mode: LaunchMode.inAppWebView); + } + }, + ), + + // Image config - optimized for mobile + ImgConfig( + builder: (url, attributes) => CachedNetworkImage( + imageUrl: url, + placeholder: (context, url) => Container( + height: 200, + color: theme.surfaceBackground, + child: Center( + child: CircularProgressIndicator( + color: AppTheme.brandPrimary, + ), + ), + ), + errorWidget: (context, url, error) => Container( + height: 100, + color: theme.surfaceBackground, + child: Center( + child: Icon( + Icons.broken_image, + color: theme.iconSecondary, + ), + ), + ), + ), + ), + + // Table config - mobile responsive + TableConfig( + wrapper: (table) => SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: table, + ), + ), + + // Paragraph config + PConfig( + textStyle: AppTypography.chatMessageStyle.copyWith( + color: theme.textPrimary, + ), + ), + + // Headers + H1Config( + style: AppTypography.headlineLargeStyle.copyWith( + color: theme.textPrimary, + ), + ), + H2Config( + style: AppTypography.headlineMediumStyle.copyWith( + color: theme.textPrimary, + ), + ), + H3Config( + style: AppTypography.headlineSmallStyle.copyWith( + color: theme.textPrimary, + ), + ), + + // Blockquote + BlockquoteConfig(), + + // Code inline + CodeConfig( + style: AppTypography.chatCodeStyle.copyWith( + color: theme.textPrimary, + backgroundColor: theme.surfaceBackground.withValues(alpha: 0.1), + ), + ), + ], + ); + } +} + +/// Custom wrapper for code blocks with copy functionality +class CodeBlockWrapper extends StatelessWidget { + final Widget child; + final String code; + final String? language; + final ConduitThemeExtension theme; + + const CodeBlockWrapper({ + super.key, + required this.child, + required this.code, + this.language, + required this.theme, + }); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + child, + Positioned( + top: 8, + right: 8, + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(AppBorderRadius.sm), + onTap: () { + // Copy code to clipboard + // Implementation depends on clipboard service + }, + child: Container( + padding: const EdgeInsets.all(Spacing.xs), + decoration: BoxDecoration( + color: theme.surfaceBackground.withValues(alpha: 0.8), + borderRadius: BorderRadius.circular(AppBorderRadius.sm), + ), + child: Icon( + Icons.copy, + size: IconSize.sm, + color: theme.iconSecondary, + ), + ), + ), + ), + ), + if (language != null) + Positioned( + top: 8, + left: 8, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.sm, + vertical: Spacing.xxs, + ), + decoration: BoxDecoration( + color: theme.surfaceBackground.withValues(alpha: 0.8), + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + ), + child: Text( + language!, + style: AppTypography.captionStyle.copyWith( + color: theme.textSecondary, + ), + ), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/shared/widgets/markdown/streaming_markdown_widget.dart b/lib/shared/widgets/markdown/streaming_markdown_widget.dart new file mode 100644 index 0000000..1cae9d4 --- /dev/null +++ b/lib/shared/widgets/markdown/streaming_markdown_widget.dart @@ -0,0 +1,200 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:markdown_widget/markdown_widget.dart'; +import 'package:conduit/shared/widgets/markdown/markdown_config.dart'; + +class StreamingMarkdownWidget extends StatefulWidget { + final Stream? contentStream; + final String? staticContent; + final bool isStreaming; + final ScrollController? scrollController; + final EdgeInsetsGeometry? padding; + + const StreamingMarkdownWidget({ + super.key, + this.contentStream, + this.staticContent, + required this.isStreaming, + this.scrollController, + this.padding, + }); + + @override + State createState() => _StreamingMarkdownWidgetState(); +} + +class _StreamingMarkdownWidgetState extends State { + final _buffer = StringBuffer(); + Timer? _debounceTimer; + String _renderedContent = ''; + StreamSubscription? _streamSubscription; + + @override + void initState() { + super.initState(); + if (widget.contentStream != null) { + _streamSubscription = widget.contentStream!.listen(_handleChunk); + } else if (widget.staticContent != null) { + _renderedContent = widget.staticContent!; + } + } + + void _handleChunk(String chunk) { + _buffer.write(chunk); + + // Debounce rendering for performance + _debounceTimer?.cancel(); + _debounceTimer = Timer(const Duration(milliseconds: 50), () { + if (mounted) { + setState(() { + _renderedContent = _fixIncompleteMarkdown(_buffer.toString()); + }); + } + }); + } + + String _fixIncompleteMarkdown(String content) { + // Auto-close unclosed code blocks for valid markdown during streaming + final fenceCount = '```'.allMatches(content).length; + if (fenceCount % 2 != 0) { + content += '\n```'; + } + + // Fix incomplete bold/italic markers + final boldCount = RegExp(r'\*\*').allMatches(content).length; + if (boldCount % 2 != 0) { + content += '**'; + } + + final italicCount = RegExp(r'(? closeBrackets) { + content += ']' * (openBrackets - closeBrackets); + } + + final openParens = '('.allMatches(content).length; + final closeParens = ')'.allMatches(content).length; + if (openParens > closeParens) { + content += ')' * (openParens - closeParens); + } + + return content; + } + + @override + void didUpdateWidget(StreamingMarkdownWidget oldWidget) { + super.didUpdateWidget(oldWidget); + + // Handle stream changes + if (widget.contentStream != oldWidget.contentStream) { + _streamSubscription?.cancel(); + if (widget.contentStream != null) { + _streamSubscription = widget.contentStream!.listen(_handleChunk); + } + } + + // Handle static content changes + if (widget.staticContent != oldWidget.staticContent) { + setState(() { + _renderedContent = widget.staticContent ?? ''; + }); + } + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final config = ConduitMarkdownConfig.getConfig( + isDark: isDark, + context: context, + isStreaming: widget.isStreaming, + ); + + if (_renderedContent.isEmpty) { + return const SizedBox.shrink(); + } + + if (widget.isStreaming && _renderedContent.isNotEmpty) { + // Use MarkdownBlock for streaming - it's optimized for live updates + return Container( + padding: widget.padding, + child: MarkdownBlock( + data: _renderedContent, + config: config, + selectable: true, + ), + ); + } else { + // Use MarkdownWidget for completed messages + // This provides better interactivity and selection + return MarkdownWidget( + data: _renderedContent, + config: config, + selectable: true, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: widget.padding, + ); + } + } + + @override + void dispose() { + _debounceTimer?.cancel(); + _streamSubscription?.cancel(); + super.dispose(); + } +} + +/// Extension to provide easy access to streaming markdown +extension StreamingMarkdownExtension on String { + Widget toMarkdown({ + required BuildContext context, + bool isStreaming = false, + EdgeInsetsGeometry? padding, + }) { + return StreamingMarkdownWidget( + staticContent: this, + isStreaming: isStreaming, + padding: padding, + ); + } +} + +/// Helper widget for displaying markdown with loading state +class MarkdownWithLoading extends StatelessWidget { + final String? content; + final bool isLoading; + final EdgeInsetsGeometry? padding; + + const MarkdownWithLoading({ + super.key, + this.content, + required this.isLoading, + this.padding, + }); + + @override + Widget build(BuildContext context) { + if (isLoading && (content == null || content!.isEmpty)) { + return Container( + padding: padding ?? const EdgeInsets.all(16), + child: const Center( + child: CircularProgressIndicator(), + ), + ); + } + + return StreamingMarkdownWidget( + staticContent: content ?? '', + isStreaming: isLoading, + padding: padding, + ); + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 9f96cf2..8896b2f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -350,6 +350,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.1" + flutter_highlight: + dependency: "direct main" + description: + name: flutter_highlight + sha256: "7b96333867aa07e122e245c033b8ad622e4e3a42a1a2372cbb098a2541d8782c" + url: "https://pub.dev" + source: hosted + version: "0.7.0" flutter_lints: dependency: "direct dev" description: @@ -358,14 +366,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" - flutter_math_fork: - dependency: transitive - description: - name: flutter_math_fork - sha256: "6d5f2f1aa57ae539ffb0a04bb39d2da67af74601d685a161aff7ce5bda5fa407" - url: "https://pub.dev" - source: hosted - version: "0.7.4" flutter_native_splash: dependency: "direct dev" description: @@ -446,14 +446,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.3" - flutter_svg: - dependency: transitive - description: - name: flutter_svg - sha256: cd57f7969b4679317c17af6fd16ee233c1e60a82ed209d8a475c54fd6fd6f845 - url: "https://pub.dev" - source: hosted - version: "2.2.0" flutter_test: dependency: "direct dev" description: flutter @@ -504,14 +496,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.2.1" - gpt_markdown: - dependency: "direct main" - description: - name: gpt_markdown - sha256: "68d5337c8a00fc03a37dbddf84a6fd90401c30e99b6baf497ef9522a81fc34ee" - url: "https://pub.dev" - source: hosted - version: "1.1.2" graphs: dependency: transitive description: @@ -520,6 +504,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" html: dependency: transitive description: @@ -696,6 +688,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + markdown: + dependency: transitive + description: + name: markdown + sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" + url: "https://pub.dev" + source: hosted + version: "7.3.0" + markdown_widget: + dependency: "direct main" + description: + name: markdown_widget + sha256: b52c13d3ee4d0e60c812e15b0593f142a3b8a2003cde1babb271d001a1dbdc1c + url: "https://pub.dev" + source: hosted + version: "2.3.2+8" matcher: dependency: transitive description: @@ -728,14 +736,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" - nested: - dependency: transitive - description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" - url: "https://pub.dev" - source: hosted - version: "1.0.0" octo_image: dependency: transitive description: @@ -776,14 +776,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" - path_parsing: - dependency: transitive - description: - name: path_parsing - sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" - url: "https://pub.dev" - source: hosted - version: "1.1.0" path_provider: dependency: "direct main" description: @@ -872,14 +864,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.3" - provider: - dependency: transitive - description: - name: provider - sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84" - url: "https://pub.dev" - source: hosted - version: "6.1.5" pub_semver: dependency: transitive description: @@ -976,6 +960,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + scroll_to_index: + dependency: transitive + description: + name: scroll_to_index + sha256: b707546e7500d9f070d63e5acf74fd437ec7eeeb68d3412ef7b0afada0b4f176 + url: "https://pub.dev" + source: hosted + version: "3.0.1" shared_preferences: dependency: "direct main" description: @@ -1197,14 +1189,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" - tuple: - dependency: transitive - description: - name: tuple - sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 - url: "https://pub.dev" - source: hosted - version: "2.0.2" typed_data: dependency: transitive description: @@ -1293,30 +1277,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.1" - vector_graphics: - dependency: transitive - description: - name: vector_graphics - sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 - url: "https://pub.dev" - source: hosted - version: "1.1.19" - vector_graphics_codec: - dependency: transitive - description: - name: vector_graphics_codec - sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" - url: "https://pub.dev" - source: hosted - version: "1.1.13" - vector_graphics_compiler: - dependency: transitive - description: - name: vector_graphics_compiler - sha256: "557a315b7d2a6dbb0aaaff84d857967ce6bdc96a63dc6ee2a57ce5a6ee5d3331" - url: "https://pub.dev" - source: hosted - version: "1.1.17" vector_math: dependency: transitive description: @@ -1325,6 +1285,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + visibility_detector: + dependency: transitive + description: + name: visibility_detector + sha256: dd5cc11e13494f432d15939c3aa8ae76844c42b723398643ce9addb88a5ed420 + url: "https://pub.dev" + source: hosted + version: "0.4.0+2" vm_service: dependency: transitive description: @@ -1415,4 +1383,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.8.0 <4.0.0" - flutter: ">=3.29.0" + flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml index aaffac9..d37df04 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,7 +23,9 @@ dependencies: shared_preferences: ^2.3.2 # UI Components - Enhanced Markdown - gpt_markdown: ^1.1.2 + markdown_widget: ^2.3.2+8 + flutter_highlight: ^0.7.0 + highlight: ^0.7.0 cached_network_image: ^3.3.1