Initial commit with Flutter and Node.js code
This commit is contained in:
185
whatsapp_app/lib/controllers/chat_controller.dart
Normal file
185
whatsapp_app/lib/controllers/chat_controller.dart
Normal file
@@ -0,0 +1,185 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../services/whatsapp_service.dart';
|
||||
import '../models/conversation_model.dart';
|
||||
import '../models/message_model.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();
|
||||
|
||||
StreamSubscription? _eventSub;
|
||||
|
||||
ChatController({required this.conversation});
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
loadMessages();
|
||||
markAsRead();
|
||||
|
||||
// Listen to push events for new messages and message delivery updates
|
||||
_eventSub = _svc.events.listen(_onPushEvent);
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
_eventSub?.cancel();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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((_) {
|
||||
if (scrollCtrl.hasClients) {
|
||||
scrollCtrl.animateTo(
|
||||
scrollCtrl.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
165
whatsapp_app/lib/controllers/conversations_controller.dart
Normal file
165
whatsapp_app/lib/controllers/conversations_controller.dart
Normal file
@@ -0,0 +1,165 @@
|
||||
import 'dart:async';
|
||||
import 'package:get/get.dart';
|
||||
import '../services/whatsapp_service.dart';
|
||||
import '../models/conversation_model.dart';
|
||||
|
||||
class ConversationsController extends GetxController {
|
||||
final WhatsAppService _svc = Get.find<WhatsAppService>();
|
||||
|
||||
final conversations = <ConversationModel>[].obs;
|
||||
final isLoading = false.obs;
|
||||
final errorMessage = Rx<String?>(null);
|
||||
|
||||
StreamSubscription? _eventSub;
|
||||
StreamSubscription? _readySub;
|
||||
Timer? _searchDebounce;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
|
||||
// Load conversations initially if already ready
|
||||
if (_svc.isWaReady.value) {
|
||||
loadConversations();
|
||||
}
|
||||
|
||||
// React to WhatsApp ready status changes
|
||||
_readySub = _svc.isWaReady.listen((ready) {
|
||||
if (ready) {
|
||||
loadConversations();
|
||||
} else {
|
||||
conversations.clear();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen to push events from the server
|
||||
_eventSub = _svc.events.listen(_onPushEvent);
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
_eventSub?.cancel();
|
||||
_readySub?.cancel();
|
||||
_searchDebounce?.cancel();
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
// ── Load Conversations ───────────────────────────────────────────────────
|
||||
Future<void> loadConversations() async {
|
||||
if (!_svc.isWaReady.value) return;
|
||||
|
||||
isLoading.value = true;
|
||||
errorMessage.value = null;
|
||||
|
||||
try {
|
||||
final res = await _svc.getConversations();
|
||||
if (res['type'] == 'conversations') {
|
||||
final List<dynamic> data = res['data'] ?? [];
|
||||
conversations.assignAll(data.map((c) => ConversationModel.fromJson(c as Map<String, dynamic>)));
|
||||
} else {
|
||||
errorMessage.value = res['message'] ?? 'Failed to load conversations';
|
||||
}
|
||||
} catch (e) {
|
||||
errorMessage.value = e.toString();
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Search Conversations ──────────────────────────────────────────────────
|
||||
void search(String query) {
|
||||
_searchDebounce?.cancel();
|
||||
if (query.trim().isEmpty) {
|
||||
loadConversations();
|
||||
return;
|
||||
}
|
||||
|
||||
_searchDebounce = Timer(const Duration(milliseconds: 400), () async {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
final res = await _svc.searchConversations(query);
|
||||
if (res['type'] == 'conversations') {
|
||||
final List<dynamic> data = res['data'] ?? [];
|
||||
conversations.assignAll(data.map((c) => ConversationModel.fromJson(c as Map<String, dynamic>)));
|
||||
}
|
||||
} catch (e) {
|
||||
print('[SEARCH ERROR] $e');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Handle Incoming Socket Push Events ──────────────────────────────────
|
||||
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;
|
||||
|
||||
// Create the LastMessage object
|
||||
final lastMsg = LastMessageModel(
|
||||
body: msgData['body'] ?? '',
|
||||
timestamp: msgData['timestamp'] ?? 0,
|
||||
fromMe: msgData['fromMe'] ?? false,
|
||||
hasMedia: msgData['hasMedia'] ?? false,
|
||||
);
|
||||
|
||||
// Find existing conversation and update it
|
||||
final index = conversations.indexWhere((c) => c.id == chatId);
|
||||
if (index != -1) {
|
||||
final existing = conversations[index];
|
||||
final updated = existing.copyWith(
|
||||
lastMessage: lastMsg,
|
||||
timestamp: lastMsg.timestamp,
|
||||
unreadCount: lastMsg.fromMe ? existing.unreadCount : existing.unreadCount + 1,
|
||||
);
|
||||
conversations.removeAt(index);
|
||||
conversations.insert(0, updated);
|
||||
} else {
|
||||
// If conversation is not loaded, trigger a silent full reload to fetch it
|
||||
loadConversationsSilently();
|
||||
}
|
||||
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 the last message in a conversation was acknowledged, update it
|
||||
final index = conversations.indexWhere((c) => c.id == chatId);
|
||||
if (index != -1) {
|
||||
// We can refresh silently if it is the current conversation's last message.
|
||||
// Since ack is simple, a quick silent refresh guarantees correct ack state.
|
||||
loadConversationsSilently();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ready':
|
||||
case 'authenticated':
|
||||
loadConversationsSilently();
|
||||
break;
|
||||
|
||||
case 'disconnected':
|
||||
conversations.clear();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadConversationsSilently() async {
|
||||
if (!_svc.isWaReady.value) return;
|
||||
try {
|
||||
final res = await _svc.getConversations();
|
||||
if (res['type'] == 'conversations') {
|
||||
final List<dynamic> data = res['data'] ?? [];
|
||||
conversations.assignAll(data.map((c) => ConversationModel.fromJson(c as Map<String, dynamic>)));
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user