26 files changed
+1061
-93
android/app/src/main/AndroidManifest.xml
+15
@@ -105,5 +105,20 @@
105
<action android:name="android.intent.action.SEND" />
106
<data android:mimeType="text/plain" />
107
</intent>
108
+ <intent>
109
+ <action android:name="android.intent.action.GET_CONTENT" />
110
+ <data android:mimeType="*/*" />
111
+ </intent>
112
+ <intent>
113
+ <action android:name="android.intent.action.OPEN_DOCUMENT" />
114
+ <data android:mimeType="*/*" />
115
+ </intent>
116
+ <intent>
117
+ <action android:name="android.intent.action.CREATE_DOCUMENT" />
118
+ <data android:mimeType="*/*" />
119
+ </intent>
120
+ <intent>
121
+ <action android:name="android.intent.action.OPEN_DOCUMENT_TREE" />
122
+ </intent>
123
</queries>
124
</manifest>
lib/core/utils/safe_file_picker.dart
new
+251
@@ -0,0 +1,251 @@
1
+// ignore_for_file: deprecated_member_use
2
+import 'dart:io';
3
+
4
+import 'package:file_picker/file_picker.dart';
5
+import 'package:flutter/material.dart';
6
+import 'package:flutter/services.dart';
7
+import 'package:image_picker/image_picker.dart';
8
+
9
+import '../../l10n/app_localizations.dart';
10
+import '../logger/app_logger.dart';
11
+
12
+/// Safe wrapper around [FilePicker] and [ImagePicker] platform operations.
13
+///
14
+/// Prevents unhandled [PlatformException]s (e.g. `explorer_not_found` on Android)
15
+/// from crashing the app when no default file manager or gallery is available.
16
+class SafeFilePicker {
17
+ SafeFilePicker._();
18
+
19
+ /// Determines whether an error is caused by a missing/disabled file manager activity.
20
+ static bool isExplorerNotFoundError(Object error) {
21
+ if (error is PlatformException) {
22
+ if (error.code == 'explorer_not_found') return true;
23
+ final msg = error.message?.toLowerCase() ?? '';
24
+ if (msg.contains('explorer') ||
25
+ msg.contains('activity to handle the request') ||
26
+ msg.contains('no activity found to handle intent') ||
27
+ msg.contains('file explorer installed')) {
28
+ return true;
29
+ }
30
+ }
31
+ final str = error.toString().toLowerCase();
32
+ return str.contains('explorer_not_found') ||
33
+ str.contains('can\'t find a valid activity') ||
34
+ str.contains('file explorer installed');
35
+ }
36
+
37
+ /// Extracts a localized, user-friendly error message from [error].
38
+ static String getErrorMessage(
39
+ Object error,
40
+ AppLocalizations l10n, {
41
+ String? fallbackMessage,
42
+ }) {
43
+ if (isExplorerNotFoundError(error)) {
44
+ return l10n.file_explorer_not_found;
45
+ }
46
+ if (error is PlatformException) {
47
+ if (error.message != null && error.message!.trim().isNotEmpty) {
48
+ return error.message!.trim();
49
+ }
50
+ }
51
+ if (error is FileSystemException) {
52
+ return error.message.isNotEmpty ? error.message : error.toString();
53
+ }
54
+ final message = error
55
+ .toString()
56
+ .replaceFirst(RegExp(r'^([A-Za-z0-9_]+Exception|Exception):\s*'), '')
57
+ .trim();
58
+ if (message.isNotEmpty) {
59
+ return message;
60
+ }
61
+ return fallbackMessage ?? error.toString();
62
+ }
63
+
64
+ /// Safely picks files using [FilePicker.platform.pickFiles].
65
+ ///
66
+ /// Catches [PlatformException] and other errors, logs them, and optionally
67
+ /// displays a user-friendly [SnackBar] if [context] is provided.
68
+ static Future<FilePickerResult?> pickFiles({
69
+ FileType type = FileType.any,
70
+ List<String>? allowedExtensions,
71
+ bool allowMultiple = false,
72
+ bool withData = false,
73
+ bool withReadStream = false,
74
+ String? dialogTitle,
75
+ String? initialDirectory,
76
+ dynamic lockParentWindow,
77
+ Function(FilePickerStatus)? onFileLoading,
78
+ BuildContext? context,
79
+ void Function(String message)? onError,
80
+ }) async {
81
+ try {
82
+ return await FilePicker.pickFiles(
83
+ type: type,
84
+ allowedExtensions: allowedExtensions,
85
+ allowMultiple: allowMultiple,
86
+ withData: withData,
87
+ withReadStream: withReadStream,
88
+ dialogTitle: dialogTitle,
89
+ initialDirectory: initialDirectory,
90
+ lockParentWindow: lockParentWindow,
91
+ onFileLoading: onFileLoading,
92
+ );
93
+ } catch (e, st) {
94
+ Log.warning('SafeFilePicker.pickFiles failed: $e\n$st');
95
+ if (context != null && context.mounted) {
96
+ final l10n = AppLocalizations.of(context);
97
+ if (l10n != null) {
98
+ final msg = getErrorMessage(e, l10n);
99
+ ScaffoldMessenger.of(context).showSnackBar(
100
+ SnackBar(content: Text(msg)),
101
+ );
102
+ }
103
+ } else if (onError != null) {
104
+ onError(e.toString());
105
+ }
106
+ return null;
107
+ }
108
+ }
109
+
110
+ /// Safely opens a save file dialog using [FilePicker.saveFile].
111
+ static Future<String?> saveFile({
112
+ String? dialogTitle,
113
+ required String fileName,
114
+ String? initialDirectory,
115
+ FileType type = FileType.any,
116
+ List<String>? allowedExtensions,
117
+ Uint8List? bytes,
118
+ dynamic lockParentWindow,
119
+ BuildContext? context,
120
+ void Function(String message)? onError,
121
+ }) async {
122
+ try {
123
+ return await FilePicker.saveFile(
124
+ dialogTitle: dialogTitle,
125
+ fileName: fileName,
126
+ initialDirectory: initialDirectory,
127
+ type: type,
128
+ allowedExtensions: allowedExtensions,
129
+ bytes: bytes ?? Uint8List(0),
130
+ lockParentWindow: lockParentWindow,
131
+ );
132
+ } catch (e, st) {
133
+ Log.warning('SafeFilePicker.saveFile failed: $e\n$st');
134
+ if (context != null && context.mounted) {
135
+ final l10n = AppLocalizations.of(context);
136
+ if (l10n != null) {
137
+ final msg = getErrorMessage(e, l10n);
138
+ ScaffoldMessenger.of(context).showSnackBar(
139
+ SnackBar(content: Text(msg)),
140
+ );
141
+ }
142
+ } else if (onError != null) {
143
+ onError(e.toString());
144
+ }
145
+ return null;
146
+ }
147
+ }
148
+
149
+ /// Safely gets a directory path using [FilePicker.getDirectoryPath].
150
+ static Future<String?> getDirectoryPath({
151
+ String? dialogTitle,
152
+ String? initialDirectory,
153
+ dynamic lockParentWindow,
154
+ BuildContext? context,
155
+ void Function(String message)? onError,
156
+ }) async {
157
+ try {
158
+ return await FilePicker.getDirectoryPath(
159
+ dialogTitle: dialogTitle,
160
+ initialDirectory: initialDirectory,
161
+ lockParentWindow: lockParentWindow,
162
+ );
163
+ } catch (e, st) {
164
+ Log.warning('SafeFilePicker.getDirectoryPath failed: $e\n$st');
165
+ if (context != null && context.mounted) {
166
+ final l10n = AppLocalizations.of(context);
167
+ if (l10n != null) {
168
+ final msg = getErrorMessage(e, l10n);
169
+ ScaffoldMessenger.of(context).showSnackBar(
170
+ SnackBar(content: Text(msg)),
171
+ );
172
+ }
173
+ } else if (onError != null) {
174
+ onError(e.toString());
175
+ }
176
+ return null;
177
+ }
178
+ }
179
+
180
+ /// Safely picks multiple images using [ImagePicker.pickMultiImage].
181
+ static Future<List<XFile>> pickMultiImage({
182
+ int? imageQuality,
183
+ double? maxWidth,
184
+ double? maxHeight,
185
+ bool requestFullMetadata = true,
186
+ BuildContext? context,
187
+ void Function(String message)? onError,
188
+ }) async {
189
+ try {
190
+ final picker = ImagePicker();
191
+ return await picker.pickMultiImage(
192
+ imageQuality: imageQuality,
193
+ maxWidth: maxWidth,
194
+ maxHeight: maxHeight,
195
+ requestFullMetadata: requestFullMetadata,
196
+ );
197
+ } catch (e, st) {
198
+ Log.warning('SafeFilePicker.pickMultiImage failed: $e\n$st');
199
+ if (context != null && context.mounted) {
200
+ final l10n = AppLocalizations.of(context);
201
+ if (l10n != null) {
202
+ final msg = getErrorMessage(e, l10n);
203
+ ScaffoldMessenger.of(context).showSnackBar(
204
+ SnackBar(content: Text(msg)),
205
+ );
206
+ }
207
+ } else if (onError != null) {
208
+ onError(e.toString());
209
+ }
210
+ return const [];
211
+ }
212
+ }
213
+
214
+ /// Safely picks a single image using [ImagePicker.pickImage].
215
+ static Future<XFile?> pickImage({
216
+ required ImageSource source,
217
+ int? imageQuality,
218
+ double? maxWidth,
219
+ double? maxHeight,
220
+ CameraDevice preferredCameraDevice = CameraDevice.rear,
221
+ bool requestFullMetadata = true,
222
+ BuildContext? context,
223
+ void Function(String message)? onError,
224
+ }) async {
225
+ try {
226
+ final picker = ImagePicker();
227
+ return await picker.pickImage(
228
+ source: source,
229
+ imageQuality: imageQuality,
230
+ maxWidth: maxWidth,
231
+ maxHeight: maxHeight,
232
+ preferredCameraDevice: preferredCameraDevice,
233
+ requestFullMetadata: requestFullMetadata,
234
+ );
235
+ } catch (e, st) {
236
+ Log.warning('SafeFilePicker.pickImage failed: $e\n$st');
237
+ if (context != null && context.mounted) {
238
+ final l10n = AppLocalizations.of(context);
239
+ if (l10n != null) {
240
+ final msg = getErrorMessage(e, l10n);
241
+ ScaffoldMessenger.of(context).showSnackBar(
242
+ SnackBar(content: Text(msg)),
243
+ );
244
+ }
245
+ } else if (onError != null) {
246
+ onError(e.toString());
247
+ }
248
+ return null;
249
+ }
250
+ }
251
+}
lib/features/chat/views/components/chat_input_bar.dart
+74
-36
@@ -4,12 +4,12 @@ import 'package:file_picker/file_picker.dart';
4
import 'package:flutter/material.dart';
5
import 'package:flutter_riverpod/flutter_riverpod.dart';
6
import 'package:hugeicons/hugeicons.dart';
7
-import 'package:image_picker/image_picker.dart';
7
import 'package:localmind/features/chat/views/components/token_usage_indicator.dart';
8
9
import '../../../../core/models/enums.dart';
10
import '../../../../core/providers/app_providers.dart';
11
import '../../../../core/services/app_haptics.dart';
12
+import '../../../../core/utils/safe_file_picker.dart';
13
import '../../../../core/theme/colors.dart';
14
import '../../../../l10n/app_localizations.dart';
15
import '../../../saved_messages/views/components/saved_message_picker_sheet.dart';
@@ -203,46 +203,84 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
203
}
204
205
Future<void> _pickImages() async {
206
- final picker = ImagePicker();
206
+ try {
207
+ final images = await SafeFilePicker.pickMultiImage(
208
+ imageQuality: 85,
209
+ context: context,
210
+ );
211
208
- final images = await picker.pickMultiImage(imageQuality: 85);
212
+ if (images.isEmpty) return;
213
210
- if (images.isEmpty) return;
214
+ final settings = ref.read(settingsProvider);
215
+ final compressed = <File>[];
216
+ for (final image in images) {
217
+ compressed.add(
218
+ await ImageUploadUtils.prepareImageFile(
219
+ File(image.path),
220
+ enabled: settings.imageCompressionEnabled,
221
+ level: settings.imageCompressionLevel,
222
+ ),
223
+ );
224
+ }
225
212
- final settings = ref.read(settingsProvider);
213
- final compressed = <File>[];
214
- for (final image in images) {
215
- compressed.add(
216
- await ImageUploadUtils.prepareImageFile(
217
- File(image.path),
218
- enabled: settings.imageCompressionEnabled,
219
- level: settings.imageCompressionLevel,
220
- ),
221
- );
226
+ if (!mounted) return;
227
+ setState(() {
228
+ _attachedFiles.addAll(compressed);
229
+ });
230
+
231
+ widget.onAttach?.call(_attachedFiles);
232
+ } catch (e) {
233
+ if (mounted) {
234
+ final l10n = AppLocalizations.of(context)!;
235
+ ScaffoldMessenger.of(context).showSnackBar(
236
+ SnackBar(
237
+ content: Text(
238
+ SafeFilePicker.getErrorMessage(
239
+ e,
240
+ l10n,
241
+ fallbackMessage: l10n.image_pick_failed(e.toString()),
242
+ ),
243
+ ),
244
+ ),
245
+ );
246
+ }
247
}
223
-
224
- setState(() {
225
- _attachedFiles.addAll(compressed);
226
- });
227
-
228
- widget.onAttach?.call(_attachedFiles);
248
}
249
250
Future<void> _pickDocuments() async {
232
- final result = await FilePicker.pickFiles(
233
- type: FileType.custom,
234
- allowedExtensions: AttachmentHelpers.supportedDocumentExtensions,
235
- );
236
-
237
- if (result == null || result.files.isEmpty) return;
238
-
239
- setState(() {
240
- _attachedFiles.addAll(
241
- result.files.where((f) => f.path != null).map((f) => File(f.path!)),
251
+ try {
252
+ final result = await SafeFilePicker.pickFiles(
253
+ type: FileType.custom,
254
+ allowedExtensions: AttachmentHelpers.supportedDocumentExtensions,
255
+ context: context,
256
);
243
- });
257
245
- widget.onAttach?.call(_attachedFiles);
258
+ if (result == null || result.files.isEmpty) return;
259
+
260
+ if (!mounted) return;
261
+ setState(() {
262
+ _attachedFiles.addAll(
263
+ result.files.where((f) => f.path != null).map((f) => File(f.path!)),
264
+ );
265
+ });
266
+
267
+ widget.onAttach?.call(_attachedFiles);
268
+ } catch (e) {
269
+ if (mounted) {
270
+ final l10n = AppLocalizations.of(context)!;
271
+ ScaffoldMessenger.of(context).showSnackBar(
272
+ SnackBar(
273
+ content: Text(
274
+ SafeFilePicker.getErrorMessage(
275
+ e,
276
+ l10n,
277
+ fallbackMessage: l10n.file_pick_failed(e.toString()),
278
+ ),
279
+ ),
280
+ ),
281
+ );
282
+ }
283
+ }
284
}
285
286
Future<void> _handleInsertSavedMessage() async {
@@ -253,16 +291,16 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
291
}
292
}
293
256
- void _showAttachMenu() async {
294
+ Future<void> _showAttachMenu() async {
295
final result = await showAttachSheet(context);
296
if (!mounted || result == null) return;
297
switch (result) {
298
case AttachAction.documents:
261
- _pickDocuments();
299
+ await _pickDocuments();
300
case AttachAction.images:
263
- _pickImages();
301
+ await _pickImages();
302
case AttachAction.savedMessage:
265
- _handleInsertSavedMessage();
303
+ await _handleInsertSavedMessage();
304
}
305
}
306
lib/features/on_device/views/model_manager_screen.dart
+12
-3
@@ -7,6 +7,7 @@ import 'package:flutter/services.dart';
7
import 'package:flutter_riverpod/flutter_riverpod.dart';
8
import 'package:localmind/core/models/enums.dart';
9
import 'package:localmind/core/providers/app_providers.dart';
10
+import 'package:localmind/core/utils/safe_file_picker.dart';
11
import 'package:localmind/l10n/app_localizations.dart';
12
import 'package:localmind/features/on_device/data/models/on_device_model.dart';
13
import 'package:localmind/features/on_device/providers/foreground_download_providers.dart';
@@ -123,7 +124,7 @@ class _OnDeviceModelManagerScreenState
124
final messenger = ScaffoldMessenger.of(context);
125
final l10n = AppLocalizations.of(context)!;
126
try {
126
- final result = await FilePicker.pickFiles(
127
+ final result = await SafeFilePicker.pickFiles(
128
type: FileType.custom,
129
allowedExtensions: const ['gguf'],
130
);
@@ -176,11 +177,15 @@ class _OnDeviceModelManagerScreenState
177
}
178
179
String _friendlyErrorForL10n(AppLocalizations l10n, Object error) {
180
+ if (SafeFilePicker.isExplorerNotFoundError(error)) {
181
+ return l10n.file_explorer_not_found;
182
+ }
183
final message = error.toString();
184
final normalized = message
185
.replaceFirst('HttpException: ', '')
186
.replaceFirst('FormatException: ', '')
183
- .replaceFirst('FileSystemException: ', '');
187
+ .replaceFirst('FileSystemException: ', '')
188
+ .replaceFirst('PlatformException: ', '');
189
190
switch (normalized) {
191
case 'GGUF import canceled.':
@@ -200,7 +205,11 @@ class _OnDeviceModelManagerScreenState
205
case 'Selected model file does not exist':
206
return l10n.gguf_selected_file_missing;
207
default:
203
- return normalized;
208
+ return SafeFilePicker.getErrorMessage(
209
+ error,
210
+ l10n,
211
+ fallbackMessage: normalized,
212
+ );
213
}
214
}
215
}
lib/features/settings/views/data_backup_actions.dart
+55
-17
@@ -14,6 +14,7 @@ import 'package:localmind/l10n/app_localizations.dart';
14
import '../../../core/providers/app_providers.dart';
15
import '../../../core/providers/storage_providers.dart';
16
import '../../../core/services/data_backup_service.dart';
17
+import '../../../core/utils/safe_file_picker.dart';
18
import '../../conversations/providers/conversation_providers.dart';
19
import '../../personas/providers/personas_providers.dart';
20
import '../../saved_messages/providers/saved_message_providers.dart';
@@ -28,18 +29,34 @@ class DataBackupActions extends ConsumerWidget {
29
String fileName,
30
String json,
31
) async {
31
- final saved = await FilePicker.saveFile(
32
- dialogTitle: dialogTitle,
33
- fileName: fileName,
34
- type: FileType.custom,
35
- allowedExtensions: const ['json'],
36
- bytes: Uint8List.fromList(utf8.encode(json)),
37
- );
38
- if (saved == null) return;
39
- if (context.mounted) {
40
- ScaffoldMessenger.of(context).showSnackBar(
41
- SnackBar(content: Text(AppLocalizations.of(context)!.export_data_success)),
32
+ final l10n = AppLocalizations.of(context)!;
33
+ try {
34
+ final saved = await SafeFilePicker.saveFile(
35
+ dialogTitle: dialogTitle,
36
+ fileName: fileName,
37
+ type: FileType.custom,
38
+ allowedExtensions: const ['json'],
39
+ bytes: Uint8List.fromList(utf8.encode(json)),
40
);
41
+ if (saved == null) return;
42
+ if (context.mounted) {
43
+ ScaffoldMessenger.of(context).showSnackBar(
44
+ SnackBar(content: Text(l10n.export_data_success)),
45
+ );
46
+ }
47
+ } catch (e) {
48
+ if (context.mounted) {
49
+ final msg = SafeFilePicker.getErrorMessage(e, l10n);
50
+ ScaffoldMessenger.of(context).showSnackBar(
51
+ SnackBar(
52
+ content: Text(
53
+ SafeFilePicker.isExplorerNotFoundError(e)
54
+ ? msg
55
+ : l10n.export_data_failed(msg),
56
+ ),
57
+ ),
58
+ );
59
+ }
60
}
61
}
62
@@ -60,7 +77,7 @@ class DataBackupActions extends ConsumerWidget {
77
settings.toJson(),
78
prefs: prefs,
79
);
63
- final saved = await FilePicker.saveFile(
80
+ final saved = await SafeFilePicker.saveFile(
81
dialogTitle: dialogTitle,
82
fileName: fileName,
83
type: FileType.custom,
@@ -75,8 +92,15 @@ class DataBackupActions extends ConsumerWidget {
92
}
93
} catch (e) {
94
if (context.mounted) {
95
+ final msg = SafeFilePicker.getErrorMessage(e, l10n);
96
ScaffoldMessenger.of(context).showSnackBar(
79
- SnackBar(content: Text(l10n.import_data_failed(e.toString()))),
97
+ SnackBar(
98
+ content: Text(
99
+ SafeFilePicker.isExplorerNotFoundError(e)
100
+ ? msg
101
+ : l10n.export_data_failed(msg),
102
+ ),
103
+ ),
104
);
105
}
106
}
@@ -116,7 +140,7 @@ class DataBackupActions extends ConsumerWidget {
140
if (!await _confirmImport(context, confirmMessage)) return;
141
142
try {
119
- final result = await FilePicker.pickFiles(
143
+ final result = await SafeFilePicker.pickFiles(
144
type: FileType.custom,
145
allowedExtensions: const ['json'],
146
);
@@ -133,8 +157,15 @@ class DataBackupActions extends ConsumerWidget {
157
}
158
} catch (e) {
159
if (context.mounted) {
160
+ final msg = SafeFilePicker.getErrorMessage(e, l10n);
161
ScaffoldMessenger.of(context).showSnackBar(
137
- SnackBar(content: Text(l10n.import_data_failed(e.toString()))),
162
+ SnackBar(
163
+ content: Text(
164
+ SafeFilePicker.isExplorerNotFoundError(e)
165
+ ? msg
166
+ : l10n.import_data_failed(msg),
167
+ ),
168
+ ),
169
);
170
}
171
}
@@ -162,7 +193,7 @@ class DataBackupActions extends ConsumerWidget {
193
if (!await _confirmImport(context, l10n.import_data_confirm)) return;
194
195
try {
165
- final result = await FilePicker.pickFiles(
196
+ final result = await SafeFilePicker.pickFiles(
197
type: FileType.custom,
198
allowedExtensions: const ['zip'],
199
);
@@ -197,8 +228,15 @@ class DataBackupActions extends ConsumerWidget {
228
}
229
} catch (e) {
230
if (context.mounted) {
231
+ final msg = SafeFilePicker.getErrorMessage(e, l10n);
232
ScaffoldMessenger.of(context).showSnackBar(
201
- SnackBar(content: Text(l10n.import_data_failed(e.toString()))),
233
+ SnackBar(
234
+ content: Text(
235
+ SafeFilePicker.isExplorerNotFoundError(e)
236
+ ? msg
237
+ : l10n.import_data_failed(msg),
238
+ ),
239
+ ),
240
);
241
}
242
}
lib/features/tts/providers/tts_providers.dart
+30
-24
@@ -13,6 +13,7 @@ import 'package:flutter_tts/flutter_tts.dart';
13
import '../../../core/logger/app_logger.dart';
14
import '../../../core/models/enums.dart';
15
import '../../../core/providers/app_providers.dart';
16
+import '../../../core/utils/safe_file_picker.dart';
17
import '../utils/tts_text_processor.dart';
18
import 'tts_model_providers.dart';
19
import '../data/piper_tts_model.dart';
@@ -1230,33 +1231,38 @@ class TtsNotifier extends Notifier<TtsState> {
1231
}
1232
if (!state.isSpeaking && !state.isPaused) return false;
1233
1233
- final wavFiles = <Uint8List>[];
1234
- for (var i = 0; i < _chunks.length; i++) {
1235
- final cached = _synthesizedChunks[i];
1236
- if (cached != null && cached.isNotEmpty) {
1237
- wavFiles.add(cached);
1238
- continue;
1239
- }
1240
- final tempFile = File(
1241
- '${Directory.systemTemp.path}/tts_chunk_${_currentSessionId}_$i.wav',
1242
- );
1243
- if (tempFile.existsSync()) {
1244
- wavFiles.add(await tempFile.readAsBytes());
1234
+ try {
1235
+ final wavFiles = <Uint8List>[];
1236
+ for (var i = 0; i < _chunks.length; i++) {
1237
+ final cached = _synthesizedChunks[i];
1238
+ if (cached != null && cached.isNotEmpty) {
1239
+ wavFiles.add(cached);
1240
+ continue;
1241
+ }
1242
+ final tempFile = File(
1243
+ '${Directory.systemTemp.path}/tts_chunk_${_currentSessionId}_$i.wav',
1244
+ );
1245
+ if (tempFile.existsSync()) {
1246
+ wavFiles.add(await tempFile.readAsBytes());
1247
+ }
1248
}
1246
- }
1249
1248
- if (wavFiles.isEmpty) return false;
1250
+ if (wavFiles.isEmpty) return false;
1251
1250
- final merged = mergeWavFiles(wavFiles);
1251
- final timestamp = DateTime.now().millisecondsSinceEpoch;
1252
- final saved = await FilePicker.saveFile(
1253
- dialogTitle: 'Save TTS audio',
1254
- fileName: 'localmind_tts_$timestamp.wav',
1255
- type: FileType.custom,
1256
- allowedExtensions: const ['wav'],
1257
- bytes: merged,
1258
- );
1259
- return saved != null;
1252
+ final merged = mergeWavFiles(wavFiles);
1253
+ final timestamp = DateTime.now().millisecondsSinceEpoch;
1254
+ final saved = await SafeFilePicker.saveFile(
1255
+ dialogTitle: 'Save TTS audio',
1256
+ fileName: 'localmind_tts_$timestamp.wav',
1257
+ type: FileType.custom,
1258
+ allowedExtensions: const ['wav'],
1259
+ bytes: merged,
1260
+ );
1261
+ return saved != null;
1262
+ } catch (e, st) {
1263
+ Log.warning('Failed to download TTS audio: $e\n$st');
1264
+ return false;
1265
+ }
1266
}
1267
1268
Future<void> previewVoice(Voice voice) async {
lib/features/tts/views/components/tts_player_bar.dart
+26
-10
@@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
6
import 'package:localmind/core/models/enums.dart';
7
import 'package:localmind/core/providers/app_providers.dart';
8
import 'package:localmind/core/routes/app_routes.dart';
9
+import 'package:localmind/core/utils/safe_file_picker.dart';
10
import 'package:localmind/l10n/app_localizations.dart';
11
import 'package:localmind/features/tts/providers/tts_providers.dart' as tts;
12
import 'package:localmind/features/conversations/data/models/conversation.dart';
@@ -245,17 +246,32 @@ class TtsPlayerBar extends ConsumerWidget {
246
tooltip: l10n.download_tts_audio,
247
visualDensity: VisualDensity.compact,
248
onPressed: () async {
248
- final ok = await notifier.downloadCurrentAudio();
249
- if (!context.mounted) return;
250
- ScaffoldMessenger.of(context).showSnackBar(
251
- SnackBar(
252
- content: Text(
253
- ok
254
- ? l10n.tts_download_success
255
- : l10n.tts_download_no_audio,
249
+ try {
250
+ final ok = await notifier.downloadCurrentAudio();
251
+ if (!context.mounted) return;
252
+ ScaffoldMessenger.of(context).showSnackBar(
253
+ SnackBar(
254
+ content: Text(
255
+ ok
256
+ ? l10n.tts_download_success
257
+ : l10n.tts_download_no_audio,
258
+ ),
259
),
257
- ),
258
- );
260
+ );
261
+ } catch (e) {
262
+ if (!context.mounted) return;
263
+ ScaffoldMessenger.of(context).showSnackBar(
264
+ SnackBar(
265
+ content: Text(
266
+ SafeFilePicker.getErrorMessage(
267
+ e,
268
+ l10n,
269
+ fallbackMessage: l10n.tts_download_no_audio,
270
+ ),
271
+ ),
272
+ ),
273
+ );
274
+ }
275
},
276
),
277
const SizedBox(width: 4),
lib/l10n/app_ar.arb
+6
-1
@@ -916,5 +916,10 @@
916
"settings_concise_voice_responses_desc": "اجعل ردود النموذج قصيرة (فقرة واحدة قصيرة) واطرح أسئلة متابعة في وضع الصوت.",
917
"s3_connection_succeeded": "نجح الاتصال بـ S3.",
918
"failed_to_open_url": "فشل فتح الرابط: {error}",
919
- "failed_to_copy": "فشل النسخ: {error}"
919
+ "failed_to_copy": "فشل النسخ: {error}",
920
+ "file_explorer_not_found": "لم يتم العثور على متصفح ملفات. يرجى التأكد من تثبيت وتفعيل تطبيق إدارة الملفات على جهازك.",
921
+ "export_data_failed": "فشل تصدير النسخة الاحتياطية: {error}",
922
+ "file_pick_failed": "فشل اختيار الملف: {error}",
923
+ "image_pick_failed": "فشل اختيار الصورة: {error}"
924
}
925
+
lib/l10n/app_en.arb
+32
@@ -3317,5 +3317,37 @@
3317
"example": "Permission denied"
3318
}
3319
}
3320
+ },
3321
+ "file_explorer_not_found": "No file explorer found. Please make sure a file manager app is installed and enabled on your device.",
3322
+ "@file_explorer_not_found": {
3323
+ "description": "Error message when no file explorer app is found on the device"
3324
+ },
3325
+ "export_data_failed": "Failed to export backup: {error}",
3326
+ "@export_data_failed": {
3327
+ "description": "Error message shown when data export fails",
3328
+ "placeholders": {
3329
+ "error": {
3330
+ "type": "String"
3331
+ }
3332
+ }
3333
+ },
3334
+ "file_pick_failed": "Failed to select file: {error}",
3335
+ "@file_pick_failed": {
3336
+ "description": "Error message shown when selecting a file fails",
3337
+ "placeholders": {
3338
+ "error": {
3339
+ "type": "String"
3340
+ }
3341
+ }
3342
+ },
3343
+ "image_pick_failed": "Failed to select image: {error}",
3344
+ "@image_pick_failed": {
3345
+ "description": "Error message shown when selecting an image fails",
3346
+ "placeholders": {
3347
+ "error": {
3348
+ "type": "String"
3349
+ }
3350
+ }
3351
}
3352
}
3353
+
lib/l10n/app_localizations.dart
+24
@@ -5602,6 +5602,30 @@ abstract class AppLocalizations {
5602
/// In en, this message translates to:
5603
/// **'Failed to copy: {error}'**
5604
String failed_to_copy(String error);
5605
+
5606
+ /// Error message when no file explorer app is found on the device
5607
+ ///
5608
+ /// In en, this message translates to:
5609
+ /// **'No file explorer found. Please make sure a file manager app is installed and enabled on your device.'**
5610
+ String get file_explorer_not_found;
5611
+
5612
+ /// Error message shown when data export fails
5613
+ ///
5614
+ /// In en, this message translates to:
5615
+ /// **'Failed to export backup: {error}'**
5616
+ String export_data_failed(String error);
5617
+
5618
+ /// Error message shown when selecting a file fails
5619
+ ///
5620
+ /// In en, this message translates to:
5621
+ /// **'Failed to select file: {error}'**
5622
+ String file_pick_failed(String error);
5623
+
5624
+ /// Error message shown when selecting an image fails
5625
+ ///
5626
+ /// In en, this message translates to:
5627
+ /// **'Failed to select image: {error}'**
5628
+ String image_pick_failed(String error);
5629
}
5630
5631
class _AppLocalizationsDelegate
lib/l10n/app_localizations_ar.dart
+19
@@ -2988,4 +2988,23 @@ class AppLocalizationsAr extends AppLocalizations {
2988
String failed_to_copy(String error) {
2989
return 'فشل النسخ: $error';
2990
}
2991
+
2992
+ @override
2993
+ String get file_explorer_not_found =>
2994
+ 'لم يتم العثور على متصفح ملفات. يرجى التأكد من تثبيت وتفعيل تطبيق إدارة الملفات على جهازك.';
2995
+
2996
+ @override
2997
+ String export_data_failed(String error) {
2998
+ return 'فشل تصدير النسخة الاحتياطية: $error';
2999
+ }
3000
+
3001
+ @override
3002
+ String file_pick_failed(String error) {
3003
+ return 'فشل اختيار الملف: $error';
3004
+ }
3005
+
3006
+ @override
3007
+ String image_pick_failed(String error) {
3008
+ return 'فشل اختيار الصورة: $error';
3009
+ }
3010
}
lib/l10n/app_localizations_bn.dart
+19
@@ -3013,4 +3013,23 @@ class AppLocalizationsBn extends AppLocalizations {
3013
String failed_to_copy(String error) {
3014
return 'কপি করতে ব্যর্থ হয়েছে: $error';
3015
}
3016
+
3017
+ @override
3018
+ String get file_explorer_not_found =>
3019
+ 'No file explorer found. Please make sure a file manager app is installed and enabled on your device.';
3020
+
3021
+ @override
3022
+ String export_data_failed(String error) {
3023
+ return 'Failed to export backup: $error';
3024
+ }
3025
+
3026
+ @override
3027
+ String file_pick_failed(String error) {
3028
+ return 'Failed to select file: $error';
3029
+ }
3030
+
3031
+ @override
3032
+ String image_pick_failed(String error) {
3033
+ return 'Failed to select image: $error';
3034
+ }
3035
}
lib/l10n/app_localizations_de.dart
+19
@@ -3003,4 +3003,23 @@ class AppLocalizationsDe extends AppLocalizations {
3003
String failed_to_copy(String error) {
3004
return 'Kopieren fehlgeschlagen: $error';
3005
}
3006
+
3007
+ @override
3008
+ String get file_explorer_not_found =>
3009
+ 'No file explorer found. Please make sure a file manager app is installed and enabled on your device.';
3010
+
3011
+ @override
3012
+ String export_data_failed(String error) {
3013
+ return 'Failed to export backup: $error';
3014
+ }
3015
+
3016
+ @override
3017
+ String file_pick_failed(String error) {
3018
+ return 'Failed to select file: $error';
3019
+ }
3020
+
3021
+ @override
3022
+ String image_pick_failed(String error) {
3023
+ return 'Failed to select image: $error';
3024
+ }
3025
}
lib/l10n/app_localizations_en.dart
+19
@@ -2993,4 +2993,23 @@ class AppLocalizationsEn extends AppLocalizations {
2993
String failed_to_copy(String error) {
2994
return 'Failed to copy: $error';
2995
}
2996
+
2997
+ @override
2998
+ String get file_explorer_not_found =>
2999
+ 'No file explorer found. Please make sure a file manager app is installed and enabled on your device.';
3000
+
3001
+ @override
3002
+ String export_data_failed(String error) {
3003
+ return 'Failed to export backup: $error';
3004
+ }
3005
+
3006
+ @override
3007
+ String file_pick_failed(String error) {
3008
+ return 'Failed to select file: $error';
3009
+ }
3010
+
3011
+ @override
3012
+ String image_pick_failed(String error) {
3013
+ return 'Failed to select image: $error';
3014
+ }
3015
}
lib/l10n/app_localizations_es.dart
+19
@@ -3017,4 +3017,23 @@ class AppLocalizationsEs extends AppLocalizations {
3017
String failed_to_copy(String error) {
3018
return 'Error al copiar: $error';
3019
}
3020
+
3021
+ @override
3022
+ String get file_explorer_not_found =>
3023
+ 'No file explorer found. Please make sure a file manager app is installed and enabled on your device.';
3024
+
3025
+ @override
3026
+ String export_data_failed(String error) {
3027
+ return 'Failed to export backup: $error';
3028
+ }
3029
+
3030
+ @override
3031
+ String file_pick_failed(String error) {
3032
+ return 'Failed to select file: $error';
3033
+ }
3034
+
3035
+ @override
3036
+ String image_pick_failed(String error) {
3037
+ return 'Failed to select image: $error';
3038
+ }
3039
}
lib/l10n/app_localizations_fr.dart
+19
@@ -3006,4 +3006,23 @@ class AppLocalizationsFr extends AppLocalizations {
3006
String failed_to_copy(String error) {
3007
return 'Échec de la copie : $error';
3008
}
3009
+
3010
+ @override
3011
+ String get file_explorer_not_found =>
3012
+ 'No file explorer found. Please make sure a file manager app is installed and enabled on your device.';
3013
+
3014
+ @override
3015
+ String export_data_failed(String error) {
3016
+ return 'Failed to export backup: $error';
3017
+ }
3018
+
3019
+ @override
3020
+ String file_pick_failed(String error) {
3021
+ return 'Failed to select file: $error';
3022
+ }
3023
+
3024
+ @override
3025
+ String image_pick_failed(String error) {
3026
+ return 'Failed to select image: $error';
3027
+ }
3028
}
lib/l10n/app_localizations_hi.dart
+19
@@ -3004,4 +3004,23 @@ class AppLocalizationsHi extends AppLocalizations {
3004
String failed_to_copy(String error) {
3005
return 'कॉपी करने में विफल: $error';
3006
}
3007
+
3008
+ @override
3009
+ String get file_explorer_not_found =>
3010
+ 'No file explorer found. Please make sure a file manager app is installed and enabled on your device.';
3011
+
3012
+ @override
3013
+ String export_data_failed(String error) {
3014
+ return 'Failed to export backup: $error';
3015
+ }
3016
+
3017
+ @override
3018
+ String file_pick_failed(String error) {
3019
+ return 'Failed to select file: $error';
3020
+ }
3021
+
3022
+ @override
3023
+ String image_pick_failed(String error) {
3024
+ return 'Failed to select image: $error';
3025
+ }
3026
}
lib/l10n/app_localizations_it.dart
+19
@@ -3020,4 +3020,23 @@ class AppLocalizationsIt extends AppLocalizations {
3020
String failed_to_copy(String error) {
3021
return 'Copia non riuscita: $error';
3022
}
3023
+
3024
+ @override
3025
+ String get file_explorer_not_found =>
3026
+ 'No file explorer found. Please make sure a file manager app is installed and enabled on your device.';
3027
+
3028
+ @override
3029
+ String export_data_failed(String error) {
3030
+ return 'Failed to export backup: $error';
3031
+ }
3032
+
3033
+ @override
3034
+ String file_pick_failed(String error) {
3035
+ return 'Failed to select file: $error';
3036
+ }
3037
+
3038
+ @override
3039
+ String image_pick_failed(String error) {
3040
+ return 'Failed to select image: $error';
3041
+ }
3042
}
lib/l10n/app_localizations_ja.dart
+19
@@ -2943,4 +2943,23 @@ class AppLocalizationsJa extends AppLocalizations {
2943
String failed_to_copy(String error) {
2944
return 'コピーに失敗しました: $error';
2945
}
2946
+
2947
+ @override
2948
+ String get file_explorer_not_found =>
2949
+ 'No file explorer found. Please make sure a file manager app is installed and enabled on your device.';
2950
+
2951
+ @override
2952
+ String export_data_failed(String error) {
2953
+ return 'Failed to export backup: $error';
2954
+ }
2955
+
2956
+ @override
2957
+ String file_pick_failed(String error) {
2958
+ return 'Failed to select file: $error';
2959
+ }
2960
+
2961
+ @override
2962
+ String image_pick_failed(String error) {
2963
+ return 'Failed to select image: $error';
2964
+ }
2965
}
lib/l10n/app_localizations_ru.dart
+19
@@ -3018,4 +3018,23 @@ class AppLocalizationsRu extends AppLocalizations {
3018
String failed_to_copy(String error) {
3019
return 'Не удалось скопировать: $error';
3020
}
3021
+
3022
+ @override
3023
+ String get file_explorer_not_found =>
3024
+ 'No file explorer found. Please make sure a file manager app is installed and enabled on your device.';
3025
+
3026
+ @override
3027
+ String export_data_failed(String error) {
3028
+ return 'Failed to export backup: $error';
3029
+ }
3030
+
3031
+ @override
3032
+ String file_pick_failed(String error) {
3033
+ return 'Failed to select file: $error';
3034
+ }
3035
+
3036
+ @override
3037
+ String image_pick_failed(String error) {
3038
+ return 'Failed to select image: $error';
3039
+ }
3040
}
lib/l10n/app_localizations_tr.dart
+19
@@ -3023,4 +3023,23 @@ class AppLocalizationsTr extends AppLocalizations {
3023
String failed_to_copy(String error) {
3024
return 'Kopyalanamadı: $error';
3025
}
3026
+
3027
+ @override
3028
+ String get file_explorer_not_found =>
3029
+ 'No file explorer found. Please make sure a file manager app is installed and enabled on your device.';
3030
+
3031
+ @override
3032
+ String export_data_failed(String error) {
3033
+ return 'Failed to export backup: $error';
3034
+ }
3035
+
3036
+ @override
3037
+ String file_pick_failed(String error) {
3038
+ return 'Failed to select file: $error';
3039
+ }
3040
+
3041
+ @override
3042
+ String image_pick_failed(String error) {
3043
+ return 'Failed to select image: $error';
3044
+ }
3045
}
lib/l10n/app_localizations_zh.dart
+19
@@ -2920,6 +2920,25 @@ class AppLocalizationsZh extends AppLocalizations {
2920
String failed_to_copy(String error) {
2921
return '复制失败: $error';
2922
}
2923
+
2924
+ @override
2925
+ String get file_explorer_not_found =>
2926
+ 'No file explorer found. Please make sure a file manager app is installed and enabled on your device.';
2927
+
2928
+ @override
2929
+ String export_data_failed(String error) {
2930
+ return 'Failed to export backup: $error';
2931
+ }
2932
+
2933
+ @override
2934
+ String file_pick_failed(String error) {
2935
+ return 'Failed to select file: $error';
2936
+ }
2937
+
2938
+ @override
2939
+ String image_pick_failed(String error) {
2940
+ return 'Failed to select image: $error';
2941
+ }
2942
}
2943
2944
/// The translations for Chinese, as used in Taiwan (`zh_TW`).
lib/l10n/untranslated.txt
+78
-1
@@ -1 +1,78 @@
1
-{}
\ No newline at end of file
1
+{
2
+ "bn": [
3
+ "file_explorer_not_found",
4
+ "export_data_failed",
5
+ "file_pick_failed",
6
+ "image_pick_failed"
7
+ ],
8
+
9
+ "de": [
10
+ "file_explorer_not_found",
11
+ "export_data_failed",
12
+ "file_pick_failed",
13
+ "image_pick_failed"
14
+ ],
15
+
16
+ "es": [
17
+ "file_explorer_not_found",
18
+ "export_data_failed",
19
+ "file_pick_failed",
20
+ "image_pick_failed"
21
+ ],
22
+
23
+ "fr": [
24
+ "file_explorer_not_found",
25
+ "export_data_failed",
26
+ "file_pick_failed",
27
+ "image_pick_failed"
28
+ ],
29
+
30
+ "hi": [
31
+ "file_explorer_not_found",
32
+ "export_data_failed",
33
+ "file_pick_failed",
34
+ "image_pick_failed"
35
+ ],
36
+
37
+ "it": [
38
+ "file_explorer_not_found",
39
+ "export_data_failed",
40
+ "file_pick_failed",
41
+ "image_pick_failed"
42
+ ],
43
+
44
+ "ja": [
45
+ "file_explorer_not_found",
46
+ "export_data_failed",
47
+ "file_pick_failed",
48
+ "image_pick_failed"
49
+ ],
50
+
51
+ "ru": [
52
+ "file_explorer_not_found",
53
+ "export_data_failed",
54
+ "file_pick_failed",
55
+ "image_pick_failed"
56
+ ],
57
+
58
+ "tr": [
59
+ "file_explorer_not_found",
60
+ "export_data_failed",
61
+ "file_pick_failed",
62
+ "image_pick_failed"
63
+ ],
64
+
65
+ "zh": [
66
+ "file_explorer_not_found",
67
+ "export_data_failed",
68
+ "file_pick_failed",
69
+ "image_pick_failed"
70
+ ],
71
+
72
+ "zh_TW": [
73
+ "file_explorer_not_found",
74
+ "export_data_failed",
75
+ "file_pick_failed",
76
+ "image_pick_failed"
77
+ ]
78
+}
test/core/utils/safe_file_picker_test.dart
new
+140
@@ -0,0 +1,140 @@
1
+import 'dart:io';
2
+
3
+import 'package:flutter/services.dart';
4
+import 'package:flutter_test/flutter_test.dart';
5
+import 'package:localmind/core/utils/safe_file_picker.dart';
6
+import 'package:localmind/l10n/app_localizations_en.dart';
7
+
8
+void main() {
9
+ final l10n = AppLocalizationsEn();
10
+
11
+ group('SafeFilePicker.isExplorerNotFoundError', () {
12
+ test('identifies PlatformException with explorer_not_found code', () {
13
+ final error = PlatformException(
14
+ code: 'explorer_not_found',
15
+ message:
16
+ "Can't find a valid activity to handle the request. Make sure you have a file explorer installed.",
17
+ );
18
+ expect(SafeFilePicker.isExplorerNotFoundError(error), isTrue);
19
+ });
20
+
21
+ test('identifies PlatformException with activity not found message', () {
22
+ final error = PlatformException(
23
+ code: 'error',
24
+ message: "Can't find a valid activity to handle the request.",
25
+ );
26
+ expect(SafeFilePicker.isExplorerNotFoundError(error), isTrue);
27
+ });
28
+
29
+ test('identifies generic string containing explorer_not_found', () {
30
+ final error = Exception('PlatformException(explorer_not_found, ...)');
31
+ expect(SafeFilePicker.isExplorerNotFoundError(error), isTrue);
32
+ });
33
+
34
+ test('returns false for unrelated errors', () {
35
+ final error = PlatformException(
36
+ code: 'permission_denied',
37
+ message: 'Permission was denied by the user',
38
+ );
39
+ expect(SafeFilePicker.isExplorerNotFoundError(error), isFalse);
40
+
41
+ final socketError = const SocketException('Connection reset');
42
+ expect(SafeFilePicker.isExplorerNotFoundError(socketError), isFalse);
43
+ });
44
+ });
45
+
46
+ group('SafeFilePicker.getErrorMessage', () {
47
+ test('returns localized file_explorer_not_found for explorer errors', () {
48
+ final error = PlatformException(
49
+ code: 'explorer_not_found',
50
+ message: "Can't find a valid activity to handle the request.",
51
+ );
52
+ expect(
53
+ SafeFilePicker.getErrorMessage(error, l10n),
54
+ l10n.file_explorer_not_found,
55
+ );
56
+ });
57
+
58
+ test('returns clean platform exception message for other codes', () {
59
+ final error = PlatformException(
60
+ code: 'read_error',
61
+ message: 'Unable to read file content',
62
+ );
63
+ expect(
64
+ SafeFilePicker.getErrorMessage(error, l10n),
65
+ 'Unable to read file content',
66
+ );
67
+ });
68
+
69
+ test('returns clean message for FileSystemException', () {
70
+ final error = const FileSystemException('File is corrupted');
71
+ expect(
72
+ SafeFilePicker.getErrorMessage(error, l10n),
73
+ 'File is corrupted',
74
+ );
75
+ });
76
+
77
+ test('strips Exception prefix for standard exceptions', () {
78
+ final error = Exception('Failed to parse backup');
79
+ expect(
80
+ SafeFilePicker.getErrorMessage(error, l10n),
81
+ 'Failed to parse backup',
82
+ );
83
+ });
84
+ });
85
+
86
+ group('SafeFilePicker method channel error catching', () {
87
+ TestWidgetsFlutterBinding.ensureInitialized();
88
+
89
+ test('pickFiles catches PlatformException and returns null', () async {
90
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
91
+ .setMockMethodCallHandler(
92
+ const MethodChannel('miguelruivo.flutter.plugins.filepicker'),
93
+ (MethodCall methodCall) async {
94
+ throw PlatformException(
95
+ code: 'explorer_not_found',
96
+ message: "Can't find a valid activity to handle the request.",
97
+ );
98
+ },
99
+ );
100
+
101
+ final result = await SafeFilePicker.pickFiles();
102
+ expect(result, isNull);
103
+ });
104
+
105
+ test('saveFile catches PlatformException and returns null', () async {
106
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
107
+ .setMockMethodCallHandler(
108
+ const MethodChannel('miguelruivo.flutter.plugins.filepicker'),
109
+ (MethodCall methodCall) async {
110
+ throw PlatformException(
111
+ code: 'explorer_not_found',
112
+ message: "Can't find a valid activity to handle the request.",
113
+ );
114
+ },
115
+ );
116
+
117
+ final result = await SafeFilePicker.saveFile(
118
+ dialogTitle: 'Save File',
119
+ fileName: 'test.json',
120
+ );
121
+ expect(result, isNull);
122
+ });
123
+
124
+ test('getDirectoryPath catches PlatformException and returns null', () async {
125
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
126
+ .setMockMethodCallHandler(
127
+ const MethodChannel('miguelruivo.flutter.plugins.filepicker'),
128
+ (MethodCall methodCall) async {
129
+ throw PlatformException(
130
+ code: 'explorer_not_found',
131
+ message: "Can't find a valid activity to handle the request.",
132
+ );
133
+ },
134
+ );
135
+
136
+ final result = await SafeFilePicker.getDirectoryPath();
137
+ expect(result, isNull);
138
+ });
139
+ });
140
+}
test/features/chat/chat_input_bar_attachment_test.dart
new
+90
@@ -0,0 +1,90 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter/services.dart';
3
+import 'package:flutter_localizations/flutter_localizations.dart';
4
+import 'package:flutter_riverpod/flutter_riverpod.dart';
5
+import 'package:flutter_test/flutter_test.dart';
6
+import 'package:localmind/core/providers/storage_providers.dart';
7
+import 'package:localmind/core/theme/app_theme.dart';
8
+import 'package:localmind/features/chat/providers/chat_providers.dart';
9
+import 'package:localmind/features/chat/views/components/chat_input_bar.dart';
10
+import 'package:localmind/features/servers/data/models/server.dart';
11
+import 'package:localmind/features/servers/providers/server_providers.dart';
12
+import 'package:localmind/features/tts/providers/tts_providers.dart';
13
+import 'package:localmind/l10n/app_localizations.dart';
14
+import 'package:shadcn_ui/shadcn_ui.dart';
15
+import 'package:shared_preferences/shared_preferences.dart';
16
+
17
+void main() {
18
+ testWidgets('ChatInputBar handles explorer_not_found error safely',
19
+ (tester) async {
20
+ SharedPreferences.setMockInitialValues({});
21
+ final prefs = await SharedPreferences.getInstance();
22
+
23
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
24
+ .setMockMethodCallHandler(
25
+ const MethodChannel('miguelruivo.flutter.plugins.filepicker'),
26
+ (MethodCall methodCall) async {
27
+ throw PlatformException(
28
+ code: 'explorer_not_found',
29
+ message:
30
+ "Can't find a valid activity to handle the request. Make sure you have a file explorer installed.",
31
+ );
32
+ },
33
+ );
34
+
35
+ await tester.pumpWidget(
36
+ ProviderScope(
37
+ overrides: [
38
+ sharedPreferencesProvider.overrideWithValue(prefs),
39
+ chatProvider.overrideWith(_StubChatNotifier.new),
40
+ activeServerProvider.overrideWith(_StubActiveServerNotifier.new),
41
+ serversProvider.overrideWith(_StubServersNotifier.new),
42
+ ttsProvider.overrideWith(_StubTtsNotifier.new),
43
+ ],
44
+ child: ShadTheme(
45
+ data: AppTheme.lightShadTheme,
46
+ child: MaterialApp(
47
+ locale: const Locale('en'),
48
+ localizationsDelegates: const [
49
+ AppLocalizations.delegate,
50
+ GlobalMaterialLocalizations.delegate,
51
+ GlobalWidgetsLocalizations.delegate,
52
+ GlobalCupertinoLocalizations.delegate,
53
+ ],
54
+ supportedLocales: AppLocalizations.supportedLocales,
55
+ home: Scaffold(
56
+ body: ChatInputBar(
57
+ onSend: (text, {attachments}) {},
58
+ onStop: () {},
59
+ ),
60
+ ),
61
+ ),
62
+ ),
63
+ ),
64
+ );
65
+
66
+ await tester.pump();
67
+ expect(find.byType(ChatInputBar), findsOneWidget);
68
+ expect(tester.takeException(), isNull);
69
+ });
70
+}
71
+
72
+class _StubChatNotifier extends ChatNotifier {
73
+ @override
74
+ ChatState build() => const ChatState();
75
+}
76
+
77
+class _StubActiveServerNotifier extends ActiveServerNotifier {
78
+ @override
79
+ Server? build() => null;
80
+}
81
+
82
+class _StubServersNotifier extends ServersNotifier {
83
+ @override
84
+ Future<List<Server>> build() async => const [];
85
+}
86
+
87
+class _StubTtsNotifier extends TtsNotifier {
88
+ @override
89
+ TtsState build() => const TtsState();
90
+}
test/features/models/model_metadata_providers_test.dart
-1
@@ -1,7 +1,6 @@
1
import 'package:flutter_riverpod/flutter_riverpod.dart';
2
import 'package:flutter_test/flutter_test.dart';
3
import 'package:localmind/core/providers/storage_providers.dart';
4
-import 'package:localmind/features/models/data/model_metadata_repository.dart';
4
import 'package:localmind/features/models/providers/model_metadata_providers.dart';
5
import 'package:shared_preferences/shared_preferences.dart';
6