| .. | .. |
|---|
| 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'; |
|---|
| .. | .. |
|---|
| 663 | 664 | } |
|---|
| 664 | 665 | } |
|---|
| 665 | 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 | + for (final file in result.files) { |
|---|
| 675 | + if (file.path == null) continue; |
|---|
| 676 | + final bytes = await File(file.path!).readAsBytes(); |
|---|
| 677 | + final b64 = base64Encode(bytes); |
|---|
| 678 | + final mimeType = _guessMimeType(file.name); |
|---|
| 679 | + final isImage = mimeType.startsWith('image/'); |
|---|
| 680 | + |
|---|
| 681 | + if (isImage) { |
|---|
| 682 | + final message = Message.image( |
|---|
| 683 | + role: MessageRole.user, |
|---|
| 684 | + imageBase64: b64, |
|---|
| 685 | + content: file.name, |
|---|
| 686 | + status: MessageStatus.sent, |
|---|
| 687 | + ); |
|---|
| 688 | + ref.read(messagesProvider.notifier).addMessage(message); |
|---|
| 689 | + _ws?.send({ |
|---|
| 690 | + 'type': 'image', |
|---|
| 691 | + 'imageBase64': b64, |
|---|
| 692 | + 'mimeType': mimeType, |
|---|
| 693 | + 'caption': file.name, |
|---|
| 694 | + 'sessionId': targetSessionId, |
|---|
| 695 | + }); |
|---|
| 696 | + } else { |
|---|
| 697 | + // Non-image file: send as text with file info, save file to temp for session |
|---|
| 698 | + final tmpDir = await getTemporaryDirectory(); |
|---|
| 699 | + final tmpPath = '${tmpDir.path}/${file.name}'; |
|---|
| 700 | + await File(tmpPath).writeAsBytes(bytes); |
|---|
| 701 | + |
|---|
| 702 | + final message = Message.text( |
|---|
| 703 | + role: MessageRole.user, |
|---|
| 704 | + content: '📎 ${file.name} (${_formatSize(bytes.length)})', |
|---|
| 705 | + status: MessageStatus.sent, |
|---|
| 706 | + ); |
|---|
| 707 | + ref.read(messagesProvider.notifier).addMessage(message); |
|---|
| 708 | + // Send file as base64 with metadata |
|---|
| 709 | + _ws?.send({ |
|---|
| 710 | + 'type': 'file', |
|---|
| 711 | + 'fileBase64': b64, |
|---|
| 712 | + 'fileName': file.name, |
|---|
| 713 | + 'mimeType': mimeType, |
|---|
| 714 | + 'fileSize': bytes.length, |
|---|
| 715 | + 'sessionId': targetSessionId, |
|---|
| 716 | + }); |
|---|
| 717 | + } |
|---|
| 718 | + } |
|---|
| 719 | + _scrollToBottom(); |
|---|
| 720 | + } |
|---|
| 721 | + |
|---|
| 722 | + String _guessMimeType(String name) { |
|---|
| 723 | + final ext = name.split('.').last.toLowerCase(); |
|---|
| 724 | + const map = { |
|---|
| 725 | + 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png', |
|---|
| 726 | + 'gif': 'image/gif', 'webp': 'image/webp', 'heic': 'image/heic', |
|---|
| 727 | + 'pdf': 'application/pdf', 'doc': 'application/msword', |
|---|
| 728 | + 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', |
|---|
| 729 | + 'xls': 'application/vnd.ms-excel', |
|---|
| 730 | + 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', |
|---|
| 731 | + 'txt': 'text/plain', 'csv': 'text/csv', 'json': 'application/json', |
|---|
| 732 | + 'zip': 'application/zip', 'mp3': 'audio/mpeg', 'mp4': 'video/mp4', |
|---|
| 733 | + }; |
|---|
| 734 | + return map[ext] ?? 'application/octet-stream'; |
|---|
| 735 | + } |
|---|
| 736 | + |
|---|
| 737 | + String _formatSize(int bytes) { |
|---|
| 738 | + if (bytes < 1024) return '$bytes B'; |
|---|
| 739 | + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; |
|---|
| 740 | + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; |
|---|
| 741 | + } |
|---|
| 742 | + |
|---|
| 666 | 743 | void _requestScreenshot() { |
|---|
| 667 | 744 | _screenshotForChat = true; |
|---|
| 668 | 745 | _sendCommand('screenshot', {'sessionId': ref.read(activeSessionIdProvider)}); |
|---|
| .. | .. |
|---|
| 683 | 760 | } |
|---|
| 684 | 761 | |
|---|
| 685 | 762 | Future<void> _pickPhoto() async { |
|---|
| 686 | | - // Capture session ID now — before any async gaps (dialog, encoding) |
|---|
| 687 | 763 | final targetSessionId = ref.read(activeSessionIdProvider); |
|---|
| 688 | 764 | |
|---|
| 689 | | - final picker = ImagePicker(); |
|---|
| 690 | | - final images = await picker.pickMultiImage( |
|---|
| 691 | | - maxWidth: 1920, |
|---|
| 692 | | - maxHeight: 1080, |
|---|
| 693 | | - imageQuality: 85, |
|---|
| 765 | + // Show picker options |
|---|
| 766 | + final source = await showModalBottomSheet<String>( |
|---|
| 767 | + context: context, |
|---|
| 768 | + builder: (ctx) => SafeArea( |
|---|
| 769 | + child: Column( |
|---|
| 770 | + mainAxisSize: MainAxisSize.min, |
|---|
| 771 | + children: [ |
|---|
| 772 | + ListTile( |
|---|
| 773 | + leading: const Icon(Icons.camera_alt), |
|---|
| 774 | + title: const Text('Take Photo'), |
|---|
| 775 | + onTap: () => Navigator.pop(ctx, 'camera'), |
|---|
| 776 | + ), |
|---|
| 777 | + ListTile( |
|---|
| 778 | + leading: const Icon(Icons.photo_library), |
|---|
| 779 | + title: const Text('Photo Library'), |
|---|
| 780 | + onTap: () => Navigator.pop(ctx, 'gallery'), |
|---|
| 781 | + ), |
|---|
| 782 | + ListTile( |
|---|
| 783 | + leading: const Icon(Icons.attach_file), |
|---|
| 784 | + title: const Text('Files'), |
|---|
| 785 | + onTap: () => Navigator.pop(ctx, 'files'), |
|---|
| 786 | + ), |
|---|
| 787 | + ], |
|---|
| 788 | + ), |
|---|
| 789 | + ), |
|---|
| 694 | 790 | ); |
|---|
| 791 | + if (source == null) return; |
|---|
| 792 | + |
|---|
| 793 | + if (source == 'files') { |
|---|
| 794 | + await _pickFiles(targetSessionId); |
|---|
| 795 | + return; |
|---|
| 796 | + } |
|---|
| 797 | + |
|---|
| 798 | + List<XFile> images; |
|---|
| 799 | + final picker = ImagePicker(); |
|---|
| 800 | + if (source == 'camera') { |
|---|
| 801 | + final photo = await picker.pickImage( |
|---|
| 802 | + source: ImageSource.camera, |
|---|
| 803 | + maxWidth: 1920, |
|---|
| 804 | + maxHeight: 1080, |
|---|
| 805 | + imageQuality: 85, |
|---|
| 806 | + ); |
|---|
| 807 | + if (photo == null) return; |
|---|
| 808 | + images = [photo]; |
|---|
| 809 | + } else { |
|---|
| 810 | + images = await picker.pickMultiImage( |
|---|
| 811 | + maxWidth: 1920, |
|---|
| 812 | + maxHeight: 1080, |
|---|
| 813 | + imageQuality: 85, |
|---|
| 814 | + ); |
|---|
| 815 | + } |
|---|
| 695 | 816 | |
|---|
| 696 | 817 | if (images.isEmpty) return; |
|---|
| 697 | 818 | |
|---|