@tduncan / Cortana / commits / 9016c75

Add attachments, exports, and TTS improvements

Add attachment helpers and UI support for text/image/audio attachments; allow attaching .txt/.md files, text preview, and inline image base64 handling. Expand DataBackupService to export/import version 3 (conversations, single-conversation export, saved messages/folders, servers, model metadata) and support zip/json exports/imports. Add conversation export UI, conversation-level export/import flows, and storage/prefs hooks. Enhance chat logic: trim content, create conversation from attachments, edit-save-only vs regenerate flow, generate response for last user, scroll-to-bottom FAB. TTS: preprocess markdown for speech (optional setting), track playing message/conversation, allow jumping to playing message, add TTS text processor. Misc: rotating welcome messages, persona preview toggle, focus/search improvements, many localization keys.

onlypuppy7 committed Jun 30, 2026 at 04:16 UTC 9016c75d2f71b5f1c52ea148062de73be353a5a1
40 files changed +1964 -408
lib/core/providers/app_providers.dart
+2
@@ -98,6 +98,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
98 _update(state.copyWith(kittenTtsModelVariant: value));
99 void setAutoSpeakEnabled(bool value) =>
100 _update(state.copyWith(autoSpeakEnabled: value));
101 + void setTtsProcessMarkdown(bool value) =>
102 + _update(state.copyWith(ttsProcessMarkdown: value));
103 void setSmartReplyEnabled(bool value) =>
104 _update(state.copyWith(smartReplyEnabled: value));
105 void setLocaleCode(String? value) =>
lib/core/services/data_backup_service.dart
+245 -51
@@ -1,23 +1,41 @@
1 import 'dart:convert';
2
3 import 'package:archive/archive.dart';
4 +import 'package:shared_preferences/shared_preferences.dart';
5
6 import '../models/enums.dart';
7 import '../storage/entities.dart';
8 import '../../features/chat/data/models/message.dart';
9 import '../../features/conversations/data/models/conversation.dart';
10 import '../../features/personas/data/models/persona.dart';
11 +import '../../features/servers/data/models/server.dart';
12 import '../../objectbox.g.dart';
13
14 class DataBackupService {
13 - Map<String, dynamic> exportAll(Store store) {
14 - final convBox = store.box<ConversationEntity>();
15 - final messageBox = store.box<MessageEntity>();
16 - final personaBox = store.box<PersonaEntity>();
15 + Map<String, dynamic> _messageToMap(Message message) => {
16 + 'id': message.id,
17 + 'conversationId': message.conversationId,
18 + 'role': message.role.name,
19 + 'content': message.content,
20 + 'createdAt': message.createdAt.toIso8601String(),
21 + 'status': message.status.name,
22 + 'modelId': message.modelId,
23 + 'tokenCount': message.tokenCount,
24 + 'errorMessage': message.errorMessage,
25 + 'attachmentPaths': message.attachmentPaths,
26 + 'generationTimeMs': message.generationTimeMs,
27 + 'reasoningContent': message.reasoningContent,
28 + 'toolCallId': message.toolCallId,
29 + 'isProcessing': message.isProcessing,
30 + 'toolSessionId': message.toolSessionId,
31 + 'variantGroupId': message.variantGroupId,
32 + 'variantIndex': message.variantIndex,
33 + 'threadOrder': message.threadOrder,
34 + 'isActiveVariant': message.isActiveVariant,
35 + 'parentMessageId': message.parentMessageId,
36 + };
37
18 - final conversations = convBox.getAll().map((entity) {
19 - final domain = entity.toDomain();
20 - return {
38 + Map<String, dynamic> _conversationToMap(Conversation domain) => {
39 'id': domain.id,
40 'title': domain.title,
41 'createdAt': domain.createdAt.toIso8601String(),
@@ -38,32 +56,76 @@ class DataBackupService {
56 'smartRepliesLastMessageId': domain.smartRepliesLastMessageId,
57 'folderId': domain.folderId,
58 };
59 +
60 + List<Map<String, dynamic>> _exportSavedMessages(Store store) {
61 + final box = store.box<SavedMessageEntity>();
62 + return box.getAll().map((e) {
63 + return {
64 + 'id': e.id,
65 + 'sourceMessageId': e.sourceMessageId,
66 + 'conversationId': e.conversationId,
67 + 'conversationTitle': e.conversationTitle,
68 + 'roleIndex': e.roleIndex,
69 + 'content': e.content,
70 + 'modelId': e.modelId,
71 + 'folderId': e.folderId,
72 + 'savedAt': e.savedAt.toIso8601String(),
73 + };
74 }).toList();
75 + }
76
43 - final messages = messageBox.getAll().map((entity) {
44 - final message = entity.toDomain();
77 + List<Map<String, dynamic>> _exportSavedMessageFolders(Store store) {
78 + final box = store.box<SavedMessageFolderEntity>();
79 + return box.getAll().map((e) {
80 return {
46 - 'id': message.id,
47 - 'conversationId': message.conversationId,
48 - 'role': message.role.name,
49 - 'content': message.content,
50 - 'createdAt': message.createdAt.toIso8601String(),
51 - 'status': message.status.name,
52 - 'modelId': message.modelId,
53 - 'tokenCount': message.tokenCount,
54 - 'errorMessage': message.errorMessage,
55 - 'attachmentPaths': message.attachmentPaths,
56 - 'generationTimeMs': message.generationTimeMs,
57 - 'reasoningContent': message.reasoningContent,
58 - 'toolCallId': message.toolCallId,
59 - 'isProcessing': message.isProcessing,
60 - 'toolSessionId': message.toolSessionId,
61 - 'variantGroupId': message.variantGroupId,
62 - 'variantIndex': message.variantIndex,
63 - 'threadOrder': message.threadOrder,
64 - 'isActiveVariant': message.isActiveVariant,
81 + 'id': e.id,
82 + 'name': e.name,
83 + 'sortOrder': e.sortOrder,
84 + 'createdAt': e.createdAt.toIso8601String(),
85 + };
86 + }).toList();
87 + }
88 +
89 + List<Map<String, dynamic>> _exportServers(Store store) {
90 + return store.box<ServerEntity>().getAll().map((entity) {
91 + final server = entity.toDomain();
92 + return {
93 + 'id': server.id,
94 + 'name': server.name,
95 + 'type': server.type.name,
96 + 'host': server.host,
97 + 'port': server.port,
98 + 'apiKey': server.apiKey,
99 + 'isDefault': server.isDefault,
100 + 'createdAt': server.createdAt.toIso8601String(),
101 + 'lastConnectedAt': server.lastConnectedAt.toIso8601String(),
102 + 'status': server.status.name,
103 + 'iconName': server.iconName,
104 + 'pathPrefix': server.pathPrefix,
105 };
106 }).toList();
107 + }
108 +
109 + Map<String, dynamic>? _readModelMetadata(SharedPreferences? prefs) {
110 + if (prefs == null) return null;
111 + final raw = prefs.getString('modelMetadata');
112 + if (raw == null || raw.isEmpty) return null;
113 + try {
114 + return Map<String, dynamic>.from(jsonDecode(raw) as Map);
115 + } catch (_) {
116 + return null;
117 + }
118 + }
119 +
120 + Map<String, dynamic> exportAll(Store store, {SharedPreferences? prefs}) {
121 + final convBox = store.box<ConversationEntity>();
122 + final messageBox = store.box<MessageEntity>();
123 + final personaBox = store.box<PersonaEntity>();
124 +
125 + final conversations =
126 + convBox.getAll().map((e) => _conversationToMap(e.toDomain())).toList();
127 + final messages =
128 + messageBox.getAll().map((e) => _messageToMap(e.toDomain())).toList();
129
130 final personas = personaBox
131 .getAll()
@@ -86,74 +148,128 @@ class DataBackupService {
148 .toList();
149
150 return {
89 - 'version': 2,
151 + 'version': 3,
152 'exportedAt': DateTime.now().toIso8601String(),
153 'conversations': conversations,
154 'messages': messages,
155 'personas': personas,
156 + 'savedMessages': _exportSavedMessages(store),
157 + 'savedMessageFolders': _exportSavedMessageFolders(store),
158 + 'servers': _exportServers(store),
159 + if (_readModelMetadata(prefs) != null)
160 + 'modelMetadata': _readModelMetadata(prefs),
161 };
162 }
163
164 Map<String, dynamic> exportConversations(Store store) {
165 final all = exportAll(store);
166 return {
100 - 'version': 2,
167 + 'version': 3,
168 'type': 'conversations',
169 'exportedAt': all['exportedAt'],
170 'conversations': all['conversations'],
171 'messages': all['messages'],
172 + 'savedMessages': all['savedMessages'],
173 + 'savedMessageFolders': all['savedMessageFolders'],
174 + };
175 + }
176 +
177 + Map<String, dynamic> exportConversation(Store store, String conversationId) {
178 + final all = exportAll(store);
179 + final conversations = (all['conversations'] as List)
180 + .where((c) => (c as Map)['id'] == conversationId)
181 + .toList();
182 + final messages = (all['messages'] as List)
183 + .where((m) => (m as Map)['conversationId'] == conversationId)
184 + .toList();
185 + final savedMessages = (all['savedMessages'] as List)
186 + .where((m) => (m as Map)['conversationId'] == conversationId)
187 + .toList();
188 +
189 + return {
190 + 'version': 3,
191 + 'type': 'conversation',
192 + 'exportedAt': all['exportedAt'],
193 + 'conversations': conversations,
194 + 'messages': messages,
195 + 'savedMessages': savedMessages,
196 };
197 }
198
199 Map<String, dynamic> exportPersonas(Store store) {
200 final all = exportAll(store);
201 return {
111 - 'version': 2,
202 + 'version': 3,
203 'type': 'personas',
204 'exportedAt': all['exportedAt'],
205 'personas': all['personas'],
206 };
207 }
208
118 - Map<String, dynamic> exportSettings(String settingsJson) {
119 - return {
120 - 'version': 2,
209 + Map<String, dynamic> exportSettings(
210 + String settingsJson, {
211 + Store? store,
212 + SharedPreferences? prefs,
213 + }) {
214 + final map = <String, dynamic>{
215 + 'version': 3,
216 'type': 'settings',
217 'exportedAt': DateTime.now().toIso8601String(),
218 'settings': jsonDecode(settingsJson),
219 };
220 + if (store != null) {
221 + map['servers'] = _exportServers(store);
222 + }
223 + final metadata = _readModelMetadata(prefs);
224 + if (metadata != null) {
225 + map['modelMetadata'] = metadata;
226 + }
227 + return map;
228 }
229
230 String exportConversationsAsJson(Store store) =>
231 const JsonEncoder.withIndent(' ').convert(exportConversations(store));
232
233 + String exportConversationAsJson(Store store, String conversationId) =>
234 + const JsonEncoder.withIndent(' ')
235 + .convert(exportConversation(store, conversationId));
236 +
237 String exportPersonasAsJson(Store store) =>
238 const JsonEncoder.withIndent(' ').convert(exportPersonas(store));
239
133 - String exportSettingsAsJson(String settingsJson) =>
134 - const JsonEncoder.withIndent(' ').convert(exportSettings(settingsJson));
240 + String exportSettingsAsJson(
241 + String settingsJson, {
242 + Store? store,
243 + SharedPreferences? prefs,
244 + }) =>
245 + const JsonEncoder.withIndent(' ').convert(
246 + exportSettings(settingsJson, store: store, prefs: prefs),
247 + );
248 +
249 + ArchiveFile _jsonArchiveFile(String name, String json) {
250 + final bytes = utf8.encode(json);
251 + return ArchiveFile(name, bytes.length, bytes);
252 + }
253
136 - List<int> exportAllZip(Store store, String settingsJson) {
254 + List<int> exportAllZip(
255 + Store store,
256 + String settingsJson, {
257 + SharedPreferences? prefs,
258 + }) {
259 final archive = Archive()
260 ..addFile(
139 - ArchiveFile(
261 + _jsonArchiveFile(
262 'conversations.json',
141 - 0,
142 - utf8.encode(exportConversationsAsJson(store)),
263 + exportConversationsAsJson(store),
264 ),
265 )
266 ..addFile(
146 - ArchiveFile(
147 - 'personas.json',
148 - 0,
149 - utf8.encode(exportPersonasAsJson(store)),
150 - ),
267 + _jsonArchiveFile('personas.json', exportPersonasAsJson(store)),
268 )
269 ..addFile(
153 - ArchiveFile(
270 + _jsonArchiveFile(
271 'settings.json',
155 - 0,
156 - utf8.encode(exportSettingsAsJson(settingsJson)),
272 + exportSettingsAsJson(settingsJson, store: store, prefs: prefs),
273 ),
274 );
275 final encoded = ZipEncoder().encode(archive);
@@ -163,8 +279,9 @@ class DataBackupService {
279 return encoded;
280 }
281
166 - String exportAllAsJson(Store store) {
167 - return const JsonEncoder.withIndent(' ').convert(exportAll(store));
282 + String exportAllAsJson(Store store, {SharedPreferences? prefs}) {
283 + return const JsonEncoder.withIndent(' ')
284 + .convert(exportAll(store, prefs: prefs));
285 }
286
287 Future<void> importFromJson(Store store, String jsonString) async {
@@ -177,6 +294,9 @@ class DataBackupService {
294 final convBox = store.box<ConversationEntity>();
295 final messageBox = store.box<MessageEntity>();
296 final personaBox = store.box<PersonaEntity>();
297 + final serverBox = store.box<ServerEntity>();
298 + final savedBox = store.box<SavedMessageEntity>();
299 + final savedFolderBox = store.box<SavedMessageFolderEntity>();
300
301 final personas = data['personas'];
302 if (personas is List) {
@@ -206,6 +326,79 @@ class DataBackupService {
326 }
327 }
328
329 + final servers = data['servers'];
330 + if (servers is List) {
331 + for (final item in servers) {
332 + if (item is! Map) continue;
333 + final server = Server(
334 + id: item['id'] as String,
335 + name: item['name'] as String,
336 + type: ServerType.values.byName(item['type'] as String? ?? 'lmStudio'),
337 + host: item['host'] as String? ?? 'localhost',
338 + port: item['port'] as int? ?? 1234,
339 + apiKey: item['apiKey'] as String?,
340 + isDefault: item['isDefault'] as bool? ?? false,
341 + createdAt: DateTime.parse(item['createdAt'] as String),
342 + lastConnectedAt: DateTime.parse(item['lastConnectedAt'] as String),
343 + status: ConnectionStatus.values.byName(
344 + item['status'] as String? ?? 'disconnected',
345 + ),
346 + iconName: item['iconName'] as String?,
347 + pathPrefix: item['pathPrefix'] as String?,
348 + );
349 + final query = serverBox.query(ServerEntity_.id.equals(server.id)).build();
350 + final existing = query.findFirst();
351 + query.close();
352 + final entity = ServerEntity.fromDomain(server);
353 + if (existing != null) entity.internalId = existing.internalId;
354 + serverBox.put(entity);
355 + }
356 + }
357 +
358 + final savedFolders = data['savedMessageFolders'];
359 + if (savedFolders is List) {
360 + for (final item in savedFolders) {
361 + if (item is! Map) continue;
362 + final entity = SavedMessageFolderEntity(
363 + id: item['id'] as String,
364 + name: item['name'] as String,
365 + sortOrder: item['sortOrder'] as int? ?? 0,
366 + createdAt: DateTime.parse(item['createdAt'] as String),
367 + );
368 + final query = savedFolderBox
369 + .query(SavedMessageFolderEntity_.id.equals(entity.id))
370 + .build();
371 + final existing = query.findFirst();
372 + query.close();
373 + if (existing != null) entity.internalId = existing.internalId;
374 + savedFolderBox.put(entity);
375 + }
376 + }
377 +
378 + final savedMessages = data['savedMessages'];
379 + if (savedMessages is List) {
380 + for (final item in savedMessages) {
381 + if (item is! Map) continue;
382 + final entity = SavedMessageEntity(
383 + id: item['id'] as String,
384 + sourceMessageId: item['sourceMessageId'] as String,
385 + conversationId: item['conversationId'] as String,
386 + conversationTitle: item['conversationTitle'] as String? ?? 'Chat',
387 + roleIndex: item['roleIndex'] as int? ?? 0,
388 + content: item['content'] as String? ?? '',
389 + modelId: item['modelId'] as String?,
390 + folderId: item['folderId'] as String?,
391 + savedAt: DateTime.parse(item['savedAt'] as String),
392 + );
393 + final query =
394 + savedBox.query(SavedMessageEntity_.id.equals(entity.id)).build();
395 + final existing = query.findFirst();
396 + query.close();
397 + if (existing != null) entity.internalId = existing.internalId;
398 + savedBox.put(entity);
399 + }
400 + }
401 +
402 final conversations = data['conversations'];
403 if (conversations is List) {
404 for (final item in conversations) {
@@ -279,6 +472,7 @@ class DataBackupService {
472 variantIndex: item['variantIndex'] as int? ?? 0,
473 threadOrder: item['threadOrder'] as int? ?? 0,
474 isActiveVariant: item['isActiveVariant'] as bool? ?? true,
475 + parentMessageId: item['parentMessageId'] as String?,
476 );
477
478 final entity = MessageEntity.fromDomain(message)
@@ -297,7 +491,7 @@ class DataBackupService {
491 final archive = ZipDecoder().decodeBytes(bytes);
492 for (final file in archive.files) {
493 if (file.isFile) {
300 - await importFromJson(store, utf8.decode(file.content));
494 + await importFromJson(store, utf8.decode(file.content as List<int>));
495 }
496 }
497 }
lib/features/chat/data/chat_service.dart
+47 -25
@@ -13,6 +13,7 @@ import '../../../core/logger/app_logger.dart';
13 import '../../on_device/data/on_device_gemma_service.dart';
14 import '../../on_device/data/on_device_chat_service.dart';
15 import 'tools/adapters/tool_transport_adapter.dart';
16 +import '../utils/attachment_helpers.dart';
17 import 'tools/adapters/openai_tool_adapter.dart';
18 import 'tools/adapters/openrouter_tool_adapter.dart';
19 import 'tools/adapters/ollama_tool_adapter.dart';
@@ -408,36 +409,57 @@ class LMStudioChatService implements ChatService {
409 Future<dynamic> _formatInputWithImages(List<Message> messages) async {
410 final formattedInputs = <Map<String, dynamic>>[];
411 for (final m in messages) {
411 - if (m.role != MessageRole.system) {
412 - if (m.attachmentPaths != null && m.attachmentPaths!.isNotEmpty) {
413 - formattedInputs.add({'type': 'text', 'content': m.content});
414 - for (final path in m.attachmentPaths!) {
415 - try {
416 - final base64Image = await Isolate.run(() async {
412 + if (m.role == MessageRole.system) continue;
413 +
414 + var textContent = m.content;
415 + final hasAttachments =
416 + m.attachmentPaths != null && m.attachmentPaths!.isNotEmpty;
417 +
418 + if (hasAttachments) {
419 + for (final path in m.attachmentPaths!) {
420 + if (!AttachmentHelpers.isTextPath(path)) continue;
421 + final text = await AttachmentHelpers.readTextFile(path);
422 + if (text != null) {
423 + textContent = AttachmentHelpers.appendTextAttachment(
424 + textContent,
425 + AttachmentHelpers.fileNameOf(path),
426 + text,
427 + );
428 + }
429 + }
430 + }
431 +
432 + final isEmptyAssistant =
433 + m.role == MessageRole.assistant && textContent.trim().isEmpty;
434 +
435 + if (textContent.trim().isNotEmpty && !isEmptyAssistant) {
436 + formattedInputs.add({'type': 'text', 'content': textContent});
437 + }
438 +
439 + if (hasAttachments) {
440 + for (final path in m.attachmentPaths!) {
441 + if (!AttachmentHelpers.isImagePath(path)) continue;
442 + try {
443 + final base64Image = await Isolate.run(() {
444 + try {
445 final file = File(path);
418 - if (await file.exists()) {
419 - final bytes = await file.readAsBytes();
420 - return base64Encode(bytes);
421 - }
446 + if (!file.existsSync()) return null;
447 + return base64Encode(file.readAsBytesSync());
448 + } catch (_) {
449 return null;
423 - });
424 -
425 - if (base64Image != null) {
426 - final ext = path.split('.').last.toLowerCase();
427 - final mimeType = (ext == 'png')
428 - ? 'image/png'
429 - : (ext == 'webp' ? 'image/webp' : 'image/jpeg');
430 - formattedInputs.add({
431 - 'type': 'image',
432 - 'data_url': 'data:$mimeType;base64,$base64Image',
433 - });
450 }
435 - } catch (e) {
436 - Log.error('Failed to read attachment for LMStudio format: $e');
451 + });
452 +
453 + if (base64Image != null) {
454 + final mimeType = AttachmentHelpers.mimeTypeForImage(path);
455 + formattedInputs.add({
456 + 'type': 'image',
457 + 'data_url': 'data:$mimeType;base64,$base64Image',
458 + });
459 }
460 + } catch (e) {
461 + Log.error('Failed to read attachment for LMStudio format: $e');
462 }
439 - } else {
440 - formattedInputs.add({'type': 'text', 'content': m.content});
463 }
464 }
465 }
lib/features/chat/providers/chat_notifier.dart
+45 -27
@@ -361,19 +361,27 @@ class ChatNotifier extends Notifier<ChatState> {
361 return;
362 }
363
364 - if (content.trim().isEmpty) return;
364 + if (content.trim().isEmpty &&
365 + (attachments == null || attachments.isEmpty)) {
366 + return;
367 + }
368 +
369 + final trimmedContent = content.trim();
370
371 if (_currentConversationId == null) {
367 - final server = ref.read(activeServerProvider);
368 - final selectedModel = ref.read(selectedModelProvider);
372 final selectedPersona = ref.read(selectedPersonaProvider);
373 + final titleSource = trimmedContent.isNotEmpty
374 + ? trimmedContent
375 + : (attachments?.isNotEmpty == true
376 + ? attachments!.first.path.split(Platform.pathSeparator).last
377 + : 'New chat');
378 final conversation = await ref
379 .read(conv.conversationsProvider.notifier)
380 .createConversation(
373 - title: content.length > 50
374 - ? '${content.substring(0, 50)}...'
375 - : content,
376 - serverId: server?.id,
381 + title: titleSource.length > 50
382 + ? '${titleSource.substring(0, 50)}...'
383 + : titleSource,
384 + serverId: server.id,
385 modelId: selectedModel?.id,
386 personaId: selectedPersona?.id,
387 systemPrompt: selectedPersona?.systemPrompt,
@@ -418,7 +426,7 @@ class ChatNotifier extends Notifier<ChatState> {
426 id: generateUuid(),
427 conversationId: _currentConversationId!,
428 role: MessageRole.user,
421 - content: content,
429 + content: trimmedContent,
430 createdAt: DateTime.now(),
431 status: MessageStatus.complete,
432 attachmentPaths: savedPaths.isNotEmpty ? savedPaths : null,
@@ -1359,30 +1367,12 @@ class ChatNotifier extends Notifier<ChatState> {
1367 if (newContent.trim().isEmpty) return;
1368 if (newContent == message.content) return;
1369
1362 - final groupId = MessageVariants.groupId(message);
1363 - final deactivated = state.allMessages.map((m) {
1364 - if (MessageVariants.groupId(m) != groupId) return m;
1365 - return m.copyWith(isActiveVariant: false);
1366 - }).toList();
1367 - for (final m in deactivated.where(
1368 - (x) => MessageVariants.groupId(x) == groupId,
1369 - )) {
1370 - await _saveMessage(m);
1371 - }
1372 - state = state.copyWith(allMessages: deactivated);
1373 -
1370 final updated = message.copyWith(
1375 - id: generateUuid(),
1371 content: newContent,
1377 - createdAt: DateTime.now(),
1372 status: MessageStatus.complete,
1379 - variantIndex: _nextVariantIndex(groupId),
1380 - isActiveVariant: true,
1381 - parentMessageId: message.parentMessageId,
1373 );
1374 await _saveMessage(updated);
1384 - final updatedAll = [...state.allMessages, updated];
1385 - _setAllMessages(updatedAll);
1375 + _replaceMessageInAll(updated);
1376 }
1377
1378 Future<void> branchFromMessage(String messageId) async {
@@ -1468,6 +1458,34 @@ class ChatNotifier extends Notifier<ChatState> {
1458 );
1459 }
1460
1461 + Future<void> editMessageSaveOnly(String messageId, String newContent) async {
1462 + final messageIndex = state.messages.indexWhere((m) => m.id == messageId);
1463 + if (messageIndex == -1) return;
1464 +
1465 + final message = state.messages[messageIndex];
1466 + if (message.role != MessageRole.user) return;
1467 + if (newContent.trim().isEmpty) return;
1468 + if (newContent == message.content) return;
1469 +
1470 + final updated = message.copyWith(content: newContent);
1471 + await _saveMessage(updated);
1472 + _replaceMessageInAll(updated);
1473 + }
1474 +
1475 + Future<void> generateResponseForLastUser() async {
1476 + if (state.isStreaming) return;
1477 + final messages = state.messages;
1478 + if (messages.isEmpty || messages.last.role != MessageRole.user) return;
1479 +
1480 + final userMessage = messages.last;
1481 + await _regenerateAssistant(
1482 + userMessage,
1483 + variantGroupId: generateUuid(),
1484 + threadOrder: userMessage.threadOrder + 1,
1485 + variantIndex: 0,
1486 + );
1487 + }
1488 +
1489 Future<void> editMessage(String messageId, String newContent) async {
1490 final messageIndex = state.messages.indexWhere((m) => m.id == messageId);
1491 if (messageIndex == -1) return;
lib/features/chat/utils/attachment_helpers.dart new
+61
@@ -0,0 +1,61 @@
1 +import 'dart:convert';
2 +import 'dart:io';
3 +
4 +class AttachmentHelpers {
5 + AttachmentHelpers._();
6 +
7 + static const _imageExtensions = {'jpg', 'jpeg', 'png', 'gif', 'webp'};
8 + static const _textExtensions = {'txt', 'md'};
9 +
10 + static String extensionOf(String path) {
11 + final name = path.split(Platform.pathSeparator).last;
12 + final dot = name.lastIndexOf('.');
13 + if (dot <= 0 || dot == name.length - 1) return '';
14 + return name.substring(dot + 1).toLowerCase();
15 + }
16 +
17 + static String fileNameOf(String path) =>
18 + path.split(Platform.pathSeparator).last;
19 +
20 + static bool isImagePath(String path) =>
21 + _imageExtensions.contains(extensionOf(path));
22 +
23 + static bool isTextPath(String path) =>
24 + _textExtensions.contains(extensionOf(path));
25 +
26 + static String mimeTypeForImage(String path) {
27 + return switch (extensionOf(path)) {
28 + 'png' => 'image/png',
29 + 'webp' => 'image/webp',
30 + 'gif' => 'image/gif',
31 + _ => 'image/jpeg',
32 + };
33 + }
34 +
35 + static Future<String?> readTextFile(String path) async {
36 + try {
37 + final file = File(path);
38 + if (!await file.exists()) return null;
39 + return await file.readAsString();
40 + } catch (_) {
41 + return null;
42 + }
43 + }
44 +
45 + static Future<String?> readImageBase64(String path) async {
46 + try {
47 + final file = File(path);
48 + if (!await file.exists()) return null;
49 + final bytes = await file.readAsBytes();
50 + return base64Encode(bytes);
51 + } catch (_) {
52 + return null;
53 + }
54 + }
55 +
56 + static String appendTextAttachment(String content, String fileName, String text) {
57 + final block = '--- $fileName ---\n$text';
58 + if (content.trim().isEmpty) return block;
59 + return '$content\n\n$block';
60 + }
61 +}
lib/features/chat/views/chat_screen.dart
+87 -7
@@ -1,4 +1,7 @@
1 import 'dart:convert';
2 +import 'dart:typed_data';
3 +
4 +import 'package:file_picker/file_picker.dart';
5
6 import 'package:flutter/material.dart';
7 import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -7,6 +10,8 @@ import 'package:hugeicons/hugeicons.dart';
10 import 'package:shadcn_ui/shadcn_ui.dart';
11 import 'package:localmind/core/models/enums.dart';
12 import 'package:localmind/core/providers/app_providers.dart';
13 +import 'package:localmind/core/providers/storage_providers.dart';
14 +import 'package:localmind/core/services/data_backup_service.dart';
15 import 'package:localmind/core/routes/app_routes.dart';
16 import 'package:localmind/core/utils/system_insets.dart';
17 import 'package:localmind/l10n/app_localizations.dart';
@@ -27,6 +32,7 @@ import 'components/chat_auto_scroll_controller.dart';
32 import 'components/chat_input_bar.dart';
33 import 'components/chat_settings_sheet.dart';
34 import 'components/edit_message_dialog.dart';
35 +import 'components/edit_message_result.dart';
36 import 'components/persona_picker_sheet.dart';
37 import 'components/notification_permission_banner.dart';
38 import 'components/message_list/message_list.dart';
@@ -49,6 +55,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
55 final ScrollController _scrollController = ScrollController();
56 final FocusNode _inputFocusNode = FocusNode();
57 bool _isApprovalDialogOpen = false;
58 + bool _showScrollToBottom = false;
59 late final ChatAutoScrollController _autoScroll;
60
61 @override
@@ -56,12 +63,29 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
63 super.initState();
64 WidgetsBinding.instance.addObserver(this);
65 _inputFocusNode.addListener(() => setState(() {}));
59 - _scrollController.addListener(
60 - () => _autoScroll.onScrollChanged(_scrollController),
61 - );
66 + _scrollController.addListener(_onScrollChanged);
67 _autoScroll = ChatAutoScrollController();
68 }
69
70 + void _onScrollChanged() {
71 + if (!_scrollController.hasClients) return;
72 + final pos = _scrollController.position;
73 + final atBottom = pos.pixels >= pos.maxScrollExtent - 96;
74 + if (atBottom != !_showScrollToBottom) {
75 + setState(() => _showScrollToBottom = !atBottom);
76 + }
77 + _autoScroll.onScrollChanged(_scrollController);
78 + }
79 +
80 + void _scrollToBottom() {
81 + if (!_scrollController.hasClients) return;
82 + _scrollController.animateTo(
83 + _scrollController.position.maxScrollExtent,
84 + duration: const Duration(milliseconds: 300),
85 + curve: Curves.easeOut,
86 + );
87 + }
88 +
89 @override
90 void didChangeAppLifecycleState(AppLifecycleState state) {
91 if (state == AppLifecycleState.inactive ||
@@ -97,6 +121,8 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
121 scrollController: _scrollController,
122 inputFocusNode: _inputFocusNode,
123 autoScroll: _autoScroll,
124 + showScrollToBottom: _showScrollToBottom,
125 + onScrollToBottom: _scrollToBottom,
126 onModelPicker: () => _showModelPicker(context),
127 onMenuAction: (action) => _handleMenuAction(action, context),
128 );
@@ -129,6 +155,8 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
155 if (activeConv != null) {
156 _showRenameDialog(context, activeConv);
157 }
158 + case 'export_chat':
159 + _exportActiveConversation(context);
160 case 'clear':
161 showDialog(
162 context: context,
@@ -210,6 +238,28 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
238 });
239 }
240
241 + Future<void> _exportActiveConversation(BuildContext context) async {
242 + final l10n = AppLocalizations.of(context)!;
243 + final activeConv = ref.read(conv.activeConversationProvider);
244 + if (activeConv == null) return;
245 + final db = ref.read(databaseProvider);
246 + final json = DataBackupService()
247 + .exportConversationAsJson(db.store, activeConv.id);
248 + final saved = await FilePicker.saveFile(
249 + dialogTitle: l10n.export_conversation,
250 + fileName:
251 + 'localmind_${activeConv.title.replaceAll(RegExp(r'[^\w\-]+'), '_')}_${DateTime.now().millisecondsSinceEpoch}.json',
252 + type: FileType.custom,
253 + allowedExtensions: const ['json'],
254 + bytes: Uint8List.fromList(utf8.encode(json)),
255 + );
256 + if (saved != null && context.mounted) {
257 + ScaffoldMessenger.of(context).showSnackBar(
258 + SnackBar(content: Text(l10n.export_data_success)),
259 + );
260 + }
261 + }
262 +
263 void _showRenameDialog(BuildContext context, Conversation conversation) {
264 final l10n = AppLocalizations.of(context)!;
265 final controller = TextEditingController(text: conversation.title);
@@ -256,6 +306,8 @@ class _ChatBody extends ConsumerWidget {
306 required this.scrollController,
307 required this.inputFocusNode,
308 required this.autoScroll,
309 + required this.showScrollToBottom,
310 + required this.onScrollToBottom,
311 required this.onModelPicker,
312 required this.onMenuAction,
313 });
@@ -263,6 +315,8 @@ class _ChatBody extends ConsumerWidget {
315 final ScrollController scrollController;
316 final FocusNode inputFocusNode;
317 final ChatAutoScrollController autoScroll;
318 + final bool showScrollToBottom;
319 + final VoidCallback onScrollToBottom;
320 final VoidCallback onModelPicker;
321 final void Function(String) onMenuAction;
322
@@ -353,6 +407,16 @@ class _ChatBody extends ConsumerWidget {
407 keyboardBottomInset: keyboardBottomInset,
408 inputFocusNode: inputFocusNode,
409 ),
410 + if (showScrollToBottom && messages.isNotEmpty)
411 + Positioned(
412 + right: 16,
413 + bottom: 120 + keyboardBottomInset,
414 + child: FloatingActionButton.small(
415 + onPressed: onScrollToBottom,
416 + tooltip: AppLocalizations.of(context)!.scroll_to_bottom,
417 + child: const Icon(Icons.arrow_downward),
418 + ),
419 + ),
420 ],
421 ),
422 ),
@@ -435,14 +499,19 @@ class _MessageArea extends ConsumerWidget {
499 onDelete: (messageId) =>
500 ref.read(chatProvider.notifier).deleteMessage(messageId),
501 onEdit: (messageId, currentContent) async {
438 - final newContent = await EditMessageDialog.show(
502 + final result = await EditMessageDialog.showUserEdit(
503 context,
504 initialContent: currentContent,
505 );
442 - if (newContent != null && newContent != currentContent) {
506 + if (result == null || result.content == currentContent) return;
507 + if (result.regenerate) {
508 + await ref
509 + .read(chatProvider.notifier)
510 + .editMessage(messageId, result.content);
511 + } else {
512 await ref
513 .read(chatProvider.notifier)
445 - .editMessage(messageId, newContent);
514 + .editMessageSaveOnly(messageId, result.content);
515 }
516 },
517 onEditAssistant: (messageId, currentContent) async {
@@ -468,6 +537,8 @@ class _MessageArea extends ConsumerWidget {
537 .cycleMessageVariant(messageId, direction),
538 onSave: (message) =>
539 ref.read(savedMessagesProvider.notifier).saveMessage(message),
540 + onGenerateResponse: () =>
541 + ref.read(chatProvider.notifier).generateResponseForLastUser(),
542 onModelPicker: onModelPicker,
543 hasSmartReplies:
544 !isStreaming &&
@@ -594,7 +665,7 @@ class _ScreenAppBar extends ConsumerWidget {
665 contentPadding: EdgeInsets.zero,
666 ),
667 ),
597 - if (activeConversation != null)
668 + if (activeConversation != null) ...[
669 PopupMenuItem(
670 value: 'rename',
671 child: ListTile(
@@ -603,6 +674,15 @@ class _ScreenAppBar extends ConsumerWidget {
674 contentPadding: EdgeInsets.zero,
675 ),
676 ),
677 + PopupMenuItem(
678 + value: 'export_chat',
679 + child: ListTile(
680 + leading: const Icon(Icons.upload_outlined),
681 + title: Text(l10n.export_conversation),
682 + contentPadding: EdgeInsets.zero,
683 + ),
684 + ),
685 + ],
686 PopupMenuItem(
687 value: 'persona',
688 child: ListTile(
lib/features/chat/views/components/chat_bubble/assistant_bubble.dart
+17 -2
@@ -1,5 +1,6 @@
1 import 'dart:async';
2 import 'package:flutter/material.dart';
3 +import 'package:localmind/l10n/app_localizations.dart';
4 import 'package:localmind/core/models/enums.dart';
5 import 'package:localmind/core/theme/colors.dart';
6 import 'package:localmind/features/chat/data/models/message.dart';
@@ -84,6 +85,16 @@ class AssistantBubble extends StatelessWidget {
85 const TypingIndicator()
86 else if (isStreaming)
87 _StreamingContent(content: message.content, isDark: isDark)
88 + else if (message.content.isEmpty &&
89 + (message.reasoningContent?.isEmpty ?? true))
90 + Text(
91 + AppLocalizations.of(context)!.no_response,
92 + style: TextStyle(
93 + fontSize: 14,
94 + fontStyle: FontStyle.italic,
95 + color: muted,
96 + ),
97 + )
98 else
99 MarkdownContent(content: message.content, isDark: isDark),
100 if (message.status == MessageStatus.error &&
@@ -139,11 +150,15 @@ class AssistantBubble extends StatelessWidget {
150 if (!isStreaming &&
151 (message.status == MessageStatus.complete ||
152 message.status == MessageStatus.error ||
142 - message.status == MessageStatus.cancelled))
153 + message.status == MessageStatus.cancelled ||
154 + message.content.isEmpty))
155 MessageActionBar(
144 - content: message.content,
156 + content: message.content.isEmpty
157 + ? AppLocalizations.of(context)!.no_response
158 + : message.content,
159 tokenCount: message.tokenCount,
160 messageId: message.id,
161 + conversationId: message.conversationId,
162 onCopy: onCopy,
163 onRetry: onRetry,
164 onDelete: onDelete,
lib/features/chat/views/components/chat_bubble/attachment_list.dart
+150 -25
@@ -1,6 +1,7 @@
1 import 'dart:io';
2 import 'package:flutter/material.dart';
3 import 'package:localmind/core/theme/colors.dart';
4 +import 'package:localmind/features/chat/utils/attachment_helpers.dart';
5 import 'package:localmind/features/chat/views/components/audio_player_widget.dart';
6
7 class AttachmentList extends StatelessWidget {
@@ -24,27 +25,6 @@ class _AttachmentItem extends StatelessWidget {
25
26 final String path;
27
27 - bool _isImage(String path) {
28 - final mime = path.toLowerCase();
29 - return mime.endsWith('.jpg') ||
30 - mime.endsWith('.jpeg') ||
31 - mime.endsWith('.png') ||
32 - mime.endsWith('.gif') ||
33 - mime.endsWith('.webp');
34 - }
35 -
36 - bool _isAudio(String path) {
37 - final ext = path.toLowerCase();
38 - return ext.endsWith('.mp3') ||
39 - ext.endsWith('.wav') ||
40 - ext.endsWith('.ogg') ||
41 - ext.endsWith('.flac') ||
42 - ext.endsWith('.aac') ||
43 - ext.endsWith('.m4a') ||
44 - ext.endsWith('.wma') ||
45 - ext.endsWith('.opus');
46 - }
47 -
28 void _viewImage(BuildContext context) {
29 showDialog(
30 context: context,
@@ -53,14 +33,33 @@ class _AttachmentItem extends StatelessWidget {
33 );
34 }
35
36 + void _viewText(BuildContext context) async {
37 + final text = await AttachmentHelpers.readTextFile(path);
38 + if (!context.mounted) return;
39 + if (text == null) {
40 + ScaffoldMessenger.of(context).showSnackBar(
41 + const SnackBar(content: Text('Could not read file')),
42 + );
43 + return;
44 + }
45 + showModalBottomSheet<void>(
46 + context: context,
47 + isScrollControlled: true,
48 + builder: (context) => _TextPreviewSheet(
49 + fileName: AttachmentHelpers.fileNameOf(path),
50 + text: text,
51 + ),
52 + );
53 + }
54 +
55 @override
56 Widget build(BuildContext context) {
57 final theme = Theme.of(context);
58 final isDark = theme.brightness == Brightness.dark;
59 final file = File(path);
61 - final fileName = path.split('/').last;
60 + final fileName = AttachmentHelpers.fileNameOf(path);
61
63 - if (_isImage(path)) {
62 + if (AttachmentHelpers.isImagePath(path)) {
63 return GestureDetector(
64 onTap: () => _viewImage(context),
65 child: ClipRRect(
@@ -77,7 +76,53 @@ class _AttachmentItem extends StatelessWidget {
76 );
77 }
78
80 - if (_isAudio(path)) {
79 + if (AttachmentHelpers.isTextPath(path)) {
80 + return GestureDetector(
81 + onTap: () => _viewText(context),
82 + child: Container(
83 + width: 180,
84 + padding: const EdgeInsets.all(12),
85 + decoration: BoxDecoration(
86 + color: isDark ? AppColors.darkSurfaceCard : AppColors.lightSurface,
87 + borderRadius: BorderRadius.circular(12),
88 + border: Border.all(
89 + color: isDark ? AppColors.darkBorder : AppColors.lightBorder,
90 + ),
91 + ),
92 + child: Row(
93 + mainAxisSize: MainAxisSize.min,
94 + children: [
95 + Icon(
96 + Icons.description_outlined,
97 + size: 20,
98 + color: theme.colorScheme.primary,
99 + ),
100 + const SizedBox(width: 8),
101 + Expanded(
102 + child: Text(
103 + fileName,
104 + style: TextStyle(
105 + fontSize: 12,
106 + color: isDark ? Colors.white70 : Colors.black87,
107 + ),
108 + maxLines: 2,
109 + overflow: TextOverflow.ellipsis,
110 + ),
111 + ),
112 + ],
113 + ),
114 + ),
115 + );
116 + }
117 +
118 + if (path.toLowerCase().endsWith('.mp3') ||
119 + path.toLowerCase().endsWith('.wav') ||
120 + path.toLowerCase().endsWith('.ogg') ||
121 + path.toLowerCase().endsWith('.flac') ||
122 + path.toLowerCase().endsWith('.aac') ||
123 + path.toLowerCase().endsWith('.m4a') ||
124 + path.toLowerCase().endsWith('.wma') ||
125 + path.toLowerCase().endsWith('.opus')) {
126 return AudioPlayerWidget(source: path);
127 }
128
@@ -117,6 +162,86 @@ class _AttachmentItem extends StatelessWidget {
162 }
163 }
164
165 +class _TextPreviewSheet extends StatelessWidget {
166 + const _TextPreviewSheet({required this.fileName, required this.text});
167 +
168 + final String fileName;
169 + final String text;
170 +
171 + @override
172 + Widget build(BuildContext context) {
173 + final theme = Theme.of(context);
174 + final isDark = theme.brightness == Brightness.dark;
175 +
176 + return DraggableScrollableSheet(
177 + expand: false,
178 + initialChildSize: 0.7,
179 + minChildSize: 0.4,
180 + maxChildSize: 0.95,
181 + builder: (context, scrollController) {
182 + return Material(
183 + color: isDark ? const Color(0xFF121212) : Colors.white,
184 + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
185 + child: Column(
186 + children: [
187 + const SizedBox(height: 8),
188 + Container(
189 + width: 32,
190 + height: 4,
191 + decoration: BoxDecoration(
192 + color: isDark ? const Color(0xFF444444) : const Color(0xFFDDDDDD),
193 + borderRadius: BorderRadius.circular(2),
194 + ),
195 + ),
196 + Padding(
197 + padding: const EdgeInsets.all(16),
198 + child: Row(
199 + children: [
200 + Icon(Icons.description_outlined, color: theme.colorScheme.primary),
201 + const SizedBox(width: 8),
202 + Expanded(
203 + child: Text(
204 + fileName,
205 + style: theme.textTheme.titleMedium?.copyWith(
206 + fontWeight: FontWeight.w600,
207 + ),
208 + maxLines: 1,
209 + overflow: TextOverflow.ellipsis,
210 + ),
211 + ),
212 + IconButton(
213 + icon: const Icon(Icons.close),
214 + onPressed: () => Navigator.pop(context),
215 + ),
216 + ],
217 + ),
218 + ),
219 + const Divider(height: 1),
220 + Expanded(
221 + child: SingleChildScrollView(
222 + controller: scrollController,
223 + padding: const EdgeInsets.all(16),
224 + child: SelectableText(
225 + text,
226 + style: TextStyle(
227 + fontSize: 14,
228 + height: 1.5,
229 + fontFamily: AttachmentHelpers.isTextPath(fileName)
230 + ? 'monospace'
231 + : null,
232 + color: isDark ? Colors.white : Colors.black87,
233 + ),
234 + ),
235 + ),
236 + ),
237 + ],
238 + ),
239 + );
240 + },
241 + );
242 + }
243 +}
244 +
245 class _FilePlaceholder extends StatelessWidget {
246 const _FilePlaceholder({required this.fileName});
247 final String fileName;
@@ -129,7 +254,7 @@ class _FilePlaceholder extends StatelessWidget {
254 color: Colors.grey[800],
255 child: Center(
256 child: Text(
132 - fileName.split('.').last.toUpperCase(),
257 + AttachmentHelpers.extensionOf(fileName).toUpperCase(),
258 style: const TextStyle(
259 color: Colors.white,
260 fontWeight: FontWeight.bold,
lib/features/chat/views/components/chat_bubble/user_bubble.dart
+1
@@ -117,6 +117,7 @@ class UserBubble extends StatelessWidget {
117 content: message.content,
118 tokenCount: message.tokenCount,
119 messageId: message.id,
120 + conversationId: message.conversationId,
121 onCopy: onCopy,
122 onDelete: onDelete,
123 onEdit: onEdit,
lib/features/chat/views/components/chat_input_bar.dart
+22 -7
@@ -151,7 +151,9 @@ class _ChatInputBarState extends ConsumerState<ChatInputBar>
151
152 Future<void> _handleAttach() async {
153 final result = await FilePicker.pickFiles(
154 - type: FileType.image,
154 + type: FileType.custom,
155 + allowedExtensions: const ['jpg', 'jpeg', 'png', 'gif', 'webp', 'txt', 'md'],
156 + allowMultiple: true,
157 );
158 if (result != null && result.files.isNotEmpty) {
159 setState(() {
@@ -337,6 +339,8 @@ class _ChatInputBarState extends ConsumerState<ChatInputBar>
339 separatorBuilder: (_, _) => const SizedBox(width: 8),
340 itemBuilder: (context, index) {
341 final file = _attachedFiles[index];
342 + final ext = file.path.split('.').last.toLowerCase();
343 + final isImage = !['txt', 'md'].contains(ext);
344 return Stack(
345 clipBehavior: Clip.none,
346 children: [
@@ -351,12 +355,23 @@ class _ChatInputBarState extends ConsumerState<ChatInputBar>
355 ),
356 child: ClipRRect(
357 borderRadius: BorderRadius.circular(11),
354 - child: Image.file(
355 - file,
356 - width: 48,
357 - height: 48,
358 - fit: BoxFit.cover,
359 - ),
358 + child: isImage
359 + ? Image.file(
360 + file,
361 + width: 48,
362 + height: 48,
363 + fit: BoxFit.cover,
364 + )
365 + : Container(
366 + width: 48,
367 + height: 48,
368 + color: theme.colorScheme.surfaceContainerHighest,
369 + child: Icon(
370 + Icons.description_outlined,
371 + size: 22,
372 + color: theme.colorScheme.primary,
373 + ),
374 + ),
375 ),
376 ),
377 PositionedDirectional(
lib/features/chat/views/components/edit_message_dialog.dart
+48 -7
@@ -1,6 +1,7 @@
1 import 'package:flutter/material.dart';
2 import 'package:shadcn_ui/shadcn_ui.dart';
3 import 'package:localmind/l10n/app_localizations.dart';
4 +import 'edit_message_result.dart';
5
6 class EditMessageDialog extends StatefulWidget {
7 const EditMessageDialog({
@@ -8,11 +9,13 @@ class EditMessageDialog extends StatefulWidget {
9 required this.initialContent,
10 this.description,
11 this.saveLabel,
12 + this.showSaveOnly = false,
13 });
14
15 final String initialContent;
16 final String? description;
17 final String? saveLabel;
18 + final bool showSaveOnly;
19
20 static Future<String?> show(
21 BuildContext context, {
@@ -32,6 +35,21 @@ class EditMessageDialog extends StatefulWidget {
35 );
36 }
37
38 + static Future<EditMessageResult?> showUserEdit(
39 + BuildContext context, {
40 + required String initialContent,
41 + }) {
42 + return showModalBottomSheet<EditMessageResult>(
43 + context: context,
44 + isScrollControlled: true,
45 + backgroundColor: Colors.transparent,
46 + builder: (context) => EditMessageDialog(
47 + initialContent: initialContent,
48 + showSaveOnly: true,
49 + ),
50 + );
51 + }
52 +
53 @override
54 State<EditMessageDialog> createState() => _EditMessageDialogState();
55 }
@@ -61,10 +79,22 @@ class _EditMessageDialogState extends State<EditMessageDialog> {
79 super.dispose();
80 }
81
64 - void _save() {
82 + String? _validatedText() {
83 final text = _controller.text.trim();
66 - if (text.isEmpty) return;
67 - Navigator.of(context).pop(text);
84 + if (text.isEmpty) return null;
85 + return text;
86 + }
87 +
88 + void _popResult({required bool regenerate}) {
89 + final text = _validatedText();
90 + if (text == null) return;
91 + if (widget.showSaveOnly) {
92 + Navigator.of(context).pop(
93 + EditMessageResult(content: text, regenerate: regenerate),
94 + );
95 + } else {
96 + Navigator.of(context).pop(text);
97 + }
98 }
99
100 @override
@@ -147,10 +177,21 @@ class _EditMessageDialogState extends State<EditMessageDialog> {
177 child: Text(l10n.cancel),
178 ),
179 const SizedBox(width: 8),
150 - ShadButton(
151 - onPressed: _save,
152 - child: Text(widget.saveLabel ?? l10n.save_regenerate),
153 - ),
180 + if (widget.showSaveOnly) ...[
181 + ShadButton.outline(
182 + onPressed: () => _popResult(regenerate: false),
183 + child: Text(l10n.save),
184 + ),
185 + const SizedBox(width: 8),
186 + ShadButton(
187 + onPressed: () => _popResult(regenerate: true),
188 + child: Text(l10n.save_regenerate),
189 + ),
190 + ] else
191 + ShadButton(
192 + onPressed: () => _popResult(regenerate: true),
193 + child: Text(widget.saveLabel ?? l10n.save_regenerate),
194 + ),
195 ],
196 ),
197 ],
lib/features/chat/views/components/edit_message_result.dart new
+9
@@ -0,0 +1,9 @@
1 +class EditMessageResult {
2 + const EditMessageResult({
3 + required this.content,
4 + required this.regenerate,
5 + });
6 +
7 + final String content;
8 + final bool regenerate;
9 +}
lib/features/chat/views/components/message_action_bar.dart
+30 -20
@@ -12,6 +12,7 @@ class MessageActionBar extends ConsumerStatefulWidget {
12 required this.content,
13 this.tokenCount,
14 this.messageId,
15 + this.conversationId,
16 this.onCopy,
17 this.onRetry,
18 this.onDelete,
@@ -25,6 +26,7 @@ class MessageActionBar extends ConsumerStatefulWidget {
26 final String content;
27 final int? tokenCount;
28 final String? messageId;
29 + final String? conversationId;
30 final VoidCallback? onCopy;
31 final VoidCallback? onRetry;
32 final VoidCallback? onDelete;
@@ -43,8 +45,7 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
45 void _toggleTts() async {
46 final ttsNotifier = ref.read(tts.ttsProvider.notifier);
47 final ttsState = ref.read(tts.ttsProvider);
46 - final isThisActive = ttsState.playingContent == widget.content &&
47 - (ttsState.isSpeaking || ttsState.isPaused);
48 + final isThisActive = _isMessageActive(ttsState);
49
50 if (isThisActive) {
51 ttsNotifier.togglePauseResume();
@@ -53,19 +54,33 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
54 await ttsNotifier.stop();
55 }
56 try {
56 - await ttsNotifier.speak(widget.content);
57 + await ttsNotifier.speak(
58 + widget.content,
59 + messageId: widget.messageId,
60 + conversationId: widget.conversationId,
61 + );
62 } catch (_) {}
63 }
64 }
65
66 + bool _isMessageActive(tts.TtsState ttsState) {
67 + if (widget.messageId != null &&
68 + ttsState.playingMessageId == widget.messageId) {
69 + return ttsState.isSpeaking || ttsState.isPaused;
70 + }
71 + return ttsState.playingContent == widget.content &&
72 + (ttsState.isSpeaking || ttsState.isPaused);
73 + }
74 +
75 @override
76 Widget build(BuildContext context) {
77 final l10n = AppLocalizations.of(context)!;
78 final ttsState = ref.watch(tts.ttsProvider);
65 - final isThisActive = ttsState.playingContent == widget.content &&
66 - (ttsState.isSpeaking || ttsState.isPaused);
67 - final isThisPlaying = ttsState.playingContent == widget.content && ttsState.isSpeaking && !ttsState.isPaused;
68 - final isThisInitializing = ttsState.playingContent == widget.content && ttsState.isInitializing;
79 + final isThisActive = _isMessageActive(ttsState);
80 + final isThisPlaying =
81 + isThisActive && ttsState.isSpeaking && !ttsState.isPaused;
82 + final isThisInitializing =
83 + isThisActive && ttsState.isInitializing;
84
85 return Row(
86 mainAxisSize: MainAxisSize.min,
@@ -170,9 +185,9 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
185 void _showMoreOptions(BuildContext context) {
186 final sheetL10n = AppLocalizations.of(context)!;
187 final ttsState = ref.read(tts.ttsProvider);
173 - final isThisActive = ttsState.playingContent == widget.content &&
174 - (ttsState.isSpeaking || ttsState.isPaused);
175 - final isThisPlaying = ttsState.playingContent == widget.content && ttsState.isSpeaking && !ttsState.isPaused;
188 + final isThisActive = _isMessageActive(ttsState);
189 + final isThisPlaying =
190 + isThisActive && ttsState.isSpeaking && !ttsState.isPaused;
191
192 showShadSheet(
193 context: context,
@@ -244,17 +259,12 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
259 title: Text(sheetL10n.character_count(widget.content.length)),
260 enabled: false,
261 ),
247 - ListTile(
248 - leading: const Icon(Icons.token),
249 - title: Text(
250 - widget.tokenCount != null
251 - ? sheetL10n.token_count(widget.tokenCount!)
252 - : sheetL10n.estimated_token_count(
253 - (widget.content.length / 4).ceil(),
254 - ),
262 + if (widget.tokenCount != null)
263 + ListTile(
264 + leading: const Icon(Icons.numbers),
265 + title: Text(sheetL10n.token_count(widget.tokenCount!)),
266 + enabled: false,
267 ),
256 - enabled: false,
257 - ),
268 ],
269 ),
270 ),
lib/features/chat/views/components/message_list/empty_state.dart
+29 -17
@@ -1,3 +1,5 @@
1 +import 'dart:async';
2 +
3 import 'package:flutter/material.dart';
4 import 'package:localmind/core/theme/colors.dart';
5 import 'package:localmind/l10n/app_localizations.dart';
@@ -34,6 +36,8 @@ class _EmptyStateState extends State<EmptyState>
36 late AnimationController _controller;
37 late List<Animation<double>> _fadeAnimations;
38 late List<Animation<Offset>> _slideAnimations;
39 + Timer? _welcomeTimer;
40 + int _welcomeIndex = 0;
41
42 @override
43 void initState() {
@@ -73,14 +77,26 @@ class _EmptyStateState extends State<EmptyState>
77 });
78
79 _controller.forward();
80 + _welcomeTimer = Timer.periodic(const Duration(seconds: 5), (_) {
81 + if (!mounted) return;
82 + setState(() => _welcomeIndex = (_welcomeIndex + 1) % 4);
83 + });
84 }
85
86 @override
87 void dispose() {
88 + _welcomeTimer?.cancel();
89 _controller.dispose();
90 super.dispose();
91 }
92
93 + List<String> _welcomeMessages(AppLocalizations l10n) => [
94 + l10n.welcome_message_1,
95 + l10n.welcome_message_2,
96 + l10n.welcome_message_3,
97 + l10n.welcome_message_4,
98 + ];
99 +
100 @override
101 Widget build(BuildContext context) {
102 final l10n = AppLocalizations.of(context)!;
@@ -94,10 +110,21 @@ class _EmptyStateState extends State<EmptyState>
110 mainAxisAlignment: MainAxisAlignment.center,
111 children: [
112 const SizedBox(height: 24),
113 + AnimatedSwitcher(
114 + duration: const Duration(milliseconds: 400),
115 + child: Text(
116 + _welcomeMessages(l10n)[_welcomeIndex],
117 + key: ValueKey(_welcomeIndex),
118 + textAlign: TextAlign.center,
119 + style: theme.textTheme.headlineSmall?.copyWith(
120 + fontWeight: FontWeight.w600,
121 + color: isDark ? Colors.white : Colors.black87,
122 + ),
123 + ),
124 + ),
125 + const SizedBox(height: 32),
126 _buildModelAndPersonaSelectors(isDark, theme, l10n),
127 const SizedBox(height: 32),
99 - _buildStartText(l10n),
100 - const SizedBox(height: 16),
128 _buildQuickPrompts(isDark),
129 if (widget.recentConversations.isNotEmpty) ...[
130 const SizedBox(height: 32),
@@ -222,21 +249,6 @@ class _EmptyStateState extends State<EmptyState>
249 );
250 }
251
225 - Widget _buildStartText(AppLocalizations l10n) {
226 - return TweenAnimationBuilder<double>(
227 - tween: Tween(begin: 0.0, end: 1.0),
228 - duration: const Duration(milliseconds: 400),
229 - curve: Curves.easeOut,
230 - builder: (context, value, child) {
231 - return Opacity(opacity: value, child: child);
232 - },
233 - child: Text(
234 - l10n.start_conversation,
235 - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
236 - ),
237 - );
238 - }
239 -
252 Widget _buildQuickPrompts(bool isDark) {
253 return Wrap(
254 spacing: 8,
lib/features/chat/views/components/message_list/message_list.dart
+51 -19
@@ -4,6 +4,7 @@ import 'package:localmind/core/models/enums.dart';
4 import 'package:localmind/features/chat/data/models/message.dart';
5 import 'package:localmind/features/chat/providers/chat_providers.dart';
6 import 'package:localmind/features/conversations/providers/conversation_providers.dart';
7 +import 'package:localmind/l10n/app_localizations.dart';
8 import '../chat_bubble.dart';
9
10 class MessageList extends StatelessWidget {
@@ -22,6 +23,7 @@ class MessageList extends StatelessWidget {
23 required this.onCycleVariant,
24 required this.onModelPicker,
25 this.onSave,
26 + this.onGenerateResponse,
27 this.hasSmartReplies = false,
28 this.bottomInset = 0,
29 });
@@ -39,6 +41,7 @@ class MessageList extends StatelessWidget {
41 final void Function(String messageId, int direction) onCycleVariant;
42 final VoidCallback onModelPicker;
43 final void Function(Message message)? onSave;
44 + final VoidCallback? onGenerateResponse;
45 final bool hasSmartReplies;
46 final double bottomInset;
47
@@ -58,6 +61,7 @@ class MessageList extends StatelessWidget {
61 onCycleVariant: onCycleVariant,
62 onModelPicker: onModelPicker,
63 onSave: onSave,
64 + onGenerateResponse: onGenerateResponse,
65 hasSmartReplies: hasSmartReplies,
66 bottomInset: bottomInset,
67 );
@@ -79,6 +83,7 @@ class _MessageListConsumer extends ConsumerStatefulWidget {
83 required this.onCycleVariant,
84 required this.onModelPicker,
85 this.onSave,
86 + this.onGenerateResponse,
87 this.hasSmartReplies = false,
88 this.bottomInset = 0,
89 });
@@ -96,6 +101,7 @@ class _MessageListConsumer extends ConsumerStatefulWidget {
101 final void Function(String messageId, int direction) onCycleVariant;
102 final VoidCallback onModelPicker;
103 final void Function(Message message)? onSave;
104 + final VoidCallback? onGenerateResponse;
105 final bool hasSmartReplies;
106 final double bottomInset;
107
@@ -164,6 +170,7 @@ class _MessageListConsumerState extends ConsumerState<_MessageListConsumer> {
170 onCycleVariant: widget.onCycleVariant,
171 onModelPicker: widget.onModelPicker,
172 onSave: widget.onSave,
173 + onGenerateResponse: widget.onGenerateResponse,
174 hasSmartReplies: widget.hasSmartReplies,
175 bottomInset: widget.bottomInset,
176 );
@@ -187,6 +194,7 @@ class _MessageList extends StatelessWidget {
194 required this.onCycleVariant,
195 required this.onModelPicker,
196 this.onSave,
197 + this.onGenerateResponse,
198 this.hasSmartReplies = false,
199 this.bottomInset = 0,
200 });
@@ -206,6 +214,7 @@ class _MessageList extends StatelessWidget {
214 final void Function(String messageId, int direction) onCycleVariant;
215 final VoidCallback onModelPicker;
216 final void Function(Message message)? onSave;
217 + final VoidCallback? onGenerateResponse;
218 final bool hasSmartReplies;
219 final double bottomInset;
220
@@ -249,26 +258,49 @@ class _MessageList extends StatelessWidget {
258 final itemKey =
259 messageKeys.putIfAbsent(message.id, GlobalKey.new);
260
252 - return ChatBubble(
253 - key: itemKey,
254 - message: message,
255 - allMessages: this.allMessages,
256 - isStreaming:
257 - isLast && isStreaming && message.id == streamingMessage?.id,
258 - onRetry: () => onRetry(message.id),
259 - onDelete: () => onDelete(message.id),
260 - onEdit: message.role == MessageRole.user
261 - ? () => onEdit(message.id, message.content)
262 - : message.role == MessageRole.assistant
263 - ? () => onEditAssistant(message.id, message.content)
261 + return Column(
262 + crossAxisAlignment: CrossAxisAlignment.stretch,
263 + children: [
264 + ChatBubble(
265 + key: itemKey,
266 + message: message,
267 + allMessages: this.allMessages,
268 + isStreaming:
269 + isLast && isStreaming && message.id == streamingMessage?.id,
270 + onRetry: () => onRetry(message.id),
271 + onDelete: () => onDelete(message.id),
272 + onEdit: message.role == MessageRole.user
273 + ? () => onEdit(message.id, message.content)
274 + : message.role == MessageRole.assistant
275 + ? () => onEditAssistant(message.id, message.content)
276 + : null,
277 + onBranch: () => onBranch(message.id),
278 + onContinue: message.role == MessageRole.assistant && isLast
279 + ? () => onContinue(message.id)
280 : null,
265 - onBranch: () => onBranch(message.id),
266 - onContinue: message.role == MessageRole.assistant && isLast
267 - ? () => onContinue(message.id)
268 - : null,
269 - onCycleVariant: (direction) => onCycleVariant(message.id, direction),
270 - onModelTap: onModelPicker,
271 - onSave: onSave,
281 + onCycleVariant: (direction) =>
282 + onCycleVariant(message.id, direction),
283 + onModelTap: onModelPicker,
284 + onSave: onSave,
285 + ),
286 + if (!isStreaming &&
287 + isLast &&
288 + message.role == MessageRole.user &&
289 + onGenerateResponse != null)
290 + Padding(
291 + padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
292 + child: Align(
293 + alignment: Alignment.centerLeft,
294 + child: OutlinedButton.icon(
295 + onPressed: onGenerateResponse,
296 + icon: const Icon(Icons.auto_awesome, size: 16),
297 + label: Text(
298 + AppLocalizations.of(context)!.generate_ai_response,
299 + ),
300 + ),
301 + ),
302 + ),
303 + ],
304 );
305 },
306 );
lib/features/conversations/providers/conversation_providers.dart
+14
@@ -642,6 +642,20 @@ final scrollToMessageIdProvider =
642 return ScrollToMessageNotifier();
643 });
644
645 +final focusHistorySearchProvider =
646 + NotifierProvider<FocusHistorySearchNotifier, int>(() {
647 + return FocusHistorySearchNotifier();
648 + });
649 +
650 +class FocusHistorySearchNotifier extends Notifier<int> {
651 + @override
652 + int build() => 0;
653 +
654 + void requestFocus() => state++;
655 +
656 + void clear() {}
657 +}
658 +
659 class ScrollToMessageNotifier extends Notifier<String?> {
660 @override
661 String? build() => null;
lib/features/conversations/views/components/conversation_list.dart
+33
@@ -1,7 +1,13 @@
1 +import 'dart:convert';
2 +import 'dart:typed_data';
3 +
4 +import 'package:file_picker/file_picker.dart';
5 import 'package:flutter/material.dart';
6 import 'package:flutter_riverpod/flutter_riverpod.dart';
7 import 'package:go_router/go_router.dart';
8 import 'package:localmind/l10n/app_localizations.dart';
9 +import 'package:localmind/core/providers/storage_providers.dart';
10 +import 'package:localmind/core/services/data_backup_service.dart';
11 import '../../../../core/routes/app_routes.dart';
12 import '../../../chat/providers/chat_providers.dart';
13 import '../../data/models/conversation.dart';
@@ -87,6 +93,9 @@ class ConversationList extends ConsumerWidget {
93 onMoveToFolder: () {
94 _showMoveToFolderSheet(context, ref, l10n, conversation);
95 },
96 + onExport: () {
97 + _exportConversation(context, ref, l10n, conversation);
98 + },
99 );
100 }),
101 ],
@@ -215,4 +224,28 @@ class ConversationList extends ConsumerWidget {
224 },
225 );
226 }
227 +
228 + Future<void> _exportConversation(
229 + BuildContext context,
230 + WidgetRef ref,
231 + AppLocalizations l10n,
232 + Conversation conversation,
233 + ) async {
234 + final db = ref.read(databaseProvider);
235 + final json = DataBackupService()
236 + .exportConversationAsJson(db.store, conversation.id);
237 + final saved = await FilePicker.saveFile(
238 + dialogTitle: l10n.export_conversation,
239 + fileName:
240 + 'localmind_${conversation.title.replaceAll(RegExp(r'[^\w\-]+'), '_')}_${DateTime.now().millisecondsSinceEpoch}.json',
241 + type: FileType.custom,
242 + allowedExtensions: const ['json'],
243 + bytes: Uint8List.fromList(utf8.encode(json)),
244 + );
245 + if (saved != null && context.mounted) {
246 + ScaffoldMessenger.of(context).showSnackBar(
247 + SnackBar(content: Text(l10n.export_data_success)),
248 + );
249 + }
250 + }
251 }
lib/features/conversations/views/components/conversation_search_bar.dart
+25 -3
@@ -13,15 +13,38 @@ class ConversationSearchBar extends ConsumerStatefulWidget {
13
14 class _ConversationSearchBarState extends ConsumerState<ConversationSearchBar> {
15 final TextEditingController _controller = TextEditingController();
16 + final FocusNode _focusNode = FocusNode();
17 + int _lastFocusRequest = 0;
18
19 @override
20 void dispose() {
21 _controller.dispose();
22 + _focusNode.dispose();
23 super.dispose();
24 }
25
26 + void _requestSearchFocus() {
27 + WidgetsBinding.instance.addPostFrameCallback((_) {
28 + if (!mounted) return;
29 + _focusNode.requestFocus();
30 + });
31 + }
32 +
33 @override
34 Widget build(BuildContext context) {
35 + ref.listen<int>(focusHistorySearchProvider, (previous, next) {
36 + if (next != _lastFocusRequest) {
37 + _lastFocusRequest = next;
38 + _requestSearchFocus();
39 + }
40 + });
41 +
42 + final pendingFocus = ref.watch(focusHistorySearchProvider);
43 + if (pendingFocus != _lastFocusRequest) {
44 + _lastFocusRequest = pendingFocus;
45 + _requestSearchFocus();
46 + }
47 +
48 final l10n = AppLocalizations.of(context)!;
49 final theme = Theme.of(context);
50 final isDark = theme.brightness == Brightness.dark;
@@ -38,6 +61,7 @@ class _ConversationSearchBarState extends ConsumerState<ConversationSearchBar> {
61 ),
62 child: TextField(
63 controller: _controller,
64 + focusNode: _focusNode,
65 onChanged: (value) {
66 ref.read(conversationSearchProvider.notifier).setSearchQuery(value);
67 setState(() {});
@@ -62,9 +86,7 @@ class _ConversationSearchBarState extends ConsumerState<ConversationSearchBar> {
86 children: [
87 IconButton(
88 icon: Icon(
65 - searchContents
66 - ? Icons.article
67 - : Icons.article_outlined,
89 + searchContents ? Icons.article : Icons.article_outlined,
90 size: 20,
91 color: searchContents
92 ? theme.colorScheme.primary
lib/features/conversations/views/components/conversation_tile.dart
+10
@@ -15,6 +15,7 @@ class ConversationTile extends StatelessWidget {
15 required this.onDelete,
16 required this.onDuplicate,
17 required this.onMoveToFolder,
18 + required this.onExport,
19 });
20
21 final Conversation conversation;
@@ -25,6 +26,7 @@ class ConversationTile extends StatelessWidget {
26 final VoidCallback onDelete;
27 final VoidCallback onDuplicate;
28 final VoidCallback onMoveToFolder;
29 + final VoidCallback onExport;
30
31 String _formatTimestamp(AppLocalizations l10n, DateTime dateTime) {
32 final now = DateTime.now();
@@ -201,6 +203,14 @@ class ConversationTile extends StatelessWidget {
203 onMoveToFolder();
204 },
205 ),
206 + ListTile(
207 + leading: const Icon(Icons.upload_outlined),
208 + title: Text(l10n.export_conversation),
209 + onTap: () {
210 + Navigator.pop(ctx);
211 + onExport();
212 + },
213 + ),
214 ListTile(
215 leading: const Icon(Icons.delete_outline, color: Colors.red),
216 title: Text(
lib/features/personas/providers/personas_providers.dart
+13
@@ -41,6 +41,19 @@ final personaCategoryFilterProvider =
41 _CategoryFilterNotifier.new,
42 );
43
44 +final personaPreviewSystemPromptsProvider =
45 + NotifierProvider<_PreviewSystemPromptsNotifier, bool>(
46 + _PreviewSystemPromptsNotifier.new,
47 + );
48 +
49 +class _PreviewSystemPromptsNotifier extends Notifier<bool> {
50 + @override
51 + bool build() => false;
52 +
53 + void toggle() => state = !state;
54 + void set(bool value) => state = value;
55 +}
56 +
57 class _CategoryFilterNotifier extends Notifier<String?> {
58 @override
59 String? build() => null;
lib/features/personas/views/persona_list_screen.dart
+38 -14
@@ -17,6 +17,7 @@ class PersonaListScreen extends ConsumerWidget {
17 final builtIn = filteredPersonas.where((p) => p.isBuiltIn).toList();
18 final userCreated = filteredPersonas.where((p) => !p.isBuiltIn).toList();
19 final selectedCategory = ref.watch(personaCategoryFilterProvider);
20 + final previewSystemPrompts = ref.watch(personaPreviewSystemPromptsProvider);
21 final theme = Theme.of(context);
22 final isDark = theme.brightness == Brightness.dark;
23 final topPadding = MediaQuery.of(context).padding.top;
@@ -53,14 +54,27 @@ class PersonaListScreen extends ConsumerWidget {
54 ),
55 ),
56 const SizedBox(width: 8),
56 - Text(
57 - l10n.personas_title,
58 - style: TextStyle(
59 - fontSize: 20,
60 - fontWeight: FontWeight.bold,
61 - color: isDark ? Colors.white : Colors.black,
57 + Expanded(
58 + child: Text(
59 + l10n.personas_title,
60 + style: TextStyle(
61 + fontSize: 20,
62 + fontWeight: FontWeight.bold,
63 + color: isDark ? Colors.white : Colors.black,
64 + ),
65 ),
66 ),
67 + IconButton(
68 + icon: Icon(
69 + previewSystemPrompts
70 + ? Icons.visibility
71 + : Icons.visibility_outlined,
72 + ),
73 + tooltip: l10n.preview_system_prompts,
74 + onPressed: () => ref
75 + .read(personaPreviewSystemPromptsProvider.notifier)
76 + .toggle(),
77 + ),
78 ],
79 ),
80 ),
@@ -148,6 +162,7 @@ class PersonaListScreen extends ConsumerWidget {
162 persona: p,
163 isDark: isDark,
164 l10n: l10n,
165 + showSystemPrompt: previewSystemPrompts,
166 onLongPress: () => _showActions(
167 context,
168 ref,
@@ -308,6 +323,7 @@ class _PersonaCard extends StatelessWidget {
323 required this.persona,
324 required this.isDark,
325 required this.l10n,
326 + this.showSystemPrompt = false,
327 this.onTap,
328 this.onLongPress,
329 });
@@ -315,6 +331,7 @@ class _PersonaCard extends StatelessWidget {
331 final Persona persona;
332 final bool isDark;
333 final AppLocalizations l10n;
334 + final bool showSystemPrompt;
335 final VoidCallback? onTap;
336 final VoidCallback? onLongPress;
337
@@ -401,19 +418,26 @@ class _PersonaCard extends StatelessWidget {
418 ),
419 const SizedBox(height: 2),
420 Text(
404 - persona.description ??
405 - persona.systemPrompt.substring(
406 - 0,
407 - persona.systemPrompt.length.clamp(0, 60),
408 - ),
421 + showSystemPrompt && persona.isBuiltIn
422 + ? persona.systemPrompt
423 + : (persona.description ??
424 + persona.systemPrompt.substring(
425 + 0,
426 + persona.systemPrompt.length.clamp(0, 60),
427 + )),
428 style: TextStyle(
410 - fontSize: 13,
429 + fontSize: showSystemPrompt && persona.isBuiltIn ? 12 : 13,
430 color: isDark
431 ? const Color(0xFF888888)
432 : const Color(0xFF999999),
433 + fontFamily: showSystemPrompt && persona.isBuiltIn
434 + ? 'monospace'
435 + : null,
436 ),
415 - maxLines: 1,
416 - overflow: TextOverflow.ellipsis,
437 + maxLines: showSystemPrompt && persona.isBuiltIn ? 8 : 1,
438 + overflow: showSystemPrompt && persona.isBuiltIn
439 + ? TextOverflow.fade
440 + : TextOverflow.ellipsis,
441 ),
442 if (persona.category != null) ...[
443 const SizedBox(height: 4),
lib/features/saved_messages/views/saved_messages_screen.dart
+89 -37
@@ -1,7 +1,13 @@
1 import 'package:flutter/material.dart';
2 +import 'package:flutter/services.dart';
3 import 'package:flutter_riverpod/flutter_riverpod.dart';
4 +import 'package:go_router/go_router.dart';
5 import 'package:localmind/core/models/enums.dart';
6 +import 'package:localmind/core/routes/app_routes.dart';
7 import 'package:localmind/l10n/app_localizations.dart';
8 +import '../../chat/providers/chat_providers.dart';
9 +import '../../conversations/data/models/conversation.dart';
10 +import '../../conversations/providers/conversation_providers.dart';
11 import '../providers/saved_message_providers.dart';
12 import 'components/saved_message_folder_bar.dart';
13
@@ -72,7 +78,7 @@ class SavedMessagesScreen extends ConsumerWidget {
78 return ListView.separated(
79 padding: const EdgeInsets.all(16),
80 itemCount: messages.length,
75 - separatorBuilder: (_, __) => const SizedBox(height: 8),
81 + separatorBuilder: (_, _) => const SizedBox(height: 8),
82 itemBuilder: (context, index) {
83 final saved = messages[index];
84 final role = MessageRole.values[saved.roleIndex];
@@ -94,43 +100,89 @@ class SavedMessagesScreen extends ConsumerWidget {
100 overflow: TextOverflow.ellipsis,
101 ),
102 isThreeLine: true,
97 - trailing: PopupMenuButton<String>(
98 - onSelected: (value) async {
99 - if (value == 'delete') {
100 - await ref
101 - .read(savedMessagesProvider.notifier)
102 - .deleteSavedMessage(saved.id);
103 - } else if (value.startsWith('folder:')) {
104 - final folderId = value.substring(7);
105 - await ref
106 - .read(savedMessagesProvider.notifier)
107 - .moveToFolder(
108 - saved.id,
109 - folderId.isEmpty ? null : folderId,
110 - );
103 + onTap: () async {
104 + final conversations =
105 + ref.read(conversationsProvider).value ?? [];
106 + Conversation? conversation;
107 + for (final conv in conversations) {
108 + if (conv.id == saved.conversationId) {
109 + conversation = conv;
110 + break;
111 + }
112 + }
113 + if (conversation == null) return;
114 +
115 + ref
116 + .read(scrollToMessageIdProvider.notifier)
117 + .scrollTo(saved.sourceMessageId);
118 + await ref
119 + .read(chatProvider.notifier)
120 + .loadConversation(conversation);
121 + if (context.mounted) {
122 + if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
123 + Navigator.pop(context);
124 }
112 - },
113 - itemBuilder: (context) {
114 - final folders =
115 - ref.read(savedMessageFoldersProvider).value ??
116 - [];
117 - return [
118 - ...folders.map(
119 - (folder) => PopupMenuItem(
120 - value: 'folder:${folder.id}',
121 - child: Text(folder.name),
122 - ),
123 - ),
124 - PopupMenuItem(
125 - value: 'folder:',
126 - child: Text(l10n.unfiled_chats),
127 - ),
128 - PopupMenuItem(
129 - value: 'delete',
130 - child: Text(l10n.delete),
131 - ),
132 - ];
133 - },
125 + context.go(AppRoutes.home);
126 + }
127 + },
128 + trailing: Row(
129 + mainAxisSize: MainAxisSize.min,
130 + children: [
131 + IconButton(
132 + icon: const Icon(Icons.copy_outlined, size: 20),
133 + tooltip: l10n.copy,
134 + onPressed: () async {
135 + await Clipboard.setData(
136 + ClipboardData(text: saved.content),
137 + );
138 + if (context.mounted) {
139 + ScaffoldMessenger.of(context).showSnackBar(
140 + SnackBar(
141 + content: Text(l10n.copied_to_clipboard),
142 + ),
143 + );
144 + }
145 + },
146 + ),
147 + PopupMenuButton<String>(
148 + onSelected: (value) async {
149 + if (value == 'delete') {
150 + await ref
151 + .read(savedMessagesProvider.notifier)
152 + .deleteSavedMessage(saved.id);
153 + } else if (value.startsWith('folder:')) {
154 + final folderId = value.substring(7);
155 + await ref
156 + .read(savedMessagesProvider.notifier)
157 + .moveToFolder(
158 + saved.id,
159 + folderId.isEmpty ? null : folderId,
160 + );
161 + }
162 + },
163 + itemBuilder: (context) {
164 + final folders =
165 + ref.read(savedMessageFoldersProvider).value ??
166 + [];
167 + return [
168 + ...folders.map(
169 + (folder) => PopupMenuItem(
170 + value: 'folder:${folder.id}',
171 + child: Text(folder.name),
172 + ),
173 + ),
174 + PopupMenuItem(
175 + value: 'folder:',
176 + child: Text(l10n.unfiled_chats),
177 + ),
178 + PopupMenuItem(
179 + value: 'delete',
180 + child: Text(l10n.delete),
181 + ),
182 + ];
183 + },
184 + ),
185 + ],
186 ),
187 ),
188 );
lib/features/settings/data/models/app_settings.dart
+6
@@ -37,6 +37,7 @@ class AppSettings {
37 final double ttsSpeed;
38 final KittenTtsModelVariant kittenTtsModelVariant;
39 final bool autoSpeakEnabled;
40 + final bool ttsProcessMarkdown;
41 final bool smartReplyEnabled;
42 final String? localeCode;
43 final String? huggingFaceToken;
@@ -69,6 +70,7 @@ class AppSettings {
70 this.ttsSpeed = 1.0,
71 this.kittenTtsModelVariant = KittenTtsModelVariant.nanoInt8,
72 this.autoSpeakEnabled = false,
73 + this.ttsProcessMarkdown = true,
74 this.smartReplyEnabled = true,
75 this.localeCode,
76 this.huggingFaceToken,
@@ -102,6 +104,7 @@ class AppSettings {
104 double? ttsSpeed,
105 KittenTtsModelVariant? kittenTtsModelVariant,
106 bool? autoSpeakEnabled,
107 + bool? ttsProcessMarkdown,
108 bool? smartReplyEnabled,
109 Object? localeCode = _unset,
110 Object? huggingFaceToken = _unset,
@@ -140,6 +143,7 @@ class AppSettings {
143 kittenTtsModelVariant:
144 kittenTtsModelVariant ?? this.kittenTtsModelVariant,
145 autoSpeakEnabled: autoSpeakEnabled ?? this.autoSpeakEnabled,
146 + ttsProcessMarkdown: ttsProcessMarkdown ?? this.ttsProcessMarkdown,
147 smartReplyEnabled: smartReplyEnabled ?? this.smartReplyEnabled,
148 localeCode: identical(localeCode, _unset)
149 ? this.localeCode
@@ -180,6 +184,7 @@ class AppSettings {
184 'ttsSpeed': ttsSpeed,
185 'kittenTtsModelVariant': kittenTtsModelVariant.name,
186 'autoSpeakEnabled': autoSpeakEnabled,
187 + 'ttsProcessMarkdown': ttsProcessMarkdown,
188 'smartReplyEnabled': smartReplyEnabled,
189 'localeCode': localeCode,
190 'huggingFaceToken': huggingFaceToken,
@@ -215,6 +220,7 @@ class AppSettings {
220 ttsSpeed: map['ttsSpeed']?.toDouble() ?? 1.0,
221 kittenTtsModelVariant: _parseKittenVariant(map['kittenTtsModelVariant']),
222 autoSpeakEnabled: map['autoSpeakEnabled'] ?? false,
223 + ttsProcessMarkdown: map['ttsProcessMarkdown'] ?? true,
224 smartReplyEnabled: map['smartReplyEnabled'] ?? true,
225 localeCode: map['localeCode'] as String?,
226 huggingFaceToken: map['huggingFaceToken'] as String?,
lib/features/settings/views/data_backup_actions.dart
+99 -57
@@ -15,6 +15,8 @@ import '../../../core/providers/storage_providers.dart';
15 import '../../../core/services/data_backup_service.dart';
16 import '../../conversations/providers/conversation_providers.dart';
17 import '../../personas/providers/personas_providers.dart';
18 +import '../../saved_messages/providers/saved_message_providers.dart';
19 +import '../../servers/providers/server_providers.dart';
20
21 class DataBackupActions extends ConsumerWidget {
22 const DataBackupActions({super.key});
@@ -50,8 +52,13 @@ class DataBackupActions extends ConsumerWidget {
52 try {
53 final db = ref.read(databaseProvider);
54 final settings = ref.read(settingsProvider);
55 + final prefs = ref.read(sharedPreferencesProvider);
56 final service = DataBackupService();
54 - final bytes = service.exportAllZip(db.store, settings.toJson());
57 + final bytes = service.exportAllZip(
58 + db.store,
59 + settings.toJson(),
60 + prefs: prefs,
61 + );
62 final saved = await FilePicker.saveFile(
63 dialogTitle: dialogTitle,
64 fileName: fileName,
@@ -132,6 +139,23 @@ class DataBackupActions extends ConsumerWidget {
139 }
140 }
141
142 + Future<void> _applySettingsPayload(
143 + WidgetRef ref,
144 + Map<String, dynamic> decoded,
145 + ) async {
146 + final prefs = ref.read(sharedPreferencesProvider);
147 + final settingsMap = decoded['settings'];
148 + if (settingsMap is Map) {
149 + await prefs.setString('appSettings', jsonEncode(settingsMap));
150 + }
151 + final metadata = decoded['modelMetadata'];
152 + if (metadata is Map) {
153 + await prefs.setString('modelMetadata', jsonEncode(metadata));
154 + }
155 + ref.invalidate(settingsProvider);
156 + ref.invalidate(serversProvider);
157 + }
158 +
159 Future<void> _importZipFile(BuildContext context, WidgetRef ref) async {
160 final l10n = AppLocalizations.of(context)!;
161 if (!await _confirmImport(context, l10n.import_data_confirm)) return;
@@ -151,18 +175,15 @@ class DataBackupActions extends ConsumerWidget {
175 final archive = ZipDecoder().decodeBytes(bytes);
176 for (final file in archive.files) {
177 if (!file.isFile || file.name != 'settings.json') continue;
154 - final settingsJson = utf8.decode(file.content as List<int>);
155 - final decoded = jsonDecode(settingsJson) as Map<String, dynamic>;
156 - final settingsMap = decoded['settings'];
157 - if (settingsMap is Map) {
158 - final prefs = ref.read(sharedPreferencesProvider);
159 - await prefs.setString('appSettings', jsonEncode(settingsMap));
160 - }
178 + final decoded = jsonDecode(utf8.decode(file.content as List<int>))
179 + as Map<String, dynamic>;
180 + await _applySettingsPayload(ref, decoded);
181 }
182
183 ref.invalidate(conversationsProvider);
184 ref.invalidate(personasNotifierProvider);
165 - ref.invalidate(settingsProvider);
185 + ref.invalidate(savedMessagesProvider);
186 + ref.invalidate(savedMessageFoldersProvider);
187
188 if (context.mounted) {
189 ScaffoldMessenger.of(context).showSnackBar(
@@ -178,18 +199,33 @@ class DataBackupActions extends ConsumerWidget {
199 }
200 }
201
181 - Widget _actionButton({
182 - required IconData icon,
202 + Widget _categoryRow({
203 required String label,
184 - required VoidCallback onPressed,
204 + required VoidCallback onExport,
205 + required VoidCallback onImport,
206 + required AppLocalizations l10n,
207 }) {
208 return Padding(
187 - padding: const EdgeInsets.only(bottom: 8),
188 - child: ShadButton.outline(
189 - onPressed: onPressed,
190 - width: double.infinity,
191 - leading: Icon(icon, size: 16),
192 - child: Align(alignment: Alignment.centerLeft, child: Text(label)),
209 + padding: const EdgeInsets.only(bottom: 12),
210 + child: Row(
211 + children: [
212 + Expanded(
213 + flex: 2,
214 + child: Text(
215 + label,
216 + style: const TextStyle(fontWeight: FontWeight.w600),
217 + ),
218 + ),
219 + ShadButton.outline(
220 + onPressed: onExport,
221 + child: Text(l10n.export),
222 + ),
223 + const SizedBox(width: 8),
224 + ShadButton.outline(
225 + onPressed: onImport,
226 + child: Text(l10n.import),
227 + ),
228 + ],
229 ),
230 );
231 }
@@ -199,43 +235,41 @@ class DataBackupActions extends ConsumerWidget {
235 final l10n = AppLocalizations.of(context)!;
236 final service = DataBackupService();
237 final db = ref.read(databaseProvider);
238 + final prefs = ref.read(sharedPreferencesProvider);
239
240 return Column(
241 + crossAxisAlignment: CrossAxisAlignment.stretch,
242 children: [
205 - _actionButton(
206 - icon: Icons.forum_outlined,
207 - label: l10n.export_conversations,
208 - onPressed: () => _saveJsonExport(
243 + _categoryRow(
244 + label: l10n.conversations_label,
245 + l10n: l10n,
246 + onExport: () => _saveJsonExport(
247 context,
248 l10n.export_conversations,
249 'localmind_conversations_${DateTime.now().millisecondsSinceEpoch}.json',
250 service.exportConversationsAsJson(db.store),
251 ),
214 - ),
215 - _actionButton(
216 - icon: Icons.download_outlined,
217 - label: l10n.import_conversations,
218 - onPressed: () => _importJsonFile(
252 + onImport: () => _importJsonFile(
253 context,
254 ref,
221 - (json) => service.importFromJson(db.store, json),
255 + (json) async {
256 + await service.importFromJson(db.store, json);
257 + ref.invalidate(conversationsProvider);
258 + ref.invalidate(savedMessagesProvider);
259 + },
260 l10n.import_data_confirm,
261 ),
262 ),
225 - _actionButton(
226 - icon: Icons.smart_toy_outlined,
227 - label: l10n.export_personas,
228 - onPressed: () => _saveJsonExport(
263 + _categoryRow(
264 + label: l10n.personas_label,
265 + l10n: l10n,
266 + onExport: () => _saveJsonExport(
267 context,
268 l10n.export_personas,
269 'localmind_personas_${DateTime.now().millisecondsSinceEpoch}.json',
270 service.exportPersonasAsJson(db.store),
271 ),
234 - ),
235 - _actionButton(
236 - icon: Icons.download_outlined,
237 - label: l10n.import_personas,
238 - onPressed: () => _importJsonFile(
272 + onImport: () => _importJsonFile(
273 context,
274 ref,
275 (json) async {
@@ -245,46 +279,54 @@ class DataBackupActions extends ConsumerWidget {
279 l10n.import_data_confirm,
280 ),
281 ),
248 - _actionButton(
249 - icon: Icons.settings_outlined,
250 - label: l10n.export_settings,
251 - onPressed: () => _saveJsonExport(
282 + _categoryRow(
283 + label: l10n.settings_label,
284 + l10n: l10n,
285 + onExport: () => _saveJsonExport(
286 context,
287 l10n.export_settings,
288 'localmind_settings_${DateTime.now().millisecondsSinceEpoch}.json',
255 - service.exportSettingsAsJson(ref.read(settingsProvider).toJson()),
289 + service.exportSettingsAsJson(
290 + ref.read(settingsProvider).toJson(),
291 + store: db.store,
292 + prefs: prefs,
293 + ),
294 ),
257 - ),
258 - _actionButton(
259 - icon: Icons.download_outlined,
260 - label: l10n.import_settings,
261 - onPressed: () => _importJsonFile(
295 + onImport: () => _importJsonFile(
296 context,
297 ref,
298 (json) async {
299 final decoded = jsonDecode(json) as Map<String, dynamic>;
266 - final settingsMap = decoded['settings'] ?? decoded;
267 - final prefs = ref.read(sharedPreferencesProvider);
268 - await prefs.setString('appSettings', jsonEncode(settingsMap));
269 - ref.invalidate(settingsProvider);
300 + await service.importFromJson(db.store, json);
301 + await _applySettingsPayload(ref, decoded);
302 },
303 l10n.import_settings_confirm,
304 ),
305 ),
274 - _actionButton(
275 - icon: Icons.folder_zip_outlined,
276 - label: l10n.export_all_zip,
306 + const SizedBox(height: 8),
307 + ShadButton.outline(
308 onPressed: () => _saveZipExport(
309 context,
310 ref,
311 l10n.export_all_zip,
312 'localmind_backup_${DateTime.now().millisecondsSinceEpoch}.zip',
313 ),
314 + width: double.infinity,
315 + leading: const Icon(Icons.folder_zip_outlined, size: 16),
316 + child: Align(
317 + alignment: Alignment.centerLeft,
318 + child: Text(l10n.export_all_zip),
319 + ),
320 ),
284 - _actionButton(
285 - icon: Icons.unarchive_outlined,
286 - label: l10n.import_all_zip,
321 + const SizedBox(height: 8),
322 + ShadButton.outline(
323 onPressed: () => _importZipFile(context, ref),
324 + width: double.infinity,
325 + leading: const Icon(Icons.unarchive_outlined, size: 16),
326 + child: Align(
327 + alignment: Alignment.centerLeft,
328 + child: Text(l10n.import_all_zip),
329 + ),
330 ),
331 ],
332 );
lib/features/settings/views/settings_screen.dart
+34 -10
@@ -162,6 +162,14 @@ class SettingsViews extends ConsumerWidget {
162 valueFormat: (value) => '${value.toStringAsFixed(2)}x',
163 ),
164 ],
165 + _ToggleSetting(
166 + label: l10n.tts_process_markdown,
167 + description: l10n.tts_process_markdown_desc,
168 + value: settings.ttsProcessMarkdown,
169 + onChanged: (value) => ref
170 + .read(settingsProvider.notifier)
171 + .setTtsProcessMarkdown(value),
172 + ),
173 ],
174 );
175
@@ -1083,10 +1091,12 @@ class _ToggleSetting extends StatelessWidget {
1091 required this.label,
1092 required this.value,
1093 required this.onChanged,
1094 + this.description,
1095 this.badges = const [],
1096 });
1097
1098 final String label;
1099 + final String? description;
1100 final bool value;
1101 final ValueChanged<bool> onChanged;
1102 final List<Widget> badges;
@@ -1100,18 +1110,32 @@ class _ToggleSetting extends StatelessWidget {
1110 crossAxisAlignment: CrossAxisAlignment.center,
1111 children: [
1112 Expanded(
1103 - child: Wrap(
1104 - spacing: 8,
1105 - runSpacing: 6,
1106 - crossAxisAlignment: WrapCrossAlignment.center,
1113 + child: Column(
1114 + crossAxisAlignment: CrossAxisAlignment.start,
1115 children: [
1108 - Text(
1109 - label,
1110 - style: theme.textTheme.titleSmall?.copyWith(
1111 - fontWeight: FontWeight.w700,
1112 - ),
1116 + Wrap(
1117 + spacing: 8,
1118 + runSpacing: 6,
1119 + crossAxisAlignment: WrapCrossAlignment.center,
1120 + children: [
1121 + Text(
1122 + label,
1123 + style: theme.textTheme.titleSmall?.copyWith(
1124 + fontWeight: FontWeight.w700,
1125 + ),
1126 + ),
1127 + ...badges,
1128 + ],
1129 ),
1114 - ...badges,
1130 + if (description != null) ...[
1131 + const SizedBox(height: 4),
1132 + Text(
1133 + description!,
1134 + style: theme.textTheme.bodySmall?.copyWith(
1135 + color: theme.colorScheme.onSurfaceVariant,
1136 + ),
1137 + ),
1138 + ],
1139 ],
1140 ),
1141 ),
lib/features/sidebar/components/sidebar_search_button.dart
+7 -3
@@ -1,14 +1,15 @@
1 import 'package:flutter/material.dart';
2 -import 'package:hugeicons/hugeicons.dart';
2 +import 'package:flutter_riverpod/flutter_riverpod.dart';import 'package:hugeicons/hugeicons.dart';
3 import 'package:go_router/go_router.dart';
4 import 'package:localmind/core/theme/colors.dart';
5 import '../../../core/routes/app_routes.dart';
6 +import '../../conversations/providers/conversation_providers.dart';
7
7 -class SidebarSearchButton extends StatelessWidget {
8 +class SidebarSearchButton extends ConsumerWidget {
9 const SidebarSearchButton({super.key});
10
11 @override
11 - Widget build(BuildContext context) {
12 + Widget build(BuildContext context, WidgetRef ref) {
13 final isDark = Theme.of(context).brightness == Brightness.dark;
14
15 return Padding(
@@ -19,6 +20,9 @@ class SidebarSearchButton extends StatelessWidget {
20 Navigator.pop(context);
21 }
22 context.go(AppRoutes.chatHistory);
23 + WidgetsBinding.instance.addPostFrameCallback((_) {
24 + ref.read(focusHistorySearchProvider.notifier).requestFocus();
25 + });
26 },
27 borderRadius: BorderRadius.circular(8),
28 child: Container(
lib/features/tts/providers/tts_providers.dart
+46 -4
@@ -12,6 +12,7 @@ import 'package:flutter_tts/flutter_tts.dart';
12 import '../../../core/logger/app_logger.dart';
13 import '../../../core/models/enums.dart';
14 import '../../../core/providers/app_providers.dart';
15 +import '../utils/tts_text_processor.dart';
16 import 'tts_model_providers.dart';
17 import '../data/piper_tts_model.dart';
18 import 'tts_worker.dart';
@@ -27,6 +28,8 @@ class TtsState {
28 final String? error;
29 final EngineId? activeEngine;
30 final String? playingContent;
31 + final String? playingMessageId;
32 + final String? playingConversationId;
33 final bool isPreview;
34
35 const TtsState({
@@ -36,6 +39,8 @@ class TtsState {
39 this.error,
40 this.activeEngine,
41 this.playingContent,
42 + this.playingMessageId,
43 + this.playingConversationId,
44 this.isPreview = false,
45 });
46
@@ -46,7 +51,10 @@ class TtsState {
51 String? error,
52 EngineId? activeEngine,
53 String? playingContent,
54 + String? playingMessageId,
55 + String? playingConversationId,
56 bool? isPreview,
57 + bool clearPlayingTarget = false,
58 }) {
59 return TtsState(
60 isSpeaking: isSpeaking ?? this.isSpeaking,
@@ -55,6 +63,11 @@ class TtsState {
63 error: error,
64 activeEngine: activeEngine ?? this.activeEngine,
65 playingContent: playingContent,
66 + playingMessageId:
67 + clearPlayingTarget ? null : (playingMessageId ?? this.playingMessageId),
68 + playingConversationId: clearPlayingTarget
69 + ? null
70 + : (playingConversationId ?? this.playingConversationId),
71 isPreview: isPreview ?? this.isPreview,
72 );
73 }
@@ -252,6 +265,7 @@ class TtsNotifier extends Notifier<TtsState> {
265 isInitializing: false,
266 playingContent: null,
267 isPreview: false,
268 + clearPlayingTarget: true,
269 );
270 }
271
@@ -398,9 +412,22 @@ class TtsNotifier extends Notifier<TtsState> {
412 }
413 }
414
401 - Future<void> speak(String text, {EngineId? engineId, String? voiceId, bool isPreview = false}) async {
415 + Future<void> speak(
416 + String text, {
417 + EngineId? engineId,
418 + String? voiceId,
419 + bool isPreview = false,
420 + String? messageId,
421 + String? conversationId,
422 + }) async {
423 if (text.trim().isEmpty) return;
424
425 + final settings = ref.read(settingsProvider);
426 + if (settings.ttsProcessMarkdown) {
427 + text = TtsTextProcessor.process(text, stripMarkdown: true);
428 + if (text.trim().isEmpty) return;
429 + }
430 +
431 _isStopping = false;
432 if (state.isSpeaking) {
433 await stop();
@@ -408,7 +435,6 @@ class TtsNotifier extends Notifier<TtsState> {
435 _isStopping = false;
436 }
437
411 - final settings = ref.read(settingsProvider);
438 engineId ??= settings.ttsEngine;
439
440 final availableVoices = voicesForEngine(engineId);
@@ -429,6 +455,8 @@ class TtsNotifier extends Notifier<TtsState> {
455 error: null,
456 activeEngine: EngineId.system,
457 playingContent: text,
458 + playingMessageId: isPreview ? null : messageId,
459 + playingConversationId: isPreview ? null : conversationId,
460 isPreview: isPreview,
461 );
462 try {
@@ -438,7 +466,11 @@ class TtsNotifier extends Notifier<TtsState> {
466 );
467 _flutterTts!.setCompletionHandler(() {
468 if (!_isStopping) {
441 - state = state.copyWith(isSpeaking: false, playingContent: null);
469 + state = state.copyWith(
470 + isSpeaking: false,
471 + playingContent: null,
472 + clearPlayingTarget: true,
473 + );
474 }
475 });
476 _flutterTts!.setErrorHandler((msg) {
@@ -447,12 +479,17 @@ class TtsNotifier extends Notifier<TtsState> {
479 isSpeaking: false,
480 playingContent: null,
481 error: msg.toString(),
482 + clearPlayingTarget: true,
483 );
484 }
485 });
486 _flutterTts!.setCancelHandler(() {
487 if (!_isStopping) {
455 - state = state.copyWith(isSpeaking: false, playingContent: null);
488 + state = state.copyWith(
489 + isSpeaking: false,
490 + playingContent: null,
491 + clearPlayingTarget: true,
492 + );
493 }
494 });
495 await _flutterTts!.awaitSpeakCompletion(true);
@@ -462,6 +499,7 @@ class TtsNotifier extends Notifier<TtsState> {
499 isSpeaking: false,
500 error: 'System TTS unavailable',
501 playingContent: null,
502 + clearPlayingTarget: true,
503 );
504 rethrow;
505 }
@@ -497,6 +535,8 @@ class TtsNotifier extends Notifier<TtsState> {
535 error: null,
536 activeEngine: engineId,
537 playingContent: text,
538 + playingMessageId: isPreview ? null : messageId,
539 + playingConversationId: isPreview ? null : conversationId,
540 isPreview: isPreview,
541 );
542
@@ -512,6 +552,7 @@ class TtsNotifier extends Notifier<TtsState> {
552 isInitializing: false,
553 error: e.toString(),
554 playingContent: null,
555 + clearPlayingTarget: true,
556 );
557 }
558 }
@@ -615,6 +656,7 @@ class TtsNotifier extends Notifier<TtsState> {
656 isInitializing: false,
657 playingContent: null,
658 isPreview: false,
659 + clearPlayingTarget: true,
660 );
661 }
662
lib/features/tts/utils/tts_text_processor.dart new
+61
@@ -0,0 +1,61 @@
1 +/// Prepares text for text-to-speech by stripping markdown and normalizing
2 +/// characters that engines misread (e.g. `$1` → "dollar one").
3 +class TtsTextProcessor {
4 + static String process(String text, {required bool stripMarkdown}) {
5 + if (text.isEmpty) return text;
6 + var result = text;
7 + if (stripMarkdown) {
8 + result = _stripMarkdown(result);
9 + }
10 + result = _normalizeForSpeech(result);
11 + return result.trim();
12 + }
13 +
14 + static String _stripMarkdown(String text) {
15 + var s = text;
16 + // Fenced code blocks
17 + s = s.replaceAll(RegExp(r'```[\s\S]*?```'), ' ');
18 + // Inline code
19 + s = s.replaceAllMapped(RegExp(r'`([^`]+)`'), (m) => m.group(1) ?? '');
20 + // Images ![alt](url)
21 + s = s.replaceAllMapped(
22 + RegExp(r'!\[([^\]]*)\]\([^)]*\)'),
23 + (m) => m.group(1)?.isNotEmpty == true ? m.group(1)! : ' ',
24 + );
25 + // Links [text](url)
26 + s = s.replaceAllMapped(RegExp(r'\[([^\]]+)\]\([^)]*\)'), (m) => m.group(1)!);
27 + // Bold / italic
28 + s = s.replaceAllMapped(RegExp(r'\*\*\*(.+?)\*\*\*'), (m) => m.group(1)!);
29 + s = s.replaceAllMapped(RegExp(r'\*\*(.+?)\*\*'), (m) => m.group(1)!);
30 + s = s.replaceAllMapped(RegExp(r'\*(.+?)\*'), (m) => m.group(1)!);
31 + s = s.replaceAllMapped(RegExp(r'__(.+?)__'), (m) => m.group(1)!);
32 + s = s.replaceAllMapped(RegExp(r'_(.+?)_'), (m) => m.group(1)!);
33 + // Headings
34 + s = s.replaceAll(RegExp(r'^#{1,6}\s+', multiLine: true), '');
35 + // Blockquote
36 + s = s.replaceAll(RegExp(r'^>\s?', multiLine: true), '');
37 + // Horizontal rules
38 + s = s.replaceAll(RegExp(r'^-{3,}$', multiLine: true), ' ');
39 + // List markers
40 + s = s.replaceAll(RegExp(r'^[\s]*[-*+]\s+', multiLine: true), '');
41 + s = s.replaceAll(RegExp(r'^[\s]*\d+\.\s+', multiLine: true), '');
42 + // Residual asterisks / underscores used as emphasis markers
43 + s = s.replaceAll('*', '').replaceAll('_', '');
44 + return s;
45 + }
46 +
47 + static String _normalizeForSpeech(String text) {
48 + var s = text;
49 + // `$1`, `$42` in templates → "1 dollars" not "dollar one"
50 + s = s.replaceAllMapped(
51 + RegExp(r'\$(\d+(?:\.\d+)?)'),
52 + (m) => '${m.group(1)} dollars',
53 + );
54 + // Lone `$` symbols
55 + s = s.replaceAll(r'$', ' dollars ');
56 + // Collapse whitespace
57 + s = s.replaceAll(RegExp(r'[ \t]+'), ' ');
58 + s = s.replaceAll(RegExp(r'\n{3,}'), '\n\n');
59 + return s;
60 + }
61 +}
lib/features/tts/views/components/tts_player_bar.dart
+124 -68
@@ -1,8 +1,13 @@
1 import 'dart:math' as math;
2 import 'package:flutter/material.dart';
3 import 'package:flutter_riverpod/flutter_riverpod.dart';
4 +import 'package:go_router/go_router.dart';
5 +import 'package:localmind/core/routes/app_routes.dart';
6 import 'package:localmind/l10n/app_localizations.dart';
7 import '../../providers/tts_providers.dart' as tts;
8 +import '../../../conversations/data/models/conversation.dart';
9 +import '../../../conversations/providers/conversation_providers.dart';
10 +import '../../../chat/providers/chat_providers.dart';
11
12 class TtsPlayerBar extends ConsumerWidget {
13 final EdgeInsetsGeometry margin;
@@ -18,6 +23,42 @@ class TtsPlayerBar extends ConsumerWidget {
23 return '${content.substring(0, 47)}...';
24 }
25
26 + Future<void> _scrollToPlayingMessage(
27 + BuildContext context,
28 + WidgetRef ref,
29 + ) async {
30 + final ttsState = ref.read(tts.ttsProvider);
31 + final messageId = ttsState.playingMessageId;
32 + if (messageId == null) return;
33 +
34 + ref.read(scrollToMessageIdProvider.notifier).scrollTo(messageId);
35 +
36 + final conversationId = ttsState.playingConversationId;
37 + if (conversationId != null) {
38 + final active = ref.read(activeConversationProvider);
39 + if (active?.id != conversationId) {
40 + final conversations = ref.read(conversationsProvider).value ?? [];
41 + Conversation? target;
42 + for (final conv in conversations) {
43 + if (conv.id == conversationId) {
44 + target = conv;
45 + break;
46 + }
47 + }
48 + if (target != null) {
49 + await ref.read(chatProvider.notifier).loadConversation(target);
50 + }
51 + }
52 + }
53 +
54 + if (context.mounted) {
55 + if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
56 + Navigator.pop(context);
57 + }
58 + context.go(AppRoutes.home);
59 + }
60 + }
61 +
62 @override
63 Widget build(BuildContext context, WidgetRef ref) {
64 final l10n = AppLocalizations.of(context)!;
@@ -44,6 +85,7 @@ class TtsPlayerBar extends ConsumerWidget {
85 }
86
87 final preview = _truncateContent(ttsState.playingContent);
88 + final canJumpToMessage = ttsState.playingMessageId != null;
89
90 return AnimatedSize(
91 duration: const Duration(milliseconds: 200),
@@ -93,79 +135,93 @@ class TtsPlayerBar extends ConsumerWidget {
135 ),
136 const SizedBox(width: 12),
137 Expanded(
96 - child: Column(
97 - crossAxisAlignment: CrossAxisAlignment.start,
98 - mainAxisSize: MainAxisSize.min,
99 - children: [
100 - Text(
101 - preview ??
102 - (ttsState.isInitializing
103 - ? l10n.loading
104 - : 'Playing'),
105 - style: TextStyle(
106 - fontSize: 12,
107 - fontWeight: FontWeight.w600,
108 - color: theme.colorScheme.onSurface,
109 - ),
110 - maxLines: 1,
111 - overflow: TextOverflow.ellipsis,
112 - ),
113 - if (ttsState.isInitializing)
114 - Padding(
115 - padding: const EdgeInsets.only(top: 2),
116 - child: Text(
117 - l10n.initializing,
118 - style: TextStyle(
119 - fontSize: 10,
120 - color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
121 - ),
122 - ),
123 - ),
124 - if (!ttsState.isInitializing)
125 - Padding(
126 - padding: const EdgeInsets.only(top: 4),
127 - child: ClipRRect(
128 - borderRadius: BorderRadius.circular(2),
129 - child: LinearProgressIndicator(
130 - backgroundColor: theme.colorScheme.secondary,
131 - valueColor: AlwaysStoppedAnimation<Color>(
132 - theme.colorScheme.primary,
138 + child: Material(
139 + color: Colors.transparent,
140 + child: InkWell(
141 + onTap: canJumpToMessage
142 + ? () => _scrollToPlayingMessage(context, ref)
143 + : null,
144 + borderRadius: BorderRadius.circular(8),
145 + child: Padding(
146 + padding: const EdgeInsets.symmetric(vertical: 2),
147 + child: Column(
148 + crossAxisAlignment: CrossAxisAlignment.start,
149 + mainAxisSize: MainAxisSize.min,
150 + children: [
151 + Text(
152 + preview ??
153 + (ttsState.isInitializing
154 + ? l10n.loading
155 + : 'Playing'),
156 + style: TextStyle(
157 + fontSize: 12,
158 + fontWeight: FontWeight.w600,
159 + color: theme.colorScheme.onSurface,
160 ),
134 - minHeight: 3,
161 + maxLines: 1,
162 + overflow: TextOverflow.ellipsis,
163 ),
136 - ),
164 + if (ttsState.isInitializing)
165 + Padding(
166 + padding: const EdgeInsets.only(top: 2),
167 + child: Text(
168 + l10n.initializing,
169 + style: TextStyle(
170 + fontSize: 10,
171 + color: theme.colorScheme.onSurface
172 + .withValues(alpha: 0.5),
173 + ),
174 + ),
175 + ),
176 + if (!ttsState.isInitializing)
177 + Padding(
178 + padding: const EdgeInsets.only(top: 4),
179 + child: ClipRRect(
180 + borderRadius: BorderRadius.circular(2),
181 + child: LinearProgressIndicator(
182 + backgroundColor:
183 + theme.colorScheme.secondary,
184 + valueColor: AlwaysStoppedAnimation<Color>(
185 + theme.colorScheme.primary,
186 + ),
187 + minHeight: 3,
188 + ),
189 + ),
190 + ),
191 + ],
192 ),
138 - ],
193 + ),
194 + ),
195 ),
196 ),
141 - const SizedBox(width: 12),
142 - if (!ttsState.isInitializing)
143 - _WaveformIndicator(isPlaying: !ttsState.isPaused),
144 - const SizedBox(width: 12),
145 - GestureDetector(
146 - onTap: () => notifier.stop(),
147 - child: Tooltip(
148 - message: l10n.stop,
149 - child: Container(
150 - padding: const EdgeInsets.all(6),
151 - decoration: BoxDecoration(
152 - color: theme.colorScheme.error.withValues(alpha: 0.1),
153 - borderRadius: BorderRadius.circular(8),
154 - ),
155 - child: Icon(
156 - Icons.stop_rounded,
157 - size: 20,
158 - color: theme.colorScheme.error,
197 + const SizedBox(width: 12),
198 + if (!ttsState.isInitializing)
199 + _WaveformIndicator(isPlaying: !ttsState.isPaused),
200 + const SizedBox(width: 12),
201 + GestureDetector(
202 + onTap: () => notifier.stop(),
203 + child: Tooltip(
204 + message: l10n.stop,
205 + child: Container(
206 + padding: const EdgeInsets.all(6),
207 + decoration: BoxDecoration(
208 + color: theme.colorScheme.error.withValues(alpha: 0.1),
209 + borderRadius: BorderRadius.circular(8),
210 + ),
211 + child: Icon(
212 + Icons.stop_rounded,
213 + size: 20,
214 + color: theme.colorScheme.error,
215 + ),
216 ),
217 ),
218 ),
162 - ),
163 - ],
164 - ),
165 - ],
219 + ],
220 + ),
221 + ],
222 + ),
223 ),
167 - ),
168 - );
224 + );
225 }
226 }
227
@@ -235,14 +291,14 @@ class _WaveformIndicatorState extends State<_WaveformIndicator>
291 animation: _controller,
292 builder: (context, child) {
293 final t = _controller.value;
238 -
239 - final h1 = widget.isPlaying
294 +
295 + final h1 = widget.isPlaying
296 ? 4.0 + 12.0 * (0.5 + 0.5 * math.sin(t * 2 * math.pi))
297 : 4.0;
242 - final h2 = widget.isPlaying
298 + final h2 = widget.isPlaying
299 ? 4.0 + 12.0 * (0.5 + 0.5 * math.sin(t * 2 * math.pi + math.pi / 3))
300 : 6.0;
245 - final h3 = widget.isPlaying
301 + final h3 = widget.isPlaying
302 ? 4.0 + 12.0 * (0.5 + 0.5 * math.sin(t * 2 * math.pi + 2 * math.pi / 3))
303 : 4.0;
304
lib/features/tts/views/tts_model_manager_screen.dart
+16 -4
@@ -1110,10 +1110,22 @@ class _TtsTestSectionState extends ConsumerState<_TtsTestSection> {
1110 ),
1111 ),
1112 const SizedBox(height: 12),
1113 - ShadButton(
1114 - onPressed: _speak,
1115 - leading: const Icon(Icons.volume_up),
1116 - child: Text(l10n.test_speak_button),
1113 + Row(
1114 + children: [
1115 + Expanded(
1116 + child: ShadButton(
1117 + onPressed: _speak,
1118 + leading: const Icon(Icons.volume_up),
1119 + child: Text(l10n.test_speak_button),
1120 + ),
1121 + ),
1122 + const SizedBox(width: 8),
1123 + ShadButton.outline(
1124 + onPressed: () => ref.read(tts.ttsProvider.notifier).stop(),
1125 + leading: const Icon(Icons.stop),
1126 + child: Text(l10n.stop),
1127 + ),
1128 + ],
1129 ),
1130 ],
1131 );
lib/l10n/app_en.arb
+17 -1
@@ -2581,5 +2581,21 @@
2581 },
2582 "test_tts_section_title": "Test voice",
2583 "test_tts_hint": "Enter text to hear the current TTS engine…",
2584 - "test_speak_button": "Speak"
2584 + "test_speak_button": "Speak",
2585 + "scroll_to_bottom": "Scroll to bottom",
2586 + "generate_ai_response": "Generate AI response",
2587 + "no_response": "No response",
2588 + "export": "Export",
2589 + "import": "Import",
2590 + "conversations_label": "Conversations",
2591 + "personas_label": "Personas",
2592 + "settings_label": "Settings",
2593 + "export_conversation": "Export conversation",
2594 + "tts_process_markdown": "Process markdown for speech",
2595 + "tts_process_markdown_desc": "Strip formatting like **bold** before reading aloud",
2596 + "preview_system_prompts": "Preview system prompts",
2597 + "welcome_message_1": "What can I help you with today?",
2598 + "welcome_message_2": "Ask me anything — I'm ready when you are.",
2599 + "welcome_message_3": "Start a conversation below.",
2600 + "welcome_message_4": "Need ideas? Try one of the quick prompts."
2601 }
lib/l10n/app_localizations.dart
+96
@@ -4010,6 +4010,102 @@ abstract class AppLocalizations {
4010 /// In en, this message translates to:
4011 /// **'Speak'**
4012 String get test_speak_button;
4013 +
4014 + /// No description provided for @scroll_to_bottom.
4015 + ///
4016 + /// In en, this message translates to:
4017 + /// **'Scroll to bottom'**
4018 + String get scroll_to_bottom;
4019 +
4020 + /// No description provided for @generate_ai_response.
4021 + ///
4022 + /// In en, this message translates to:
4023 + /// **'Generate AI response'**
4024 + String get generate_ai_response;
4025 +
4026 + /// No description provided for @no_response.
4027 + ///
4028 + /// In en, this message translates to:
4029 + /// **'No response'**
4030 + String get no_response;
4031 +
4032 + /// No description provided for @export.
4033 + ///
4034 + /// In en, this message translates to:
4035 + /// **'Export'**
4036 + String get export;
4037 +
4038 + /// No description provided for @import.
4039 + ///
4040 + /// In en, this message translates to:
4041 + /// **'Import'**
4042 + String get import;
4043 +
4044 + /// No description provided for @conversations_label.
4045 + ///
4046 + /// In en, this message translates to:
4047 + /// **'Conversations'**
4048 + String get conversations_label;
4049 +
4050 + /// No description provided for @personas_label.
4051 + ///
4052 + /// In en, this message translates to:
4053 + /// **'Personas'**
4054 + String get personas_label;
4055 +
4056 + /// No description provided for @settings_label.
4057 + ///
4058 + /// In en, this message translates to:
4059 + /// **'Settings'**
4060 + String get settings_label;
4061 +
4062 + /// No description provided for @export_conversation.
4063 + ///
4064 + /// In en, this message translates to:
4065 + /// **'Export conversation'**
4066 + String get export_conversation;
4067 +
4068 + /// No description provided for @tts_process_markdown.
4069 + ///
4070 + /// In en, this message translates to:
4071 + /// **'Process markdown for speech'**
4072 + String get tts_process_markdown;
4073 +
4074 + /// No description provided for @tts_process_markdown_desc.
4075 + ///
4076 + /// In en, this message translates to:
4077 + /// **'Strip formatting like **bold** before reading aloud'**
4078 + String get tts_process_markdown_desc;
4079 +
4080 + /// No description provided for @preview_system_prompts.
4081 + ///
4082 + /// In en, this message translates to:
4083 + /// **'Preview system prompts'**
4084 + String get preview_system_prompts;
4085 +
4086 + /// No description provided for @welcome_message_1.
4087 + ///
4088 + /// In en, this message translates to:
4089 + /// **'What can I help you with today?'**
4090 + String get welcome_message_1;
4091 +
4092 + /// No description provided for @welcome_message_2.
4093 + ///
4094 + /// In en, this message translates to:
4095 + /// **'Ask me anything — I\'m ready when you are.'**
4096 + String get welcome_message_2;
4097 +
4098 + /// No description provided for @welcome_message_3.
4099 + ///
4100 + /// In en, this message translates to:
4101 + /// **'Start a conversation below.'**
4102 + String get welcome_message_3;
4103 +
4104 + /// No description provided for @welcome_message_4.
4105 + ///
4106 + /// In en, this message translates to:
4107 + /// **'Need ideas? Try one of the quick prompts.'**
4108 + String get welcome_message_4;
4109 }
4110
4111 class _AppLocalizationsDelegate
lib/l10n/app_localizations_ar.dart
+49
@@ -2112,4 +2112,53 @@ class AppLocalizationsAr extends AppLocalizations {
2112
2113 @override
2114 String get test_speak_button => 'Speak';
2115 +
2116 + @override
2117 + String get scroll_to_bottom => 'Scroll to bottom';
2118 +
2119 + @override
2120 + String get generate_ai_response => 'Generate AI response';
2121 +
2122 + @override
2123 + String get no_response => 'No response';
2124 +
2125 + @override
2126 + String get export => 'Export';
2127 +
2128 + @override
2129 + String get import => 'Import';
2130 +
2131 + @override
2132 + String get conversations_label => 'Conversations';
2133 +
2134 + @override
2135 + String get personas_label => 'Personas';
2136 +
2137 + @override
2138 + String get settings_label => 'Settings';
2139 +
2140 + @override
2141 + String get export_conversation => 'Export conversation';
2142 +
2143 + @override
2144 + String get tts_process_markdown => 'Process markdown for speech';
2145 +
2146 + @override
2147 + String get tts_process_markdown_desc =>
2148 + 'Strip formatting like **bold** before reading aloud';
2149 +
2150 + @override
2151 + String get preview_system_prompts => 'Preview system prompts';
2152 +
2153 + @override
2154 + String get welcome_message_1 => 'What can I help you with today?';
2155 +
2156 + @override
2157 + String get welcome_message_2 => 'Ask me anything — I\'m ready when you are.';
2158 +
2159 + @override
2160 + String get welcome_message_3 => 'Start a conversation below.';
2161 +
2162 + @override
2163 + String get welcome_message_4 => 'Need ideas? Try one of the quick prompts.';
2164 }
lib/l10n/app_localizations_bn.dart
+49
@@ -2125,4 +2125,53 @@ class AppLocalizationsBn extends AppLocalizations {
2125
2126 @override
2127 String get test_speak_button => 'Speak';
2128 +
2129 + @override
2130 + String get scroll_to_bottom => 'Scroll to bottom';
2131 +
2132 + @override
2133 + String get generate_ai_response => 'Generate AI response';
2134 +
2135 + @override
2136 + String get no_response => 'No response';
2137 +
2138 + @override
2139 + String get export => 'Export';
2140 +
2141 + @override
2142 + String get import => 'Import';
2143 +
2144 + @override
2145 + String get conversations_label => 'Conversations';
2146 +
2147 + @override
2148 + String get personas_label => 'Personas';
2149 +
2150 + @override
2151 + String get settings_label => 'Settings';
2152 +
2153 + @override
2154 + String get export_conversation => 'Export conversation';
2155 +
2156 + @override
2157 + String get tts_process_markdown => 'Process markdown for speech';
2158 +
2159 + @override
2160 + String get tts_process_markdown_desc =>
2161 + 'Strip formatting like **bold** before reading aloud';
2162 +
2163 + @override
2164 + String get preview_system_prompts => 'Preview system prompts';
2165 +
2166 + @override
2167 + String get welcome_message_1 => 'What can I help you with today?';
2168 +
2169 + @override
2170 + String get welcome_message_2 => 'Ask me anything — I\'m ready when you are.';
2171 +
2172 + @override
2173 + String get welcome_message_3 => 'Start a conversation below.';
2174 +
2175 + @override
2176 + String get welcome_message_4 => 'Need ideas? Try one of the quick prompts.';
2177 }
lib/l10n/app_localizations_en.dart
+49
@@ -2117,4 +2117,53 @@ class AppLocalizationsEn extends AppLocalizations {
2117
2118 @override
2119 String get test_speak_button => 'Speak';
2120 +
2121 + @override
2122 + String get scroll_to_bottom => 'Scroll to bottom';
2123 +
2124 + @override
2125 + String get generate_ai_response => 'Generate AI response';
2126 +
2127 + @override
2128 + String get no_response => 'No response';
2129 +
2130 + @override
2131 + String get export => 'Export';
2132 +
2133 + @override
2134 + String get import => 'Import';
2135 +
2136 + @override
2137 + String get conversations_label => 'Conversations';
2138 +
2139 + @override
2140 + String get personas_label => 'Personas';
2141 +
2142 + @override
2143 + String get settings_label => 'Settings';
2144 +
2145 + @override
2146 + String get export_conversation => 'Export conversation';
2147 +
2148 + @override
2149 + String get tts_process_markdown => 'Process markdown for speech';
2150 +
2151 + @override
2152 + String get tts_process_markdown_desc =>
2153 + 'Strip formatting like **bold** before reading aloud';
2154 +
2155 + @override
2156 + String get preview_system_prompts => 'Preview system prompts';
2157 +
2158 + @override
2159 + String get welcome_message_1 => 'What can I help you with today?';
2160 +
2161 + @override
2162 + String get welcome_message_2 => 'Ask me anything — I\'m ready when you are.';
2163 +
2164 + @override
2165 + String get welcome_message_3 => 'Start a conversation below.';
2166 +
2167 + @override
2168 + String get welcome_message_4 => 'Need ideas? Try one of the quick prompts.';
2169 }
lib/l10n/app_localizations_es.dart
+49
@@ -2132,4 +2132,53 @@ class AppLocalizationsEs extends AppLocalizations {
2132
2133 @override
2134 String get test_speak_button => 'Speak';
2135 +
2136 + @override
2137 + String get scroll_to_bottom => 'Scroll to bottom';
2138 +
2139 + @override
2140 + String get generate_ai_response => 'Generate AI response';
2141 +
2142 + @override
2143 + String get no_response => 'No response';
2144 +
2145 + @override
2146 + String get export => 'Export';
2147 +
2148 + @override
2149 + String get import => 'Import';
2150 +
2151 + @override
2152 + String get conversations_label => 'Conversations';
2153 +
2154 + @override
2155 + String get personas_label => 'Personas';
2156 +
2157 + @override
2158 + String get settings_label => 'Settings';
2159 +
2160 + @override
2161 + String get export_conversation => 'Export conversation';
2162 +
2163 + @override
2164 + String get tts_process_markdown => 'Process markdown for speech';
2165 +
2166 + @override
2167 + String get tts_process_markdown_desc =>
2168 + 'Strip formatting like **bold** before reading aloud';
2169 +
2170 + @override
2171 + String get preview_system_prompts => 'Preview system prompts';
2172 +
2173 + @override
2174 + String get welcome_message_1 => 'What can I help you with today?';
2175 +
2176 + @override
2177 + String get welcome_message_2 => 'Ask me anything — I\'m ready when you are.';
2178 +
2179 + @override
2180 + String get welcome_message_3 => 'Start a conversation below.';
2181 +
2182 + @override
2183 + String get welcome_message_4 => 'Need ideas? Try one of the quick prompts.';
2184 }
lib/l10n/app_localizations_hi.dart
+49
@@ -2121,4 +2121,53 @@ class AppLocalizationsHi extends AppLocalizations {
2121
2122 @override
2123 String get test_speak_button => 'Speak';
2124 +
2125 + @override
2126 + String get scroll_to_bottom => 'Scroll to bottom';
2127 +
2128 + @override
2129 + String get generate_ai_response => 'Generate AI response';
2130 +
2131 + @override
2132 + String get no_response => 'No response';
2133 +
2134 + @override
2135 + String get export => 'Export';
2136 +
2137 + @override
2138 + String get import => 'Import';
2139 +
2140 + @override
2141 + String get conversations_label => 'Conversations';
2142 +
2143 + @override
2144 + String get personas_label => 'Personas';
2145 +
2146 + @override
2147 + String get settings_label => 'Settings';
2148 +
2149 + @override
2150 + String get export_conversation => 'Export conversation';
2151 +
2152 + @override
2153 + String get tts_process_markdown => 'Process markdown for speech';
2154 +
2155 + @override
2156 + String get tts_process_markdown_desc =>
2157 + 'Strip formatting like **bold** before reading aloud';
2158 +
2159 + @override
2160 + String get preview_system_prompts => 'Preview system prompts';
2161 +
2162 + @override
2163 + String get welcome_message_1 => 'What can I help you with today?';
2164 +
2165 + @override
2166 + String get welcome_message_2 => 'Ask me anything — I\'m ready when you are.';
2167 +
2168 + @override
2169 + String get welcome_message_3 => 'Start a conversation below.';
2170 +
2171 + @override
2172 + String get welcome_message_4 => 'Need ideas? Try one of the quick prompts.';
2173 }
lib/l10n/app_localizations_it.dart
+49
@@ -2132,4 +2132,53 @@ class AppLocalizationsIt extends AppLocalizations {
2132
2133 @override
2134 String get test_speak_button => 'Speak';
2135 +
2136 + @override
2137 + String get scroll_to_bottom => 'Scroll to bottom';
2138 +
2139 + @override
2140 + String get generate_ai_response => 'Generate AI response';
2141 +
2142 + @override
2143 + String get no_response => 'No response';
2144 +
2145 + @override
2146 + String get export => 'Export';
2147 +
2148 + @override
2149 + String get import => 'Import';
2150 +
2151 + @override
2152 + String get conversations_label => 'Conversations';
2153 +
2154 + @override
2155 + String get personas_label => 'Personas';
2156 +
2157 + @override
2158 + String get settings_label => 'Settings';
2159 +
2160 + @override
2161 + String get export_conversation => 'Export conversation';
2162 +
2163 + @override
2164 + String get tts_process_markdown => 'Process markdown for speech';
2165 +
2166 + @override
2167 + String get tts_process_markdown_desc =>
2168 + 'Strip formatting like **bold** before reading aloud';
2169 +
2170 + @override
2171 + String get preview_system_prompts => 'Preview system prompts';
2172 +
2173 + @override
2174 + String get welcome_message_1 => 'What can I help you with today?';
2175 +
2176 + @override
2177 + String get welcome_message_2 => 'Ask me anything — I\'m ready when you are.';
2178 +
2179 + @override
2180 + String get welcome_message_3 => 'Start a conversation below.';
2181 +
2182 + @override
2183 + String get welcome_message_4 => 'Need ideas? Try one of the quick prompts.';
2184 }
lib/l10n/app_localizations_ja.dart
+49
@@ -2094,4 +2094,53 @@ class AppLocalizationsJa extends AppLocalizations {
2094
2095 @override
2096 String get test_speak_button => 'Speak';
2097 +
2098 + @override
2099 + String get scroll_to_bottom => 'Scroll to bottom';
2100 +
2101 + @override
2102 + String get generate_ai_response => 'Generate AI response';
2103 +
2104 + @override
2105 + String get no_response => 'No response';
2106 +
2107 + @override
2108 + String get export => 'Export';
2109 +
2110 + @override
2111 + String get import => 'Import';
2112 +
2113 + @override
2114 + String get conversations_label => 'Conversations';
2115 +
2116 + @override
2117 + String get personas_label => 'Personas';
2118 +
2119 + @override
2120 + String get settings_label => 'Settings';
2121 +
2122 + @override
2123 + String get export_conversation => 'Export conversation';
2124 +
2125 + @override
2126 + String get tts_process_markdown => 'Process markdown for speech';
2127 +
2128 + @override
2129 + String get tts_process_markdown_desc =>
2130 + 'Strip formatting like **bold** before reading aloud';
2131 +
2132 + @override
2133 + String get preview_system_prompts => 'Preview system prompts';
2134 +
2135 + @override
2136 + String get welcome_message_1 => 'What can I help you with today?';
2137 +
2138 + @override
2139 + String get welcome_message_2 => 'Ask me anything — I\'m ready when you are.';
2140 +
2141 + @override
2142 + String get welcome_message_3 => 'Start a conversation below.';
2143 +
2144 + @override
2145 + String get welcome_message_4 => 'Need ideas? Try one of the quick prompts.';
2146 }
lib/l10n/app_localizations_zh.dart
+49
@@ -2087,6 +2087,55 @@ class AppLocalizationsZh extends AppLocalizations {
2087
2088 @override
2089 String get test_speak_button => 'Speak';
2090 +
2091 + @override
2092 + String get scroll_to_bottom => 'Scroll to bottom';
2093 +
2094 + @override
2095 + String get generate_ai_response => 'Generate AI response';
2096 +
2097 + @override
2098 + String get no_response => 'No response';
2099 +
2100 + @override
2101 + String get export => 'Export';
2102 +
2103 + @override
2104 + String get import => 'Import';
2105 +
2106 + @override
2107 + String get conversations_label => 'Conversations';
2108 +
2109 + @override
2110 + String get personas_label => 'Personas';
2111 +
2112 + @override
2113 + String get settings_label => 'Settings';
2114 +
2115 + @override
2116 + String get export_conversation => 'Export conversation';
2117 +
2118 + @override
2119 + String get tts_process_markdown => 'Process markdown for speech';
2120 +
2121 + @override
2122 + String get tts_process_markdown_desc =>
2123 + 'Strip formatting like **bold** before reading aloud';
2124 +
2125 + @override
2126 + String get preview_system_prompts => 'Preview system prompts';
2127 +
2128 + @override
2129 + String get welcome_message_1 => 'What can I help you with today?';
2130 +
2131 + @override
2132 + String get welcome_message_2 => 'Ask me anything — I\'m ready when you are.';
2133 +
2134 + @override
2135 + String get welcome_message_3 => 'Start a conversation below.';
2136 +
2137 + @override
2138 + String get welcome_message_4 => 'Need ideas? Try one of the quick prompts.';
2139 }
2140
2141 /// The translations for Chinese, as used in Taiwan (`zh_TW`).