@tduncan / Cortana / commits / bc62886

feat: implement SafeFilePicker to handle missing file explorer exceptions and add associated localizations and tests. Fix #58

abdulmominsakib committed Aug 15, 2026 at 09:59 UTC bc62886f08828720053a32ce1d587ccd3e1de8a8
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