| .. | .. |
|---|
| 47 | 47 | void initState() { |
|---|
| 48 | 48 | super.initState(); |
|---|
| 49 | 49 | WidgetsBinding.instance.addObserver(this); |
|---|
| 50 | | - _loadLastSeq(); |
|---|
| 51 | | - _initConnection(); |
|---|
| 50 | + _initAll(); |
|---|
| 52 | 51 | _scrollController.addListener(_onScroll); |
|---|
| 53 | 52 | } |
|---|
| 54 | 53 | |
|---|
| 55 | | - Future<void> _loadLastSeq() async { |
|---|
| 54 | + Future<void> _initAll() async { |
|---|
| 55 | + // Load lastSeq BEFORE connecting so catch_up sends the right value |
|---|
| 56 | 56 | final prefs = await SharedPreferences.getInstance(); |
|---|
| 57 | 57 | _lastSeq = prefs.getInt('lastSeq') ?? 0; |
|---|
| 58 | + if (!mounted) return; |
|---|
| 59 | + _initConnection(); |
|---|
| 58 | 60 | } |
|---|
| 59 | 61 | |
|---|
| 60 | 62 | void _saveLastSeq() { |
|---|
| .. | .. |
|---|
| 538 | 540 | textCaption = ''; |
|---|
| 539 | 541 | } |
|---|
| 540 | 542 | |
|---|
| 541 | | - // Send all images together — first with caption, rest without |
|---|
| 542 | | - for (var i = 0; i < encodedImages.length; i++) { |
|---|
| 543 | | - final isFirst = i == 0; |
|---|
| 544 | | - final msgCaption = isFirst ? textCaption : ''; |
|---|
| 545 | | - |
|---|
| 546 | | - _ws?.send({ |
|---|
| 547 | | - 'type': 'image', |
|---|
| 548 | | - 'imageBase64': encodedImages[i], |
|---|
| 549 | | - 'mimeType': 'image/jpeg', |
|---|
| 550 | | - 'caption': msgCaption, |
|---|
| 551 | | - if (isFirst && voiceB64 != null) 'audioBase64': voiceB64, |
|---|
| 552 | | - 'sessionId': ref.read(activeSessionIdProvider), |
|---|
| 553 | | - // Signal how many images follow so receiving session can wait |
|---|
| 554 | | - if (isFirst && encodedImages.length > 1) |
|---|
| 555 | | - 'totalImages': encodedImages.length, |
|---|
| 556 | | - }); |
|---|
| 557 | | - } |
|---|
| 558 | | - |
|---|
| 559 | | - // If voice caption, also send the voice message so it gets transcribed |
|---|
| 543 | + // Send voice FIRST so Whisper transcribes it and the [PAILot:voice] prefix |
|---|
| 544 | + // sets the reply channel. Images follow — Claude sees transcript + images together. |
|---|
| 560 | 545 | if (voiceB64 != null) { |
|---|
| 561 | 546 | final voiceMsg = Message.voice( |
|---|
| 562 | 547 | role: MessageRole.user, |
|---|
| .. | .. |
|---|
| 569 | 554 | 'audioBase64': voiceB64, |
|---|
| 570 | 555 | 'content': '', |
|---|
| 571 | 556 | 'messageId': voiceMsg.id, |
|---|
| 557 | + 'sessionId': ref.read(activeSessionIdProvider), |
|---|
| 558 | + }); |
|---|
| 559 | + } |
|---|
| 560 | + |
|---|
| 561 | + // Send images — first with text caption (if any), rest without |
|---|
| 562 | + for (var i = 0; i < encodedImages.length; i++) { |
|---|
| 563 | + final isFirst = i == 0; |
|---|
| 564 | + final msgCaption = isFirst ? textCaption : ''; |
|---|
| 565 | + |
|---|
| 566 | + _ws?.send({ |
|---|
| 567 | + 'type': 'image', |
|---|
| 568 | + 'imageBase64': encodedImages[i], |
|---|
| 569 | + 'mimeType': 'image/jpeg', |
|---|
| 570 | + 'caption': msgCaption, |
|---|
| 572 | 571 | 'sessionId': ref.read(activeSessionIdProvider), |
|---|
| 573 | 572 | }); |
|---|
| 574 | 573 | } |
|---|
| .. | .. |
|---|
| 591 | 590 | final captionController = TextEditingController(); |
|---|
| 592 | 591 | String? voicePath; |
|---|
| 593 | 592 | bool isVoiceRecording = false; |
|---|
| 593 | + bool hasVoiceCaption = false; |
|---|
| 594 | 594 | |
|---|
| 595 | | - return showModalBottomSheet<String>( |
|---|
| 595 | + final result = await showModalBottomSheet<String>( |
|---|
| 596 | 596 | context: context, |
|---|
| 597 | 597 | isScrollControlled: true, |
|---|
| 598 | 598 | builder: (ctx) => StatefulBuilder( |
|---|
| .. | .. |
|---|
| 611 | 611 | style: Theme.of(ctx).textTheme.titleSmall, |
|---|
| 612 | 612 | ), |
|---|
| 613 | 613 | const SizedBox(height: 12), |
|---|
| 614 | | - TextField( |
|---|
| 615 | | - controller: captionController, |
|---|
| 616 | | - decoration: InputDecoration( |
|---|
| 617 | | - hintText: 'Add a caption (optional)', |
|---|
| 618 | | - border: const OutlineInputBorder(), |
|---|
| 619 | | - suffixIcon: IconButton( |
|---|
| 620 | | - icon: Icon( |
|---|
| 621 | | - isVoiceRecording ? Icons.stop_circle : Icons.mic, |
|---|
| 622 | | - color: isVoiceRecording ? Colors.red : null, |
|---|
| 623 | | - ), |
|---|
| 624 | | - onPressed: () async { |
|---|
| 625 | | - if (isVoiceRecording) { |
|---|
| 626 | | - final path = await AudioService.stopRecording(); |
|---|
| 627 | | - setSheetState(() => isVoiceRecording = false); |
|---|
| 628 | | - if (path != null) { |
|---|
| 629 | | - voicePath = path; |
|---|
| 630 | | - captionController.text = '🎤 Voice caption recorded'; |
|---|
| 631 | | - } |
|---|
| 632 | | - } else { |
|---|
| 633 | | - final path = await AudioService.startRecording(); |
|---|
| 634 | | - if (path != null) { |
|---|
| 635 | | - setSheetState(() => isVoiceRecording = true); |
|---|
| 636 | | - } |
|---|
| 637 | | - } |
|---|
| 638 | | - }, |
|---|
| 614 | + // Text caption input |
|---|
| 615 | + if (!isVoiceRecording && !hasVoiceCaption) |
|---|
| 616 | + TextField( |
|---|
| 617 | + controller: captionController, |
|---|
| 618 | + decoration: const InputDecoration( |
|---|
| 619 | + hintText: 'Add a text caption (optional)', |
|---|
| 620 | + border: OutlineInputBorder(), |
|---|
| 621 | + ), |
|---|
| 622 | + autofocus: true, |
|---|
| 623 | + maxLines: 3, |
|---|
| 624 | + ), |
|---|
| 625 | + // Voice recording indicator |
|---|
| 626 | + if (isVoiceRecording) |
|---|
| 627 | + Container( |
|---|
| 628 | + padding: const EdgeInsets.symmetric(vertical: 20), |
|---|
| 629 | + child: const Row( |
|---|
| 630 | + mainAxisAlignment: MainAxisAlignment.center, |
|---|
| 631 | + children: [ |
|---|
| 632 | + Icon(Icons.fiber_manual_record, color: Colors.red, size: 16), |
|---|
| 633 | + SizedBox(width: 8), |
|---|
| 634 | + Text('Recording voice caption...', style: TextStyle(fontSize: 16)), |
|---|
| 635 | + ], |
|---|
| 639 | 636 | ), |
|---|
| 640 | 637 | ), |
|---|
| 641 | | - autofocus: true, |
|---|
| 642 | | - maxLines: 3, |
|---|
| 643 | | - enabled: !isVoiceRecording, |
|---|
| 644 | | - ), |
|---|
| 638 | + // Voice recorded confirmation |
|---|
| 639 | + if (hasVoiceCaption && !isVoiceRecording) |
|---|
| 640 | + Container( |
|---|
| 641 | + padding: const EdgeInsets.symmetric(vertical: 20), |
|---|
| 642 | + child: const Row( |
|---|
| 643 | + mainAxisAlignment: MainAxisAlignment.center, |
|---|
| 644 | + children: [ |
|---|
| 645 | + Icon(Icons.check_circle, color: Colors.green, size: 20), |
|---|
| 646 | + SizedBox(width: 8), |
|---|
| 647 | + Text('Voice caption recorded', style: TextStyle(fontSize: 16)), |
|---|
| 648 | + ], |
|---|
| 649 | + ), |
|---|
| 650 | + ), |
|---|
| 645 | 651 | const SizedBox(height: 12), |
|---|
| 652 | + // Action row: mic/stop + cancel + send |
|---|
| 646 | 653 | Row( |
|---|
| 647 | | - mainAxisAlignment: MainAxisAlignment.end, |
|---|
| 648 | 654 | children: [ |
|---|
| 655 | + // Mic / Stop button — large and clear |
|---|
| 656 | + if (!hasVoiceCaption) |
|---|
| 657 | + IconButton.filled( |
|---|
| 658 | + onPressed: () async { |
|---|
| 659 | + if (isVoiceRecording) { |
|---|
| 660 | + final path = await AudioService.stopRecording(); |
|---|
| 661 | + setSheetState(() { |
|---|
| 662 | + isVoiceRecording = false; |
|---|
| 663 | + if (path != null) { |
|---|
| 664 | + voicePath = path; |
|---|
| 665 | + hasVoiceCaption = true; |
|---|
| 666 | + } |
|---|
| 667 | + }); |
|---|
| 668 | + } else { |
|---|
| 669 | + final path = await AudioService.startRecording(); |
|---|
| 670 | + if (path != null) { |
|---|
| 671 | + setSheetState(() => isVoiceRecording = true); |
|---|
| 672 | + } |
|---|
| 673 | + } |
|---|
| 674 | + }, |
|---|
| 675 | + icon: Icon(isVoiceRecording ? Icons.stop : Icons.mic), |
|---|
| 676 | + style: IconButton.styleFrom( |
|---|
| 677 | + backgroundColor: isVoiceRecording ? Colors.red : null, |
|---|
| 678 | + foregroundColor: isVoiceRecording ? Colors.white : null, |
|---|
| 679 | + ), |
|---|
| 680 | + ), |
|---|
| 681 | + const Spacer(), |
|---|
| 649 | 682 | TextButton( |
|---|
| 650 | | - onPressed: () { |
|---|
| 651 | | - if (isVoiceRecording) AudioService.cancelRecording(); |
|---|
| 652 | | - Navigator.pop(ctx); |
|---|
| 683 | + onPressed: () async { |
|---|
| 684 | + if (isVoiceRecording) { |
|---|
| 685 | + await AudioService.cancelRecording(); |
|---|
| 686 | + } |
|---|
| 687 | + if (ctx.mounted) Navigator.pop(ctx); |
|---|
| 653 | 688 | }, |
|---|
| 654 | 689 | child: const Text('Cancel'), |
|---|
| 655 | 690 | ), |
|---|
| 656 | 691 | const SizedBox(width: 8), |
|---|
| 657 | 692 | FilledButton( |
|---|
| 658 | | - onPressed: () { |
|---|
| 659 | | - if (voicePath != null) { |
|---|
| 660 | | - // Voice caption: send as voice message with images |
|---|
| 661 | | - Navigator.pop(ctx, '__voice__:$voicePath'); |
|---|
| 662 | | - } else { |
|---|
| 663 | | - Navigator.pop(ctx, captionController.text); |
|---|
| 664 | | - } |
|---|
| 665 | | - }, |
|---|
| 693 | + onPressed: isVoiceRecording |
|---|
| 694 | + ? null // disable Send while recording |
|---|
| 695 | + : () { |
|---|
| 696 | + if (voicePath != null) { |
|---|
| 697 | + Navigator.pop(ctx, '__voice__:$voicePath'); |
|---|
| 698 | + } else { |
|---|
| 699 | + Navigator.pop(ctx, captionController.text); |
|---|
| 700 | + } |
|---|
| 701 | + }, |
|---|
| 666 | 702 | child: const Text('Send'), |
|---|
| 667 | 703 | ), |
|---|
| 668 | 704 | ], |
|---|
| .. | .. |
|---|
| 673 | 709 | ), |
|---|
| 674 | 710 | ), |
|---|
| 675 | 711 | ); |
|---|
| 712 | + |
|---|
| 713 | + // Safety net: clean up recording if sheet dismissed by swipe/tap outside |
|---|
| 714 | + if (isVoiceRecording) { |
|---|
| 715 | + await AudioService.cancelRecording(); |
|---|
| 716 | + } |
|---|
| 717 | + |
|---|
| 718 | + captionController.dispose(); |
|---|
| 719 | + return result; |
|---|
| 676 | 720 | } |
|---|
| 677 | 721 | |
|---|
| 678 | 722 | void _clearChat() { |
|---|