| .. | .. |
|---|
| 7 | 7 | import 'package:flutter_riverpod/flutter_riverpod.dart'; |
|---|
| 8 | 8 | import 'package:go_router/go_router.dart'; |
|---|
| 9 | 9 | import 'package:image_picker/image_picker.dart'; |
|---|
| 10 | +import 'package:file_picker/file_picker.dart'; |
|---|
| 10 | 11 | import 'package:shared_preferences/shared_preferences.dart'; |
|---|
| 11 | 12 | |
|---|
| 12 | 13 | import '../models/message.dart'; |
|---|
| .. | .. |
|---|
| 214 | 215 | final messageId = msg['messageId'] as String?; |
|---|
| 215 | 216 | final content = msg['content'] as String?; |
|---|
| 216 | 217 | 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 | + } |
|---|
| 218 | 228 | } |
|---|
| 219 | 229 | case 'unread': |
|---|
| 220 | 230 | final sessionId = msg['sessionId'] as String?; |
|---|
| .. | .. |
|---|
| 391 | 401 | void _handleIncomingImage(Map<String, dynamic> msg) { |
|---|
| 392 | 402 | final imageData = msg['imageBase64'] as String? ?? msg['data'] as String? ?? msg['image'] as String?; |
|---|
| 393 | 403 | final content = msg['content'] as String? ?? msg['caption'] as String? ?? ''; |
|---|
| 404 | + final sessionId = msg['sessionId'] as String?; |
|---|
| 394 | 405 | |
|---|
| 395 | 406 | if (imageData == null) return; |
|---|
| 396 | 407 | |
|---|
| 397 | | - // Always update the Navigate screen screenshot |
|---|
| 408 | + // Always update the Navigate screen screenshot provider |
|---|
| 398 | 409 | ref.read(latestScreenshotProvider.notifier).state = imageData; |
|---|
| 399 | 410 | |
|---|
| 400 | | - final isScreenshot = content == 'Screenshot' || content == 'Capturing screenshot...'; |
|---|
| 411 | + final isScreenshot = content == 'Screenshot' || |
|---|
| 412 | + content == 'Capturing screenshot...' || |
|---|
| 413 | + (msg['type'] == 'screenshot'); |
|---|
| 401 | 414 | |
|---|
| 402 | 415 | if (isScreenshot) { |
|---|
| 403 | 416 | // Remove any "Capturing screenshot..." placeholder text messages |
|---|
| .. | .. |
|---|
| 405 | 418 | (m) => m.role == MessageRole.assistant && m.content == 'Capturing screenshot...', |
|---|
| 406 | 419 | ); |
|---|
| 407 | 420 | |
|---|
| 408 | | - // Only add to chat if the Screen button requested it |
|---|
| 421 | + // Only add to chat if the Screen button explicitly requested it |
|---|
| 409 | 422 | if (!_screenshotForChat) { |
|---|
| 410 | 423 | ref.read(isTypingProvider.notifier).state = false; |
|---|
| 411 | 424 | return; |
|---|
| .. | .. |
|---|
| 419 | 432 | content: content, |
|---|
| 420 | 433 | status: MessageStatus.sent, |
|---|
| 421 | 434 | ); |
|---|
| 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 | + } |
|---|
| 422 | 443 | |
|---|
| 423 | 444 | ref.read(messagesProvider.notifier).addMessage(message); |
|---|
| 424 | 445 | ref.read(isTypingProvider.notifier).state = false; |
|---|
| .. | .. |
|---|
| 434 | 455 | // Verify |
|---|
| 435 | 456 | final verify = await MessageStore.loadAll(sessionId); |
|---|
| 436 | 457 | _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 | + } |
|---|
| 437 | 494 | } |
|---|
| 438 | 495 | |
|---|
| 439 | 496 | void _incrementUnread(String sessionId) { |
|---|
| .. | .. |
|---|
| 607 | 664 | } |
|---|
| 608 | 665 | } |
|---|
| 609 | 666 | |
|---|
| 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 | + |
|---|
| 610 | 778 | void _requestScreenshot() { |
|---|
| 611 | 779 | _screenshotForChat = true; |
|---|
| 612 | 780 | _sendCommand('screenshot', {'sessionId': ref.read(activeSessionIdProvider)}); |
|---|
| .. | .. |
|---|
| 627 | 795 | } |
|---|
| 628 | 796 | |
|---|
| 629 | 797 | Future<void> _pickPhoto() async { |
|---|
| 630 | | - // Capture session ID now ā before any async gaps (dialog, encoding) |
|---|
| 631 | 798 | final targetSessionId = ref.read(activeSessionIdProvider); |
|---|
| 632 | 799 | |
|---|
| 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 | + ), |
|---|
| 638 | 825 | ); |
|---|
| 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 | + } |
|---|
| 639 | 851 | |
|---|
| 640 | 852 | if (images.isEmpty) return; |
|---|
| 641 | 853 | |
|---|
| .. | .. |
|---|
| 662 | 874 | textCaption = ''; |
|---|
| 663 | 875 | } |
|---|
| 664 | 876 | |
|---|
| 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; |
|---|
| 667 | 883 | if (voiceB64 != null) { |
|---|
| 668 | 884 | final voiceMsg = Message.voice( |
|---|
| 669 | 885 | role: MessageRole.user, |
|---|
| 670 | 886 | audioUri: caption.substring('__voice__:'.length), |
|---|
| 671 | 887 | status: MessageStatus.sent, |
|---|
| 672 | 888 | ); |
|---|
| 889 | + voiceMessageId = voiceMsg.id; |
|---|
| 673 | 890 | 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 | | - }); |
|---|
| 681 | 891 | } |
|---|
| 682 | 892 | |
|---|
| 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 | + }); |
|---|
| 696 | 902 | |
|---|
| 697 | 903 | // Show images in chat locally |
|---|
| 698 | 904 | for (var i = 0; i < encodedImages.length; i++) { |
|---|