Matthias Nott
2026-03-24 5b875009924f990baece93fa5e25134906f86c6d
feat: markdown rendering in assistant message bubbles
3 files modified
changed files
lib/widgets/message_bubble.dart patch | view | blame | history
pubspec.lock patch | view | blame | history
pubspec.yaml patch | view | blame | history
lib/widgets/message_bubble.dart
....@@ -4,6 +4,7 @@
44
55 import 'package:flutter/material.dart';
66 import 'package:flutter/services.dart';
7
+import 'package:flutter_markdown/flutter_markdown.dart';
78 import 'package:intl/intl.dart';
89
910 import '../models/message.dart';
....@@ -105,14 +106,17 @@
105106 Widget _buildContent(BuildContext context) {
106107 switch (message.type) {
107108 case MessageType.text:
108
- return SelectableText(
109
- message.content,
110
- style: TextStyle(
111
- fontSize: 15,
112
- color: _isUser ? Colors.white : null,
113
- height: 1.4,
114
- ),
115
- );
109
+ if (_isUser) {
110
+ return SelectableText(
111
+ message.content,
112
+ style: const TextStyle(
113
+ fontSize: 15,
114
+ color: Colors.white,
115
+ height: 1.4,
116
+ ),
117
+ );
118
+ }
119
+ return _buildMarkdown(context);
116120
117121 case MessageType.voice:
118122 return _buildVoiceContent(context);
....@@ -120,6 +124,57 @@
120124 case MessageType.image:
121125 return _buildImageContent(context);
122126 }
127
+ }
128
+
129
+ Widget _buildMarkdown(BuildContext context) {
130
+ final isDark = Theme.of(context).brightness == Brightness.dark;
131
+ final textColor = isDark ? Colors.white : Colors.black87;
132
+ final codeBackground = isDark
133
+ ? Colors.white.withAlpha(20)
134
+ : Colors.black.withAlpha(15);
135
+
136
+ return MarkdownBody(
137
+ data: message.content,
138
+ selectable: true,
139
+ softLineBreak: true,
140
+ styleSheet: MarkdownStyleSheet(
141
+ p: TextStyle(fontSize: 15, height: 1.4, color: textColor),
142
+ h1: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: textColor),
143
+ h2: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: textColor),
144
+ h3: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: textColor),
145
+ strong: TextStyle(fontWeight: FontWeight.bold, color: textColor),
146
+ em: TextStyle(fontStyle: FontStyle.italic, color: textColor),
147
+ code: TextStyle(
148
+ fontSize: 13,
149
+ fontFamily: 'monospace',
150
+ color: textColor,
151
+ backgroundColor: codeBackground,
152
+ ),
153
+ codeblockDecoration: BoxDecoration(
154
+ color: codeBackground,
155
+ borderRadius: BorderRadius.circular(8),
156
+ ),
157
+ codeblockPadding: const EdgeInsets.all(10),
158
+ listBullet: TextStyle(fontSize: 15, color: textColor),
159
+ blockquoteDecoration: BoxDecoration(
160
+ border: Border(
161
+ left: BorderSide(color: AppColors.accent, width: 3),
162
+ ),
163
+ ),
164
+ blockquotePadding: const EdgeInsets.only(left: 12, top: 4, bottom: 4),
165
+ ),
166
+ onTapLink: (text, href, title) {
167
+ if (href != null) {
168
+ Clipboard.setData(ClipboardData(text: href));
169
+ ScaffoldMessenger.of(context).showSnackBar(
170
+ SnackBar(
171
+ content: Text('Link copied: $href'),
172
+ duration: const Duration(seconds: 2),
173
+ ),
174
+ );
175
+ }
176
+ },
177
+ );
123178 }
124179
125180 Widget _buildVoiceContent(BuildContext context) {
....@@ -194,14 +249,17 @@
194249 // Transcript
195250 if (message.content.isNotEmpty) ...[
196251 const SizedBox(height: 8),
197
- SelectableText(
198
- message.content,
199
- style: TextStyle(
200
- fontSize: 14,
201
- color: _isUser ? Colors.white.withAlpha(220) : null,
202
- height: 1.3,
203
- ),
204
- ),
252
+ if (_isUser)
253
+ SelectableText(
254
+ message.content,
255
+ style: TextStyle(
256
+ fontSize: 14,
257
+ color: Colors.white.withAlpha(220),
258
+ height: 1.3,
259
+ ),
260
+ )
261
+ else
262
+ _buildMarkdown(context),
205263 ],
206264 ],
207265 );
pubspec.lock
....@@ -254,6 +254,14 @@
254254 url: "https://pub.dev"
255255 source: hosted
256256 version: "6.0.0"
257
+ flutter_markdown:
258
+ dependency: "direct main"
259
+ description:
260
+ name: flutter_markdown
261
+ sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27"
262
+ url: "https://pub.dev"
263
+ source: hosted
264
+ version: "0.7.7+1"
257265 flutter_plugin_android_lifecycle:
258266 dependency: transitive
259267 description:
....@@ -488,6 +496,14 @@
488496 url: "https://pub.dev"
489497 source: hosted
490498 version: "1.3.0"
499
+ markdown:
500
+ dependency: transitive
501
+ description:
502
+ name: markdown
503
+ sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9
504
+ url: "https://pub.dev"
505
+ source: hosted
506
+ version: "7.3.1"
491507 matcher:
492508 dependency: transitive
493509 description:
pubspec.yaml
....@@ -30,6 +30,7 @@
3030 uuid: ^4.5.1
3131 collection: ^1.19.1
3232 file_picker: ^10.3.10
33
+ flutter_markdown: ^0.7.7+1
3334
3435 dev_dependencies:
3536 flutter_test: