diff --git a/lib/dashbot/widgets/dashbot_widget.dart b/lib/dashbot/widgets/dashbot_widget.dart index 24e0dc07..095a329d 100644 --- a/lib/dashbot/widgets/dashbot_widget.dart +++ b/lib/dashbot/widgets/dashbot_widget.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:apidash/dashbot/providers/dashbot_providers.dart'; import 'package:apidash/providers/providers.dart'; -import '../../providers/collection_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'; class DashBotWidget extends ConsumerStatefulWidget { const DashBotWidget({Key? key}) : super(key: key); @@ -31,7 +34,7 @@ class _DashBotWidgetState extends ConsumerState { try { final response = await dashBotService.handleRequest(message, requestModel, responseModel); - final formattedResponse = _formatMarkdown(response); + final formattedResponse = _limitResponse(response); ref.read(chatMessagesProvider.notifier).addMessage({ 'role': 'bot', @@ -46,24 +49,21 @@ class _DashBotWidgetState extends ConsumerState { setState(() => _isLoading = false); } } - String _formatMarkdown(String text) { - if (!text.contains("```") && text.trim().isNotEmpty) { - text = "```\n$text\n```"; - } - text = text.replaceAllMapped(RegExp(r'^\*\*(.*?)\*\*', multiLine: true), - (match) => '## ${match.group(1)}'); - return text; + + String _limitResponse(String response) { + return response; } @override Widget build(BuildContext context) { final messages = ref.watch(chatMessagesProvider); - final requestModel = ref.watch(selectedRequestModelProvider); + final requestModel = ref.read(selectedRequestModelProvider); final statusCode = requestModel?.httpResponseModel?.statusCode; final showDebugButton = statusCode != null && statusCode >= 400; return Container( height: 450, + width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, @@ -71,76 +71,121 @@ class _DashBotWidgetState extends ConsumerState { boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 8, offset: Offset(0, 4))], ), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('DashBot', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), - IconButton( - icon: const Icon(Icons.delete_sweep), - tooltip: 'Clear Chat History', - onPressed: () => ref.read(chatMessagesProvider.notifier).clearMessages(), - ), - ], - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - alignment: WrapAlignment.center, - children: [ - ElevatedButton.icon( - onPressed: () => _sendMessage("Explain API"), - icon: const Icon(Icons.info_outline), - label: const Text("Explain API"), - ), - ], - ), + _buildHeader(context), const SizedBox(height: 12), - Expanded( - child: ListView.builder( - reverse: true, - itemCount: messages.length, - itemBuilder: (context, index) { - final message = messages.reversed.toList()[index]; - return ChatBubble( - message: message['message'], - isUser: message['role'] == 'user', - ); + _buildQuickActions(showDebugButton), + const SizedBox(height: 12), + Expanded(child: _buildChatArea(messages)), + if (_isLoading) _buildLoadingIndicator(), + const SizedBox(height: 10), + _buildInputArea(context), + ], + ), + ); + } + + Widget _buildHeader(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('DashBot', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + Row( + children: [ + IconButton( + icon: const Icon(Icons.copy), + tooltip: 'Copy Last Response', + onPressed: () { + final lastBotMessage = ref.read(chatMessagesProvider).lastWhere( + (msg) => msg['role'] == 'bot', + orElse: () => {'message': ''}, + )['message']; + Share.share(lastBotMessage); }, ), - ), - if (_isLoading) - const Padding( - padding: EdgeInsets.all(8.0), - child: CircularProgressIndicator(), - ), - const SizedBox(height: 10), - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - color: Theme.of(context).colorScheme.surfaceVariant, + IconButton( + icon: const Icon(Icons.delete_sweep), + tooltip: 'Clear Chat', + onPressed: () => ref.read(chatMessagesProvider.notifier).clearMessages(), ), + ], + ), + ], + ); + } + + Widget _buildQuickActions(bool showDebugButton) { + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ElevatedButton.icon( + onPressed: () => _sendMessage("Explain API"), + icon: const Icon(Icons.info_outline), + label: const Text("Explain"), + style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _controller, - decoration: const InputDecoration( - hintText: 'Ask something...', - border: InputBorder.none, - ), - onSubmitted: _sendMessage, - ), - ), - IconButton( - icon: const Icon(Icons.send), - onPressed: () => _sendMessage(_controller.text), - ), - ], + ), + ), + if (showDebugButton) + ElevatedButton.icon( + onPressed: () => _sendMessage("Debug API"), + icon: const Icon(Icons.bug_report_outlined), + label: const Text("Debug"), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), ), ), + ], + ); + } + + Widget _buildChatArea(List> messages) { + return ListView.builder( + reverse: true, + itemCount: messages.length, + itemBuilder: (context, index) { + final message = messages.reversed.toList()[index]; + return ChatBubble( + message: message['message'], + isUser: message['role'] == 'user', + ); + }, + ); + } + + Widget _buildLoadingIndicator() { + return const Padding( + padding: EdgeInsets.all(8.0), + child: LinearProgressIndicator(), + ); + } + + Widget _buildInputArea(BuildContext context) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Theme.of(context).colorScheme.surfaceContainer, + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + decoration: const InputDecoration( + hintText: 'Ask DashBot...', + border: InputBorder.none, + ), + onSubmitted: _sendMessage, + maxLines: 1, + ), + ), + IconButton( + icon: const Icon(Icons.send), + onPressed: () => _sendMessage(_controller.text), + ), ], ), ); @@ -163,27 +208,145 @@ class ChatBubble extends StatelessWidget { decoration: BoxDecoration( color: isUser ? Theme.of(context).colorScheme.primaryContainer - : Theme.of(context).colorScheme.secondaryContainer, + : Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), - child: MarkdownBody( - data: message, - selectable: true, - styleSheet: MarkdownStyleSheet( - h2: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - h3: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurface, - ), - listBullet: TextStyle(color: Theme.of(context).colorScheme.onSurface), - ), - ), + 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 095c7da4..e1f301f2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -168,10 +168,10 @@ packages: dependency: transitive description: name: built_value - sha256: "28a712df2576b63c6c005c465989a348604960c0958d28be5303ba9baa841ac2" + sha256: "8b158ab94ec6913e480dc3f752418348b5ae099eb75868b5f4775f0572999c61" url: "https://pub.dev" source: hosted - version: "8.9.3" + version: "8.9.4" characters: dependency: transitive description: @@ -700,14 +700,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" highlighter: dependency: "direct main" description: @@ -1083,18 +1075,18 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: "67eae327b1b0faf761964a1d2e5d323c797f3799db0e85aa232db8d9e922bc35" + sha256: "7976bfe4c583170d6cdc7077e3237560b364149fcd268b5f53d95a991963b191" url: "https://pub.dev" source: hosted - version: "8.2.1" + version: "8.3.0" package_info_plus_platform_interface: dependency: transitive description: name: package_info_plus_platform_interface - sha256: "205ec83335c2ab9107bbba3f8997f9356d72ca3c715d2f038fc773d0366b4c76" + sha256: "6c935fb612dff8e3cc9632c2b301720c77450a126114126ffaafe28d2e87956c" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.2.0" path: dependency: "direct main" description: