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 +++++++++++++++++++++++++++++++++++++--------
1 files changed, 74 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),
],
],
);
--
Gitblit v1.3.1