Files
mywhatsapp/whatsapp_app/lib/controllers/chat_controller.dart

317 lines
10 KiB
Dart

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<WhatsAppService>();
final messages = <MessageModel>[].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<ConversationsController>().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<void> loadMessages() async {
isLoading.value = true;
try {
final res = await _svc.getMessages(conversation.id);
if (res['type'] == 'messages') {
final List<dynamic> data = res['data'] ?? [];
final fetched = data.map((m) => MessageModel.fromJson(m as Map<String, dynamic>)).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<void> 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<String, dynamic>);
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<void> 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<String, dynamic>);
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<void> markAsRead() async {
try {
await _svc.markRead(conversation.id);
} catch (e) {
print('[MARK READ ERROR] $e');
}
}
// ── Push Event Handler ───────────────────────────────────────────────────
void _onPushEvent(Map<String, dynamic> 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<String, dynamic>?;
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?;
final ack = event['ack'] as int?;
if (chatId == null || messageId == null || ack == null) return;
if (chatId == conversation.id) {
final index = messages.indexWhere((m) => m.id == messageId);
if (index != -1) {
messages[index] = messages[index].copyWith(ack: ack);
}
}
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<dynamic> get groupedMessages {
final list = <dynamic>[];
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<void> 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<void> 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<void> cancelRecording() async {
try {
_recordTimer?.cancel();
await audioRecord.stop();
isRecording.value = false;
recordDuration.value = 0;
} catch (e) {
print('[CANCEL RECORDING ERROR] $e');
}
}
}