Matthias Nott
2026-03-21 fa34201bc07e5312ff0c6825933cd02ce7900254
lib/services/message_store.dart
....@@ -82,7 +82,7 @@
8282 final jsonStr = await file.readAsString();
8383 final List<dynamic> jsonList = jsonDecode(jsonStr) as List<dynamic>;
8484 final allMessages = jsonList
85
- .map((j) => Message.fromJson(j as Map<String, dynamic>))
85
+ .map((j) => _messageFromJson(j as Map<String, dynamic>))
8686 .where((m) => !m.isEmptyVoice) // Filter out voice msgs with no content
8787 .toList();
8888
....@@ -106,7 +106,7 @@
106106 final jsonStr = await file.readAsString();
107107 final List<dynamic> jsonList = jsonDecode(jsonStr) as List<dynamic>;
108108 return jsonList
109
- .map((j) => Message.fromJson(j as Map<String, dynamic>))
109
+ .map((j) => _messageFromJson(j as Map<String, dynamic>))
110110 .where((m) => !m.isEmptyVoice)
111111 .toList();
112112 } catch (e) {
....@@ -114,6 +114,29 @@
114114 }
115115 }
116116
117
+ /// Deserialize a message from JSON, applying migration rules:
118
+ /// - Voice messages without audioUri are downgraded to text (transcript only).
119
+ /// This handles messages saved before a restart, where the temp audio file
120
+ /// is no longer available. The transcript (content) is preserved.
121
+ static Message _messageFromJson(Map<String, dynamic> json) {
122
+ final raw = Message.fromJson(json);
123
+ if (raw.type == MessageType.voice &&
124
+ (raw.audioUri == null || raw.audioUri!.isEmpty)) {
125
+ // Downgrade to text so the bubble shows the transcript instead of a
126
+ // broken play button.
127
+ return Message(
128
+ id: raw.id,
129
+ role: raw.role,
130
+ type: MessageType.text,
131
+ content: raw.content,
132
+ timestamp: raw.timestamp,
133
+ status: raw.status,
134
+ duration: raw.duration,
135
+ );
136
+ }
137
+ return raw;
138
+ }
139
+
117140 /// Delete stored messages for a session.
118141 static Future<void> delete(String sessionId) async {
119142 try {