7 files modified
changed files
ios/Podfile.lock patch | view | blame | history
lib/screens/chat_screen.dart patch | view | blame | history
lib/services/mqtt_service.dart patch | view | blame | history
lib/widgets/command_bar.dart patch | view | blame | history
macos/Flutter/GeneratedPluginRegistrant.swift patch | view | blame | history
pubspec.lock patch | view | blame | history
pubspec.yaml patch | view | blame | history
ios/Podfile.lock
....@@ -4,6 +4,40 @@
44 - FlutterMacOS
55 - device_info_plus (0.0.1):
66 - Flutter
7
+ - DKImagePickerController/Core (4.3.9):
8
+ - DKImagePickerController/ImageDataManager
9
+ - DKImagePickerController/Resource
10
+ - DKImagePickerController/ImageDataManager (4.3.9)
11
+ - DKImagePickerController/PhotoGallery (4.3.9):
12
+ - DKImagePickerController/Core
13
+ - DKPhotoGallery
14
+ - DKImagePickerController/Resource (4.3.9)
15
+ - DKPhotoGallery (0.0.19):
16
+ - DKPhotoGallery/Core (= 0.0.19)
17
+ - DKPhotoGallery/Model (= 0.0.19)
18
+ - DKPhotoGallery/Preview (= 0.0.19)
19
+ - DKPhotoGallery/Resource (= 0.0.19)
20
+ - SDWebImage
21
+ - SwiftyGif
22
+ - DKPhotoGallery/Core (0.0.19):
23
+ - DKPhotoGallery/Model
24
+ - DKPhotoGallery/Preview
25
+ - SDWebImage
26
+ - SwiftyGif
27
+ - DKPhotoGallery/Model (0.0.19):
28
+ - SDWebImage
29
+ - SwiftyGif
30
+ - DKPhotoGallery/Preview (0.0.19):
31
+ - DKPhotoGallery/Model
32
+ - DKPhotoGallery/Resource
33
+ - SDWebImage
34
+ - SwiftyGif
35
+ - DKPhotoGallery/Resource (0.0.19):
36
+ - SDWebImage
37
+ - SwiftyGif
38
+ - file_picker (0.0.1):
39
+ - DKImagePickerController/PhotoGallery
40
+ - Flutter
741 - Flutter (1.0.0)
842 - flutter_secure_storage (6.0.0):
943 - Flutter
....@@ -15,11 +49,15 @@
1549 - Flutter
1650 - record_ios (1.2.0):
1751 - Flutter
52
+ - SDWebImage (5.21.7):
53
+ - SDWebImage/Core (= 5.21.7)
54
+ - SDWebImage/Core (5.21.7)
1855 - share_plus (0.0.1):
1956 - Flutter
2057 - shared_preferences_foundation (0.0.1):
2158 - Flutter
2259 - FlutterMacOS
60
+ - SwiftyGif (5.4.5)
2361 - vibration (1.7.5):
2462 - Flutter
2563 - wakelock_plus (0.0.1):
....@@ -28,6 +66,7 @@
2866 DEPENDENCIES:
2967 - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`)
3068 - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
69
+ - file_picker (from `.symlinks/plugins/file_picker/ios`)
3170 - Flutter (from `Flutter`)
3271 - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
3372 - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
....@@ -39,11 +78,20 @@
3978 - vibration (from `.symlinks/plugins/vibration/ios`)
4079 - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
4180
81
+SPEC REPOS:
82
+ trunk:
83
+ - DKImagePickerController
84
+ - DKPhotoGallery
85
+ - SDWebImage
86
+ - SwiftyGif
87
+
4288 EXTERNAL SOURCES:
4389 audioplayers_darwin:
4490 :path: ".symlinks/plugins/audioplayers_darwin/darwin"
4591 device_info_plus:
4692 :path: ".symlinks/plugins/device_info_plus/ios"
93
+ file_picker:
94
+ :path: ".symlinks/plugins/file_picker/ios"
4795 Flutter:
4896 :path: Flutter
4997 flutter_secure_storage:
....@@ -68,14 +116,19 @@
68116 SPEC CHECKSUMS:
69117 audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5
70118 device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
119
+ DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
120
+ DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
121
+ file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
71122 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
72123 flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
73124 image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
74125 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
75126 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
76127 record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844
128
+ SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
77129 share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
78130 shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
131
+ SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
79132 vibration: 8e2f50fc35bb736f9eecb7dd9f7047fbb6a6e888
80133 wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
81134
lib/screens/chat_screen.dart
....@@ -7,6 +7,7 @@
77 import 'package:flutter_riverpod/flutter_riverpod.dart';
88 import 'package:go_router/go_router.dart';
99 import 'package:image_picker/image_picker.dart';
10
+import 'package:file_picker/file_picker.dart';
1011 import 'package:shared_preferences/shared_preferences.dart';
1112
1213 import '../models/message.dart';
....@@ -214,7 +215,16 @@
214215 final messageId = msg['messageId'] as String?;
215216 final content = msg['content'] as String?;
216217 if (messageId != null && content != null) {
217
- ref.read(messagesProvider.notifier).updateContent(messageId, content);
218
+ // Try updating in current session first
219
+ final currentMessages = ref.read(messagesProvider);
220
+ final inCurrent = currentMessages.any((m) => m.id == messageId);
221
+ if (inCurrent) {
222
+ ref.read(messagesProvider.notifier).updateContent(messageId, content);
223
+ } else {
224
+ // Message is in a different session (user switched after recording).
225
+ // Load that session's messages from disk, update, and save back.
226
+ _updateTranscriptOnDisk(messageId, content);
227
+ }
218228 }
219229 case 'unread':
220230 final sessionId = msg['sessionId'] as String?;
....@@ -391,13 +401,16 @@
391401 void _handleIncomingImage(Map<String, dynamic> msg) {
392402 final imageData = msg['imageBase64'] as String? ?? msg['data'] as String? ?? msg['image'] as String?;
393403 final content = msg['content'] as String? ?? msg['caption'] as String? ?? '';
404
+ final sessionId = msg['sessionId'] as String?;
394405
395406 if (imageData == null) return;
396407
397
- // Always update the Navigate screen screenshot
408
+ // Always update the Navigate screen screenshot provider
398409 ref.read(latestScreenshotProvider.notifier).state = imageData;
399410
400
- final isScreenshot = content == 'Screenshot' || content == 'Capturing screenshot...';
411
+ final isScreenshot = content == 'Screenshot' ||
412
+ content == 'Capturing screenshot...' ||
413
+ (msg['type'] == 'screenshot');
401414
402415 if (isScreenshot) {
403416 // Remove any "Capturing screenshot..." placeholder text messages
....@@ -405,7 +418,7 @@
405418 (m) => m.role == MessageRole.assistant && m.content == 'Capturing screenshot...',
406419 );
407420
408
- // Only add to chat if the Screen button requested it
421
+ // Only add to chat if the Screen button explicitly requested it
409422 if (!_screenshotForChat) {
410423 ref.read(isTypingProvider.notifier).state = false;
411424 return;
....@@ -419,6 +432,14 @@
419432 content: content,
420433 status: MessageStatus.sent,
421434 );
435
+
436
+ // Cross-session routing: store for target session if not active
437
+ final activeId = ref.read(activeSessionIdProvider);
438
+ if (sessionId != null && sessionId != activeId) {
439
+ _storeForSession(sessionId, message);
440
+ _incrementUnread(sessionId);
441
+ return;
442
+ }
422443
423444 ref.read(messagesProvider.notifier).addMessage(message);
424445 ref.read(isTypingProvider.notifier).state = false;
....@@ -434,6 +455,42 @@
434455 // Verify
435456 final verify = await MessageStore.loadAll(sessionId);
436457 _chatLog('storeForSession: verified ${verify.length} messages after save');
458
+ }
459
+
460
+ /// Update a transcript for a message stored on disk (not in the active session).
461
+ /// Scans all session files to find the message by ID, updates content, and saves.
462
+ Future<void> _updateTranscriptOnDisk(String messageId, String content) async {
463
+ try {
464
+ final dir = await getApplicationDocumentsDirectory();
465
+ final msgDir = Directory('${dir.path}/messages');
466
+ if (!await msgDir.exists()) return;
467
+
468
+ await for (final entity in msgDir.list()) {
469
+ if (entity is! File || !entity.path.endsWith('.json')) continue;
470
+
471
+ final jsonStr = await entity.readAsString();
472
+ final List<dynamic> jsonList = jsonDecode(jsonStr) as List<dynamic>;
473
+ bool found = false;
474
+
475
+ final updated = jsonList.map((j) {
476
+ final map = j as Map<String, dynamic>;
477
+ if (map['id'] == messageId) {
478
+ found = true;
479
+ return {...map, 'content': content};
480
+ }
481
+ return map;
482
+ }).toList();
483
+
484
+ if (found) {
485
+ await entity.writeAsString(jsonEncode(updated));
486
+ _chatLog('transcript: updated messageId=$messageId on disk in ${entity.path.split('/').last}');
487
+ return;
488
+ }
489
+ }
490
+ _chatLog('transcript: messageId=$messageId not found on disk');
491
+ } catch (e) {
492
+ _chatLog('transcript: disk update error=$e');
493
+ }
437494 }
438495
439496 void _incrementUnread(String sessionId) {
....@@ -607,6 +664,117 @@
607664 }
608665 }
609666
667
+ Future<void> _pickFiles(String? targetSessionId) async {
668
+ final result = await FilePicker.platform.pickFiles(
669
+ allowMultiple: true,
670
+ type: FileType.any,
671
+ );
672
+ if (result == null || result.files.isEmpty) return;
673
+
674
+ // Build attachments list
675
+ final attachments = <Map<String, dynamic>>[];
676
+ for (final file in result.files) {
677
+ if (file.path == null) continue;
678
+ final bytes = await File(file.path!).readAsBytes();
679
+ final b64 = base64Encode(bytes);
680
+ final mimeType = _guessMimeType(file.name);
681
+ attachments.add({
682
+ 'data': b64,
683
+ 'mimeType': mimeType,
684
+ 'fileName': file.name,
685
+ });
686
+ }
687
+ if (attachments.isEmpty) return;
688
+
689
+ // Show caption dialog
690
+ final fileNames = result.files.map((f) => f.name).join(', ');
691
+ final caption = await _showCaptionDialog(result.files.length);
692
+ if (caption == null) return;
693
+
694
+ // Handle voice caption
695
+ String textCaption = caption;
696
+ String? voiceB64;
697
+ if (caption.startsWith('__voice__:')) {
698
+ final voicePath = caption.substring('__voice__:'.length);
699
+ final voiceFile = File(voicePath);
700
+ if (await voiceFile.exists()) {
701
+ voiceB64 = base64Encode(await voiceFile.readAsBytes());
702
+ }
703
+ textCaption = '';
704
+ }
705
+
706
+ // Send voice first if present
707
+ if (voiceB64 != null) {
708
+ final voiceMsg = Message.voice(
709
+ role: MessageRole.user,
710
+ audioUri: caption.substring('__voice__:'.length),
711
+ status: MessageStatus.sent,
712
+ );
713
+ ref.read(messagesProvider.notifier).addMessage(voiceMsg);
714
+ _ws?.send({
715
+ 'type': 'voice',
716
+ 'audioBase64': voiceB64,
717
+ 'content': '',
718
+ 'messageId': voiceMsg.id,
719
+ 'sessionId': targetSessionId,
720
+ });
721
+ }
722
+
723
+ // Send all files as one atomic bundle
724
+ _ws?.send({
725
+ 'type': 'bundle',
726
+ 'caption': textCaption,
727
+ 'attachments': attachments,
728
+ 'sessionId': targetSessionId,
729
+ });
730
+
731
+ // Show in chat
732
+ for (final att in attachments) {
733
+ final mime = att['mimeType'] as String;
734
+ final name = att['fileName'] as String? ?? 'file';
735
+ if (mime.startsWith('image/')) {
736
+ ref.read(messagesProvider.notifier).addMessage(Message.image(
737
+ role: MessageRole.user,
738
+ imageBase64: att['data'] as String,
739
+ content: name,
740
+ status: MessageStatus.sent,
741
+ ));
742
+ } else {
743
+ final size = base64Decode(att['data'] as String).length;
744
+ ref.read(messagesProvider.notifier).addMessage(Message.text(
745
+ role: MessageRole.user,
746
+ content: textCaption.isNotEmpty
747
+ ? '$textCaption\nšŸ“Ž $name (${_formatSize(size)})'
748
+ : 'šŸ“Ž $name (${_formatSize(size)})',
749
+ status: MessageStatus.sent,
750
+ ));
751
+ }
752
+ }
753
+
754
+ _scrollToBottom();
755
+ }
756
+
757
+ String _guessMimeType(String name) {
758
+ final ext = name.split('.').last.toLowerCase();
759
+ const map = {
760
+ 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png',
761
+ 'gif': 'image/gif', 'webp': 'image/webp', 'heic': 'image/heic',
762
+ 'pdf': 'application/pdf', 'doc': 'application/msword',
763
+ 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
764
+ 'xls': 'application/vnd.ms-excel',
765
+ 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
766
+ 'txt': 'text/plain', 'csv': 'text/csv', 'json': 'application/json',
767
+ 'zip': 'application/zip', 'mp3': 'audio/mpeg', 'mp4': 'video/mp4',
768
+ };
769
+ return map[ext] ?? 'application/octet-stream';
770
+ }
771
+
772
+ String _formatSize(int bytes) {
773
+ if (bytes < 1024) return '$bytes B';
774
+ if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
775
+ return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
776
+ }
777
+
610778 void _requestScreenshot() {
611779 _screenshotForChat = true;
612780 _sendCommand('screenshot', {'sessionId': ref.read(activeSessionIdProvider)});
....@@ -627,15 +795,59 @@
627795 }
628796
629797 Future<void> _pickPhoto() async {
630
- // Capture session ID now — before any async gaps (dialog, encoding)
631798 final targetSessionId = ref.read(activeSessionIdProvider);
632799
633
- final picker = ImagePicker();
634
- final images = await picker.pickMultiImage(
635
- maxWidth: 1920,
636
- maxHeight: 1080,
637
- imageQuality: 85,
800
+ // Show picker options
801
+ final source = await showModalBottomSheet<String>(
802
+ context: context,
803
+ builder: (ctx) => SafeArea(
804
+ child: Column(
805
+ mainAxisSize: MainAxisSize.min,
806
+ children: [
807
+ ListTile(
808
+ leading: const Icon(Icons.camera_alt),
809
+ title: const Text('Take Photo'),
810
+ onTap: () => Navigator.pop(ctx, 'camera'),
811
+ ),
812
+ ListTile(
813
+ leading: const Icon(Icons.photo_library),
814
+ title: const Text('Photo Library'),
815
+ onTap: () => Navigator.pop(ctx, 'gallery'),
816
+ ),
817
+ ListTile(
818
+ leading: const Icon(Icons.attach_file),
819
+ title: const Text('Files'),
820
+ onTap: () => Navigator.pop(ctx, 'files'),
821
+ ),
822
+ ],
823
+ ),
824
+ ),
638825 );
826
+ if (source == null) return;
827
+
828
+ if (source == 'files') {
829
+ await _pickFiles(targetSessionId);
830
+ return;
831
+ }
832
+
833
+ List<XFile> images;
834
+ final picker = ImagePicker();
835
+ if (source == 'camera') {
836
+ final photo = await picker.pickImage(
837
+ source: ImageSource.camera,
838
+ maxWidth: 1920,
839
+ maxHeight: 1080,
840
+ imageQuality: 85,
841
+ );
842
+ if (photo == null) return;
843
+ images = [photo];
844
+ } else {
845
+ images = await picker.pickMultiImage(
846
+ maxWidth: 1920,
847
+ maxHeight: 1080,
848
+ imageQuality: 85,
849
+ );
850
+ }
639851
640852 if (images.isEmpty) return;
641853
....@@ -662,37 +874,31 @@
662874 textCaption = '';
663875 }
664876
665
- // Send voice FIRST so Whisper transcribes it and the [PAILot:voice] prefix
666
- // sets the reply channel. Images follow — Claude sees transcript + images together.
877
+ final attachments = encodedImages.map((b64) =>
878
+ <String, dynamic>{'data': b64, 'mimeType': 'image/jpeg'}
879
+ ).toList();
880
+
881
+ // Create voice bubble first to get messageId for transcript reflection
882
+ String? voiceMessageId;
667883 if (voiceB64 != null) {
668884 final voiceMsg = Message.voice(
669885 role: MessageRole.user,
670886 audioUri: caption.substring('__voice__:'.length),
671887 status: MessageStatus.sent,
672888 );
889
+ voiceMessageId = voiceMsg.id;
673890 ref.read(messagesProvider.notifier).addMessage(voiceMsg);
674
- _ws?.send({
675
- 'type': 'voice',
676
- 'audioBase64': voiceB64,
677
- 'content': '',
678
- 'messageId': voiceMsg.id,
679
- 'sessionId': targetSessionId,
680
- });
681891 }
682892
683
- // Send images — first with text caption (if any), rest without
684
- for (var i = 0; i < encodedImages.length; i++) {
685
- final isFirst = i == 0;
686
- final msgCaption = isFirst ? textCaption : '';
687
-
688
- _ws?.send({
689
- 'type': 'image',
690
- 'imageBase64': encodedImages[i],
691
- 'mimeType': 'image/jpeg',
692
- 'caption': msgCaption,
693
- 'sessionId': targetSessionId,
694
- });
695
- }
893
+ // Send everything as a single atomic bundle
894
+ _ws?.send({
895
+ 'type': 'bundle',
896
+ 'caption': textCaption,
897
+ if (voiceB64 != null) 'audioBase64': voiceB64,
898
+ if (voiceMessageId != null) 'voiceMessageId': voiceMessageId,
899
+ 'attachments': attachments,
900
+ 'sessionId': targetSessionId,
901
+ });
696902
697903 // Show images in chat locally
698904 for (var i = 0; i < encodedImages.length; i++) {
lib/services/mqtt_service.dart
....@@ -141,22 +141,18 @@
141141 client.keepAlivePeriod = 30;
142142 client.autoReconnect = false; // Don't auto-reconnect during trial — enable after success
143143 client.connectTimeoutPeriod = timeout;
144
- client.logging(on: true);
144
+ // client.maxConnectionAttempts is final — can't set it
145
+ client.logging(on: false);
145146
146147 client.onConnected = _onConnected;
147148 client.onDisconnected = _onDisconnected;
148149 client.onAutoReconnect = _onAutoReconnect;
149150 client.onAutoReconnected = _onAutoReconnected;
150151
151
- // Persistent session (cleanSession = false) for offline message queuing
152
+ // Persistent session: broker queues QoS 1 messages while client is offline
152153 final connMessage = MqttConnectMessage()
153154 .withClientIdentifier(clientId)
154
- .authenticateAs('pailot', config.mqttToken ?? '')
155
- .startClean(); // Use clean session for now; persistent sessions require broker support
156
-
157
- // For persistent sessions, replace startClean() with:
158
- // .withWillQos(MqttQos.atLeastOnce);
159
- // and remove startClean()
155
+ .authenticateAs('pailot', config.mqttToken ?? '');
160156
161157 client.connectionMessage = connMessage;
162158
....@@ -407,7 +403,6 @@
407403 }
408404
409405 if (type == 'image' && sessionId != null) {
410
- // Image message
411406 _publish('pailot/$sessionId/in', {
412407 'msgId': _uuid(),
413408 'type': 'image',
....@@ -420,6 +415,34 @@
420415 return;
421416 }
422417
418
+ if (type == 'bundle' && sessionId != null) {
419
+ // Atomic multi-attachment message
420
+ _publish('pailot/$sessionId/in', {
421
+ 'msgId': _uuid(),
422
+ 'type': 'bundle',
423
+ 'sessionId': sessionId,
424
+ 'caption': message['caption'] ?? '',
425
+ if (message['audioBase64'] != null) 'audioBase64': message['audioBase64'],
426
+ 'attachments': message['attachments'] ?? [],
427
+ 'ts': _now(),
428
+ }, MqttQos.atLeastOnce);
429
+ return;
430
+ }
431
+
432
+ if (type == 'file' && sessionId != null) {
433
+ _publish('pailot/$sessionId/in', {
434
+ 'msgId': _uuid(),
435
+ 'type': 'file',
436
+ 'sessionId': sessionId,
437
+ 'fileBase64': message['fileBase64'] ?? '',
438
+ 'fileName': message['fileName'] ?? 'file',
439
+ 'mimeType': message['mimeType'] ?? 'application/octet-stream',
440
+ 'fileSize': message['fileSize'] ?? 0,
441
+ 'ts': _now(),
442
+ }, MqttQos.atLeastOnce);
443
+ return;
444
+ }
445
+
423446 if (type == 'tts' && sessionId != null) {
424447 // TTS request — route as command
425448 _publish('pailot/control/in', {
lib/widgets/command_bar.dart
....@@ -45,7 +45,7 @@
4545 ),
4646 _CommandButton(
4747 icon: Icons.attach_file,
48
- label: 'Photo',
48
+ label: 'Attach',
4949 onTap: onPhoto,
5050 ),
5151 _CommandButton(
macos/Flutter/GeneratedPluginRegistrant.swift
....@@ -7,6 +7,7 @@
77
88 import audioplayers_darwin
99 import device_info_plus
10
+import file_picker
1011 import file_selector_macos
1112 import flutter_secure_storage_macos
1213 import package_info_plus
....@@ -18,6 +19,7 @@
1819 func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
1920 AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin"))
2021 DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
22
+ FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
2123 FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
2224 FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
2325 FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
pubspec.lock
....@@ -193,6 +193,14 @@
193193 url: "https://pub.dev"
194194 source: hosted
195195 version: "7.0.1"
196
+ file_picker:
197
+ dependency: "direct main"
198
+ description:
199
+ name: file_picker
200
+ sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343"
201
+ url: "https://pub.dev"
202
+ source: hosted
203
+ version: "10.3.10"
196204 file_selector_linux:
197205 dependency: transitive
198206 description:
pubspec.yaml
....@@ -29,6 +29,7 @@
2929 mqtt_client: ^10.6.0
3030 uuid: ^4.5.1
3131 collection: ^1.19.1
32
+ file_picker: ^10.3.10
3233
3334 dev_dependencies:
3435 flutter_test: