diff --git a/lib/dashbot/widgets/chat_bubble.dart b/lib/dashbot/widgets/chat_bubble.dart new file mode 100644 index 00000000..cd61c48f --- /dev/null +++ b/lib/dashbot/widgets/chat_bubble.dart @@ -0,0 +1,28 @@ +// lib/dashbot/widgets/chat_bubble.dart +import 'package:flutter/material.dart'; +import 'content_renderer.dart'; + +class ChatBubble extends StatelessWidget { + final String message; + final bool isUser; + + const ChatBubble({super.key, required this.message, this.isUser = false}); + + @override + Widget build(BuildContext context) { + return Align( + alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isUser + ? Theme.of(context).colorScheme.primaryContainer + : Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: renderContent(context, message), + ), + ); + } +} diff --git a/lib/dashbot/widgets/content_renderer.dart b/lib/dashbot/widgets/content_renderer.dart new file mode 100644 index 00000000..c157c0c9 --- /dev/null +++ b/lib/dashbot/widgets/content_renderer.dart @@ -0,0 +1,97 @@ +// lib/dashbot/widgets/content_renderer.dart +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter_highlighter/flutter_highlighter.dart'; +import 'package:flutter_highlighter/themes/monokai-sublime.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; + +Widget renderContent(BuildContext context, String text) { + if (text.isEmpty) { + return const Text("No content to display."); + } + + final codeBlockPattern = RegExp(r'```(\w+)?\n([\s\S]*?)```', multiLine: true); + final matches = codeBlockPattern.allMatches(text); + + if (matches.isEmpty) { + return _renderMarkdown(context, text); + } + + List children = []; + int lastEnd = 0; + + for (var match in matches) { + if (match.start > lastEnd) { + children.add(_renderMarkdown(context, text.substring(lastEnd, match.start))); + } + + final language = match.group(1) ?? 'text'; + final code = match.group(2)!.trim(); + children.add(_renderCodeBlock(context, language, code)); + + lastEnd = match.end; + } + + if (lastEnd < text.length) { + children.add(_renderMarkdown(context, text.substring(lastEnd))); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ); +} + +Widget _renderMarkdown(BuildContext context, String markdown) { + return MarkdownBody( + data: markdown, + selectable: true, + styleSheet: MarkdownStyleSheet( + p: TextStyle(color: Theme.of(context).colorScheme.onSurface), + ), + ); +} + +Widget _renderCodeBlock(BuildContext context, String language, String code) { + if (language == 'json') { + try { + final prettyJson = const JsonEncoder.withIndent(' ').convert(jsonDecode(code)); + return Container( + padding: const EdgeInsets.all(8), + color: Theme.of(context).colorScheme.surfaceContainerLow, + child: SelectableText( + prettyJson, + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ), + ); + } catch (e) { + return _renderFallbackCode(context, code); + } + } else { + try { + return Container( + padding: const EdgeInsets.all(8), + color: Theme.of(context).colorScheme.surfaceContainerLow, + child: HighlightView( + code, + language: language, + theme: monokaiSublimeTheme, + textStyle: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ), + ); + } catch (e) { + return _renderFallbackCode(context, code); + } + } +} + +Widget _renderFallbackCode(BuildContext context, String code) { + return Container( + padding: const EdgeInsets.all(8), + color: Theme.of(context).colorScheme.surfaceContainerLow, + child: SelectableText( + code, + style: const TextStyle(fontFamily: 'monospace', fontSize: 12, color: Colors.red), + ), + ); +} diff --git a/lib/dashbot/widgets/dashbot_widget.dart b/lib/dashbot/widgets/dashbot_widget.dart index 095a329d..443d984a 100644 --- a/lib/dashbot/widgets/dashbot_widget.dart +++ b/lib/dashbot/widgets/dashbot_widget.dart @@ -1,12 +1,10 @@ +// lib/dashbot/widgets/dashbot_widget.dart import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:apidash/dashbot/providers/dashbot_providers.dart'; import 'package:apidash/providers/providers.dart'; -import 'package:highlighter/highlighter.dart' show highlight; -import 'package:json_explorer/json_explorer.dart'; -import 'package:share_plus/share_plus.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; -import 'dart:convert'; +import 'package:flutter/services.dart'; +import 'chat_bubble.dart'; class DashBotWidget extends ConsumerStatefulWidget { const DashBotWidget({Key? key}) : super(key: key); @@ -17,8 +15,22 @@ class DashBotWidget extends ConsumerStatefulWidget { class _DashBotWidgetState extends ConsumerState { final TextEditingController _controller = TextEditingController(); + late ScrollController _scrollController; bool _isLoading = false; + @override + void initState() { + super.initState(); + _scrollController = ScrollController(); + } + + @override + void dispose() { + _scrollController.dispose(); + _controller.dispose(); + super.dispose(); + } + Future _sendMessage(String message) async { if (message.trim().isEmpty) return; final dashBotService = ref.read(dashBotServiceProvider); @@ -34,26 +46,29 @@ class _DashBotWidgetState extends ConsumerState { try { final response = await dashBotService.handleRequest(message, requestModel, responseModel); - final formattedResponse = _limitResponse(response); - ref.read(chatMessagesProvider.notifier).addMessage({ 'role': 'bot', - 'message': formattedResponse, + 'message': response, }); - } catch (error) { + } catch (error, stackTrace) { + print('Error in _sendMessage: $error'); + print('StackTrace: $stackTrace'); ref.read(chatMessagesProvider.notifier).addMessage({ 'role': 'bot', 'message': "Error: ${error.toString()}", }); } finally { setState(() => _isLoading = false); + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollController.animateTo( + 0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + }); } } - String _limitResponse(String response) { - return response; - } - @override Widget build(BuildContext context) { final messages = ref.watch(chatMessagesProvider); @@ -101,7 +116,10 @@ class _DashBotWidgetState extends ConsumerState { (msg) => msg['role'] == 'bot', orElse: () => {'message': ''}, )['message']; - Share.share(lastBotMessage); + Clipboard.setData(ClipboardData(text: lastBotMessage)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Copied to clipboard')), + ); }, ), IconButton( @@ -143,6 +161,7 @@ class _DashBotWidgetState extends ConsumerState { Widget _buildChatArea(List> messages) { return ListView.builder( + controller: _scrollController, reverse: true, itemCount: messages.length, itemBuilder: (context, index) { @@ -191,162 +210,3 @@ class _DashBotWidgetState extends ConsumerState { ); } } - -class ChatBubble extends StatelessWidget { - final String message; - final bool isUser; - - const ChatBubble({super.key, required this.message, this.isUser = false}); - - @override - Widget build(BuildContext context) { - return Align( - alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, - child: Container( - margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 12), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: isUser - ? Theme.of(context).colorScheme.primaryContainer - : Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: _renderContent(context, message), - ), - ); - } - - Widget _renderContent(BuildContext context, String text) { - final codeBlockPattern = RegExp(r'```(\w+)?\n([\s\S]*?)```', multiLine: true); - final matches = codeBlockPattern.allMatches(text); - - if (matches.isEmpty) { - return MarkdownBody( - data: text, - selectable: true, - styleSheet: MarkdownStyleSheet( - h1: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface), - h2: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface), - h3: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Theme.of(context).colorScheme.onSurface), - strong: TextStyle(fontWeight: FontWeight.bold), - em: TextStyle(fontStyle: FontStyle.italic), - listBullet: TextStyle(color: Theme.of(context).colorScheme.onSurface), - p: TextStyle(color: Theme.of(context).colorScheme.onSurface), - ), - ); - } - - List children = []; - int lastEnd = 0; - - for (var match in matches) { - if (match.start > lastEnd) { - children.add( - MarkdownBody( - data: text.substring(lastEnd, match.start), - selectable: true, - styleSheet: MarkdownStyleSheet( - h1: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface), - h2: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface), - h3: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Theme.of(context).colorScheme.onSurface), - strong: TextStyle(fontWeight: FontWeight.bold), - em: TextStyle(fontStyle: FontStyle.italic), - listBullet: TextStyle(color: Theme.of(context).colorScheme.onSurface), - p: TextStyle(color: Theme.of(context).colorScheme.onSurface), - ), - ), - ); - } - - final language = match.group(1) ?? 'text'; - final code = match.group(2)!.trim(); - - if (language == 'json' && _isValidJson(code)) { - final prettyJson = const JsonEncoder.withIndent(' ').convert(jsonDecode(code)); - children.add( - Container( - padding: const EdgeInsets.all(8), - color: Theme.of(context).colorScheme.surfaceContainerLow, - child: SelectableText( - prettyJson, - style: const TextStyle(fontFamily: 'monospace', fontSize: 12), - ), - ), - ); - } else { - final highlighted = highlight.parse(code, language: language); - final spans = _buildTextSpans(highlighted, context); - children.add( - Container( - padding: const EdgeInsets.all(8), - color: Theme.of(context).colorScheme.surfaceContainerLow, - child: SelectableText.rich( - TextSpan(children: spans), - style: const TextStyle(fontFamily: 'monospace', fontSize: 12), - ), - ), - ); - } - - lastEnd = match.end; - } - - if (lastEnd < text.length) { - children.add( - MarkdownBody( - data: text.substring(lastEnd), - selectable: true, - styleSheet: MarkdownStyleSheet( - h1: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface), - h2: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface), - h3: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Theme.of(context).colorScheme.onSurface), - strong: TextStyle(fontWeight: FontWeight.bold), - em: TextStyle(fontStyle: FontStyle.italic), - listBullet: TextStyle(color: Theme.of(context).colorScheme.onSurface), - p: TextStyle(color: Theme.of(context).colorScheme.onSurface), - ), - ), - ); - } - - return Column( - crossAxisAlignment: isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, - children: children, - ); - } - - bool _isValidJson(String text) { - try { - jsonDecode(text); - return true; - } catch (_) { - return false; - } - } - - List _buildTextSpans(dynamic highlighted, BuildContext context) { - final List spans = []; - for (var span in highlighted.spans ?? []) { - spans.add(TextSpan( - text: span.text, - style: TextStyle( - color: _getColorForClassName(span.className, context), - ), - )); - } - return spans.isEmpty ? [TextSpan(text: highlighted.source)] : spans; - } - - Color _getColorForClassName(String? className, BuildContext context) { - switch (className) { - case 'keyword': - return Colors.blue; - case 'string': - return Colors.green; - case 'comment': - return Colors.grey; - default: - return Theme.of(context).colorScheme.onSurface; - } - } -} diff --git a/pubspec.lock b/pubspec.lock index e1f301f2..26028170 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -509,6 +509,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_highlighter: + dependency: "direct main" + description: + name: flutter_highlighter + sha256: "93173afd47a9ada53f3176371755e7ea4a1065362763976d06d6adfb4d946e10" + url: "https://pub.dev" + source: hosted + version: "0.1.1" flutter_hooks: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 186964c7..f6a075ff 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -67,6 +67,7 @@ dependencies: path: plugins/window_size share_plus: ^10.1.4 ollama_dart: ^0.2.2 + flutter_highlighter: ^0.1.0 dependency_overrides: extended_text_field: ^16.0.0