Matthias Nott
2026-03-21 fa34201bc07e5312ff0c6825933cd02ce7900254
lib/screens/chat_screen.dart
....@@ -47,14 +47,16 @@
4747 void initState() {
4848 super.initState();
4949 WidgetsBinding.instance.addObserver(this);
50
- _loadLastSeq();
51
- _initConnection();
50
+ _initAll();
5251 _scrollController.addListener(_onScroll);
5352 }
5453
55
- Future<void> _loadLastSeq() async {
54
+ Future<void> _initAll() async {
55
+ // Load lastSeq BEFORE connecting so catch_up sends the right value
5656 final prefs = await SharedPreferences.getInstance();
5757 _lastSeq = prefs.getInt('lastSeq') ?? 0;
58
+ if (!mounted) return;
59
+ _initConnection();
5860 }
5961
6062 void _saveLastSeq() {
....@@ -538,25 +540,8 @@
538540 textCaption = '';
539541 }
540542
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.
560545 if (voiceB64 != null) {
561546 final voiceMsg = Message.voice(
562547 role: MessageRole.user,
....@@ -569,6 +554,20 @@
569554 'audioBase64': voiceB64,
570555 'content': '',
571556 '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,
572571 'sessionId': ref.read(activeSessionIdProvider),
573572 });
574573 }
....@@ -591,8 +590,9 @@
591590 final captionController = TextEditingController();
592591 String? voicePath;
593592 bool isVoiceRecording = false;
593
+ bool hasVoiceCaption = false;
594594
595
- return showModalBottomSheet<String>(
595
+ final result = await showModalBottomSheet<String>(
596596 context: context,
597597 isScrollControlled: true,
598598 builder: (ctx) => StatefulBuilder(
....@@ -611,58 +611,94 @@
611611 style: Theme.of(ctx).textTheme.titleSmall,
612612 ),
613613 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
+ ],
639636 ),
640637 ),
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
+ ),
645651 const SizedBox(height: 12),
652
+ // Action row: mic/stop + cancel + send
646653 Row(
647
- mainAxisAlignment: MainAxisAlignment.end,
648654 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(),
649682 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);
653688 },
654689 child: const Text('Cancel'),
655690 ),
656691 const SizedBox(width: 8),
657692 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
+ },
666702 child: const Text('Send'),
667703 ),
668704 ],
....@@ -673,6 +709,14 @@
673709 ),
674710 ),
675711 );
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;
676720 }
677721
678722 void _clearChat() {