From 5b875009924f990baece93fa5e25134906f86c6d Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Tue, 24 Mar 2026 10:02:20 +0100
Subject: [PATCH] feat: markdown rendering in assistant message bubbles

---
 lib/widgets/message_bubble.dart |   90 +++++++++++++++++++++++++++++++++++++--------
 pubspec.lock                    |   16 ++++++++
 pubspec.yaml                    |    1 
 3 files changed, 91 insertions(+), 16 deletions(-)

diff --git a/lib/widgets/message_bubble.dart b/lib/widgets/message_bubble.dart
index c5df417..870ff55 100644
--- a/lib/widgets/message_bubble.dart
+++ b/lib/widgets/message_bubble.dart
@@ -4,6 +4,7 @@
 
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
+import 'package:flutter_markdown/flutter_markdown.dart';
 import 'package:intl/intl.dart';
 
 import '../models/message.dart';
@@ -105,14 +106,17 @@
   Widget _buildContent(BuildContext context) {
     switch (message.type) {
       case MessageType.text:
-        return SelectableText(
-          message.content,
-          style: TextStyle(
-            fontSize: 15,
-            color: _isUser ? Colors.white : null,
-            height: 1.4,
-          ),
-        );
+        if (_isUser) {
+          return SelectableText(
+            message.content,
+            style: const TextStyle(
+              fontSize: 15,
+              color: Colors.white,
+              height: 1.4,
+            ),
+          );
+        }
+        return _buildMarkdown(context);
 
       case MessageType.voice:
         return _buildVoiceContent(context);
@@ -120,6 +124,57 @@
       case MessageType.image:
         return _buildImageContent(context);
     }
+  }
+
+  Widget _buildMarkdown(BuildContext context) {
+    final isDark = Theme.of(context).brightness == Brightness.dark;
+    final textColor = isDark ? Colors.white : Colors.black87;
+    final codeBackground = isDark
+        ? Colors.white.withAlpha(20)
+        : Colors.black.withAlpha(15);
+
+    return MarkdownBody(
+      data: message.content,
+      selectable: true,
+      softLineBreak: true,
+      styleSheet: MarkdownStyleSheet(
+        p: TextStyle(fontSize: 15, height: 1.4, color: textColor),
+        h1: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: textColor),
+        h2: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: textColor),
+        h3: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: textColor),
+        strong: TextStyle(fontWeight: FontWeight.bold, color: textColor),
+        em: TextStyle(fontStyle: FontStyle.italic, color: textColor),
+        code: TextStyle(
+          fontSize: 13,
+          fontFamily: 'monospace',
+          color: textColor,
+          backgroundColor: codeBackground,
+        ),
+        codeblockDecoration: BoxDecoration(
+          color: codeBackground,
+          borderRadius: BorderRadius.circular(8),
+        ),
+        codeblockPadding: const EdgeInsets.all(10),
+        listBullet: TextStyle(fontSize: 15, color: textColor),
+        blockquoteDecoration: BoxDecoration(
+          border: Border(
+            left: BorderSide(color: AppColors.accent, width: 3),
+          ),
+        ),
+        blockquotePadding: const EdgeInsets.only(left: 12, top: 4, bottom: 4),
+      ),
+      onTapLink: (text, href, title) {
+        if (href != null) {
+          Clipboard.setData(ClipboardData(text: href));
+          ScaffoldMessenger.of(context).showSnackBar(
+            SnackBar(
+              content: Text('Link copied: $href'),
+              duration: const Duration(seconds: 2),
+            ),
+          );
+        }
+      },
+    );
   }
 
   Widget _buildVoiceContent(BuildContext context) {
@@ -194,14 +249,17 @@
         // Transcript
         if (message.content.isNotEmpty) ...[
           const SizedBox(height: 8),
-          SelectableText(
-            message.content,
-            style: TextStyle(
-              fontSize: 14,
-              color: _isUser ? Colors.white.withAlpha(220) : null,
-              height: 1.3,
-            ),
-          ),
+          if (_isUser)
+            SelectableText(
+              message.content,
+              style: TextStyle(
+                fontSize: 14,
+                color: Colors.white.withAlpha(220),
+                height: 1.3,
+              ),
+            )
+          else
+            _buildMarkdown(context),
         ],
       ],
     );
diff --git a/pubspec.lock b/pubspec.lock
index c246b00..ec7686d 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -254,6 +254,14 @@
       url: "https://pub.dev"
     source: hosted
     version: "6.0.0"
+  flutter_markdown:
+    dependency: "direct main"
+    description:
+      name: flutter_markdown
+      sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.7.7+1"
   flutter_plugin_android_lifecycle:
     dependency: transitive
     description:
@@ -488,6 +496,14 @@
       url: "https://pub.dev"
     source: hosted
     version: "1.3.0"
+  markdown:
+    dependency: transitive
+    description:
+      name: markdown
+      sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9
+      url: "https://pub.dev"
+    source: hosted
+    version: "7.3.1"
   matcher:
     dependency: transitive
     description:
diff --git a/pubspec.yaml b/pubspec.yaml
index a892e29..f4da55f 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -30,6 +30,7 @@
   uuid: ^4.5.1
   collection: ^1.19.1
   file_picker: ^10.3.10
+  flutter_markdown: ^0.7.7+1
 
 dev_dependencies:
   flutter_test:

--
Gitblit v1.3.1