import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:intl/intl.dart'; import 'package:record/record.dart'; import 'package:path_provider/path_provider.dart'; import '../services/whatsapp_service.dart'; import '../models/conversation_model.dart'; import '../models/message_model.dart'; import 'conversations_controller.dart'; class ChatController extends GetxController { final ConversationModel conversation; final WhatsAppService _svc = Get.find(); final messages = [].obs; final isLoading = false.obs; final isSending = false.obs; final inputCtrl = TextEditingController(); final scrollCtrl = ScrollController(); final hasText = false.obs; // Recording State final audioRecord = AudioRecorder(); final isRecording = false.obs; final recordDuration = 0.obs; Timer? _recordTimer; StreamSubscription? _eventSub; ChatController({required this.conversation}); @override void onInit() { super.onInit(); _svc.activeChatId.value = conversation.id; // Instantly clear the unread count badge in the UI try { Get.find().clearUnreadCount(conversation.id); } catch (_) {} inputCtrl.addListener(() { hasText.value = inputCtrl.text.trim().isNotEmpty; }); loadMessages(); markAsRead(); // Listen to push events for new messages and message delivery updates _eventSub = _svc.events.listen(_onPushEvent); } @override void onClose() { if (_svc.activeChatId.value == conversation.id) { _svc.activeChatId.value = null; } _eventSub?.cancel(); _recordTimer?.cancel(); audioRecord.dispose(); inputCtrl.dispose(); scrollCtrl.dispose(); super.onClose(); } // ── Load Messages ──────────────────────────────────────────────────────── Future loadMessages() async { isLoading.value = true; try { final res = await _svc.getMessages(conversation.id); if (res['type'] == 'messages') { final List data = res['data'] ?? []; final fetched = data.map((m) => MessageModel.fromJson(m as Map)).toList(); // Sort chronologically (oldest to newest) fetched.sort((a, b) => a.timestamp.compareTo(b.timestamp)); messages.assignAll(fetched); // Scroll to bottom after list is rendered _scrollToBottom(); } } catch (e) { print('[LOAD MESSAGES ERROR] $e'); } finally { isLoading.value = false; } } // ── Send Message ───────────────────────────────────────────────────────── Future sendMessage() async { final text = inputCtrl.text.trim(); if (text.isEmpty || isSending.value) return; isSending.value = true; inputCtrl.clear(); try { final res = await _svc.sendMessage(conversation.id, text); if (res['type'] == 'message_sent') { final sentMsg = MessageModel.fromJson(res['data'] as Map); messages.add(sentMsg); _scrollToBottom(); } } catch (e) { print('[SEND MESSAGE ERROR] $e'); Get.snackbar('Error', 'Failed to send message: $e', backgroundColor: Colors.redAccent.withOpacity(0.8), colorText: Colors.white, ); } finally { isSending.value = false; } } // ── Send Media Message ─────────────────────────────────────────────────── Future sendMediaMessage(String base64, String mimetype, String filename, {String? caption}) async { isSending.value = true; try { final res = await _svc.sendMedia(conversation.id, base64, mimetype, filename, caption: caption); if (res['type'] == 'message_sent') { final sentMsg = MessageModel.fromJson(res['data'] as Map); messages.add(sentMsg); _scrollToBottom(); // Also pre-cache local base64 in mediaCache to display instantly _svc.mediaCache[sentMsg.id] = base64; } else { Get.snackbar('Error', res['message'] ?? 'Failed to send media', backgroundColor: Colors.redAccent.withOpacity(0.8), colorText: Colors.white, ); } } catch (e) { print('[SEND MEDIA ERROR] $e'); Get.snackbar('Error', 'Failed to send media: $e', backgroundColor: Colors.redAccent.withOpacity(0.8), colorText: Colors.white, ); } finally { isSending.value = false; } } // ── Mark Chat as Read ──────────────────────────────────────────────────── Future markAsRead() async { try { await _svc.markRead(conversation.id); } catch (e) { print('[MARK READ ERROR] $e'); } } // ── Push Event Handler ─────────────────────────────────────────────────── void _onPushEvent(Map event) { final type = event['type'] as String?; if (type == null) return; switch (type) { case 'new_message': final chatId = event['chatId'] as String?; final msgData = event['data'] as Map?; if (chatId == null || msgData == null) return; // If the new message is for this chat if (chatId == conversation.id) { final newMsg = MessageModel.fromJson(msgData); // Prevent duplicates just in case if (!messages.any((m) => m.id == newMsg.id)) { messages.add(newMsg); _scrollToBottom(); markAsRead(); // Mark as read since user is actively viewing } } break; case 'message_ack': final messageId = event['messageId'] as String?; final chatId = event['chatId'] as String?; // ack can arrive as int or double from JSON — handle both final rawAck = event['ack']; final ack = rawAck is int ? rawAck : rawAck is double ? rawAck.toInt() : null; if (chatId == null || messageId == null || ack == null) return; if (chatId == conversation.id) { final index = messages.indexWhere((m) => m.id == messageId); if (index != -1) { // Force a list rebuild so Obx re-renders the bubble final updated = messages[index].copyWith(ack: ack); final newList = List.from(messages); newList[index] = updated; messages.assignAll(newList); } } break; } } // ── Helper: Scroll to Bottom ───────────────────────────────────────────── void _scrollToBottom() { WidgetsBinding.instance.addPostFrameCallback((_) => _performScroll()); // Also trigger after a short delay to account for async layout calculations Future.delayed(const Duration(milliseconds: 150), () { _performScroll(); }); } void _performScroll() { if (scrollCtrl.hasClients) { scrollCtrl.animateTo( scrollCtrl.position.maxScrollExtent, duration: const Duration(milliseconds: 250), curve: Curves.easeOut, ); } } // ── Date Separator Logic ───────────────────────────────────────────────── List get groupedMessages { final list = []; if (messages.isEmpty) return list; String? lastDate; for (final msg in messages) { final date = _formatDateSeparator(msg.timestamp); if (date != lastDate) { list.add(date); lastDate = date; } list.add(msg); } return list; } String _formatDateSeparator(int timestamp) { final dt = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000); final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); final yesterday = today.subtract(const Duration(days: 1)); final msgDate = DateTime(dt.year, dt.month, dt.day); if (msgDate == today) { return 'Today'; } else if (msgDate == yesterday) { return 'Yesterday'; } else { return DateFormat('MMMM d, yyyy').format(dt); } } // ── Audio Recording Engine ─────────────────────────────────────────────── Future startRecording() async { try { if (await audioRecord.hasPermission()) { final tempDir = await getTemporaryDirectory(); final path = '${tempDir.path}/rec_${DateTime.now().millisecondsSinceEpoch}.m4a'; await audioRecord.start( const RecordConfig(encoder: AudioEncoder.aacLc), path: path, ); recordDuration.value = 0; isRecording.value = true; _recordTimer?.cancel(); _recordTimer = Timer.periodic(const Duration(seconds: 1), (timer) { recordDuration.value++; }); } else { Get.snackbar( 'Permission Denied', 'Microphone permission is required to record voice notes.', snackPosition: SnackPosition.BOTTOM, backgroundColor: Colors.redAccent.withOpacity(0.8), colorText: Colors.white, ); } } catch (e) { print('[START RECORDING ERROR] $e'); } } Future stopAndSendRecording() async { try { _recordTimer?.cancel(); final path = await audioRecord.stop(); isRecording.value = false; if (path != null && recordDuration.value > 0) { final file = File(path); if (await file.exists()) { final bytes = await file.readAsBytes(); final base64String = base64Encode(bytes); await sendMediaMessage( base64String, 'audio/mp4', // Recorded as M4A (AAC), perfect for all platforms natively! 'voice_note.m4a', ); } } } catch (e) { print('[STOP RECORDING ERROR] $e'); } } Future cancelRecording() async { try { _recordTimer?.cancel(); await audioRecord.stop(); isRecording.value = false; recordDuration.value = 0; } catch (e) { print('[CANCEL RECORDING ERROR] $e'); } } }