Add temporary chats, saved messages & TTS features
Introduce temporary (in-memory) chat mode and UI (banner, mode button), Saved Messages UI/workflow (picker, folders, save sheet), sharing service, and AI title generation service. Enhance chat streaming/metrics and persistence (new conversation/message fields + ObjectBox schema updates). Add TTS improvements: seeking, skip interval setting, download merged WAV, system-TTS seek heuristics, and player UI/controls. New reusable UI components (FolderFilterBar, ListFilterButton, ModelInfoSheet, Server picker) and image/attachment support (permissions + image_picker). Add Russian localization and many l10n updates.
onlypuppy7 committed
Jun 30, 2026 at 22:40 UTC
d912c4c5b7d00cade19f838adf4b9701ec5a80c6
61 files changed
+9416
-852
android/app/src/main/AndroidManifest.xml
+2
@@ -7,6 +7,8 @@
7
<uses-permission android:name="android.permission.WAKE_LOCK" />
8
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
9
<uses-permission android:name="android.permission.RECORD_AUDIO" />
10
+ <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
11
+ <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
12
13
<application
14
android:label="LocalMind"
lib/app.dart
+1
@@ -234,6 +234,7 @@ class App extends ConsumerWidget {
234
Locale('hi'),
235
Locale('it'),
236
Locale('ja'),
237
+ Locale('ru'),
238
],
239
locale: localeCode != null ? Locale(localeCode) : null,
240
localeResolutionCallback: (locale, supportedLocales) {
lib/core/components/folder_filter_bar.dart
new
+155
@@ -0,0 +1,155 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:localmind/l10n/app_localizations.dart';
3
+
4
+class FolderFilterItem {
5
+ const FolderFilterItem({required this.id, required this.name});
6
+
7
+ final String id;
8
+ final String name;
9
+}
10
+
11
+class FolderFilterBar extends StatelessWidget {
12
+ const FolderFilterBar({
13
+ super.key,
14
+ required this.folders,
15
+ required this.selectedFolderId,
16
+ required this.onFilterChanged,
17
+ required this.onCreateFolder,
18
+ this.isLoading = false,
19
+ this.showCreateFolder = true,
20
+ });
21
+
22
+ /// `null` = all, `''` = unfiled, otherwise folder id.
23
+ final List<FolderFilterItem> folders;
24
+ final String? selectedFolderId;
25
+ final ValueChanged<String?> onFilterChanged;
26
+ final VoidCallback onCreateFolder;
27
+ final bool isLoading;
28
+ final bool showCreateFolder;
29
+
30
+ @override
31
+ Widget build(BuildContext context) {
32
+ final l10n = AppLocalizations.of(context)!;
33
+ final theme = Theme.of(context);
34
+ final isDark = theme.brightness == Brightness.dark;
35
+
36
+ if (isLoading) {
37
+ return const SizedBox(height: 44);
38
+ }
39
+
40
+ return SingleChildScrollView(
41
+ scrollDirection: Axis.horizontal,
42
+ padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
43
+ child: Row(
44
+ children: [
45
+ SystemFolderFilterChip(
46
+ label: l10n.all_chats,
47
+ icon: Icons.view_list_rounded,
48
+ selected: selectedFolderId == null,
49
+ isDark: isDark,
50
+ onSelected: (_) => onFilterChanged(null),
51
+ ),
52
+ const SizedBox(width: 6),
53
+ SystemFolderFilterChip(
54
+ label: l10n.unfiled_chats,
55
+ icon: Icons.inbox_outlined,
56
+ selected: selectedFolderId != null && selectedFolderId!.isEmpty,
57
+ isDark: isDark,
58
+ outlined: true,
59
+ onSelected: (_) => onFilterChanged(''),
60
+ ),
61
+ ...folders.map(
62
+ (folder) => Padding(
63
+ padding: const EdgeInsets.only(left: 6),
64
+ child: FilterChip(
65
+ avatar: Icon(
66
+ Icons.folder_outlined,
67
+ size: 16,
68
+ color: selectedFolderId == folder.id
69
+ ? theme.colorScheme.onSecondaryContainer
70
+ : theme.colorScheme.primary,
71
+ ),
72
+ label: Text(folder.name),
73
+ selected: selectedFolderId == folder.id,
74
+ showCheckmark: false,
75
+ onSelected: (_) => onFilterChanged(folder.id),
76
+ ),
77
+ ),
78
+ ),
79
+ if (showCreateFolder) ...[
80
+ const SizedBox(width: 6),
81
+ ActionChip(
82
+ avatar: Icon(
83
+ Icons.create_new_folder_outlined,
84
+ size: 16,
85
+ color: theme.colorScheme.primary,
86
+ ),
87
+ label: Text(l10n.new_folder),
88
+ side: BorderSide(
89
+ color: theme.colorScheme.primary.withValues(alpha: 0.5),
90
+ style: BorderStyle.solid,
91
+ ),
92
+ onPressed: onCreateFolder,
93
+ ),
94
+ ],
95
+ ],
96
+ ),
97
+ );
98
+ }
99
+}
100
+
101
+class SystemFolderFilterChip extends StatelessWidget {
102
+ const SystemFolderFilterChip({
103
+ super.key,
104
+ required this.label,
105
+ required this.icon,
106
+ required this.selected,
107
+ required this.isDark,
108
+ required this.onSelected,
109
+ this.outlined = false,
110
+ });
111
+
112
+ final String label;
113
+ final IconData icon;
114
+ final bool selected;
115
+ final bool isDark;
116
+ final bool outlined;
117
+ final ValueChanged<bool> onSelected;
118
+
119
+ @override
120
+ Widget build(BuildContext context) {
121
+ final theme = Theme.of(context);
122
+ final bgColor = selected
123
+ ? (outlined
124
+ ? theme.colorScheme.surfaceContainerHighest
125
+ : theme.colorScheme.secondaryContainer)
126
+ : Colors.transparent;
127
+ final borderColor = outlined
128
+ ? (isDark ? const Color(0xFF444444) : const Color(0xFFCCCCCC))
129
+ : (selected
130
+ ? theme.colorScheme.secondaryContainer
131
+ : (isDark ? const Color(0xFF333333) : const Color(0xFFE0E0E0)));
132
+
133
+ return FilterChip(
134
+ avatar: Icon(
135
+ icon,
136
+ size: 16,
137
+ color: selected
138
+ ? theme.colorScheme.onSecondaryContainer
139
+ : theme.colorScheme.onSurfaceVariant,
140
+ ),
141
+ label: Text(
142
+ label,
143
+ style: TextStyle(
144
+ fontWeight: outlined ? FontWeight.normal : FontWeight.w600,
145
+ fontStyle: outlined ? FontStyle.italic : FontStyle.normal,
146
+ ),
147
+ ),
148
+ selected: selected,
149
+ showCheckmark: false,
150
+ backgroundColor: bgColor,
151
+ side: BorderSide(color: borderColor),
152
+ onSelected: onSelected,
153
+ );
154
+ }
155
+}
lib/core/components/list_filter_button.dart
new
+93
@@ -0,0 +1,93 @@
1
+import 'package:flutter/material.dart';
2
+
3
+class ListFilterOption<T> {
4
+ const ListFilterOption({
5
+ required this.value,
6
+ required this.label,
7
+ this.icon,
8
+ });
9
+
10
+ final T value;
11
+ final String label;
12
+ final IconData? icon;
13
+}
14
+
15
+class ListFilterButton<T> extends StatelessWidget {
16
+ const ListFilterButton({
17
+ super.key,
18
+ required this.options,
19
+ required this.selected,
20
+ required this.onChanged,
21
+ this.tooltip,
22
+ });
23
+
24
+ final List<ListFilterOption<T>> options;
25
+ final T selected;
26
+ final ValueChanged<T> onChanged;
27
+ final String? tooltip;
28
+
29
+ bool get _hasActiveFilter {
30
+ if (options.isEmpty) return false;
31
+ return selected != options.first.value;
32
+ }
33
+
34
+ @override
35
+ Widget build(BuildContext context) {
36
+ final theme = Theme.of(context);
37
+
38
+ return IconButton(
39
+ tooltip: tooltip,
40
+ onPressed: () => _showFilterSheet(context),
41
+ icon: Badge(
42
+ isLabelVisible: _hasActiveFilter,
43
+ smallSize: 8,
44
+ child: Icon(
45
+ Icons.filter_list_rounded,
46
+ color: _hasActiveFilter
47
+ ? theme.colorScheme.primary
48
+ : theme.iconTheme.color,
49
+ ),
50
+ ),
51
+ );
52
+ }
53
+
54
+ void _showFilterSheet(BuildContext context) {
55
+ showModalBottomSheet<void>(
56
+ context: context,
57
+ builder: (ctx) {
58
+ return SafeArea(
59
+ child: Column(
60
+ mainAxisSize: MainAxisSize.min,
61
+ children: [
62
+ const SizedBox(height: 8),
63
+ ...options.map(
64
+ (option) => ListTile(
65
+ leading: option.icon != null
66
+ ? Icon(
67
+ option.icon,
68
+ color: selected == option.value
69
+ ? Theme.of(ctx).colorScheme.primary
70
+ : null,
71
+ )
72
+ : null,
73
+ title: Text(option.label),
74
+ trailing: selected == option.value
75
+ ? Icon(
76
+ Icons.check,
77
+ color: Theme.of(ctx).colorScheme.primary,
78
+ )
79
+ : null,
80
+ onTap: () {
81
+ onChanged(option.value);
82
+ Navigator.pop(ctx);
83
+ },
84
+ ),
85
+ ),
86
+ const SizedBox(height: 8),
87
+ ],
88
+ ),
89
+ );
90
+ },
91
+ );
92
+ }
93
+}
lib/core/providers/app_providers.dart
+2
@@ -100,6 +100,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
100
_update(state.copyWith(autoSpeakEnabled: value));
101
void setTtsProcessMarkdown(bool value) =>
102
_update(state.copyWith(ttsProcessMarkdown: value));
103
+ void setTtsSkipSeconds(int value) =>
104
+ _update(state.copyWith(ttsSkipSeconds: value));
105
void setSmartReplyEnabled(bool value) =>
106
_update(state.copyWith(smartReplyEnabled: value));
107
void setLocaleCode(String? value) =>
lib/core/services/share_service.dart
new
+10
@@ -0,0 +1,10 @@
1
+import 'package:share_plus/share_plus.dart';
2
+
3
+class ShareService {
4
+ ShareService._();
5
+
6
+ static Future<void> shareText(String text, {String? subject}) async {
7
+ if (text.trim().isEmpty) return;
8
+ await SharePlus.instance.share(ShareParams(text: text, subject: subject));
9
+ }
10
+}
lib/core/storage/entities.dart
+28
@@ -188,6 +188,9 @@ class ConversationEntity {
188
String? smartRepliesJson;
189
String? smartRepliesLastMessageId;
190
String? folderId;
191
+ bool isTemporary;
192
+ bool isArchived;
193
+ int characterCount;
194
195
@Backlink()
196
final messages = ToMany<MessageEntity>();
@@ -213,6 +216,9 @@ class ConversationEntity {
216
this.smartRepliesJson,
217
this.smartRepliesLastMessageId,
218
this.folderId,
219
+ this.isTemporary = false,
220
+ this.isArchived = false,
221
+ this.characterCount = 0,
222
});
223
224
factory ConversationEntity.fromDomain(Conversation conversation) {
@@ -238,6 +244,9 @@ class ConversationEntity {
244
: null,
245
smartRepliesLastMessageId: conversation.smartRepliesLastMessageId,
246
folderId: conversation.folderId,
247
+ isTemporary: conversation.isTemporary,
248
+ isArchived: conversation.isArchived,
249
+ characterCount: conversation.characterCount,
250
);
251
}
252
@@ -264,6 +273,9 @@ class ConversationEntity {
273
: null,
274
smartRepliesLastMessageId: smartRepliesLastMessageId,
275
folderId: folderId,
276
+ isTemporary: isTemporary,
277
+ isArchived: isArchived,
278
+ characterCount: characterCount,
279
);
280
}
281
@@ -379,6 +391,10 @@ class MessageEntity {
391
392
String? modelId;
393
int? tokenCount;
394
+ int? inputTokenCount;
395
+ double? tokensPerSecond;
396
+ int? ttftMs;
397
+ String? stopReason;
398
String? errorMessage;
399
String? attachmentPathsJson;
400
int? generationTimeMs;
@@ -404,6 +420,10 @@ class MessageEntity {
420
required this.statusIndex,
421
this.modelId,
422
this.tokenCount,
423
+ this.inputTokenCount,
424
+ this.tokensPerSecond,
425
+ this.ttftMs,
426
+ this.stopReason,
427
this.errorMessage,
428
this.attachmentPathsJson,
429
this.generationTimeMs,
@@ -430,6 +450,10 @@ class MessageEntity {
450
statusIndex: message.status.index,
451
modelId: message.modelId,
452
tokenCount: message.tokenCount,
453
+ inputTokenCount: message.inputTokenCount,
454
+ tokensPerSecond: message.tokensPerSecond,
455
+ ttftMs: message.ttftMs,
456
+ stopReason: message.stopReason,
457
errorMessage: message.errorMessage,
458
attachmentPathsJson: message.attachmentPaths != null
459
? jsonEncode(message.attachmentPaths)
@@ -463,6 +487,10 @@ class MessageEntity {
487
status: MessageStatus.values[statusIndex],
488
modelId: modelId,
489
tokenCount: tokenCount,
490
+ inputTokenCount: inputTokenCount,
491
+ tokensPerSecond: tokensPerSecond,
492
+ ttftMs: ttftMs,
493
+ stopReason: stopReason,
494
errorMessage: errorMessage,
495
attachmentPaths: attachmentPathsJson != null
496
? List<String>.from(jsonDecode(attachmentPathsJson!))
lib/features/chat/data/models/message.dart
+16
@@ -12,6 +12,10 @@ class Message {
12
final MessageStatus status;
13
final String? modelId;
14
final int? tokenCount;
15
+ final int? inputTokenCount;
16
+ final double? tokensPerSecond;
17
+ final int? ttftMs;
18
+ final String? stopReason;
19
final String? errorMessage;
20
final List<String>? attachmentPaths;
21
final int? generationTimeMs;
@@ -36,6 +40,10 @@ class Message {
40
this.status = MessageStatus.complete,
41
this.modelId,
42
this.tokenCount,
43
+ this.inputTokenCount,
44
+ this.tokensPerSecond,
45
+ this.ttftMs,
46
+ this.stopReason,
47
this.errorMessage,
48
this.attachmentPaths,
49
this.generationTimeMs,
@@ -61,6 +69,10 @@ class Message {
69
MessageStatus? status,
70
String? modelId,
71
int? tokenCount,
72
+ int? inputTokenCount,
73
+ double? tokensPerSecond,
74
+ int? ttftMs,
75
+ String? stopReason,
76
String? errorMessage,
77
List<String>? attachmentPaths,
78
int? generationTimeMs,
@@ -85,6 +97,10 @@ class Message {
97
status: status ?? this.status,
98
modelId: modelId ?? this.modelId,
99
tokenCount: tokenCount ?? this.tokenCount,
100
+ inputTokenCount: inputTokenCount ?? this.inputTokenCount,
101
+ tokensPerSecond: tokensPerSecond ?? this.tokensPerSecond,
102
+ ttftMs: ttftMs ?? this.ttftMs,
103
+ stopReason: stopReason ?? this.stopReason,
104
errorMessage: errorMessage ?? this.errorMessage,
105
attachmentPaths: attachmentPaths ?? this.attachmentPaths,
106
generationTimeMs: generationTimeMs ?? this.generationTimeMs,
lib/features/chat/data/title_generation_service.dart
new
+131
@@ -0,0 +1,131 @@
1
+import 'dart:async';
2
+
3
+import '../../../core/logger/app_logger.dart';
4
+import '../../../core/models/enums.dart';
5
+import '../../servers/data/models/server.dart';
6
+import 'chat_service.dart';
7
+import 'models/chat_parameters.dart';
8
+import 'models/message.dart';
9
+
10
+class TitleGenerationService {
11
+ static const _defaultTitle = 'New Chat';
12
+
13
+ Future<String?> generateTitleWithLLM({
14
+ required ChatService chatService,
15
+ required Server server,
16
+ required String modelId,
17
+ required List<Message> messages,
18
+ required ChatParameters params,
19
+ }) async {
20
+ if (messages.isEmpty) return null;
21
+
22
+ final promptMessage = Message(
23
+ id: 'title-generation-prompt',
24
+ conversationId: messages.first.conversationId,
25
+ role: MessageRole.user,
26
+ content:
27
+ 'Based on the conversation above, generate a short, descriptive title for this chat. '
28
+ 'Return ONLY the title text — no quotes, markdown, labels, or explanation. '
29
+ 'Keep it under 8 words.',
30
+ createdAt: DateTime.now(),
31
+ );
32
+
33
+ final titleParams = params.copyWith(
34
+ temperature: 0.3,
35
+ maxTokens: 32,
36
+ systemPrompt:
37
+ 'You generate concise chat titles. Output only the title string, nothing else.',
38
+ );
39
+
40
+ try {
41
+ final completer = Completer<String>();
42
+ var accumulatedContent = '';
43
+
44
+ final stream = chatService.sendMessage(
45
+ server: server,
46
+ modelId: modelId,
47
+ messages: [...messages, promptMessage],
48
+ params: titleParams,
49
+ );
50
+
51
+ StreamSubscription? subscription;
52
+ subscription = stream.listen(
53
+ (response) {
54
+ if (response.type == ChatResponseType.message &&
55
+ response.content != null) {
56
+ accumulatedContent += response.content!;
57
+ } else if (response.type == ChatResponseType.done) {
58
+ subscription?.cancel();
59
+ if (!completer.isCompleted) {
60
+ completer.complete(accumulatedContent);
61
+ }
62
+ } else if (response.type == ChatResponseType.error) {
63
+ subscription?.cancel();
64
+ if (!completer.isCompleted) {
65
+ completer.completeError(
66
+ response.content ?? 'Unknown streaming error',
67
+ );
68
+ }
69
+ }
70
+ },
71
+ onError: (err) {
72
+ subscription?.cancel();
73
+ if (!completer.isCompleted) {
74
+ completer.completeError(err);
75
+ }
76
+ },
77
+ onDone: () {
78
+ subscription?.cancel();
79
+ if (!completer.isCompleted) {
80
+ completer.complete(accumulatedContent);
81
+ }
82
+ },
83
+ cancelOnError: true,
84
+ );
85
+
86
+ final rawResponse = await completer.future.timeout(
87
+ const Duration(seconds: 15),
88
+ onTimeout: () {
89
+ subscription?.cancel();
90
+ return accumulatedContent;
91
+ },
92
+ );
93
+
94
+ return _parseTitle(rawResponse);
95
+ } catch (e) {
96
+ Log.warning('LLM title generation failed: $e');
97
+ return null;
98
+ }
99
+ }
100
+
101
+ String truncateFirstMessageTitle(String content, {int maxLength = 50}) {
102
+ final trimmed = content.trim();
103
+ if (trimmed.isEmpty) return _defaultTitle;
104
+ if (trimmed.length <= maxLength) return trimmed;
105
+ return '${trimmed.substring(0, maxLength)}...';
106
+ }
107
+
108
+ String _parseTitle(String text) {
109
+ var title = text.trim();
110
+ if (title.isEmpty) return '';
111
+
112
+ title = title.replaceFirst(
113
+ RegExp(r'^(title|chat title|subject)\s*:\s*', caseSensitive: false),
114
+ '',
115
+ );
116
+ title = title.replaceAll(RegExp(r'```.*?```', dotAll: true), '');
117
+ title = title.replaceAll('`', '');
118
+ title = title.split('\n').first.trim();
119
+
120
+ if ((title.startsWith('"') && title.endsWith('"')) ||
121
+ (title.startsWith("'") && title.endsWith("'"))) {
122
+ title = title.substring(1, title.length - 1).trim();
123
+ }
124
+
125
+ if (title.length > 80) {
126
+ title = '${title.substring(0, 80).trim()}...';
127
+ }
128
+
129
+ return title;
130
+ }
131
+}
lib/features/chat/providers/chat_notifier.dart
+375
-72
@@ -8,6 +8,7 @@ import 'package:localmind/core/models/enums.dart';
8
import 'package:localmind/core/providers/app_providers.dart';
9
import 'package:localmind/core/providers/review_prompt_providers.dart';
10
import 'package:localmind/core/providers/chat_background_service_provider.dart';
11
+import 'package:localmind/core/providers/service_providers.dart';
12
import 'package:localmind/core/providers/storage_providers.dart';
13
import 'package:localmind/core/services/message_save_service.dart';
14
import 'package:localmind/core/storage/entities.dart';
@@ -22,6 +23,7 @@ import 'package:localmind/features/servers/providers/server_providers.dart';
23
import 'package:localmind/objectbox.g.dart';
24
import '../data/chat_service.dart';
25
import '../data/models/message.dart' hide ToolCallData;
26
+import '../data/title_generation_service.dart';
27
import '../data/tools/tool_definition.dart';
28
import '../data/tools/tool_execution_loop.dart';
29
import '../data/tools/adapters/tool_transport_adapter.dart' show ParsedToolCall;
@@ -47,6 +49,7 @@ class ChatState {
49
final String? errorMessage;
50
final Message? streamingMessage;
51
final PendingToolApproval? pendingToolApproval;
52
+ final bool isTemporary;
53
54
const ChatState({
55
this.messages = const [],
@@ -56,6 +59,7 @@ class ChatState {
59
this.errorMessage,
60
this.streamingMessage,
61
this.pendingToolApproval,
62
+ this.isTemporary = false,
63
});
64
65
ChatState copyWith({
@@ -66,6 +70,7 @@ class ChatState {
70
String? errorMessage,
71
Message? streamingMessage,
72
PendingToolApproval? pendingToolApproval,
73
+ bool? isTemporary,
74
bool clearError = false,
75
bool clearStreaming = false,
76
bool clearPendingApproval = false,
@@ -82,6 +87,7 @@ class ChatState {
87
pendingToolApproval: clearPendingApproval
88
? null
89
: (pendingToolApproval ?? this.pendingToolApproval),
90
+ isTemporary: isTemporary ?? this.isTemporary,
91
);
92
}
93
}
@@ -104,6 +110,60 @@ class ChatNotifier extends Notifier<ChatState> {
110
int _lastSavedReasoningLength = 0;
111
112
MessageSaveService? _saveService;
113
+ bool _pendingTemporaryChat = false;
114
+ String? _ephemeralConversationId;
115
+ ChatStats? _streamStats;
116
+ DateTime? _streamStartTime;
117
+ DateTime? _firstTokenTime;
118
+
119
+ String? get _activeConversationId =>
120
+ _currentConversationId ?? _ephemeralConversationId;
121
+
122
+ bool get _isInMemoryChat => state.isTemporary;
123
+
124
+ void _resetStreamMetrics() {
125
+ _streamStats = null;
126
+ _streamStartTime = DateTime.now();
127
+ _firstTokenTime = null;
128
+ }
129
+
130
+ void _noteFirstToken() {
131
+ _firstTokenTime ??= DateTime.now();
132
+ }
133
+
134
+ Message _finalizeStreamMessage(Message msg, {String? stopReason}) {
135
+ final stats = _streamStats;
136
+ final start = _streamStartTime;
137
+ final firstToken = _firstTokenTime;
138
+ final now = DateTime.now();
139
+
140
+ int? ttftMs;
141
+ if (firstToken != null && start != null) {
142
+ ttftMs = firstToken.difference(start).inMilliseconds;
143
+ } else if (stats?.timeToFirstTokenSeconds != null) {
144
+ ttftMs = (stats!.timeToFirstTokenSeconds! * 1000).round();
145
+ }
146
+
147
+ int? genMs;
148
+ if (start != null) {
149
+ genMs = now.difference(start).inMilliseconds;
150
+ }
151
+
152
+ double? tps = stats?.tokensPerSecond;
153
+ final outputTokens = stats?.totalOutputTokens;
154
+ if (tps == null && outputTokens != null && genMs != null && genMs > 0) {
155
+ tps = outputTokens / (genMs / 1000);
156
+ }
157
+
158
+ return msg.copyWith(
159
+ tokenCount: outputTokens ?? msg.tokenCount,
160
+ inputTokenCount: stats?.inputTokens ?? msg.inputTokenCount,
161
+ tokensPerSecond: tps ?? msg.tokensPerSecond,
162
+ generationTimeMs: genMs ?? msg.generationTimeMs,
163
+ ttftMs: ttftMs ?? msg.ttftMs,
164
+ stopReason: stopReason ?? msg.stopReason,
165
+ );
166
+ }
167
168
void approveTool(bool approved) {
169
final pending = state.pendingToolApproval;
@@ -161,6 +221,7 @@ class ChatNotifier extends Notifier<ChatState> {
221
allMessages: messages,
222
messages: MessageVariants.resolveActiveTimeline(messages),
223
isLoading: false,
224
+ isTemporary: conversation.isTemporary,
225
);
226
ref
227
.read(conv.activeConversationProvider.notifier)
@@ -272,8 +333,11 @@ class ChatNotifier extends Notifier<ChatState> {
333
}
334
335
Future<void> startNewConversation() async {
275
- await cancelStream();
336
+ _abortStreamImmediately();
337
+
338
_currentConversationId = null;
339
+ _ephemeralConversationId = null;
340
+ _pendingTemporaryChat = false;
341
ref.read(smartReplyServiceProvider).reset();
342
state = const ChatState();
343
ref
@@ -286,6 +350,61 @@ class ChatNotifier extends Notifier<ChatState> {
350
.setEnabled(settings.newChatMcpEnabled);
351
}
352
353
+ void _abortStreamImmediately() {
354
+ _uiUpdateTimer?.cancel();
355
+ _uiUpdateTimer = null;
356
+ _clearPendingApproval();
357
+
358
+ final subscription = _streamSubscription;
359
+ _streamSubscription = null;
360
+ if (subscription != null) {
361
+ unawaited(subscription.cancel());
362
+ }
363
+
364
+ ref.read(chatServiceProvider)?.cancelStream();
365
+ ref.read(chatBackgroundServiceProvider).stop();
366
+ ref.read(isStreamingProvider.notifier).setStreaming(false);
367
+
368
+ _chunkCount = 0;
369
+ _lastCheckpointTime = null;
370
+ _lastSavedContentLength = 0;
371
+ _lastSavedReasoningLength = 0;
372
+
373
+ final convId = _currentConversationId;
374
+ final streamingMessage = _latestStreamingMessage ?? state.streamingMessage;
375
+ _latestStreamingMessage = null;
376
+
377
+ if (streamingMessage != null && convId != null) {
378
+ unawaited(_persistCancelledMessageInBackground(streamingMessage, convId));
379
+ }
380
+ }
381
+
382
+ Future<void> _persistCancelledMessageInBackground(
383
+ Message streamingMessage,
384
+ String conversationId,
385
+ ) async {
386
+ try {
387
+ final finalMessage = _finalizeStreamMessage(
388
+ streamingMessage.copyWith(
389
+ conversationId: conversationId,
390
+ status: MessageStatus.cancelled,
391
+ isProcessing: false,
392
+ ),
393
+ stopReason: 'cancelled',
394
+ );
395
+ await _saveService?.flush();
396
+ await _saveMessage(finalMessage);
397
+ } catch (e) {
398
+ Log.error('Failed to persist cancelled stream message: $e');
399
+ }
400
+ }
401
+
402
+ void setTemporaryMode(bool enabled) {
403
+ if (state.messages.isNotEmpty && enabled != state.isTemporary) return;
404
+ _pendingTemporaryChat = enabled;
405
+ state = state.copyWith(isTemporary: enabled);
406
+ }
407
+
408
String generateUuid() {
409
final random = Random.secure();
410
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
@@ -333,6 +452,7 @@ class ChatNotifier extends Notifier<ChatState> {
452
}
453
454
Future<void> checkpointStreamingMessage({bool flush = false}) async {
455
+ if (_isInMemoryChat) return;
456
final latest = _latestStreamingMessage ?? state.streamingMessage;
457
if (latest == null) return;
458
@@ -368,37 +488,52 @@ class ChatNotifier extends Notifier<ChatState> {
488
489
final trimmedContent = content.trim();
490
371
- if (_currentConversationId == null) {
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(
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,
388
- mcpEnabled: settings.newChatMcpEnabled,
389
- );
390
- _currentConversationId = conversation.id;
391
- ref
392
- .read(conv.activeConversationProvider.notifier)
393
- .setActiveConversation(conversation);
491
+ if (_currentConversationId == null && _ephemeralConversationId == null) {
492
+ final isTemp = state.isTemporary || _pendingTemporaryChat;
493
+ if (isTemp) {
494
+ _ephemeralConversationId = generateUuid();
495
+ _pendingTemporaryChat = false;
496
+ state = state.copyWith(isTemporary: true);
497
+ } else {
498
+ final titleService = ref.read(titleGenerationServiceProvider);
499
+ final String initialTitle;
500
+ if (settings.autoGenerateTitle) {
501
+ initialTitle = 'New Chat';
502
+ } else {
503
+ final titleSource = trimmedContent.isNotEmpty
504
+ ? trimmedContent
505
+ : (attachments?.isNotEmpty == true
506
+ ? attachments!.first.path.split(Platform.pathSeparator).last
507
+ : 'New Chat');
508
+ initialTitle = titleService.truncateFirstMessageTitle(titleSource);
509
+ }
510
+ final conversation = await ref
511
+ .read(conv.conversationsProvider.notifier)
512
+ .createConversation(
513
+ title: initialTitle,
514
+ serverId: server.id,
515
+ modelId: selectedModel?.id,
516
+ personaId: ref.read(selectedPersonaProvider)?.id,
517
+ systemPrompt: ref.read(selectedPersonaProvider)?.systemPrompt,
518
+ mcpEnabled: settings.newChatMcpEnabled,
519
+ isTemporary: false,
520
+ );
521
+ _currentConversationId = conversation.id;
522
+ ref
523
+ .read(conv.activeConversationProvider.notifier)
524
+ .setActiveConversation(conversation);
525
395
- ref
396
- .read(chatMcpConfigProvider.notifier)
397
- .setEnabled(settings.newChatMcpEnabled);
526
+ ref
527
+ .read(chatMcpConfigProvider.notifier)
528
+ .setEnabled(settings.newChatMcpEnabled);
529
399
- ref.read(selectedPersonaProvider.notifier).clear();
530
+ ref.read(selectedPersonaProvider.notifier).clear();
531
+ }
532
}
533
534
+ final convId = _activeConversationId;
535
+ if (convId == null) return;
536
+
537
final List<String> savedPaths = [];
538
if (attachments != null && attachments.isNotEmpty) {
539
final appDir = await ref.read(storageDirectoryProvider.future);
@@ -424,7 +559,7 @@ class ChatNotifier extends Notifier<ChatState> {
559
560
final userMessage = Message(
561
id: generateUuid(),
427
- conversationId: _currentConversationId!,
562
+ conversationId: convId,
563
role: MessageRole.user,
564
content: trimmedContent,
565
createdAt: DateTime.now(),
@@ -440,7 +575,7 @@ class ChatNotifier extends Notifier<ChatState> {
575
final assistantMessageId = generateUuid();
576
var assistantMessage = Message(
577
id: assistantMessageId,
443
- conversationId: _currentConversationId!,
578
+ conversationId: convId,
579
role: MessageRole.assistant,
580
content: '',
581
createdAt: DateTime.now(),
@@ -468,6 +603,7 @@ class ChatNotifier extends Notifier<ChatState> {
603
604
ref.read(chatBackgroundServiceProvider).start();
605
606
+ _resetStreamMetrics();
607
_resetCheckpointMetrics();
608
_updateSavedMetrics(assistantMessage);
609
@@ -529,6 +665,9 @@ class ChatNotifier extends Notifier<ChatState> {
665
666
switch (response.type) {
667
case ChatResponseType.message:
668
+ if (response.content?.isNotEmpty ?? false) {
669
+ _noteFirstToken();
670
+ }
671
streamingAssistantMessage = streamingAssistantMessage
672
.copyWith(
673
content:
@@ -540,7 +679,8 @@ class ChatNotifier extends Notifier<ChatState> {
679
680
_chunkCount++;
681
543
- if (_shouldCheckpointSave(streamingAssistantMessage)) {
682
+ if (!_isInMemoryChat &&
683
+ _shouldCheckpointSave(streamingAssistantMessage)) {
684
_saveService?.enqueue(streamingAssistantMessage);
685
_updateSavedMetrics(streamingAssistantMessage);
686
_resetCheckpointMetrics();
@@ -549,6 +689,9 @@ class ChatNotifier extends Notifier<ChatState> {
689
stateNeedsUpdate = true;
690
break;
691
case ChatResponseType.reasoning:
692
+ if (response.reasoningContent?.isNotEmpty ?? false) {
693
+ _noteFirstToken();
694
+ }
695
reasoningContent += response.reasoningContent ?? '';
696
streamingAssistantMessage = streamingAssistantMessage
697
.copyWith(
@@ -559,7 +702,8 @@ class ChatNotifier extends Notifier<ChatState> {
702
703
_chunkCount++;
704
562
- if (_shouldCheckpointSave(streamingAssistantMessage)) {
705
+ if (!_isInMemoryChat &&
706
+ _shouldCheckpointSave(streamingAssistantMessage)) {
707
_saveService?.enqueue(streamingAssistantMessage);
708
_updateSavedMetrics(streamingAssistantMessage);
709
_resetCheckpointMetrics();
@@ -610,18 +754,23 @@ class ChatNotifier extends Notifier<ChatState> {
754
break;
755
case ChatResponseType.invalidToolCall:
756
case ChatResponseType.done:
757
+ if (response.stats != null) {
758
+ _streamStats = response.stats;
759
+ }
760
break;
761
}
762
},
763
onDone: () async {
617
- _uiUpdateTimer?.cancel();
618
- _uiUpdateTimer = null;
619
- updateUiState();
764
+ _uiUpdateTimer?.cancel();
765
+ _uiUpdateTimer = null;
766
+ updateUiState();
767
768
+ if (!_isInMemoryChat) {
769
await _saveService?.flush();
622
- ref.read(chatBackgroundServiceProvider).stop();
623
- final streamConvId = assistantMessage.conversationId;
624
- final isCurrentContext = _currentConversationId == streamConvId;
770
+ }
771
+ ref.read(chatBackgroundServiceProvider).stop();
772
+ final streamConvId = assistantMessage.conversationId;
773
+ final isCurrentContext = _activeConversationId == streamConvId;
774
final streamingMessage = streamingAssistantMessage;
775
final hasContent =
776
streamingMessage.content.isNotEmpty ||
@@ -650,9 +799,12 @@ class ChatNotifier extends Notifier<ChatState> {
799
ref.read(isStreamingProvider.notifier).setStreaming(false);
800
_latestStreamingMessage = null;
801
} else {
653
- var finalMessage = streamingMessage.copyWith(
654
- status: MessageStatus.complete,
655
- isProcessing: false,
802
+ var finalMessage = _finalizeStreamMessage(
803
+ streamingMessage.copyWith(
804
+ status: MessageStatus.complete,
805
+ isProcessing: false,
806
+ ),
807
+ stopReason: 'complete',
808
);
809
810
if (mcpConfig.enabled && collectedToolCalls.isNotEmpty) {
@@ -722,27 +874,28 @@ class ChatNotifier extends Notifier<ChatState> {
874
}
875
ref.read(isStreamingProvider.notifier).setStreaming(false);
876
_latestStreamingMessage = null;
725
- if (_currentConversationId != null) {
877
+ if (_currentConversationId != null) {
878
final preview = finalMessage.content.length > 100
879
? '${finalMessage.content.substring(0, 100)}...'
880
: finalMessage.content;
881
+ final totalChars = state.messages.fold<int>(
882
+ 0,
883
+ (sum, message) => sum + message.content.length,
884
+ );
885
await ref
886
.read(conv.conversationsProvider.notifier)
887
.updatePreview(
888
_currentConversationId!,
889
preview,
890
DateTime.now(),
891
+ characterCount: totalChars,
892
);
893
894
final userMessage = state.messages
895
.where((m) => m.role == MessageRole.user)
896
.firstOrNull;
740
- if (userMessage != null &&
741
- userMessage.content.length > 10) {
742
- _autoGenerateTitle(
743
- userMessage.content,
744
- finalMessage.content,
745
- );
897
+ if (userMessage != null) {
898
+ _maybeAutoGenerateTitleAfterFirstReply();
899
}
900
}
901
@@ -900,22 +1053,132 @@ class ChatNotifier extends Notifier<ChatState> {
1053
return result;
1054
}
1055
903
- void _autoGenerateTitle(String userContent, String assistantContent) {
1056
+ void _maybeAutoGenerateTitleAfterFirstReply() {
1057
+ if (_currentConversationId == null || _isInMemoryChat) return;
1058
+
1059
final settings = ref.read(settingsProvider);
1060
if (!settings.autoGenerateTitle) return;
906
- if (_currentConversationId == null) return;
1061
1062
final activeConv = ref.read(conv.activeConversationProvider);
909
- if (activeConv == null) return;
910
- if (activeConv.title != 'New Chat') return;
1063
+ if (activeConv == null || activeConv.title != 'New Chat') return;
1064
912
- final title = userContent.length > 40
913
- ? '${userContent.substring(0, 40)}...'
914
- : userContent;
1065
+ final assistantCount = state.messages
1066
+ .where((m) => m.role == MessageRole.assistant)
1067
+ .length;
1068
+ if (assistantCount != 1) return;
1069
916
- ref
1070
+ unawaited(_generateAndApplyTitle(_currentConversationId!));
1071
+ }
1072
+
1073
+ Future<void> _generateAndApplyTitle(String conversationId) async {
1074
+ final title = await generateTitleWithAi(conversationId);
1075
+ if (title == null || title.isEmpty || title == 'New Chat') return;
1076
+
1077
+ await ref
1078
.read(conv.conversationsProvider.notifier)
918
- .renameConversation(_currentConversationId!, title);
1079
+ .renameConversation(conversationId, title);
1080
+ }
1081
+
1082
+ Future<String?> generateTitleWithAi(String conversationId) async {
1083
+ final messages = await _loadMessagesForConversation(conversationId);
1084
+ if (messages.isEmpty) return null;
1085
+
1086
+ final timeline = MessageVariants.resolveActiveTimeline(messages);
1087
+ if (timeline.isEmpty) return null;
1088
+
1089
+ final conversation = ref
1090
+ .read(conv.conversationsProvider)
1091
+ .value
1092
+ ?.where((c) => c.id == conversationId)
1093
+ .firstOrNull;
1094
+
1095
+ final server = _resolveServerForTitle(conversation);
1096
+ final modelId = _resolveModelIdForTitle(conversation);
1097
+ final titleService = ref.read(titleGenerationServiceProvider);
1098
+
1099
+ if (server == null || modelId == null) {
1100
+ return _fallbackTitleFromMessages(timeline, titleService);
1101
+ }
1102
+
1103
+ final chatService = _resolveChatServiceForTitle(server);
1104
+ if (chatService == null) {
1105
+ return _fallbackTitleFromMessages(timeline, titleService);
1106
+ }
1107
+
1108
+ final generated = await titleService.generateTitleWithLLM(
1109
+ chatService: chatService,
1110
+ server: server,
1111
+ modelId: modelId,
1112
+ messages: timeline,
1113
+ params: ref.read(chatParamsProvider),
1114
+ );
1115
+
1116
+ if (generated != null && generated.isNotEmpty) {
1117
+ return generated;
1118
+ }
1119
+ return _fallbackTitleFromMessages(timeline, titleService);
1120
+ }
1121
+
1122
+ Future<List<Message>> _loadMessagesForConversation(
1123
+ String conversationId,
1124
+ ) async {
1125
+ if (_currentConversationId == conversationId && state.allMessages.isNotEmpty) {
1126
+ return MessageVariants.resolveActiveTimeline(state.allMessages);
1127
+ }
1128
+
1129
+ final db = ref.read(databaseProvider);
1130
+ final loaded = await db.store.runInTransactionAsync(
1131
+ TxMode.read,
1132
+ _loadMessagesInBackground,
1133
+ conversationId,
1134
+ );
1135
+ return MessageVariants.resolveActiveTimeline(loaded);
1136
+ }
1137
+
1138
+ Server? _resolveServerForTitle(Conversation? conversation) {
1139
+ if (conversation?.serverId != null) {
1140
+ final servers = ref.read(serversProvider).value ?? [];
1141
+ for (final server in servers) {
1142
+ if (server.id == conversation!.serverId) {
1143
+ return server;
1144
+ }
1145
+ }
1146
+ }
1147
+ return ref.read(activeServerProvider);
1148
+ }
1149
+
1150
+ String? _resolveModelIdForTitle(Conversation? conversation) {
1151
+ return conversation?.modelId ?? ref.read(selectedModelProvider)?.id;
1152
+ }
1153
+
1154
+ ChatService? _resolveChatServiceForTitle(Server server) {
1155
+ final active = ref.read(activeServerProvider);
1156
+ if (active?.id == server.id) {
1157
+ return ref.read(chatServiceProvider);
1158
+ }
1159
+
1160
+ return createChatServiceForServer(
1161
+ server: server,
1162
+ dio: ref.read(dioProvider),
1163
+ onDeviceGemmaService: ref.read(onDeviceGemmaServiceProvider),
1164
+ onDeviceLlamaService: ref.read(onDeviceLlamaServiceProvider),
1165
+ loadedOnDeviceRuntime: ref.read(
1166
+ onDeviceEngineProvider.select((s) => s.loadedRuntime),
1167
+ ),
1168
+ );
1169
+ }
1170
+
1171
+ String _fallbackTitleFromMessages(
1172
+ List<Message> messages,
1173
+ TitleGenerationService titleService,
1174
+ ) {
1175
+ final userMessage = messages
1176
+ .where((m) => m.role == MessageRole.user)
1177
+ .firstOrNull;
1178
+ if (userMessage == null || userMessage.content.trim().isEmpty) {
1179
+ return 'New Chat';
1180
+ }
1181
+ return titleService.truncateFirstMessageTitle(userMessage.content);
1182
}
1183
1184
void _maybeRequestReviewAfterSuccessfulCompletion({
@@ -974,9 +1237,12 @@ class ChatNotifier extends Notifier<ChatState> {
1237
1238
final streamingMessage = _latestStreamingMessage ?? state.streamingMessage;
1239
if (streamingMessage != null) {
977
- final finalMessage = streamingMessage.copyWith(
978
- status: MessageStatus.cancelled,
979
- isProcessing: false,
1240
+ final finalMessage = _finalizeStreamMessage(
1241
+ streamingMessage.copyWith(
1242
+ status: MessageStatus.cancelled,
1243
+ isProcessing: false,
1244
+ ),
1245
+ stopReason: 'cancelled',
1246
);
1247
await _saveMessage(finalMessage);
1248
_replaceMessageInState(finalMessage, clearStreaming: true);
@@ -1211,7 +1477,7 @@ class ChatNotifier extends Notifier<ChatState> {
1477
void updateUiState() {
1478
if (!stateNeedsUpdate) return;
1479
stateNeedsUpdate = false;
1214
- if (_currentConversationId == assistantMessage.conversationId) {
1480
+ if (_activeConversationId == assistantMessage.conversationId) {
1481
state = state.copyWith(streamingMessage: streamingAssistantMessage);
1482
}
1483
}
@@ -1232,12 +1498,15 @@ class ChatNotifier extends Notifier<ChatState> {
1498
)
1499
.listen(
1500
(response) async {
1235
- if (_currentConversationId != assistantMessage.conversationId) {
1501
+ if (_activeConversationId != assistantMessage.conversationId) {
1502
return;
1503
}
1504
1505
switch (response.type) {
1506
case ChatResponseType.message:
1507
+ if (response.content?.isNotEmpty ?? false) {
1508
+ _noteFirstToken();
1509
+ }
1510
streamingAssistantMessage = streamingAssistantMessage
1511
.copyWith(
1512
content:
@@ -1247,7 +1516,8 @@ class ChatNotifier extends Notifier<ChatState> {
1516
);
1517
_latestStreamingMessage = streamingAssistantMessage;
1518
_chunkCount++;
1250
- if (_shouldCheckpointSave(streamingAssistantMessage)) {
1519
+ if (!_isInMemoryChat &&
1520
+ _shouldCheckpointSave(streamingAssistantMessage)) {
1521
_saveService?.enqueue(streamingAssistantMessage);
1522
_updateSavedMetrics(streamingAssistantMessage);
1523
_resetCheckpointMetrics();
@@ -1255,6 +1525,9 @@ class ChatNotifier extends Notifier<ChatState> {
1525
stateNeedsUpdate = true;
1526
break;
1527
case ChatResponseType.reasoning:
1528
+ if (response.reasoningContent?.isNotEmpty ?? false) {
1529
+ _noteFirstToken();
1530
+ }
1531
reasoningContent += response.reasoningContent ?? '';
1532
streamingAssistantMessage = streamingAssistantMessage
1533
.copyWith(
@@ -1263,7 +1536,8 @@ class ChatNotifier extends Notifier<ChatState> {
1536
);
1537
_latestStreamingMessage = streamingAssistantMessage;
1538
_chunkCount++;
1266
- if (_shouldCheckpointSave(streamingAssistantMessage)) {
1539
+ if (!_isInMemoryChat &&
1540
+ _shouldCheckpointSave(streamingAssistantMessage)) {
1541
_saveService?.enqueue(streamingAssistantMessage);
1542
_updateSavedMetrics(streamingAssistantMessage);
1543
_resetCheckpointMetrics();
@@ -1280,10 +1554,13 @@ class ChatNotifier extends Notifier<ChatState> {
1554
case ChatResponseType.error:
1555
_uiUpdateTimer?.cancel();
1556
_uiUpdateTimer = null;
1283
- streamingAssistantMessage = streamingAssistantMessage.copyWith(
1284
- status: MessageStatus.error,
1285
- errorMessage: response.content,
1286
- isProcessing: false,
1557
+ streamingAssistantMessage = _finalizeStreamMessage(
1558
+ streamingAssistantMessage.copyWith(
1559
+ status: MessageStatus.error,
1560
+ errorMessage: response.content,
1561
+ isProcessing: false,
1562
+ ),
1563
+ stopReason: 'error',
1564
);
1565
await _saveMessage(streamingAssistantMessage);
1566
_replaceMessageInAll(
@@ -1294,6 +1571,11 @@ class ChatNotifier extends Notifier<ChatState> {
1571
ref.read(isStreamingProvider.notifier).setStreaming(false);
1572
ref.read(chatBackgroundServiceProvider).stop();
1573
break;
1574
+ case ChatResponseType.done:
1575
+ if (response.stats != null) {
1576
+ _streamStats = response.stats;
1577
+ }
1578
+ break;
1579
default:
1580
break;
1581
}
@@ -1301,9 +1583,12 @@ class ChatNotifier extends Notifier<ChatState> {
1583
onDone: () async {
1584
_uiUpdateTimer?.cancel();
1585
_uiUpdateTimer = null;
1304
- final finalMessage = streamingAssistantMessage.copyWith(
1305
- status: MessageStatus.complete,
1306
- isProcessing: false,
1586
+ final finalMessage = _finalizeStreamMessage(
1587
+ streamingAssistantMessage.copyWith(
1588
+ status: MessageStatus.complete,
1589
+ isProcessing: false,
1590
+ ),
1591
+ stopReason: 'complete',
1592
);
1593
await _saveMessage(finalMessage);
1594
_replaceMessageInAll(finalMessage, clearStreaming: true);
@@ -1429,6 +1714,10 @@ class ChatNotifier extends Notifier<ChatState> {
1714
preview,
1715
DateTime.now(),
1716
messageCount: messagesToCopy.length,
1717
+ characterCount: messagesToCopy.fold<int>(
1718
+ 0,
1719
+ (sum, message) => sum + message.content.length,
1720
+ ),
1721
);
1722
1723
final refreshedConversations = ref.read(conv.conversationsProvider).value;
@@ -1528,6 +1817,7 @@ class ChatNotifier extends Notifier<ChatState> {
1817
}
1818
1819
Future<void> _saveMessage(Message message) async {
1820
+ if (_isInMemoryChat) return;
1821
final db = ref.read(databaseProvider);
1822
await db.store.runInTransactionAsync(
1823
TxMode.write,
@@ -1567,7 +1857,7 @@ class ChatNotifier extends Notifier<ChatState> {
1857
1858
Future<void> clearConversation() async {
1859
final db = ref.read(databaseProvider);
1570
- if (_currentConversationId != null) {
1860
+ if (_currentConversationId != null && !_isInMemoryChat) {
1861
final query = db.messageBox
1862
.query(MessageEntity_.conversationUid.equals(_currentConversationId!))
1863
.build();
@@ -1575,10 +1865,23 @@ class ChatNotifier extends Notifier<ChatState> {
1865
query.close();
1866
}
1867
_currentConversationId = null;
1868
+ _ephemeralConversationId = null;
1869
+ _pendingTemporaryChat = false;
1870
state = const ChatState();
1871
+ ref
1872
+ .read(conv.activeConversationProvider.notifier)
1873
+ .setActiveConversation(null);
1874
}
1875
1876
void clearError() {
1877
state = state.copyWith(clearError: true);
1878
}
1879
}
1880
+
1881
+final hasActiveChatSessionProvider = Provider<bool>((ref) {
1882
+ final chat = ref.watch(chatProvider);
1883
+ final activeConv = ref.watch(conv.activeConversationProvider);
1884
+ return chat.messages.isNotEmpty ||
1885
+ chat.isStreaming ||
1886
+ activeConv != null;
1887
+});
lib/features/chat/providers/chat_service_providers.dart
+5
@@ -11,6 +11,7 @@ import 'package:localmind/features/on_device/data/on_device_llama_service.dart';
11
import 'package:localmind/features/on_device/data/models/on_device_model.dart';
12
import '../data/chat_service.dart';
13
import '../data/smart_reply_service.dart';
14
+import '../data/title_generation_service.dart';
15
16
final chatServiceProvider = Provider<ChatService?>((ref) {
17
final server = ref.watch(activeServerProvider);
@@ -65,3 +66,7 @@ final smartReplyServiceProvider = Provider<SmartReplyService>((ref) {
66
ref.onDispose(() => service.dispose());
67
return service;
68
});
69
+
70
+final titleGenerationServiceProvider = Provider<TitleGenerationService>((ref) {
71
+ return TitleGenerationService();
72
+});
lib/features/chat/views/chat_screen.dart
+243
-116
@@ -12,6 +12,7 @@ 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/services/share_service.dart';
16
import 'package:localmind/core/routes/app_routes.dart';
17
import 'package:localmind/core/utils/system_insets.dart';
18
import 'package:localmind/l10n/app_localizations.dart';
@@ -21,18 +22,18 @@ import 'package:localmind/features/conversations/data/models/conversation.dart';
22
import 'package:localmind/features/models/screens/model_picker_sheet.dart';
23
import 'package:localmind/features/personas/providers/personas_providers.dart';
24
import 'package:localmind/features/servers/providers/server_providers.dart';
24
-import 'package:localmind/features/servers/data/models/server.dart';
25
-import 'package:localmind/features/servers/views/components/server_icon_picker.dart';
25
import 'package:localmind/features/tts/views/components/tts_player_bar.dart';
27
-import 'package:localmind/features/saved_messages/providers/saved_message_providers.dart';
26
+import 'package:localmind/features/saved_messages/views/components/save_message_sheet.dart';
27
+import '../data/export_service.dart';
28
import '../data/models/message.dart';
29
import '../providers/chat_mcp_providers.dart';
30
+import '../../conversations/views/components/rename_conversation_dialog.dart';
31
import '../providers/chat_providers.dart';
32
import 'components/chat_auto_scroll_controller.dart';
33
+import 'components/model_info_sheet.dart';
34
import 'components/chat_input_bar.dart';
35
import 'components/chat_settings_sheet.dart';
36
import 'components/edit_message_dialog.dart';
35
-import 'components/edit_message_result.dart';
37
import 'components/persona_picker_sheet.dart';
38
import 'components/notification_permission_banner.dart';
39
import 'components/message_list/message_list.dart';
@@ -54,6 +55,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
55
with WidgetsBindingObserver {
56
final ScrollController _scrollController = ScrollController();
57
final FocusNode _inputFocusNode = FocusNode();
58
+ final GlobalKey<ChatInputBarState> _inputBarKey = GlobalKey();
59
bool _isApprovalDialogOpen = false;
60
bool _showScrollToBottom = false;
61
late final ChatAutoScrollController _autoScroll;
@@ -120,11 +122,14 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
122
return _ChatBody(
123
scrollController: _scrollController,
124
inputFocusNode: _inputFocusNode,
125
+ inputBarKey: _inputBarKey,
126
autoScroll: _autoScroll,
127
showScrollToBottom: _showScrollToBottom,
128
onScrollToBottom: _scrollToBottom,
129
onModelPicker: () => _showModelPicker(context),
130
onMenuAction: (action) => _handleMenuAction(action, context),
131
+ onShareMessage: _shareMessage,
132
+ onSaveMessage: (message) => _saveMessage(context, message),
133
);
134
}
135
@@ -141,6 +146,8 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
146
switch (action) {
147
case 'new_chat':
148
ref.read(chatProvider.notifier).startNewConversation();
149
+ case 'share_chat':
150
+ _shareConversation(context);
151
case 'persona':
152
showPersonaPickerSheet(context);
153
case 'remove_persona':
@@ -184,6 +191,31 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
191
}
192
}
193
194
+ Future<void> _shareConversation(BuildContext context) async {
195
+ final messages = ref.read(chatProvider).messages;
196
+ if (messages.isEmpty) return;
197
+ final activeConv = ref.read(conv.activeConversationProvider);
198
+ final text = ExportService.exportAsText(
199
+ messages,
200
+ title: activeConv?.title,
201
+ );
202
+ await ShareService.shareText(text, subject: activeConv?.title);
203
+ }
204
+
205
+ void _shareMessage(String content) {
206
+ ShareService.shareText(content);
207
+ }
208
+
209
+ Future<void> _saveMessage(BuildContext context, Message message) async {
210
+ final isTemporary = ref.read(chatProvider.select((s) => s.isTemporary));
211
+ await showSaveMessageSheet(
212
+ context,
213
+ ref,
214
+ message,
215
+ isTemporaryChat: isTemporary,
216
+ );
217
+ }
218
+
219
void _showToolApprovalDialog(
220
BuildContext context,
221
PendingToolApproval approval,
@@ -261,64 +293,74 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
293
}
294
295
void _showRenameDialog(BuildContext context, Conversation conversation) {
264
- final l10n = AppLocalizations.of(context)!;
265
- final controller = TextEditingController(text: conversation.title);
296
+ showRenameConversationDialog(context, ref, conversation: conversation);
297
+ }
298
+}
299
+
300
+void _handleChatModeAction(
301
+ BuildContext context,
302
+ WidgetRef ref, {
303
+ required bool hasMessages,
304
+ required bool isTemporary,
305
+}) {
306
+ final l10n = AppLocalizations.of(context)!;
307
+
308
+ if (!hasMessages) {
309
+ ref.read(chatProvider.notifier).setTemporaryMode(!isTemporary);
310
+ return;
311
+ }
312
267
- showDialog(
313
+ if (isTemporary) {
314
+ showDialog<void>(
315
context: context,
269
- builder: (context) {
270
- return AlertDialog(
271
- title: Text(l10n.rename_conversation),
272
- content: TextField(
273
- controller: controller,
274
- autofocus: true,
275
- decoration: InputDecoration(
276
- hintText: l10n.enter_new_title,
277
- border: const OutlineInputBorder(),
278
- ),
316
+ builder: (ctx) => AlertDialog(
317
+ title: Text(l10n.exit_temporary_chat_title),
318
+ content: Text(l10n.exit_temporary_chat_body),
319
+ actions: [
320
+ TextButton(
321
+ onPressed: () => Navigator.pop(ctx),
322
+ child: Text(l10n.cancel),
323
),
280
- actions: [
281
- TextButton(
282
- onPressed: () => Navigator.pop(context),
283
- child: Text(l10n.cancel),
284
- ),
285
- TextButton(
286
- onPressed: () {
287
- final newTitle = controller.text.trim();
288
- if (newTitle.isNotEmpty) {
289
- ref
290
- .read(conv.conversationsProvider.notifier)
291
- .renameConversation(conversation.id, newTitle);
292
- }
293
- Navigator.pop(context);
294
- },
295
- child: Text(l10n.rename),
296
- ),
297
- ],
298
- );
299
- },
300
- ).then((_) => controller.dispose());
324
+ TextButton(
325
+ onPressed: () {
326
+ Navigator.pop(ctx);
327
+ ref.read(chatProvider.notifier).startNewConversation();
328
+ },
329
+ child: Text(l10n.nav_new_chat),
330
+ ),
331
+ ],
332
+ ),
333
+ );
334
+ return;
335
}
336
+
337
+ ref.read(chatProvider.notifier).startNewConversation();
338
}
339
340
class _ChatBody extends ConsumerWidget {
341
const _ChatBody({
342
required this.scrollController,
343
required this.inputFocusNode,
344
+ required this.inputBarKey,
345
required this.autoScroll,
346
required this.showScrollToBottom,
347
required this.onScrollToBottom,
348
required this.onModelPicker,
349
required this.onMenuAction,
350
+ required this.onShareMessage,
351
+ required this.onSaveMessage,
352
});
353
354
final ScrollController scrollController;
355
final FocusNode inputFocusNode;
356
+ final GlobalKey<ChatInputBarState> inputBarKey;
357
final ChatAutoScrollController autoScroll;
358
final bool showScrollToBottom;
359
final VoidCallback onScrollToBottom;
360
final VoidCallback onModelPicker;
361
final void Function(String) onMenuAction;
362
+ final void Function(String content) onShareMessage;
363
+ final Future<void> Function(Message message) onSaveMessage;
364
365
@override
366
Widget build(BuildContext context, WidgetRef ref) {
@@ -333,8 +375,8 @@ class _ChatBody extends ConsumerWidget {
375
final errorMessage = ref.watch(chatProvider.select((s) => s.errorMessage));
376
final selectedModel = ref.watch(selectedModelProvider);
377
final connectionStatus = ref.watch(connectionStatusProvider);
336
- final activeServer = ref.watch(activeServerProvider);
378
final activeConversation = ref.watch(conv.activeConversationProvider);
379
+ final isTemporary = ref.watch(chatProvider.select((s) => s.isTemporary));
380
final personaId = activeConversation?.personaId;
381
final persona = personaId != null
382
? ref.watch(personaByIdProvider(personaId))
@@ -359,15 +401,24 @@ class _ChatBody extends ConsumerWidget {
401
402
return Column(
403
children: [
362
- ModelTopBar(selectedModel: selectedModel, onTap: onModelPicker),
404
+ ModelTopBar(
405
+ selectedModel: selectedModel,
406
+ onModelTap: onModelPicker,
407
+ ),
408
_ScreenAppBar(
364
- activeServer: activeServer,
409
activeConversation: activeConversation,
366
- connectionStatus: connectionStatus,
410
isDark: isDark,
411
persona: persona,
412
+ isTemporary: isTemporary,
413
+ hasMessages: messages.isNotEmpty,
414
onMenuAction: onMenuAction,
415
onPersonaPicker: () => showPersonaPickerSheet(context),
416
+ onChatModeAction: () => _handleChatModeAction(
417
+ context,
418
+ ref,
419
+ hasMessages: messages.isNotEmpty,
420
+ isTemporary: isTemporary,
421
+ ),
422
),
423
const NotificationPermissionBanner(),
424
if (connectionStatus == ConnectionStatus.disconnected ||
@@ -388,36 +439,56 @@ class _ChatBody extends ConsumerWidget {
439
),
440
const TtsPlayerBar(),
441
Expanded(
391
- child: Stack(
392
- children: [
393
- _MessageArea(
394
- isLoading: isLoading,
395
- messages: messages,
396
- activeConversation: activeConversation,
397
- errorMessage: errorMessage,
398
- selectedModel: selectedModel,
399
- isStreaming: isStreaming,
400
- scrollController: scrollController,
401
- effectiveBottomInset: effectiveBottomInset,
402
- keyboardBottomInset: keyboardBottomInset,
403
- onModelPicker: onModelPicker,
404
- ),
405
- _ChatBottomBar(
406
- isStreaming: isStreaming,
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
- ),
442
+ child: DecoratedBox(
443
+ decoration: isTemporary
444
+ ? BoxDecoration(
445
+ color: isDark
446
+ ? const Color(0xFF15120C)
447
+ : const Color(0xFFFFFBF0),
448
+ border: Border.all(
449
+ color: isDark
450
+ ? const Color(0xFF6B5A2E)
451
+ : const Color(0xFFE6C35C),
452
+ width: 2,
453
+ ),
454
+ )
455
+ : const BoxDecoration(),
456
+ child: Stack(
457
+ clipBehavior: Clip.hardEdge,
458
+ children: [
459
+ _MessageArea(
460
+ isLoading: isLoading,
461
+ messages: messages,
462
+ activeConversation: activeConversation,
463
+ errorMessage: errorMessage,
464
+ selectedModel: selectedModel,
465
+ isStreaming: isStreaming,
466
+ scrollController: scrollController,
467
+ effectiveBottomInset: effectiveBottomInset,
468
+ keyboardBottomInset: keyboardBottomInset,
469
+ onModelPicker: onModelPicker,
470
+ onShareMessage: onShareMessage,
471
+ onSaveMessage: onSaveMessage,
472
+ isTemporary: isTemporary,
473
),
420
- ],
474
+ _ChatBottomBar(
475
+ isStreaming: isStreaming,
476
+ keyboardBottomInset: keyboardBottomInset,
477
+ inputFocusNode: inputFocusNode,
478
+ inputBarKey: inputBarKey,
479
+ ),
480
+ if (showScrollToBottom && messages.isNotEmpty)
481
+ Positioned(
482
+ right: 16,
483
+ bottom: 120 + keyboardBottomInset,
484
+ child: FloatingActionButton.small(
485
+ onPressed: onScrollToBottom,
486
+ tooltip: AppLocalizations.of(context)!.scroll_to_bottom,
487
+ child: const Icon(Icons.arrow_downward),
488
+ ),
489
+ ),
490
+ ],
491
+ ),
492
),
493
),
494
],
@@ -437,6 +508,9 @@ class _MessageArea extends ConsumerWidget {
508
required this.effectiveBottomInset,
509
required this.keyboardBottomInset,
510
required this.onModelPicker,
511
+ required this.onShareMessage,
512
+ required this.onSaveMessage,
513
+ required this.isTemporary,
514
});
515
516
final bool isLoading;
@@ -449,6 +523,9 @@ class _MessageArea extends ConsumerWidget {
523
final double effectiveBottomInset;
524
final double keyboardBottomInset;
525
final VoidCallback onModelPicker;
526
+ final void Function(String content) onShareMessage;
527
+ final Future<void> Function(Message message) onSaveMessage;
528
+ final bool isTemporary;
529
530
@override
531
Widget build(BuildContext context, WidgetRef ref) {
@@ -535,11 +612,13 @@ class _MessageArea extends ConsumerWidget {
612
onCycleVariant: (messageId, direction) => ref
613
.read(chatProvider.notifier)
614
.cycleMessageVariant(messageId, direction),
538
- onSave: (message) =>
539
- ref.read(savedMessagesProvider.notifier).saveMessage(message),
615
+ onSave: (message) => onSaveMessage(message),
616
+ onShare: (message) => onShareMessage(message.content),
617
onGenerateResponse: () =>
618
ref.read(chatProvider.notifier).generateResponseForLastUser(),
619
onModelPicker: onModelPicker,
620
+ onModelLongPress: (modelId) =>
621
+ showModelInfoSheet(context, ref, modelId),
622
hasSmartReplies:
623
!isStreaming &&
624
keyboardBottomInset == 0 &&
@@ -551,22 +630,24 @@ class _MessageArea extends ConsumerWidget {
630
631
class _ScreenAppBar extends ConsumerWidget {
632
const _ScreenAppBar({
554
- required this.activeServer,
633
required this.activeConversation,
556
- required this.connectionStatus,
634
required this.isDark,
635
required this.persona,
636
+ required this.isTemporary,
637
+ required this.hasMessages,
638
required this.onMenuAction,
639
required this.onPersonaPicker,
640
+ required this.onChatModeAction,
641
});
642
563
- final dynamic activeServer;
643
final Conversation? activeConversation;
565
- final ConnectionStatus connectionStatus;
644
final bool isDark;
645
final dynamic persona;
646
+ final bool isTemporary;
647
+ final bool hasMessages;
648
final void Function(String) onMenuAction;
649
final VoidCallback onPersonaPicker;
650
+ final VoidCallback onChatModeAction;
651
652
@override
653
Widget build(BuildContext context, WidgetRef ref) {
@@ -593,34 +674,15 @@ class _ScreenAppBar extends ConsumerWidget {
674
),
675
const SizedBox(width: 8),
676
Expanded(
596
- child: Row(
597
- children: [
598
- if (activeServer != null) ...[
599
- _buildServerIcon(context, activeServer as Server?),
600
- const SizedBox(width: 8),
601
- ],
602
- Text(
603
- activeConversation?.title.isNotEmpty == true
604
- ? activeConversation!.title
605
- : (activeServer?.name ?? l10n.chat_title),
606
- style: const TextStyle(
607
- fontSize: 18,
608
- fontWeight: FontWeight.bold,
609
- ),
610
- overflow: TextOverflow.ellipsis,
611
- ),
612
- const SizedBox(width: 8),
613
- Container(
614
- width: 8,
615
- height: 8,
616
- decoration: BoxDecoration(
617
- shape: BoxShape.circle,
618
- color: connectionStatus == ConnectionStatus.connected
619
- ? Colors.green
620
- : Colors.grey,
621
- ),
622
- ),
623
- ],
677
+ child: Text(
678
+ activeConversation?.title.isNotEmpty == true
679
+ ? activeConversation!.title
680
+ : l10n.nav_new_chat,
681
+ style: const TextStyle(
682
+ fontSize: 18,
683
+ fontWeight: FontWeight.bold,
684
+ ),
685
+ overflow: TextOverflow.ellipsis,
686
),
687
),
688
Stack(
@@ -653,6 +715,12 @@ class _ScreenAppBar extends ConsumerWidget {
715
),
716
],
717
),
718
+ _ChatModeIconButton(
719
+ hasMessages: hasMessages,
720
+ isTemporary: isTemporary,
721
+ isDark: isDark,
722
+ onPressed: onChatModeAction,
723
+ ),
724
PopupMenuButton<String>(
725
icon: const Icon(Icons.more_vert),
726
onSelected: onMenuAction,
@@ -683,6 +751,15 @@ class _ScreenAppBar extends ConsumerWidget {
751
),
752
),
753
],
754
+ if (hasMessages)
755
+ PopupMenuItem(
756
+ value: 'share_chat',
757
+ child: ListTile(
758
+ leading: const Icon(Icons.share_outlined),
759
+ title: Text(l10n.share_conversation),
760
+ contentPadding: EdgeInsets.zero,
761
+ ),
762
+ ),
763
PopupMenuItem(
764
value: 'persona',
765
child: ListTile(
@@ -722,20 +799,67 @@ class _ScreenAppBar extends ConsumerWidget {
799
}
800
}
801
725
-Widget _buildServerIcon(BuildContext context, Server? server) {
726
- if (server == null) return const SizedBox.shrink();
802
+class _ChatModeIconButton extends StatelessWidget {
803
+ const _ChatModeIconButton({
804
+ required this.hasMessages,
805
+ required this.isTemporary,
806
+ required this.isDark,
807
+ required this.onPressed,
808
+ });
809
728
- final iconData = server.iconName != null
729
- ? getHugeIconByName(server.iconName)
730
- : getDefaultServerIcon(server.type.name);
810
+ final bool hasMessages;
811
+ final bool isTemporary;
812
+ final bool isDark;
813
+ final VoidCallback onPressed;
814
732
- if (iconData == null) return const Icon(Icons.dns, size: 18);
815
+ @override
816
+ Widget build(BuildContext context) {
817
+ final l10n = AppLocalizations.of(context)!;
818
+ final showGhost = !hasMessages || isTemporary;
819
+ final ghostActive = isTemporary;
820
+
821
+ if (showGhost) {
822
+ final activeColor =
823
+ isDark ? const Color(0xFFE6C35C) : const Color(0xFF9A7B1A);
824
+ final inactiveColor =
825
+ isDark ? Colors.white54 : Colors.black45;
826
+
827
+ return IconButton(
828
+ onPressed: onPressed,
829
+ tooltip: ghostActive
830
+ ? l10n.exit_temporary_chat_title
831
+ : l10n.temporary_chat,
832
+ icon: ghostActive
833
+ ? Container(
834
+ padding: const EdgeInsets.all(4),
835
+ decoration: BoxDecoration(
836
+ color: activeColor.withValues(alpha: 0.25),
837
+ shape: BoxShape.circle,
838
+ ),
839
+ child: Icon(
840
+ LucideIcons.ghost,
841
+ size: 20,
842
+ color: activeColor,
843
+ ),
844
+ )
845
+ : Icon(
846
+ LucideIcons.ghost,
847
+ size: 22,
848
+ color: inactiveColor,
849
+ ),
850
+ );
851
+ }
852
734
- return HugeIcon(
735
- icon: iconData.icon,
736
- size: 18,
737
- color: Theme.of(context).colorScheme.primary,
738
- );
853
+ return IconButton(
854
+ onPressed: onPressed,
855
+ tooltip: l10n.nav_new_chat,
856
+ icon: Icon(
857
+ Icons.add_comment_outlined,
858
+ size: 22,
859
+ color: isDark ? Colors.white70 : Colors.black87,
860
+ ),
861
+ );
862
+ }
863
}
864
865
class _ChatBottomBar extends ConsumerWidget {
@@ -743,11 +867,13 @@ class _ChatBottomBar extends ConsumerWidget {
867
required this.isStreaming,
868
required this.keyboardBottomInset,
869
required this.inputFocusNode,
870
+ required this.inputBarKey,
871
});
872
873
final bool isStreaming;
874
final double keyboardBottomInset;
875
final FocusNode inputFocusNode;
876
+ final GlobalKey<ChatInputBarState> inputBarKey;
877
878
@override
879
Widget build(BuildContext context, WidgetRef ref) {
@@ -776,6 +902,7 @@ class _ChatBottomBar extends ConsumerWidget {
902
_SmartReplyChipsWrapper(),
903
],
904
ChatInputBar(
905
+ key: inputBarKey,
906
focusNode: inputFocusNode,
907
isStreaming: isStreaming,
908
onSend: (message, {attachments}) {
lib/features/chat/views/components/chat_bubble.dart
+7
@@ -19,8 +19,10 @@ class ChatBubble extends StatelessWidget {
19
this.onBranch,
20
this.onContinue,
21
this.onModelTap,
22
+ this.onModelLongPress,
23
this.onCycleVariant,
24
this.onSave,
25
+ this.onShare,
26
this.isStreaming = false,
27
});
28
@@ -33,8 +35,10 @@ class ChatBubble extends StatelessWidget {
35
final VoidCallback? onBranch;
36
final VoidCallback? onContinue;
37
final VoidCallback? onModelTap;
38
+ final VoidCallback? onModelLongPress;
39
final void Function(int direction)? onCycleVariant;
40
final void Function(Message message)? onSave;
41
+ final VoidCallback? onShare;
42
final bool isStreaming;
43
44
@override
@@ -56,6 +60,7 @@ class ChatBubble extends StatelessWidget {
60
onBranch: onBranch,
61
onCycleVariant: onCycleVariant,
62
onSave: onSave,
63
+ onShare: onShare,
64
),
65
);
66
case MessageRole.assistant:
@@ -71,8 +76,10 @@ class ChatBubble extends StatelessWidget {
76
onBranch: onBranch,
77
onContinue: onContinue,
78
onModelTap: onModelTap,
79
+ onModelLongPress: onModelLongPress,
80
onCycleVariant: onCycleVariant,
81
onSave: onSave,
82
+ onShare: onShare,
83
isStreaming: isStreaming,
84
),
85
);
lib/features/chat/views/components/chat_bubble/assistant_bubble.dart
+13
-4
@@ -23,8 +23,10 @@ class AssistantBubble extends StatelessWidget {
23
this.onBranch,
24
this.onContinue,
25
this.onModelTap,
26
+ this.onModelLongPress,
27
this.onCycleVariant,
28
this.onSave,
29
+ this.onShare,
30
this.allMessages = const [],
31
this.isStreaming = false,
32
});
@@ -38,7 +40,9 @@ class AssistantBubble extends StatelessWidget {
40
final VoidCallback? onBranch;
41
final VoidCallback? onContinue;
42
final void Function(Message message)? onSave;
43
+ final VoidCallback? onShare;
44
final VoidCallback? onModelTap;
45
+ final VoidCallback? onModelLongPress;
46
final void Function(int direction)? onCycleVariant;
47
final bool isStreaming;
48
@@ -60,9 +64,8 @@ class AssistantBubble extends StatelessWidget {
64
!isStreaming)
65
Padding(
66
padding: const EdgeInsets.only(bottom: 6),
63
- child: InkWell(
64
- onTap: onModelTap,
65
- borderRadius: BorderRadius.circular(4),
67
+ child: GestureDetector(
68
+ onLongPress: onModelLongPress,
69
child: Text(
70
message.modelId!,
71
style: TextStyle(
@@ -156,9 +159,14 @@ class AssistantBubble extends StatelessWidget {
159
content: message.content.isEmpty
160
? AppLocalizations.of(context)!.no_response
161
: message.content,
159
- tokenCount: message.tokenCount,
162
messageId: message.id,
163
conversationId: message.conversationId,
164
+ tokenCount: message.tokenCount,
165
+ inputTokenCount: message.inputTokenCount,
166
+ generationTimeMs: message.generationTimeMs,
167
+ ttftMs: message.ttftMs,
168
+ tokensPerSecond: message.tokensPerSecond,
169
+ stopReason: message.stopReason,
170
onCopy: onCopy,
171
onRetry: onRetry,
172
onDelete: onDelete,
@@ -166,6 +174,7 @@ class AssistantBubble extends StatelessWidget {
174
onBranch: onBranch,
175
onContinue: onContinue,
176
onSave: () => onSave?.call(message),
177
+ onShare: onShare,
178
),
179
],
180
),
lib/features/chat/views/components/chat_bubble/user_bubble.dart
+3
@@ -17,6 +17,7 @@ class UserBubble extends StatelessWidget {
17
this.onBranch,
18
this.onCycleVariant,
19
this.onSave,
20
+ this.onShare,
21
this.allMessages = const [],
22
});
23
@@ -28,6 +29,7 @@ class UserBubble extends StatelessWidget {
29
final VoidCallback? onBranch;
30
final void Function(int direction)? onCycleVariant;
31
final void Function(Message message)? onSave;
32
+ final VoidCallback? onShare;
33
34
@override
35
Widget build(BuildContext context) {
@@ -123,6 +125,7 @@ class UserBubble extends StatelessWidget {
125
onEdit: onEdit,
126
onBranch: onBranch,
127
onSave: onSave == null ? null : () => onSave!(message),
128
+ onShare: onShare,
129
),
130
],
131
),
lib/features/chat/views/components/chat_input_bar.dart
+805
-157
@@ -1,461 +1,1109 @@
1
import 'dart:io';
2
3
+
4
+
5
import 'package:file_picker/file_picker.dart';
6
+
7
import 'package:flutter/material.dart';
8
+
9
import 'package:flutter_riverpod/flutter_riverpod.dart';
10
+
11
import 'package:haptic_feedback/haptic_feedback.dart';
12
+
13
import 'package:hugeicons/hugeicons.dart';
14
+
15
+import 'package:image_picker/image_picker.dart';
16
+
17
import 'package:localmind/l10n/app_localizations.dart';
18
+
19
import 'package:localmind/core/theme/colors.dart';
20
21
+import 'package:permission_handler/permission_handler.dart';
22
+
23
+
24
+
25
import '../../../../core/models/enums.dart';
26
+
27
import '../../../servers/providers/server_providers.dart';
28
+
29
import '../../../stt/providers/stt_providers.dart';
30
31
+import '../../../saved_messages/views/components/saved_message_picker_sheet.dart';
32
+
33
+
34
+
35
class ChatInputBar extends ConsumerStatefulWidget {
36
+
37
const ChatInputBar({
38
+
39
super.key,
40
+
41
required this.onSend,
42
+
43
required this.onStop,
44
+
45
this.onAttach,
46
+
47
this.enabled = true,
48
+
49
this.isStreaming = false,
50
+
51
this.focusNode,
52
+
53
});
54
55
+
56
+
57
final void Function(String message, {List<File>? attachments}) onSend;
58
+
59
final VoidCallback onStop;
60
+
61
final void Function(List<File> attachments)? onAttach;
62
+
63
final bool enabled;
64
+
65
final bool isStreaming;
66
+
67
final FocusNode? focusNode;
68
69
+
70
+
71
@override
34
- ConsumerState<ChatInputBar> createState() => _ChatInputBarState();
72
+
73
+ ConsumerState<ChatInputBar> createState() => ChatInputBarState();
74
+
75
}
76
37
-class _ChatInputBarState extends ConsumerState<ChatInputBar>
77
+
78
+
79
+class ChatInputBarState extends ConsumerState<ChatInputBar>
80
+
81
with TickerProviderStateMixin {
82
+
83
final _controller = TextEditingController();
84
+
85
late final FocusNode _focusNode;
86
+
87
final List<File> _attachedFiles = [];
88
+
89
late AnimationController _sendButtonAnimController;
90
+
91
late Animation<double> _sendButtonScale;
92
+
93
late AnimationController _micAnimController;
94
+
95
String _preSpeechText = '';
96
97
+
98
+
99
@override
100
+
101
void initState() {
102
+
103
super.initState();
104
+
105
_focusNode = widget.focusNode ?? FocusNode();
106
+
107
_sendButtonAnimController = AnimationController(
108
+
109
duration: const Duration(milliseconds: 200),
110
+
111
vsync: this,
112
+
113
);
114
+
115
_sendButtonScale = Tween<double>(begin: 1.0, end: 1.1).animate(
116
+
117
CurvedAnimation(parent: _sendButtonAnimController, curve: Curves.easeOut),
118
+
119
);
120
+
121
_micAnimController = AnimationController(
122
+
123
duration: const Duration(milliseconds: 800),
124
+
125
vsync: this,
126
+
127
);
128
+
129
}
130
131
+
132
+
133
@override
134
+
135
void dispose() {
136
+
137
_controller.dispose();
138
+
139
if (widget.focusNode == null) {
140
+
141
_focusNode.dispose();
142
+
143
}
144
+
145
_sendButtonAnimController.dispose();
146
+
147
_micAnimController.dispose();
148
+
149
super.dispose();
150
+
151
}
152
153
+
154
+
155
+ void insertText(String text) {
156
+
157
+ final insert = text.trim();
158
+
159
+ if (insert.isEmpty) return;
160
+
161
+ final current = _controller.text;
162
+
163
+ if (current.isEmpty) {
164
+
165
+ _controller.text = insert;
166
+
167
+ } else {
168
+
169
+ _controller.text = '$current\n\n$insert';
170
+
171
+ }
172
+
173
+ setState(() {});
174
+
175
+ _focusNode.requestFocus();
176
+
177
+ _controller.selection = TextSelection.fromPosition(
178
+
179
+ TextPosition(offset: _controller.text.length),
180
+
181
+ );
182
+
183
+ }
184
+
185
+
186
+
187
+ Future<bool> _ensurePhotoPermission() async {
188
+
189
+ if (!Platform.isAndroid && !Platform.isIOS) return true;
190
+
191
+
192
+
193
+ var status = await Permission.photos.status;
194
+
195
+ if (status.isGranted || status.isLimited) return true;
196
+
197
+
198
+
199
+ status = await Permission.photos.request();
200
+
201
+ if (status.isGranted || status.isLimited) return true;
202
+
203
+
204
+
205
+ if (Platform.isAndroid) {
206
+
207
+ final storage = await Permission.storage.request();
208
+
209
+ return storage.isGranted;
210
+
211
+ }
212
+
213
+ return false;
214
+
215
+ }
216
+
217
+
218
+
219
+ Future<void> _pickImages() async {
220
+
221
+ final granted = await _ensurePhotoPermission();
222
+
223
+ if (!granted) {
224
+
225
+ if (!mounted) return;
226
+
227
+ ScaffoldMessenger.of(context).showSnackBar(
228
+
229
+ SnackBar(
230
+
231
+ content: Text(AppLocalizations.of(context)!.photo_permission_denied),
232
+
233
+ ),
234
+
235
+ );
236
+
237
+ return;
238
+
239
+ }
240
+
241
+
242
+
243
+ final picker = ImagePicker();
244
+
245
+ final images = await picker.pickMultiImage(imageQuality: 85);
246
+
247
+ if (images.isEmpty) return;
248
+
249
+
250
+
251
+ setState(() {
252
+
253
+ _attachedFiles.addAll(images.map((x) => File(x.path)));
254
+
255
+ });
256
+
257
+ widget.onAttach?.call(_attachedFiles);
258
+
259
+ }
260
+
261
+
262
+
263
+ Future<void> _pickTextDocument() async {
264
+
265
+ final result = await FilePicker.pickFiles(
266
+
267
+ type: FileType.custom,
268
+
269
+ allowedExtensions: const ['txt', 'md'],
270
+
271
+ allowMultiple: true,
272
+
273
+ );
274
+
275
+ if (result == null || result.files.isEmpty) return;
276
+
277
+
278
+
279
+ setState(() {
280
+
281
+ _attachedFiles.addAll(
282
+
283
+ result.files.where((f) => f.path != null).map((f) => File(f.path!)),
284
+
285
+ );
286
+
287
+ });
288
+
289
+ widget.onAttach?.call(_attachedFiles);
290
+
291
+ }
292
+
293
+
294
+
295
+ Future<void> _handleInsertSavedMessage() async {
296
+
297
+ final content = await showSavedMessagePickerSheet(context);
298
+
299
+ if (content != null && content.isNotEmpty) {
300
+
301
+ insertText(content);
302
+
303
+ }
304
+
305
+ }
306
+
307
+
308
+
309
+ void _showAttachMenu() {
310
+
311
+ final l10n = AppLocalizations.of(context)!;
312
+
313
+ showModalBottomSheet(
314
+
315
+ context: context,
316
+
317
+ builder: (ctx) => SafeArea(
318
+
319
+ child: Column(
320
+
321
+ mainAxisSize: MainAxisSize.min,
322
+
323
+ children: [
324
+
325
+ ListTile(
326
+
327
+ leading: const Icon(Icons.image_outlined),
328
+
329
+ title: Text(l10n.attach_image),
330
+
331
+ onTap: () {
332
+
333
+ Navigator.pop(ctx);
334
+
335
+ _pickImages();
336
+
337
+ },
338
+
339
+ ),
340
+
341
+ ListTile(
342
+
343
+ leading: const Icon(Icons.description_outlined),
344
+
345
+ title: Text(l10n.attach_text_document),
346
+
347
+ onTap: () {
348
+
349
+ Navigator.pop(ctx);
350
+
351
+ _pickTextDocument();
352
+
353
+ },
354
+
355
+ ),
356
+
357
+ ListTile(
358
+
359
+ leading: const Icon(Icons.bookmark_outline),
360
+
361
+ title: Text(l10n.insert_saved_message),
362
+
363
+ onTap: () {
364
+
365
+ Navigator.pop(ctx);
366
+
367
+ _handleInsertSavedMessage();
368
+
369
+ },
370
+
371
+ ),
372
+
373
+ const SizedBox(height: 8),
374
+
375
+ ],
376
+
377
+ ),
378
+
379
+ ),
380
+
381
+ );
382
+
383
+ }
384
+
385
+
386
+
387
Future<void> _handleMicToggle() async {
388
+
389
final stt = ref.read(sttProvider.notifier);
390
+
391
final isListening = ref.read(sttProvider).isListening;
392
393
+
394
+
395
Haptics.vibrate(HapticsType.light);
396
397
+
398
+
399
if (isListening) {
400
+
401
await stt.stopListening();
402
+
403
} else {
404
+
405
_preSpeechText = _controller.text;
406
+
407
await stt.startListening(
408
+
409
onResult: (words) {
410
+
411
if (words.isNotEmpty) {
412
+
413
setState(() {
414
+
415
if (_preSpeechText.isEmpty) {
416
+
417
_controller.text = words;
418
+
419
} else {
420
+
421
_controller.text = '$_preSpeechText $words';
422
+
423
}
94
- // Position the cursor at the end of the text
424
+
425
_controller.selection = TextSelection.fromPosition(
426
+
427
TextPosition(offset: _controller.text.length),
428
+
429
);
430
+
431
});
432
+
433
}
434
+
435
},
436
+
437
);
438
+
439
}
440
+
441
}
442
443
+
444
+
445
Widget _buildMicButton(bool isListening, ThemeData theme) {
446
+
447
final l10n = AppLocalizations.of(context)!;
448
+
449
final isDark = theme.brightness == Brightness.dark;
450
+
451
return AnimatedBuilder(
452
+
453
animation: _micAnimController,
454
+
455
builder: (context, child) {
456
+
457
final pulseValue = isListening ? _micAnimController.value : 0.0;
458
+
459
return Container(
460
+
461
decoration: BoxDecoration(
462
+
463
shape: BoxShape.circle,
464
+
465
boxShadow: [
466
+
467
if (isListening)
468
+
469
BoxShadow(
470
+
471
color: Colors.red.withValues(alpha: 0.4 * pulseValue),
472
+
473
blurRadius: 8 + (8 * pulseValue),
474
+
475
spreadRadius: 1 + (3 * pulseValue),
476
+
477
),
478
+
479
],
480
+
481
),
482
+
483
child: Container(
125
- width: 44,
126
- height: 44,
484
+
485
+ width: 36,
486
+
487
+ height: 36,
488
+
489
decoration: BoxDecoration(
490
+
491
color: isListening
492
+
493
? Colors.red
494
+
495
: theme.colorScheme.onSurface.withValues(alpha: 0.05),
496
+
497
shape: BoxShape.circle,
498
+
499
),
500
+
501
child: IconButton(
134
- icon: HugeIcon(
135
- icon: isListening
136
- ? HugeIcons.strokeRoundedVoice
137
- : HugeIcons.strokeRoundedMic01,
502
+
503
+ padding: EdgeInsets.zero,
504
+
505
+ icon: Icon(
506
+
507
+ Icons.mic_none_rounded,
508
+
509
+ size: 20,
510
+
511
color: isListening
512
+
513
? (isDark ? Colors.white : Colors.black)
514
+
515
: theme.colorScheme.onSurface.withValues(alpha: 0.7),
141
- size: 20,
516
+
517
),
518
+
519
onPressed: _handleMicToggle,
144
- tooltip: isListening ? l10n.stop_listening_tooltip : l10n.start_listening_tooltip,
520
+
521
+ tooltip: isListening
522
+
523
+ ? l10n.stop_listening_tooltip
524
+
525
+ : l10n.start_listening_tooltip,
526
+
527
),
528
+
529
),
530
+
531
);
532
+
533
},
149
- );
150
- }
534
152
- Future<void> _handleAttach() async {
153
- final result = await FilePicker.pickFiles(
154
- type: FileType.custom,
155
- allowedExtensions: const ['jpg', 'jpeg', 'png', 'gif', 'webp', 'txt', 'md'],
156
- allowMultiple: true,
535
);
158
- if (result != null && result.files.isNotEmpty) {
159
- setState(() {
160
- _attachedFiles.addAll(
161
- result.files.where((f) => f.path != null).map((f) => File(f.path!)),
162
- );
163
- });
164
- widget.onAttach?.call(_attachedFiles);
165
- }
536
+
537
}
538
539
+
540
+
541
void _handleSubmit() {
542
+
543
final text = _controller.text.trim();
544
+
545
if (text.isEmpty && _attachedFiles.isEmpty) return;
546
547
+
548
+
549
Haptics.vibrate(HapticsType.medium);
550
+
551
widget.onSend(text, attachments: List.from(_attachedFiles));
552
+
553
_controller.clear();
554
+
555
setState(() {
556
+
557
_attachedFiles.clear();
558
+
559
});
560
+
561
}
562
563
+
564
+
565
void _handleStop() {
566
+
567
Haptics.vibrate(HapticsType.light);
568
+
569
widget.onStop();
570
+
571
}
572
573
+
574
+
575
Widget _buildActionButton(bool canSend, ThemeData theme) {
576
+
577
final l10n = AppLocalizations.of(context)!;
578
+
579
final backgroundColor = widget.isStreaming
580
+
581
? Colors.red
582
+
583
: theme.colorScheme.primary;
584
+
585
final iconColor = widget.isStreaming
586
+
587
? Colors.white
588
+
589
: theme.colorScheme.onPrimary;
590
591
+
592
+
593
return GestureDetector(
594
+
595
onTapDown: canSend ? (_) => _sendButtonAnimController.forward() : null,
596
+
597
onTapUp: canSend ? (_) => _sendButtonAnimController.reverse() : null,
598
+
599
onTapCancel: canSend ? () => _sendButtonAnimController.reverse() : null,
600
+
601
child: ScaleTransition(
602
+
603
scale: _sendButtonScale,
604
+
605
child: AnimatedContainer(
606
+
607
duration: const Duration(milliseconds: 200),
608
+
609
curve: Curves.easeOut,
203
- width: 44,
204
- height: 44,
610
+
611
+ width: 36,
612
+
613
+ height: 36,
614
+
615
decoration: BoxDecoration(
616
+
617
color: backgroundColor.withValues(
618
+
619
alpha: (canSend || widget.isStreaming) ? 1.0 : 0.2,
620
+
621
),
622
+
623
shape: BoxShape.circle,
624
+
625
),
626
+
627
child: IconButton(
628
+
629
+ padding: EdgeInsets.zero,
630
+
631
icon: AnimatedSwitcher(
632
+
633
duration: const Duration(milliseconds: 150),
634
+
635
transitionBuilder: (child, animation) {
636
+
637
return ScaleTransition(scale: animation, child: child);
638
+
639
},
640
+
641
child: widget.isStreaming
642
+
643
? HugeIcon(
644
+
645
icon: HugeIcons.strokeRoundedStop,
646
+
647
key: const ValueKey('stop'),
648
+
649
color: iconColor,
222
- size: 20,
650
+
651
+ size: 18,
652
+
653
)
654
+
655
: HugeIcon(
656
+
657
icon: HugeIcons.strokeRoundedArrowUp01,
658
+
659
key: ValueKey(canSend),
660
+
661
color: iconColor,
228
- size: 20,
662
+
663
+ size: 18,
664
+
665
),
666
+
667
),
668
+
669
onPressed: widget.isStreaming
670
+
671
? _handleStop
672
+
673
: (canSend ? _handleSubmit : null),
234
- tooltip: widget.isStreaming ? l10n.stop_generation_tooltip : l10n.send_message_tooltip,
674
+
675
+ tooltip: widget.isStreaming
676
+
677
+ ? l10n.stop_generation_tooltip
678
+
679
+ : l10n.send_message_tooltip,
680
+
681
),
682
+
683
),
684
+
685
),
686
+
687
);
688
+
689
}
690
691
+
692
+
693
String? _mapSttError(String error) {
694
+
695
switch (error) {
696
+
697
case 'error_no_match':
698
+
699
return 'No speech recognized. Please try speaking again and check your microphone.';
700
+
701
case 'error_speech_timeout':
702
+
703
return 'No speech detected. Timed out.';
704
+
705
case 'error_permission':
706
+
707
return 'Microphone permission denied.';
708
+
709
case 'error_busy':
710
+
711
return 'Speech recognition is busy. Please try again.';
712
+
713
case 'error_network':
714
+
715
case 'error_network_timeout':
716
+
717
return 'Network error. Please check your connection and try again.';
718
+
719
case 'error_audio':
720
+
721
return 'Audio recording error. Please check your microphone.';
722
+
723
default:
724
+
725
if (error.startsWith('error_')) {
258
- final cleanName = error.replaceFirst('error_', '').replaceAll('_', ' ');
726
+
727
+ final cleanName =
728
+
729
+ error.replaceFirst('error_', '').replaceAll('_', ' ');
730
+
731
return 'Speech recognition error: $cleanName';
732
+
733
}
734
+
735
return error;
736
+
737
}
738
+
739
}
740
741
+
742
+
743
@override
744
+
745
Widget build(BuildContext context) {
746
+
747
final l10n = AppLocalizations.of(context)!;
748
+
749
final theme = Theme.of(context);
750
+
751
final isDark = theme.brightness == Brightness.dark;
752
+
753
final connectionStatus = ref.watch(connectionStatusProvider);
754
+
755
final isConnected = connectionStatus == ConnectionStatus.connected;
756
757
+
758
+
759
final sttState = ref.watch(sttProvider);
760
+
761
final isListening = sttState.isListening;
762
763
+
764
+
765
if (isListening) {
766
+
767
if (!_micAnimController.isAnimating) {
768
+
769
_micAnimController.repeat(reverse: true);
770
+
771
}
772
+
773
} else {
774
+
775
if (_micAnimController.isAnimating) {
776
+
777
_micAnimController.stop();
778
+
779
_micAnimController.reset();
780
+
781
}
782
+
783
}
784
785
+
786
+
787
ref.listen<String?>(
788
+
789
sttProvider.select((s) => s.error),
790
+
791
(previous, next) {
792
+
793
if (next != null) {
794
+
795
final message = _mapSttError(next);
796
+
797
if (message != null) {
798
+
799
ScaffoldMessenger.of(context).showSnackBar(
800
+
801
SnackBar(content: Text(message)),
802
+
803
);
804
+
805
}
806
+
807
}
808
+
809
},
810
+
811
);
812
301
- final canSend =
302
- widget.enabled &&
813
+
814
+
815
+ final canSend = widget.enabled &&
816
+
817
isConnected &&
818
+
819
(_controller.text.trim().isNotEmpty || _attachedFiles.isNotEmpty) &&
820
+
821
!widget.isStreaming;
822
823
+
824
+
825
return SafeArea(
826
+
827
top: false,
828
+
829
child: Container(
310
- margin: const EdgeInsets.only(left: 20, right: 20, top: 8, bottom: 16),
311
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
830
+
831
+ margin: const EdgeInsets.only(left: 16, right: 16, top: 8, bottom: 12),
832
+
833
+ padding: const EdgeInsets.fromLTRB(12, 10, 12, 8),
834
+
835
decoration: BoxDecoration(
836
+
837
color: isDark ? AppColors.darkSurfaceInput : AppColors.lightSurface,
314
- borderRadius: BorderRadius.circular(12),
838
+
839
+ borderRadius: BorderRadius.circular(16),
840
+
841
border: Border.all(
842
+
843
color: isDark ? AppColors.darkBorder : AppColors.lightBorder,
317
- width: 1,
844
+
845
),
846
+
847
),
848
+
849
child: Column(
850
+
851
mainAxisSize: MainAxisSize.min,
852
+
853
+ crossAxisAlignment: CrossAxisAlignment.stretch,
854
+
855
children: [
323
- AnimatedSize(
324
- duration: const Duration(milliseconds: 200),
325
- curve: Curves.easeOut,
326
- child: _attachedFiles.isEmpty
327
- ? const SizedBox.shrink()
328
- : Container(
329
- height: 70,
330
- padding: const EdgeInsets.only(
331
- left: 12,
332
- right: 12,
333
- top: 8,
334
- bottom: 4,
335
- ),
336
- child: ListView.separated(
337
- scrollDirection: Axis.horizontal,
338
- itemCount: _attachedFiles.length,
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: [
347
- Container(
348
- decoration: BoxDecoration(
349
- borderRadius: BorderRadius.circular(12),
350
- border: Border.all(
351
- color: isDark
352
- ? AppColors.darkBorder
353
- : AppColors.lightBorder,
354
- ),
355
- ),
356
- child: ClipRRect(
357
- borderRadius: BorderRadius.circular(11),
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(
378
- top: -4,
379
- end: -4,
380
- child: GestureDetector(
381
- onTap: () => setState(
382
- () => _attachedFiles.removeAt(index),
383
- ),
384
- child: Container(
385
- width: 18,
386
- height: 18,
387
- decoration: const BoxDecoration(
388
- color: Colors.black,
389
- shape: BoxShape.circle,
390
- ),
391
- child: const Icon(
392
- Icons.close,
393
- size: 10,
394
- color: Colors.white,
856
+
857
+ if (_attachedFiles.isNotEmpty)
858
+
859
+ SizedBox(
860
+
861
+ height: 68,
862
+
863
+ child: ListView.separated(
864
+
865
+ scrollDirection: Axis.horizontal,
866
+
867
+ itemCount: _attachedFiles.length,
868
+
869
+ separatorBuilder: (_, __) => const SizedBox(width: 8),
870
+
871
+ itemBuilder: (context, index) {
872
+
873
+ final file = _attachedFiles[index];
874
+
875
+ final ext = file.path.split('.').last.toLowerCase();
876
+
877
+ final isImage = !['txt', 'md'].contains(ext);
878
+
879
+ return Stack(
880
+
881
+ clipBehavior: Clip.none,
882
+
883
+ children: [
884
+
885
+ Container(
886
+
887
+ decoration: BoxDecoration(
888
+
889
+ borderRadius: BorderRadius.circular(10),
890
+
891
+ border: Border.all(
892
+
893
+ color: isDark
894
+
895
+ ? AppColors.darkBorder
896
+
897
+ : AppColors.lightBorder,
898
+
899
+ ),
900
+
901
+ ),
902
+
903
+ child: ClipRRect(
904
+
905
+ borderRadius: BorderRadius.circular(9),
906
+
907
+ child: isImage
908
+
909
+ ? Image.file(
910
+
911
+ file,
912
+
913
+ width: 48,
914
+
915
+ height: 48,
916
+
917
+ fit: BoxFit.cover,
918
+
919
+ )
920
+
921
+ : Container(
922
+
923
+ width: 48,
924
+
925
+ height: 48,
926
+
927
+ color: theme
928
+
929
+ .colorScheme.surfaceContainerHighest,
930
+
931
+ child: Icon(
932
+
933
+ Icons.description_outlined,
934
+
935
+ size: 22,
936
+
937
+ color: theme.colorScheme.primary,
938
+
939
),
940
+
941
),
397
- ),
942
+
943
+ ),
944
+
945
+ ),
946
+
947
+ PositionedDirectional(
948
+
949
+ top: -4,
950
+
951
+ end: -4,
952
+
953
+ child: GestureDetector(
954
+
955
+ onTap: () =>
956
+
957
+ setState(() => _attachedFiles.removeAt(index)),
958
+
959
+ child: Container(
960
+
961
+ width: 18,
962
+
963
+ height: 18,
964
+
965
+ decoration: const BoxDecoration(
966
+
967
+ color: Colors.black,
968
+
969
+ shape: BoxShape.circle,
970
+
971
),
399
- ],
400
- );
401
- },
402
- ),
403
- ),
972
+
973
+ child: const Icon(
974
+
975
+ Icons.close,
976
+
977
+ size: 10,
978
+
979
+ color: Colors.white,
980
+
981
+ ),
982
+
983
+ ),
984
+
985
+ ),
986
+
987
+ ),
988
+
989
+ ],
990
+
991
+ );
992
+
993
+ },
994
+
995
+ ),
996
+
997
+ ),
998
+
999
+ TextField(
1000
+
1001
+ controller: _controller,
1002
+
1003
+ focusNode: _focusNode,
1004
+
1005
+ enabled: widget.enabled,
1006
+
1007
+ maxLines: 6,
1008
+
1009
+ minLines: 1,
1010
+
1011
+ textInputAction: TextInputAction.newline,
1012
+
1013
+ keyboardType: TextInputType.multiline,
1014
+
1015
+ onChanged: (_) => setState(() {}),
1016
+
1017
+ style: TextStyle(
1018
+
1019
+ fontSize: 15,
1020
+
1021
+ color: theme.colorScheme.onSurface,
1022
+
1023
+ ),
1024
+
1025
+ decoration: InputDecoration(
1026
+
1027
+ filled: false,
1028
+
1029
+ border: InputBorder.none,
1030
+
1031
+ enabledBorder: InputBorder.none,
1032
+
1033
+ focusedBorder: InputBorder.none,
1034
+
1035
+ disabledBorder: InputBorder.none,
1036
+
1037
+ hintText: l10n.chat_input_hint,
1038
+
1039
+ hintStyle: TextStyle(
1040
+
1041
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.38),
1042
+
1043
+ fontSize: 15,
1044
+
1045
+ ),
1046
+
1047
+ isDense: true,
1048
+
1049
+ contentPadding: const EdgeInsets.symmetric(vertical: 4),
1050
+
1051
+ ),
1052
+
1053
),
1054
+
1055
+ const SizedBox(height: 6),
1056
+
1057
Row(
1058
+
1059
children: [
1060
+
1061
IconButton(
408
- icon: HugeIcon(
409
- icon: HugeIcons.strokeRoundedPlusSign,
410
- color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
1062
+
1063
+ visualDensity: VisualDensity.compact,
1064
+
1065
+ padding: const EdgeInsets.all(8),
1066
+
1067
+ constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
1068
+
1069
+ icon: Icon(
1070
+
1071
+ Icons.add,
1072
+
1073
size: 22,
1074
+
1075
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
1076
+
1077
),
413
- onPressed: isConnected ? _handleAttach : null,
414
- tooltip: l10n.attach_images_tooltip,
415
- ),
416
- const SizedBox(width: 4),
417
- Expanded(
418
- child: TextField(
419
- controller: _controller,
420
- focusNode: _focusNode,
421
- enabled: widget.enabled,
422
- maxLines: 5,
423
- minLines: 1,
424
- textInputAction: TextInputAction.newline,
425
- keyboardType: TextInputType.multiline,
426
- onChanged: (text) {
427
- setState(() {});
428
- },
429
- style: TextStyle(
430
- fontSize: 15,
431
- color: theme.colorScheme.onSurface,
432
- ),
433
- decoration: InputDecoration(
434
- filled: false,
435
- border: InputBorder.none,
436
- enabledBorder: InputBorder.none,
437
- focusedBorder: InputBorder.none,
438
- disabledBorder: InputBorder.none,
439
- hintText: l10n.chat_input_hint,
440
- hintStyle: TextStyle(
441
- color: theme.colorScheme.onSurface.withValues(alpha: 0.38),
442
- fontSize: 15,
443
- fontWeight: FontWeight.w400,
444
- ),
445
- isDense: true,
446
- contentPadding: const EdgeInsets.symmetric(vertical: 12),
447
- ),
448
- ),
1078
+
1079
+ onPressed: isConnected ? _showAttachMenu : null,
1080
+
1081
+ tooltip: l10n.add_attachment,
1082
+
1083
),
450
- const SizedBox(width: 8),
1084
+
1085
+ const Spacer(),
1086
+
1087
_buildMicButton(isListening, theme),
452
- const SizedBox(width: 8),
1088
+
1089
+ const SizedBox(width: 6),
1090
+
1091
_buildActionButton(canSend, theme),
1092
+
1093
],
1094
+
1095
),
1096
+
1097
],
1098
+
1099
),
1100
+
1101
),
1102
+
1103
);
1104
+
1105
}
1106
+
1107
}
1108
+
1109
+
lib/features/chat/views/components/message_action_bar.dart
+231
-80
@@ -4,15 +4,21 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
4
import 'package:shadcn_ui/shadcn_ui.dart';
5
import 'package:localmind/l10n/app_localizations.dart';
6
import 'package:localmind/core/theme/colors.dart';
7
+import '../../../saved_messages/providers/saved_message_providers.dart';
8
import '../../../tts/providers/tts_providers.dart' as tts;
9
10
class MessageActionBar extends ConsumerStatefulWidget {
11
const MessageActionBar({
12
super.key,
13
required this.content,
13
- this.tokenCount,
14
this.messageId,
15
this.conversationId,
16
+ this.tokenCount,
17
+ this.inputTokenCount,
18
+ this.generationTimeMs,
19
+ this.ttftMs,
20
+ this.tokensPerSecond,
21
+ this.stopReason,
22
this.onCopy,
23
this.onRetry,
24
this.onDelete,
@@ -24,9 +30,14 @@ class MessageActionBar extends ConsumerStatefulWidget {
30
});
31
32
final String content;
27
- final int? tokenCount;
33
final String? messageId;
34
final String? conversationId;
35
+ final int? tokenCount;
36
+ final int? inputTokenCount;
37
+ final int? generationTimeMs;
38
+ final int? ttftMs;
39
+ final double? tokensPerSecond;
40
+ final String? stopReason;
41
final VoidCallback? onCopy;
42
final VoidCallback? onRetry;
43
final VoidCallback? onDelete;
@@ -41,7 +52,6 @@ class MessageActionBar extends ConsumerStatefulWidget {
52
}
53
54
class _MessageActionBarState extends ConsumerState<MessageActionBar> {
44
-
55
void _toggleTts() async {
56
final ttsNotifier = ref.read(tts.ttsProvider.notifier);
57
final ttsState = ref.read(tts.ttsProvider);
@@ -72,6 +82,13 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
82
(ttsState.isSpeaking || ttsState.isPaused);
83
}
84
85
+ String _formatMs(int ms) {
86
+ if (ms >= 1000) {
87
+ return '${(ms / 1000).toStringAsFixed(2)}s';
88
+ }
89
+ return '${ms}ms';
90
+ }
91
+
92
@override
93
Widget build(BuildContext context) {
94
final l10n = AppLocalizations.of(context)!;
@@ -79,8 +96,10 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
96
final isThisActive = _isMessageActive(ttsState);
97
final isThisPlaying =
98
isThisActive && ttsState.isSpeaking && !ttsState.isPaused;
82
- final isThisInitializing =
83
- isThisActive && ttsState.isInitializing;
99
+ final isThisInitializing = isThisActive && ttsState.isInitializing;
100
+ final isSaved = widget.messageId != null
101
+ ? ref.watch(isMessageSavedProvider(widget.messageId!)).value ?? false
102
+ : false;
103
104
return Row(
105
mainAxisSize: MainAxisSize.min,
@@ -101,6 +120,15 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
120
}
121
},
122
),
123
+ if (widget.onSave != null) ...[
124
+ const SizedBox(width: 4),
125
+ _ActionButton(
126
+ icon: isSaved ? Icons.bookmark : Icons.bookmark_border,
127
+ label: isSaved ? l10n.message_already_saved : l10n.save_message,
128
+ onTap: widget.onSave,
129
+ isActive: isSaved,
130
+ ),
131
+ ],
132
if (widget.onRetry != null) ...[
133
const SizedBox(width: 4),
134
_ActionButton(
@@ -141,7 +169,9 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
169
const SizedBox(width: 4),
170
_ActionButton(
171
icon: isThisActive
144
- ? (isThisPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled)
172
+ ? (isThisPlaying
173
+ ? Icons.pause_circle_filled
174
+ : Icons.play_circle_filled)
175
: (isThisInitializing ? Icons.hourglass_top : Icons.volume_up),
176
label: isThisActive
177
? (isThisPlaying ? l10n.pause : l10n.resume)
@@ -152,7 +182,7 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
182
_ActionButton(
183
icon: Icons.more_horiz,
184
label: l10n.more,
155
- onTap: () => _showMoreOptions(context),
185
+ onTap: () => _showMoreOptions(context, isSaved),
186
),
187
],
188
);
@@ -182,108 +212,227 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
212
);
213
}
214
185
- void _showMoreOptions(BuildContext context) {
215
+ void _showMoreOptions(BuildContext context, bool isSaved) {
216
final sheetL10n = AppLocalizations.of(context)!;
217
final ttsState = ref.read(tts.ttsProvider);
218
final isThisActive = _isMessageActive(ttsState);
219
final isThisPlaying =
220
isThisActive && ttsState.isSpeaking && !ttsState.isPaused;
221
+ final theme = Theme.of(context);
222
192
- showShadSheet(
223
+ final stats = <({String label, String value})>[];
224
+ stats.add((
225
+ label: sheetL10n.characters_label,
226
+ value: '${widget.content.length}',
227
+ ));
228
+ if (widget.ttftMs != null) {
229
+ stats.add((
230
+ label: sheetL10n.stream_ttft,
231
+ value: _formatMs(widget.ttftMs!),
232
+ ));
233
+ }
234
+ if (widget.generationTimeMs != null) {
235
+ stats.add((
236
+ label: sheetL10n.stream_generation_time,
237
+ value: _formatMs(widget.generationTimeMs!),
238
+ ));
239
+ }
240
+ if (widget.inputTokenCount != null) {
241
+ stats.add((
242
+ label: sheetL10n.stream_input_tokens,
243
+ value: '${widget.inputTokenCount}',
244
+ ));
245
+ }
246
+ if (widget.tokenCount != null) {
247
+ stats.add((
248
+ label: sheetL10n.stream_output_tokens,
249
+ value: '${widget.tokenCount}',
250
+ ));
251
+ }
252
+ if (widget.tokensPerSecond != null) {
253
+ stats.add((
254
+ label: sheetL10n.stream_tokens_per_sec,
255
+ value: widget.tokensPerSecond!.toStringAsFixed(1),
256
+ ));
257
+ }
258
+ if (widget.stopReason != null && widget.stopReason!.isNotEmpty) {
259
+ stats.add((
260
+ label: sheetL10n.stream_stop_reason,
261
+ value: widget.stopReason!,
262
+ ));
263
+ }
264
+
265
+ showModalBottomSheet(
266
context: context,
194
- builder: (context) => ShadSheet(
195
- title: Text(sheetL10n.message_options),
196
- child: Column(
197
- mainAxisSize: MainAxisSize.min,
198
- children: [
199
- ListTile(
200
- leading: const Icon(Icons.code),
201
- title: Text(sheetL10n.copy_markdown),
202
- onTap: () {
203
- Navigator.of(context).pop();
204
- Clipboard.setData(ClipboardData(text: widget.content));
205
- ScaffoldMessenger.of(context).showSnackBar(
206
- SnackBar(content: Text(sheetL10n.copied_markdown)),
207
- );
208
- },
209
- ),
210
- ListTile(
211
- leading: Icon(
212
- isThisActive
213
- ? (isThisPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled)
214
- : Icons.volume_up,
215
- ),
216
- title: Text(
217
- isThisActive
218
- ? (isThisPlaying ? sheetL10n.pause : sheetL10n.resume)
219
- : sheetL10n.read_aloud,
220
- ),
221
- onTap: () {
222
- Navigator.of(context).pop();
223
- _toggleTts();
224
- },
225
- ),
226
- if (widget.onShare != null)
227
- ListTile(
228
- leading: const Icon(Icons.ios_share),
229
- title: Text(sheetL10n.share),
230
- onTap: () {
231
- Navigator.of(context).pop();
232
- widget.onShare?.call();
233
- },
234
- ),
235
- if (widget.onBranch != null)
236
- ListTile(
237
- leading: const Icon(Icons.call_split),
238
- title: Text(sheetL10n.branch_chat),
239
- subtitle: Text(sheetL10n.branch_chat_desc),
240
- onTap: () {
241
- Navigator.of(context).pop();
242
- widget.onBranch?.call();
243
- },
267
+ builder: (ctx) => SafeArea(
268
+ child: Padding(
269
+ padding: const EdgeInsets.fromLTRB(8, 8, 8, 12),
270
+ child: Column(
271
+ mainAxisSize: MainAxisSize.min,
272
+ crossAxisAlignment: CrossAxisAlignment.stretch,
273
+ children: [
274
+ Padding(
275
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
276
+ child: Text(
277
+ sheetL10n.message_options,
278
+ style: theme.textTheme.titleSmall,
279
+ ),
280
),
245
- if (widget.onSave != null)
246
- ListTile(
247
- leading: const Icon(Icons.bookmark_outline),
248
- title: Text(sheetL10n.save_message),
281
+ _CompactOptionTile(
282
+ icon: Icons.code,
283
+ label: sheetL10n.copy_markdown,
284
onTap: () {
250
- Navigator.of(context).pop();
251
- widget.onSave?.call();
285
+ Navigator.pop(ctx);
286
+ Clipboard.setData(ClipboardData(text: widget.content));
287
ScaffoldMessenger.of(context).showSnackBar(
253
- SnackBar(content: Text(sheetL10n.message_saved)),
288
+ SnackBar(content: Text(sheetL10n.copied_markdown)),
289
);
290
},
291
),
257
- ListTile(
258
- leading: const Icon(Icons.text_fields),
259
- title: Text(sheetL10n.character_count(widget.content.length)),
260
- enabled: false,
261
- ),
262
- if (widget.tokenCount != null)
263
- ListTile(
264
- leading: const Icon(Icons.numbers),
265
- title: Text(sheetL10n.token_count(widget.tokenCount!)),
266
- enabled: false,
292
+ _CompactOptionTile(
293
+ icon: isThisActive
294
+ ? (isThisPlaying
295
+ ? Icons.pause_circle_filled
296
+ : Icons.play_circle_filled)
297
+ : Icons.volume_up,
298
+ label: isThisActive
299
+ ? (isThisPlaying ? sheetL10n.pause : sheetL10n.resume)
300
+ : sheetL10n.read_aloud,
301
+ onTap: () {
302
+ Navigator.pop(ctx);
303
+ _toggleTts();
304
+ },
305
),
268
- ],
306
+ if (widget.onShare != null)
307
+ _CompactOptionTile(
308
+ icon: Icons.ios_share,
309
+ label: sheetL10n.share,
310
+ onTap: () {
311
+ Navigator.pop(ctx);
312
+ widget.onShare?.call();
313
+ },
314
+ ),
315
+ if (widget.onBranch != null)
316
+ _CompactOptionTile(
317
+ icon: Icons.call_split,
318
+ label: sheetL10n.branch_chat,
319
+ onTap: () {
320
+ Navigator.pop(ctx);
321
+ widget.onBranch?.call();
322
+ },
323
+ ),
324
+ if (widget.onSave != null)
325
+ _CompactOptionTile(
326
+ icon: isSaved ? Icons.bookmark : Icons.bookmark_outline,
327
+ label: isSaved
328
+ ? sheetL10n.message_already_saved
329
+ : sheetL10n.save_message,
330
+ onTap: () {
331
+ Navigator.pop(ctx);
332
+ widget.onSave?.call();
333
+ },
334
+ ),
335
+ if (stats.isNotEmpty) ...[
336
+ const SizedBox(height: 8),
337
+ Padding(
338
+ padding: const EdgeInsets.symmetric(horizontal: 8),
339
+ child: GridView.builder(
340
+ shrinkWrap: true,
341
+ physics: const NeverScrollableScrollPhysics(),
342
+ gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
343
+ crossAxisCount: 2,
344
+ childAspectRatio: 3.2,
345
+ crossAxisSpacing: 8,
346
+ mainAxisSpacing: 4,
347
+ ),
348
+ itemCount: stats.length,
349
+ itemBuilder: (context, index) {
350
+ final stat = stats[index];
351
+ return Container(
352
+ padding: const EdgeInsets.symmetric(
353
+ horizontal: 8,
354
+ vertical: 6,
355
+ ),
356
+ decoration: BoxDecoration(
357
+ color: theme.colorScheme.surfaceContainerHighest
358
+ .withValues(alpha: 0.5),
359
+ borderRadius: BorderRadius.circular(8),
360
+ ),
361
+ child: Column(
362
+ crossAxisAlignment: CrossAxisAlignment.start,
363
+ mainAxisAlignment: MainAxisAlignment.center,
364
+ children: [
365
+ Text(
366
+ stat.label,
367
+ maxLines: 1,
368
+ overflow: TextOverflow.ellipsis,
369
+ style: TextStyle(
370
+ fontSize: 10,
371
+ color: theme.colorScheme.onSurfaceVariant,
372
+ ),
373
+ ),
374
+ Text(
375
+ stat.value,
376
+ maxLines: 1,
377
+ overflow: TextOverflow.ellipsis,
378
+ style: const TextStyle(
379
+ fontSize: 12,
380
+ fontWeight: FontWeight.w600,
381
+ ),
382
+ ),
383
+ ],
384
+ ),
385
+ );
386
+ },
387
+ ),
388
+ ),
389
+ ],
390
+ ],
391
+ ),
392
),
393
),
394
);
395
}
396
}
397
398
+class _CompactOptionTile extends StatelessWidget {
399
+ const _CompactOptionTile({
400
+ required this.icon,
401
+ required this.label,
402
+ required this.onTap,
403
+ });
404
+
405
+ final IconData icon;
406
+ final String label;
407
+ final VoidCallback onTap;
408
+
409
+ @override
410
+ Widget build(BuildContext context) {
411
+ return ListTile(
412
+ dense: true,
413
+ visualDensity: VisualDensity.compact,
414
+ contentPadding: const EdgeInsets.symmetric(horizontal: 8),
415
+ leading: Icon(icon, size: 20),
416
+ title: Text(label, style: const TextStyle(fontSize: 14)),
417
+ onTap: onTap,
418
+ );
419
+ }
420
+}
421
+
422
class _ActionButton extends StatefulWidget {
423
const _ActionButton({
424
required this.icon,
425
required this.label,
426
required this.onTap,
427
this.isDestructive = false,
428
+ this.isActive = false,
429
});
430
431
final IconData icon;
432
final String label;
433
final VoidCallback? onTap;
434
final bool isDestructive;
435
+ final bool isActive;
436
437
@override
438
State<_ActionButton> createState() => _ActionButtonState();
@@ -319,9 +468,11 @@ class _ActionButtonState extends State<_ActionButton>
468
final theme = Theme.of(context);
469
final isDark = theme.brightness == Brightness.dark;
470
322
- final baseColor = widget.isDestructive
323
- ? (isDark ? Colors.red[300] : Colors.red[600])
324
- : (isDark ? AppColors.darkMutedText : AppColors.lightMutedText);
471
+ final baseColor = widget.isActive
472
+ ? theme.colorScheme.primary
473
+ : widget.isDestructive
474
+ ? (isDark ? Colors.red[300] : Colors.red[600])
475
+ : (isDark ? AppColors.darkMutedText : AppColors.lightMutedText);
476
477
final hoverColor = widget.isDestructive
478
? (isDark ? Colors.red[200] : Colors.red[500])
lib/features/chat/views/components/message_list/empty_state.dart
+49
-31
@@ -1,6 +1,7 @@
1
import 'dart:async';
2
3
import 'package:flutter/material.dart';
4
+import 'package:shadcn_ui/shadcn_ui.dart';
5
import 'package:localmind/core/theme/colors.dart';
6
import 'package:localmind/l10n/app_localizations.dart';
7
import 'recent_conversation_item.dart';
@@ -103,40 +104,52 @@ class _EmptyStateState extends State<EmptyState>
104
final theme = Theme.of(context);
105
final isDark = theme.brightness == Brightness.dark;
106
106
- return Center(
107
- child: SingleChildScrollView(
108
- padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
109
- child: Column(
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,
107
+ return SingleChildScrollView(
108
+ padding: const EdgeInsets.fromLTRB(32, 8, 32, 24),
109
+ child: Column(
110
+ crossAxisAlignment: CrossAxisAlignment.center,
111
+ children: [
112
+ SizedBox(
113
+ height: 72,
114
+ child: Center(
115
+ child: AnimatedSwitcher(
116
+ duration: const Duration(milliseconds: 400),
117
+ layoutBuilder: (current, previous) => Stack(
118
+ alignment: Alignment.center,
119
+ children: [...previous, if (current != null) current],
120
+ ),
121
+ child: Text(
122
+ _welcomeMessages(l10n)[_welcomeIndex],
123
+ key: ValueKey(_welcomeIndex),
124
+ textAlign: TextAlign.center,
125
+ maxLines: 2,
126
+ overflow: TextOverflow.ellipsis,
127
+ style: theme.textTheme.headlineSmall?.copyWith(
128
+ fontWeight: FontWeight.w600,
129
+ color: isDark ? Colors.white : Colors.black87,
130
+ ),
131
),
132
),
133
),
125
- const SizedBox(height: 32),
126
- _buildModelAndPersonaSelectors(isDark, theme, l10n),
127
- const SizedBox(height: 32),
128
- _buildQuickPrompts(isDark),
129
- if (widget.recentConversations.isNotEmpty) ...[
130
- const SizedBox(height: 32),
131
- _buildRecentSection(isDark, l10n),
132
- ],
134
+ ),
135
+ const SizedBox(height: 8),
136
+ _buildModelAndPersonaSelectors(isDark, theme, l10n),
137
+ const SizedBox(height: 24),
138
+ _buildQuickPrompts(isDark),
139
+ if (widget.recentConversations.isNotEmpty) ...[
140
+ const SizedBox(height: 28),
141
+ _buildRecentSection(isDark, l10n),
142
],
134
- ),
143
+ ],
144
),
145
);
146
}
147
139
- Widget _buildModelAndPersonaSelectors(bool isDark, ThemeData theme, AppLocalizations l10n) {
148
+ Widget _buildModelAndPersonaSelectors(
149
+ bool isDark,
150
+ ThemeData theme,
151
+ AppLocalizations l10n,
152
+ ) {
153
return TweenAnimationBuilder<double>(
154
tween: Tween(begin: 0.0, end: 1.0),
155
duration: const Duration(milliseconds: 300),
@@ -160,7 +173,8 @@ class _EmptyStateState extends State<EmptyState>
173
child: Row(
174
mainAxisSize: MainAxisSize.min,
175
children: [
163
- Icon(Icons.settings_suggest, size: 18, color: theme.colorScheme.primary),
176
+ Icon(Icons.settings_suggest,
177
+ size: 18, color: theme.colorScheme.primary),
178
const SizedBox(width: 8),
179
Flexible(
180
child: Text(
@@ -190,7 +204,8 @@ class _EmptyStateState extends State<EmptyState>
204
mainAxisSize: MainAxisSize.min,
205
children: [
206
if (widget.selectedPersona != null) ...[
193
- Text(widget.selectedPersona!.emoji, style: const TextStyle(fontSize: 18)),
207
+ Text(widget.selectedPersona!.emoji,
208
+ style: const TextStyle(fontSize: 18)),
209
const SizedBox(width: 8),
210
Flexible(
211
child: Text(
@@ -205,7 +220,8 @@ class _EmptyStateState extends State<EmptyState>
220
),
221
),
222
] else ...[
208
- Icon(Icons.smart_toy_outlined, size: 18, color: theme.colorScheme.primary),
223
+ Icon(Icons.smart_toy_outlined,
224
+ size: 18, color: theme.colorScheme.primary),
225
const SizedBox(width: 8),
226
Flexible(
227
child: Text(
@@ -278,7 +294,8 @@ class _EmptyStateState extends State<EmptyState>
294
child: ActionChip(
295
label: Text(prompt),
296
onPressed: () => widget.onQuickPrompt(prompt),
281
- backgroundColor: isDark ? AppColors.darkSurfaceCard : AppColors.lightSurface,
297
+ backgroundColor:
298
+ isDark ? AppColors.darkSurfaceCard : AppColors.lightSurface,
299
side: BorderSide(
300
color: isDark ? AppColors.darkBorder : AppColors.lightBorder,
301
),
@@ -299,7 +316,8 @@ class _EmptyStateState extends State<EmptyState>
316
style: TextStyle(
317
fontSize: 14,
318
fontWeight: FontWeight.w500,
302
- color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
319
+ color:
320
+ isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
321
),
322
),
323
const SizedBox(width: 8),
lib/features/chat/views/components/message_list/message_list.dart
+24
@@ -22,7 +22,9 @@ class MessageList extends StatelessWidget {
22
required this.onContinue,
23
required this.onCycleVariant,
24
required this.onModelPicker,
25
+ this.onModelLongPress,
26
this.onSave,
27
+ this.onShare,
28
this.onGenerateResponse,
29
this.hasSmartReplies = false,
30
this.bottomInset = 0,
@@ -40,7 +42,9 @@ class MessageList extends StatelessWidget {
42
final void Function(String messageId) onContinue;
43
final void Function(String messageId, int direction) onCycleVariant;
44
final VoidCallback onModelPicker;
45
+ final void Function(String modelId)? onModelLongPress;
46
final void Function(Message message)? onSave;
47
+ final void Function(Message message)? onShare;
48
final VoidCallback? onGenerateResponse;
49
final bool hasSmartReplies;
50
final double bottomInset;
@@ -60,7 +64,9 @@ class MessageList extends StatelessWidget {
64
onContinue: onContinue,
65
onCycleVariant: onCycleVariant,
66
onModelPicker: onModelPicker,
67
+ onModelLongPress: onModelLongPress,
68
onSave: onSave,
69
+ onShare: onShare,
70
onGenerateResponse: onGenerateResponse,
71
hasSmartReplies: hasSmartReplies,
72
bottomInset: bottomInset,
@@ -82,7 +88,9 @@ class _MessageListConsumer extends ConsumerStatefulWidget {
88
required this.onContinue,
89
required this.onCycleVariant,
90
required this.onModelPicker,
91
+ this.onModelLongPress,
92
this.onSave,
93
+ this.onShare,
94
this.onGenerateResponse,
95
this.hasSmartReplies = false,
96
this.bottomInset = 0,
@@ -100,7 +108,9 @@ class _MessageListConsumer extends ConsumerStatefulWidget {
108
final void Function(String messageId) onContinue;
109
final void Function(String messageId, int direction) onCycleVariant;
110
final VoidCallback onModelPicker;
111
+ final void Function(String modelId)? onModelLongPress;
112
final void Function(Message message)? onSave;
113
+ final void Function(Message message)? onShare;
114
final VoidCallback? onGenerateResponse;
115
final bool hasSmartReplies;
116
final double bottomInset;
@@ -169,7 +179,9 @@ class _MessageListConsumerState extends ConsumerState<_MessageListConsumer> {
179
onContinue: widget.onContinue,
180
onCycleVariant: widget.onCycleVariant,
181
onModelPicker: widget.onModelPicker,
182
+ onModelLongPress: widget.onModelLongPress,
183
onSave: widget.onSave,
184
+ onShare: widget.onShare,
185
onGenerateResponse: widget.onGenerateResponse,
186
hasSmartReplies: widget.hasSmartReplies,
187
bottomInset: widget.bottomInset,
@@ -193,7 +205,9 @@ class _MessageList extends StatelessWidget {
205
required this.onContinue,
206
required this.onCycleVariant,
207
required this.onModelPicker,
208
+ this.onModelLongPress,
209
this.onSave,
210
+ this.onShare,
211
this.onGenerateResponse,
212
this.hasSmartReplies = false,
213
this.bottomInset = 0,
@@ -213,7 +227,9 @@ class _MessageList extends StatelessWidget {
227
final void Function(String messageId) onContinue;
228
final void Function(String messageId, int direction) onCycleVariant;
229
final VoidCallback onModelPicker;
230
+ final void Function(String modelId)? onModelLongPress;
231
final void Function(Message message)? onSave;
232
+ final void Function(Message message)? onShare;
233
final VoidCallback? onGenerateResponse;
234
final bool hasSmartReplies;
235
final double bottomInset;
@@ -281,7 +297,15 @@ class _MessageList extends StatelessWidget {
297
onCycleVariant: (direction) =>
298
onCycleVariant(message.id, direction),
299
onModelTap: onModelPicker,
300
+ onModelLongPress: message.modelId != null &&
301
+ message.role == MessageRole.assistant &&
302
+ onModelLongPress != null
303
+ ? () => onModelLongPress!(message.modelId!)
304
+ : null,
305
onSave: onSave,
306
+ onShare: onShare == null
307
+ ? null
308
+ : () => onShare!(message),
309
),
310
if (!isStreaming &&
311
isLast &&
lib/features/chat/views/components/model_info_sheet.dart
new
+85
@@ -0,0 +1,85 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter/services.dart';
3
+import 'package:flutter_riverpod/flutter_riverpod.dart';
4
+import 'package:shadcn_ui/shadcn_ui.dart';
5
+import 'package:localmind/features/chat/providers/chat_params_providers.dart';
6
+import 'package:localmind/features/models/data/models/model_info.dart';
7
+import 'package:localmind/features/servers/providers/server_providers.dart';
8
+import 'package:localmind/l10n/app_localizations.dart';
9
+
10
+Future<void> showModelInfoSheet(
11
+ BuildContext context,
12
+ WidgetRef ref,
13
+ String modelId,
14
+) async {
15
+ final l10n = AppLocalizations.of(context)!;
16
+ final server = ref.read(activeServerProvider);
17
+ ModelInfo? model;
18
+ if (server != null) {
19
+ final models = await ref.read(availableModelsProvider(server.id).future);
20
+ for (final m in models) {
21
+ if (m is ModelInfo && m.id == modelId) {
22
+ model = m;
23
+ break;
24
+ }
25
+ }
26
+ }
27
+
28
+ if (!context.mounted) return;
29
+
30
+ final displayName = model?.displayName ?? modelId;
31
+ final contextLength = ref.read(chatParamsProvider).contextLength;
32
+ final identifier = model?.id ?? modelId;
33
+
34
+ await showShadSheet(
35
+ context: context,
36
+ builder: (ctx) => ShadSheet(
37
+ title: Text(l10n.model_info),
38
+ child: Column(
39
+ mainAxisSize: MainAxisSize.min,
40
+ children: [
41
+ _ModelInfoRow(
42
+ label: l10n.model_name,
43
+ value: displayName,
44
+ ),
45
+ _ModelInfoRow(
46
+ label: l10n.model_identifier,
47
+ value: identifier,
48
+ ),
49
+ _ModelInfoRow(
50
+ label: l10n.context_length,
51
+ value: contextLength.toString(),
52
+ ),
53
+ ],
54
+ ),
55
+ ),
56
+ );
57
+}
58
+
59
+class _ModelInfoRow extends StatelessWidget {
60
+ const _ModelInfoRow({required this.label, required this.value});
61
+
62
+ final String label;
63
+ final String value;
64
+
65
+ @override
66
+ Widget build(BuildContext context) {
67
+ final l10n = AppLocalizations.of(context)!;
68
+ return ListTile(
69
+ title: Text(label, style: const TextStyle(fontSize: 12)),
70
+ subtitle: SelectableText(value),
71
+ trailing: IconButton(
72
+ icon: const Icon(Icons.copy, size: 18),
73
+ tooltip: l10n.copy,
74
+ onPressed: () async {
75
+ await Clipboard.setData(ClipboardData(text: value));
76
+ if (context.mounted) {
77
+ ScaffoldMessenger.of(context).showSnackBar(
78
+ SnackBar(content: Text(l10n.copied_to_clipboard)),
79
+ );
80
+ }
81
+ },
82
+ ),
83
+ );
84
+ }
85
+}
lib/features/chat/views/components/temporary_chat_banner.dart
new
+69
@@ -0,0 +1,69 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:localmind/l10n/app_localizations.dart';
3
+
4
+class TemporaryChatBanner extends StatelessWidget {
5
+ const TemporaryChatBanner({
6
+ super.key,
7
+ required this.onSaveToHistory,
8
+ });
9
+
10
+ final VoidCallback onSaveToHistory;
11
+
12
+ @override
13
+ Widget build(BuildContext context) {
14
+ final l10n = AppLocalizations.of(context)!;
15
+ final theme = Theme.of(context);
16
+ final isDark = theme.brightness == Brightness.dark;
17
+
18
+ return Container(
19
+ width: double.infinity,
20
+ margin: const EdgeInsets.fromLTRB(12, 0, 12, 8),
21
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
22
+ decoration: BoxDecoration(
23
+ color: isDark
24
+ ? const Color(0xFF2A2418)
25
+ : const Color(0xFFFFF8E7),
26
+ borderRadius: BorderRadius.circular(10),
27
+ border: Border.all(
28
+ color: isDark
29
+ ? const Color(0xFF6B5A2E)
30
+ : const Color(0xFFE6C35C),
31
+ ),
32
+ ),
33
+ child: Row(
34
+ children: [
35
+ Icon(
36
+ Icons.hourglass_empty_outlined,
37
+ size: 18,
38
+ color: isDark ? const Color(0xFFE6C35C) : const Color(0xFF9A7B1A),
39
+ ),
40
+ const SizedBox(width: 10),
41
+ Expanded(
42
+ child: Text(
43
+ l10n.temporary_chat_banner,
44
+ style: TextStyle(
45
+ fontSize: 12,
46
+ fontWeight: FontWeight.w600,
47
+ color: isDark ? const Color(0xFFF5E6B8) : const Color(0xFF5C4A12),
48
+ ),
49
+ ),
50
+ ),
51
+ TextButton(
52
+ onPressed: onSaveToHistory,
53
+ style: TextButton.styleFrom(
54
+ visualDensity: VisualDensity.compact,
55
+ padding: const EdgeInsets.symmetric(horizontal: 8),
56
+ ),
57
+ child: Text(
58
+ l10n.save_to_history,
59
+ style: TextStyle(
60
+ fontSize: 12,
61
+ color: isDark ? const Color(0xFFE6C35C) : const Color(0xFF9A7B1A),
62
+ ),
63
+ ),
64
+ ),
65
+ ],
66
+ ),
67
+ );
68
+ }
69
+}
lib/features/chat/views/components/top_bar/model_top_bar.dart
+142
-29
@@ -1,60 +1,173 @@
1
import 'package:flutter/material.dart';
2
+import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import 'package:hugeicons/hugeicons.dart';
4
+
5
+import 'package:localmind/core/models/enums.dart';
6
import 'package:localmind/l10n/app_localizations.dart';
7
+import '../../../../servers/data/models/server.dart';
8
+import '../../../../servers/providers/server_providers.dart';
9
+import '../../../../servers/views/components/server_icon_picker.dart';
10
+import '../../../../servers/views/components/server_picker_sheet.dart';
11
4
-class ModelTopBar extends StatelessWidget {
12
+class ModelTopBar extends ConsumerWidget {
13
const ModelTopBar({
14
super.key,
15
required this.selectedModel,
8
- required this.onTap,
16
+ required this.onModelTap,
17
});
18
19
final dynamic selectedModel;
12
- final VoidCallback onTap;
20
+ final VoidCallback onModelTap;
21
22
@override
15
- Widget build(BuildContext context) {
23
+ Widget build(BuildContext context, WidgetRef ref) {
24
final l10n = AppLocalizations.of(context)!;
25
final theme = Theme.of(context);
26
final isDark = theme.brightness == Brightness.dark;
27
+ final activeServer = ref.watch(activeServerProvider);
28
+ final connectionStatus = ref.watch(connectionStatusProvider);
29
+ final mutedColor = isDark ? Colors.white54 : Colors.black45;
30
+ final textColor = isDark ? Colors.white70 : Colors.black87;
31
32
return Container(
33
width: double.infinity,
34
color: theme.colorScheme.surface,
35
child: SafeArea(
36
bottom: false,
25
- child: GestureDetector(
26
- onTap: onTap,
27
- behavior: HitTestBehavior.opaque,
28
- child: Padding(
29
- padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
30
- child: Row(
31
- mainAxisAlignment: MainAxisAlignment.center,
32
- mainAxisSize: MainAxisSize.min,
33
- children: [
34
- Flexible(
35
- child: Text(
36
- selectedModel?.displayName ?? l10n.select_model,
37
- style: TextStyle(
38
- fontSize: 13,
39
- fontWeight: FontWeight.w500,
40
- color: isDark ? Colors.white70 : Colors.black87,
37
+ child: Padding(
38
+ padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
39
+ child: Row(
40
+ children: [
41
+ if (activeServer != null) ...[
42
+ InkWell(
43
+ onTap: () => showServerPickerSheet(context, ref),
44
+ borderRadius: BorderRadius.circular(6),
45
+ child: Padding(
46
+ padding: const EdgeInsets.symmetric(
47
+ horizontal: 4,
48
+ vertical: 2,
49
+ ),
50
+ child: Row(
51
+ mainAxisSize: MainAxisSize.min,
52
+ children: [
53
+ _ServerGlyph(
54
+ server: activeServer,
55
+ connectionStatus: connectionStatus,
56
+ isDark: isDark,
57
+ ),
58
+ const SizedBox(width: 4),
59
+ ConstrainedBox(
60
+ constraints: BoxConstraints(
61
+ maxWidth: MediaQuery.sizeOf(context).width * 0.28,
62
+ ),
63
+ child: Text(
64
+ activeServer.name,
65
+ style: TextStyle(
66
+ fontSize: 11,
67
+ fontWeight: FontWeight.w500,
68
+ color: textColor,
69
+ ),
70
+ maxLines: 1,
71
+ overflow: TextOverflow.ellipsis,
72
+ ),
73
+ ),
74
+ ],
75
),
42
- maxLines: 2,
43
- textAlign: TextAlign.center,
44
- overflow: TextOverflow.ellipsis,
76
),
77
),
47
- const SizedBox(width: 4),
48
- Icon(
49
- Icons.expand_more,
50
- size: 14,
51
- color: isDark ? Colors.white38 : Colors.black38,
78
+ Padding(
79
+ padding: const EdgeInsets.symmetric(horizontal: 6),
80
+ child: Text('·', style: TextStyle(color: mutedColor, fontSize: 12)),
81
),
82
],
54
- ),
83
+ Expanded(
84
+ child: InkWell(
85
+ onTap: onModelTap,
86
+ borderRadius: BorderRadius.circular(6),
87
+ child: Padding(
88
+ padding: const EdgeInsets.symmetric(
89
+ horizontal: 4,
90
+ vertical: 2,
91
+ ),
92
+ child: Row(
93
+ children: [
94
+ Expanded(
95
+ child: Text(
96
+ selectedModel?.displayName ?? l10n.select_model,
97
+ style: TextStyle(
98
+ fontSize: 11,
99
+ fontWeight: FontWeight.w500,
100
+ color: textColor,
101
+ ),
102
+ maxLines: 1,
103
+ overflow: TextOverflow.ellipsis,
104
+ ),
105
+ ),
106
+ Icon(Icons.expand_more, size: 14, color: mutedColor),
107
+ ],
108
+ ),
109
+ ),
110
+ ),
111
+ ),
112
+ ],
113
),
114
),
115
),
116
);
117
}
118
}
119
+
120
+class _ServerGlyph extends StatelessWidget {
121
+ const _ServerGlyph({
122
+ required this.server,
123
+ required this.connectionStatus,
124
+ required this.isDark,
125
+ });
126
+
127
+ final Server server;
128
+ final ConnectionStatus connectionStatus;
129
+ final bool isDark;
130
+
131
+ @override
132
+ Widget build(BuildContext context) {
133
+ final iconName = server.iconName;
134
+ final serverIcon = iconName != null
135
+ ? (getHugeIconByName(iconName)?.icon ??
136
+ getDefaultServerIcon(server.type.name)?.icon)
137
+ : getDefaultServerIcon(server.type.name)?.icon;
138
+
139
+ final statusColor = switch (connectionStatus) {
140
+ ConnectionStatus.connected => Colors.green,
141
+ ConnectionStatus.error => Colors.red,
142
+ ConnectionStatus.checking => Colors.orange,
143
+ ConnectionStatus.disconnected => Colors.grey,
144
+ };
145
+
146
+ return Stack(
147
+ clipBehavior: Clip.none,
148
+ children: [
149
+ HugeIcon(
150
+ icon: serverIcon ?? HugeIcons.strokeRoundedServerStack01,
151
+ size: 14,
152
+ color: isDark ? Colors.white70 : Colors.black87,
153
+ ),
154
+ Positioned(
155
+ right: -2,
156
+ bottom: -2,
157
+ child: Container(
158
+ width: 6,
159
+ height: 6,
160
+ decoration: BoxDecoration(
161
+ color: statusColor,
162
+ shape: BoxShape.circle,
163
+ border: Border.all(
164
+ color: Theme.of(context).colorScheme.surface,
165
+ width: 1,
166
+ ),
167
+ ),
168
+ ),
169
+ ),
170
+ ],
171
+ );
172
+ }
173
+}
lib/features/conversations/data/models/conversation.dart
+12
-1
@@ -18,6 +18,9 @@ class Conversation {
18
final List<String>? smartReplies;
19
final String? smartRepliesLastMessageId;
20
final String? folderId;
21
+ final bool isTemporary;
22
+ final bool isArchived;
23
+ final int characterCount;
24
25
Conversation({
26
required this.id,
@@ -39,6 +42,9 @@ class Conversation {
42
this.smartReplies,
43
this.smartRepliesLastMessageId,
44
this.folderId,
45
+ this.isTemporary = false,
46
+ this.isArchived = false,
47
+ this.characterCount = 0,
48
});
49
50
Conversation copyWith({
@@ -71,6 +77,9 @@ class Conversation {
77
bool clearSmartRepliesLastMessageId = false,
78
String? folderId,
79
bool clearFolderId = false,
80
+ bool? isTemporary,
81
+ bool? isArchived,
82
+ int? characterCount,
83
}) {
84
return Conversation(
85
id: id ?? this.id,
@@ -96,7 +105,9 @@ class Conversation {
105
? null
106
: (smartRepliesLastMessageId ?? this.smartRepliesLastMessageId),
107
folderId: clearFolderId ? null : (folderId ?? this.folderId),
108
+ isTemporary: isTemporary ?? this.isTemporary,
109
+ isArchived: isArchived ?? this.isArchived,
110
+ characterCount: characterCount ?? this.characterCount,
111
);
112
}
101
-
113
}
lib/features/conversations/providers/conversation_providers.dart
+102
-2
@@ -49,6 +49,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
49
String? serverId,
50
String? modelId,
51
bool? mcpEnabled,
52
+ bool isTemporary = false,
53
}) async {
54
final db = ref.read(databaseProvider);
55
final now = DateTime.now();
@@ -67,6 +68,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
68
lastMessagePreview: null,
69
systemPrompt: systemPrompt,
70
mcpEnabled: mcpEnabled,
71
+ isTemporary: isTemporary,
72
);
73
74
db.conversationBox.put(ConversationEntity.fromDomain(conversation));
@@ -140,6 +142,45 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
142
}
143
}
144
145
+ Future<void> setTemporary(String id, bool isTemporary) async {
146
+ final db = ref.read(databaseProvider);
147
+ final conversations = state.value ?? [];
148
+ final existing = conversations.firstWhere(
149
+ (c) => c.id == id,
150
+ orElse: () => throw Exception('Conversation not found in state'),
151
+ );
152
+
153
+ final updated = existing.copyWith(
154
+ isTemporary: isTemporary,
155
+ updatedAt: DateTime.now(),
156
+ );
157
+
158
+ final query = db.conversationBox
159
+ .query(ConversationEntity_.id.equals(id))
160
+ .build();
161
+ final existingEntity = query.findFirst();
162
+ query.close();
163
+
164
+ final entity = ConversationEntity.fromDomain(updated);
165
+ if (existingEntity != null) {
166
+ entity.internalId = existingEntity.internalId;
167
+ }
168
+ db.conversationBox.put(entity);
169
+
170
+ state = AsyncData(await _loadAll());
171
+
172
+ final activeId = ref.read(activeConversationProvider)?.id;
173
+ if (activeId == id) {
174
+ final refreshed = state.value?.firstWhere(
175
+ (c) => c.id == id,
176
+ orElse: () => updated,
177
+ );
178
+ ref
179
+ .read(activeConversationProvider.notifier)
180
+ .setActiveConversation(refreshed);
181
+ }
182
+ }
183
+
184
Future<void> deleteConversation(String id) async {
185
final db = ref.read(databaseProvider);
186
@@ -188,11 +229,41 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
229
state = AsyncData(await _loadAll());
230
}
231
232
+ Future<void> setArchived(String id, bool archived) async {
233
+ final db = ref.read(databaseProvider);
234
+ final conversations = state.value ?? [];
235
+ final existing = conversations.firstWhere(
236
+ (c) => c.id == id,
237
+ orElse: () => throw Exception('Conversation not found in state'),
238
+ );
239
+
240
+ final updated = existing.copyWith(
241
+ isArchived: archived,
242
+ updatedAt: DateTime.now(),
243
+ );
244
+
245
+ final query = db.conversationBox
246
+ .query(ConversationEntity_.id.equals(id))
247
+ .build();
248
+ final existingEntity = query.findFirst();
249
+ query.close();
250
+
251
+ final entity = ConversationEntity.fromDomain(updated);
252
+ if (existingEntity != null) {
253
+ entity.internalId = existingEntity.internalId;
254
+ }
255
+ db.conversationBox.put(entity);
256
+
257
+ state = AsyncData(await _loadAll());
258
+ }
259
+
260
Future<void> updatePreview(
261
String id,
262
String preview,
263
DateTime updatedAt, {
264
int? messageCount,
265
+ int? characterCountDelta,
266
+ int? characterCount,
267
}) async {
268
final db = ref.read(databaseProvider);
269
final conversations = state.value ?? [];
@@ -203,6 +274,10 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
274
lastMessagePreview: preview,
275
updatedAt: updatedAt,
276
messageCount: messageCount ?? existing.messageCount + 1,
277
+ characterCount: characterCount ??
278
+ (characterCountDelta != null
279
+ ? existing.characterCount + characterCountDelta
280
+ : existing.characterCount),
281
);
282
283
final query = db.conversationBox
@@ -459,9 +534,20 @@ final filteredConversationsProvider = Provider<AsyncValue<List<Conversation>>>((
534
final conversationsAsync = ref.watch(conversationsProvider);
535
final query = ref.watch(conversationSearchProvider).toLowerCase();
536
final folderFilter = ref.watch(historyFolderFilterProvider);
537
+ final listFilter = ref.watch(historyListFilterProvider);
538
539
return conversationsAsync.whenData((conversations) {
464
- var filtered = conversations;
540
+ var filtered = conversations.where((c) => !c.isTemporary).toList();
541
+
542
+ switch (listFilter) {
543
+ case HistoryListFilter.archived:
544
+ filtered = filtered.where((c) => c.isArchived).toList();
545
+ case HistoryListFilter.pinned:
546
+ filtered = filtered.where((c) => c.isPinned && !c.isArchived).toList();
547
+ case HistoryListFilter.all:
548
+ filtered = filtered.where((c) => !c.isArchived).toList();
549
+ }
550
+
551
if (folderFilter != null) {
552
if (folderFilter.isEmpty) {
553
filtered = filtered
@@ -482,7 +568,7 @@ final filteredConversationsProvider = Provider<AsyncValue<List<Conversation>>>((
568
final recentConversationsProvider = Provider<List<Conversation>>((ref) {
569
final allAsync = ref.watch(conversationsProvider);
570
final all = allAsync.value ?? [];
485
- return all.take(3).toList();
571
+ return all.where((c) => !c.isTemporary).take(3).toList();
572
});
573
574
final groupedConversationsProvider =
@@ -532,6 +618,20 @@ final historyFolderFilterProvider =
618
return HistoryFolderFilterNotifier();
619
});
620
621
+enum HistoryListFilter { all, pinned, archived }
622
+
623
+final historyListFilterProvider =
624
+ NotifierProvider<HistoryListFilterNotifier, HistoryListFilter>(() {
625
+ return HistoryListFilterNotifier();
626
+ });
627
+
628
+class HistoryListFilterNotifier extends Notifier<HistoryListFilter> {
629
+ @override
630
+ HistoryListFilter build() => HistoryListFilter.all;
631
+
632
+ void setFilter(HistoryListFilter filter) => state = filter;
633
+}
634
+
635
class HistoryFolderFilterNotifier extends Notifier<String?> {
636
@override
637
String? build() => null;
lib/features/conversations/views/chat_history_screen.dart
+25
@@ -1,5 +1,6 @@
1
import 'package:flutter/material.dart';
2
import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import 'package:localmind/core/components/list_filter_button.dart';
4
import 'package:localmind/l10n/app_localizations.dart';
5
import '../providers/conversation_providers.dart';
6
import 'components/conversation_folder_bar.dart';
@@ -60,6 +61,30 @@ class ChatHistoryScreen extends ConsumerWidget {
61
),
62
),
63
const Spacer(),
64
+ ListFilterButton<HistoryListFilter>(
65
+ tooltip: l10n.filter_title,
66
+ selected: ref.watch(historyListFilterProvider),
67
+ onChanged: (filter) => ref
68
+ .read(historyListFilterProvider.notifier)
69
+ .setFilter(filter),
70
+ options: [
71
+ ListFilterOption(
72
+ value: HistoryListFilter.all,
73
+ label: l10n.all_chats,
74
+ icon: Icons.view_list_rounded,
75
+ ),
76
+ ListFilterOption(
77
+ value: HistoryListFilter.pinned,
78
+ label: l10n.filter_pinned,
79
+ icon: Icons.push_pin_outlined,
80
+ ),
81
+ ListFilterOption(
82
+ value: HistoryListFilter.archived,
83
+ label: l10n.filter_archived,
84
+ icon: Icons.archive_outlined,
85
+ ),
86
+ ],
87
+ ),
88
],
89
),
90
),
lib/features/conversations/views/components/conversation_folder_bar.dart
+10
-122
@@ -1,5 +1,6 @@
1
import 'package:flutter/material.dart';
2
import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import 'package:localmind/core/components/folder_filter_bar.dart';
4
import 'package:localmind/l10n/app_localizations.dart';
5
import '../../providers/conversation_providers.dart';
6
@@ -37,134 +38,21 @@ class ConversationFolderBar extends ConsumerWidget {
38
39
@override
40
Widget build(BuildContext context, WidgetRef ref) {
40
- final l10n = AppLocalizations.of(context)!;
41
final foldersAsync = ref.watch(conversationFoldersProvider);
42
final selected = ref.watch(historyFolderFilterProvider);
43
- final theme = Theme.of(context);
44
- final isDark = theme.brightness == Brightness.dark;
43
44
return foldersAsync.when(
47
- data: (folders) {
48
- return SingleChildScrollView(
49
- scrollDirection: Axis.horizontal,
50
- padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
51
- child: Row(
52
- children: [
53
- _SystemFilterChip(
54
- label: l10n.all_chats,
55
- icon: Icons.all_inbox_outlined,
56
- selected: selected == null,
57
- isDark: isDark,
58
- onSelected: (_) => ref
59
- .read(historyFolderFilterProvider.notifier)
60
- .setFilter(null),
61
- ),
62
- const SizedBox(width: 6),
63
- _SystemFilterChip(
64
- label: l10n.unfiled_chats,
65
- icon: Icons.inbox_outlined,
66
- selected: selected != null && selected.isEmpty,
67
- isDark: isDark,
68
- outlined: true,
69
- onSelected: (_) => ref
70
- .read(historyFolderFilterProvider.notifier)
71
- .setFilter(''),
72
- ),
73
- ...folders.map(
74
- (folder) => Padding(
75
- padding: const EdgeInsets.only(left: 6),
76
- child: FilterChip(
77
- avatar: Icon(
78
- Icons.folder_outlined,
79
- size: 16,
80
- color: selected == folder.id
81
- ? theme.colorScheme.onSecondaryContainer
82
- : theme.colorScheme.primary,
83
- ),
84
- label: Text(folder.name),
85
- selected: selected == folder.id,
86
- showCheckmark: false,
87
- onSelected: (_) => ref
88
- .read(historyFolderFilterProvider.notifier)
89
- .setFilter(folder.id),
90
- ),
91
- ),
92
- ),
93
- const SizedBox(width: 6),
94
- ActionChip(
95
- avatar: Icon(
96
- Icons.create_new_folder_outlined,
97
- size: 16,
98
- color: theme.colorScheme.primary,
99
- ),
100
- label: Text(l10n.new_folder),
101
- side: BorderSide(
102
- color: theme.colorScheme.primary.withValues(alpha: 0.5),
103
- style: BorderStyle.solid,
104
- ),
105
- onPressed: () => _createFolder(context, ref),
106
- ),
107
- ],
108
- ),
109
- );
110
- },
45
+ data: (folders) => FolderFilterBar(
46
+ folders: folders
47
+ .map((f) => FolderFilterItem(id: f.id, name: f.name))
48
+ .toList(),
49
+ selectedFolderId: selected,
50
+ onFilterChanged: (id) =>
51
+ ref.read(historyFolderFilterProvider.notifier).setFilter(id),
52
+ onCreateFolder: () => _createFolder(context, ref),
53
+ ),
54
loading: () => const SizedBox(height: 44),
55
error: (_, __) => const SizedBox.shrink(),
56
);
57
}
58
}
116
-
117
-class _SystemFilterChip extends StatelessWidget {
118
- const _SystemFilterChip({
119
- required this.label,
120
- required this.icon,
121
- required this.selected,
122
- required this.isDark,
123
- required this.onSelected,
124
- this.outlined = false,
125
- });
126
-
127
- final String label;
128
- final IconData icon;
129
- final bool selected;
130
- final bool isDark;
131
- final bool outlined;
132
- final ValueChanged<bool> onSelected;
133
-
134
- @override
135
- Widget build(BuildContext context) {
136
- final theme = Theme.of(context);
137
- final bgColor = selected
138
- ? (outlined
139
- ? theme.colorScheme.surfaceContainerHighest
140
- : theme.colorScheme.secondaryContainer)
141
- : Colors.transparent;
142
- final borderColor = outlined
143
- ? (isDark ? const Color(0xFF444444) : const Color(0xFFCCCCCC))
144
- : (selected
145
- ? theme.colorScheme.secondaryContainer
146
- : (isDark ? const Color(0xFF333333) : const Color(0xFFE0E0E0)));
147
-
148
- return FilterChip(
149
- avatar: Icon(
150
- icon,
151
- size: 16,
152
- color: selected
153
- ? theme.colorScheme.onSecondaryContainer
154
- : theme.colorScheme.onSurfaceVariant,
155
- ),
156
- label: Text(
157
- label,
158
- style: TextStyle(
159
- fontWeight: outlined ? FontWeight.normal : FontWeight.w600,
160
- fontStyle: outlined ? FontStyle.italic : FontStyle.normal,
161
- ),
162
- ),
163
- selected: selected,
164
- showCheckmark: false,
165
- backgroundColor: bgColor,
166
- side: BorderSide(color: borderColor),
167
- onSelected: onSelected,
168
- );
169
- }
170
-}
lib/features/conversations/views/components/conversation_list.dart
+9
-37
@@ -12,8 +12,9 @@ import '../../../../core/routes/app_routes.dart';
12
import '../../../chat/providers/chat_providers.dart';
13
import '../../data/models/conversation.dart';
14
import '../../providers/conversation_providers.dart';
15
-import 'conversation_tile.dart';
15
+import 'rename_conversation_dialog.dart';
16
import 'date_section_header.dart';
17
+import 'conversation_tile.dart';
18
19
class ConversationList extends ConsumerWidget {
20
const ConversationList({
@@ -96,6 +97,12 @@ class ConversationList extends ConsumerWidget {
97
onExport: () {
98
_exportConversation(context, ref, l10n, conversation);
99
},
100
+ onArchive: () {
101
+ ref.read(conversationsProvider.notifier).setArchived(
102
+ conversation.id,
103
+ !conversation.isArchived,
104
+ );
105
+ },
106
);
107
}),
108
],
@@ -110,42 +117,7 @@ class ConversationList extends ConsumerWidget {
117
AppLocalizations l10n,
118
Conversation conversation,
119
) {
113
- final controller = TextEditingController(text: conversation.title);
114
-
115
- showDialog(
116
- context: context,
117
- builder: (context) {
118
- return AlertDialog(
119
- title: Text(l10n.rename_conversation),
120
- content: TextField(
121
- controller: controller,
122
- autofocus: true,
123
- decoration: InputDecoration(
124
- hintText: l10n.enter_new_title,
125
- border: const OutlineInputBorder(),
126
- ),
127
- ),
128
- actions: [
129
- TextButton(
130
- onPressed: () => Navigator.pop(context),
131
- child: Text(l10n.cancel),
132
- ),
133
- TextButton(
134
- onPressed: () {
135
- final newTitle = controller.text.trim();
136
- if (newTitle.isNotEmpty) {
137
- ref
138
- .read(conversationsProvider.notifier)
139
- .renameConversation(conversation.id, newTitle);
140
- }
141
- Navigator.pop(context);
142
- },
143
- child: Text(l10n.rename),
144
- ),
145
- ],
146
- );
147
- },
148
- );
120
+ showRenameConversationDialog(context, ref, conversation: conversation);
121
}
122
123
void _showDeleteConfirmation(
lib/features/conversations/views/components/conversation_tile.dart
+28
@@ -16,6 +16,7 @@ class ConversationTile extends StatelessWidget {
16
required this.onDuplicate,
17
required this.onMoveToFolder,
18
required this.onExport,
19
+ required this.onArchive,
20
});
21
22
final Conversation conversation;
@@ -27,6 +28,7 @@ class ConversationTile extends StatelessWidget {
28
final VoidCallback onDuplicate;
29
final VoidCallback onMoveToFolder;
30
final VoidCallback onExport;
31
+ final VoidCallback onArchive;
32
33
String _formatTimestamp(AppLocalizations l10n, DateTime dateTime) {
34
final now = DateTime.now();
@@ -123,6 +125,16 @@ class ConversationTile extends StatelessWidget {
125
overflow: TextOverflow.ellipsis,
126
),
127
],
128
+ const SizedBox(height: 2),
129
+ Text(
130
+ '${l10n.conversation_message_count(conversation.messageCount)} · ${l10n.conversation_character_count(conversation.characterCount)}',
131
+ style: TextStyle(
132
+ fontSize: 11,
133
+ color: isDark
134
+ ? AppColors.darkMutedText
135
+ : AppColors.lightMutedText,
136
+ ),
137
+ ),
138
],
139
),
140
),
@@ -211,6 +223,22 @@ class ConversationTile extends StatelessWidget {
223
onExport();
224
},
225
),
226
+ ListTile(
227
+ leading: Icon(
228
+ conversation.isArchived
229
+ ? Icons.unarchive_outlined
230
+ : Icons.archive_outlined,
231
+ ),
232
+ title: Text(
233
+ conversation.isArchived
234
+ ? l10n.unarchive_chat
235
+ : l10n.archive_chat,
236
+ ),
237
+ onTap: () {
238
+ Navigator.pop(ctx);
239
+ onArchive();
240
+ },
241
+ ),
242
ListTile(
243
leading: const Icon(Icons.delete_outline, color: Colors.red),
244
title: Text(
lib/features/conversations/views/components/rename_conversation_dialog.dart
new
+106
@@ -0,0 +1,106 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import 'package:localmind/l10n/app_localizations.dart';
4
+
5
+import '../../../conversations/data/models/conversation.dart';
6
+import '../../../conversations/providers/conversation_providers.dart';
7
+import '../../../chat/providers/chat_providers.dart';
8
+
9
+Future<void> showRenameConversationDialog(
10
+ BuildContext context,
11
+ WidgetRef ref, {
12
+ required Conversation conversation,
13
+}) async {
14
+ final l10n = AppLocalizations.of(context)!;
15
+ final controller = TextEditingController(text: conversation.title);
16
+ var isGenerating = false;
17
+
18
+ await showDialog<void>(
19
+ context: context,
20
+ builder: (dialogContext) {
21
+ return StatefulBuilder(
22
+ builder: (context, setState) {
23
+ Future<void> generateTitle() async {
24
+ setState(() => isGenerating = true);
25
+ try {
26
+ final title = await ref
27
+ .read(chatProvider.notifier)
28
+ .generateTitleWithAi(conversation.id);
29
+ if (!context.mounted) return;
30
+ if (title != null && title.isNotEmpty) {
31
+ controller.text = title;
32
+ } else {
33
+ ScaffoldMessenger.of(context).showSnackBar(
34
+ SnackBar(content: Text(l10n.generate_title_failed)),
35
+ );
36
+ }
37
+ } finally {
38
+ if (context.mounted) {
39
+ setState(() => isGenerating = false);
40
+ }
41
+ }
42
+ }
43
+
44
+ return AlertDialog(
45
+ title: Text(l10n.rename_conversation),
46
+ content: Column(
47
+ mainAxisSize: MainAxisSize.min,
48
+ crossAxisAlignment: CrossAxisAlignment.stretch,
49
+ children: [
50
+ TextField(
51
+ controller: controller,
52
+ autofocus: true,
53
+ enabled: !isGenerating,
54
+ decoration: InputDecoration(
55
+ hintText: l10n.enter_new_title,
56
+ border: const OutlineInputBorder(),
57
+ ),
58
+ ),
59
+ const SizedBox(height: 12),
60
+ OutlinedButton.icon(
61
+ onPressed: isGenerating ? null : generateTitle,
62
+ icon: isGenerating
63
+ ? const SizedBox(
64
+ width: 16,
65
+ height: 16,
66
+ child: CircularProgressIndicator(strokeWidth: 2),
67
+ )
68
+ : const Icon(Icons.auto_awesome_outlined, size: 18),
69
+ label: Text(
70
+ isGenerating
71
+ ? l10n.generating_title
72
+ : l10n.generate_title_with_ai,
73
+ ),
74
+ ),
75
+ ],
76
+ ),
77
+ actions: [
78
+ TextButton(
79
+ onPressed: isGenerating
80
+ ? null
81
+ : () => Navigator.pop(dialogContext),
82
+ child: Text(l10n.cancel),
83
+ ),
84
+ TextButton(
85
+ onPressed: isGenerating
86
+ ? null
87
+ : () {
88
+ final newTitle = controller.text.trim();
89
+ if (newTitle.isNotEmpty) {
90
+ ref
91
+ .read(conversationsProvider.notifier)
92
+ .renameConversation(conversation.id, newTitle);
93
+ }
94
+ Navigator.pop(dialogContext);
95
+ },
96
+ child: Text(l10n.rename),
97
+ ),
98
+ ],
99
+ );
100
+ },
101
+ );
102
+ },
103
+ );
104
+
105
+ controller.dispose();
106
+}
lib/features/onboarding/screens/onboarding_language_screen.dart
+10
@@ -112,6 +112,16 @@ const _languages = [
112
shortText: 'Ja',
113
gradient: [Color(0xFFF43F5E), Color(0xFFBE123C)],
114
),
115
+ _LanguageOption(
116
+ code: 'ru',
117
+ nativeName: 'Русский',
118
+ englishName: 'Russian',
119
+ flag: '🇷🇺',
120
+ flagAsset: 'assets/images/flag_ru.png',
121
+ countryCode: 'RU',
122
+ shortText: 'Ru',
123
+ gradient: [Color(0xFF3B82F6), Color(0xFF1D4ED8)],
124
+ ),
125
];
126
127
class OnboardingLanguageScreen extends ConsumerStatefulWidget {
lib/features/saved_messages/providers/saved_message_providers.dart
+72
-9
@@ -12,6 +12,20 @@ final savedMessageFolderFilterProvider =
12
return SavedMessageFolderFilterNotifier();
13
});
14
15
+enum SavedMessageListFilter { all, tempChats, user, assistant }
16
+
17
+final savedMessageListFilterProvider =
18
+ NotifierProvider<SavedMessageListFilterNotifier, SavedMessageListFilter>(() {
19
+ return SavedMessageListFilterNotifier();
20
+ });
21
+
22
+class SavedMessageListFilterNotifier extends Notifier<SavedMessageListFilter> {
23
+ @override
24
+ SavedMessageListFilter build() => SavedMessageListFilter.all;
25
+
26
+ void setFilter(SavedMessageListFilter filter) => state = filter;
27
+}
28
+
29
class SavedMessageFolderFilterNotifier extends Notifier<String?> {
30
@override
31
String? build() => null;
@@ -129,26 +143,59 @@ class SavedMessagesNotifier extends AsyncNotifier<List<SavedMessage>> {
143
return exists;
144
}
145
132
- Future<void> saveMessage(Message message) async {
146
+ Future<SavedMessage?> getBySourceMessageId(String sourceMessageId) async {
147
+ final db = ref.read(databaseProvider);
148
+ final query = db.savedMessageBox
149
+ .query(SavedMessageEntity_.sourceMessageId.equals(sourceMessageId))
150
+ .build();
151
+ final entity = query.findFirst();
152
+ query.close();
153
+ if (entity == null) return null;
154
+ return SavedMessage(
155
+ id: entity.id,
156
+ sourceMessageId: entity.sourceMessageId,
157
+ conversationId: entity.conversationId,
158
+ conversationTitle: entity.conversationTitle,
159
+ roleIndex: entity.roleIndex,
160
+ content: entity.content,
161
+ modelId: entity.modelId,
162
+ folderId: entity.folderId,
163
+ savedAt: entity.savedAt,
164
+ );
165
+ }
166
+
167
+ Future<void> saveMessage(
168
+ Message message, {
169
+ String? folderId,
170
+ bool isTemporaryChat = false,
171
+ }) async {
172
final db = ref.read(databaseProvider);
173
final existingQuery = db.savedMessageBox
174
.query(SavedMessageEntity_.sourceMessageId.equals(message.id))
175
.build();
137
- if (existingQuery.findFirst() != null) {
138
- existingQuery.close();
176
+ final existing = existingQuery.findFirst();
177
+ existingQuery.close();
178
+
179
+ if (existing != null) {
180
+ existing.folderId = folderId;
181
+ existing.content = message.content;
182
+ db.savedMessageBox.put(existing);
183
+ state = AsyncData(await _loadAll());
184
return;
185
}
141
- existingQuery.close();
186
187
final conversation = ref.read(activeConversationProvider);
188
final entity = SavedMessageEntity(
189
id: DateTime.now().millisecondsSinceEpoch.toString(),
190
sourceMessageId: message.id,
147
- conversationId: message.conversationId,
148
- conversationTitle: conversation?.title ?? 'Chat',
191
+ conversationId: isTemporaryChat ? '' : message.conversationId,
192
+ conversationTitle: isTemporaryChat
193
+ ? ''
194
+ : (conversation?.title ?? 'Chat'),
195
roleIndex: message.role.index,
196
content: message.content,
197
modelId: message.modelId,
198
+ folderId: folderId,
199
savedAt: DateTime.now(),
200
);
201
db.savedMessageBox.put(entity);
@@ -193,15 +240,31 @@ final filteredSavedMessagesProvider =
240
Provider<AsyncValue<List<SavedMessage>>>((ref) {
241
final messagesAsync = ref.watch(savedMessagesProvider);
242
final folderFilter = ref.watch(savedMessageFolderFilterProvider);
243
+ final listFilter = ref.watch(savedMessageListFilterProvider);
244
245
return messagesAsync.whenData((messages) {
198
- if (folderFilter == null) return messages;
246
+ var filtered = messages;
247
+
248
+ switch (listFilter) {
249
+ case SavedMessageListFilter.tempChats:
250
+ filtered = filtered
251
+ .where((m) => m.conversationId.isEmpty)
252
+ .toList();
253
+ case SavedMessageListFilter.user:
254
+ filtered = filtered.where((m) => m.roleIndex == 0).toList();
255
+ case SavedMessageListFilter.assistant:
256
+ filtered = filtered.where((m) => m.roleIndex == 1).toList();
257
+ case SavedMessageListFilter.all:
258
+ break;
259
+ }
260
+
261
+ if (folderFilter == null) return filtered;
262
if (folderFilter.isEmpty) {
200
- return messages
263
+ return filtered
264
.where((m) => m.folderId == null || m.folderId!.isEmpty)
265
.toList();
266
}
204
- return messages.where((m) => m.folderId == folderFilter).toList();
267
+ return filtered.where((m) => m.folderId == folderFilter).toList();
268
});
269
});
270
lib/features/saved_messages/views/components/save_message_sheet.dart
new
+98
@@ -0,0 +1,98 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import 'package:shadcn_ui/shadcn_ui.dart';
4
+import 'package:localmind/features/chat/data/models/message.dart';
5
+import 'package:localmind/l10n/app_localizations.dart';
6
+import '../../providers/saved_message_providers.dart';
7
+
8
+Future<void> showSaveMessageSheet(
9
+ BuildContext context,
10
+ WidgetRef ref,
11
+ Message message, {
12
+ bool isTemporaryChat = false,
13
+}) async {
14
+ final l10n = AppLocalizations.of(context)!;
15
+ final foldersAsync = ref.read(savedMessageFoldersProvider);
16
+ final folders = foldersAsync.value ?? [];
17
+ final existing = await ref
18
+ .read(savedMessagesProvider.notifier)
19
+ .getBySourceMessageId(message.id);
20
+ String? selectedFolderId = existing?.folderId;
21
+
22
+ if (!context.mounted) return;
23
+
24
+ await showShadSheet(
25
+ context: context,
26
+ builder: (ctx) => StatefulBuilder(
27
+ builder: (context, setState) {
28
+ return ShadSheet(
29
+ title: Text(l10n.save_message_folders),
30
+ child: Column(
31
+ mainAxisSize: MainAxisSize.min,
32
+ crossAxisAlignment: CrossAxisAlignment.stretch,
33
+ children: [
34
+ if (existing != null)
35
+ Padding(
36
+ padding: const EdgeInsets.only(bottom: 12),
37
+ child: Text(
38
+ l10n.message_already_saved,
39
+ style: TextStyle(
40
+ color: Theme.of(context).colorScheme.primary,
41
+ fontWeight: FontWeight.w600,
42
+ ),
43
+ ),
44
+ ),
45
+ ListTile(
46
+ leading: Icon(
47
+ selectedFolderId == null
48
+ ? Icons.radio_button_checked
49
+ : Icons.radio_button_off,
50
+ ),
51
+ title: Text(l10n.unfiled_chats),
52
+ onTap: () => setState(() => selectedFolderId = null),
53
+ ),
54
+ ...folders.map(
55
+ (folder) => ListTile(
56
+ leading: Icon(
57
+ selectedFolderId == folder.id
58
+ ? Icons.radio_button_checked
59
+ : Icons.radio_button_off,
60
+ ),
61
+ title: Text(folder.name),
62
+ onTap: () => setState(() => selectedFolderId = folder.id),
63
+ ),
64
+ ),
65
+ const SizedBox(height: 8),
66
+ if (existing != null)
67
+ OutlinedButton.icon(
68
+ onPressed: () async {
69
+ await ref
70
+ .read(savedMessagesProvider.notifier)
71
+ .removeBySourceMessageId(message.id);
72
+ ref.invalidate(isMessageSavedProvider(message.id));
73
+ if (context.mounted) Navigator.pop(context);
74
+ },
75
+ icon: const Icon(Icons.bookmark_remove_outlined),
76
+ label: Text(l10n.remove_from_saved),
77
+ ),
78
+ const SizedBox(height: 8),
79
+ ShadButton(
80
+ width: double.infinity,
81
+ onPressed: () async {
82
+ await ref.read(savedMessagesProvider.notifier).saveMessage(
83
+ message,
84
+ folderId: selectedFolderId,
85
+ isTemporaryChat: isTemporaryChat,
86
+ );
87
+ ref.invalidate(isMessageSavedProvider(message.id));
88
+ if (context.mounted) Navigator.pop(context);
89
+ },
90
+ child: Text(l10n.save),
91
+ ),
92
+ ],
93
+ ),
94
+ );
95
+ },
96
+ ),
97
+ );
98
+}
lib/features/saved_messages/views/components/saved_message_folder_bar.dart
+14
-63
@@ -1,10 +1,13 @@
1
import 'package:flutter/material.dart';
2
import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import 'package:localmind/core/components/folder_filter_bar.dart';
4
import 'package:localmind/l10n/app_localizations.dart';
5
import '../../providers/saved_message_providers.dart';
6
7
class SavedMessageFolderBar extends ConsumerWidget {
7
- const SavedMessageFolderBar({super.key});
8
+ const SavedMessageFolderBar({super.key, this.showCreateFolder = true});
9
+
10
+ final bool showCreateFolder;
11
12
Future<void> _createFolder(BuildContext context, WidgetRef ref) async {
13
final l10n = AppLocalizations.of(context)!;
@@ -37,72 +40,20 @@ class SavedMessageFolderBar extends ConsumerWidget {
40
41
@override
42
Widget build(BuildContext context, WidgetRef ref) {
40
- final l10n = AppLocalizations.of(context)!;
43
final foldersAsync = ref.watch(savedMessageFoldersProvider);
44
final selected = ref.watch(savedMessageFolderFilterProvider);
43
- final theme = Theme.of(context);
45
46
return foldersAsync.when(
46
- data: (folders) {
47
- return SingleChildScrollView(
48
- scrollDirection: Axis.horizontal,
49
- padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
50
- child: Row(
51
- children: [
52
- FilterChip(
53
- avatar: const Icon(Icons.all_inbox_outlined, size: 16),
54
- label: Text(l10n.all_chats),
55
- selected: selected == null,
56
- onSelected: (_) => ref
57
- .read(savedMessageFolderFilterProvider.notifier)
58
- .setFilter(null),
59
- ),
60
- const SizedBox(width: 6),
61
- FilterChip(
62
- avatar: const Icon(Icons.inbox_outlined, size: 16),
63
- label: Text(l10n.unfiled_chats),
64
- selected: selected != null && selected.isEmpty,
65
- onSelected: (_) => ref
66
- .read(savedMessageFolderFilterProvider.notifier)
67
- .setFilter(''),
68
- ),
69
- ...folders.map(
70
- (folder) => Padding(
71
- padding: const EdgeInsets.only(left: 6),
72
- child: FilterChip(
73
- avatar: Icon(
74
- Icons.folder_outlined,
75
- size: 16,
76
- color: selected == folder.id
77
- ? theme.colorScheme.onSecondaryContainer
78
- : theme.colorScheme.primary,
79
- ),
80
- label: Text(folder.name),
81
- selected: selected == folder.id,
82
- showCheckmark: false,
83
- onSelected: (_) => ref
84
- .read(savedMessageFolderFilterProvider.notifier)
85
- .setFilter(folder.id),
86
- ),
87
- ),
88
- ),
89
- const SizedBox(width: 6),
90
- ActionChip(
91
- avatar: Icon(
92
- Icons.create_new_folder_outlined,
93
- size: 16,
94
- color: theme.colorScheme.primary,
95
- ),
96
- label: Text(l10n.new_folder),
97
- side: BorderSide(
98
- color: theme.colorScheme.primary.withValues(alpha: 0.5),
99
- ),
100
- onPressed: () => _createFolder(context, ref),
101
- ),
102
- ],
103
- ),
104
- );
105
- },
47
+ data: (folders) => FolderFilterBar(
48
+ folders: folders
49
+ .map((f) => FolderFilterItem(id: f.id, name: f.name))
50
+ .toList(),
51
+ selectedFolderId: selected,
52
+ onFilterChanged: (id) =>
53
+ ref.read(savedMessageFolderFilterProvider.notifier).setFilter(id),
54
+ onCreateFolder: () => _createFolder(context, ref),
55
+ showCreateFolder: showCreateFolder,
56
+ ),
57
loading: () => const SizedBox(height: 44),
58
error: (_, __) => const SizedBox.shrink(),
59
);
lib/features/saved_messages/views/components/saved_message_folder_sheet.dart
new
+49
@@ -0,0 +1,49 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import 'package:localmind/l10n/app_localizations.dart';
4
+import '../../providers/saved_message_providers.dart';
5
+
6
+Future<void> showSavedMessageMoveToFolderSheet(
7
+ BuildContext context,
8
+ WidgetRef ref,
9
+ String savedMessageId,
10
+) async {
11
+ final l10n = AppLocalizations.of(context)!;
12
+ final folders = ref.read(savedMessageFoldersProvider).value ?? [];
13
+
14
+ await showModalBottomSheet<void>(
15
+ context: context,
16
+ builder: (ctx) {
17
+ return SafeArea(
18
+ child: Column(
19
+ mainAxisSize: MainAxisSize.min,
20
+ children: [
21
+ ListTile(
22
+ leading: const Icon(Icons.folder_off_outlined),
23
+ title: Text(l10n.remove_from_folder),
24
+ onTap: () async {
25
+ Navigator.pop(ctx);
26
+ await ref
27
+ .read(savedMessagesProvider.notifier)
28
+ .moveToFolder(savedMessageId, null);
29
+ },
30
+ ),
31
+ ...folders.map(
32
+ (folder) => ListTile(
33
+ leading: const Icon(Icons.folder_outlined),
34
+ title: Text(folder.name),
35
+ onTap: () async {
36
+ Navigator.pop(ctx);
37
+ await ref
38
+ .read(savedMessagesProvider.notifier)
39
+ .moveToFolder(savedMessageId, folder.id);
40
+ },
41
+ ),
42
+ ),
43
+ const SizedBox(height: 8),
44
+ ],
45
+ ),
46
+ );
47
+ },
48
+ );
49
+}
lib/features/saved_messages/views/components/saved_message_picker_sheet.dart
new
+69
@@ -0,0 +1,69 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import 'package:shadcn_ui/shadcn_ui.dart';
4
+import 'package:localmind/l10n/app_localizations.dart';
5
+import '../../providers/saved_message_providers.dart';
6
+import 'saved_message_folder_bar.dart';
7
+import 'saved_message_picker_tile.dart';
8
+
9
+Future<String?> showSavedMessagePickerSheet(BuildContext context) async {
10
+ return showShadSheet<String>(
11
+ context: context,
12
+ builder: (ctx) => const _SavedMessagePickerSheet(),
13
+ );
14
+}
15
+
16
+class _SavedMessagePickerSheet extends ConsumerWidget {
17
+ const _SavedMessagePickerSheet();
18
+
19
+ @override
20
+ Widget build(BuildContext context, WidgetRef ref) {
21
+ final l10n = AppLocalizations.of(context)!;
22
+ final theme = Theme.of(context);
23
+ final isDark = theme.brightness == Brightness.dark;
24
+ final messagesAsync = ref.watch(filteredSavedMessagesProvider);
25
+
26
+ return ShadSheet(
27
+ title: Text(l10n.insert_saved_message),
28
+ description: Text(l10n.insert_saved_message_desc),
29
+ child: SizedBox(
30
+ height: MediaQuery.of(context).size.height * 0.55,
31
+ child: Column(
32
+ crossAxisAlignment: CrossAxisAlignment.stretch,
33
+ children: [
34
+ const SavedMessageFolderBar(showCreateFolder: false),
35
+ Expanded(
36
+ child: messagesAsync.when(
37
+ data: (messages) {
38
+ if (messages.isEmpty) {
39
+ return Center(child: Text(l10n.saved_messages_empty));
40
+ }
41
+ return ListView.separated(
42
+ padding: const EdgeInsets.only(bottom: 8),
43
+ itemCount: messages.length,
44
+ separatorBuilder: (_, __) => Divider(
45
+ height: 1,
46
+ color: isDark
47
+ ? const Color(0xFF2A2A2A)
48
+ : const Color(0xFFE5E5E5),
49
+ ),
50
+ itemBuilder: (context, index) {
51
+ final saved = messages[index];
52
+ return SavedMessagePickerTile(
53
+ saved: saved,
54
+ onTap: () => Navigator.pop(context, saved.content),
55
+ );
56
+ },
57
+ );
58
+ },
59
+ loading: () =>
60
+ const Center(child: CircularProgressIndicator()),
61
+ error: (e, _) => Center(child: Text(e.toString())),
62
+ ),
63
+ ),
64
+ ],
65
+ ),
66
+ ),
67
+ );
68
+ }
69
+}
lib/features/saved_messages/views/components/saved_message_picker_tile.dart
new
+94
@@ -0,0 +1,94 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:localmind/core/models/enums.dart';
3
+import 'package:localmind/core/theme/colors.dart';
4
+import 'package:localmind/l10n/app_localizations.dart';
5
+import '../../data/models/saved_message.dart';
6
+
7
+class SavedMessagePickerTile extends StatelessWidget {
8
+ const SavedMessagePickerTile({
9
+ super.key,
10
+ required this.saved,
11
+ required this.onTap,
12
+ });
13
+
14
+ final SavedMessage saved;
15
+ final VoidCallback onTap;
16
+
17
+ @override
18
+ Widget build(BuildContext context) {
19
+ final l10n = AppLocalizations.of(context)!;
20
+ final theme = Theme.of(context);
21
+ final isDark = theme.brightness == Brightness.dark;
22
+ final role = MessageRole.values[saved.roleIndex];
23
+ final isUser = role == MessageRole.user;
24
+ final isFromTempChat = saved.conversationId.isEmpty;
25
+ final mutedColor =
26
+ isDark ? AppColors.darkMutedText : AppColors.lightMutedText;
27
+
28
+ return Material(
29
+ color: Colors.transparent,
30
+ child: InkWell(
31
+ onTap: onTap,
32
+ child: Container(
33
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
34
+ child: Row(
35
+ crossAxisAlignment: CrossAxisAlignment.start,
36
+ children: [
37
+ Icon(
38
+ isUser
39
+ ? Icons.person_outline
40
+ : Icons.auto_awesome_outlined,
41
+ size: 20,
42
+ color: mutedColor,
43
+ ),
44
+ const SizedBox(width: 12),
45
+ Expanded(
46
+ child: Column(
47
+ crossAxisAlignment: CrossAxisAlignment.start,
48
+ children: [
49
+ if (isFromTempChat)
50
+ Text(
51
+ l10n.temporary_chat,
52
+ style: TextStyle(
53
+ fontSize: 14,
54
+ fontWeight: FontWeight.w500,
55
+ fontStyle: FontStyle.italic,
56
+ color: mutedColor,
57
+ ),
58
+ maxLines: 1,
59
+ overflow: TextOverflow.ellipsis,
60
+ )
61
+ else if (saved.conversationTitle.isNotEmpty)
62
+ Text(
63
+ saved.conversationTitle,
64
+ style: TextStyle(
65
+ fontSize: 14,
66
+ fontWeight: FontWeight.w600,
67
+ color: isDark
68
+ ? AppColors.darkPrimaryText
69
+ : AppColors.lightPrimaryText,
70
+ ),
71
+ maxLines: 1,
72
+ overflow: TextOverflow.ellipsis,
73
+ ),
74
+ if (isFromTempChat || saved.conversationTitle.isNotEmpty)
75
+ const SizedBox(height: 2),
76
+ Text(
77
+ saved.content,
78
+ style: TextStyle(
79
+ fontSize: 13,
80
+ color: mutedColor,
81
+ ),
82
+ maxLines: 3,
83
+ overflow: TextOverflow.ellipsis,
84
+ ),
85
+ ],
86
+ ),
87
+ ),
88
+ ],
89
+ ),
90
+ ),
91
+ ),
92
+ );
93
+ }
94
+}
lib/features/saved_messages/views/components/saved_message_tile.dart
new
+161
@@ -0,0 +1,161 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:hugeicons/hugeicons.dart';
3
+import 'package:localmind/core/theme/colors.dart';
4
+import 'package:localmind/l10n/app_localizations.dart';
5
+import '../../data/models/saved_message.dart';
6
+
7
+class SavedMessageTile extends StatelessWidget {
8
+ const SavedMessageTile({
9
+ super.key,
10
+ required this.saved,
11
+ required this.isUser,
12
+ required this.onTap,
13
+ required this.onCopy,
14
+ required this.onMoveToFolder,
15
+ required this.onDelete,
16
+ });
17
+
18
+ final SavedMessage saved;
19
+ final bool isUser;
20
+ final VoidCallback onTap;
21
+ final VoidCallback onCopy;
22
+ final VoidCallback onMoveToFolder;
23
+ final VoidCallback onDelete;
24
+
25
+ @override
26
+ Widget build(BuildContext context) {
27
+ final l10n = AppLocalizations.of(context)!;
28
+ final theme = Theme.of(context);
29
+ final isDark = theme.brightness == Brightness.dark;
30
+ final isFromTempChat = saved.conversationId.isEmpty;
31
+ final mutedColor =
32
+ isDark ? AppColors.darkMutedText : AppColors.lightMutedText;
33
+
34
+ return Material(
35
+ color: Colors.transparent,
36
+ child: InkWell(
37
+ onTap: onTap,
38
+ onLongPress: () => _showContextMenu(context, l10n, isDark),
39
+ child: Container(
40
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
41
+ child: Row(
42
+ crossAxisAlignment: CrossAxisAlignment.start,
43
+ children: [
44
+ Icon(
45
+ isUser
46
+ ? Icons.person_outline
47
+ : Icons.auto_awesome_outlined,
48
+ size: 20,
49
+ color: mutedColor,
50
+ ),
51
+ const SizedBox(width: 12),
52
+ Expanded(
53
+ child: Column(
54
+ crossAxisAlignment: CrossAxisAlignment.start,
55
+ children: [
56
+ if (isFromTempChat)
57
+ Text(
58
+ l10n.temporary_chat,
59
+ style: TextStyle(
60
+ fontSize: 14,
61
+ fontWeight: FontWeight.w500,
62
+ fontStyle: FontStyle.italic,
63
+ color: mutedColor,
64
+ ),
65
+ maxLines: 1,
66
+ overflow: TextOverflow.ellipsis,
67
+ )
68
+ else if (saved.conversationTitle.isNotEmpty)
69
+ Text(
70
+ saved.conversationTitle,
71
+ style: TextStyle(
72
+ fontSize: 14,
73
+ fontWeight: FontWeight.w600,
74
+ color: isDark
75
+ ? AppColors.darkPrimaryText
76
+ : AppColors.lightPrimaryText,
77
+ ),
78
+ maxLines: 1,
79
+ overflow: TextOverflow.ellipsis,
80
+ ),
81
+ if (isFromTempChat || saved.conversationTitle.isNotEmpty)
82
+ const SizedBox(height: 2),
83
+ Text(
84
+ saved.content,
85
+ style: TextStyle(
86
+ fontSize: 13,
87
+ color: mutedColor,
88
+ ),
89
+ maxLines: 3,
90
+ overflow: TextOverflow.ellipsis,
91
+ ),
92
+ ],
93
+ ),
94
+ ),
95
+ const SizedBox(width: 4),
96
+ IconButton(
97
+ onPressed: () => _showContextMenu(context, l10n, isDark),
98
+ icon: HugeIcon(
99
+ icon: HugeIcons.strokeRoundedMoreVertical,
100
+ size: 18,
101
+ color: mutedColor,
102
+ ),
103
+ padding: EdgeInsets.zero,
104
+ constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
105
+ visualDensity: VisualDensity.compact,
106
+ tooltip: l10n.options_tooltip,
107
+ ),
108
+ ],
109
+ ),
110
+ ),
111
+ ),
112
+ );
113
+ }
114
+
115
+ void _showContextMenu(
116
+ BuildContext context,
117
+ AppLocalizations l10n,
118
+ bool isDark,
119
+ ) {
120
+ showModalBottomSheet(
121
+ context: context,
122
+ builder: (ctx) {
123
+ return SafeArea(
124
+ child: Column(
125
+ mainAxisSize: MainAxisSize.min,
126
+ children: [
127
+ ListTile(
128
+ leading: const Icon(Icons.copy_outlined),
129
+ title: Text(l10n.copy),
130
+ onTap: () {
131
+ Navigator.pop(ctx);
132
+ onCopy();
133
+ },
134
+ ),
135
+ ListTile(
136
+ leading: const Icon(Icons.folder_outlined),
137
+ title: Text(l10n.move_to_folder),
138
+ onTap: () {
139
+ Navigator.pop(ctx);
140
+ onMoveToFolder();
141
+ },
142
+ ),
143
+ ListTile(
144
+ leading: const Icon(Icons.delete_outline, color: Colors.red),
145
+ title: Text(
146
+ l10n.delete,
147
+ style: const TextStyle(color: Colors.red),
148
+ ),
149
+ onTap: () {
150
+ Navigator.pop(ctx);
151
+ onDelete();
152
+ },
153
+ ),
154
+ const SizedBox(height: 8),
155
+ ],
156
+ ),
157
+ );
158
+ },
159
+ );
160
+ }
161
+}
lib/features/saved_messages/views/saved_messages_screen.dart
+90
-101
@@ -4,12 +4,15 @@ 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/core/components/list_filter_button.dart';
8
import 'package:localmind/l10n/app_localizations.dart';
9
import '../../chat/providers/chat_providers.dart';
10
import '../../conversations/data/models/conversation.dart';
11
import '../../conversations/providers/conversation_providers.dart';
12
import '../providers/saved_message_providers.dart';
13
import 'components/saved_message_folder_bar.dart';
14
+import 'components/saved_message_folder_sheet.dart';
15
+import 'components/saved_message_tile.dart';
16
17
class SavedMessagesScreen extends ConsumerWidget {
18
const SavedMessagesScreen({super.key});
@@ -58,6 +61,36 @@ class SavedMessagesScreen extends ConsumerWidget {
61
color: isDark ? Colors.white : Colors.black,
62
),
63
),
64
+ const Spacer(),
65
+ ListFilterButton<SavedMessageListFilter>(
66
+ tooltip: l10n.filter_title,
67
+ selected: ref.watch(savedMessageListFilterProvider),
68
+ onChanged: (filter) => ref
69
+ .read(savedMessageListFilterProvider.notifier)
70
+ .setFilter(filter),
71
+ options: [
72
+ ListFilterOption(
73
+ value: SavedMessageListFilter.all,
74
+ label: l10n.all_chats,
75
+ icon: Icons.view_list_rounded,
76
+ ),
77
+ ListFilterOption(
78
+ value: SavedMessageListFilter.tempChats,
79
+ label: l10n.filter_temp_chats,
80
+ icon: Icons.bolt_outlined,
81
+ ),
82
+ ListFilterOption(
83
+ value: SavedMessageListFilter.user,
84
+ label: l10n.filter_user_messages,
85
+ icon: Icons.person_outline,
86
+ ),
87
+ ListFilterOption(
88
+ value: SavedMessageListFilter.assistant,
89
+ label: l10n.filter_assistant_messages,
90
+ icon: Icons.auto_awesome_outlined,
91
+ ),
92
+ ],
93
+ ),
94
],
95
),
96
),
@@ -76,115 +109,71 @@ class SavedMessagesScreen extends ConsumerWidget {
109
);
110
}
111
return ListView.separated(
79
- padding: const EdgeInsets.all(16),
112
+ padding: const EdgeInsets.only(bottom: 8),
113
itemCount: messages.length,
81
- separatorBuilder: (_, _) => const SizedBox(height: 8),
114
+ separatorBuilder: (_, __) => Divider(
115
+ height: 1,
116
+ color: isDark
117
+ ? const Color(0xFF2A2A2A)
118
+ : const Color(0xFFE5E5E5),
119
+ ),
120
itemBuilder: (context, index) {
121
final saved = messages[index];
122
final role = MessageRole.values[saved.roleIndex];
85
- return Card(
86
- child: ListTile(
87
- leading: Icon(
88
- role == MessageRole.user
89
- ? Icons.person_outline
90
- : Icons.smart_toy_outlined,
91
- ),
92
- title: Text(
93
- saved.conversationTitle,
94
- maxLines: 1,
95
- overflow: TextOverflow.ellipsis,
96
- ),
97
- subtitle: Text(
98
- saved.content,
99
- maxLines: 3,
100
- overflow: TextOverflow.ellipsis,
101
- ),
102
- isThreeLine: true,
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
- }
123
+ return SavedMessageTile(
124
+ saved: saved,
125
+ isUser: role == MessageRole.user,
126
+ onTap: () async {
127
+ if (saved.conversationId.isEmpty) {
128
+ ScaffoldMessenger.of(context).showSnackBar(
129
+ SnackBar(
130
+ content: Text(l10n.saved_message_temp_snap_unavailable),
131
+ ),
132
+ );
133
+ return;
134
+ }
135
+ final conversations =
136
+ ref.read(conversationsProvider).value ?? [];
137
+ Conversation? conversation;
138
+ for (final conv in conversations) {
139
+ if (conv.id == saved.conversationId) {
140
+ conversation = conv;
141
+ break;
142
}
113
- if (conversation == null) return;
143
+ }
144
+ if (conversation == null) return;
145
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
- }
125
- context.go(AppRoutes.home);
146
+ ref
147
+ .read(scrollToMessageIdProvider.notifier)
148
+ .scrollTo(saved.sourceMessageId);
149
+ await ref
150
+ .read(chatProvider.notifier)
151
+ .loadConversation(conversation);
152
+ if (context.mounted) {
153
+ if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
154
+ Navigator.pop(context);
155
}
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
- ),
156
+ context.go(AppRoutes.home);
157
+ }
158
+ },
159
+ onCopy: () async {
160
+ await Clipboard.setData(
161
+ ClipboardData(text: saved.content),
162
+ );
163
+ if (context.mounted) {
164
+ ScaffoldMessenger.of(context).showSnackBar(
165
+ SnackBar(content: Text(l10n.copied_to_clipboard)),
166
+ );
167
+ }
168
+ },
169
+ onMoveToFolder: () => showSavedMessageMoveToFolderSheet(
170
+ context,
171
+ ref,
172
+ saved.id,
173
),
174
+ onDelete: () => ref
175
+ .read(savedMessagesProvider.notifier)
176
+ .deleteSavedMessage(saved.id),
177
);
178
},
179
);
lib/features/servers/views/components/server_picker_sheet.dart
new
+217
@@ -0,0 +1,217 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import 'package:go_router/go_router.dart';
4
+import 'package:hugeicons/hugeicons.dart';
5
+import 'package:shadcn_ui/shadcn_ui.dart';
6
+import '../../../../core/routes/app_routes.dart';
7
+import '../../../../l10n/app_localizations.dart';
8
+import '../../providers/server_providers.dart';
9
+import 'server_icon_picker.dart';
10
+
11
+void showServerPickerSheet(BuildContext context, WidgetRef ref) {
12
+ final l10n = AppLocalizations.of(context)!;
13
+ final theme = Theme.of(context);
14
+ final colorScheme = theme.colorScheme;
15
+ final isDark = theme.brightness == Brightness.dark;
16
+ final activeServer = ref.read(activeServerProvider);
17
+ final serversAsync = ref.read(serversProvider);
18
+ final servers = serversAsync.value ?? [];
19
+
20
+ showModalBottomSheet<void>(
21
+ context: context,
22
+ isScrollControlled: true,
23
+ backgroundColor: Colors.transparent,
24
+ builder: (context) => Container(
25
+ padding: EdgeInsets.only(
26
+ bottom: MediaQuery.of(context).padding.bottom,
27
+ ),
28
+ decoration: BoxDecoration(
29
+ color: theme.colorScheme.surface,
30
+ borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
31
+ ),
32
+ child: Column(
33
+ mainAxisSize: MainAxisSize.min,
34
+ children: [
35
+ const SizedBox(height: 12),
36
+ Container(
37
+ width: 40,
38
+ height: 4,
39
+ decoration: BoxDecoration(
40
+ color: colorScheme.onSurfaceVariant.withAlpha(50),
41
+ borderRadius: BorderRadius.circular(2),
42
+ ),
43
+ ),
44
+ const SizedBox(height: 16),
45
+ Padding(
46
+ padding: const EdgeInsets.symmetric(horizontal: 20),
47
+ child: Row(
48
+ crossAxisAlignment: CrossAxisAlignment.start,
49
+ children: [
50
+ Expanded(
51
+ child: Column(
52
+ crossAxisAlignment: CrossAxisAlignment.start,
53
+ children: [
54
+ Text(
55
+ l10n.switch_server,
56
+ style: theme.textTheme.titleLarge?.copyWith(
57
+ fontWeight: FontWeight.bold,
58
+ ),
59
+ ),
60
+ const SizedBox(height: 4),
61
+ Text(
62
+ l10n.switch_server_subtitle,
63
+ style: theme.textTheme.bodyMedium?.copyWith(
64
+ color: colorScheme.onSurfaceVariant,
65
+ ),
66
+ ),
67
+ ],
68
+ ),
69
+ ),
70
+ IconButton(
71
+ onPressed: () => Navigator.pop(context),
72
+ icon: const Icon(Icons.close, size: 20),
73
+ visualDensity: VisualDensity.compact,
74
+ color: colorScheme.onSurfaceVariant,
75
+ ),
76
+ ],
77
+ ),
78
+ ),
79
+ const SizedBox(height: 8),
80
+ ConstrainedBox(
81
+ constraints: BoxConstraints(
82
+ maxHeight: MediaQuery.sizeOf(context).height * 0.6,
83
+ ),
84
+ child: SingleChildScrollView(
85
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
86
+ child: Column(
87
+ mainAxisSize: MainAxisSize.min,
88
+ children: [
89
+ if (activeServer == null)
90
+ Padding(
91
+ padding: const EdgeInsets.only(bottom: 8),
92
+ child: Text(
93
+ l10n.no_server_selected,
94
+ style: theme.textTheme.bodyMedium?.copyWith(
95
+ color: colorScheme.onSurfaceVariant,
96
+ ),
97
+ ),
98
+ ),
99
+ ...servers.map((server) {
100
+ final isCurrentlyActive = server.id == activeServer?.id;
101
+ final serverIconName = server.iconName;
102
+ final currentServerIcon = serverIconName != null
103
+ ? (getHugeIconByName(serverIconName)?.icon ??
104
+ getDefaultServerIcon(server.type.name)?.icon)
105
+ : getDefaultServerIcon(server.type.name)?.icon;
106
+
107
+ return Padding(
108
+ padding: const EdgeInsets.only(bottom: 8),
109
+ child: InkWell(
110
+ onTap: isCurrentlyActive
111
+ ? null
112
+ : () {
113
+ ref
114
+ .read(activeServerProvider.notifier)
115
+ .setActiveServer(server);
116
+ Navigator.pop(context);
117
+ },
118
+ borderRadius: BorderRadius.circular(16),
119
+ child: Container(
120
+ padding: const EdgeInsets.all(16),
121
+ decoration: BoxDecoration(
122
+ color: isCurrentlyActive
123
+ ? (isDark
124
+ ? colorScheme.primary.withAlpha(30)
125
+ : colorScheme.primary.withAlpha(20))
126
+ : (isDark
127
+ ? Colors.white.withAlpha(10)
128
+ : Colors.black.withAlpha(5)),
129
+ borderRadius: BorderRadius.circular(16),
130
+ border: Border.all(
131
+ color: isCurrentlyActive
132
+ ? colorScheme.primary.withAlpha(100)
133
+ : Colors.transparent,
134
+ ),
135
+ ),
136
+ child: Row(
137
+ children: [
138
+ HugeIcon(
139
+ icon: currentServerIcon ??
140
+ HugeIcons.strokeRoundedServerStack01,
141
+ size: 20,
142
+ color: isCurrentlyActive
143
+ ? colorScheme.primary
144
+ : colorScheme.onSurface,
145
+ ),
146
+ const SizedBox(width: 12),
147
+ Expanded(
148
+ child: Column(
149
+ crossAxisAlignment: CrossAxisAlignment.start,
150
+ children: [
151
+ Text(
152
+ server.name,
153
+ style: TextStyle(
154
+ fontWeight: isCurrentlyActive
155
+ ? FontWeight.w600
156
+ : FontWeight.w500,
157
+ color: isCurrentlyActive
158
+ ? colorScheme.primary
159
+ : colorScheme.onSurface,
160
+ ),
161
+ ),
162
+ Text(
163
+ isCurrentlyActive
164
+ ? l10n.active
165
+ : (server.host.isEmpty
166
+ ? 'Local'
167
+ : server.host),
168
+ style: TextStyle(
169
+ fontSize: 12,
170
+ color: isCurrentlyActive
171
+ ? colorScheme.primary.withAlpha(150)
172
+ : colorScheme.onSurfaceVariant,
173
+ ),
174
+ ),
175
+ ],
176
+ ),
177
+ ),
178
+ if (isCurrentlyActive)
179
+ Icon(
180
+ Icons.check_circle,
181
+ size: 20,
182
+ color: colorScheme.primary,
183
+ )
184
+ else
185
+ const Icon(
186
+ Icons.chevron_right,
187
+ size: 20,
188
+ color: Colors.grey,
189
+ ),
190
+ ],
191
+ ),
192
+ ),
193
+ ),
194
+ );
195
+ }),
196
+ const SizedBox(height: 12),
197
+ const ShadSeparator.horizontal(),
198
+ const SizedBox(height: 12),
199
+ ShadButton.ghost(
200
+ width: double.infinity,
201
+ leading: const Icon(Icons.settings, size: 18),
202
+ onPressed: () {
203
+ Navigator.pop(context);
204
+ context.go(AppRoutes.servers);
205
+ },
206
+ child: Text(l10n.manage_servers),
207
+ ),
208
+ ],
209
+ ),
210
+ ),
211
+ ),
212
+ const SizedBox(height: 12),
213
+ ],
214
+ ),
215
+ ),
216
+ );
217
+}
lib/features/settings/data/models/app_settings.dart
+12
@@ -38,6 +38,7 @@ class AppSettings {
38
final KittenTtsModelVariant kittenTtsModelVariant;
39
final bool autoSpeakEnabled;
40
final bool ttsProcessMarkdown;
41
+ final int ttsSkipSeconds;
42
final bool smartReplyEnabled;
43
final String? localeCode;
44
final String? huggingFaceToken;
@@ -71,6 +72,7 @@ class AppSettings {
72
this.kittenTtsModelVariant = KittenTtsModelVariant.nanoInt8,
73
this.autoSpeakEnabled = false,
74
this.ttsProcessMarkdown = true,
75
+ this.ttsSkipSeconds = 10,
76
this.smartReplyEnabled = true,
77
this.localeCode,
78
this.huggingFaceToken,
@@ -105,6 +107,7 @@ class AppSettings {
107
KittenTtsModelVariant? kittenTtsModelVariant,
108
bool? autoSpeakEnabled,
109
bool? ttsProcessMarkdown,
110
+ int? ttsSkipSeconds,
111
bool? smartReplyEnabled,
112
Object? localeCode = _unset,
113
Object? huggingFaceToken = _unset,
@@ -144,6 +147,7 @@ class AppSettings {
147
kittenTtsModelVariant ?? this.kittenTtsModelVariant,
148
autoSpeakEnabled: autoSpeakEnabled ?? this.autoSpeakEnabled,
149
ttsProcessMarkdown: ttsProcessMarkdown ?? this.ttsProcessMarkdown,
150
+ ttsSkipSeconds: ttsSkipSeconds ?? this.ttsSkipSeconds,
151
smartReplyEnabled: smartReplyEnabled ?? this.smartReplyEnabled,
152
localeCode: identical(localeCode, _unset)
153
? this.localeCode
@@ -185,6 +189,7 @@ class AppSettings {
189
'kittenTtsModelVariant': kittenTtsModelVariant.name,
190
'autoSpeakEnabled': autoSpeakEnabled,
191
'ttsProcessMarkdown': ttsProcessMarkdown,
192
+ 'ttsSkipSeconds': ttsSkipSeconds,
193
'smartReplyEnabled': smartReplyEnabled,
194
'localeCode': localeCode,
195
'huggingFaceToken': huggingFaceToken,
@@ -221,6 +226,7 @@ class AppSettings {
226
kittenTtsModelVariant: _parseKittenVariant(map['kittenTtsModelVariant']),
227
autoSpeakEnabled: map['autoSpeakEnabled'] ?? false,
228
ttsProcessMarkdown: map['ttsProcessMarkdown'] ?? true,
229
+ ttsSkipSeconds: _parseTtsSkipSeconds(map['ttsSkipSeconds']),
230
smartReplyEnabled: map['smartReplyEnabled'] ?? true,
231
localeCode: map['localeCode'] as String?,
232
huggingFaceToken: map['huggingFaceToken'] as String?,
@@ -257,6 +263,12 @@ class AppSettings {
263
return KittenTtsModelVariant.nanoInt8;
264
}
265
266
+ static int _parseTtsSkipSeconds(dynamic value) {
267
+ const allowed = {5, 10, 15, 30};
268
+ if (value is int && allowed.contains(value)) return value;
269
+ return 10;
270
+ }
271
+
272
String toJson() => json.encode(toMap());
273
274
factory AppSettings.fromJson(String source) =>
lib/features/settings/views/settings_screen.dart
+60
@@ -170,6 +170,12 @@ class SettingsViews extends ConsumerWidget {
170
.read(settingsProvider.notifier)
171
.setTtsProcessMarkdown(value),
172
),
173
+ _TtsSkipSecondsSetting(
174
+ value: settings.ttsSkipSeconds,
175
+ onChanged: (value) => ref
176
+ .read(settingsProvider.notifier)
177
+ .setTtsSkipSeconds(value),
178
+ ),
179
],
180
);
181
@@ -877,6 +883,7 @@ class _LanguageSetting extends StatelessWidget {
883
('ar', 'العربية', 'assets/images/flag_sa.png', '🇸🇦'),
884
('bn', 'বাংলা', 'assets/images/flag_bd.png', '🇧🇩'),
885
('hi', 'हिन्दी', 'assets/images/flag_in.png', '🇮🇳'),
886
+ ('ru', 'Русский', 'assets/images/flag_ru.png', '🇷🇺'),
887
];
888
889
final String? current;
@@ -1553,6 +1560,59 @@ class _SectionActionButton extends StatelessWidget {
1560
}
1561
}
1562
1563
+class _TtsSkipSecondsSetting extends StatelessWidget {
1564
+ const _TtsSkipSecondsSetting({
1565
+ required this.value,
1566
+ required this.onChanged,
1567
+ });
1568
+
1569
+ final int value;
1570
+ final ValueChanged<int> onChanged;
1571
+
1572
+ static const _options = [5, 10, 15, 30];
1573
+
1574
+ @override
1575
+ Widget build(BuildContext context) {
1576
+ final l10n = AppLocalizations.of(context)!;
1577
+ final theme = Theme.of(context);
1578
+
1579
+ return _SettingPanel(
1580
+ child: Column(
1581
+ crossAxisAlignment: CrossAxisAlignment.start,
1582
+ children: [
1583
+ Text(
1584
+ l10n.tts_skip_seconds,
1585
+ style: theme.textTheme.titleSmall?.copyWith(
1586
+ fontWeight: FontWeight.w700,
1587
+ ),
1588
+ ),
1589
+ const SizedBox(height: 4),
1590
+ Text(
1591
+ l10n.tts_skip_seconds_desc,
1592
+ style: theme.textTheme.bodySmall?.copyWith(
1593
+ color: theme.colorScheme.onSurfaceVariant,
1594
+ ),
1595
+ ),
1596
+ const SizedBox(height: 10),
1597
+ Wrap(
1598
+ spacing: 8,
1599
+ runSpacing: 8,
1600
+ children: _options.map((seconds) {
1601
+ final selected = value == seconds;
1602
+ return FilterChip(
1603
+ label: Text(l10n.tts_skip_seconds_value(seconds)),
1604
+ selected: selected,
1605
+ onSelected: (_) => onChanged(seconds),
1606
+ showCheckmark: false,
1607
+ );
1608
+ }).toList(),
1609
+ ),
1610
+ ],
1611
+ ),
1612
+ );
1613
+ }
1614
+}
1615
+
1616
class _EngineDropdown extends StatelessWidget {
1617
const _EngineDropdown({required this.current, required this.onChanged});
1618
lib/features/sidebar/sidebar_widget.dart
+33
@@ -32,6 +32,9 @@ class SidebarWidget extends ConsumerWidget {
32
final isLocalModels = location.startsWith(AppRoutes.onDeviceModels);
33
final isTtsModels = location.startsWith(AppRoutes.ttsModels);
34
final isSettings = location.startsWith(AppRoutes.settings);
35
+ final isHome = location == AppRoutes.home || location == '/';
36
+ final hasActiveChat = ref.watch(hasActiveChatSessionProvider);
37
+ final isTemporary = ref.watch(chatProvider.select((s) => s.isTemporary));
38
39
return Container(
40
width: AppSizes.sidebarWidth,
@@ -63,6 +66,36 @@ class SidebarWidget extends ConsumerWidget {
66
),
67
),
68
69
+ if (hasActiveChat)
70
+ Padding(
71
+ padding: EdgeInsets.fromLTRB(16, 0, 16, isHome ? 8 : 8),
72
+ child: isTemporary
73
+ ? ShadButton.outline(
74
+ width: double.infinity,
75
+ leading: const Icon(Icons.arrow_back, size: 18),
76
+ onPressed: () {
77
+ if (!isHome) context.go(AppRoutes.home);
78
+ if (Scaffold.maybeOf(context)?.isDrawerOpen ??
79
+ false) {
80
+ Navigator.pop(context);
81
+ }
82
+ },
83
+ child: Text(l10n.return_to_temp_chat),
84
+ )
85
+ : ShadButton.secondary(
86
+ width: double.infinity,
87
+ leading: const Icon(Icons.arrow_back, size: 18),
88
+ onPressed: () {
89
+ if (!isHome) context.go(AppRoutes.home);
90
+ if (Scaffold.maybeOf(context)?.isDrawerOpen ??
91
+ false) {
92
+ Navigator.pop(context);
93
+ }
94
+ },
95
+ child: Text(l10n.return_to_chat),
96
+ ),
97
+ ),
98
+
99
const SidebarSearchButton(),
100
const SizedBox(height: 8),
101
const Divider(height: 1, indent: 16, endIndent: 16),
lib/features/tts/providers/tts_providers.dart
+331
-1
@@ -4,6 +4,7 @@ import 'dart:isolate';
4
import 'dart:typed_data';
5
6
import 'package:audio_session/audio_session.dart';
7
+import 'package:file_picker/file_picker.dart';
8
import 'package:just_audio/just_audio.dart';
9
import 'package:just_audio_background/just_audio_background.dart';
10
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -16,6 +17,7 @@ import '../utils/tts_text_processor.dart';
17
import 'tts_model_providers.dart';
18
import '../data/piper_tts_model.dart';
19
import 'tts_worker.dart';
20
+import '../utils/wav_merge.dart';
21
22
final ttsProvider = NotifierProvider<TtsNotifier, TtsState>(() {
23
return TtsNotifier();
@@ -31,6 +33,9 @@ class TtsState {
33
final String? playingMessageId;
34
final String? playingConversationId;
35
final bool isPreview;
36
+ final Duration position;
37
+ final Duration duration;
38
+ final bool canSeek;
39
40
const TtsState({
41
this.isSpeaking = false,
@@ -42,6 +47,9 @@ class TtsState {
47
this.playingMessageId,
48
this.playingConversationId,
49
this.isPreview = false,
50
+ this.position = Duration.zero,
51
+ this.duration = Duration.zero,
52
+ this.canSeek = false,
53
});
54
55
TtsState copyWith({
@@ -54,7 +62,11 @@ class TtsState {
62
String? playingMessageId,
63
String? playingConversationId,
64
bool? isPreview,
65
+ Duration? position,
66
+ Duration? duration,
67
+ bool? canSeek,
68
bool clearPlayingTarget = false,
69
+ bool resetProgress = false,
70
}) {
71
return TtsState(
72
isSpeaking: isSpeaking ?? this.isSpeaking,
@@ -69,6 +81,9 @@ class TtsState {
81
? null
82
: (playingConversationId ?? this.playingConversationId),
83
isPreview: isPreview ?? this.isPreview,
84
+ position: resetProgress ? Duration.zero : (position ?? this.position),
85
+ duration: resetProgress ? Duration.zero : (duration ?? this.duration),
86
+ canSeek: canSeek ?? this.canSeek,
87
);
88
}
89
}
@@ -107,6 +122,9 @@ class TtsNotifier extends Notifier<TtsState> {
122
final Map<int, AudioSource> _playlistBuffer = {};
123
int _nextPlaylistIndexToAdd = 0;
124
StreamSubscription<int?>? _currentIndexSubscription;
125
+ StreamSubscription<Duration>? _positionSubscription;
126
+ StreamSubscription<Duration?>? _durationSubscription;
127
+ Timer? _systemProgressTimer;
128
129
// Progress tracking
130
final Map<int, Duration> _chunkDurations = {};
@@ -114,6 +132,11 @@ class TtsNotifier extends Notifier<TtsState> {
132
Duration _chunkOffset = Duration.zero;
133
final StreamController<void> _onChunkChanged = StreamController<void>.broadcast();
134
135
+ // System TTS approximate seek
136
+ String? _systemFullText;
137
+ Duration _systemSkippedOffset = Duration.zero;
138
+ DateTime? _systemSpeakStart;
139
+
140
AudioPlayer get player => _player;
141
Stream<Duration> get onPositionChanged => _player.positionStream;
142
Duration get totalDuration => _totalDuration;
@@ -141,11 +164,21 @@ class TtsNotifier extends Notifier<TtsState> {
164
_currentIndexSubscription = _player.currentIndexStream.listen((index) {
165
if (index != null && index != _currentChunkIndex) {
166
_currentChunkIndex = index;
167
+ _updateChunkOffset();
168
_onChunkChanged.add(null);
169
_enqueueNextChunks();
170
+ _updatePlaybackProgress();
171
}
172
});
173
174
+ _positionSubscription = _player.positionStream.listen((_) {
175
+ _updatePlaybackProgress();
176
+ });
177
+
178
+ _durationSubscription = _player.durationStream.listen((_) {
179
+ _updatePlaybackProgress();
180
+ });
181
+
182
_setupWorkerListener();
183
184
ref.onDispose(() {
@@ -155,6 +188,9 @@ class TtsNotifier extends Notifier<TtsState> {
188
_mainReceivePort.close();
189
_playerCompleteSubscription?.cancel();
190
_currentIndexSubscription?.cancel();
191
+ _positionSubscription?.cancel();
192
+ _durationSubscription?.cancel();
193
+ _systemProgressTimer?.cancel();
194
_onChunkChanged.close();
195
if (!_isPlayerDisposed) {
196
_isPlayerDisposed = true;
@@ -187,6 +223,7 @@ class TtsNotifier extends Notifier<TtsState> {
223
_synthesizedChunks[message.chunkIndex!] = message.wavBytes!;
224
_computeChunkDuration(message.chunkIndex!, message.wavBytes!);
225
await _addChunkToPlaylist(message.chunkIndex!, message.wavBytes!);
226
+ _updatePlaybackProgress();
227
} else {
228
Log.error(
229
'Worker error for chunk ${message.chunkIndex}: ${message.error}',
@@ -230,12 +267,106 @@ class TtsNotifier extends Notifier<TtsState> {
267
for (final d in _chunkDurations.values) {
268
_totalDuration += d;
269
}
270
+ _updatePlaybackProgress();
271
+ }
272
+
273
+ void _updateChunkOffset() {
274
+ var offset = Duration.zero;
275
+ for (var i = 0; i < _currentChunkIndex; i++) {
276
+ offset += _chunkDurations[i] ?? Duration.zero;
277
+ }
278
+ _chunkOffset = offset;
279
+ }
280
+
281
+ Duration _averageChunkDuration() {
282
+ if (_chunkDurations.isEmpty) return const Duration(seconds: 3);
283
+ final totalMs = _chunkDurations.values.fold<int>(
284
+ 0,
285
+ (sum, d) => sum + d.inMilliseconds,
286
+ );
287
+ return Duration(milliseconds: totalMs ~/ _chunkDurations.length);
288
+ }
289
+
290
+ Duration _estimatedTotalDuration() {
291
+ if (_chunks.isEmpty) return Duration.zero;
292
+ if (_chunkDurations.length >= _chunks.length) return _totalDuration;
293
+ final avg = _averageChunkDuration();
294
+ return Duration(
295
+ milliseconds: avg.inMilliseconds * _chunks.length,
296
+ );
297
+ }
298
+
299
+ Duration _computeGlobalPosition() {
300
+ if (state.activeEngine == EngineId.system) {
301
+ return _systemEstimatedPosition();
302
+ }
303
+ _updateChunkOffset();
304
+ return _chunkOffset + _player.position;
305
+ }
306
+
307
+ void _updatePlaybackProgress() {
308
+ if (!state.isSpeaking || state.isPreview) return;
309
+
310
+ if (state.activeEngine == EngineId.system) {
311
+ final duration = _systemEstimatedDuration();
312
+ state = state.copyWith(
313
+ position: _systemEstimatedPosition(),
314
+ duration: duration,
315
+ canSeek: duration > Duration.zero,
316
+ );
317
+ return;
318
+ }
319
+
320
+ final position = _computeGlobalPosition();
321
+ final duration = _estimatedTotalDuration();
322
+ state = state.copyWith(
323
+ position: position,
324
+ duration: duration,
325
+ canSeek: !state.isInitializing && duration > Duration.zero,
326
+ );
327
+ }
328
+
329
+ Duration _systemEstimatedDuration() {
330
+ if (_systemFullText == null) return Duration.zero;
331
+ final cps = 12.0 * ref.read(settingsProvider).ttsSpeed;
332
+ return Duration(
333
+ milliseconds: (_systemFullText!.length / cps * 1000).round(),
334
+ );
335
+ }
336
+
337
+ Duration _systemEstimatedPosition() {
338
+ if (_systemSpeakStart == null) return _systemSkippedOffset;
339
+ final pos =
340
+ _systemSkippedOffset + DateTime.now().difference(_systemSpeakStart!);
341
+ final total = _systemEstimatedDuration();
342
+ return pos > total ? total : pos;
343
+ }
344
+
345
+ void _startSystemProgressTimer() {
346
+ _systemProgressTimer?.cancel();
347
+ _systemProgressTimer = Timer.periodic(
348
+ const Duration(milliseconds: 250),
349
+ (_) => _updatePlaybackProgress(),
350
+ );
351
+ }
352
+
353
+ void _stopSystemProgressTimer() {
354
+ _systemProgressTimer?.cancel();
355
+ _systemProgressTimer = null;
356
+ }
357
+
358
+ void _resetSystemSeekState() {
359
+ _systemFullText = null;
360
+ _systemSkippedOffset = Duration.zero;
361
+ _systemSpeakStart = null;
362
+ _stopSystemProgressTimer();
363
}
364
365
void _resetProgress() {
366
_chunkDurations.clear();
367
_totalDuration = Duration.zero;
368
_chunkOffset = Duration.zero;
369
+ _resetSystemSeekState();
370
}
371
372
void _deleteChunkFile(int sessionId, int chunkIndex) {
@@ -266,6 +397,8 @@ class TtsNotifier extends Notifier<TtsState> {
397
playingContent: null,
398
isPreview: false,
399
clearPlayingTarget: true,
400
+ resetProgress: true,
401
+ canSeek: false,
402
);
403
}
404
@@ -445,6 +578,10 @@ class TtsNotifier extends Notifier<TtsState> {
578
579
if (engineId == EngineId.system) {
580
_flutterTts ??= FlutterTts();
581
+ _resetSystemSeekState();
582
+ _systemFullText = text;
583
+ _systemSkippedOffset = Duration.zero;
584
+ _systemSpeakStart = DateTime.now();
585
// Clear any previous handlers to prevent stale callbacks
586
_flutterTts!.setCompletionHandler(() {});
587
_flutterTts!.setErrorHandler((_) {});
@@ -458,7 +595,10 @@ class TtsNotifier extends Notifier<TtsState> {
595
playingMessageId: isPreview ? null : messageId,
596
playingConversationId: isPreview ? null : conversationId,
597
isPreview: isPreview,
598
+ canSeek: !isPreview,
599
+ duration: _systemEstimatedDuration(),
600
);
601
+ _startSystemProgressTimer();
602
try {
603
await _flutterTts!.setLanguage('en-US');
604
await _flutterTts!.setSpeechRate(
@@ -466,40 +606,52 @@ class TtsNotifier extends Notifier<TtsState> {
606
);
607
_flutterTts!.setCompletionHandler(() {
608
if (!_isStopping) {
609
+ _resetSystemSeekState();
610
state = state.copyWith(
611
isSpeaking: false,
612
playingContent: null,
613
clearPlayingTarget: true,
614
+ resetProgress: true,
615
+ canSeek: false,
616
);
617
}
618
});
619
_flutterTts!.setErrorHandler((msg) {
620
if (!_isStopping) {
621
+ _resetSystemSeekState();
622
state = state.copyWith(
623
isSpeaking: false,
624
playingContent: null,
625
error: msg.toString(),
626
clearPlayingTarget: true,
627
+ resetProgress: true,
628
+ canSeek: false,
629
);
630
}
631
});
632
_flutterTts!.setCancelHandler(() {
633
if (!_isStopping) {
634
+ _resetSystemSeekState();
635
state = state.copyWith(
636
isSpeaking: false,
637
playingContent: null,
638
clearPlayingTarget: true,
639
+ resetProgress: true,
640
+ canSeek: false,
641
);
642
}
643
});
644
await _flutterTts!.awaitSpeakCompletion(true);
645
await _flutterTts!.speak(text);
646
} catch (e) {
647
+ _resetSystemSeekState();
648
state = state.copyWith(
649
isSpeaking: false,
650
error: 'System TTS unavailable',
651
playingContent: null,
652
clearPlayingTarget: true,
653
+ resetProgress: true,
654
+ canSeek: false,
655
);
656
rethrow;
657
}
@@ -542,7 +694,11 @@ class TtsNotifier extends Notifier<TtsState> {
694
695
try {
696
await _initEngine(engineId, voiceId: resolvedVoiceId);
545
- state = state.copyWith(isInitializing: false);
697
+ state = state.copyWith(
698
+ isInitializing: false,
699
+ canSeek: true,
700
+ duration: _estimatedTotalDuration(),
701
+ );
702
_enqueueNextChunks();
703
} catch (e, st) {
704
if (_isPlayerDisposed) return;
@@ -591,6 +747,143 @@ class TtsNotifier extends Notifier<TtsState> {
747
}
748
}
749
750
+ void _enqueueChunk(int chunkIndex) {
751
+ if (_workerSendPort == null || _currentEngine == null) return;
752
+ if (chunkIndex >= _chunks.length) return;
753
+
754
+ final settings = ref.read(settingsProvider);
755
+ final availableVoices = voicesForEngine(_currentEngine!);
756
+ final voiceId =
757
+ _currentVoice ??
758
+ settings.ttsVoiceId ??
759
+ (availableVoices.isNotEmpty ? availableVoices.first.id : null);
760
+
761
+ while (_lastEnqueuedChunk < chunkIndex) {
762
+ _lastEnqueuedChunk++;
763
+ final index = _lastEnqueuedChunk;
764
+ Log.debug('Enqueuing synthesis for chunk $index');
765
+ _workerSendPort!.send(
766
+ TtsSynthesizeRequest(
767
+ text: _chunks[index],
768
+ voiceId: voiceId ?? '',
769
+ speed: settings.ttsSpeed,
770
+ chunkIndex: index,
771
+ sessionId: _currentSessionId,
772
+ ),
773
+ );
774
+ }
775
+ }
776
+
777
+ Future<void> _ensurePlaylistThrough(int targetIndex) async {
778
+ if (targetIndex < 0 || targetIndex >= _chunks.length) return;
779
+ _enqueueChunk(targetIndex);
780
+
781
+ final deadline = DateTime.now().add(const Duration(seconds: 20));
782
+ while (_nextPlaylistIndexToAdd <= targetIndex &&
783
+ DateTime.now().isBefore(deadline)) {
784
+ await Future.delayed(const Duration(milliseconds: 50));
785
+ if (_lastEnqueuedChunk < targetIndex) {
786
+ _enqueueChunk(targetIndex);
787
+ }
788
+ }
789
+ }
790
+
791
+ int _chunkIndexForPosition(Duration target) {
792
+ var accumulated = Duration.zero;
793
+ for (var i = 0; i < _chunks.length; i++) {
794
+ final chunkDuration = _chunkDurations[i] ?? _averageChunkDuration();
795
+ if (accumulated + chunkDuration > target) return i;
796
+ accumulated += chunkDuration;
797
+ }
798
+ return _chunks.isEmpty ? 0 : _chunks.length - 1;
799
+ }
800
+
801
+ Future<void> seekTo(Duration target) async {
802
+ if (!state.isSpeaking || state.isPreview) return;
803
+
804
+ if (state.activeEngine == EngineId.system) {
805
+ await _systemSeekTo(target);
806
+ return;
807
+ }
808
+
809
+ final maxDuration = _estimatedTotalDuration();
810
+ if (maxDuration <= Duration.zero) return;
811
+
812
+ var clamped = target;
813
+ if (clamped.isNegative) clamped = Duration.zero;
814
+ if (clamped > maxDuration) clamped = maxDuration;
815
+
816
+ final targetChunk = _chunkIndexForPosition(clamped);
817
+ await _ensurePlaylistThrough(targetChunk);
818
+ await _player.seek(clamped);
819
+ _currentChunkIndex = targetChunk;
820
+ _updateChunkOffset();
821
+ _updatePlaybackProgress();
822
+ }
823
+
824
+ Future<void> _systemSeekTo(Duration target) async {
825
+ final full = _systemFullText;
826
+ if (full == null || _flutterTts == null) return;
827
+
828
+ final total = _systemEstimatedDuration();
829
+ var clamped = target;
830
+ if (clamped.isNegative) clamped = Duration.zero;
831
+ if (clamped > total) clamped = total;
832
+
833
+ final settings = ref.read(settingsProvider);
834
+ final cps = 12.0 * settings.ttsSpeed;
835
+ final charIndex =
836
+ (clamped.inMilliseconds / 1000 * cps).round().clamp(0, full.length);
837
+
838
+ var start = charIndex;
839
+ if (start > 0 && start < full.length) {
840
+ while (start > 0 && full[start - 1] != ' ') {
841
+ start--;
842
+ }
843
+ }
844
+
845
+ final remainder = full.substring(start.clamp(0, full.length));
846
+ if (remainder.trim().isEmpty) {
847
+ _resetSystemSeekState();
848
+ state = state.copyWith(
849
+ isSpeaking: false,
850
+ playingContent: null,
851
+ clearPlayingTarget: true,
852
+ resetProgress: true,
853
+ canSeek: false,
854
+ );
855
+ return;
856
+ }
857
+
858
+ await _flutterTts!.stop();
859
+ _systemSkippedOffset = Duration(
860
+ milliseconds: (start / cps * 1000).round(),
861
+ );
862
+ _systemSpeakStart = DateTime.now();
863
+
864
+ await _flutterTts!.setSpeechRate(
865
+ (settings.ttsSpeed * 0.5).clamp(0.0, 1.0),
866
+ );
867
+ await _flutterTts!.speak(remainder);
868
+ _updatePlaybackProgress();
869
+ }
870
+
871
+ Future<void> skipForward() async {
872
+ if (!state.canSeek && !state.isSpeaking) return;
873
+ final skip = Duration(
874
+ seconds: ref.read(settingsProvider).ttsSkipSeconds,
875
+ );
876
+ await seekTo(_computeGlobalPosition() + skip);
877
+ }
878
+
879
+ Future<void> skipBackward() async {
880
+ if (!state.canSeek && !state.isSpeaking) return;
881
+ final skip = Duration(
882
+ seconds: ref.read(settingsProvider).ttsSkipSeconds,
883
+ );
884
+ await seekTo(_computeGlobalPosition() - skip);
885
+ }
886
+
887
void _enqueueNextChunks() {
888
if (_workerSendPort == null || _currentEngine == null) return;
889
@@ -657,7 +950,44 @@ class TtsNotifier extends Notifier<TtsState> {
950
playingContent: null,
951
isPreview: false,
952
clearPlayingTarget: true,
953
+ resetProgress: true,
954
+ canSeek: false,
955
+ );
956
+ }
957
+
958
+ Future<bool> downloadCurrentAudio() async {
959
+ if (state.activeEngine == EngineId.system || state.isPreview) {
960
+ return false;
961
+ }
962
+ if (!state.isSpeaking && !state.isPaused) return false;
963
+
964
+ final wavFiles = <Uint8List>[];
965
+ for (var i = 0; i < _chunks.length; i++) {
966
+ final cached = _synthesizedChunks[i];
967
+ if (cached != null && cached.isNotEmpty) {
968
+ wavFiles.add(cached);
969
+ continue;
970
+ }
971
+ final tempFile = File(
972
+ '${Directory.systemTemp.path}/tts_chunk_${_currentSessionId}_$i.wav',
973
+ );
974
+ if (tempFile.existsSync()) {
975
+ wavFiles.add(await tempFile.readAsBytes());
976
+ }
977
+ }
978
+
979
+ if (wavFiles.isEmpty) return false;
980
+
981
+ final merged = mergeWavFiles(wavFiles);
982
+ final timestamp = DateTime.now().millisecondsSinceEpoch;
983
+ final saved = await FilePicker.saveFile(
984
+ dialogTitle: 'Save TTS audio',
985
+ fileName: 'localmind_tts_$timestamp.wav',
986
+ type: FileType.custom,
987
+ allowedExtensions: const ['wav'],
988
+ bytes: merged,
989
);
990
+ return saved != null;
991
}
992
993
Future<void> previewVoice(Voice voice) async {
lib/features/tts/utils/wav_merge.dart
new
+46
@@ -0,0 +1,46 @@
1
+import 'dart:typed_data';
2
+
3
+/// Merges multiple PCM WAV files (same format) into a single WAV.
4
+Uint8List mergeWavFiles(List<Uint8List> wavFiles) {
5
+ if (wavFiles.isEmpty) return Uint8List(0);
6
+ if (wavFiles.length == 1) return wavFiles.first;
7
+
8
+ final first = wavFiles.first;
9
+ if (first.length < 44) return first;
10
+
11
+ final pcmBuilder = BytesBuilder(copy: false);
12
+ for (final wav in wavFiles) {
13
+ if (wav.length > 44) {
14
+ pcmBuilder.add(wav.sublist(44));
15
+ }
16
+ }
17
+ final pcmData = pcmBuilder.toBytes();
18
+ final totalSize = 36 + pcmData.length;
19
+
20
+ final merged = ByteData(44);
21
+ merged.setUint8(0, 0x52); // R
22
+ merged.setUint8(1, 0x49); // I
23
+ merged.setUint8(2, 0x46); // F
24
+ merged.setUint8(3, 0x46); // F
25
+ merged.setUint32(4, totalSize, Endian.little);
26
+ merged.setUint8(8, 0x57); // W
27
+ merged.setUint8(9, 0x41); // A
28
+ merged.setUint8(10, 0x56); // V
29
+ merged.setUint8(11, 0x45); // E
30
+
31
+ // Copy fmt chunk from first file (bytes 12-35)
32
+ for (var i = 12; i < 36; i++) {
33
+ merged.setUint8(i, first[i]);
34
+ }
35
+
36
+ merged.setUint8(36, 0x64); // d
37
+ merged.setUint8(37, 0x61); // a
38
+ merged.setUint8(38, 0x74); // t
39
+ merged.setUint8(39, 0x61); // a
40
+ merged.setUint32(40, pcmData.length, Endian.little);
41
+
42
+ return Uint8List.fromList([
43
+ ...merged.buffer.asUint8List(),
44
+ ...pcmData,
45
+ ]);
46
+}
lib/features/tts/views/components/tts_player_bar.dart
+161
-17
@@ -2,6 +2,8 @@ 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/models/enums.dart';
6
+import 'package:localmind/core/providers/app_providers.dart';
7
import 'package:localmind/core/routes/app_routes.dart';
8
import 'package:localmind/l10n/app_localizations.dart';
9
import '../../providers/tts_providers.dart' as tts;
@@ -23,6 +25,18 @@ class TtsPlayerBar extends ConsumerWidget {
25
return '${content.substring(0, 47)}...';
26
}
27
28
+ String _formatDuration(Duration d) {
29
+ if (d.inHours > 0) {
30
+ final h = d.inHours.toString();
31
+ final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
32
+ final s = (d.inSeconds % 60).toString().padLeft(2, '0');
33
+ return '$h:$m:$s';
34
+ }
35
+ final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
36
+ final s = (d.inSeconds % 60).toString().padLeft(2, '0');
37
+ return '$m:$s';
38
+ }
39
+
40
Future<void> _scrollToPlayingMessage(
41
BuildContext context,
42
WidgetRef ref,
@@ -86,6 +100,12 @@ class TtsPlayerBar extends ConsumerWidget {
100
101
final preview = _truncateContent(ttsState.playingContent);
102
final canJumpToMessage = ttsState.playingMessageId != null;
103
+ final skipSeconds = ref.watch(settingsProvider).ttsSkipSeconds;
104
+ final hasTimeline = ttsState.duration > Duration.zero;
105
+ final progress = hasTimeline
106
+ ? (ttsState.position.inMilliseconds / ttsState.duration.inMilliseconds)
107
+ .clamp(0.0, 1.0)
108
+ : null;
109
110
return AnimatedSize(
111
duration: const Duration(milliseconds: 200),
@@ -173,31 +193,36 @@ class TtsPlayerBar extends ConsumerWidget {
193
),
194
),
195
),
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
- ),
196
],
197
),
198
),
199
),
200
),
201
),
197
- const SizedBox(width: 12),
202
+ const SizedBox(width: 8),
203
if (!ttsState.isInitializing)
204
_WaveformIndicator(isPlaying: !ttsState.isPaused),
200
- const SizedBox(width: 12),
205
+ if (!ttsState.isInitializing &&
206
+ ttsState.activeEngine != EngineId.system)
207
+ IconButton(
208
+ icon: const Icon(Icons.download_outlined, size: 20),
209
+ tooltip: l10n.download_tts_audio,
210
+ visualDensity: VisualDensity.compact,
211
+ onPressed: () async {
212
+ final ok = await notifier.downloadCurrentAudio();
213
+ if (!context.mounted) return;
214
+ ScaffoldMessenger.of(context).showSnackBar(
215
+ SnackBar(
216
+ content: Text(
217
+ ok
218
+ ? l10n.tts_download_success
219
+ : l10n.tts_download_no_audio,
220
+ ),
221
+ ),
222
+ );
223
+ },
224
+ ),
225
+ const SizedBox(width: 4),
226
GestureDetector(
227
onTap: () => notifier.stop(),
228
child: Tooltip(
@@ -218,6 +243,35 @@ class TtsPlayerBar extends ConsumerWidget {
243
),
244
],
245
),
246
+ if (!ttsState.isInitializing && (ttsState.canSeek || hasTimeline)) ...[
247
+ const SizedBox(height: 8),
248
+ Row(
249
+ children: [
250
+ IconButton(
251
+ icon: const Icon(Icons.fast_rewind_rounded, size: 22),
252
+ tooltip: '-${skipSeconds}s',
253
+ visualDensity: VisualDensity.compact,
254
+ onPressed: () => notifier.skipBackward(),
255
+ ),
256
+ Expanded(
257
+ child: _TtsSeekSlider(
258
+ progress: progress ?? 0,
259
+ position: ttsState.position,
260
+ duration: ttsState.duration,
261
+ enabled: hasTimeline && ttsState.canSeek,
262
+ onSeek: notifier.seekTo,
263
+ formatDuration: _formatDuration,
264
+ ),
265
+ ),
266
+ IconButton(
267
+ icon: const Icon(Icons.fast_forward_rounded, size: 22),
268
+ tooltip: '+${skipSeconds}s',
269
+ visualDensity: VisualDensity.compact,
270
+ onPressed: () => notifier.skipForward(),
271
+ ),
272
+ ],
273
+ ),
274
+ ],
275
],
276
),
277
),
@@ -225,6 +279,96 @@ class TtsPlayerBar extends ConsumerWidget {
279
}
280
}
281
282
+class _TtsSeekSlider extends StatefulWidget {
283
+ const _TtsSeekSlider({
284
+ required this.progress,
285
+ required this.position,
286
+ required this.duration,
287
+ required this.enabled,
288
+ required this.onSeek,
289
+ required this.formatDuration,
290
+ });
291
+
292
+ final double progress;
293
+ final Duration position;
294
+ final Duration duration;
295
+ final bool enabled;
296
+ final Future<void> Function(Duration) onSeek;
297
+ final String Function(Duration) formatDuration;
298
+
299
+ @override
300
+ State<_TtsSeekSlider> createState() => _TtsSeekSliderState();
301
+}
302
+
303
+class _TtsSeekSliderState extends State<_TtsSeekSlider> {
304
+ double? _dragValue;
305
+
306
+ @override
307
+ Widget build(BuildContext context) {
308
+ final theme = Theme.of(context);
309
+ final displayProgress = _dragValue ?? widget.progress;
310
+
311
+ return Column(
312
+ children: [
313
+ SliderTheme(
314
+ data: SliderTheme.of(context).copyWith(
315
+ trackHeight: 3,
316
+ thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
317
+ overlayShape: const RoundSliderOverlayShape(overlayRadius: 12),
318
+ ),
319
+ child: Slider(
320
+ value: displayProgress.clamp(0.0, 1.0),
321
+ onChanged: widget.enabled
322
+ ? (v) => setState(() => _dragValue = v)
323
+ : null,
324
+ onChangeEnd: widget.enabled
325
+ ? (v) async {
326
+ setState(() => _dragValue = null);
327
+ await widget.onSeek(
328
+ Duration(
329
+ milliseconds:
330
+ (v * widget.duration.inMilliseconds).round(),
331
+ ),
332
+ );
333
+ }
334
+ : null,
335
+ ),
336
+ ),
337
+ Padding(
338
+ padding: const EdgeInsets.symmetric(horizontal: 8),
339
+ child: Row(
340
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
341
+ children: [
342
+ Text(
343
+ widget.formatDuration(
344
+ _dragValue != null
345
+ ? Duration(
346
+ milliseconds: (_dragValue! *
347
+ widget.duration.inMilliseconds)
348
+ .round(),
349
+ )
350
+ : widget.position,
351
+ ),
352
+ style: TextStyle(
353
+ fontSize: 10,
354
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
355
+ ),
356
+ ),
357
+ Text(
358
+ widget.formatDuration(widget.duration),
359
+ style: TextStyle(
360
+ fontSize: 10,
361
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
362
+ ),
363
+ ),
364
+ ],
365
+ ),
366
+ ),
367
+ ],
368
+ );
369
+ }
370
+}
371
+
372
class _WaveformIndicator extends StatefulWidget {
373
final bool isPlaying;
374
lib/l10n/app_en.arb
+75
-2
@@ -824,7 +824,7 @@
824
"@stop_generation_tooltip": {
825
"description": "Tooltip for stop button"
826
},
827
- "attach_images_tooltip": "Attach images",
827
+ "attach_images_tooltip": "Attach images or text",
828
"@attach_images_tooltip": {
829
"description": "Tooltip for attach button"
830
},
@@ -2593,9 +2593,82 @@
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
+ "tts_skip_seconds": "Skip interval",
2597
+ "tts_skip_seconds_desc": "Forward and rewind jump size during playback",
2598
+ "tts_skip_seconds_value": "{seconds}s",
2599
+ "@tts_skip_seconds_value": {
2600
+ "placeholders": {
2601
+ "seconds": {
2602
+ "type": "int"
2603
+ }
2604
+ }
2605
+ },
2606
"preview_system_prompts": "Preview system prompts",
2607
"welcome_message_1": "What can I help you with today?",
2608
"welcome_message_2": "Ask me anything — I'm ready when you are.",
2609
"welcome_message_3": "Start a conversation below.",
2600
- "welcome_message_4": "Need ideas? Try one of the quick prompts."
2610
+ "welcome_message_4": "Need ideas? Try one of the quick prompts.",
2611
+ "temporary_chat": "Temporary chat",
2612
+ "temporary_chat_desc": "Chats aren't saved to history.",
2613
+ "temporary_chat_banner": "Temporary chat — not saved to history",
2614
+ "temporary_chat_save_warning_title": "Save message in temporary chat?",
2615
+ "temporary_chat_save_warning_body": "This chat is temporary and hidden from history. The saved message will still appear in Saved Messages.",
2616
+ "save_to_history": "Save to history",
2617
+ "share_conversation": "Share conversation",
2618
+ "download_tts_audio": "Download audio",
2619
+ "tts_download_unavailable": "Download is only available for Piper and Kitten TTS",
2620
+ "tts_download_no_audio": "No audio available to download yet",
2621
+ "tts_download_success": "Audio saved",
2622
+ "return_to_chat": "Return to chat",
2623
+ "return_to_temp_chat": "Return to temporary chat",
2624
+ "insert_saved_message": "Insert saved message",
2625
+ "insert_saved_message_desc": "Choose a saved message to add to your input",
2626
+ "model_info": "Model info",
2627
+ "model_name": "Model name",
2628
+ "model_identifier": "Identifier",
2629
+ "not_available": "Not available",
2630
+ "save_message_folders": "Save message",
2631
+ "remove_from_saved": "Remove from saved",
2632
+ "message_already_saved": "Saved",
2633
+ "stream_ttft": "Time to first token",
2634
+ "stream_tokens_per_sec": "Tokens per second",
2635
+ "stream_stop_reason": "Stop reason",
2636
+ "stream_input_tokens": "Input tokens",
2637
+ "stream_output_tokens": "Output tokens",
2638
+ "stream_generation_time": "Generation time",
2639
+ "attach_image": "Image",
2640
+ "attach_text_document": "Text document",
2641
+ "add_attachment": "Add attachment",
2642
+ "photo_permission_denied": "Photo access is required to attach images",
2643
+ "characters_label": "Characters",
2644
+ "exit_temporary_chat_title": "Exit temporary chat?",
2645
+ "exit_temporary_chat_body": "This will discard the current temporary chat and return to a new chat.",
2646
+ "saved_message_temp_snap_unavailable": "This message was saved from a temporary chat and can't be opened in its original conversation.",
2647
+ "filter_title": "Filter",
2648
+ "filter_pinned": "Pinned",
2649
+ "filter_archived": "Archived",
2650
+ "filter_temp_chats": "Temporary chats",
2651
+ "filter_user_messages": "User messages",
2652
+ "filter_assistant_messages": "Assistant messages",
2653
+ "archive_chat": "Archive",
2654
+ "unarchive_chat": "Unarchive",
2655
+ "conversation_message_count": "{count, plural, one{1 message} other{{count} messages}}",
2656
+ "@conversation_message_count": {
2657
+ "placeholders": {
2658
+ "count": {
2659
+ "type": "int"
2660
+ }
2661
+ }
2662
+ },
2663
+ "conversation_character_count": "{count} chars",
2664
+ "@conversation_character_count": {
2665
+ "placeholders": {
2666
+ "count": {
2667
+ "type": "int"
2668
+ }
2669
+ }
2670
+ },
2671
+ "generate_title_with_ai": "Generate with AI",
2672
+ "generating_title": "Generating...",
2673
+ "generate_title_failed": "Could not generate a title"
2674
}
lib/l10n/app_localizations.dart
+318
-1
@@ -12,6 +12,7 @@ import 'app_localizations_es.dart';
12
import 'app_localizations_hi.dart';
13
import 'app_localizations_it.dart';
14
import 'app_localizations_ja.dart';
15
+import 'app_localizations_ru.dart';
16
import 'app_localizations_zh.dart';
17
18
// ignore_for_file: type=lint
@@ -107,6 +108,7 @@ abstract class AppLocalizations {
108
Locale('hi'),
109
Locale('it'),
110
Locale('ja'),
111
+ Locale('ru'),
112
Locale('zh'),
113
Locale('zh', 'TW'),
114
];
@@ -1224,7 +1226,7 @@ abstract class AppLocalizations {
1226
/// Tooltip for attach button
1227
///
1228
/// In en, this message translates to:
1227
- /// **'Attach images'**
1229
+ /// **'Attach images or text'**
1230
String get attach_images_tooltip;
1231
1232
/// Tooltip for start listening button
@@ -4077,6 +4079,24 @@ abstract class AppLocalizations {
4079
/// **'Strip formatting like **bold** before reading aloud'**
4080
String get tts_process_markdown_desc;
4081
4082
+ /// No description provided for @tts_skip_seconds.
4083
+ ///
4084
+ /// In en, this message translates to:
4085
+ /// **'Skip interval'**
4086
+ String get tts_skip_seconds;
4087
+
4088
+ /// No description provided for @tts_skip_seconds_desc.
4089
+ ///
4090
+ /// In en, this message translates to:
4091
+ /// **'Forward and rewind jump size during playback'**
4092
+ String get tts_skip_seconds_desc;
4093
+
4094
+ /// No description provided for @tts_skip_seconds_value.
4095
+ ///
4096
+ /// In en, this message translates to:
4097
+ /// **'{seconds}s'**
4098
+ String tts_skip_seconds_value(int seconds);
4099
+
4100
/// No description provided for @preview_system_prompts.
4101
///
4102
/// In en, this message translates to:
@@ -4106,6 +4126,300 @@ abstract class AppLocalizations {
4126
/// In en, this message translates to:
4127
/// **'Need ideas? Try one of the quick prompts.'**
4128
String get welcome_message_4;
4129
+
4130
+ /// No description provided for @temporary_chat.
4131
+ ///
4132
+ /// In en, this message translates to:
4133
+ /// **'Temporary chat'**
4134
+ String get temporary_chat;
4135
+
4136
+ /// No description provided for @temporary_chat_desc.
4137
+ ///
4138
+ /// In en, this message translates to:
4139
+ /// **'Chats aren\'t saved to history.'**
4140
+ String get temporary_chat_desc;
4141
+
4142
+ /// No description provided for @temporary_chat_banner.
4143
+ ///
4144
+ /// In en, this message translates to:
4145
+ /// **'Temporary chat — not saved to history'**
4146
+ String get temporary_chat_banner;
4147
+
4148
+ /// No description provided for @temporary_chat_save_warning_title.
4149
+ ///
4150
+ /// In en, this message translates to:
4151
+ /// **'Save message in temporary chat?'**
4152
+ String get temporary_chat_save_warning_title;
4153
+
4154
+ /// No description provided for @temporary_chat_save_warning_body.
4155
+ ///
4156
+ /// In en, this message translates to:
4157
+ /// **'This chat is temporary and hidden from history. The saved message will still appear in Saved Messages.'**
4158
+ String get temporary_chat_save_warning_body;
4159
+
4160
+ /// No description provided for @save_to_history.
4161
+ ///
4162
+ /// In en, this message translates to:
4163
+ /// **'Save to history'**
4164
+ String get save_to_history;
4165
+
4166
+ /// No description provided for @share_conversation.
4167
+ ///
4168
+ /// In en, this message translates to:
4169
+ /// **'Share conversation'**
4170
+ String get share_conversation;
4171
+
4172
+ /// No description provided for @download_tts_audio.
4173
+ ///
4174
+ /// In en, this message translates to:
4175
+ /// **'Download audio'**
4176
+ String get download_tts_audio;
4177
+
4178
+ /// No description provided for @tts_download_unavailable.
4179
+ ///
4180
+ /// In en, this message translates to:
4181
+ /// **'Download is only available for Piper and Kitten TTS'**
4182
+ String get tts_download_unavailable;
4183
+
4184
+ /// No description provided for @tts_download_no_audio.
4185
+ ///
4186
+ /// In en, this message translates to:
4187
+ /// **'No audio available to download yet'**
4188
+ String get tts_download_no_audio;
4189
+
4190
+ /// No description provided for @tts_download_success.
4191
+ ///
4192
+ /// In en, this message translates to:
4193
+ /// **'Audio saved'**
4194
+ String get tts_download_success;
4195
+
4196
+ /// No description provided for @return_to_chat.
4197
+ ///
4198
+ /// In en, this message translates to:
4199
+ /// **'Return to chat'**
4200
+ String get return_to_chat;
4201
+
4202
+ /// No description provided for @return_to_temp_chat.
4203
+ ///
4204
+ /// In en, this message translates to:
4205
+ /// **'Return to temporary chat'**
4206
+ String get return_to_temp_chat;
4207
+
4208
+ /// No description provided for @insert_saved_message.
4209
+ ///
4210
+ /// In en, this message translates to:
4211
+ /// **'Insert saved message'**
4212
+ String get insert_saved_message;
4213
+
4214
+ /// No description provided for @insert_saved_message_desc.
4215
+ ///
4216
+ /// In en, this message translates to:
4217
+ /// **'Choose a saved message to add to your input'**
4218
+ String get insert_saved_message_desc;
4219
+
4220
+ /// No description provided for @model_info.
4221
+ ///
4222
+ /// In en, this message translates to:
4223
+ /// **'Model info'**
4224
+ String get model_info;
4225
+
4226
+ /// No description provided for @model_name.
4227
+ ///
4228
+ /// In en, this message translates to:
4229
+ /// **'Model name'**
4230
+ String get model_name;
4231
+
4232
+ /// No description provided for @model_identifier.
4233
+ ///
4234
+ /// In en, this message translates to:
4235
+ /// **'Identifier'**
4236
+ String get model_identifier;
4237
+
4238
+ /// No description provided for @not_available.
4239
+ ///
4240
+ /// In en, this message translates to:
4241
+ /// **'Not available'**
4242
+ String get not_available;
4243
+
4244
+ /// No description provided for @save_message_folders.
4245
+ ///
4246
+ /// In en, this message translates to:
4247
+ /// **'Save message'**
4248
+ String get save_message_folders;
4249
+
4250
+ /// No description provided for @remove_from_saved.
4251
+ ///
4252
+ /// In en, this message translates to:
4253
+ /// **'Remove from saved'**
4254
+ String get remove_from_saved;
4255
+
4256
+ /// No description provided for @message_already_saved.
4257
+ ///
4258
+ /// In en, this message translates to:
4259
+ /// **'Saved'**
4260
+ String get message_already_saved;
4261
+
4262
+ /// No description provided for @stream_ttft.
4263
+ ///
4264
+ /// In en, this message translates to:
4265
+ /// **'Time to first token'**
4266
+ String get stream_ttft;
4267
+
4268
+ /// No description provided for @stream_tokens_per_sec.
4269
+ ///
4270
+ /// In en, this message translates to:
4271
+ /// **'Tokens per second'**
4272
+ String get stream_tokens_per_sec;
4273
+
4274
+ /// No description provided for @stream_stop_reason.
4275
+ ///
4276
+ /// In en, this message translates to:
4277
+ /// **'Stop reason'**
4278
+ String get stream_stop_reason;
4279
+
4280
+ /// No description provided for @stream_input_tokens.
4281
+ ///
4282
+ /// In en, this message translates to:
4283
+ /// **'Input tokens'**
4284
+ String get stream_input_tokens;
4285
+
4286
+ /// No description provided for @stream_output_tokens.
4287
+ ///
4288
+ /// In en, this message translates to:
4289
+ /// **'Output tokens'**
4290
+ String get stream_output_tokens;
4291
+
4292
+ /// No description provided for @stream_generation_time.
4293
+ ///
4294
+ /// In en, this message translates to:
4295
+ /// **'Generation time'**
4296
+ String get stream_generation_time;
4297
+
4298
+ /// No description provided for @attach_image.
4299
+ ///
4300
+ /// In en, this message translates to:
4301
+ /// **'Image'**
4302
+ String get attach_image;
4303
+
4304
+ /// No description provided for @attach_text_document.
4305
+ ///
4306
+ /// In en, this message translates to:
4307
+ /// **'Text document'**
4308
+ String get attach_text_document;
4309
+
4310
+ /// No description provided for @add_attachment.
4311
+ ///
4312
+ /// In en, this message translates to:
4313
+ /// **'Add attachment'**
4314
+ String get add_attachment;
4315
+
4316
+ /// No description provided for @photo_permission_denied.
4317
+ ///
4318
+ /// In en, this message translates to:
4319
+ /// **'Photo access is required to attach images'**
4320
+ String get photo_permission_denied;
4321
+
4322
+ /// No description provided for @characters_label.
4323
+ ///
4324
+ /// In en, this message translates to:
4325
+ /// **'Characters'**
4326
+ String get characters_label;
4327
+
4328
+ /// No description provided for @exit_temporary_chat_title.
4329
+ ///
4330
+ /// In en, this message translates to:
4331
+ /// **'Exit temporary chat?'**
4332
+ String get exit_temporary_chat_title;
4333
+
4334
+ /// No description provided for @exit_temporary_chat_body.
4335
+ ///
4336
+ /// In en, this message translates to:
4337
+ /// **'This will discard the current temporary chat and return to a new chat.'**
4338
+ String get exit_temporary_chat_body;
4339
+
4340
+ /// No description provided for @saved_message_temp_snap_unavailable.
4341
+ ///
4342
+ /// In en, this message translates to:
4343
+ /// **'This message was saved from a temporary chat and can\'t be opened in its original conversation.'**
4344
+ String get saved_message_temp_snap_unavailable;
4345
+
4346
+ /// No description provided for @filter_title.
4347
+ ///
4348
+ /// In en, this message translates to:
4349
+ /// **'Filter'**
4350
+ String get filter_title;
4351
+
4352
+ /// No description provided for @filter_pinned.
4353
+ ///
4354
+ /// In en, this message translates to:
4355
+ /// **'Pinned'**
4356
+ String get filter_pinned;
4357
+
4358
+ /// No description provided for @filter_archived.
4359
+ ///
4360
+ /// In en, this message translates to:
4361
+ /// **'Archived'**
4362
+ String get filter_archived;
4363
+
4364
+ /// No description provided for @filter_temp_chats.
4365
+ ///
4366
+ /// In en, this message translates to:
4367
+ /// **'Temporary chats'**
4368
+ String get filter_temp_chats;
4369
+
4370
+ /// No description provided for @filter_user_messages.
4371
+ ///
4372
+ /// In en, this message translates to:
4373
+ /// **'User messages'**
4374
+ String get filter_user_messages;
4375
+
4376
+ /// No description provided for @filter_assistant_messages.
4377
+ ///
4378
+ /// In en, this message translates to:
4379
+ /// **'Assistant messages'**
4380
+ String get filter_assistant_messages;
4381
+
4382
+ /// No description provided for @archive_chat.
4383
+ ///
4384
+ /// In en, this message translates to:
4385
+ /// **'Archive'**
4386
+ String get archive_chat;
4387
+
4388
+ /// No description provided for @unarchive_chat.
4389
+ ///
4390
+ /// In en, this message translates to:
4391
+ /// **'Unarchive'**
4392
+ String get unarchive_chat;
4393
+
4394
+ /// No description provided for @conversation_message_count.
4395
+ ///
4396
+ /// In en, this message translates to:
4397
+ /// **'{count, plural, one{1 message} other{{count} messages}}'**
4398
+ String conversation_message_count(int count);
4399
+
4400
+ /// No description provided for @conversation_character_count.
4401
+ ///
4402
+ /// In en, this message translates to:
4403
+ /// **'{count} chars'**
4404
+ String conversation_character_count(int count);
4405
+
4406
+ /// No description provided for @generate_title_with_ai.
4407
+ ///
4408
+ /// In en, this message translates to:
4409
+ /// **'Generate with AI'**
4410
+ String get generate_title_with_ai;
4411
+
4412
+ /// No description provided for @generating_title.
4413
+ ///
4414
+ /// In en, this message translates to:
4415
+ /// **'Generating...'**
4416
+ String get generating_title;
4417
+
4418
+ /// No description provided for @generate_title_failed.
4419
+ ///
4420
+ /// In en, this message translates to:
4421
+ /// **'Could not generate a title'**
4422
+ String get generate_title_failed;
4423
}
4424
4425
class _AppLocalizationsDelegate
@@ -4126,6 +4440,7 @@ class _AppLocalizationsDelegate
4440
'hi',
4441
'it',
4442
'ja',
4443
+ 'ru',
4444
'zh',
4445
].contains(locale.languageCode);
4446
@@ -4162,6 +4477,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
4477
return AppLocalizationsIt();
4478
case 'ja':
4479
return AppLocalizationsJa();
4480
+ case 'ru':
4481
+ return AppLocalizationsRu();
4482
case 'zh':
4483
return AppLocalizationsZh();
4484
}
lib/l10n/app_localizations_ar.dart
+176
@@ -2147,6 +2147,18 @@ class AppLocalizationsAr extends AppLocalizations {
2147
String get tts_process_markdown_desc =>
2148
'Strip formatting like **bold** before reading aloud';
2149
2150
+ @override
2151
+ String get tts_skip_seconds => 'Skip interval';
2152
+
2153
+ @override
2154
+ String get tts_skip_seconds_desc =>
2155
+ 'Forward and rewind jump size during playback';
2156
+
2157
+ @override
2158
+ String tts_skip_seconds_value(int seconds) {
2159
+ return '${seconds}s';
2160
+ }
2161
+
2162
@override
2163
String get preview_system_prompts => 'Preview system prompts';
2164
@@ -2161,4 +2173,168 @@ class AppLocalizationsAr extends AppLocalizations {
2173
2174
@override
2175
String get welcome_message_4 => 'Need ideas? Try one of the quick prompts.';
2176
+
2177
+ @override
2178
+ String get temporary_chat => 'Temporary chat';
2179
+
2180
+ @override
2181
+ String get temporary_chat_desc => 'Chats aren\'t saved to history.';
2182
+
2183
+ @override
2184
+ String get temporary_chat_banner => 'Temporary chat — not saved to history';
2185
+
2186
+ @override
2187
+ String get temporary_chat_save_warning_title =>
2188
+ 'Save message in temporary chat?';
2189
+
2190
+ @override
2191
+ String get temporary_chat_save_warning_body =>
2192
+ 'This chat is temporary and hidden from history. The saved message will still appear in Saved Messages.';
2193
+
2194
+ @override
2195
+ String get save_to_history => 'Save to history';
2196
+
2197
+ @override
2198
+ String get share_conversation => 'Share conversation';
2199
+
2200
+ @override
2201
+ String get download_tts_audio => 'Download audio';
2202
+
2203
+ @override
2204
+ String get tts_download_unavailable =>
2205
+ 'Download is only available for Piper and Kitten TTS';
2206
+
2207
+ @override
2208
+ String get tts_download_no_audio => 'No audio available to download yet';
2209
+
2210
+ @override
2211
+ String get tts_download_success => 'Audio saved';
2212
+
2213
+ @override
2214
+ String get return_to_chat => 'Return to chat';
2215
+
2216
+ @override
2217
+ String get return_to_temp_chat => 'Return to temporary chat';
2218
+
2219
+ @override
2220
+ String get insert_saved_message => 'Insert saved message';
2221
+
2222
+ @override
2223
+ String get insert_saved_message_desc =>
2224
+ 'Choose a saved message to add to your input';
2225
+
2226
+ @override
2227
+ String get model_info => 'Model info';
2228
+
2229
+ @override
2230
+ String get model_name => 'Model name';
2231
+
2232
+ @override
2233
+ String get model_identifier => 'Identifier';
2234
+
2235
+ @override
2236
+ String get not_available => 'Not available';
2237
+
2238
+ @override
2239
+ String get save_message_folders => 'Save message';
2240
+
2241
+ @override
2242
+ String get remove_from_saved => 'Remove from saved';
2243
+
2244
+ @override
2245
+ String get message_already_saved => 'Saved';
2246
+
2247
+ @override
2248
+ String get stream_ttft => 'Time to first token';
2249
+
2250
+ @override
2251
+ String get stream_tokens_per_sec => 'Tokens per second';
2252
+
2253
+ @override
2254
+ String get stream_stop_reason => 'Stop reason';
2255
+
2256
+ @override
2257
+ String get stream_input_tokens => 'Input tokens';
2258
+
2259
+ @override
2260
+ String get stream_output_tokens => 'Output tokens';
2261
+
2262
+ @override
2263
+ String get stream_generation_time => 'Generation time';
2264
+
2265
+ @override
2266
+ String get attach_image => 'Image';
2267
+
2268
+ @override
2269
+ String get attach_text_document => 'Text document';
2270
+
2271
+ @override
2272
+ String get add_attachment => 'Add attachment';
2273
+
2274
+ @override
2275
+ String get photo_permission_denied =>
2276
+ 'Photo access is required to attach images';
2277
+
2278
+ @override
2279
+ String get characters_label => 'Characters';
2280
+
2281
+ @override
2282
+ String get exit_temporary_chat_title => 'Exit temporary chat?';
2283
+
2284
+ @override
2285
+ String get exit_temporary_chat_body =>
2286
+ 'This will discard the current temporary chat and return to a new chat.';
2287
+
2288
+ @override
2289
+ String get saved_message_temp_snap_unavailable =>
2290
+ 'This message was saved from a temporary chat and can\'t be opened in its original conversation.';
2291
+
2292
+ @override
2293
+ String get filter_title => 'Filter';
2294
+
2295
+ @override
2296
+ String get filter_pinned => 'Pinned';
2297
+
2298
+ @override
2299
+ String get filter_archived => 'Archived';
2300
+
2301
+ @override
2302
+ String get filter_temp_chats => 'Temporary chats';
2303
+
2304
+ @override
2305
+ String get filter_user_messages => 'User messages';
2306
+
2307
+ @override
2308
+ String get filter_assistant_messages => 'Assistant messages';
2309
+
2310
+ @override
2311
+ String get archive_chat => 'Archive';
2312
+
2313
+ @override
2314
+ String get unarchive_chat => 'Unarchive';
2315
+
2316
+ @override
2317
+ String conversation_message_count(int count) {
2318
+ String _temp0 = intl.Intl.pluralLogic(
2319
+ count,
2320
+ locale: localeName,
2321
+ other: '$count messages',
2322
+ one: '1 message',
2323
+ );
2324
+ return '$_temp0';
2325
+ }
2326
+
2327
+ @override
2328
+ String conversation_character_count(int count) {
2329
+ return '$count chars';
2330
+ }
2331
+
2332
+ @override
2333
+ String get generate_title_with_ai => 'Generate with AI';
2334
+
2335
+ @override
2336
+ String get generating_title => 'Generating...';
2337
+
2338
+ @override
2339
+ String get generate_title_failed => 'Could not generate a title';
2340
}
lib/l10n/app_localizations_bn.dart
+176
@@ -2160,6 +2160,18 @@ class AppLocalizationsBn extends AppLocalizations {
2160
String get tts_process_markdown_desc =>
2161
'Strip formatting like **bold** before reading aloud';
2162
2163
+ @override
2164
+ String get tts_skip_seconds => 'Skip interval';
2165
+
2166
+ @override
2167
+ String get tts_skip_seconds_desc =>
2168
+ 'Forward and rewind jump size during playback';
2169
+
2170
+ @override
2171
+ String tts_skip_seconds_value(int seconds) {
2172
+ return '${seconds}s';
2173
+ }
2174
+
2175
@override
2176
String get preview_system_prompts => 'Preview system prompts';
2177
@@ -2174,4 +2186,168 @@ class AppLocalizationsBn extends AppLocalizations {
2186
2187
@override
2188
String get welcome_message_4 => 'Need ideas? Try one of the quick prompts.';
2189
+
2190
+ @override
2191
+ String get temporary_chat => 'Temporary chat';
2192
+
2193
+ @override
2194
+ String get temporary_chat_desc => 'Chats aren\'t saved to history.';
2195
+
2196
+ @override
2197
+ String get temporary_chat_banner => 'Temporary chat — not saved to history';
2198
+
2199
+ @override
2200
+ String get temporary_chat_save_warning_title =>
2201
+ 'Save message in temporary chat?';
2202
+
2203
+ @override
2204
+ String get temporary_chat_save_warning_body =>
2205
+ 'This chat is temporary and hidden from history. The saved message will still appear in Saved Messages.';
2206
+
2207
+ @override
2208
+ String get save_to_history => 'Save to history';
2209
+
2210
+ @override
2211
+ String get share_conversation => 'Share conversation';
2212
+
2213
+ @override
2214
+ String get download_tts_audio => 'Download audio';
2215
+
2216
+ @override
2217
+ String get tts_download_unavailable =>
2218
+ 'Download is only available for Piper and Kitten TTS';
2219
+
2220
+ @override
2221
+ String get tts_download_no_audio => 'No audio available to download yet';
2222
+
2223
+ @override
2224
+ String get tts_download_success => 'Audio saved';
2225
+
2226
+ @override
2227
+ String get return_to_chat => 'Return to chat';
2228
+
2229
+ @override
2230
+ String get return_to_temp_chat => 'Return to temporary chat';
2231
+
2232
+ @override
2233
+ String get insert_saved_message => 'Insert saved message';
2234
+
2235
+ @override
2236
+ String get insert_saved_message_desc =>
2237
+ 'Choose a saved message to add to your input';
2238
+
2239
+ @override
2240
+ String get model_info => 'Model info';
2241
+
2242
+ @override
2243
+ String get model_name => 'Model name';
2244
+
2245
+ @override
2246
+ String get model_identifier => 'Identifier';
2247
+
2248
+ @override
2249
+ String get not_available => 'Not available';
2250
+
2251
+ @override
2252
+ String get save_message_folders => 'Save message';
2253
+
2254
+ @override
2255
+ String get remove_from_saved => 'Remove from saved';
2256
+
2257
+ @override
2258
+ String get message_already_saved => 'Saved';
2259
+
2260
+ @override
2261
+ String get stream_ttft => 'Time to first token';
2262
+
2263
+ @override
2264
+ String get stream_tokens_per_sec => 'Tokens per second';
2265
+
2266
+ @override
2267
+ String get stream_stop_reason => 'Stop reason';
2268
+
2269
+ @override
2270
+ String get stream_input_tokens => 'Input tokens';
2271
+
2272
+ @override
2273
+ String get stream_output_tokens => 'Output tokens';
2274
+
2275
+ @override
2276
+ String get stream_generation_time => 'Generation time';
2277
+
2278
+ @override
2279
+ String get attach_image => 'Image';
2280
+
2281
+ @override
2282
+ String get attach_text_document => 'Text document';
2283
+
2284
+ @override
2285
+ String get add_attachment => 'Add attachment';
2286
+
2287
+ @override
2288
+ String get photo_permission_denied =>
2289
+ 'Photo access is required to attach images';
2290
+
2291
+ @override
2292
+ String get characters_label => 'Characters';
2293
+
2294
+ @override
2295
+ String get exit_temporary_chat_title => 'Exit temporary chat?';
2296
+
2297
+ @override
2298
+ String get exit_temporary_chat_body =>
2299
+ 'This will discard the current temporary chat and return to a new chat.';
2300
+
2301
+ @override
2302
+ String get saved_message_temp_snap_unavailable =>
2303
+ 'This message was saved from a temporary chat and can\'t be opened in its original conversation.';
2304
+
2305
+ @override
2306
+ String get filter_title => 'Filter';
2307
+
2308
+ @override
2309
+ String get filter_pinned => 'Pinned';
2310
+
2311
+ @override
2312
+ String get filter_archived => 'Archived';
2313
+
2314
+ @override
2315
+ String get filter_temp_chats => 'Temporary chats';
2316
+
2317
+ @override
2318
+ String get filter_user_messages => 'User messages';
2319
+
2320
+ @override
2321
+ String get filter_assistant_messages => 'Assistant messages';
2322
+
2323
+ @override
2324
+ String get archive_chat => 'Archive';
2325
+
2326
+ @override
2327
+ String get unarchive_chat => 'Unarchive';
2328
+
2329
+ @override
2330
+ String conversation_message_count(int count) {
2331
+ String _temp0 = intl.Intl.pluralLogic(
2332
+ count,
2333
+ locale: localeName,
2334
+ other: '$count messages',
2335
+ one: '1 message',
2336
+ );
2337
+ return '$_temp0';
2338
+ }
2339
+
2340
+ @override
2341
+ String conversation_character_count(int count) {
2342
+ return '$count chars';
2343
+ }
2344
+
2345
+ @override
2346
+ String get generate_title_with_ai => 'Generate with AI';
2347
+
2348
+ @override
2349
+ String get generating_title => 'Generating...';
2350
+
2351
+ @override
2352
+ String get generate_title_failed => 'Could not generate a title';
2353
}
lib/l10n/app_localizations_en.dart
+177
-1
@@ -594,7 +594,7 @@ class AppLocalizationsEn extends AppLocalizations {
594
String get stop_generation_tooltip => 'Stop generation';
595
596
@override
597
- String get attach_images_tooltip => 'Attach images';
597
+ String get attach_images_tooltip => 'Attach images or text';
598
599
@override
600
String get start_listening_tooltip => 'Start listening';
@@ -2152,6 +2152,18 @@ class AppLocalizationsEn extends AppLocalizations {
2152
String get tts_process_markdown_desc =>
2153
'Strip formatting like **bold** before reading aloud';
2154
2155
+ @override
2156
+ String get tts_skip_seconds => 'Skip interval';
2157
+
2158
+ @override
2159
+ String get tts_skip_seconds_desc =>
2160
+ 'Forward and rewind jump size during playback';
2161
+
2162
+ @override
2163
+ String tts_skip_seconds_value(int seconds) {
2164
+ return '${seconds}s';
2165
+ }
2166
+
2167
@override
2168
String get preview_system_prompts => 'Preview system prompts';
2169
@@ -2166,4 +2178,168 @@ class AppLocalizationsEn extends AppLocalizations {
2178
2179
@override
2180
String get welcome_message_4 => 'Need ideas? Try one of the quick prompts.';
2181
+
2182
+ @override
2183
+ String get temporary_chat => 'Temporary chat';
2184
+
2185
+ @override
2186
+ String get temporary_chat_desc => 'Chats aren\'t saved to history.';
2187
+
2188
+ @override
2189
+ String get temporary_chat_banner => 'Temporary chat — not saved to history';
2190
+
2191
+ @override
2192
+ String get temporary_chat_save_warning_title =>
2193
+ 'Save message in temporary chat?';
2194
+
2195
+ @override
2196
+ String get temporary_chat_save_warning_body =>
2197
+ 'This chat is temporary and hidden from history. The saved message will still appear in Saved Messages.';
2198
+
2199
+ @override
2200
+ String get save_to_history => 'Save to history';
2201
+
2202
+ @override
2203
+ String get share_conversation => 'Share conversation';
2204
+
2205
+ @override
2206
+ String get download_tts_audio => 'Download audio';
2207
+
2208
+ @override
2209
+ String get tts_download_unavailable =>
2210
+ 'Download is only available for Piper and Kitten TTS';
2211
+
2212
+ @override
2213
+ String get tts_download_no_audio => 'No audio available to download yet';
2214
+
2215
+ @override
2216
+ String get tts_download_success => 'Audio saved';
2217
+
2218
+ @override
2219
+ String get return_to_chat => 'Return to chat';
2220
+
2221
+ @override
2222
+ String get return_to_temp_chat => 'Return to temporary chat';
2223
+
2224
+ @override
2225
+ String get insert_saved_message => 'Insert saved message';
2226
+
2227
+ @override
2228
+ String get insert_saved_message_desc =>
2229
+ 'Choose a saved message to add to your input';
2230
+
2231
+ @override
2232
+ String get model_info => 'Model info';
2233
+
2234
+ @override
2235
+ String get model_name => 'Model name';
2236
+
2237
+ @override
2238
+ String get model_identifier => 'Identifier';
2239
+
2240
+ @override
2241
+ String get not_available => 'Not available';
2242
+
2243
+ @override
2244
+ String get save_message_folders => 'Save message';
2245
+
2246
+ @override
2247
+ String get remove_from_saved => 'Remove from saved';
2248
+
2249
+ @override
2250
+ String get message_already_saved => 'Saved';
2251
+
2252
+ @override
2253
+ String get stream_ttft => 'Time to first token';
2254
+
2255
+ @override
2256
+ String get stream_tokens_per_sec => 'Tokens per second';
2257
+
2258
+ @override
2259
+ String get stream_stop_reason => 'Stop reason';
2260
+
2261
+ @override
2262
+ String get stream_input_tokens => 'Input tokens';
2263
+
2264
+ @override
2265
+ String get stream_output_tokens => 'Output tokens';
2266
+
2267
+ @override
2268
+ String get stream_generation_time => 'Generation time';
2269
+
2270
+ @override
2271
+ String get attach_image => 'Image';
2272
+
2273
+ @override
2274
+ String get attach_text_document => 'Text document';
2275
+
2276
+ @override
2277
+ String get add_attachment => 'Add attachment';
2278
+
2279
+ @override
2280
+ String get photo_permission_denied =>
2281
+ 'Photo access is required to attach images';
2282
+
2283
+ @override
2284
+ String get characters_label => 'Characters';
2285
+
2286
+ @override
2287
+ String get exit_temporary_chat_title => 'Exit temporary chat?';
2288
+
2289
+ @override
2290
+ String get exit_temporary_chat_body =>
2291
+ 'This will discard the current temporary chat and return to a new chat.';
2292
+
2293
+ @override
2294
+ String get saved_message_temp_snap_unavailable =>
2295
+ 'This message was saved from a temporary chat and can\'t be opened in its original conversation.';
2296
+
2297
+ @override
2298
+ String get filter_title => 'Filter';
2299
+
2300
+ @override
2301
+ String get filter_pinned => 'Pinned';
2302
+
2303
+ @override
2304
+ String get filter_archived => 'Archived';
2305
+
2306
+ @override
2307
+ String get filter_temp_chats => 'Temporary chats';
2308
+
2309
+ @override
2310
+ String get filter_user_messages => 'User messages';
2311
+
2312
+ @override
2313
+ String get filter_assistant_messages => 'Assistant messages';
2314
+
2315
+ @override
2316
+ String get archive_chat => 'Archive';
2317
+
2318
+ @override
2319
+ String get unarchive_chat => 'Unarchive';
2320
+
2321
+ @override
2322
+ String conversation_message_count(int count) {
2323
+ String _temp0 = intl.Intl.pluralLogic(
2324
+ count,
2325
+ locale: localeName,
2326
+ other: '$count messages',
2327
+ one: '1 message',
2328
+ );
2329
+ return '$_temp0';
2330
+ }
2331
+
2332
+ @override
2333
+ String conversation_character_count(int count) {
2334
+ return '$count chars';
2335
+ }
2336
+
2337
+ @override
2338
+ String get generate_title_with_ai => 'Generate with AI';
2339
+
2340
+ @override
2341
+ String get generating_title => 'Generating...';
2342
+
2343
+ @override
2344
+ String get generate_title_failed => 'Could not generate a title';
2345
}
lib/l10n/app_localizations_es.dart
+176
@@ -2167,6 +2167,18 @@ class AppLocalizationsEs extends AppLocalizations {
2167
String get tts_process_markdown_desc =>
2168
'Strip formatting like **bold** before reading aloud';
2169
2170
+ @override
2171
+ String get tts_skip_seconds => 'Skip interval';
2172
+
2173
+ @override
2174
+ String get tts_skip_seconds_desc =>
2175
+ 'Forward and rewind jump size during playback';
2176
+
2177
+ @override
2178
+ String tts_skip_seconds_value(int seconds) {
2179
+ return '${seconds}s';
2180
+ }
2181
+
2182
@override
2183
String get preview_system_prompts => 'Preview system prompts';
2184
@@ -2181,4 +2193,168 @@ class AppLocalizationsEs extends AppLocalizations {
2193
2194
@override
2195
String get welcome_message_4 => 'Need ideas? Try one of the quick prompts.';
2196
+
2197
+ @override
2198
+ String get temporary_chat => 'Temporary chat';
2199
+
2200
+ @override
2201
+ String get temporary_chat_desc => 'Chats aren\'t saved to history.';
2202
+
2203
+ @override
2204
+ String get temporary_chat_banner => 'Temporary chat — not saved to history';
2205
+
2206
+ @override
2207
+ String get temporary_chat_save_warning_title =>
2208
+ 'Save message in temporary chat?';
2209
+
2210
+ @override
2211
+ String get temporary_chat_save_warning_body =>
2212
+ 'This chat is temporary and hidden from history. The saved message will still appear in Saved Messages.';
2213
+
2214
+ @override
2215
+ String get save_to_history => 'Save to history';
2216
+
2217
+ @override
2218
+ String get share_conversation => 'Share conversation';
2219
+
2220
+ @override
2221
+ String get download_tts_audio => 'Download audio';
2222
+
2223
+ @override
2224
+ String get tts_download_unavailable =>
2225
+ 'Download is only available for Piper and Kitten TTS';
2226
+
2227
+ @override
2228
+ String get tts_download_no_audio => 'No audio available to download yet';
2229
+
2230
+ @override
2231
+ String get tts_download_success => 'Audio saved';
2232
+
2233
+ @override
2234
+ String get return_to_chat => 'Return to chat';
2235
+
2236
+ @override
2237
+ String get return_to_temp_chat => 'Return to temporary chat';
2238
+
2239
+ @override
2240
+ String get insert_saved_message => 'Insert saved message';
2241
+
2242
+ @override
2243
+ String get insert_saved_message_desc =>
2244
+ 'Choose a saved message to add to your input';
2245
+
2246
+ @override
2247
+ String get model_info => 'Model info';
2248
+
2249
+ @override
2250
+ String get model_name => 'Model name';
2251
+
2252
+ @override
2253
+ String get model_identifier => 'Identifier';
2254
+
2255
+ @override
2256
+ String get not_available => 'Not available';
2257
+
2258
+ @override
2259
+ String get save_message_folders => 'Save message';
2260
+
2261
+ @override
2262
+ String get remove_from_saved => 'Remove from saved';
2263
+
2264
+ @override
2265
+ String get message_already_saved => 'Saved';
2266
+
2267
+ @override
2268
+ String get stream_ttft => 'Time to first token';
2269
+
2270
+ @override
2271
+ String get stream_tokens_per_sec => 'Tokens per second';
2272
+
2273
+ @override
2274
+ String get stream_stop_reason => 'Stop reason';
2275
+
2276
+ @override
2277
+ String get stream_input_tokens => 'Input tokens';
2278
+
2279
+ @override
2280
+ String get stream_output_tokens => 'Output tokens';
2281
+
2282
+ @override
2283
+ String get stream_generation_time => 'Generation time';
2284
+
2285
+ @override
2286
+ String get attach_image => 'Image';
2287
+
2288
+ @override
2289
+ String get attach_text_document => 'Text document';
2290
+
2291
+ @override
2292
+ String get add_attachment => 'Add attachment';
2293
+
2294
+ @override
2295
+ String get photo_permission_denied =>
2296
+ 'Photo access is required to attach images';
2297
+
2298
+ @override
2299
+ String get characters_label => 'Characters';
2300
+
2301
+ @override
2302
+ String get exit_temporary_chat_title => 'Exit temporary chat?';
2303
+
2304
+ @override
2305
+ String get exit_temporary_chat_body =>
2306
+ 'This will discard the current temporary chat and return to a new chat.';
2307
+
2308
+ @override
2309
+ String get saved_message_temp_snap_unavailable =>
2310
+ 'This message was saved from a temporary chat and can\'t be opened in its original conversation.';
2311
+
2312
+ @override
2313
+ String get filter_title => 'Filter';
2314
+
2315
+ @override
2316
+ String get filter_pinned => 'Pinned';
2317
+
2318
+ @override
2319
+ String get filter_archived => 'Archived';
2320
+
2321
+ @override
2322
+ String get filter_temp_chats => 'Temporary chats';
2323
+
2324
+ @override
2325
+ String get filter_user_messages => 'User messages';
2326
+
2327
+ @override
2328
+ String get filter_assistant_messages => 'Assistant messages';
2329
+
2330
+ @override
2331
+ String get archive_chat => 'Archive';
2332
+
2333
+ @override
2334
+ String get unarchive_chat => 'Unarchive';
2335
+
2336
+ @override
2337
+ String conversation_message_count(int count) {
2338
+ String _temp0 = intl.Intl.pluralLogic(
2339
+ count,
2340
+ locale: localeName,
2341
+ other: '$count messages',
2342
+ one: '1 message',
2343
+ );
2344
+ return '$_temp0';
2345
+ }
2346
+
2347
+ @override
2348
+ String conversation_character_count(int count) {
2349
+ return '$count chars';
2350
+ }
2351
+
2352
+ @override
2353
+ String get generate_title_with_ai => 'Generate with AI';
2354
+
2355
+ @override
2356
+ String get generating_title => 'Generating...';
2357
+
2358
+ @override
2359
+ String get generate_title_failed => 'Could not generate a title';
2360
}
lib/l10n/app_localizations_hi.dart
+176
@@ -2156,6 +2156,18 @@ class AppLocalizationsHi extends AppLocalizations {
2156
String get tts_process_markdown_desc =>
2157
'Strip formatting like **bold** before reading aloud';
2158
2159
+ @override
2160
+ String get tts_skip_seconds => 'Skip interval';
2161
+
2162
+ @override
2163
+ String get tts_skip_seconds_desc =>
2164
+ 'Forward and rewind jump size during playback';
2165
+
2166
+ @override
2167
+ String tts_skip_seconds_value(int seconds) {
2168
+ return '${seconds}s';
2169
+ }
2170
+
2171
@override
2172
String get preview_system_prompts => 'Preview system prompts';
2173
@@ -2170,4 +2182,168 @@ class AppLocalizationsHi extends AppLocalizations {
2182
2183
@override
2184
String get welcome_message_4 => 'Need ideas? Try one of the quick prompts.';
2185
+
2186
+ @override
2187
+ String get temporary_chat => 'Temporary chat';
2188
+
2189
+ @override
2190
+ String get temporary_chat_desc => 'Chats aren\'t saved to history.';
2191
+
2192
+ @override
2193
+ String get temporary_chat_banner => 'Temporary chat — not saved to history';
2194
+
2195
+ @override
2196
+ String get temporary_chat_save_warning_title =>
2197
+ 'Save message in temporary chat?';
2198
+
2199
+ @override
2200
+ String get temporary_chat_save_warning_body =>
2201
+ 'This chat is temporary and hidden from history. The saved message will still appear in Saved Messages.';
2202
+
2203
+ @override
2204
+ String get save_to_history => 'Save to history';
2205
+
2206
+ @override
2207
+ String get share_conversation => 'Share conversation';
2208
+
2209
+ @override
2210
+ String get download_tts_audio => 'Download audio';
2211
+
2212
+ @override
2213
+ String get tts_download_unavailable =>
2214
+ 'Download is only available for Piper and Kitten TTS';
2215
+
2216
+ @override
2217
+ String get tts_download_no_audio => 'No audio available to download yet';
2218
+
2219
+ @override
2220
+ String get tts_download_success => 'Audio saved';
2221
+
2222
+ @override
2223
+ String get return_to_chat => 'Return to chat';
2224
+
2225
+ @override
2226
+ String get return_to_temp_chat => 'Return to temporary chat';
2227
+
2228
+ @override
2229
+ String get insert_saved_message => 'Insert saved message';
2230
+
2231
+ @override
2232
+ String get insert_saved_message_desc =>
2233
+ 'Choose a saved message to add to your input';
2234
+
2235
+ @override
2236
+ String get model_info => 'Model info';
2237
+
2238
+ @override
2239
+ String get model_name => 'Model name';
2240
+
2241
+ @override
2242
+ String get model_identifier => 'Identifier';
2243
+
2244
+ @override
2245
+ String get not_available => 'Not available';
2246
+
2247
+ @override
2248
+ String get save_message_folders => 'Save message';
2249
+
2250
+ @override
2251
+ String get remove_from_saved => 'Remove from saved';
2252
+
2253
+ @override
2254
+ String get message_already_saved => 'Saved';
2255
+
2256
+ @override
2257
+ String get stream_ttft => 'Time to first token';
2258
+
2259
+ @override
2260
+ String get stream_tokens_per_sec => 'Tokens per second';
2261
+
2262
+ @override
2263
+ String get stream_stop_reason => 'Stop reason';
2264
+
2265
+ @override
2266
+ String get stream_input_tokens => 'Input tokens';
2267
+
2268
+ @override
2269
+ String get stream_output_tokens => 'Output tokens';
2270
+
2271
+ @override
2272
+ String get stream_generation_time => 'Generation time';
2273
+
2274
+ @override
2275
+ String get attach_image => 'Image';
2276
+
2277
+ @override
2278
+ String get attach_text_document => 'Text document';
2279
+
2280
+ @override
2281
+ String get add_attachment => 'Add attachment';
2282
+
2283
+ @override
2284
+ String get photo_permission_denied =>
2285
+ 'Photo access is required to attach images';
2286
+
2287
+ @override
2288
+ String get characters_label => 'Characters';
2289
+
2290
+ @override
2291
+ String get exit_temporary_chat_title => 'Exit temporary chat?';
2292
+
2293
+ @override
2294
+ String get exit_temporary_chat_body =>
2295
+ 'This will discard the current temporary chat and return to a new chat.';
2296
+
2297
+ @override
2298
+ String get saved_message_temp_snap_unavailable =>
2299
+ 'This message was saved from a temporary chat and can\'t be opened in its original conversation.';
2300
+
2301
+ @override
2302
+ String get filter_title => 'Filter';
2303
+
2304
+ @override
2305
+ String get filter_pinned => 'Pinned';
2306
+
2307
+ @override
2308
+ String get filter_archived => 'Archived';
2309
+
2310
+ @override
2311
+ String get filter_temp_chats => 'Temporary chats';
2312
+
2313
+ @override
2314
+ String get filter_user_messages => 'User messages';
2315
+
2316
+ @override
2317
+ String get filter_assistant_messages => 'Assistant messages';
2318
+
2319
+ @override
2320
+ String get archive_chat => 'Archive';
2321
+
2322
+ @override
2323
+ String get unarchive_chat => 'Unarchive';
2324
+
2325
+ @override
2326
+ String conversation_message_count(int count) {
2327
+ String _temp0 = intl.Intl.pluralLogic(
2328
+ count,
2329
+ locale: localeName,
2330
+ other: '$count messages',
2331
+ one: '1 message',
2332
+ );
2333
+ return '$_temp0';
2334
+ }
2335
+
2336
+ @override
2337
+ String conversation_character_count(int count) {
2338
+ return '$count chars';
2339
+ }
2340
+
2341
+ @override
2342
+ String get generate_title_with_ai => 'Generate with AI';
2343
+
2344
+ @override
2345
+ String get generating_title => 'Generating...';
2346
+
2347
+ @override
2348
+ String get generate_title_failed => 'Could not generate a title';
2349
}
lib/l10n/app_localizations_it.dart
+176
@@ -2167,6 +2167,18 @@ class AppLocalizationsIt extends AppLocalizations {
2167
String get tts_process_markdown_desc =>
2168
'Strip formatting like **bold** before reading aloud';
2169
2170
+ @override
2171
+ String get tts_skip_seconds => 'Skip interval';
2172
+
2173
+ @override
2174
+ String get tts_skip_seconds_desc =>
2175
+ 'Forward and rewind jump size during playback';
2176
+
2177
+ @override
2178
+ String tts_skip_seconds_value(int seconds) {
2179
+ return '${seconds}s';
2180
+ }
2181
+
2182
@override
2183
String get preview_system_prompts => 'Preview system prompts';
2184
@@ -2181,4 +2193,168 @@ class AppLocalizationsIt extends AppLocalizations {
2193
2194
@override
2195
String get welcome_message_4 => 'Need ideas? Try one of the quick prompts.';
2196
+
2197
+ @override
2198
+ String get temporary_chat => 'Temporary chat';
2199
+
2200
+ @override
2201
+ String get temporary_chat_desc => 'Chats aren\'t saved to history.';
2202
+
2203
+ @override
2204
+ String get temporary_chat_banner => 'Temporary chat — not saved to history';
2205
+
2206
+ @override
2207
+ String get temporary_chat_save_warning_title =>
2208
+ 'Save message in temporary chat?';
2209
+
2210
+ @override
2211
+ String get temporary_chat_save_warning_body =>
2212
+ 'This chat is temporary and hidden from history. The saved message will still appear in Saved Messages.';
2213
+
2214
+ @override
2215
+ String get save_to_history => 'Save to history';
2216
+
2217
+ @override
2218
+ String get share_conversation => 'Share conversation';
2219
+
2220
+ @override
2221
+ String get download_tts_audio => 'Download audio';
2222
+
2223
+ @override
2224
+ String get tts_download_unavailable =>
2225
+ 'Download is only available for Piper and Kitten TTS';
2226
+
2227
+ @override
2228
+ String get tts_download_no_audio => 'No audio available to download yet';
2229
+
2230
+ @override
2231
+ String get tts_download_success => 'Audio saved';
2232
+
2233
+ @override
2234
+ String get return_to_chat => 'Return to chat';
2235
+
2236
+ @override
2237
+ String get return_to_temp_chat => 'Return to temporary chat';
2238
+
2239
+ @override
2240
+ String get insert_saved_message => 'Insert saved message';
2241
+
2242
+ @override
2243
+ String get insert_saved_message_desc =>
2244
+ 'Choose a saved message to add to your input';
2245
+
2246
+ @override
2247
+ String get model_info => 'Model info';
2248
+
2249
+ @override
2250
+ String get model_name => 'Model name';
2251
+
2252
+ @override
2253
+ String get model_identifier => 'Identifier';
2254
+
2255
+ @override
2256
+ String get not_available => 'Not available';
2257
+
2258
+ @override
2259
+ String get save_message_folders => 'Save message';
2260
+
2261
+ @override
2262
+ String get remove_from_saved => 'Remove from saved';
2263
+
2264
+ @override
2265
+ String get message_already_saved => 'Saved';
2266
+
2267
+ @override
2268
+ String get stream_ttft => 'Time to first token';
2269
+
2270
+ @override
2271
+ String get stream_tokens_per_sec => 'Tokens per second';
2272
+
2273
+ @override
2274
+ String get stream_stop_reason => 'Stop reason';
2275
+
2276
+ @override
2277
+ String get stream_input_tokens => 'Input tokens';
2278
+
2279
+ @override
2280
+ String get stream_output_tokens => 'Output tokens';
2281
+
2282
+ @override
2283
+ String get stream_generation_time => 'Generation time';
2284
+
2285
+ @override
2286
+ String get attach_image => 'Image';
2287
+
2288
+ @override
2289
+ String get attach_text_document => 'Text document';
2290
+
2291
+ @override
2292
+ String get add_attachment => 'Add attachment';
2293
+
2294
+ @override
2295
+ String get photo_permission_denied =>
2296
+ 'Photo access is required to attach images';
2297
+
2298
+ @override
2299
+ String get characters_label => 'Characters';
2300
+
2301
+ @override
2302
+ String get exit_temporary_chat_title => 'Exit temporary chat?';
2303
+
2304
+ @override
2305
+ String get exit_temporary_chat_body =>
2306
+ 'This will discard the current temporary chat and return to a new chat.';
2307
+
2308
+ @override
2309
+ String get saved_message_temp_snap_unavailable =>
2310
+ 'This message was saved from a temporary chat and can\'t be opened in its original conversation.';
2311
+
2312
+ @override
2313
+ String get filter_title => 'Filter';
2314
+
2315
+ @override
2316
+ String get filter_pinned => 'Pinned';
2317
+
2318
+ @override
2319
+ String get filter_archived => 'Archived';
2320
+
2321
+ @override
2322
+ String get filter_temp_chats => 'Temporary chats';
2323
+
2324
+ @override
2325
+ String get filter_user_messages => 'User messages';
2326
+
2327
+ @override
2328
+ String get filter_assistant_messages => 'Assistant messages';
2329
+
2330
+ @override
2331
+ String get archive_chat => 'Archive';
2332
+
2333
+ @override
2334
+ String get unarchive_chat => 'Unarchive';
2335
+
2336
+ @override
2337
+ String conversation_message_count(int count) {
2338
+ String _temp0 = intl.Intl.pluralLogic(
2339
+ count,
2340
+ locale: localeName,
2341
+ other: '$count messages',
2342
+ one: '1 message',
2343
+ );
2344
+ return '$_temp0';
2345
+ }
2346
+
2347
+ @override
2348
+ String conversation_character_count(int count) {
2349
+ return '$count chars';
2350
+ }
2351
+
2352
+ @override
2353
+ String get generate_title_with_ai => 'Generate with AI';
2354
+
2355
+ @override
2356
+ String get generating_title => 'Generating...';
2357
+
2358
+ @override
2359
+ String get generate_title_failed => 'Could not generate a title';
2360
}
lib/l10n/app_localizations_ja.dart
+176
@@ -2129,6 +2129,18 @@ class AppLocalizationsJa extends AppLocalizations {
2129
String get tts_process_markdown_desc =>
2130
'Strip formatting like **bold** before reading aloud';
2131
2132
+ @override
2133
+ String get tts_skip_seconds => 'Skip interval';
2134
+
2135
+ @override
2136
+ String get tts_skip_seconds_desc =>
2137
+ 'Forward and rewind jump size during playback';
2138
+
2139
+ @override
2140
+ String tts_skip_seconds_value(int seconds) {
2141
+ return '${seconds}s';
2142
+ }
2143
+
2144
@override
2145
String get preview_system_prompts => 'Preview system prompts';
2146
@@ -2143,4 +2155,168 @@ class AppLocalizationsJa extends AppLocalizations {
2155
2156
@override
2157
String get welcome_message_4 => 'Need ideas? Try one of the quick prompts.';
2158
+
2159
+ @override
2160
+ String get temporary_chat => 'Temporary chat';
2161
+
2162
+ @override
2163
+ String get temporary_chat_desc => 'Chats aren\'t saved to history.';
2164
+
2165
+ @override
2166
+ String get temporary_chat_banner => 'Temporary chat — not saved to history';
2167
+
2168
+ @override
2169
+ String get temporary_chat_save_warning_title =>
2170
+ 'Save message in temporary chat?';
2171
+
2172
+ @override
2173
+ String get temporary_chat_save_warning_body =>
2174
+ 'This chat is temporary and hidden from history. The saved message will still appear in Saved Messages.';
2175
+
2176
+ @override
2177
+ String get save_to_history => 'Save to history';
2178
+
2179
+ @override
2180
+ String get share_conversation => 'Share conversation';
2181
+
2182
+ @override
2183
+ String get download_tts_audio => 'Download audio';
2184
+
2185
+ @override
2186
+ String get tts_download_unavailable =>
2187
+ 'Download is only available for Piper and Kitten TTS';
2188
+
2189
+ @override
2190
+ String get tts_download_no_audio => 'No audio available to download yet';
2191
+
2192
+ @override
2193
+ String get tts_download_success => 'Audio saved';
2194
+
2195
+ @override
2196
+ String get return_to_chat => 'Return to chat';
2197
+
2198
+ @override
2199
+ String get return_to_temp_chat => 'Return to temporary chat';
2200
+
2201
+ @override
2202
+ String get insert_saved_message => 'Insert saved message';
2203
+
2204
+ @override
2205
+ String get insert_saved_message_desc =>
2206
+ 'Choose a saved message to add to your input';
2207
+
2208
+ @override
2209
+ String get model_info => 'Model info';
2210
+
2211
+ @override
2212
+ String get model_name => 'Model name';
2213
+
2214
+ @override
2215
+ String get model_identifier => 'Identifier';
2216
+
2217
+ @override
2218
+ String get not_available => 'Not available';
2219
+
2220
+ @override
2221
+ String get save_message_folders => 'Save message';
2222
+
2223
+ @override
2224
+ String get remove_from_saved => 'Remove from saved';
2225
+
2226
+ @override
2227
+ String get message_already_saved => 'Saved';
2228
+
2229
+ @override
2230
+ String get stream_ttft => 'Time to first token';
2231
+
2232
+ @override
2233
+ String get stream_tokens_per_sec => 'Tokens per second';
2234
+
2235
+ @override
2236
+ String get stream_stop_reason => 'Stop reason';
2237
+
2238
+ @override
2239
+ String get stream_input_tokens => 'Input tokens';
2240
+
2241
+ @override
2242
+ String get stream_output_tokens => 'Output tokens';
2243
+
2244
+ @override
2245
+ String get stream_generation_time => 'Generation time';
2246
+
2247
+ @override
2248
+ String get attach_image => 'Image';
2249
+
2250
+ @override
2251
+ String get attach_text_document => 'Text document';
2252
+
2253
+ @override
2254
+ String get add_attachment => 'Add attachment';
2255
+
2256
+ @override
2257
+ String get photo_permission_denied =>
2258
+ 'Photo access is required to attach images';
2259
+
2260
+ @override
2261
+ String get characters_label => 'Characters';
2262
+
2263
+ @override
2264
+ String get exit_temporary_chat_title => 'Exit temporary chat?';
2265
+
2266
+ @override
2267
+ String get exit_temporary_chat_body =>
2268
+ 'This will discard the current temporary chat and return to a new chat.';
2269
+
2270
+ @override
2271
+ String get saved_message_temp_snap_unavailable =>
2272
+ 'This message was saved from a temporary chat and can\'t be opened in its original conversation.';
2273
+
2274
+ @override
2275
+ String get filter_title => 'Filter';
2276
+
2277
+ @override
2278
+ String get filter_pinned => 'Pinned';
2279
+
2280
+ @override
2281
+ String get filter_archived => 'Archived';
2282
+
2283
+ @override
2284
+ String get filter_temp_chats => 'Temporary chats';
2285
+
2286
+ @override
2287
+ String get filter_user_messages => 'User messages';
2288
+
2289
+ @override
2290
+ String get filter_assistant_messages => 'Assistant messages';
2291
+
2292
+ @override
2293
+ String get archive_chat => 'Archive';
2294
+
2295
+ @override
2296
+ String get unarchive_chat => 'Unarchive';
2297
+
2298
+ @override
2299
+ String conversation_message_count(int count) {
2300
+ String _temp0 = intl.Intl.pluralLogic(
2301
+ count,
2302
+ locale: localeName,
2303
+ other: '$count messages',
2304
+ one: '1 message',
2305
+ );
2306
+ return '$_temp0';
2307
+ }
2308
+
2309
+ @override
2310
+ String conversation_character_count(int count) {
2311
+ return '$count chars';
2312
+ }
2313
+
2314
+ @override
2315
+ String get generate_title_with_ai => 'Generate with AI';
2316
+
2317
+ @override
2318
+ String get generating_title => 'Generating...';
2319
+
2320
+ @override
2321
+ String get generate_title_failed => 'Could not generate a title';
2322
}
lib/l10n/app_localizations_ru.dart
new
+2363
@@ -0,0 +1,2363 @@
1
+// ignore: unused_import
2
+import 'package:intl/intl.dart' as intl;
3
+import 'app_localizations.dart';
4
+
5
+// ignore_for_file: type=lint
6
+
7
+/// The translations for Russian (`ru`).
8
+class AppLocalizationsRu extends AppLocalizations {
9
+ AppLocalizationsRu([String locale = 'ru']) : super(locale);
10
+
11
+ @override
12
+ String get app_name => 'LocalMind';
13
+
14
+ @override
15
+ String get app_tagline => 'Ваш ИИ. Ваше устройство. Ваши правила.';
16
+
17
+ @override
18
+ String get app_version => '1.0.0';
19
+
20
+ @override
21
+ String get cancel => 'Отмена';
22
+
23
+ @override
24
+ String get confirm => 'Подтверждать';
25
+
26
+ @override
27
+ String get delete => 'Удалить';
28
+
29
+ @override
30
+ String get save => 'Сохранять';
31
+
32
+ @override
33
+ String get retry => 'Повторить попытку';
34
+
35
+ @override
36
+ String get close => 'Закрывать';
37
+
38
+ @override
39
+ String get done => 'Сделанный';
40
+
41
+ @override
42
+ String get continue_action => 'Продолжать';
43
+
44
+ @override
45
+ String get skip => 'Пропускать';
46
+
47
+ @override
48
+ String get install => 'Установить';
49
+
50
+ @override
51
+ String get download => 'Скачать';
52
+
53
+ @override
54
+ String get resume => 'Резюме';
55
+
56
+ @override
57
+ String get pause => 'Пауза';
58
+
59
+ @override
60
+ String get stop => 'Останавливаться';
61
+
62
+ @override
63
+ String get edit => 'Редактировать';
64
+
65
+ @override
66
+ String get preview => 'Предварительный просмотр';
67
+
68
+ @override
69
+ String get unload => 'Разгрузить';
70
+
71
+ @override
72
+ String get load => 'Нагрузка';
73
+
74
+ @override
75
+ String get rename => 'Переименовать';
76
+
77
+ @override
78
+ String get pin => 'Приколоть';
79
+
80
+ @override
81
+ String get unpin => 'Открепить';
82
+
83
+ @override
84
+ String get share => 'Делиться';
85
+
86
+ @override
87
+ String get copy => 'Копировать';
88
+
89
+ @override
90
+ String get copied => 'Скопировано!';
91
+
92
+ @override
93
+ String get copied_to_clipboard => 'Скопировано в буфер обмена';
94
+
95
+ @override
96
+ String get select => 'Выбирать';
97
+
98
+ @override
99
+ String get active => 'Активный';
100
+
101
+ @override
102
+ String get all => 'Все';
103
+
104
+ @override
105
+ String get none => 'Никто';
106
+
107
+ @override
108
+ String get none_selected => 'Ничего не выбрано';
109
+
110
+ @override
111
+ String get online => 'Онлайн';
112
+
113
+ @override
114
+ String get offline => 'Оффлайн';
115
+
116
+ @override
117
+ String get error => 'Ошибка';
118
+
119
+ @override
120
+ String get unknown_error => 'Неизвестная ошибка';
121
+
122
+ @override
123
+ String get not_now => 'Не сейчас';
124
+
125
+ @override
126
+ String get enable => 'Давать возможность';
127
+
128
+ @override
129
+ String get proceed_anyway => 'Продолжить в любом случае';
130
+
131
+ @override
132
+ String get test_connection => 'Тестовое соединение';
133
+
134
+ @override
135
+ String get testing => 'Тестирование...';
136
+
137
+ @override
138
+ String get connection_successful => 'Подключение успешно!';
139
+
140
+ @override
141
+ String get connection_failed =>
142
+ 'Соединение не удалось. Проверьте свои настройки.';
143
+
144
+ @override
145
+ String get save_continue => 'Сохранить и продолжить';
146
+
147
+ @override
148
+ String get save_changes => 'Сохранить изменения';
149
+
150
+ @override
151
+ String get finish_setup => 'Завершить настройку';
152
+
153
+ @override
154
+ String get start_new_chat => 'Начать новый чат';
155
+
156
+ @override
157
+ String get cannot_undo => 'Это действие невозможно отменить.';
158
+
159
+ @override
160
+ String get ram_warning => 'Предупреждение об оперативной памяти';
161
+
162
+ @override
163
+ String get recommended => 'РЕКОМЕНДУЕТСЯ';
164
+
165
+ @override
166
+ String get may_be_large => 'Может быть слишком большим для этого устройства';
167
+
168
+ @override
169
+ String get calculating => 'Расчет...';
170
+
171
+ @override
172
+ String get download_failed => 'Загрузка не удалась';
173
+
174
+ @override
175
+ String get downloaded => 'Скачано';
176
+
177
+ @override
178
+ String get not_downloaded => 'Не загружено';
179
+
180
+ @override
181
+ String get installed => 'Установлено';
182
+
183
+ @override
184
+ String get not_installed => 'Не установлено';
185
+
186
+ @override
187
+ String get loading => 'Загрузка...';
188
+
189
+ @override
190
+ String get thinking => 'мышление';
191
+
192
+ @override
193
+ String get processing => 'Обработка...';
194
+
195
+ @override
196
+ String get initializing => 'Инициализация...';
197
+
198
+ @override
199
+ String get ready => 'Готовый';
200
+
201
+ @override
202
+ String get preparing_app => 'Подготовка приложения...';
203
+
204
+ @override
205
+ String get initializing_services => 'Инициализация служб...';
206
+
207
+ @override
208
+ String get configuring_server => 'Настройка сервера...';
209
+
210
+ @override
211
+ String get startup_failed => 'Запуск не удался';
212
+
213
+ @override
214
+ String get something_went_wrong => 'Что-то пошло не так';
215
+
216
+ @override
217
+ String get delete_model_title => 'Удалить модель';
218
+
219
+ @override
220
+ String delete_model_body(String name) {
221
+ return 'Вы уверены, что хотите удалить $name?';
222
+ }
223
+
224
+ @override
225
+ String delete_model_body_with_size(String name, String size) {
226
+ return 'Вы уверены, что хотите удалить $name? Это освободит примерно $size места.\n\nПри необходимости вы сможете загрузить эту модель позже.';
227
+ }
228
+
229
+ @override
230
+ String get delete_voice_title => 'Удалить голос';
231
+
232
+ @override
233
+ String delete_voice_body(String name, String size) {
234
+ return 'Вы уверены, что хотите удалить $name? Это освободит примерно $size места.\n\nПри необходимости вы сможете скачать этот голос позже.';
235
+ }
236
+
237
+ @override
238
+ String get delete_server_title => 'Удалить сервер';
239
+
240
+ @override
241
+ String delete_server_body(String name) {
242
+ return 'Вы уверены, что хотите удалить \"$name\"? Это невозможно отменить.';
243
+ }
244
+
245
+ @override
246
+ String get delete_conversation_title => 'Удалить разговор?';
247
+
248
+ @override
249
+ String delete_conversation_body(String title) {
250
+ return 'Вы уверены, что хотите удалить \"$title\"? Это невозможно отменить.';
251
+ }
252
+
253
+ @override
254
+ String get delete_message_title => 'Удалить сообщение?';
255
+
256
+ @override
257
+ String delete_persona_title(String name) {
258
+ return 'Удалить \"$name\"?';
259
+ }
260
+
261
+ @override
262
+ String get delete_persona_body => 'Это невозможно отменить.';
263
+
264
+ @override
265
+ String get clear_conversation_title => 'Ясный разговор?';
266
+
267
+ @override
268
+ String get clear_conversation_body =>
269
+ 'Это приведет к удалению всех сообщений в этом разговоре.';
270
+
271
+ @override
272
+ String get clear => 'Прозрачный';
273
+
274
+ @override
275
+ String label_completed(String label) {
276
+ return '$label завершено';
277
+ }
278
+
279
+ @override
280
+ String error_with_message(String error) {
281
+ return 'Ошибка: $error';
282
+ }
283
+
284
+ @override
285
+ String preview_failed(String error) {
286
+ return 'Не удалось выполнить предварительный просмотр: $error';
287
+ }
288
+
289
+ @override
290
+ String loading_model(String modelId) {
291
+ return 'Загрузка $modelId...';
292
+ }
293
+
294
+ @override
295
+ String model_loaded(String modelId, String backend) {
296
+ return 'Модель загружена: $modelId ($backend)';
297
+ }
298
+
299
+ @override
300
+ String get no_model_loaded =>
301
+ 'Ни одна модель не загружена. Нажмите «Управление моделями на устройстве», чтобы скачать и загрузить модель.';
302
+
303
+ @override
304
+ String loading_model_error(String error) {
305
+ return 'Ошибка: $error';
306
+ }
307
+
308
+ @override
309
+ String get delete_conversation => 'Удалить разговор?';
310
+
311
+ @override
312
+ String get nav_history => 'История';
313
+
314
+ @override
315
+ String get nav_servers => 'Серверы';
316
+
317
+ @override
318
+ String get nav_local_models => 'Локальные модели';
319
+
320
+ @override
321
+ String get nav_tts => 'Текст в речь';
322
+
323
+ @override
324
+ String get nav_personas => 'Персоны';
325
+
326
+ @override
327
+ String get nav_settings => 'Настройки';
328
+
329
+ @override
330
+ String get nav_new_chat => 'Новый чат';
331
+
332
+ @override
333
+ String get search_hint => 'Поиск разговоров...';
334
+
335
+ @override
336
+ String get no_server_selected => 'Сервер не выбран';
337
+
338
+ @override
339
+ String get switch_server => 'Переключить сервер';
340
+
341
+ @override
342
+ String get switch_server_subtitle => 'Выберите сервер для подключения';
343
+
344
+ @override
345
+ String get manage_servers => 'Управление серверами';
346
+
347
+ @override
348
+ String get open_source => 'Открытый исходный код';
349
+
350
+ @override
351
+ String get open_source_desc =>
352
+ 'LocalMind имеет открытый исходный код. Следите за нашим прогрессом или вносите свой вклад на GitHub.';
353
+
354
+ @override
355
+ String get star_on_github => 'Звезда на GitHub';
356
+
357
+ @override
358
+ String get add_more => 'Добавить еще';
359
+
360
+ @override
361
+ String get on_github => 'на GitHub';
362
+
363
+ @override
364
+ String get could_not_open_github => 'Не удалось открыть GitHub.';
365
+
366
+ @override
367
+ String get settings_title => 'Настройки';
368
+
369
+ @override
370
+ String get settings_appearance => 'Появление';
371
+
372
+ @override
373
+ String get settings_language => 'Язык';
374
+
375
+ @override
376
+ String get language_system_default => 'Системные настройки по умолчанию';
377
+
378
+ @override
379
+ String get settings_tts => 'Преобразование текста в речь';
380
+
381
+ @override
382
+ String get settings_behavior => 'Поведение';
383
+
384
+ @override
385
+ String get settings_on_device => 'Вывод на устройстве';
386
+
387
+ @override
388
+ String get settings_default_server => 'Сервер по умолчанию';
389
+
390
+ @override
391
+ String get settings_default_persona => 'Персона по умолчанию';
392
+
393
+ @override
394
+ String get settings_privacy => 'Конфиденциальность';
395
+
396
+ @override
397
+ String get settings_data_management => 'Управление данными';
398
+
399
+ @override
400
+ String get settings_about => 'О';
401
+
402
+ @override
403
+ String get theme => 'Тема';
404
+
405
+ @override
406
+ String get theme_system => 'Система';
407
+
408
+ @override
409
+ String get theme_light => 'Свет';
410
+
411
+ @override
412
+ String get theme_dark => 'Темный';
413
+
414
+ @override
415
+ String get theme_claude => 'Клод';
416
+
417
+ @override
418
+ String get font_size => 'Размер шрифта';
419
+
420
+ @override
421
+ String get font_size_desc => 'Настройте размер текста в чате.';
422
+
423
+ @override
424
+ String get font_preview =>
425
+ 'Быстрая бурая лиса перепрыгивает через ленивую собаку.';
426
+
427
+ @override
428
+ String get code_theme_dark => 'Кодовая тема (темная)';
429
+
430
+ @override
431
+ String get code_theme_light => 'Тема кода (светлая)';
432
+
433
+ @override
434
+ String get code_theme_desc =>
435
+ 'Выберите тему подсветки синтаксиса для блоков кода.';
436
+
437
+ @override
438
+ String get tts_engine => 'Двигатель ТТС';
439
+
440
+ @override
441
+ String get tts_engine_system => 'Система ТТС';
442
+
443
+ @override
444
+ String get tts_engine_kitten => 'Котенок ТТС';
445
+
446
+ @override
447
+ String get voice => 'Голос';
448
+
449
+ @override
450
+ String get voice_female => 'Женский';
451
+
452
+ @override
453
+ String get voice_male => 'Мужской';
454
+
455
+ @override
456
+ String get voice_other => 'Другой';
457
+
458
+ @override
459
+ String get tts_speed => 'Скорость TTS';
460
+
461
+ @override
462
+ String get tts_speed_desc => 'Отрегулируйте скорость воспроизведения.';
463
+
464
+ @override
465
+ String get manage_tts_models => 'Управление моделями TTS';
466
+
467
+ @override
468
+ String get manage_on_device_models => 'Управление моделями на устройстве';
469
+
470
+ @override
471
+ String get enable_smart_reply => 'Умные ответы на устройстве';
472
+
473
+ @override
474
+ String get streaming_responses => 'Потоковая передача ответов';
475
+
476
+ @override
477
+ String get auto_generate_titles => 'Автоматическое создание заголовков';
478
+
479
+ @override
480
+ String get send_on_enter => 'Отправить при вводе';
481
+
482
+ @override
483
+ String get show_system_messages => 'Показать системные сообщения';
484
+
485
+ @override
486
+ String get haptic_feedback => 'Тактильная обратная связь';
487
+
488
+ @override
489
+ String get enable_mcp => 'Включить MCP';
490
+
491
+ @override
492
+ String get new_chat_mcp_default => 'Новый чат MCP по умолчанию';
493
+
494
+ @override
495
+ String get show_data_indicator => 'Показать индикатор данных';
496
+
497
+ @override
498
+ String get privacy_info => '«LocalMind никогда не видит ваши данные»';
499
+
500
+ @override
501
+ String get delete_all_conversations => 'Удалить все разговоры';
502
+
503
+ @override
504
+ String get reset_settings_defaults =>
505
+ 'Сброс настроек к значениям по умолчанию';
506
+
507
+ @override
508
+ String get chat_title => 'LocalMind';
509
+
510
+ @override
511
+ String get chat_parameters_tooltip => 'Параметры чата';
512
+
513
+ @override
514
+ String get change_persona => 'Изменить личность';
515
+
516
+ @override
517
+ String get set_persona => 'Установить Персону';
518
+
519
+ @override
520
+ String get remove_persona => 'Удалить персону';
521
+
522
+ @override
523
+ String get clear_conversation => 'Чистый разговор';
524
+
525
+ @override
526
+ String get connection_error => 'Ошибка подключения. Проверьте свой сервер.';
527
+
528
+ @override
529
+ String get disconnected => 'Отключился от сервера.';
530
+
531
+ @override
532
+ String get configure => 'Настроить';
533
+
534
+ @override
535
+ String get select_model => 'Выберите модель';
536
+
537
+ @override
538
+ String get select_persona => 'Выберите Персону';
539
+
540
+ @override
541
+ String get start_conversation => 'Начать разговор';
542
+
543
+ @override
544
+ String get recent_chats => 'Недавние чаты';
545
+
546
+ @override
547
+ String get see_all => 'Посмотреть все';
548
+
549
+ @override
550
+ String get quick_write => 'Помогите мне написать функцию';
551
+
552
+ @override
553
+ String get quick_explain => 'Объясните этот код';
554
+
555
+ @override
556
+ String get quick_debug => 'Отладьте это для меня';
557
+
558
+ @override
559
+ String get quick_async => 'Как использовать async/await?';
560
+
561
+ @override
562
+ String get history_missing_title => 'История отсутствует';
563
+
564
+ @override
565
+ String get history_missing_desc =>
566
+ 'Либо сообщения в этом чате были удалены, либо запись истории повреждена.';
567
+
568
+ @override
569
+ String get technical_details => 'Технические детали';
570
+
571
+ @override
572
+ String get last_error => 'Последняя ошибка:';
573
+
574
+ @override
575
+ String get copy_info => 'Копировать информацию';
576
+
577
+ @override
578
+ String get conversation_id => 'Идентификатор беседы';
579
+
580
+ @override
581
+ String get created_at => 'Создано в';
582
+
583
+ @override
584
+ String get expected_messages => 'Ожидаемые сообщения';
585
+
586
+ @override
587
+ String get debug_dialog_desc =>
588
+ 'Диагностическая информация, помогающая выявить проблемы синхронизации.';
589
+
590
+ @override
591
+ String get chat_input_hint => 'Спроси что-нибудь';
592
+
593
+ @override
594
+ String get send_message_tooltip => 'Отправить сообщение';
595
+
596
+ @override
597
+ String get stop_generation_tooltip => 'Остановить генерацию';
598
+
599
+ @override
600
+ String get attach_images_tooltip => 'Прикрепите изображения или текст';
601
+
602
+ @override
603
+ String get start_listening_tooltip => 'Начать слушать';
604
+
605
+ @override
606
+ String get stop_listening_tooltip => 'Хватит слушать';
607
+
608
+ @override
609
+ String tool_label(String toolCallId) {
610
+ return 'Инструмент: $toolCallId';
611
+ }
612
+
613
+ @override
614
+ String get tool_unknown => 'Инструмент: Неизвестно';
615
+
616
+ @override
617
+ String get message_options => 'Параметры сообщения';
618
+
619
+ @override
620
+ String get copy_markdown => 'Скопировать как Markdown';
621
+
622
+ @override
623
+ String get copied_markdown => 'Скопировано как Markdown';
624
+
625
+ @override
626
+ String get read_aloud => 'Читать вслух';
627
+
628
+ @override
629
+ String get stop_reading => 'Хватит читать';
630
+
631
+ @override
632
+ String get more => 'Более';
633
+
634
+ @override
635
+ String character_count(int length) {
636
+ return '$length символов';
637
+ }
638
+
639
+ @override
640
+ String get edit_message => 'Редактировать сообщение';
641
+
642
+ @override
643
+ String get edit_message_desc =>
644
+ 'При сохранении ответ помощника ниже будет удален и восстановлен.';
645
+
646
+ @override
647
+ String get save_regenerate => 'Сохранить и восстановить';
648
+
649
+ @override
650
+ String get chat_settings_title => 'Настройки чата';
651
+
652
+ @override
653
+ String get reset_defaults => 'Сбросить настройки по умолчанию';
654
+
655
+ @override
656
+ String get parameters_tab => 'Параметры';
657
+
658
+ @override
659
+ String get mcp_tab => 'МКП';
660
+
661
+ @override
662
+ String get temperature => 'Температура';
663
+
664
+ @override
665
+ String get temperature_desc =>
666
+ 'Управляет случайностью: выше = креативно, ниже = сосредоточено.';
667
+
668
+ @override
669
+ String get top_p => 'Топ П';
670
+
671
+ @override
672
+ String get top_p_desc => 'Порог выборки ядра';
673
+
674
+ @override
675
+ String get max_tokens => 'Макс. жетонов';
676
+
677
+ @override
678
+ String get max_tokens_desc => 'Предел ответа';
679
+
680
+ @override
681
+ String get context_length => 'Длина контекста';
682
+
683
+ @override
684
+ String get context_length_desc => 'Окно истории';
685
+
686
+ @override
687
+ String get mcp_disabled_warning =>
688
+ 'MCP отключен глобально. Включите его в настройках, чтобы использовать эти функции.';
689
+
690
+ @override
691
+ String get mcp_enable_chat => 'Включите MCP для этого чата';
692
+
693
+ @override
694
+ String get auto_execute_tools => 'Инструменты автоматического выполнения';
695
+
696
+ @override
697
+ String get beta_label => 'Бета';
698
+
699
+ @override
700
+ String get experimental_label => 'Экспериментальный';
701
+
702
+ @override
703
+ String get add_ephemeral_mcp => 'Добавить эфемерный сервер MCP';
704
+
705
+ @override
706
+ String get mcp_label_placeholder => 'Этикетка';
707
+
708
+ @override
709
+ String get mcp_url_placeholder => 'URL-адрес (https://...)';
710
+
711
+ @override
712
+ String get active_integrations => 'Активная интеграция';
713
+
714
+ @override
715
+ String get enable_notifications => 'Включить уведомления';
716
+
717
+ @override
718
+ String get enable_notifications_desc =>
719
+ 'Получайте уведомления, когда модели завершают загрузку.';
720
+
721
+ @override
722
+ String get chat_history_title => 'История чата';
723
+
724
+ @override
725
+ String get conversation_just_now => 'Прямо сейчас';
726
+
727
+ @override
728
+ String conversation_minutes_ago(int minutes) {
729
+ return '$minutes мин назад';
730
+ }
731
+
732
+ @override
733
+ String conversation_hours_ago(int hours) {
734
+ return '$hoursч назад';
735
+ }
736
+
737
+ @override
738
+ String get conversation_yesterday => 'Вчера';
739
+
740
+ @override
741
+ String conversation_days_ago(int days) {
742
+ return '$daysд назад';
743
+ }
744
+
745
+ @override
746
+ String conversation_date(int month, int day, int year) {
747
+ return '$month/$day/$year';
748
+ }
749
+
750
+ @override
751
+ String get options_tooltip => 'Параметры';
752
+
753
+ @override
754
+ String get no_results_found => 'Результаты не найдены';
755
+
756
+ @override
757
+ String get no_conversations_yet => 'Пока нет разговоров';
758
+
759
+ @override
760
+ String get try_different_search => 'Попробуйте другой поисковый запрос';
761
+
762
+ @override
763
+ String get start_new_conversation => 'Начать новый разговор';
764
+
765
+ @override
766
+ String get rename_conversation => 'Переименовать беседу';
767
+
768
+ @override
769
+ String get enter_new_title => 'Введите новое название';
770
+
771
+ @override
772
+ String get pinned_section => 'ПРИКРЕПЛЕНО';
773
+
774
+ @override
775
+ String get today_section => 'СЕГОДНЯ';
776
+
777
+ @override
778
+ String get yesterday_section => 'ВЧЕРА';
779
+
780
+ @override
781
+ String get previous_7_days => 'ПРЕДЫДУЩИЕ 7 ДНЕЙ';
782
+
783
+ @override
784
+ String get previous_30_days => 'ПРЕДЫДУЩИЕ 30 ДНЕЙ';
785
+
786
+ @override
787
+ String get older_section => 'СТАРШЕ';
788
+
789
+ @override
790
+ String get onboarding_choose_language => 'Выберите язык';
791
+
792
+ @override
793
+ String get onboarding_choose_language_desc =>
794
+ 'Выберите предпочитаемый язык. Вы можете изменить это в любое время в настройках.';
795
+
796
+ @override
797
+ String get onboarding_localmind => 'МЕСТНЫЙ РАЗУМ';
798
+
799
+ @override
800
+ String get onboarding_connect_server => 'Подключите свой\nСервер';
801
+
802
+ @override
803
+ String get onboarding_connect_desc =>
804
+ 'Подключитесь к LM Studio, Ollama или\nOpenRouter для запуска вашего частного ИИ\nопыт.';
805
+
806
+ @override
807
+ String get openai_compatible_api => 'OpenAI-совместимый API';
808
+
809
+ @override
810
+ String get https_requires_ssl => 'HTTPS требует SSL';
811
+
812
+ @override
813
+ String get most_local_setups_use_http =>
814
+ 'Большинство локальных установок используют http://';
815
+
816
+ @override
817
+ String get onboarding_welcome => 'Добро пожаловать в LocalMind';
818
+
819
+ @override
820
+ String get server_type_on_device => 'На устройстве';
821
+
822
+ @override
823
+ String get server_type_lm_studio => 'ЛМ Студия';
824
+
825
+ @override
826
+ String get server_type_ollama => 'Оллама';
827
+
828
+ @override
829
+ String get server_type_openrouter => 'OpenRouter';
830
+
831
+ @override
832
+ String get server_type_openrouter_sub => 'ЕДИНОЕ ОБЛАКО';
833
+
834
+ @override
835
+ String get ready_continue => 'ГОТОВЫ ПРОДОЛЖИТЬ';
836
+
837
+ @override
838
+ String get waiting_selection => 'ОЖИДАНИЕ ВЫБОРА';
839
+
840
+ @override
841
+ String get setup_connection => 'Настройка соединения';
842
+
843
+ @override
844
+ String setup_connection_desc(String server) {
845
+ return 'Настройте свой сервер $server, чтобы начать общение.';
846
+ }
847
+
848
+ @override
849
+ String get server_name => 'Имя сервера';
850
+
851
+ @override
852
+ String get name_required => 'Требуется имя';
853
+
854
+ @override
855
+ String get name_max_50 => 'Макс. 50 символов.';
856
+
857
+ @override
858
+ String get host_label => 'Хост/IP-адрес';
859
+
860
+ @override
861
+ String get host_required => 'Требуется хост';
862
+
863
+ @override
864
+ String get port_label => 'Порт';
865
+
866
+ @override
867
+ String get port_required => 'Требуется порт';
868
+
869
+ @override
870
+ String get port_invalid => 'Должно быть число';
871
+
872
+ @override
873
+ String get port_range => 'Введите действительный порт (1-65535).';
874
+
875
+ @override
876
+ String get api_key_required => 'API-ключ *';
877
+
878
+ @override
879
+ String get api_key_optional => 'Ключ API (необязательно)';
880
+
881
+ @override
882
+ String get api_key_required_openrouter =>
883
+ 'Ключ API, необходимый для OpenRouter';
884
+
885
+ @override
886
+ String get api_key_format => 'Ключи API OpenRouter начинаются с sk-';
887
+
888
+ @override
889
+ String get my_server_hint => 'Мой сервер';
890
+
891
+ @override
892
+ String get name_length_validation =>
893
+ 'Имя должно содержать не более 50 символов.';
894
+
895
+ @override
896
+ String get host_valid => 'Введите действительное имя хоста или IP-адрес.';
897
+
898
+ @override
899
+ String get api_key_hint_openrouter => 'ск-...';
900
+
901
+ @override
902
+ String get api_key_hint_generic => 'Для аутентифицированных серверов';
903
+
904
+ @override
905
+ String get update_server => 'Обновление сервера';
906
+
907
+ @override
908
+ String get save_server => 'Сохранить сервер';
909
+
910
+ @override
911
+ String get server_updated => 'Сервер обновлен';
912
+
913
+ @override
914
+ String get server_added => 'Сервер добавлен';
915
+
916
+ @override
917
+ String get download_model_title => 'Скачать модель';
918
+
919
+ @override
920
+ String get download_model_desc =>
921
+ 'Выберите модель для загрузки.\nОн будет работать локально на вашем устройстве.';
922
+
923
+ @override
924
+ String get on_device_android_only =>
925
+ 'Вывод на устройстве в настоящее время доступен только на Android.';
926
+
927
+ @override
928
+ String get total_ram => 'Общий объем оперативной памяти';
929
+
930
+ @override
931
+ String get available => 'Доступный';
932
+
933
+ @override
934
+ String ram_min_required(String fileSize) {
935
+ return '$fileSize ГБ ОЗУ мин.';
936
+ }
937
+
938
+ @override
939
+ String download_progress(String percent, String speed) {
940
+ return '$percent% • $speed';
941
+ }
942
+
943
+ @override
944
+ String eta_label(String eta) {
945
+ return 'Оставшееся время: $eta';
946
+ }
947
+
948
+ @override
949
+ String paused_progress(String percent) {
950
+ return 'Приостановлено – $percent %';
951
+ }
952
+
953
+ @override
954
+ String ram_warning_body_download(String ram, String totalMemory) {
955
+ return 'Для этой модели требуется не менее $ram ГБ ОЗУ, но на вашем устройстве имеется $totalMemory. Оно может работать неправильно или привести к сбою приложения.';
956
+ }
957
+
958
+ @override
959
+ String ram_warning_body_load(String availableRAM, String ram) {
960
+ return 'На вашем устройстве доступно ОЗУ $availableRAM, но для этой модели рекомендуется не менее $ram ГБ. Загрузка может привести к сбою или вызвать нестабильность.';
961
+ }
962
+
963
+ @override
964
+ String get choose_theme => 'Выбрать тему';
965
+
966
+ @override
967
+ String get choose_theme_desc =>
968
+ 'Персонализируйте внешний вид приложения. Вы всегда можете изменить это позже в настройках.';
969
+
970
+ @override
971
+ String get theme_card_system => 'Система';
972
+
973
+ @override
974
+ String get theme_card_system_sub =>
975
+ 'Соответствует настройкам вашего устройства';
976
+
977
+ @override
978
+ String get theme_card_light => 'Свет';
979
+
980
+ @override
981
+ String get theme_card_light_sub => 'Чистый и светлый';
982
+
983
+ @override
984
+ String get theme_card_dark => 'Темный';
985
+
986
+ @override
987
+ String get theme_card_dark_sub => 'Легко для глаз';
988
+
989
+ @override
990
+ String get theme_card_claude => 'Клод';
991
+
992
+ @override
993
+ String get theme_card_claude_sub => 'Теплая тема персикового оттенка.';
994
+
995
+ @override
996
+ String get stay_updated => 'Оставайтесь в курсе';
997
+
998
+ @override
999
+ String get stay_updated_desc =>
1000
+ 'Получайте уведомления, когда ваши модели ИИ завершают загрузку или завершаются длительные задачи.';
1001
+
1002
+ @override
1003
+ String get notification_benefit_downloads => 'Ход загрузки модели';
1004
+
1005
+ @override
1006
+ String get notification_benefit_completions => 'Завершения генерации';
1007
+
1008
+ @override
1009
+ String get notification_benefit_background => 'Статус фоновых задач';
1010
+
1011
+ @override
1012
+ String get allow_notifications => 'Разрешить уведомления';
1013
+
1014
+ @override
1015
+ String get servers_title => 'Серверы';
1016
+
1017
+ @override
1018
+ String get no_servers_yet => 'Серверов пока нет';
1019
+
1020
+ @override
1021
+ String get no_servers_desc =>
1022
+ 'Добавьте свой первый сервер, чтобы начать общаться с моделями ИИ.';
1023
+
1024
+ @override
1025
+ String get add_server => 'Добавить сервер';
1026
+
1027
+ @override
1028
+ String switched_to_server(String name) {
1029
+ return 'Переключено на $name';
1030
+ }
1031
+
1032
+ @override
1033
+ String get edit_server => 'Редактировать сервер';
1034
+
1035
+ @override
1036
+ String get add_server_title => 'Добавить сервер';
1037
+
1038
+ @override
1039
+ String get server_type_label => 'Тип сервера';
1040
+
1041
+ @override
1042
+ String get server_icon_label => 'Значок сервера';
1043
+
1044
+ @override
1045
+ String get default_icon => 'Значок по умолчанию';
1046
+
1047
+ @override
1048
+ String get server_type_lm_studio_display => 'ЛМ Студия';
1049
+
1050
+ @override
1051
+ String get server_type_openai_display => 'Совместимость с OpenAI';
1052
+
1053
+ @override
1054
+ String get server_type_ollama_display => 'Оллама';
1055
+
1056
+ @override
1057
+ String get server_type_openrouter_display => 'OpenRouter';
1058
+
1059
+ @override
1060
+ String get server_type_on_device_display => 'На устройстве';
1061
+
1062
+ @override
1063
+ String get server_address_openrouter => 'openrouter.ai';
1064
+
1065
+ @override
1066
+ String get server_address_on_device => 'Локальный вывод';
1067
+
1068
+ @override
1069
+ String server_address_format(String host, String port) {
1070
+ return '$host:$port';
1071
+ }
1072
+
1073
+ @override
1074
+ String get default_badge => 'По умолчанию';
1075
+
1076
+ @override
1077
+ String get set_as_default => 'Установить по умолчанию';
1078
+
1079
+ @override
1080
+ String get select_icon => 'Выберите значок';
1081
+
1082
+ @override
1083
+ String get select_icon_desc => 'Выберите значок для своего сервера';
1084
+
1085
+ @override
1086
+ String get search_icons_hint => 'Иконки поиска...';
1087
+
1088
+ @override
1089
+ String get server_icon_stack => 'Серверный стек';
1090
+
1091
+ @override
1092
+ String get server_icon_stack2 => 'Серверный стек 02';
1093
+
1094
+ @override
1095
+ String get server_icon_stack3 => 'Серверный стек 03';
1096
+
1097
+ @override
1098
+ String get server_icon_cloud => 'Облако';
1099
+
1100
+ @override
1101
+ String get server_icon_cloud_server => 'Облачный сервер';
1102
+
1103
+ @override
1104
+ String get server_icon_mcp => 'MCP-сервер';
1105
+
1106
+ @override
1107
+ String get server_icon_database => 'База данных';
1108
+
1109
+ @override
1110
+ String get server_icon_database1 => 'База данных 01';
1111
+
1112
+ @override
1113
+ String get server_icon_database2 => 'База данных 02';
1114
+
1115
+ @override
1116
+ String get server_icon_cpu => 'Процессор';
1117
+
1118
+ @override
1119
+ String get server_icon_chip => 'Чип';
1120
+
1121
+ @override
1122
+ String get server_icon_chip2 => 'Чип 02';
1123
+
1124
+ @override
1125
+ String get server_icon_computer => 'Компьютер';
1126
+
1127
+ @override
1128
+ String get server_icon_laptop => 'Ноутбук';
1129
+
1130
+ @override
1131
+ String get server_icon_terminal => 'Компьютерный терминал';
1132
+
1133
+ @override
1134
+ String get server_icon_code => 'Код';
1135
+
1136
+ @override
1137
+ String get server_icon_ai_brain => 'ИИ Мозг';
1138
+
1139
+ @override
1140
+ String get server_icon_ai_brain2 => 'ИИ Мозг 02';
1141
+
1142
+ @override
1143
+ String get server_icon_ai_cloud => 'Облако ИИ';
1144
+
1145
+ @override
1146
+ String get server_icon_ai_network => 'Сеть искусственного интеллекта';
1147
+
1148
+ @override
1149
+ String get server_icon_ai_chat => 'AI-чат';
1150
+
1151
+ @override
1152
+ String get server_icon_cellular => 'Сотовая сеть';
1153
+
1154
+ @override
1155
+ String get server_icon_plug1 => 'Вилка 01';
1156
+
1157
+ @override
1158
+ String get server_icon_plug2 => 'Вилка 02';
1159
+
1160
+ @override
1161
+ String get server_icon_bot => 'Бот';
1162
+
1163
+ @override
1164
+ String get server_icon_bot2 => 'Бот 02';
1165
+
1166
+ @override
1167
+ String get server_icon_robotic => 'Роботизированный';
1168
+
1169
+ @override
1170
+ String get server_icon_rocket => 'Ракета';
1171
+
1172
+ @override
1173
+ String get server_icon_star => 'Звезда';
1174
+
1175
+ @override
1176
+ String get server_icon_settings1 => 'Настройки 01';
1177
+
1178
+ @override
1179
+ String get server_icon_settings2 => 'Настройки 02';
1180
+
1181
+ @override
1182
+ String get server_icon_home1 => 'Дом 01';
1183
+
1184
+ @override
1185
+ String get server_icon_home2 => 'Дом 02';
1186
+
1187
+ @override
1188
+ String get server_icon_folder1 => 'Папка 01';
1189
+
1190
+ @override
1191
+ String get server_icon_folder2 => 'Папка 02';
1192
+
1193
+ @override
1194
+ String get server_icon_file1 => 'Файл 01';
1195
+
1196
+ @override
1197
+ String get server_icon_lock => 'Замок';
1198
+
1199
+ @override
1200
+ String get server_icon_key => 'Ключ 01';
1201
+
1202
+ @override
1203
+ String get server_icon_link => 'Ссылка 01';
1204
+
1205
+ @override
1206
+ String get server_icon_globe => 'Глобус';
1207
+
1208
+ @override
1209
+ String get server_icon_api => 'API';
1210
+
1211
+ @override
1212
+ String get server_icon_arrow_right => 'Стрелка вправо 01';
1213
+
1214
+ @override
1215
+ String get server_icon_check => 'Проверить круг';
1216
+
1217
+ @override
1218
+ String get server_icon_alert => 'Круг оповещения';
1219
+
1220
+ @override
1221
+ String get server_icon_info => 'Информационный круг';
1222
+
1223
+ @override
1224
+ String get server_icon_zap => 'Зап';
1225
+
1226
+ @override
1227
+ String get server_icon_cloud_upload => 'Загрузка в облако';
1228
+
1229
+ @override
1230
+ String get server_icon_cloud_download => 'Облачная загрузка';
1231
+
1232
+ @override
1233
+ String get server_icon_refresh => 'Обновить';
1234
+
1235
+ @override
1236
+ String get server_icon_hard_drive => 'Жесткий диск';
1237
+
1238
+ @override
1239
+ String get server_icon_drive => 'Водить машину';
1240
+
1241
+ @override
1242
+ String get personas_title => 'Персоны';
1243
+
1244
+ @override
1245
+ String get persona_category_general => 'Общий';
1246
+
1247
+ @override
1248
+ String get persona_category_coding => 'Кодирование';
1249
+
1250
+ @override
1251
+ String get persona_category_education => 'Образование';
1252
+
1253
+ @override
1254
+ String get persona_category_creative => 'Креатив';
1255
+
1256
+ @override
1257
+ String get persona_builtin_section => 'ВСТРОЕННЫЙ';
1258
+
1259
+ @override
1260
+ String get persona_my_section => 'МОИ ПЕРСОНАЛЫ';
1261
+
1262
+ @override
1263
+ String get clone_edit => 'Клонировать и редактировать';
1264
+
1265
+ @override
1266
+ String get builtin_badge => 'Встроенный';
1267
+
1268
+ @override
1269
+ String get no_personas_found => 'Персоны не найдены';
1270
+
1271
+ @override
1272
+ String get no_personas_desc =>
1273
+ 'Создайте свою первую личность, чтобы настроить поведение ИИ.';
1274
+
1275
+ @override
1276
+ String get edit_persona => 'Изменить личность';
1277
+
1278
+ @override
1279
+ String get create_persona => 'Создать Персону';
1280
+
1281
+ @override
1282
+ String get create_persona_button => 'Создавать';
1283
+
1284
+ @override
1285
+ String get emoji_label => 'Эмодзи';
1286
+
1287
+ @override
1288
+ String get name_label => 'Имя';
1289
+
1290
+ @override
1291
+ String get my_persona_hint => 'Моя Персона';
1292
+
1293
+ @override
1294
+ String get category_label => 'Категория';
1295
+
1296
+ @override
1297
+ String get description_optional => 'Описание (необязательно)';
1298
+
1299
+ @override
1300
+ String get description_hint => 'Что делает эта личность...';
1301
+
1302
+ @override
1303
+ String get system_prompt => 'Системная подсказка';
1304
+
1305
+ @override
1306
+ String character_count_max(int currentLen) {
1307
+ return '$currentLen/4000';
1308
+ }
1309
+
1310
+ @override
1311
+ String get no_prompt_placeholder => 'Пока нет подсказки...';
1312
+
1313
+ @override
1314
+ String get prompt_hint => 'Вы полезный помощник...';
1315
+
1316
+ @override
1317
+ String get prompt_required => 'Требуется системное приглашение';
1318
+
1319
+ @override
1320
+ String get prompt_max_chars => 'Макс. 4000 символов.';
1321
+
1322
+ @override
1323
+ String get advanced_settings => 'Расширенные настройки';
1324
+
1325
+ @override
1326
+ String get temperature_label => 'Температура (0,0-2,0)';
1327
+
1328
+ @override
1329
+ String get top_p_label => 'Топ П (0,0-1,0)';
1330
+
1331
+ @override
1332
+ String get temp_hint => '0,7';
1333
+
1334
+ @override
1335
+ String get top_p_hint => '0,9';
1336
+
1337
+ @override
1338
+ String get range_0_2 => '0,0-2,0';
1339
+
1340
+ @override
1341
+ String get range_0_1 => '0,0-1,0';
1342
+
1343
+ @override
1344
+ String get persona_updated => 'Персона обновлена';
1345
+
1346
+ @override
1347
+ String get persona_created => 'Персона создана';
1348
+
1349
+ @override
1350
+ String get tts_models_title => 'Модели преобразования текста в речь';
1351
+
1352
+ @override
1353
+ String get always_available => 'Всегда доступен';
1354
+
1355
+ @override
1356
+ String get tts_system_desc =>
1357
+ 'Использует встроенный в ваше устройство механизм преобразования текста в речь.\nНикаких загрузок не требуется. Выбор голоса использует системные настройки вашего устройства.';
1358
+
1359
+ @override
1360
+ String get downloading_status => 'Загрузка...';
1361
+
1362
+ @override
1363
+ String tts_kitten_desc(String size) {
1364
+ return 'Молниеносный нейронный TTS с 8 выразительными голосами.\nТребуется загрузка $size.';
1365
+ }
1366
+
1367
+ @override
1368
+ String tts_piper_desc(String size) {
1369
+ return 'Быстрые офлайн-голоса Пайпер с двумя выразительными голосами.\nТребуется загрузка $size на голос.';
1370
+ }
1371
+
1372
+ @override
1373
+ String engine_spec(String sizeMb, String ramMb, int voiceCount) {
1374
+ return '$sizeMb МБ · $ramMb МБ ОЗУ · $voiceCount голосов';
1375
+ }
1376
+
1377
+ @override
1378
+ String get on_device_models_title => 'Модели на устройстве';
1379
+
1380
+ @override
1381
+ String get settings_huggingface_token =>
1382
+ 'Жетон «Обнимающее лицо» (необязательно)';
1383
+
1384
+ @override
1385
+ String get settings_huggingface_token_desc =>
1386
+ 'Требуется только для закрытых моделей (например, Gemma). Получите токен на сайтеhuggingface.co/settings/tokens.';
1387
+
1388
+ @override
1389
+ String get settings_huggingface_token_set => 'Токен сохранен.';
1390
+
1391
+ @override
1392
+ String get settings_huggingface_token_cleared => 'Токен очищен';
1393
+
1394
+ @override
1395
+ String get model_requires_huggingface_token =>
1396
+ 'Требуется жетон «Обнимающее лицо».';
1397
+
1398
+ @override
1399
+ String get model_missing_huggingface_token =>
1400
+ 'Данная модель изготовлена на Hugging Face. Добавьте токен в «Настройки» → «Вывод на устройстве», чтобы загрузить его.';
1401
+
1402
+ @override
1403
+ String get set_huggingface_token => 'Установить токен';
1404
+
1405
+ @override
1406
+ String get clear_huggingface_token => 'Прозрачный';
1407
+
1408
+ @override
1409
+ String get edit_huggingface_token_dialog_title =>
1410
+ 'Токен доступа «Обнимающее лицо»';
1411
+
1412
+ @override
1413
+ String get huggingface_token_dialog_hint => 'хф_…';
1414
+
1415
+ @override
1416
+ String get server_type_ollama_desc =>
1417
+ 'Локальный ИИ-движок. Ключ API не требуется.';
1418
+
1419
+ @override
1420
+ String get server_type_on_device_desc =>
1421
+ 'Работает на вашем телефоне. Некоторым моделям нужен жетон «Обнимающее лицо».';
1422
+
1423
+ @override
1424
+ String get server_type_lm_studio_desc =>
1425
+ 'Локальный API-сервер. Ключ API не требуется.';
1426
+
1427
+ @override
1428
+ String get available_models => 'Доступные модели';
1429
+
1430
+ @override
1431
+ String get device_memory => 'Память устройства';
1432
+
1433
+ @override
1434
+ String get ram_usage => 'Использование оперативной памяти';
1435
+
1436
+ @override
1437
+ String get memory_healthy => 'Здоровый';
1438
+
1439
+ @override
1440
+ String get memory_critical => 'Критический';
1441
+
1442
+ @override
1443
+ String get memory_low => 'Низкий';
1444
+
1445
+ @override
1446
+ String ram_used(String percent) {
1447
+ return '$percent % использовано';
1448
+ }
1449
+
1450
+ @override
1451
+ String get available_ram => 'Доступная оперативная память';
1452
+
1453
+ @override
1454
+ String get total_capacity => 'Общая емкость';
1455
+
1456
+ @override
1457
+ String get loaded_status => 'Загружено';
1458
+
1459
+ @override
1460
+ String get inference_backend => 'Серверная часть вывода';
1461
+
1462
+ @override
1463
+ String get backend_ios_notice => 'В iOS доступен только серверный процессор.';
1464
+
1465
+ @override
1466
+ String get backend_cpu_desc =>
1467
+ 'Работает на всех устройствах. Наиболее совместим.';
1468
+
1469
+ @override
1470
+ String get backend_gpu_desc =>
1471
+ 'Ускорение OpenCL. Быстрее на поддерживаемых устройствах.';
1472
+
1473
+ @override
1474
+ String get backend_npu_desc =>
1475
+ 'Поставщик НПУ (Qualcomm/MediaTek). Самый быстрый вывод.';
1476
+
1477
+ @override
1478
+ String get select_model_title => 'Выберите модель';
1479
+
1480
+ @override
1481
+ String get refresh_models => 'Обновить модели';
1482
+
1483
+ @override
1484
+ String get search_models_hint => 'Поиск моделей...';
1485
+
1486
+ @override
1487
+ String get no_server_connected => 'Сервер не подключен';
1488
+
1489
+ @override
1490
+ String get add_server_first =>
1491
+ 'Сначала добавьте сервер, чтобы увидеть доступные модели.';
1492
+
1493
+ @override
1494
+ String get failed_load_models => 'Не удалось загрузить модели.';
1495
+
1496
+ @override
1497
+ String get no_models_available => 'Нет доступных моделей';
1498
+
1499
+ @override
1500
+ String no_models_match(String searchQuery) {
1501
+ return 'Нет моделей, соответствующих запросу \"$searchQuery\".';
1502
+ }
1503
+
1504
+ @override
1505
+ String model_load_failed(String error) {
1506
+ return 'Не удалось загрузить модель: $error';
1507
+ }
1508
+
1509
+ @override
1510
+ String model_unloaded_ollama(String name) {
1511
+ return 'Выгрузка запрошена для $name. Если Оллама доступен, модель выпускается немедленно.';
1512
+ }
1513
+
1514
+ @override
1515
+ String model_unloaded_success(String name) {
1516
+ return '$name успешно выгружено';
1517
+ }
1518
+
1519
+ @override
1520
+ String model_unload_failed(String error) {
1521
+ return 'Не удалось выгрузить модель: $error';
1522
+ }
1523
+
1524
+ @override
1525
+ String get unload_from_server => 'Выгрузить с сервера';
1526
+
1527
+ @override
1528
+ String context_chip(String ctx) {
1529
+ return '$ctx ctx';
1530
+ }
1531
+
1532
+ @override
1533
+ String get unload_all_models => 'Unload all';
1534
+
1535
+ @override
1536
+ String loaded_models_count(int count) {
1537
+ return '$count loaded';
1538
+ }
1539
+
1540
+ @override
1541
+ String get all_models_unloaded => 'All models unloaded';
1542
+
1543
+ @override
1544
+ String get branch_chat => 'Branch chat';
1545
+
1546
+ @override
1547
+ String get branch_chat_desc => 'Start a new conversation from this message';
1548
+
1549
+ @override
1550
+ String get edit_assistant_message_desc => 'Edit the assistant response text.';
1551
+
1552
+ @override
1553
+ String switch_to_model(String modelName) {
1554
+ return 'Switch to $modelName';
1555
+ }
1556
+
1557
+ @override
1558
+ String download_notification_title(String modelName) {
1559
+ return 'Загрузка $modelName...';
1560
+ }
1561
+
1562
+ @override
1563
+ String get download_complete_notification => 'Загрузка завершена!';
1564
+
1565
+ @override
1566
+ String download_complete_body(String modelName) {
1567
+ return 'Модель $modelName успешно загружена.';
1568
+ }
1569
+
1570
+ @override
1571
+ String download_failed_notification(String error) {
1572
+ return 'Загрузка не удалась: $error';
1573
+ }
1574
+
1575
+ @override
1576
+ String download_failed_body(String modelName) {
1577
+ return 'Не удалось загрузить $modelName.';
1578
+ }
1579
+
1580
+ @override
1581
+ String get engine_name_system => 'Система ТТС';
1582
+
1583
+ @override
1584
+ String get engine_tagline_system => 'Встроенный движок устройства';
1585
+
1586
+ @override
1587
+ String get engine_name_kitten => 'Котенок ТТС';
1588
+
1589
+ @override
1590
+ String get engine_tagline_kitten => 'Высокоскоростная нейронная TTS';
1591
+
1592
+ @override
1593
+ String get engine_name_sherpa => 'Шерпа ОННКС ВИТС';
1594
+
1595
+ @override
1596
+ String get engine_tagline_sherpa => 'Оффлайн голоса Пайпер';
1597
+
1598
+ @override
1599
+ String get voice_jasper => 'Джаспер';
1600
+
1601
+ @override
1602
+ String get voice_bella => 'Белла';
1603
+
1604
+ @override
1605
+ String get voice_bruno => 'Бруно';
1606
+
1607
+ @override
1608
+ String get voice_luna => 'Луна';
1609
+
1610
+ @override
1611
+ String get voice_hugo => 'Хьюго';
1612
+
1613
+ @override
1614
+ String get voice_rosie => 'Рози';
1615
+
1616
+ @override
1617
+ String get voice_leo => 'Лео';
1618
+
1619
+ @override
1620
+ String get voice_kiki => 'Кики';
1621
+
1622
+ @override
1623
+ String get voice_lessac => 'Лессак (США)';
1624
+
1625
+ @override
1626
+ String get voice_ryan => 'Райан (США)';
1627
+
1628
+ @override
1629
+ String get model_qwen_3 => 'Квен 3 0.6Б';
1630
+
1631
+ @override
1632
+ String get model_qwen_3_desc =>
1633
+ 'Самая маленькая модель чата общего назначения. Быстрые ответы, низкое использование памяти.';
1634
+
1635
+ @override
1636
+ String get model_license_apache => 'Апач-2.0';
1637
+
1638
+ @override
1639
+ String get model_qwen_25 => 'Квен 2.5 1.5B Инструктировать';
1640
+
1641
+ @override
1642
+ String get model_qwen_25_desc =>
1643
+ 'Сбалансированное качество и размер. Подходит для общего разговора.';
1644
+
1645
+ @override
1646
+ String get model_deepseek => 'DeepSeek R1 Дистилл Qwen 1.5B';
1647
+
1648
+ @override
1649
+ String get model_deepseek_desc =>
1650
+ 'Модель рассуждения и цепочки мыслей. Лучше всего подходит для логических задач.';
1651
+
1652
+ @override
1653
+ String get model_license_mit => 'Массачусетский технологический институт';
1654
+
1655
+ @override
1656
+ String get model_gemma => 'Джемма 4 E2B Инструктирует';
1657
+
1658
+ @override
1659
+ String get model_gemma_desc =>
1660
+ 'Флагманская модель Google. Высочайшее качество, требует больше оперативной памяти.';
1661
+
1662
+ @override
1663
+ String export_header(String date) {
1664
+ return '*Экспортировано из LocalMind — $date*';
1665
+ }
1666
+
1667
+ @override
1668
+ String get export_role_user => '## 👤 Пользователь';
1669
+
1670
+ @override
1671
+ String get export_role_assistant => '## 🤖 Помощник';
1672
+
1673
+ @override
1674
+ String get export_role_system => '## ⚙️ Система';
1675
+
1676
+ @override
1677
+ String get export_role_tool => '## 🔧 Инструмент';
1678
+
1679
+ @override
1680
+ String get export_text_user => '[ПОЛЬЗОВАТЕЛЬ]';
1681
+
1682
+ @override
1683
+ String get export_text_assistant => '[ПОМОЩНИК]';
1684
+
1685
+ @override
1686
+ String get export_text_system => '[СИСТЕМА]';
1687
+
1688
+ @override
1689
+ String get export_text_tool => '[ИНСТРУМЕНТ]';
1690
+
1691
+ @override
1692
+ String get export_label_user => 'ПОЛЬЗОВАТЕЛЬ';
1693
+
1694
+ @override
1695
+ String get export_label_assistant => 'ПОМОЩНИК';
1696
+
1697
+ @override
1698
+ String get export_label_system => 'СИСТЕМА';
1699
+
1700
+ @override
1701
+ String get export_label_tool => 'ИНСТРУМЕНТ';
1702
+
1703
+ @override
1704
+ String get select_model_hint => 'Выберите модель, чтобы начать общение';
1705
+
1706
+ @override
1707
+ String get test_notification_title => 'Уведомление о тестировании';
1708
+
1709
+ @override
1710
+ String get test_notification_body =>
1711
+ 'Это тестовое уведомление о ходе загрузки модели.';
1712
+
1713
+ @override
1714
+ String get tts_supports_background =>
1715
+ 'Поддерживает фоновое воспроизведение как собственный звук';
1716
+
1717
+ @override
1718
+ String get tts_other_services_background_note =>
1719
+ 'Примечание. Другие службы TTS поддерживают фоновое воспроизведение как собственный звук.';
1720
+
1721
+ @override
1722
+ String get gguf_imported_models_title => 'Импортированные модели GGUF';
1723
+
1724
+ @override
1725
+ String get gguf_imported_models_empty_subtitle =>
1726
+ 'Импортируйте GGUF со своего устройства или добавьте его из Hugging Face. Импортированные модели запускаются локально с помощью llama.cpp.';
1727
+
1728
+ @override
1729
+ String get gguf_imported_models_ready =>
1730
+ 'импортированные модели готовы для локального вывода.';
1731
+
1732
+ @override
1733
+ String get gguf_curated_models_subtitle =>
1734
+ 'Рекомендуемые модели на устройстве, которые вы можете загружать и управлять ими внутри LocalMind.';
1735
+
1736
+ @override
1737
+ String get gguf_only_supported =>
1738
+ 'Для этого импорта поддерживаются только модели GGUF.';
1739
+
1740
+ @override
1741
+ String get gguf_imported_from_local_file =>
1742
+ 'импортировано из локального файла.';
1743
+
1744
+ @override
1745
+ String get gguf_import_failed => 'Не удалось импортировать модель GGUF.';
1746
+
1747
+ @override
1748
+ String get gguf_imported_from_huggingface => 'импортировано из Hugging Face.';
1749
+
1750
+ @override
1751
+ String get gguf_import_canceled => 'Импорт GGUF отменен.';
1752
+
1753
+ @override
1754
+ String get gguf_enter_huggingface_url =>
1755
+ 'Введите URL-адрес GGUF «Обнимающее лицо».';
1756
+
1757
+ @override
1758
+ String get gguf_only_official_huggingface_urls =>
1759
+ 'Поддерживаются только официальные URL-адреса Hugging Face GGUF.';
1760
+
1761
+ @override
1762
+ String get gguf_use_https_url =>
1763
+ 'Используйте URL-адрес обнимающего лица HTTPS для импорта GGUF.';
1764
+
1765
+ @override
1766
+ String get gguf_url_must_point_to_file =>
1767
+ 'URL-адрес «Обнимающего лица» должен указывать непосредственно на файл .gguf.';
1768
+
1769
+ @override
1770
+ String get gguf_unable_to_detect_file_name =>
1771
+ 'Невозможно определить имя файла GGUF.';
1772
+
1773
+ @override
1774
+ String get gguf_download_empty =>
1775
+ 'Загруженный файл GGUF был пуст или отсутствовал.';
1776
+
1777
+ @override
1778
+ String get gguf_selected_file_missing =>
1779
+ 'Выбранный файл модели не существует.';
1780
+
1781
+ @override
1782
+ String get gguf_import_action => 'Импортировать GGUF';
1783
+
1784
+ @override
1785
+ String get gguf_overview_title => 'Принесите свои собственные модели GGUF';
1786
+
1787
+ @override
1788
+ String get gguf_overview_subtitle =>
1789
+ 'Импортируйте .gguf из локального хранилища или загрузите его прямо с Hugging Face. Импортированные модели остаются на этом устройстве и загружаются с помощью llama.cpp.';
1790
+
1791
+ @override
1792
+ String get gguf_imported_count_label => 'импортированный';
1793
+
1794
+ @override
1795
+ String get gguf_local_files_label => 'локальные файлы';
1796
+
1797
+ @override
1798
+ String get gguf_huggingface_label => 'Обнимающее лицо';
1799
+
1800
+ @override
1801
+ String get gguf_import_local_title => 'Импортировать локальный GGUF';
1802
+
1803
+ @override
1804
+ String get gguf_import_local_subtitle =>
1805
+ 'Скопируйте файл .gguf с этого устройства.';
1806
+
1807
+ @override
1808
+ String get gguf_import_huggingface_title => 'Импорт из «Обнимающего лица»';
1809
+
1810
+ @override
1811
+ String get gguf_import_huggingface_subtitle =>
1812
+ 'Вставьте URL-адрес GGUF или путь к репозиторию.';
1813
+
1814
+ @override
1815
+ String get gguf_no_imported_title => 'Импортированных моделей GGUF пока нет.';
1816
+
1817
+ @override
1818
+ String get gguf_no_imported_subtitle =>
1819
+ 'Вы можете перенести свой собственный файл GGUF из хранилища устройства или вставить URL-адрес Hugging Face или путь к репозиторию, указывающий на файл .gguf.';
1820
+
1821
+ @override
1822
+ String get gguf_import_huggingface_dialog_title =>
1823
+ 'Импортировать GGUF из Hugging Face';
1824
+
1825
+ @override
1826
+ String get gguf_import_huggingface_dialog_subtitle =>
1827
+ 'Вставьте прямой URL-адрес GGUF или путь репозитория Hugging Face, например `owner/repo/blob/main/model.gguf`. Ссылки на большие двоичные объекты преобразуются автоматически.';
1828
+
1829
+ @override
1830
+ String get gguf_url_or_repo_path => 'URL-адрес GGUF или путь к репозиторию';
1831
+
1832
+ @override
1833
+ String get paste => 'Вставить';
1834
+
1835
+ @override
1836
+ String get gguf_browse => 'Просмотр GGUF';
1837
+
1838
+ @override
1839
+ String get gguf_huggingface_token_ready => 'Жетон «Обнимающее лицо» готов.';
1840
+
1841
+ @override
1842
+ String get gguf_huggingface_token_optional =>
1843
+ 'Токен необязателен, но рекомендуется';
1844
+
1845
+ @override
1846
+ String get gguf_huggingface_token_ready_desc =>
1847
+ 'Ваш сохраненный токен будет автоматически использоваться для закрытых или частных репозиториев.';
1848
+
1849
+ @override
1850
+ String get gguf_huggingface_token_optional_desc =>
1851
+ 'Требуется жетон «Обнимающее лицо». Добавьте его в настройках, если этот GGUF является закрытым или частным.';
1852
+
1853
+ @override
1854
+ String get gguf_downloading => 'Загрузка GGUF';
1855
+
1856
+ @override
1857
+ String get gguf_preparing => 'Подготовка';
1858
+
1859
+ @override
1860
+ String get gguf_preparing_download => 'Подготовка загрузки...';
1861
+
1862
+ @override
1863
+ String get gguf_cancel_import => 'Отменить импорт';
1864
+
1865
+ @override
1866
+ String get clipboard_empty => 'Буфер обмена пуст.';
1867
+
1868
+ @override
1869
+ String get could_not_open_huggingface =>
1870
+ 'Не удалось открыть «Обнимающее лицо».';
1871
+
1872
+ @override
1873
+ String get gguf_paste_url_error =>
1874
+ 'Вставьте URL-адрес GGUF Hugging Face или путь к репозиторию.';
1875
+
1876
+ @override
1877
+ String get gguf_blob_link => 'Ссылка на BLOB-объект';
1878
+
1879
+ @override
1880
+ String get gguf_repository_label => 'Репозиторий';
1881
+
1882
+ @override
1883
+ String get gguf_detected_path_label => 'Обнаруженный путь';
1884
+
1885
+ @override
1886
+ String get gguf_imported_section_label => 'Импортированный GGUF';
1887
+
1888
+ @override
1889
+ String get gguf_already_available => 'Уже доступно на этом устройстве';
1890
+
1891
+ @override
1892
+ String get gguf_curated_models_short => 'Кураторские модели на устройстве';
1893
+
1894
+ @override
1895
+ String get execute_tool_title => 'Выполнить инструмент';
1896
+
1897
+ @override
1898
+ String get execute_tool_request_desc =>
1899
+ 'Модель запрашивает выполнение следующего инструмента:';
1900
+
1901
+ @override
1902
+ String get reject => 'Отклонять';
1903
+
1904
+ @override
1905
+ String get approve => 'Утвердить';
1906
+
1907
+ @override
1908
+ String get server_type_help =>
1909
+ 'Прежде чем заполнять данные подключения, выберите провайдера.';
1910
+
1911
+ @override
1912
+ String get server_identity_title => 'Личность';
1913
+
1914
+ @override
1915
+ String get server_identity_desc =>
1916
+ 'Назовите этот сервер и выберите, как он будет отображаться в списке.';
1917
+
1918
+ @override
1919
+ String get server_connection_title => 'Связь';
1920
+
1921
+ @override
1922
+ String get server_connection_desc =>
1923
+ 'Используйте адрес и порт, предоставленные вашим сервером.';
1924
+
1925
+ @override
1926
+ String get server_authentication_title => 'Аутентификация';
1927
+
1928
+ @override
1929
+ String get server_authentication_required_desc =>
1930
+ 'OpenRouter требует ключ API перед тестированием.';
1931
+
1932
+ @override
1933
+ String get server_authentication_optional_desc =>
1934
+ 'Оставьте ключ API пустым, если этот сервер не требует его.';
1935
+
1936
+ @override
1937
+ String get mcp_tools_title => 'Инструменты MCP';
1938
+
1939
+ @override
1940
+ String get available_tools => 'Доступные инструменты';
1941
+
1942
+ @override
1943
+ String get unable_load_tools => 'Невозможно загрузить инструменты';
1944
+
1945
+ @override
1946
+ String get no_tools_registered => 'Инструменты не зарегистрированы';
1947
+
1948
+ @override
1949
+ String get no_tools_registered_desc =>
1950
+ 'Включите пример сервера MCP или добавьте интеграцию MCP из настроек чата.';
1951
+
1952
+ @override
1953
+ String get example_mcp_server_title => 'Пример MCP-сервера';
1954
+
1955
+ @override
1956
+ String get example_mcp_server_desc =>
1957
+ 'Регистрирует example.echo и example.word_count через того же поставщика инструментов MCP, который используется внешними серверами.';
1958
+
1959
+ @override
1960
+ String get disable_example_server => 'Отключить пример сервера';
1961
+
1962
+ @override
1963
+ String get enable_example_server => 'Включить пример сервера';
1964
+
1965
+ @override
1966
+ String get built_in_label => 'Встроенный';
1967
+
1968
+ @override
1969
+ String get highlights_label => 'Основные моменты';
1970
+
1971
+ @override
1972
+ String get built_with_label => 'Построен с';
1973
+
1974
+ @override
1975
+ String get local_label => 'Местный';
1976
+
1977
+ @override
1978
+ String get gguf_format_label => 'ГГУФ';
1979
+
1980
+ @override
1981
+ String get tool_status_requested => 'Запрошено';
1982
+
1983
+ @override
1984
+ String get tool_status_approved => 'Одобренный';
1985
+
1986
+ @override
1987
+ String get tool_status_rejected => 'Отклоненный';
1988
+
1989
+ @override
1990
+ String get tool_status_running => 'Бег';
1991
+
1992
+ @override
1993
+ String get tool_status_done => 'Сделанный';
1994
+
1995
+ @override
1996
+ String get tool_status_failed => 'Неуспешный';
1997
+
1998
+ @override
1999
+ String get model_favorite_toggle => 'Toggle favorite';
2000
+
2001
+ @override
2002
+ String get model_note_label => 'Note';
2003
+
2004
+ @override
2005
+ String get model_note_hint => 'Add a note about this model…';
2006
+
2007
+ @override
2008
+ String get unload_models_before_load =>
2009
+ 'Unload all models before loading a new one';
2010
+
2011
+ @override
2012
+ String get export_all_data => 'Export all data';
2013
+
2014
+ @override
2015
+ String get import_all_data => 'Import all data';
2016
+
2017
+ @override
2018
+ String get export_data_success => 'Backup exported successfully';
2019
+
2020
+ @override
2021
+ String get import_data_success => 'Backup imported successfully';
2022
+
2023
+ @override
2024
+ String import_data_failed(String error) {
2025
+ return 'Failed to import backup: $error';
2026
+ }
2027
+
2028
+ @override
2029
+ String get import_data_confirm =>
2030
+ 'Import conversations and custom personas from this backup? Existing items with the same IDs will be updated.';
2031
+
2032
+ @override
2033
+ String get import_settings_confirm =>
2034
+ 'Replace current settings with the imported backup?';
2035
+
2036
+ @override
2037
+ String get export_conversations => 'Export conversations';
2038
+
2039
+ @override
2040
+ String get import_conversations => 'Import conversations';
2041
+
2042
+ @override
2043
+ String get export_personas => 'Export personas';
2044
+
2045
+ @override
2046
+ String get import_personas => 'Import personas';
2047
+
2048
+ @override
2049
+ String get export_settings => 'Export settings';
2050
+
2051
+ @override
2052
+ String get import_settings => 'Import settings';
2053
+
2054
+ @override
2055
+ String get export_all_zip => 'Export all (ZIP)';
2056
+
2057
+ @override
2058
+ String get import_all_zip => 'Import all (ZIP)';
2059
+
2060
+ @override
2061
+ String get duplicate_chat => 'Duplicate chat';
2062
+
2063
+ @override
2064
+ String get duplicate_chat_success => 'Chat duplicated';
2065
+
2066
+ @override
2067
+ String get move_to_folder => 'Move to folder';
2068
+
2069
+ @override
2070
+ String get remove_from_folder => 'Remove from folder';
2071
+
2072
+ @override
2073
+ String get create_folder => 'Create folder';
2074
+
2075
+ @override
2076
+ String get new_folder => 'New folder';
2077
+
2078
+ @override
2079
+ String get folder_name_hint => 'Folder name';
2080
+
2081
+ @override
2082
+ String get all_chats => 'All';
2083
+
2084
+ @override
2085
+ String get unfiled_chats => 'Unfiled';
2086
+
2087
+ @override
2088
+ String get create => 'Create';
2089
+
2090
+ @override
2091
+ String get server_path_prefix_label => 'API path prefix';
2092
+
2093
+ @override
2094
+ String get server_path_prefix_hint => '/your-secret-token';
2095
+
2096
+ @override
2097
+ String get search_message_contents => 'Search message contents';
2098
+
2099
+ @override
2100
+ String get message_search_results => 'Message matches';
2101
+
2102
+ @override
2103
+ String get saved_messages_title => 'Saved Messages';
2104
+
2105
+ @override
2106
+ String get nav_saved_messages => 'Saved Messages';
2107
+
2108
+ @override
2109
+ String get saved_messages_empty =>
2110
+ 'No saved messages yet. Bookmark a message from its options menu.';
2111
+
2112
+ @override
2113
+ String get save_message => 'Save message';
2114
+
2115
+ @override
2116
+ String get message_saved => 'Message saved';
2117
+
2118
+ @override
2119
+ String token_count(int count) {
2120
+ return '$count tokens';
2121
+ }
2122
+
2123
+ @override
2124
+ String estimated_token_count(int count) {
2125
+ return '~$count tokens (estimated)';
2126
+ }
2127
+
2128
+ @override
2129
+ String get test_tts_section_title => 'Test voice';
2130
+
2131
+ @override
2132
+ String get test_tts_hint => 'Enter text to hear the current TTS engine…';
2133
+
2134
+ @override
2135
+ String get test_speak_button => 'Speak';
2136
+
2137
+ @override
2138
+ String get scroll_to_bottom => 'Scroll to bottom';
2139
+
2140
+ @override
2141
+ String get generate_ai_response => 'Generate AI response';
2142
+
2143
+ @override
2144
+ String get no_response => 'No response';
2145
+
2146
+ @override
2147
+ String get export => 'Export';
2148
+
2149
+ @override
2150
+ String get import => 'Import';
2151
+
2152
+ @override
2153
+ String get conversations_label => 'Conversations';
2154
+
2155
+ @override
2156
+ String get personas_label => 'Personas';
2157
+
2158
+ @override
2159
+ String get settings_label => 'Settings';
2160
+
2161
+ @override
2162
+ String get export_conversation => 'Export conversation';
2163
+
2164
+ @override
2165
+ String get tts_process_markdown => 'Process markdown for speech';
2166
+
2167
+ @override
2168
+ String get tts_process_markdown_desc =>
2169
+ 'Strip formatting like **bold** before reading aloud';
2170
+
2171
+ @override
2172
+ String get tts_skip_seconds => 'Skip interval';
2173
+
2174
+ @override
2175
+ String get tts_skip_seconds_desc =>
2176
+ 'Forward and rewind jump size during playback';
2177
+
2178
+ @override
2179
+ String tts_skip_seconds_value(int seconds) {
2180
+ return '${seconds}s';
2181
+ }
2182
+
2183
+ @override
2184
+ String get preview_system_prompts => 'Preview system prompts';
2185
+
2186
+ @override
2187
+ String get welcome_message_1 => 'What can I help you with today?';
2188
+
2189
+ @override
2190
+ String get welcome_message_2 => 'Ask me anything — I\'m ready when you are.';
2191
+
2192
+ @override
2193
+ String get welcome_message_3 => 'Start a conversation below.';
2194
+
2195
+ @override
2196
+ String get welcome_message_4 => 'Need ideas? Try one of the quick prompts.';
2197
+
2198
+ @override
2199
+ String get temporary_chat => 'Temporary chat';
2200
+
2201
+ @override
2202
+ String get temporary_chat_desc => 'Chats aren\'t saved to history.';
2203
+
2204
+ @override
2205
+ String get temporary_chat_banner => 'Temporary chat — not saved to history';
2206
+
2207
+ @override
2208
+ String get temporary_chat_save_warning_title =>
2209
+ 'Save message in temporary chat?';
2210
+
2211
+ @override
2212
+ String get temporary_chat_save_warning_body =>
2213
+ 'This chat is temporary and hidden from history. The saved message will still appear in Saved Messages.';
2214
+
2215
+ @override
2216
+ String get save_to_history => 'Save to history';
2217
+
2218
+ @override
2219
+ String get share_conversation => 'Share conversation';
2220
+
2221
+ @override
2222
+ String get download_tts_audio => 'Download audio';
2223
+
2224
+ @override
2225
+ String get tts_download_unavailable =>
2226
+ 'Download is only available for Piper and Kitten TTS';
2227
+
2228
+ @override
2229
+ String get tts_download_no_audio => 'No audio available to download yet';
2230
+
2231
+ @override
2232
+ String get tts_download_success => 'Audio saved';
2233
+
2234
+ @override
2235
+ String get return_to_chat => 'Return to chat';
2236
+
2237
+ @override
2238
+ String get return_to_temp_chat => 'Return to temporary chat';
2239
+
2240
+ @override
2241
+ String get insert_saved_message => 'Insert saved message';
2242
+
2243
+ @override
2244
+ String get insert_saved_message_desc =>
2245
+ 'Choose a saved message to add to your input';
2246
+
2247
+ @override
2248
+ String get model_info => 'Model info';
2249
+
2250
+ @override
2251
+ String get model_name => 'Model name';
2252
+
2253
+ @override
2254
+ String get model_identifier => 'Identifier';
2255
+
2256
+ @override
2257
+ String get not_available => 'Not available';
2258
+
2259
+ @override
2260
+ String get save_message_folders => 'Save message';
2261
+
2262
+ @override
2263
+ String get remove_from_saved => 'Remove from saved';
2264
+
2265
+ @override
2266
+ String get message_already_saved => 'Saved';
2267
+
2268
+ @override
2269
+ String get stream_ttft => 'Time to first token';
2270
+
2271
+ @override
2272
+ String get stream_tokens_per_sec => 'Tokens per second';
2273
+
2274
+ @override
2275
+ String get stream_stop_reason => 'Stop reason';
2276
+
2277
+ @override
2278
+ String get stream_input_tokens => 'Input tokens';
2279
+
2280
+ @override
2281
+ String get stream_output_tokens => 'Output tokens';
2282
+
2283
+ @override
2284
+ String get stream_generation_time => 'Generation time';
2285
+
2286
+ @override
2287
+ String get attach_image => 'Image';
2288
+
2289
+ @override
2290
+ String get attach_text_document => 'Text document';
2291
+
2292
+ @override
2293
+ String get add_attachment => 'Add attachment';
2294
+
2295
+ @override
2296
+ String get photo_permission_denied =>
2297
+ 'Photo access is required to attach images';
2298
+
2299
+ @override
2300
+ String get characters_label => 'Characters';
2301
+
2302
+ @override
2303
+ String get exit_temporary_chat_title => 'Выйти из временного чата?';
2304
+
2305
+ @override
2306
+ String get exit_temporary_chat_body =>
2307
+ 'Текущий временный чат будет удалён, и вы вернётесь к новому чату.';
2308
+
2309
+ @override
2310
+ String get saved_message_temp_snap_unavailable =>
2311
+ 'Это сообщение сохранено из временного чата и не может быть открыто в исходной беседе.';
2312
+
2313
+ @override
2314
+ String get filter_title => 'Фильтр';
2315
+
2316
+ @override
2317
+ String get filter_pinned => 'Закреплённые';
2318
+
2319
+ @override
2320
+ String get filter_archived => 'Архив';
2321
+
2322
+ @override
2323
+ String get filter_temp_chats => 'Временные чаты';
2324
+
2325
+ @override
2326
+ String get filter_user_messages => 'Сообщения пользователя';
2327
+
2328
+ @override
2329
+ String get filter_assistant_messages => 'Сообщения ассистента';
2330
+
2331
+ @override
2332
+ String get archive_chat => 'Архивировать';
2333
+
2334
+ @override
2335
+ String get unarchive_chat => 'Разархивировать';
2336
+
2337
+ @override
2338
+ String conversation_message_count(int count) {
2339
+ String _temp0 = intl.Intl.pluralLogic(
2340
+ count,
2341
+ locale: localeName,
2342
+ other: '$count сообщений',
2343
+ many: '$count сообщений',
2344
+ few: '$count сообщения',
2345
+ one: '1 сообщение',
2346
+ );
2347
+ return '$_temp0';
2348
+ }
2349
+
2350
+ @override
2351
+ String conversation_character_count(int count) {
2352
+ return '$count симв.';
2353
+ }
2354
+
2355
+ @override
2356
+ String get generate_title_with_ai => 'Сгенерировать с ИИ';
2357
+
2358
+ @override
2359
+ String get generating_title => 'Генерация...';
2360
+
2361
+ @override
2362
+ String get generate_title_failed => 'Не удалось сгенерировать заголовок';
2363
+}
lib/l10n/app_localizations_zh.dart
+176
@@ -2122,6 +2122,18 @@ class AppLocalizationsZh extends AppLocalizations {
2122
String get tts_process_markdown_desc =>
2123
'Strip formatting like **bold** before reading aloud';
2124
2125
+ @override
2126
+ String get tts_skip_seconds => 'Skip interval';
2127
+
2128
+ @override
2129
+ String get tts_skip_seconds_desc =>
2130
+ 'Forward and rewind jump size during playback';
2131
+
2132
+ @override
2133
+ String tts_skip_seconds_value(int seconds) {
2134
+ return '${seconds}s';
2135
+ }
2136
+
2137
@override
2138
String get preview_system_prompts => 'Preview system prompts';
2139
@@ -2136,6 +2148,170 @@ class AppLocalizationsZh extends AppLocalizations {
2148
2149
@override
2150
String get welcome_message_4 => 'Need ideas? Try one of the quick prompts.';
2151
+
2152
+ @override
2153
+ String get temporary_chat => 'Temporary chat';
2154
+
2155
+ @override
2156
+ String get temporary_chat_desc => 'Chats aren\'t saved to history.';
2157
+
2158
+ @override
2159
+ String get temporary_chat_banner => 'Temporary chat — not saved to history';
2160
+
2161
+ @override
2162
+ String get temporary_chat_save_warning_title =>
2163
+ 'Save message in temporary chat?';
2164
+
2165
+ @override
2166
+ String get temporary_chat_save_warning_body =>
2167
+ 'This chat is temporary and hidden from history. The saved message will still appear in Saved Messages.';
2168
+
2169
+ @override
2170
+ String get save_to_history => 'Save to history';
2171
+
2172
+ @override
2173
+ String get share_conversation => 'Share conversation';
2174
+
2175
+ @override
2176
+ String get download_tts_audio => 'Download audio';
2177
+
2178
+ @override
2179
+ String get tts_download_unavailable =>
2180
+ 'Download is only available for Piper and Kitten TTS';
2181
+
2182
+ @override
2183
+ String get tts_download_no_audio => 'No audio available to download yet';
2184
+
2185
+ @override
2186
+ String get tts_download_success => 'Audio saved';
2187
+
2188
+ @override
2189
+ String get return_to_chat => 'Return to chat';
2190
+
2191
+ @override
2192
+ String get return_to_temp_chat => 'Return to temporary chat';
2193
+
2194
+ @override
2195
+ String get insert_saved_message => 'Insert saved message';
2196
+
2197
+ @override
2198
+ String get insert_saved_message_desc =>
2199
+ 'Choose a saved message to add to your input';
2200
+
2201
+ @override
2202
+ String get model_info => 'Model info';
2203
+
2204
+ @override
2205
+ String get model_name => 'Model name';
2206
+
2207
+ @override
2208
+ String get model_identifier => 'Identifier';
2209
+
2210
+ @override
2211
+ String get not_available => 'Not available';
2212
+
2213
+ @override
2214
+ String get save_message_folders => 'Save message';
2215
+
2216
+ @override
2217
+ String get remove_from_saved => 'Remove from saved';
2218
+
2219
+ @override
2220
+ String get message_already_saved => 'Saved';
2221
+
2222
+ @override
2223
+ String get stream_ttft => 'Time to first token';
2224
+
2225
+ @override
2226
+ String get stream_tokens_per_sec => 'Tokens per second';
2227
+
2228
+ @override
2229
+ String get stream_stop_reason => 'Stop reason';
2230
+
2231
+ @override
2232
+ String get stream_input_tokens => 'Input tokens';
2233
+
2234
+ @override
2235
+ String get stream_output_tokens => 'Output tokens';
2236
+
2237
+ @override
2238
+ String get stream_generation_time => 'Generation time';
2239
+
2240
+ @override
2241
+ String get attach_image => 'Image';
2242
+
2243
+ @override
2244
+ String get attach_text_document => 'Text document';
2245
+
2246
+ @override
2247
+ String get add_attachment => 'Add attachment';
2248
+
2249
+ @override
2250
+ String get photo_permission_denied =>
2251
+ 'Photo access is required to attach images';
2252
+
2253
+ @override
2254
+ String get characters_label => 'Characters';
2255
+
2256
+ @override
2257
+ String get exit_temporary_chat_title => 'Exit temporary chat?';
2258
+
2259
+ @override
2260
+ String get exit_temporary_chat_body =>
2261
+ 'This will discard the current temporary chat and return to a new chat.';
2262
+
2263
+ @override
2264
+ String get saved_message_temp_snap_unavailable =>
2265
+ 'This message was saved from a temporary chat and can\'t be opened in its original conversation.';
2266
+
2267
+ @override
2268
+ String get filter_title => 'Filter';
2269
+
2270
+ @override
2271
+ String get filter_pinned => 'Pinned';
2272
+
2273
+ @override
2274
+ String get filter_archived => 'Archived';
2275
+
2276
+ @override
2277
+ String get filter_temp_chats => 'Temporary chats';
2278
+
2279
+ @override
2280
+ String get filter_user_messages => 'User messages';
2281
+
2282
+ @override
2283
+ String get filter_assistant_messages => 'Assistant messages';
2284
+
2285
+ @override
2286
+ String get archive_chat => 'Archive';
2287
+
2288
+ @override
2289
+ String get unarchive_chat => 'Unarchive';
2290
+
2291
+ @override
2292
+ String conversation_message_count(int count) {
2293
+ String _temp0 = intl.Intl.pluralLogic(
2294
+ count,
2295
+ locale: localeName,
2296
+ other: '$count messages',
2297
+ one: '1 message',
2298
+ );
2299
+ return '$_temp0';
2300
+ }
2301
+
2302
+ @override
2303
+ String conversation_character_count(int count) {
2304
+ return '$count chars';
2305
+ }
2306
+
2307
+ @override
2308
+ String get generate_title_with_ai => 'Generate with AI';
2309
+
2310
+ @override
2311
+ String get generating_title => 'Generating...';
2312
+
2313
+ @override
2314
+ String get generate_title_failed => 'Could not generate a title';
2315
}
2316
2317
/// The translations for Chinese, as used in Taiwan (`zh_TW`).
lib/l10n/app_ru.arb
new
+619
@@ -0,0 +1,619 @@
1
+{
2
+ "@@locale": "ru",
3
+ "active": "Активный",
4
+ "active_integrations": "Активная интеграция",
5
+ "add_ephemeral_mcp": "Добавить эфемерный сервер MCP",
6
+ "add_server": "Добавить сервер",
7
+ "add_server_first": "Сначала добавьте сервер, чтобы увидеть доступные модели.",
8
+ "add_server_title": "Добавить сервер",
9
+ "advanced_settings": "Расширенные настройки",
10
+ "all": "Все",
11
+ "allow_notifications": "Разрешить уведомления",
12
+ "always_available": "Всегда доступен",
13
+ "api_key_format": "Ключи API OpenRouter начинаются с sk-",
14
+ "api_key_hint_generic": "Для аутентифицированных серверов",
15
+ "api_key_hint_openrouter": "ск-...",
16
+ "api_key_optional": "Ключ API (необязательно)",
17
+ "api_key_required": "API-ключ *",
18
+ "api_key_required_openrouter": "Ключ API, необходимый для OpenRouter",
19
+ "app_name": "LocalMind",
20
+ "app_tagline": "Ваш ИИ. Ваше устройство. Ваши правила.",
21
+ "app_version": "1.0.0",
22
+ "attach_images_tooltip": "Прикрепите изображения или текст",
23
+ "auto_execute_tools": "Инструменты автоматического выполнения",
24
+ "auto_generate_titles": "Автоматическое создание заголовков",
25
+ "available": "Доступный",
26
+ "beta_label": "Бета",
27
+ "experimental_label": "Экспериментальный",
28
+ "available_models": "Доступные модели",
29
+ "available_ram": "Доступная оперативная память",
30
+ "backend_cpu_desc": "Работает на всех устройствах. Наиболее совместим.",
31
+ "backend_gpu_desc": "Ускорение OpenCL. Быстрее на поддерживаемых устройствах.",
32
+ "backend_ios_notice": "В iOS доступен только серверный процессор.",
33
+ "backend_npu_desc": "Поставщик НПУ (Qualcomm/MediaTek). Самый быстрый вывод.",
34
+ "builtin_badge": "Встроенный",
35
+ "calculating": "Расчет...",
36
+ "cancel": "Отмена",
37
+ "cannot_undo": "Это действие невозможно отменить.",
38
+ "category_label": "Категория",
39
+ "change_persona": "Изменить личность",
40
+ "character_count": "{length} символов",
41
+ "character_count_max": "{currentLen}/4000",
42
+ "chat_history_title": "История чата",
43
+ "chat_input_hint": "Спроси что-нибудь",
44
+ "chat_parameters_tooltip": "Параметры чата",
45
+ "chat_settings_title": "Настройки чата",
46
+ "chat_title": "LocalMind",
47
+ "choose_theme": "Выбрать тему",
48
+ "choose_theme_desc": "Персонализируйте внешний вид приложения. Вы всегда можете изменить это позже в настройках.",
49
+ "clear": "Прозрачный",
50
+ "clear_conversation": "Чистый разговор",
51
+ "clear_conversation_body": "Это приведет к удалению всех сообщений в этом разговоре.",
52
+ "clear_conversation_title": "Ясный разговор?",
53
+ "clone_edit": "Клонировать и редактировать",
54
+ "close": "Закрывать",
55
+ "code_theme_dark": "Кодовая тема (темная)",
56
+ "code_theme_desc": "Выберите тему подсветки синтаксиса для блоков кода.",
57
+ "code_theme_light": "Тема кода (светлая)",
58
+ "configure": "Настроить",
59
+ "configuring_server": "Настройка сервера...",
60
+ "confirm": "Подтверждать",
61
+ "connection_error": "Ошибка подключения. Проверьте свой сервер.",
62
+ "connection_failed": "Соединение не удалось. Проверьте свои настройки.",
63
+ "connection_successful": "Подключение успешно!",
64
+ "context_chip": "{ctx} ctx",
65
+ "context_length": "Длина контекста",
66
+ "context_length_desc": "Окно истории",
67
+ "continue_action": "Продолжать",
68
+ "conversation_date": "{month}/{day}/{year}",
69
+ "conversation_days_ago": "{days}д назад",
70
+ "conversation_hours_ago": "{hours}ч назад",
71
+ "conversation_id": "Идентификатор беседы",
72
+ "conversation_just_now": "Прямо сейчас",
73
+ "conversation_minutes_ago": "{minutes} мин назад",
74
+ "conversation_yesterday": "Вчера",
75
+ "copied": "Скопировано!",
76
+ "copied_markdown": "Скопировано как Markdown",
77
+ "copied_to_clipboard": "Скопировано в буфер обмена",
78
+ "copy": "Копировать",
79
+ "copy_info": "Копировать информацию",
80
+ "copy_markdown": "Скопировать как Markdown",
81
+ "create_persona": "Создать Персону",
82
+ "create_persona_button": "Создавать",
83
+ "created_at": "Создано в",
84
+ "debug_dialog_desc": "Диагностическая информация, помогающая выявить проблемы синхронизации.",
85
+ "default_badge": "По умолчанию",
86
+ "default_icon": "Значок по умолчанию",
87
+ "delete": "Удалить",
88
+ "delete_all_conversations": "Удалить все разговоры",
89
+ "delete_conversation": "Удалить разговор?",
90
+ "delete_conversation_body": "Вы уверены, что хотите удалить \"{title}\"? Это невозможно отменить.",
91
+ "delete_conversation_title": "Удалить разговор?",
92
+ "delete_message_title": "Удалить сообщение?",
93
+ "delete_model_body": "Вы уверены, что хотите удалить {name}?",
94
+ "delete_model_body_with_size": "Вы уверены, что хотите удалить {name}? Это освободит примерно {size} места.\n\nПри необходимости вы сможете загрузить эту модель позже.",
95
+ "delete_model_title": "Удалить модель",
96
+ "delete_persona_body": "Это невозможно отменить.",
97
+ "delete_persona_title": "Удалить \"{name}\"?",
98
+ "delete_server_body": "Вы уверены, что хотите удалить \"{name}\"? Это невозможно отменить.",
99
+ "delete_server_title": "Удалить сервер",
100
+ "delete_voice_body": "Вы уверены, что хотите удалить {name}? Это освободит примерно {size} места.\n\nПри необходимости вы сможете скачать этот голос позже.",
101
+ "delete_voice_title": "Удалить голос",
102
+ "description_hint": "Что делает эта личность...",
103
+ "description_optional": "Описание (необязательно)",
104
+ "device_memory": "Память устройства",
105
+ "disconnected": "Отключился от сервера.",
106
+ "done": "Сделанный",
107
+ "download": "Скачать",
108
+ "download_complete_body": "Модель {modelName} успешно загружена.",
109
+ "download_complete_notification": "Загрузка завершена!",
110
+ "download_failed": "Загрузка не удалась",
111
+ "download_failed_body": "Не удалось загрузить {modelName}.",
112
+ "download_failed_notification": "Загрузка не удалась: {error}",
113
+ "download_model_desc": "Выберите модель для загрузки.\nОн будет работать локально на вашем устройстве.",
114
+ "download_model_title": "Скачать модель",
115
+ "download_notification_title": "Загрузка {modelName}...",
116
+ "download_progress": "{percent}% • {speed}",
117
+ "downloaded": "Скачано",
118
+ "downloading_status": "Загрузка...",
119
+ "edit": "Редактировать",
120
+ "edit_message": "Редактировать сообщение",
121
+ "edit_message_desc": "При сохранении ответ помощника ниже будет удален и восстановлен.",
122
+ "edit_persona": "Изменить личность",
123
+ "edit_server": "Редактировать сервер",
124
+ "emoji_label": "Эмодзи",
125
+ "enable": "Давать возможность",
126
+ "enable_mcp": "Включить MCP",
127
+ "enable_notifications": "Включить уведомления",
128
+ "enable_notifications_desc": "Получайте уведомления, когда модели завершают загрузку.",
129
+ "engine_name_kitten": "Котенок ТТС",
130
+ "engine_name_sherpa": "Шерпа ОННКС ВИТС",
131
+ "engine_name_system": "Система ТТС",
132
+ "engine_spec": "{sizeMb} МБ · {ramMb} МБ ОЗУ · {voiceCount} голосов",
133
+ "engine_tagline_kitten": "Высокоскоростная нейронная TTS",
134
+ "engine_tagline_sherpa": "Оффлайн голоса Пайпер",
135
+ "engine_tagline_system": "Встроенный движок устройства",
136
+ "enter_new_title": "Введите новое название",
137
+ "error": "Ошибка",
138
+ "error_with_message": "Ошибка: {error}",
139
+ "eta_label": "Оставшееся время: {eta}",
140
+ "expected_messages": "Ожидаемые сообщения",
141
+ "export_header": "*Экспортировано из LocalMind — {date}*",
142
+ "export_label_assistant": "ПОМОЩНИК",
143
+ "export_label_system": "СИСТЕМА",
144
+ "export_label_tool": "ИНСТРУМЕНТ",
145
+ "export_label_user": "ПОЛЬЗОВАТЕЛЬ",
146
+ "export_role_assistant": "## 🤖 Помощник",
147
+ "export_role_system": "## ⚙️ Система",
148
+ "export_role_tool": "## 🔧 Инструмент",
149
+ "export_role_user": "## 👤 Пользователь",
150
+ "export_text_assistant": "[ПОМОЩНИК]",
151
+ "export_text_system": "[СИСТЕМА]",
152
+ "export_text_tool": "[ИНСТРУМЕНТ]",
153
+ "export_text_user": "[ПОЛЬЗОВАТЕЛЬ]",
154
+ "failed_load_models": "Не удалось загрузить модели.",
155
+ "finish_setup": "Завершить настройку",
156
+ "font_preview": "Быстрая бурая лиса перепрыгивает через ленивую собаку.",
157
+ "font_size": "Размер шрифта",
158
+ "font_size_desc": "Настройте размер текста в чате.",
159
+ "haptic_feedback": "Тактильная обратная связь",
160
+ "history_missing_desc": "Либо сообщения в этом чате были удалены, либо запись истории повреждена.",
161
+ "history_missing_title": "История отсутствует",
162
+ "host_label": "Хост/IP-адрес",
163
+ "host_required": "Требуется хост",
164
+ "host_valid": "Введите действительное имя хоста или IP-адрес.",
165
+ "inference_backend": "Серверная часть вывода",
166
+ "initializing": "Инициализация...",
167
+ "initializing_services": "Инициализация служб...",
168
+ "install": "Установить",
169
+ "installed": "Установлено",
170
+ "label_completed": "{label} завершено",
171
+ "language_system_default": "Системные настройки по умолчанию",
172
+ "last_error": "Последняя ошибка:",
173
+ "load": "Нагрузка",
174
+ "loaded_status": "Загружено",
175
+ "loading": "Загрузка...",
176
+ "loading_model": "Загрузка {modelId}...",
177
+ "loading_model_error": "Ошибка: {error}",
178
+ "manage_on_device_models": "Управление моделями на устройстве",
179
+ "enable_smart_reply": "Умные ответы на устройстве",
180
+ "manage_servers": "Управление серверами",
181
+ "manage_tts_models": "Управление моделями TTS",
182
+ "max_tokens": "Макс. жетонов",
183
+ "max_tokens_desc": "Предел ответа",
184
+ "may_be_large": "Может быть слишком большим для этого устройства",
185
+ "mcp_disabled_warning": "MCP отключен глобально. Включите его в настройках, чтобы использовать эти функции.",
186
+ "mcp_enable_chat": "Включите MCP для этого чата",
187
+ "mcp_label_placeholder": "Этикетка",
188
+ "mcp_tab": "МКП",
189
+ "mcp_url_placeholder": "URL-адрес (https://...)",
190
+ "memory_critical": "Критический",
191
+ "memory_healthy": "Здоровый",
192
+ "memory_low": "Низкий",
193
+ "message_options": "Параметры сообщения",
194
+ "model_deepseek": "DeepSeek R1 Дистилл Qwen 1.5B",
195
+ "model_deepseek_desc": "Модель рассуждения и цепочки мыслей. Лучше всего подходит для логических задач.",
196
+ "model_gemma": "Джемма 4 E2B Инструктирует",
197
+ "model_gemma_desc": "Флагманская модель Google. Высочайшее качество, требует больше оперативной памяти.",
198
+ "model_license_apache": "Апач-2.0",
199
+ "model_license_mit": "Массачусетский технологический институт",
200
+ "model_load_failed": "Не удалось загрузить модель: {error}",
201
+ "model_loaded": "Модель загружена: {modelId} ({backend})",
202
+ "model_qwen_25": "Квен 2.5 1.5B Инструктировать",
203
+ "model_qwen_25_desc": "Сбалансированное качество и размер. Подходит для общего разговора.",
204
+ "model_qwen_3": "Квен 3 0.6Б",
205
+ "model_qwen_3_desc": "Самая маленькая модель чата общего назначения. Быстрые ответы, низкое использование памяти.",
206
+ "model_unload_failed": "Не удалось выгрузить модель: {error}",
207
+ "model_unloaded_ollama": "Выгрузка запрошена для {name}. Если Оллама доступен, модель выпускается немедленно.",
208
+ "model_unloaded_success": "{name} успешно выгружено",
209
+ "more": "Более",
210
+ "my_persona_hint": "Моя Персона",
211
+ "my_server_hint": "Мой сервер",
212
+ "name_label": "Имя",
213
+ "name_length_validation": "Имя должно содержать не более 50 символов.",
214
+ "name_max_50": "Макс. 50 символов.",
215
+ "name_required": "Требуется имя",
216
+ "nav_history": "История",
217
+ "nav_local_models": "Локальные модели",
218
+ "nav_new_chat": "Новый чат",
219
+ "nav_personas": "Персоны",
220
+ "nav_servers": "Серверы",
221
+ "nav_settings": "Настройки",
222
+ "nav_tts": "Текст в речь",
223
+ "new_chat_mcp_default": "Новый чат MCP по умолчанию",
224
+ "no_conversations_yet": "Пока нет разговоров",
225
+ "no_model_loaded": "Ни одна модель не загружена. Нажмите «Управление моделями на устройстве», чтобы скачать и загрузить модель.",
226
+ "no_models_available": "Нет доступных моделей",
227
+ "no_models_match": "Нет моделей, соответствующих запросу \"{searchQuery}\".",
228
+ "no_personas_desc": "Создайте свою первую личность, чтобы настроить поведение ИИ.",
229
+ "no_personas_found": "Персоны не найдены",
230
+ "no_prompt_placeholder": "Пока нет подсказки...",
231
+ "no_results_found": "Результаты не найдены",
232
+ "no_server_connected": "Сервер не подключен",
233
+ "no_server_selected": "Сервер не выбран",
234
+ "no_servers_desc": "Добавьте свой первый сервер, чтобы начать общаться с моделями ИИ.",
235
+ "no_servers_yet": "Серверов пока нет",
236
+ "none": "Никто",
237
+ "none_selected": "Ничего не выбрано",
238
+ "not_downloaded": "Не загружено",
239
+ "not_installed": "Не установлено",
240
+ "not_now": "Не сейчас",
241
+ "notification_benefit_background": "Статус фоновых задач",
242
+ "notification_benefit_completions": "Завершения генерации",
243
+ "notification_benefit_downloads": "Ход загрузки модели",
244
+ "offline": "Оффлайн",
245
+ "older_section": "СТАРШЕ",
246
+ "on_device_android_only": "Вывод на устройстве в настоящее время доступен только на Android.",
247
+ "on_device_models_title": "Модели на устройстве",
248
+ "onboarding_choose_language": "Выберите язык",
249
+ "onboarding_choose_language_desc": "Выберите предпочитаемый язык. Вы можете изменить это в любое время в настройках.",
250
+ "onboarding_connect_desc": "Подключитесь к LM Studio, Ollama или\nOpenRouter для запуска вашего частного ИИ\nопыт.",
251
+ "onboarding_connect_server": "Подключите свой\nСервер",
252
+ "onboarding_localmind": "МЕСТНЫЙ РАЗУМ",
253
+ "onboarding_welcome": "Добро пожаловать в LocalMind",
254
+ "online": "Онлайн",
255
+ "open_source": "Открытый исходный код",
256
+ "open_source_desc": "LocalMind имеет открытый исходный код. Следите за нашим прогрессом или вносите свой вклад на GitHub.",
257
+ "add_more": "Добавить еще",
258
+ "on_github": "на GitHub",
259
+ "could_not_open_github": "Не удалось открыть GitHub.",
260
+ "openai_compatible_api": "OpenAI-совместимый API",
261
+ "https_requires_ssl": "HTTPS требует SSL",
262
+ "most_local_setups_use_http": "Большинство локальных установок используют http://",
263
+ "options_tooltip": "Параметры",
264
+ "parameters_tab": "Параметры",
265
+ "pause": "Пауза",
266
+ "paused_progress": "Приостановлено – {percent} %",
267
+ "persona_builtin_section": "ВСТРОЕННЫЙ",
268
+ "persona_category_coding": "Кодирование",
269
+ "persona_category_creative": "Креатив",
270
+ "persona_category_education": "Образование",
271
+ "persona_category_general": "Общий",
272
+ "persona_created": "Персона создана",
273
+ "persona_my_section": "МОИ ПЕРСОНАЛЫ",
274
+ "persona_updated": "Персона обновлена",
275
+ "personas_title": "Персоны",
276
+ "pin": "Приколоть",
277
+ "pinned_section": "ПРИКРЕПЛЕНО",
278
+ "port_invalid": "Должно быть число",
279
+ "port_label": "Порт",
280
+ "port_range": "Введите действительный порт (1-65535).",
281
+ "port_required": "Требуется порт",
282
+ "preparing_app": "Подготовка приложения...",
283
+ "preview": "Предварительный просмотр",
284
+ "preview_failed": "Не удалось выполнить предварительный просмотр: {error}",
285
+ "previous_30_days": "ПРЕДЫДУЩИЕ 30 ДНЕЙ",
286
+ "previous_7_days": "ПРЕДЫДУЩИЕ 7 ДНЕЙ",
287
+ "privacy_info": "«LocalMind никогда не видит ваши данные»",
288
+ "proceed_anyway": "Продолжить в любом случае",
289
+ "processing": "Обработка...",
290
+ "prompt_hint": "Вы полезный помощник...",
291
+ "prompt_max_chars": "Макс. 4000 символов.",
292
+ "prompt_required": "Требуется системное приглашение",
293
+ "quick_async": "Как использовать async/await?",
294
+ "quick_debug": "Отладьте это для меня",
295
+ "quick_explain": "Объясните этот код",
296
+ "quick_write": "Помогите мне написать функцию",
297
+ "ram_min_required": "{fileSize} ГБ ОЗУ мин.",
298
+ "ram_usage": "Использование оперативной памяти",
299
+ "ram_used": "{percent} % использовано",
300
+ "ram_warning": "Предупреждение об оперативной памяти",
301
+ "ram_warning_body_download": "Для этой модели требуется не менее {ram} ГБ ОЗУ, но на вашем устройстве имеется {totalMemory}. Оно может работать неправильно или привести к сбою приложения.",
302
+ "ram_warning_body_load": "На вашем устройстве доступно ОЗУ {availableRAM}, но для этой модели рекомендуется не менее {ram} ГБ. Загрузка может привести к сбою или вызвать нестабильность.",
303
+ "range_0_1": "0,0-1,0",
304
+ "range_0_2": "0,0-2,0",
305
+ "read_aloud": "Читать вслух",
306
+ "ready": "Готовый",
307
+ "ready_continue": "ГОТОВЫ ПРОДОЛЖИТЬ",
308
+ "recent_chats": "Недавние чаты",
309
+ "recommended": "РЕКОМЕНДУЕТСЯ",
310
+ "refresh_models": "Обновить модели",
311
+ "remove_persona": "Удалить персону",
312
+ "rename": "Переименовать",
313
+ "rename_conversation": "Переименовать беседу",
314
+ "reset_defaults": "Сбросить настройки по умолчанию",
315
+ "reset_settings_defaults": "Сброс настроек к значениям по умолчанию",
316
+ "resume": "Резюме",
317
+ "retry": "Повторить попытку",
318
+ "save": "Сохранять",
319
+ "save_changes": "Сохранить изменения",
320
+ "save_continue": "Сохранить и продолжить",
321
+ "save_regenerate": "Сохранить и восстановить",
322
+ "save_server": "Сохранить сервер",
323
+ "search_hint": "Поиск разговоров...",
324
+ "search_icons_hint": "Иконки поиска...",
325
+ "search_models_hint": "Поиск моделей...",
326
+ "see_all": "Посмотреть все",
327
+ "select": "Выбирать",
328
+ "select_icon": "Выберите значок",
329
+ "select_icon_desc": "Выберите значок для своего сервера",
330
+ "select_model": "Выберите модель",
331
+ "select_model_hint": "Выберите модель, чтобы начать общение",
332
+ "select_model_title": "Выберите модель",
333
+ "select_persona": "Выберите Персону",
334
+ "send_message_tooltip": "Отправить сообщение",
335
+ "send_on_enter": "Отправить при вводе",
336
+ "server_added": "Сервер добавлен",
337
+ "server_address_format": "{host}:{port}",
338
+ "server_address_on_device": "Локальный вывод",
339
+ "server_address_openrouter": "openrouter.ai",
340
+ "server_icon_ai_brain": "ИИ Мозг",
341
+ "server_icon_ai_brain2": "ИИ Мозг 02",
342
+ "server_icon_ai_chat": "AI-чат",
343
+ "server_icon_ai_cloud": "Облако ИИ",
344
+ "server_icon_ai_network": "Сеть искусственного интеллекта",
345
+ "server_icon_alert": "Круг оповещения",
346
+ "server_icon_api": "API",
347
+ "server_icon_arrow_right": "Стрелка вправо 01",
348
+ "server_icon_bot": "Бот",
349
+ "server_icon_bot2": "Бот 02",
350
+ "server_icon_cellular": "Сотовая сеть",
351
+ "server_icon_check": "Проверить круг",
352
+ "server_icon_chip": "Чип",
353
+ "server_icon_chip2": "Чип 02",
354
+ "server_icon_cloud": "Облако",
355
+ "server_icon_cloud_download": "Облачная загрузка",
356
+ "server_icon_cloud_server": "Облачный сервер",
357
+ "server_icon_cloud_upload": "Загрузка в облако",
358
+ "server_icon_code": "Код",
359
+ "server_icon_computer": "Компьютер",
360
+ "server_icon_cpu": "Процессор",
361
+ "server_icon_database": "База данных",
362
+ "server_icon_database1": "База данных 01",
363
+ "server_icon_database2": "База данных 02",
364
+ "server_icon_drive": "Водить машину",
365
+ "server_icon_file1": "Файл 01",
366
+ "server_icon_folder1": "Папка 01",
367
+ "server_icon_folder2": "Папка 02",
368
+ "server_icon_globe": "Глобус",
369
+ "server_icon_hard_drive": "Жесткий диск",
370
+ "server_icon_home1": "Дом 01",
371
+ "server_icon_home2": "Дом 02",
372
+ "server_icon_info": "Информационный круг",
373
+ "server_icon_key": "Ключ 01",
374
+ "server_icon_label": "Значок сервера",
375
+ "server_icon_laptop": "Ноутбук",
376
+ "server_icon_link": "Ссылка 01",
377
+ "server_icon_lock": "Замок",
378
+ "server_icon_mcp": "MCP-сервер",
379
+ "server_icon_plug1": "Вилка 01",
380
+ "server_icon_plug2": "Вилка 02",
381
+ "server_icon_refresh": "Обновить",
382
+ "server_icon_robotic": "Роботизированный",
383
+ "server_icon_rocket": "Ракета",
384
+ "server_icon_settings1": "Настройки 01",
385
+ "server_icon_settings2": "Настройки 02",
386
+ "server_icon_stack": "Серверный стек",
387
+ "server_icon_stack2": "Серверный стек 02",
388
+ "server_icon_stack3": "Серверный стек 03",
389
+ "server_icon_star": "Звезда",
390
+ "server_icon_terminal": "Компьютерный терминал",
391
+ "server_icon_zap": "Зап",
392
+ "server_name": "Имя сервера",
393
+ "server_type_label": "Тип сервера",
394
+ "server_type_lm_studio": "ЛМ Студия",
395
+ "server_type_lm_studio_display": "ЛМ Студия",
396
+ "server_type_ollama": "Оллама",
397
+ "server_type_ollama_display": "Оллама",
398
+ "server_type_on_device": "На устройстве",
399
+ "server_type_on_device_display": "На устройстве",
400
+ "server_type_openai_display": "Совместимость с OpenAI",
401
+ "server_type_openrouter": "OpenRouter",
402
+ "server_type_openrouter_display": "OpenRouter",
403
+ "server_type_openrouter_sub": "ЕДИНОЕ ОБЛАКО",
404
+ "server_updated": "Сервер обновлен",
405
+ "servers_title": "Серверы",
406
+ "set_as_default": "Установить по умолчанию",
407
+ "set_persona": "Установить Персону",
408
+ "settings_about": "О",
409
+ "settings_appearance": "Появление",
410
+ "settings_behavior": "Поведение",
411
+ "settings_data_management": "Управление данными",
412
+ "settings_default_persona": "Персона по умолчанию",
413
+ "settings_default_server": "Сервер по умолчанию",
414
+ "settings_language": "Язык",
415
+ "settings_on_device": "Вывод на устройстве",
416
+ "settings_privacy": "Конфиденциальность",
417
+ "settings_title": "Настройки",
418
+ "settings_tts": "Преобразование текста в речь",
419
+ "setup_connection": "Настройка соединения",
420
+ "setup_connection_desc": "Настройте свой сервер {server}, чтобы начать общение.",
421
+ "share": "Делиться",
422
+ "show_data_indicator": "Показать индикатор данных",
423
+ "show_system_messages": "Показать системные сообщения",
424
+ "skip": "Пропускать",
425
+ "something_went_wrong": "Что-то пошло не так",
426
+ "star_on_github": "Звезда на GitHub",
427
+ "start_conversation": "Начать разговор",
428
+ "start_new_chat": "Начать новый чат",
429
+ "start_new_conversation": "Начать новый разговор",
430
+ "startup_failed": "Запуск не удался",
431
+ "stay_updated": "Оставайтесь в курсе",
432
+ "stay_updated_desc": "Получайте уведомления, когда ваши модели ИИ завершают загрузку или завершаются длительные задачи.",
433
+ "stop": "Останавливаться",
434
+ "stop_generation_tooltip": "Остановить генерацию",
435
+ "stop_reading": "Хватит читать",
436
+ "streaming_responses": "Потоковая передача ответов",
437
+ "switch_server": "Переключить сервер",
438
+ "switch_server_subtitle": "Выберите сервер для подключения",
439
+ "switched_to_server": "Переключено на {name}",
440
+ "system_prompt": "Системная подсказка",
441
+ "technical_details": "Технические детали",
442
+ "temp_hint": "0,7",
443
+ "temperature": "Температура",
444
+ "temperature_desc": "Управляет случайностью: выше = креативно, ниже = сосредоточено.",
445
+ "temperature_label": "Температура (0,0-2,0)",
446
+ "test_connection": "Тестовое соединение",
447
+ "test_notification_body": "Это тестовое уведомление о ходе загрузки модели.",
448
+ "test_notification_title": "Уведомление о тестировании",
449
+ "testing": "Тестирование...",
450
+ "theme": "Тема",
451
+ "theme_card_claude": "Клод",
452
+ "theme_card_claude_sub": "Теплая тема персикового оттенка.",
453
+ "theme_card_dark": "Темный",
454
+ "theme_card_dark_sub": "Легко для глаз",
455
+ "theme_card_light": "Свет",
456
+ "theme_card_light_sub": "Чистый и светлый",
457
+ "theme_card_system": "Система",
458
+ "theme_card_system_sub": "Соответствует настройкам вашего устройства",
459
+ "theme_claude": "Клод",
460
+ "theme_dark": "Темный",
461
+ "theme_light": "Свет",
462
+ "theme_system": "Система",
463
+ "thinking": "мышление",
464
+ "today_section": "СЕГОДНЯ",
465
+ "tool_label": "Инструмент: {toolCallId}",
466
+ "tool_unknown": "Инструмент: Неизвестно",
467
+ "top_p": "Топ П",
468
+ "top_p_desc": "Порог выборки ядра",
469
+ "top_p_hint": "0,9",
470
+ "top_p_label": "Топ П (0,0-1,0)",
471
+ "total_capacity": "Общая емкость",
472
+ "total_ram": "Общий объем оперативной памяти",
473
+ "try_different_search": "Попробуйте другой поисковый запрос",
474
+ "tts_engine": "Двигатель ТТС",
475
+ "tts_engine_kitten": "Котенок ТТС",
476
+ "tts_engine_system": "Система ТТС",
477
+ "tts_kitten_desc": "Молниеносный нейронный TTS с 8 выразительными голосами.\nТребуется загрузка {size}.",
478
+ "tts_models_title": "Модели преобразования текста в речь",
479
+ "tts_piper_desc": "Быстрые офлайн-голоса Пайпер с двумя выразительными голосами.\nТребуется загрузка {size} на голос.",
480
+ "tts_speed": "Скорость TTS",
481
+ "tts_speed_desc": "Отрегулируйте скорость воспроизведения.",
482
+ "tts_system_desc": "Использует встроенный в ваше устройство механизм преобразования текста в речь.\nНикаких загрузок не требуется. Выбор голоса использует системные настройки вашего устройства.",
483
+ "unknown_error": "Неизвестная ошибка",
484
+ "unload": "Разгрузить",
485
+ "unload_from_server": "Выгрузить с сервера",
486
+ "unpin": "Открепить",
487
+ "update_server": "Обновление сервера",
488
+ "voice": "Голос",
489
+ "voice_bella": "Белла",
490
+ "voice_bruno": "Бруно",
491
+ "voice_female": "Женский",
492
+ "voice_hugo": "Хьюго",
493
+ "voice_jasper": "Джаспер",
494
+ "voice_kiki": "Кики",
495
+ "voice_leo": "Лео",
496
+ "voice_lessac": "Лессак (США)",
497
+ "voice_luna": "Луна",
498
+ "voice_male": "Мужской",
499
+ "voice_other": "Другой",
500
+ "voice_rosie": "Рози",
501
+ "voice_ryan": "Райан (США)",
502
+ "waiting_selection": "ОЖИДАНИЕ ВЫБОРА",
503
+ "yesterday_section": "ВЧЕРА",
504
+ "tts_supports_background": "Поддерживает фоновое воспроизведение как собственный звук",
505
+ "tts_other_services_background_note": "Примечание. Другие службы TTS поддерживают фоновое воспроизведение как собственный звук.",
506
+ "start_listening_tooltip": "Начать слушать",
507
+ "stop_listening_tooltip": "Хватит слушать",
508
+ "settings_huggingface_token": "Жетон «Обнимающее лицо» (необязательно)",
509
+ "settings_huggingface_token_desc": "Требуется только для закрытых моделей (например, Gemma). Получите токен на сайтеhuggingface.co/settings/tokens.",
510
+ "settings_huggingface_token_set": "Токен сохранен.",
511
+ "settings_huggingface_token_cleared": "Токен очищен",
512
+ "model_requires_huggingface_token": "Требуется жетон «Обнимающее лицо».",
513
+ "model_missing_huggingface_token": "Данная модель изготовлена на Hugging Face. Добавьте токен в «Настройки» → «Вывод на устройстве», чтобы загрузить его.",
514
+ "set_huggingface_token": "Установить токен",
515
+ "clear_huggingface_token": "Прозрачный",
516
+ "edit_huggingface_token_dialog_title": "Токен доступа «Обнимающее лицо»",
517
+ "huggingface_token_dialog_hint": "хф_…",
518
+ "server_type_ollama_desc": "Локальный ИИ-движок. Ключ API не требуется.",
519
+ "server_type_on_device_desc": "Работает на вашем телефоне. Некоторым моделям нужен жетон «Обнимающее лицо».",
520
+ "server_type_lm_studio_desc": "Локальный API-сервер. Ключ API не требуется.",
521
+ "gguf_imported_models_title": "Импортированные модели GGUF",
522
+ "gguf_imported_models_empty_subtitle": "Импортируйте GGUF со своего устройства или добавьте его из Hugging Face. Импортированные модели запускаются локально с помощью llama.cpp.",
523
+ "gguf_imported_models_ready": "импортированные модели готовы для локального вывода.",
524
+ "gguf_curated_models_subtitle": "Рекомендуемые модели на устройстве, которые вы можете загружать и управлять ими внутри LocalMind.",
525
+ "gguf_only_supported": "Для этого импорта поддерживаются только модели GGUF.",
526
+ "gguf_imported_from_local_file": "импортировано из локального файла.",
527
+ "gguf_import_failed": "Не удалось импортировать модель GGUF.",
528
+ "gguf_imported_from_huggingface": "импортировано из Hugging Face.",
529
+ "gguf_import_canceled": "Импорт GGUF отменен.",
530
+ "gguf_enter_huggingface_url": "Введите URL-адрес GGUF «Обнимающее лицо».",
531
+ "gguf_only_official_huggingface_urls": "Поддерживаются только официальные URL-адреса Hugging Face GGUF.",
532
+ "gguf_use_https_url": "Используйте URL-адрес обнимающего лица HTTPS для импорта GGUF.",
533
+ "gguf_url_must_point_to_file": "URL-адрес «Обнимающего лица» должен указывать непосредственно на файл .gguf.",
534
+ "gguf_unable_to_detect_file_name": "Невозможно определить имя файла GGUF.",
535
+ "gguf_download_empty": "Загруженный файл GGUF был пуст или отсутствовал.",
536
+ "gguf_selected_file_missing": "Выбранный файл модели не существует.",
537
+ "gguf_import_action": "Импортировать GGUF",
538
+ "gguf_overview_title": "Принесите свои собственные модели GGUF",
539
+ "gguf_overview_subtitle": "Импортируйте .gguf из локального хранилища или загрузите его прямо с Hugging Face. Импортированные модели остаются на этом устройстве и загружаются с помощью llama.cpp.",
540
+ "gguf_imported_count_label": "импортированный",
541
+ "gguf_local_files_label": "локальные файлы",
542
+ "gguf_huggingface_label": "Обнимающее лицо",
543
+ "gguf_import_local_title": "Импортировать локальный GGUF",
544
+ "gguf_import_local_subtitle": "Скопируйте файл .gguf с этого устройства.",
545
+ "gguf_import_huggingface_title": "Импорт из «Обнимающего лица»",
546
+ "gguf_import_huggingface_subtitle": "Вставьте URL-адрес GGUF или путь к репозиторию.",
547
+ "gguf_no_imported_title": "Импортированных моделей GGUF пока нет.",
548
+ "gguf_no_imported_subtitle": "Вы можете перенести свой собственный файл GGUF из хранилища устройства или вставить URL-адрес Hugging Face или путь к репозиторию, указывающий на файл .gguf.",
549
+ "gguf_import_huggingface_dialog_title": "Импортировать GGUF из Hugging Face",
550
+ "gguf_import_huggingface_dialog_subtitle": "Вставьте прямой URL-адрес GGUF или путь репозитория Hugging Face, например `owner/repo/blob/main/model.gguf`. Ссылки на большие двоичные объекты преобразуются автоматически.",
551
+ "gguf_url_or_repo_path": "URL-адрес GGUF или путь к репозиторию",
552
+ "paste": "Вставить",
553
+ "gguf_browse": "Просмотр GGUF",
554
+ "gguf_huggingface_token_ready": "Жетон «Обнимающее лицо» готов.",
555
+ "gguf_huggingface_token_optional": "Токен необязателен, но рекомендуется",
556
+ "gguf_huggingface_token_ready_desc": "Ваш сохраненный токен будет автоматически использоваться для закрытых или частных репозиториев.",
557
+ "gguf_huggingface_token_optional_desc": "Требуется жетон «Обнимающее лицо». Добавьте его в настройках, если этот GGUF является закрытым или частным.",
558
+ "gguf_downloading": "Загрузка GGUF",
559
+ "gguf_preparing": "Подготовка",
560
+ "gguf_preparing_download": "Подготовка загрузки...",
561
+ "gguf_cancel_import": "Отменить импорт",
562
+ "clipboard_empty": "Буфер обмена пуст.",
563
+ "could_not_open_huggingface": "Не удалось открыть «Обнимающее лицо».",
564
+ "gguf_paste_url_error": "Вставьте URL-адрес GGUF Hugging Face или путь к репозиторию.",
565
+ "gguf_blob_link": "Ссылка на BLOB-объект",
566
+ "gguf_repository_label": "Репозиторий",
567
+ "gguf_detected_path_label": "Обнаруженный путь",
568
+ "gguf_imported_section_label": "Импортированный GGUF",
569
+ "gguf_already_available": "Уже доступно на этом устройстве",
570
+ "gguf_curated_models_short": "Кураторские модели на устройстве",
571
+ "execute_tool_title": "Выполнить инструмент",
572
+ "execute_tool_request_desc": "Модель запрашивает выполнение следующего инструмента:",
573
+ "reject": "Отклонять",
574
+ "approve": "Утвердить",
575
+ "server_type_help": "Прежде чем заполнять данные подключения, выберите провайдера.",
576
+ "server_identity_title": "Личность",
577
+ "server_identity_desc": "Назовите этот сервер и выберите, как он будет отображаться в списке.",
578
+ "server_connection_title": "Связь",
579
+ "server_connection_desc": "Используйте адрес и порт, предоставленные вашим сервером.",
580
+ "server_authentication_title": "Аутентификация",
581
+ "server_authentication_required_desc": "OpenRouter требует ключ API перед тестированием.",
582
+ "server_authentication_optional_desc": "Оставьте ключ API пустым, если этот сервер не требует его.",
583
+ "mcp_tools_title": "Инструменты MCP",
584
+ "available_tools": "Доступные инструменты",
585
+ "unable_load_tools": "Невозможно загрузить инструменты",
586
+ "no_tools_registered": "Инструменты не зарегистрированы",
587
+ "no_tools_registered_desc": "Включите пример сервера MCP или добавьте интеграцию MCP из настроек чата.",
588
+ "example_mcp_server_title": "Пример MCP-сервера",
589
+ "example_mcp_server_desc": "Регистрирует example.echo и example.word_count через того же поставщика инструментов MCP, который используется внешними серверами.",
590
+ "disable_example_server": "Отключить пример сервера",
591
+ "enable_example_server": "Включить пример сервера",
592
+ "built_in_label": "Встроенный",
593
+ "highlights_label": "Основные моменты",
594
+ "built_with_label": "Построен с",
595
+ "local_label": "Местный",
596
+ "gguf_format_label": "ГГУФ",
597
+ "tool_status_requested": "Запрошено",
598
+ "tool_status_approved": "Одобренный",
599
+ "tool_status_rejected": "Отклоненный",
600
+ "tool_status_running": "Бег",
601
+ "tool_status_done": "Сделанный",
602
+ "tool_status_failed": "Неуспешный",
603
+ "exit_temporary_chat_title": "Выйти из временного чата?",
604
+ "exit_temporary_chat_body": "Текущий временный чат будет удалён, и вы вернётесь к новому чату.",
605
+ "saved_message_temp_snap_unavailable": "Это сообщение сохранено из временного чата и не может быть открыто в исходной беседе.",
606
+ "filter_title": "Фильтр",
607
+ "filter_pinned": "Закреплённые",
608
+ "filter_archived": "Архив",
609
+ "filter_temp_chats": "Временные чаты",
610
+ "filter_user_messages": "Сообщения пользователя",
611
+ "filter_assistant_messages": "Сообщения ассистента",
612
+ "archive_chat": "Архивировать",
613
+ "unarchive_chat": "Разархивировать",
614
+ "conversation_message_count": "{count, plural, one{1 сообщение} few{{count} сообщения} many{{count} сообщений} other{{count} сообщений}}",
615
+ "conversation_character_count": "{count} симв.",
616
+ "generate_title_with_ai": "Сгенерировать с ИИ",
617
+ "generating_title": "Генерация...",
618
+ "generate_title_failed": "Не удалось сгенерировать заголовок"
619
+}
lib/objectbox-model.json
+37
-2
@@ -5,7 +5,7 @@
5
"entities": [
6
{
7
"id": "1:6060443518060866028",
8
- "lastPropertyId": "20:6746128880040296628",
8
+ "lastPropertyId": "23:3122805731877931734",
9
"name": "ConversationEntity",
10
"properties": [
11
{
@@ -110,13 +110,28 @@
110
"id": "20:6746128880040296628",
111
"name": "folderId",
112
"type": 9
113
+ },
114
+ {
115
+ "id": "21:4372897463643655414",
116
+ "name": "isTemporary",
117
+ "type": 1
118
+ },
119
+ {
120
+ "id": "22:2980396196878246454",
121
+ "name": "isArchived",
122
+ "type": 1
123
+ },
124
+ {
125
+ "id": "23:3122805731877931734",
126
+ "name": "characterCount",
127
+ "type": 6
128
}
129
],
130
"relations": []
131
},
132
{
133
"id": "2:196881068437544233",
119
- "lastPropertyId": "24:5960797950716320436",
134
+ "lastPropertyId": "28:8162956617579414275",
135
"name": "MessageEntity",
136
"properties": [
137
{
@@ -246,6 +261,26 @@
261
"id": "24:5960797950716320436",
262
"name": "parentMessageId",
263
"type": 9
264
+ },
265
+ {
266
+ "id": "25:1552629421552033386",
267
+ "name": "inputTokenCount",
268
+ "type": 6
269
+ },
270
+ {
271
+ "id": "26:1965067209419994290",
272
+ "name": "tokensPerSecond",
273
+ "type": 8
274
+ },
275
+ {
276
+ "id": "27:7785830711210478899",
277
+ "name": "ttftMs",
278
+ "type": 6
279
+ },
280
+ {
281
+ "id": "28:8162956617579414275",
282
+ "name": "stopReason",
283
+ "type": 9
284
}
285
],
286
"relations": []
lib/objectbox.g.dart
+134
-4
@@ -22,7 +22,7 @@ final _entities = <obx_int.ModelEntity>[
22
obx_int.ModelEntity(
23
id: const obx_int.IdUid(1, 6060443518060866028),
24
name: 'ConversationEntity',
25
- lastPropertyId: const obx_int.IdUid(20, 6746128880040296628),
25
+ lastPropertyId: const obx_int.IdUid(23, 3122805731877931734),
26
flags: 0,
27
properties: <obx_int.ModelProperty>[
28
obx_int.ModelProperty(
@@ -146,6 +146,24 @@ final _entities = <obx_int.ModelEntity>[
146
type: 9,
147
flags: 0,
148
),
149
+ obx_int.ModelProperty(
150
+ id: const obx_int.IdUid(21, 4372897463643655414),
151
+ name: 'isTemporary',
152
+ type: 1,
153
+ flags: 0,
154
+ ),
155
+ obx_int.ModelProperty(
156
+ id: const obx_int.IdUid(22, 2980396196878246454),
157
+ name: 'isArchived',
158
+ type: 1,
159
+ flags: 0,
160
+ ),
161
+ obx_int.ModelProperty(
162
+ id: const obx_int.IdUid(23, 3122805731877931734),
163
+ name: 'characterCount',
164
+ type: 6,
165
+ flags: 0,
166
+ ),
167
],
168
relations: <obx_int.ModelRelation>[],
169
backlinks: <obx_int.ModelBacklink>[
@@ -159,7 +177,7 @@ final _entities = <obx_int.ModelEntity>[
177
obx_int.ModelEntity(
178
id: const obx_int.IdUid(2, 196881068437544233),
179
name: 'MessageEntity',
162
- lastPropertyId: const obx_int.IdUid(24, 5960797950716320436),
180
+ lastPropertyId: const obx_int.IdUid(28, 8162956617579414275),
181
flags: 0,
182
properties: <obx_int.ModelProperty>[
183
obx_int.ModelProperty(
@@ -311,6 +329,30 @@ final _entities = <obx_int.ModelEntity>[
329
type: 9,
330
flags: 0,
331
),
332
+ obx_int.ModelProperty(
333
+ id: const obx_int.IdUid(25, 1552629421552033386),
334
+ name: 'inputTokenCount',
335
+ type: 6,
336
+ flags: 0,
337
+ ),
338
+ obx_int.ModelProperty(
339
+ id: const obx_int.IdUid(26, 1965067209419994290),
340
+ name: 'tokensPerSecond',
341
+ type: 8,
342
+ flags: 0,
343
+ ),
344
+ obx_int.ModelProperty(
345
+ id: const obx_int.IdUid(27, 7785830711210478899),
346
+ name: 'ttftMs',
347
+ type: 6,
348
+ flags: 0,
349
+ ),
350
+ obx_int.ModelProperty(
351
+ id: const obx_int.IdUid(28, 8162956617579414275),
352
+ name: 'stopReason',
353
+ type: 9,
354
+ flags: 0,
355
+ ),
356
],
357
relations: <obx_int.ModelRelation>[],
358
backlinks: <obx_int.ModelBacklink>[],
@@ -737,7 +779,7 @@ obx_int.ModelDefinition getObjectBoxModel() {
779
final folderIdOffset = object.folderId == null
780
? null
781
: fbb.writeString(object.folderId!);
740
- fbb.startTable(21);
782
+ fbb.startTable(24);
783
fbb.addInt64(0, object.internalId);
784
fbb.addOffset(1, idOffset);
785
fbb.addOffset(2, titleOffset);
@@ -758,6 +800,9 @@ obx_int.ModelDefinition getObjectBoxModel() {
800
fbb.addOffset(17, smartRepliesJsonOffset);
801
fbb.addOffset(18, smartRepliesLastMessageIdOffset);
802
fbb.addOffset(19, folderIdOffset);
803
+ fbb.addBool(20, object.isTemporary);
804
+ fbb.addBool(21, object.isArchived);
805
+ fbb.addInt64(22, object.characterCount);
806
fbb.finish(fbb.endTable());
807
return object.internalId;
808
},
@@ -843,6 +888,24 @@ obx_int.ModelDefinition getObjectBoxModel() {
888
final folderIdParam = const fb.StringReader(
889
asciiOptimization: true,
890
).vTableGetNullable(buffer, rootOffset, 42);
891
+ final isTemporaryParam = const fb.BoolReader().vTableGet(
892
+ buffer,
893
+ rootOffset,
894
+ 44,
895
+ false,
896
+ );
897
+ final isArchivedParam = const fb.BoolReader().vTableGet(
898
+ buffer,
899
+ rootOffset,
900
+ 46,
901
+ false,
902
+ );
903
+ final characterCountParam = const fb.Int64Reader().vTableGet(
904
+ buffer,
905
+ rootOffset,
906
+ 48,
907
+ 0,
908
+ );
909
final object = ConversationEntity(
910
internalId: internalIdParam,
911
id: idParam,
@@ -864,6 +927,9 @@ obx_int.ModelDefinition getObjectBoxModel() {
927
smartRepliesJson: smartRepliesJsonParam,
928
smartRepliesLastMessageId: smartRepliesLastMessageIdParam,
929
folderId: folderIdParam,
930
+ isTemporary: isTemporaryParam,
931
+ isArchived: isArchivedParam,
932
+ characterCount: characterCountParam,
933
);
934
obx_int.InternalToManyAccess.setRelInfo<ConversationEntity>(
935
object.messages,
@@ -919,7 +985,10 @@ obx_int.ModelDefinition getObjectBoxModel() {
985
final parentMessageIdOffset = object.parentMessageId == null
986
? null
987
: fbb.writeString(object.parentMessageId!);
922
- fbb.startTable(25);
988
+ final stopReasonOffset = object.stopReason == null
989
+ ? null
990
+ : fbb.writeString(object.stopReason!);
991
+ fbb.startTable(29);
992
fbb.addInt64(0, object.internalId);
993
fbb.addOffset(1, idOffset);
994
fbb.addInt64(2, object.conversation.targetId);
@@ -944,6 +1013,10 @@ obx_int.ModelDefinition getObjectBoxModel() {
1013
fbb.addInt64(21, object.threadOrder);
1014
fbb.addBool(22, object.isActiveVariant);
1015
fbb.addOffset(23, parentMessageIdOffset);
1016
+ fbb.addInt64(24, object.inputTokenCount);
1017
+ fbb.addFloat64(25, object.tokensPerSecond);
1018
+ fbb.addInt64(26, object.ttftMs);
1019
+ fbb.addOffset(27, stopReasonOffset);
1020
fbb.finish(fbb.endTable());
1021
return object.internalId;
1022
},
@@ -988,6 +1061,24 @@ obx_int.ModelDefinition getObjectBoxModel() {
1061
rootOffset,
1062
22,
1063
);
1064
+ final inputTokenCountParam = const fb.Int64Reader().vTableGetNullable(
1065
+ buffer,
1066
+ rootOffset,
1067
+ 52,
1068
+ );
1069
+ final tokensPerSecondParam = const fb.Float64Reader().vTableGetNullable(
1070
+ buffer,
1071
+ rootOffset,
1072
+ 54,
1073
+ );
1074
+ final ttftMsParam = const fb.Int64Reader().vTableGetNullable(
1075
+ buffer,
1076
+ rootOffset,
1077
+ 56,
1078
+ );
1079
+ final stopReasonParam = const fb.StringReader(
1080
+ asciiOptimization: true,
1081
+ ).vTableGetNullable(buffer, rootOffset, 58);
1082
final errorMessageParam = const fb.StringReader(
1083
asciiOptimization: true,
1084
).vTableGetNullable(buffer, rootOffset, 24);
@@ -1054,6 +1145,10 @@ obx_int.ModelDefinition getObjectBoxModel() {
1145
statusIndex: statusIndexParam,
1146
modelId: modelIdParam,
1147
tokenCount: tokenCountParam,
1148
+ inputTokenCount: inputTokenCountParam,
1149
+ tokensPerSecond: tokensPerSecondParam,
1150
+ ttftMs: ttftMsParam,
1151
+ stopReason: stopReasonParam,
1152
errorMessage: errorMessageParam,
1153
attachmentPathsJson: attachmentPathsJsonParam,
1154
generationTimeMs: generationTimeMsParam,
@@ -1598,6 +1693,21 @@ class ConversationEntity_ {
1693
_entities[0].properties[19],
1694
);
1695
1696
+ /// See [ConversationEntity.isTemporary].
1697
+ static final isTemporary = obx.QueryBooleanProperty<ConversationEntity>(
1698
+ _entities[0].properties[20],
1699
+ );
1700
+
1701
+ /// See [ConversationEntity.isArchived].
1702
+ static final isArchived = obx.QueryBooleanProperty<ConversationEntity>(
1703
+ _entities[0].properties[21],
1704
+ );
1705
+
1706
+ /// See [ConversationEntity.characterCount].
1707
+ static final characterCount = obx.QueryIntegerProperty<ConversationEntity>(
1708
+ _entities[0].properties[22],
1709
+ );
1710
+
1711
/// see [ConversationEntity.messages]
1712
static final messages =
1713
obx.QueryBacklinkToMany<MessageEntity, ConversationEntity>(
@@ -1727,6 +1837,26 @@ class MessageEntity_ {
1837
static final parentMessageId = obx.QueryStringProperty<MessageEntity>(
1838
_entities[1].properties[23],
1839
);
1840
+
1841
+ /// See [MessageEntity.inputTokenCount].
1842
+ static final inputTokenCount = obx.QueryIntegerProperty<MessageEntity>(
1843
+ _entities[1].properties[24],
1844
+ );
1845
+
1846
+ /// See [MessageEntity.tokensPerSecond].
1847
+ static final tokensPerSecond = obx.QueryDoubleProperty<MessageEntity>(
1848
+ _entities[1].properties[25],
1849
+ );
1850
+
1851
+ /// See [MessageEntity.ttftMs].
1852
+ static final ttftMs = obx.QueryIntegerProperty<MessageEntity>(
1853
+ _entities[1].properties[26],
1854
+ );
1855
+
1856
+ /// See [MessageEntity.stopReason].
1857
+ static final stopReason = obx.QueryStringProperty<MessageEntity>(
1858
+ _entities[1].properties[27],
1859
+ );
1860
}
1861
1862
/// [PersonaEntity] entity fields to define ObjectBox queries.
pubspec.lock
+160
@@ -433,6 +433,38 @@ packages:
433
url: "https://pub.dev"
434
source: hosted
435
version: "12.0.0-beta.5"
436
+ file_selector_linux:
437
+ dependency: transitive
438
+ description:
439
+ name: file_selector_linux
440
+ sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
441
+ url: "https://pub.dev"
442
+ source: hosted
443
+ version: "0.9.4"
444
+ file_selector_macos:
445
+ dependency: transitive
446
+ description:
447
+ name: file_selector_macos
448
+ sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
449
+ url: "https://pub.dev"
450
+ source: hosted
451
+ version: "0.9.5"
452
+ file_selector_platform_interface:
453
+ dependency: transitive
454
+ description:
455
+ name: file_selector_platform_interface
456
+ sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
457
+ url: "https://pub.dev"
458
+ source: hosted
459
+ version: "2.7.0"
460
+ file_selector_windows:
461
+ dependency: transitive
462
+ description:
463
+ name: file_selector_windows
464
+ sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
465
+ url: "https://pub.dev"
466
+ source: hosted
467
+ version: "0.9.3+5"
468
fixnum:
469
dependency: transitive
470
description:
@@ -749,6 +781,70 @@ packages:
781
url: "https://pub.dev"
782
source: hosted
783
version: "1.1.7"
784
+ image_picker:
785
+ dependency: "direct main"
786
+ description:
787
+ name: image_picker
788
+ sha256: "91c025426c2881c551100bce834e201c835a170151545f58d17da5180ca7d9ac"
789
+ url: "https://pub.dev"
790
+ source: hosted
791
+ version: "1.2.2"
792
+ image_picker_android:
793
+ dependency: transitive
794
+ description:
795
+ name: image_picker_android
796
+ sha256: "6f3a1995eafb000333174fae92202622033b0ee7fd917a6cd3730295264df84a"
797
+ url: "https://pub.dev"
798
+ source: hosted
799
+ version: "0.8.13+19"
800
+ image_picker_for_web:
801
+ dependency: transitive
802
+ description:
803
+ name: image_picker_for_web
804
+ sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
805
+ url: "https://pub.dev"
806
+ source: hosted
807
+ version: "3.1.1"
808
+ image_picker_ios:
809
+ dependency: transitive
810
+ description:
811
+ name: image_picker_ios
812
+ sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588
813
+ url: "https://pub.dev"
814
+ source: hosted
815
+ version: "0.8.13+6"
816
+ image_picker_linux:
817
+ dependency: transitive
818
+ description:
819
+ name: image_picker_linux
820
+ sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
821
+ url: "https://pub.dev"
822
+ source: hosted
823
+ version: "0.2.2"
824
+ image_picker_macos:
825
+ dependency: transitive
826
+ description:
827
+ name: image_picker_macos
828
+ sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
829
+ url: "https://pub.dev"
830
+ source: hosted
831
+ version: "0.2.2+1"
832
+ image_picker_platform_interface:
833
+ dependency: transitive
834
+ description:
835
+ name: image_picker_platform_interface
836
+ sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
837
+ url: "https://pub.dev"
838
+ source: hosted
839
+ version: "2.11.1"
840
+ image_picker_windows:
841
+ dependency: transitive
842
+ description:
843
+ name: image_picker_windows
844
+ sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
845
+ url: "https://pub.dev"
846
+ source: hosted
847
+ version: "0.2.2"
848
in_app_review:
849
dependency: "direct main"
850
description:
@@ -1117,6 +1213,54 @@ packages:
1213
url: "https://pub.dev"
1214
source: hosted
1215
version: "2.3.0"
1216
+ permission_handler:
1217
+ dependency: "direct main"
1218
+ description:
1219
+ name: permission_handler
1220
+ sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
1221
+ url: "https://pub.dev"
1222
+ source: hosted
1223
+ version: "11.4.0"
1224
+ permission_handler_android:
1225
+ dependency: transitive
1226
+ description:
1227
+ name: permission_handler_android
1228
+ sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
1229
+ url: "https://pub.dev"
1230
+ source: hosted
1231
+ version: "12.1.0"
1232
+ permission_handler_apple:
1233
+ dependency: transitive
1234
+ description:
1235
+ name: permission_handler_apple
1236
+ sha256: "79dfa1df734798aa3cfdad166d3a3698c206d8813de13516ea1071b5d7e2f420"
1237
+ url: "https://pub.dev"
1238
+ source: hosted
1239
+ version: "9.4.10"
1240
+ permission_handler_html:
1241
+ dependency: transitive
1242
+ description:
1243
+ name: permission_handler_html
1244
+ sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
1245
+ url: "https://pub.dev"
1246
+ source: hosted
1247
+ version: "0.1.3+5"
1248
+ permission_handler_platform_interface:
1249
+ dependency: transitive
1250
+ description:
1251
+ name: permission_handler_platform_interface
1252
+ sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
1253
+ url: "https://pub.dev"
1254
+ source: hosted
1255
+ version: "4.3.0"
1256
+ permission_handler_windows:
1257
+ dependency: transitive
1258
+ description:
1259
+ name: permission_handler_windows
1260
+ sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
1261
+ url: "https://pub.dev"
1262
+ source: hosted
1263
+ version: "0.2.1"
1264
petitparser:
1265
dependency: transitive
1266
description:
@@ -1229,6 +1373,22 @@ packages:
1373
url: "https://pub.dev"
1374
source: hosted
1375
version: "0.54.0"
1376
+ share_plus:
1377
+ dependency: "direct main"
1378
+ description:
1379
+ name: share_plus
1380
+ sha256: "9eee8283462d91a7a1c8bdb67d08874abd75a2f8fae3bc0ca033035e375fb3d8"
1381
+ url: "https://pub.dev"
1382
+ source: hosted
1383
+ version: "13.2.0"
1384
+ share_plus_platform_interface:
1385
+ dependency: transitive
1386
+ description:
1387
+ name: share_plus_platform_interface
1388
+ sha256: "7f7ae28cf400d13f811e297ff37742dba83b79e0a6f5dce14eec0248274e6ce9"
1389
+ url: "https://pub.dev"
1390
+ source: hosted
1391
+ version: "7.1.0"
1392
shared_preferences:
1393
dependency: "direct main"
1394
description:
pubspec.yaml
+3
@@ -78,6 +78,9 @@ dependencies:
78
loading_animation_widget: ^1.3.0
79
gpt_markdown: ^1.1.7
80
speech_to_text: ^7.4.0
81
+ share_plus: ^13.2.0
82
+ image_picker: ^1.1.2
83
+ permission_handler: ^11.4.0
84
85
dev_dependencies:
86
flutter_test: