From 3f9117f1c98250188456b314f8b1f460581824c9 Mon Sep 17 00:00:00 2001 From: ChandruWritesCode Date: Sun, 2 Aug 2026 01:58:55 +0530 Subject: [PATCH 1/2] made chat_controller modular bug fixes remaining --- .../lib/cache/database/repository/repo.dart | 0 .../chat/active_chat_controller.dart | 431 ++++++ .../chat/chat_connection_controller.dart | 155 +++ .../chat/chat_search_controller.dart | 52 + .../chat/group_details_controller.dart | 92 ++ .../controllers/chat/inbox_controller.dart | 224 ++++ mobile/lib/controllers/chat_controller.dart | 1185 ----------------- mobile/lib/main.dart | 53 +- mobile/lib/models/inbox_item.dart | 2 +- mobile/lib/pages/chat/chat_details_page.dart | 65 +- mobile/lib/pages/chat/chat_page.dart | 40 +- mobile/lib/pages/home/home_page.dart | 32 +- mobile/lib/pages/new chat/new_chat_page.dart | 35 +- .../pages/new chat/select_contact_page.dart | 29 +- mobile/lib/pages/settings/accounts.dart | 11 +- .../lib/services/chat/chat_event_handler.dart | 303 +++++ .../lib/services/chat/chat_sync_service.dart | 56 + .../database => }/services/db_services.dart | 4 +- mobile/lib/services/ws_service.dart | 14 +- mobile/lib/widgets/home_page_widgets.dart | 20 +- 20 files changed, 1510 insertions(+), 1293 deletions(-) delete mode 100644 mobile/lib/cache/database/repository/repo.dart create mode 100644 mobile/lib/controllers/chat/active_chat_controller.dart create mode 100644 mobile/lib/controllers/chat/chat_connection_controller.dart create mode 100644 mobile/lib/controllers/chat/chat_search_controller.dart create mode 100644 mobile/lib/controllers/chat/group_details_controller.dart create mode 100644 mobile/lib/controllers/chat/inbox_controller.dart delete mode 100644 mobile/lib/controllers/chat_controller.dart create mode 100644 mobile/lib/services/chat/chat_event_handler.dart create mode 100644 mobile/lib/services/chat/chat_sync_service.dart rename mobile/lib/{cache/database => }/services/db_services.dart (96%) diff --git a/mobile/lib/cache/database/repository/repo.dart b/mobile/lib/cache/database/repository/repo.dart deleted file mode 100644 index e69de29..0000000 diff --git a/mobile/lib/controllers/chat/active_chat_controller.dart b/mobile/lib/controllers/chat/active_chat_controller.dart new file mode 100644 index 0000000..1a11bfe --- /dev/null +++ b/mobile/lib/controllers/chat/active_chat_controller.dart @@ -0,0 +1,431 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:sqflite_sqlcipher/sqflite.dart'; +import 'package:uuid/uuid.dart'; +import '../../models/message.dart'; +import '../../services/api_services.dart'; +import '../../services/ws_service.dart'; +import '../../services/db_services.dart'; + +class ActiveChatController extends ChangeNotifier { + final ApiService _api = ApiService(); + final WebSocketService _ws = WebSocketService(); + final Uuid _uuid = const Uuid(); + + List activeChat = []; + String? currentChatUserId; + bool isPeerTyping = false; + bool isPeerOnline = false; + bool isChatHistoryLoading = false; + bool isCurrentChatGroup = false; + int chatOpenCount = 0; + + void refreshUI() { + notifyListeners(); + } + + Future openChat(String targetUid, {bool isGroup = false}) async { + if (targetUid.isEmpty || targetUid == 'null') return; + + if (currentChatUserId != targetUid) { + activeChat.clear(); + isChatHistoryLoading = true; + chatOpenCount = 0; + } + + chatOpenCount++; + currentChatUserId = targetUid; + isCurrentChatGroup = isGroup; + isPeerTyping = false; + isPeerOnline = false; + notifyListeners(); + + // 1. Load Local DB Messages + try { + final db = await DatabaseHelper.instance.database; + final localData = await db.query( + 'messages', + where: 'chat_id = ?', + whereArgs: [targetUid], + orderBy: 'created_at DESC', + limit: 50, + ); + + if (localData.isNotEmpty && currentChatUserId == targetUid) { + activeChat = localData + .map( + (row) => Message( + id: row['id'] as String, + senderId: row['sender_id'] as String, + receiverId: targetUid, + content: row['content'] as String, + createdAt: DateTime.fromMillisecondsSinceEpoch( + row['created_at'] as int, + ), + isRead: (row['is_read'] as int) == 1, + replyToMessageId: row['reply_to_id'] as String?, + syncStatus: row['sync_status'] as String? ?? 'synced', + ), + ) + .toList() + .reversed + .toList(); + + isChatHistoryLoading = false; + notifyListeners(); + } + } catch (e) { + debugPrint("Local cache read failed: $e"); + } + + // 2. Fetch Network History + try { + final res = await _api.getChatHistory(targetUid, isGroup: isGroup); + if (currentChatUserId != targetUid) return; + + final targetList = _extractDataList(res.data, ['messages']); + final loadedMessages = targetList.reversed + .map((json) => Message.fromJson(json)) + .toList(); + + if (currentChatUserId != targetUid) return; + + if (loadedMessages.isNotEmpty) { + final pendingMessages = activeChat + .where((m) => m.syncStatus == 'pending') + .toList(); + + pendingMessages.removeWhere( + (pending) => loadedMessages.any( + (loaded) => + loaded.id == pending.id || + loaded.content.trim() == pending.content.trim(), + ), + ); + + activeChat = [...loadedMessages, ...pendingMessages]; + + final db = await DatabaseHelper.instance.database; + Batch batch = db.batch(); + for (var msg in loadedMessages) { + batch.insert('messages', { + 'id': msg.id, + 'chat_id': targetUid, + 'sender_id': msg.senderId, + 'content': msg.content, + 'created_at': msg.createdAt.millisecondsSinceEpoch, + 'is_read': msg.isRead ? 1 : 0, + 'reply_to_id': msg.replyToMessageId, + 'sync_status': 'synced', + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + await batch.commit(noResult: true); + } + + _ws.sendReadReceipt( + receiverId: isGroup ? null : targetUid, + groupId: isGroup ? targetUid : null, + ); + + _ws.sendRequestStatus(targetId: targetUid); + } catch (e) { + debugPrint("API Timeline tracking fail: $e"); + } finally { + isChatHistoryLoading = false; + notifyListeners(); + } + } + + Future syncActiveChatSilently() async { + if (currentChatUserId == null) return; + final String targetUid = currentChatUserId!; + + try { + final res = await _api.getChatHistory( + targetUid, + isGroup: isCurrentChatGroup, + ); + if (currentChatUserId != targetUid) return; + + final targetList = _extractDataList(res.data, ['messages']); + final loadedMessages = targetList.reversed + .map((json) => Message.fromJson(json)) + .toList(); + + if (currentChatUserId != targetUid) return; + + if (loadedMessages.isNotEmpty) { + for (int i = 0; i < loadedMessages.length; i++) { + final existingMsg = activeChat.firstWhere( + (m) => m.id == loadedMessages[i].id, + orElse: () => loadedMessages[i], + ); + + if (existingMsg.quotedMessage != null && + loadedMessages[i].quotedMessage != null) { + if (loadedMessages[i].quotedMessage!.senderDisplayName.isEmpty) { + loadedMessages[i] = Message( + id: loadedMessages[i].id, + senderId: loadedMessages[i].senderId, + receiverId: loadedMessages[i].receiverId, + content: loadedMessages[i].content, + createdAt: loadedMessages[i].createdAt, + isRead: loadedMessages[i].isRead, + replyToMessageId: loadedMessages[i].replyToMessageId, + quotedMessage: existingMsg.quotedMessage, + ); + } + } + } + + final pendingMessages = activeChat + .where((m) => m.syncStatus == 'pending') + .toList(); + + pendingMessages.removeWhere( + (pending) => loadedMessages.any( + (loaded) => + loaded.id == pending.id || + loaded.content.trim() == pending.content.trim(), + ), + ); + + final mergedMessages = [...loadedMessages, ...pendingMessages]; + + try { + final db = await DatabaseHelper.instance.database; + Batch batch = db.batch(); + for (var msg in loadedMessages) { + batch.insert('messages', { + 'id': msg.id, + 'chat_id': targetUid, + 'sender_id': msg.senderId, + 'content': msg.content, + 'created_at': msg.createdAt.millisecondsSinceEpoch, + 'is_read': msg.isRead ? 1 : 0, + 'reply_to_id': msg.replyToMessageId, + 'sync_status': 'synced', + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + await batch.commit(noResult: true); + } catch (dbError) { + debugPrint("Silent Sync DB save failed: $dbError"); + } + + bool hasChanges = activeChat.length != mergedMessages.length; + if (!hasChanges && activeChat.isNotEmpty && mergedMessages.isNotEmpty) { + hasChanges = + activeChat.last.id != mergedMessages.last.id || + activeChat.first.id != mergedMessages.first.id; + } + + if (hasChanges) { + activeChat = mergedMessages; + notifyListeners(); + _ws.sendReadReceipt( + receiverId: isCurrentChatGroup ? null : targetUid, + groupId: isCurrentChatGroup ? targetUid : null, + ); + } + } + } catch (e) { + debugPrint("Silent chat sync fail: $e"); + } + } + + Future sendTextMessage( + String text, { + Message? replyingTo, + String? replyingToName, + String? senderId, + }) async { + final cleanContent = text.trim(); + if (currentChatUserId == null || cleanContent.isEmpty) return; + + final targetId = currentChatUserId!; + final clientMessageId = _uuid.v4(); + + QuotedMessage? quoted; + + if (replyingTo != null) { + quoted = QuotedMessage( + id: replyingTo.id, + senderId: replyingTo.senderId, + senderDisplayName: replyingToName ?? 'Unknown', + content: replyingTo.content, + ); + } + + final optimisticMsg = Message( + id: clientMessageId, + senderId: "me", + receiverId: targetId, + content: cleanContent, + createdAt: DateTime.now(), + isRead: false, + replyToMessageId: replyingTo?.id, + quotedMessage: quoted, + syncStatus: 'pending', + ); + + activeChat = [...activeChat, optimisticMsg]; + notifyListeners(); + + try { + await DatabaseHelper.instance.insertMessage({ + 'id': clientMessageId, + 'chat_id': targetId, + 'sender_id': 'me', + 'content': cleanContent, + 'created_at': DateTime.now().millisecondsSinceEpoch, + 'is_read': 0, + 'reply_to_id': replyingTo?.id, + 'sync_status': 'pending', + }); + + final payload = { + 'messageId': clientMessageId, + 'receiverId': isCurrentChatGroup ? null : targetId, + 'groupId': isCurrentChatGroup ? targetId : null, + 'content': cleanContent, + 'replyToMessageId': replyingTo?.id, + }; + + await DatabaseHelper.instance.queueAction( + clientMessageId, + isCurrentChatGroup ? 'send_group_chat' : 'send_chat', + payload, + ); + + isCurrentChatGroup && senderId != null + ? _ws.sendGroupChat( + messageId: clientMessageId, + groupId: targetId, + content: cleanContent, + senderId: senderId, + replyToMessageId: replyingTo?.id, + ) + : _ws.sendChat( + messageId: clientMessageId, + receiverId: targetId, + content: cleanContent, + replyToMessageId: replyingTo?.id, + ); + } catch (e) { + debugPrint("Immediate send failed, message queued: $e"); + } + } + + void sendTypingNotification(bool typing) { + if (currentChatUserId != null) { + _ws.sendTyping( + receiverId: isCurrentChatGroup ? null : currentChatUserId, + groupId: isCurrentChatGroup ? currentChatUserId : null, + isTyping: typing, + ); + } + } + + void closeChat(String closedChatId) { + if (currentChatUserId == closedChatId) { + chatOpenCount--; + if (chatOpenCount <= 0) { + currentChatUserId = null; + isPeerTyping = false; + isPeerOnline = false; + activeChat.clear(); + chatOpenCount = 0; + } + notifyListeners(); + } + } + + List _extractDataList(dynamic data, List fallbackKeys) { + if (data == null) return []; + if (data is List) return data; + if (data is Map) { + if (data['data'] is List) return data['data']; + for (final key in fallbackKeys) { + if (data[key] is List) return data[key]; + } + } + return []; + } + + Future> getLocalMessagesForChat(String chatId) async { + try { + final db = await DatabaseHelper.instance.database; + + final List> maps = await db.query( + 'messages', + where: 'chat_id = ? COLLATE NOCASE', + whereArgs: [chatId], + orderBy: + 'created_at ASC', + ); + + return maps.map((map) => Message.fromJson(map)).toList(); + } catch (e) { + debugPrint("Error fetching local messages for chat search/copy: $e"); + return []; + } + } + + Future markMessageAsSynced(String messageId) async { + final index = activeChat.indexWhere((m) => m.id == messageId); + if (index != -1) { + final existingMsg = activeChat[index]; + activeChat[index] = Message( + id: existingMsg.id, + senderId: existingMsg.senderId, + receiverId: existingMsg.receiverId, + content: existingMsg.content, + createdAt: existingMsg.createdAt, + isRead: existingMsg.isRead, + replyToMessageId: existingMsg.replyToMessageId, + quotedMessage: existingMsg.quotedMessage, + syncStatus: 'synced', + ); + notifyListeners(); + + try { + final db = await DatabaseHelper.instance.database; + await db.update( + 'messages', + {'sync_status': 'synced'}, + where: 'id = ?', + whereArgs: [messageId], + ); + await db.delete( + 'action_queue', + where: 'id = ?', + whereArgs: [messageId], + ); + } catch (e) { + debugPrint("Failed to update DB sync status: $e"); + } + } + } + + void addRealTimeMessage(Message newMsg) { + if (currentChatUserId == null) return; + + bool belongsToCurrentChat = + (isCurrentChatGroup && newMsg.receiverId == currentChatUserId) || + (!isCurrentChatGroup && + (newMsg.senderId == currentChatUserId || + newMsg.receiverId == currentChatUserId)); + + if (belongsToCurrentChat) { + if (!activeChat.any((m) => m.id == newMsg.id)) { + activeChat = [...activeChat, newMsg]; + notifyListeners(); + + _ws.sendReadReceipt( + receiverId: isCurrentChatGroup ? null : currentChatUserId, + groupId: isCurrentChatGroup ? currentChatUserId : null, + ); + } + } + } +} diff --git a/mobile/lib/controllers/chat/chat_connection_controller.dart b/mobile/lib/controllers/chat/chat_connection_controller.dart new file mode 100644 index 0000000..44a58e4 --- /dev/null +++ b/mobile/lib/controllers/chat/chat_connection_controller.dart @@ -0,0 +1,155 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:mobile/services/chat/chat_event_handler.dart'; +import 'package:mobile/services/chat/chat_sync_service.dart'; +import '../../services/ws_service.dart'; +import '../../services/auth_service.dart'; + +class ChatConnectionController extends ChangeNotifier + with WidgetsBindingObserver { + final WebSocketService _ws = WebSocketService(); + final AuthService _auth = AuthService(); + final ChatSyncService _syncService = ChatSyncService(); + final ChatEventHandler eventHandler; + + bool isOffline = false; + bool _isWsConnecting = false; + Timer? _reconnectTimer; + StreamSubscription? _connectivitySubscription; + StreamSubscription? _wsSubscription; + + ChatConnectionController({required this.eventHandler}) { + WidgetsBinding.instance.addObserver(this); + + _connectivitySubscription = Connectivity().onConnectivityChanged.listen(( + result, + ) { + final bool currentlyOffline = result.contains(ConnectivityResult.none); + + if (isOffline != currentlyOffline) { + isOffline = currentlyOffline; + notifyListeners(); + + if (!isOffline) { + connectWebSocket(); + eventHandler.inboxController + .loadInbox(); + } else { + eventHandler.activeChatController.isPeerOnline = false; + eventHandler.activeChatController.isPeerTyping = false; + eventHandler.activeChatController.refreshUI(); + + _ws.disconnect(); + } + } + }); + } + + Future connectWebSocket() async { + if (_ws.isConnected || _isWsConnecting) return; + _isWsConnecting = true; + + try { + _reconnectTimer?.cancel(); + + final token = await _auth.getToken(); + if (token == null) { + _isWsConnecting = false; + return; + } + + await _wsSubscription?.cancel(); + _ws.disconnect(); + + final connected = await _ws.connect(token); + if (!connected) { + _triggerReconnectLoop(); + return; + } + + _syncService.processOfflineQueue(eventHandler.currentUserId); + + final currentChatId = eventHandler.activeChatController.currentChatUserId; + if (currentChatId != null && + !eventHandler.activeChatController.isCurrentChatGroup) { + _ws.sendRequestStatus(targetId: currentChatId); + } + + _wsSubscription = _ws.stream?.listen( + (rawFrame) { + try { + debugPrint("📥 WS Received: $rawFrame"); + final decoded = jsonDecode(rawFrame); + if (decoded is Map) { + eventHandler.handleIncomingEvent(decoded); + } + } catch (e) { + debugPrint("WebSocket payload error: $e"); + } + }, + onError: (err) { + debugPrint("WS Pipeline Error: $err"); + _triggerReconnectLoop(); + }, + onDone: () { + debugPrint("WS Pipeline Closed by Server."); + _triggerReconnectLoop(); + }, + ); + } catch (e) { + debugPrint("WS Setup Error: $e"); + _triggerReconnectLoop(); + } finally { + _isWsConnecting = false; + } + } + + void _triggerReconnectLoop() { + _ws.disconnect(); + _isWsConnecting = false; + + if (eventHandler.activeChatController.isPeerOnline) { + eventHandler.activeChatController.isPeerOnline = false; + eventHandler.activeChatController.refreshUI(); + } + + _reconnectTimer?.cancel(); + _reconnectTimer = Timer(const Duration(seconds: 4), () { + if (!isOffline) { + connectWebSocket(); + } + }); + } + + void disconnectWebSocket() { + _reconnectTimer?.cancel(); + _ws.disconnect(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + connectWebSocket(); + _syncService.processOfflineQueue( + eventHandler.currentUserId, + ); + eventHandler.inboxController + .loadInbox(); + } else if (state == AppLifecycleState.paused) { + _reconnectTimer?.cancel(); + _ws.disconnect(); + } + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _connectivitySubscription?.cancel(); + _wsSubscription?.cancel(); + _reconnectTimer?.cancel(); + _ws.disconnect(); + super.dispose(); + } +} diff --git a/mobile/lib/controllers/chat/chat_search_controller.dart b/mobile/lib/controllers/chat/chat_search_controller.dart new file mode 100644 index 0000000..bc3b11f --- /dev/null +++ b/mobile/lib/controllers/chat/chat_search_controller.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import '../../services/api_services.dart'; + +class ChatSearchController extends ChangeNotifier { + final ApiService _api = ApiService(); + + List contactSearchResults = []; + bool isSearchLoading = false; + + Future queryUsers(String term) async { + final String cleanTerm = term.trim(); + if (cleanTerm.isEmpty || cleanTerm.length < 3) { + contactSearchResults.clear(); + notifyListeners(); + return; + } + + isSearchLoading = true; + notifyListeners(); + + try { + final res = await _api.searchUsers(term); + contactSearchResults = _extractDataList(res.data, ['users']); + } catch (e) { + debugPrint("User query failure: $e"); + contactSearchResults.clear(); + } finally { + isSearchLoading = false; + notifyListeners(); + } + } + + void clearSearch() { + if (contactSearchResults.isNotEmpty || isSearchLoading) { + contactSearchResults.clear(); + isSearchLoading = false; + notifyListeners(); + } + } + + List _extractDataList(dynamic data, List fallbackKeys) { + if (data == null) return []; + if (data is List) return data; + if (data is Map) { + if (data['data'] is List) return data['data']; + for (final key in fallbackKeys) { + if (data[key] is List) return data[key]; + } + } + return []; + } +} diff --git a/mobile/lib/controllers/chat/group_details_controller.dart b/mobile/lib/controllers/chat/group_details_controller.dart new file mode 100644 index 0000000..48ab9cc --- /dev/null +++ b/mobile/lib/controllers/chat/group_details_controller.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:mobile/pages/chat/chat_details_page.dart'; +import '../../services/api_services.dart'; + +class GroupDetailsController extends ChangeNotifier { + final ApiService _api = ApiService(); + + List currentGroupMembers = []; + Map groupMemberNames = {}; + Map userCache = {}; + bool isLoadingDetails = false; + final Set _fetchedGroups = {}; + bool hasFetchedGroup(String groupId) => _fetchedGroups.contains(groupId); + + Future fetchGroupMembers(String groupId) async { + try { + isLoadingDetails = true; + notifyListeners(); + + final response = await _api.getGroupMembers(groupId); + if (response.statusCode == 200) { + final List memberList = _extractDataList(response.data, [ + 'members', + 'data', + ]); + + currentGroupMembers = memberList + .map((json) => ChatMember.fromJson(json)) + .toList(); + + for (var member in memberList) { + final uid = member['user_id'].toString(); + userCache[uid] = member['display_name'] ?? 'Member'; + groupMemberNames[uid] = member['display_name'] ?? 'Unknown'; + } + } + } catch (e) { + debugPrint("Error fetching members: $e"); + } finally { + isLoadingDetails = false; + notifyListeners(); + } + } + + Future preloadGroupMembers(String groupId) async { + if (_fetchedGroups.contains(groupId)) return; + + _fetchedGroups.add(groupId); + + try { + final res = await _api.getGroupMembers(groupId); + final members = _extractDataList(res.data, ['members', 'data']); + + bool updatedCache = false; + for (var m in members) { + final uid = m['user_id'].toString(); + final name = m['display_name'] ?? 'Member'; + + if (userCache[uid] != name) { + userCache[uid] = name; + updatedCache = true; + } + } + + if (updatedCache) notifyListeners(); + } catch (e) { + _fetchedGroups.remove(groupId); + debugPrint("Failed to preload group members for $groupId: $e"); + } + } + + void clearCache() { + currentGroupMembers.clear(); + groupMemberNames.clear(); + userCache.clear(); + _fetchedGroups.clear(); + isLoadingDetails = false; + notifyListeners(); + } + + List _extractDataList(dynamic data, List fallbackKeys) { + if (data == null) return []; + if (data is List) return data; + if (data is Map) { + if (data['data'] is List) return data['data']; + for (final key in fallbackKeys) { + if (data[key] is List) return data[key]; + } + } + return []; + } +} diff --git a/mobile/lib/controllers/chat/inbox_controller.dart b/mobile/lib/controllers/chat/inbox_controller.dart new file mode 100644 index 0000000..f430239 --- /dev/null +++ b/mobile/lib/controllers/chat/inbox_controller.dart @@ -0,0 +1,224 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:sqflite_sqlcipher/sqflite.dart'; +import '../../models/group.dart'; +import '../../models/inbox_item.dart'; +import '../../models/conversation.dart'; +import '../../models/message.dart'; +import '../../services/api_services.dart'; +import '../../services/db_services.dart'; + +class InboxController extends ChangeNotifier { + final ApiService _api = ApiService(); + List inbox = []; + + void clearInbox() { + inbox = []; + } + + Future loadInbox({ + bool isOffline = false, + String? currentChatId, + }) async { + // 1. Load from Local Cache + try { + final db = await DatabaseHelper.instance.database; + final localData = await db.query('inbox', orderBy: 'timestamp DESC'); + if (localData.isNotEmpty) { + inbox = localData.map((map) => InboxItem.fromMap(map)).toList(); + notifyListeners(); + } + } catch (e) { + debugPrint("Failed to load local inbox cache: $e"); + } + + if (isOffline) return; + + // 2. Fetch from Network + try { + final response = await _api.getConversations(); + final rawData = _parseResponse(response.data, ['conversations']); + + List combinedInbox = []; + for (var json in rawData) { + try { + final bool isGroup = + json['is_group'] == true || json['type'] == 'group'; + if (isGroup) { + combinedInbox.add(InboxItem.fromGroup(Group.fromJson(json))); + } else { + combinedInbox.add( + InboxItem.fromConversation(Conversation.fromJson(json)), + ); + } + } catch (e) { + debugPrint("BAD JSON OBJECT: $json"); + } + } + + combinedInbox.sort((a, b) => b.timestamp.compareTo(a.timestamp)); + + List chatsToCatchUp = []; + for (var newConv in combinedInbox) { + final oldConvIndex = inbox.indexWhere((c) => c.id == newConv.id); + if (oldConvIndex == -1 || + inbox[oldConvIndex].timestamp.isBefore(newConv.timestamp)) { + chatsToCatchUp.add(newConv); + } + } + + if (_hasInboxChanged(inbox, combinedInbox)) { + inbox = combinedInbox; + notifyListeners(); + _saveInboxToDb(inbox); + } + + for (var missedChat in chatsToCatchUp) { + if (missedChat.id != currentChatId) { + unawaited( + _backgroundSyncChatHistoryToDb(missedChat.id, missedChat.isGroup), + ); + } + } + } catch (e) { + debugPrint("Network Inbox Read Error: $e"); + } + } + + void updateLocalInboxState( + String chatId, + String lastMessage, + DateTime timestamp, + bool incrementUnread, { + String? senderId, + String syncStatus = 'synced', + bool isRead = false, + }) { + final int index = inbox.indexWhere((item) => item.id == chatId); + + if (index != -1) { + final existingItem = inbox[index]; + existingItem.lastMessage = lastMessage; + existingItem.timestamp = timestamp; + existingItem.lastMessageSender = senderId; + existingItem.lastMessageSyncStatus = syncStatus; + existingItem.lastMessageIsRead = isRead; + + if (incrementUnread) { + existingItem.unreadCount += 1; + } + + inbox.removeAt(index); + inbox.insert(0, existingItem); + + DatabaseHelper.instance.database.then((db) { + db.insert( + 'inbox', + existingItem.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + }); + } else { + unawaited(loadInbox()); + } + notifyListeners(); + } + + Future markInboxItemAsRead(String dbTargetChatId) async { + final int inboxIndex = inbox.indexWhere( + (item) => item.id == dbTargetChatId, + ); + if (inboxIndex != -1) { + inbox[inboxIndex].lastMessageIsRead = true; + notifyListeners(); + + try { + final db = await DatabaseHelper.instance.database; + await db.update( + 'inbox', + {'last_message_is_read': 1}, + where: 'id = ?', + whereArgs: [dbTargetChatId], + ); + } catch (e) { + debugPrint("Failed to update inbox read status: $e"); + } + } + } + + Future _backgroundSyncChatHistoryToDb( + String chatId, + bool isGroup, + ) async { + try { + final res = await _api.getChatHistory(chatId, isGroup: isGroup); + final targetList = _parseResponse(res.data, ['messages']); + final loadedMessages = targetList.reversed + .map((json) => Message.fromJson(json)) + .toList(); + + if (loadedMessages.isEmpty) return; + + final db = await DatabaseHelper.instance.database; + Batch batch = db.batch(); + for (var msg in loadedMessages) { + batch.insert('messages', { + 'id': msg.id, + 'chat_id': chatId, + 'sender_id': msg.senderId, + 'content': msg.content, + 'created_at': msg.createdAt.millisecondsSinceEpoch, + 'is_read': msg.isRead ? 1 : 0, + 'reply_to_id': msg.replyToMessageId, + 'sync_status': 'synced', + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + await batch.commit(noResult: true); + } catch (e) { + debugPrint("Background sync failed for $chatId: $e"); + } + } + + Future _saveInboxToDb(List items) async { + try { + final db = await DatabaseHelper.instance.database; + Batch batch = db.batch(); + for (var item in items) { + batch.insert( + 'inbox', + item.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + await batch.commit(noResult: true); + } catch (dbError) { + debugPrint("Failed to save fresh inbox to DB: $dbError"); + } + } + + bool _hasInboxChanged(List oldList, List newList) { + if (oldList.length != newList.length) return true; + for (int i = 0; i < oldList.length; i++) { + final old = oldList[i]; + final current = newList[i]; + if (old.id != current.id || + old.lastMessage != current.lastMessage || + old.timestamp != current.timestamp || + old.unreadCount != current.unreadCount) { + return true; + } + } + return false; + } + + List _parseResponse(dynamic data, List primaryKeys) { + if (data is List) return data; + if (data is Map) { + for (var key in primaryKeys) { + if (data.containsKey(key) && data[key] is List) return data[key]; + } + if (data.containsKey('data') && data['data'] is List) return data['data']; + } + return []; + } +} diff --git a/mobile/lib/controllers/chat_controller.dart b/mobile/lib/controllers/chat_controller.dart deleted file mode 100644 index e618de3..0000000 --- a/mobile/lib/controllers/chat_controller.dart +++ /dev/null @@ -1,1185 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'package:connectivity_plus/connectivity_plus.dart'; -import 'package:flutter/material.dart'; -import 'package:mobile/controllers/auth_state.dart'; -import 'package:mobile/models/group.dart'; -import 'package:mobile/models/inbox_item.dart'; -import 'package:mobile/pages/chat/chat_details_page.dart'; -import 'package:mobile/cache/database/services/db_services.dart'; -import 'package:sqflite_sqlcipher/sqflite.dart'; -import 'package:uuid/uuid.dart'; -import '../models/message.dart'; -import '../models/conversation.dart'; -import '../services/api_services.dart'; -import '../services/ws_service.dart'; -import '../services/auth_service.dart'; - -class ChatController extends ChangeNotifier with WidgetsBindingObserver { - final ApiService _api = ApiService(); - final WebSocketService _ws = WebSocketService(); - final AuthService _auth = AuthService(); - final AuthState _user = AuthState(); - final Uuid _uuid = const Uuid(); - final Set _fetchedGroups = {}; - - StreamSubscription? _connectivitySubscription; - - List activeChat = []; - List contactSearchResults = []; - List inbox = []; - Map groupMemberNames = {}; - Map userCache = {}; - - String? currentChatUserId; - bool isPeerTyping = false; - bool isPeerOnline = false; - bool isSearchLoading = false; - bool isChatHistoryLoading = false; - bool isCurrentChatGroup = false; - - bool isOffline = false; - - bool _isLoadingDetails = false; - bool get isLoadingDetails => _isLoadingDetails; - - List _currentGroupMembers = []; - List get currentGroupMembers => _currentGroupMembers; - - bool _isWsInitialized = false; - bool _isWsConnecting = false; - - int _chatOpenCount = 0; - - StreamSubscription? _wsSubscription; - Timer? _reconnectTimer; - - ChatController() { - WidgetsBinding.instance.addObserver(this); - - _connectivitySubscription = Connectivity().onConnectivityChanged.listen(( - result, - ) { - final bool currentlyOffline = result.contains(ConnectivityResult.none); - - if (isOffline != currentlyOffline) { - isOffline = currentlyOffline; - notifyListeners(); - - if (!isOffline) { - _connectWebSocket(); - loadInbox(); - } else { - isPeerOnline = false; - isPeerTyping = false; - _ws.disconnect(); - _isWsInitialized = false; - } - } - }); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.resumed) { - _isWsInitialized = false; - _connectWebSocket(); - _processOfflineQueue(); - loadInbox(); - } else if (state == AppLifecycleState.paused) { - _reconnectTimer?.cancel(); - _ws.disconnect(); - _isWsInitialized = false; - } - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - _wsSubscription?.cancel(); - _reconnectTimer?.cancel(); - _connectivitySubscription?.cancel(); - _ws.disconnect(); - super.dispose(); - } - - Future _processOfflineQueue() async { - final db = await DatabaseHelper.instance.database; - - final pendingActions = await db.query( - 'action_queue', - orderBy: 'created_at ASC', - ); - - if (pendingActions.isEmpty) return; - - debugPrint("Processing ${pendingActions.length} queued actions..."); - - for (var action in pendingActions) { - final actionId = action['id'] as String; - final type = action['action_type'] as String; - final payload = jsonDecode(action['payload'] as String); - - try { - if (type == 'send_chat' || type == 'send_group_chat') { - if (_ws.isConnected) { - type == 'send_group_chat' - ? _ws.sendGroupChat( - messageId: payload['messageId'], - groupId: payload['groupId'], - content: payload['content'], - senderId: _user.currentUser?.id ?? 'me', - replyToMessageId: payload['replyToMessageId'], - ) - : _ws.sendChat( - messageId: payload['messageId'], - receiverId: payload['receiverId'], - content: payload['content'], - replyToMessageId: payload['replyToMessageId'], - ); - } - } - } catch (e) { - debugPrint("Failed to process queue action $actionId: $e"); - await db.rawUpdate( - 'UPDATE action_queue SET retry_count = retry_count + 1 WHERE id = ?', - [actionId], - ); - } - } - } - - Future initSession() async { - inbox.clear(); - activeChat.clear(); - contactSearchResults.clear(); - currentChatUserId = null; - isPeerTyping = false; - isPeerOnline = false; - _chatOpenCount = 0; - - if (_isWsInitialized) return; - _isWsInitialized = true; - - _connectWebSocket(); - notifyListeners(); - } - - Future clearSessionData() async { - _ws.disconnect(); - _isWsInitialized = false; - _reconnectTimer?.cancel(); - - inbox.clear(); - activeChat.clear(); - contactSearchResults.clear(); - groupMemberNames.clear(); - userCache.clear(); - _currentGroupMembers.clear(); - currentChatUserId = null; - isPeerTyping = false; - isPeerOnline = false; - _chatOpenCount = 0; - - try { - final db = await DatabaseHelper.instance.database; - - await db.delete('messages'); - await db.delete('inbox'); - await db.delete('action_queue'); - - debugPrint("Local SQLite cache successfully wiped for logout."); - } catch (e) { - debugPrint("CRITICAL: Failed to wipe SQLite DB on logout: $e"); - } - - notifyListeners(); - } - - void _updateLocalInboxState( - String chatId, - String lastMessage, - DateTime timestamp, - bool incrementUnread, { - String? senderId, - String syncStatus = 'synced', - bool isRead = false, - }) { - final int index = inbox.indexWhere((item) => item.id == chatId); - - if (index != -1) { - final existingItem = inbox[index]; - existingItem.lastMessage = lastMessage; - existingItem.timestamp = timestamp; - - existingItem.lastMessageSender = senderId; - existingItem.lastMessageSyncStatus = syncStatus; - existingItem.lastMessageIsRead = isRead; - - if (incrementUnread) { - existingItem.unreadCount += 1; - } - - inbox.removeAt(index); - inbox.insert(0, existingItem); - - DatabaseHelper.instance.database - .then((db) { - db.insert( - 'inbox', - existingItem.toMap(), - conflictAlgorithm: ConflictAlgorithm.replace, - ); - }) - .catchError((e) => debugPrint("Failed to save inbox to DB: $e")); - } else { - unawaited(loadInbox()); - } - - notifyListeners(); - } - - void _connectWebSocket() async { - if (_ws.isConnected || _isWsConnecting) return; - _isWsConnecting = true; - - try { - _reconnectTimer?.cancel(); - - final freshToken = await _auth.getToken(); - if (freshToken == null) { - _isWsConnecting = false; - return; - } - - await _wsSubscription?.cancel(); - _ws.disconnect(); - - final bool connected = await _ws.connect(freshToken); - - if (!connected) { - _triggerReconnectLoop(); - return; - } - - _processOfflineQueue(); - - if (currentChatUserId != null && !isCurrentChatGroup) { - _ws.sendRequestStatus(targetId: currentChatUserId!); - } - - _wsSubscription = _ws.stream?.listen( - (rawFrame) { - try { - final decoded = jsonDecode(rawFrame); - if (decoded is Map) { - _handleIncomingWebSocketEvent(decoded); - } - } catch (e) { - debugPrint("WebSocket payload error: $e"); - } - }, - onError: (err) { - debugPrint("WS Pipeline Error: $err"); - _triggerReconnectLoop(); - }, - onDone: () { - debugPrint("WS Pipeline Closed by Server."); - _triggerReconnectLoop(); - }, - ); - } catch (e) { - debugPrint("WS Setup Error: $e"); - _triggerReconnectLoop(); - } finally { - _isWsConnecting = false; - } - } - - void _triggerReconnectLoop() { - _ws.disconnect(); - _isWsInitialized = false; - _isWsConnecting = false; - - if (isPeerOnline) { - isPeerOnline = false; - notifyListeners(); - } - - _reconnectTimer?.cancel(); - _reconnectTimer = Timer(const Duration(seconds: 4), () { - if (!isOffline) { - _isWsInitialized = true; - _connectWebSocket(); - } - }); - } - - Future _backgroundSyncChatHistoryToDb( - String chatId, - bool isGroup, - ) async { - if (isOffline) return; - - try { - final res = await _api.getChatHistory(chatId, isGroup: isGroup); - final targetList = _extractDataList(res.data, ['messages']); - final loadedMessages = targetList.reversed - .map((json) => Message.fromJson(json)) - .toList(); - - if (loadedMessages.isEmpty) return; - - final db = await DatabaseHelper.instance.database; - Batch batch = db.batch(); - for (var msg in loadedMessages) { - batch.insert('messages', { - 'id': msg.id, - 'chat_id': chatId, - 'sender_id': msg.senderId, - 'content': msg.content, - 'created_at': msg.createdAt.millisecondsSinceEpoch, - 'is_read': msg.isRead ? 1 : 0, - 'reply_to_id': msg.replyToMessageId, - 'sync_status': 'synced', - }, conflictAlgorithm: ConflictAlgorithm.replace); - } - await batch.commit(noResult: true); - } catch (e) { - debugPrint("Background sync failed for $chatId: $e"); - } - } - - Future> getLocalMessagesForChat(String chatId) async { - try { - final db = await DatabaseHelper.instance.database; - final localData = await db.query( - 'messages', - where: 'chat_id = ?', - whereArgs: [chatId], - orderBy: 'created_at DESC', - limit: 50, - offset: 0, - ); - - return localData - .map( - (row) => Message( - id: row['id'] as String, - senderId: row['sender_id'] as String, - receiverId: chatId, - content: row['content'] as String, - createdAt: DateTime.fromMillisecondsSinceEpoch( - row['created_at'] as int, - ), - isRead: (row['is_read'] as int) == 1, - replyToMessageId: row['reply_to_id'] as String?, - syncStatus: row['sync_status'] as String? ?? 'synced', - ), - ) - .toList(); - } catch (e) { - debugPrint("Error fetching local messages: $e"); - return []; - } - } - - Future openChat(String targetUid, {bool isGroup = false}) async { - if (targetUid.isEmpty || targetUid == 'null') return; - - if (currentChatUserId != targetUid) { - activeChat.clear(); - isChatHistoryLoading = true; - _chatOpenCount = 0; - } - - _chatOpenCount++; - currentChatUserId = targetUid; - isCurrentChatGroup = isGroup; - isPeerTyping = false; - isPeerOnline = false; - groupMemberNames.clear(); - notifyListeners(); - - try { - final db = await DatabaseHelper.instance.database; - final localData = await db.query( - 'messages', - where: 'chat_id = ?', - whereArgs: [targetUid], - orderBy: 'created_at DESC', - limit: 50, - offset: 0, - ); - - if (localData.isNotEmpty && currentChatUserId == targetUid) { - activeChat = localData - .map( - (row) => Message( - id: row['id'] as String, - senderId: row['sender_id'] as String, - receiverId: targetUid, - content: row['content'] as String, - createdAt: DateTime.fromMillisecondsSinceEpoch( - row['created_at'] as int, - ), - isRead: (row['is_read'] as int) == 1, - replyToMessageId: row['reply_to_id'] as String?, - syncStatus: row['sync_status'] as String? ?? 'synced', - ), - ) - .toList() - .reversed - .toList(); - - isChatHistoryLoading = false; - notifyListeners(); - } - } catch (e) { - debugPrint("Local cache read failed: $e"); - } - - if (isGroup) { - _api - .getGroupMembers(targetUid) - .then((memberRes) { - if (currentChatUserId != targetUid) return; - final memberList = _extractDataList(memberRes.data, ['members']); - - _currentGroupMembers = memberList - .map((json) => ChatMember.fromJson(json)) - .toList(); - - for (var member in memberList) { - userCache[member['user_id'].toString()] = - member['display_name'] ?? 'Member'; - groupMemberNames[member['user_id'].toString()] = - member['display_name'] ?? 'Unknown'; - } - notifyListeners(); - }) - .catchError((e) => debugPrint("Failed to load group members: $e")); - } - - try { - final res = await _api.getChatHistory(targetUid, isGroup: isGroup); - if (currentChatUserId != targetUid) return; - - final targetList = _extractDataList(res.data, ['messages']); - final loadedMessages = targetList.reversed - .map((json) => Message.fromJson(json)) - .toList(); - - if (currentChatUserId != targetUid) return; - - if (loadedMessages.isNotEmpty) { - final pendingMessages = activeChat - .where((m) => m.syncStatus == 'pending') - .toList(); - - pendingMessages.removeWhere( - (pending) => loadedMessages.any((loaded) => - loaded.id == pending.id || - loaded.content.trim() == pending.content.trim()), - ); - - activeChat = [...loadedMessages, ...pendingMessages]; - - final db = await DatabaseHelper.instance.database; - Batch batch = db.batch(); - for (var msg in loadedMessages) { - batch.insert('messages', { - 'id': msg.id, - 'chat_id': targetUid, - 'sender_id': msg.senderId, - 'content': msg.content, - 'created_at': msg.createdAt.millisecondsSinceEpoch, - 'is_read': msg.isRead ? 1 : 0, - 'reply_to_id': msg.replyToMessageId, - 'sync_status': 'synced', - }, conflictAlgorithm: ConflictAlgorithm.replace); - } - await batch.commit(noResult: true); - } - - _ws.sendReadReceipt( - receiverId: isCurrentChatGroup ? null : targetUid, - groupId: isCurrentChatGroup ? targetUid : null, - ); - - _ws.sendRequestStatus(targetId: targetUid); - unawaited(loadInbox()); - } catch (e) { - debugPrint("API Timeline tracking fail (Offline?): $e"); - } finally { - isChatHistoryLoading = false; - notifyListeners(); - } - } - - Future fetchGroupMembers(String groupId) async { - try { - _isLoadingDetails = true; - notifyListeners(); - - final response = await _api.getGroupMembers(groupId); - - if (response.statusCode == 200) { - final List data = response.data['data'] ?? response.data; - _currentGroupMembers = data - .map((json) => ChatMember.fromJson(json)) - .toList(); - } - } catch (e) { - debugPrint("Error fetching members: $e"); - } finally { - _isLoadingDetails = false; - notifyListeners(); - } - } - - Future queryUsers(String term) async { - final String cleanTerm = term.trim(); - if (cleanTerm.isEmpty || cleanTerm.length < 3) { - contactSearchResults.clear(); - notifyListeners(); - return; - } - - isSearchLoading = true; - notifyListeners(); - - try { - final res = await _api.searchUsers(term); - contactSearchResults = _extractDataList(res.data, ['users']); - } catch (e) { - debugPrint("User query failure: $e"); - contactSearchResults.clear(); - } finally { - isSearchLoading = false; - notifyListeners(); - } - } - - void closeChat(String closedChatId) { - if (currentChatUserId == closedChatId) { - _chatOpenCount--; - - if (_chatOpenCount <= 0) { - currentChatUserId = null; - isPeerTyping = false; - isPeerOnline = false; - activeChat.clear(); - _chatOpenCount = 0; - } - notifyListeners(); - } - } - - Future sendTextMessage( - String text, { - Message? replyingTo, - String? replyingToName, - String? senderId, - }) async { - final cleanContent = text.trim(); - if (currentChatUserId == null || cleanContent.isEmpty) return; - final targetId = currentChatUserId!; - - final clientMessageId = _uuid.v4(); - - QuotedMessage? quoted; - - if (replyingTo != null) { - quoted = QuotedMessage( - id: replyingTo.id, - senderId: replyingTo.senderId, - senderDisplayName: replyingToName ?? 'Unknown', - content: replyingTo.content, - ); - } - - final optimisticMsg = Message( - id: clientMessageId, - senderId: "me", - receiverId: targetId, - content: cleanContent, - createdAt: DateTime.now(), - isRead: false, - replyToMessageId: replyingTo?.id, - quotedMessage: quoted, - syncStatus: 'pending', - ); - - activeChat = [...activeChat, optimisticMsg]; - - _updateLocalInboxState( - targetId, - cleanContent, - optimisticMsg.createdAt, - false, - senderId: 'me', - syncStatus: 'pending', - isRead: false, - ); - notifyListeners(); - - try { - await DatabaseHelper.instance.insertMessage({ - 'id': clientMessageId, - 'chat_id': targetId, - 'sender_id': 'me', - 'content': cleanContent, - 'created_at': DateTime.now().millisecondsSinceEpoch, - 'is_read': 0, - 'reply_to_id': replyingTo?.id, - 'sync_status': 'pending', - }); - - final payload = { - 'messageId': clientMessageId, - 'receiverId': isCurrentChatGroup ? null : targetId, - 'groupId': isCurrentChatGroup ? targetId : null, - 'content': cleanContent, - 'replyToMessageId': replyingTo?.id, - }; - - await DatabaseHelper.instance.queueAction( - clientMessageId, - isCurrentChatGroup ? 'send_group_chat' : 'send_chat', - payload, - ); - - isCurrentChatGroup && senderId != null - ? _ws.sendGroupChat( - messageId: clientMessageId, - groupId: targetId, - content: cleanContent, - senderId: senderId, - replyToMessageId: replyingTo?.id, - ) - : _ws.sendChat( - messageId: clientMessageId, - receiverId: targetId, - content: cleanContent, - replyToMessageId: replyingTo?.id, - ); - unawaited(loadInbox()); - } catch (e) { - debugPrint("Immediate send failed, message queued: $e"); - } - } - - void sendTypingNotification(bool typing) { - if (currentChatUserId != null) { - _ws.sendTyping( - receiverId: isCurrentChatGroup ? null : currentChatUserId, - groupId: isCurrentChatGroup ? currentChatUserId : null, - isTyping: typing, - ); - } - } - - Future _handleIncomingWebSocketEvent(Map data) async { - final String? type = data['type']; - if (type == null) return; - - final String? cleanCurrentChat = currentChatUserId?.trim().toLowerCase(); - - bool isCurrentChat = false; - if (cleanCurrentChat != null) { - if (isCurrentChatGroup) { - final String? eventGroupId = data['group_id'] - ?.toString() - .trim() - .toLowerCase(); - isCurrentChat = (eventGroupId == cleanCurrentChat); - } else { - final String? eventSenderId = - (data['sender_id'] ?? data['sender'] ?? data['receiver_id']) - ?.toString() - .trim() - .toLowerCase(); - isCurrentChat = (eventSenderId == cleanCurrentChat); - } - } - - switch (type) { - case 'user_status': - case 'status': - final String? eventUserId = (data['user_id'] ?? data['id']) - ?.toString() - .trim() - .toLowerCase(); - if (eventUserId == cleanCurrentChat && !isCurrentChatGroup) { - isPeerOnline = data['online'] == true || data['content'] == 'online'; - notifyListeners(); - } - break; - - case 'chat': - case 'message': - final incomingMsg = Message.fromJson(data); - - final String echoId = - data['message_id'] ?? - data['messageId'] ?? - data['client_message_id'] ?? - incomingMsg.id; - - final String dbChatId = data['group_id'] != null - ? data['group_id'].toString() - : incomingMsg.senderId; - - try { - final db = await DatabaseHelper.instance.database; - - final queuedItems = await db.query( - 'action_queue', - where: 'id = ?', - whereArgs: [echoId], - ); - - final String cleanSenderId = incomingMsg.senderId.trim().toLowerCase(); - final String? myId = _user.currentUser?.id.trim().toLowerCase(); - final bool isMe = (cleanSenderId == 'me' || cleanSenderId == myId); - - final bool isOurMessage = queuedItems.isNotEmpty || isMe; - - if (isOurMessage) { - int index = activeChat.indexWhere((m) => m.id == echoId || m.id == incomingMsg.id); - String originalClientId = echoId; - - if (index == -1) { - index = activeChat.lastIndexWhere((m) => - m.syncStatus == 'pending' && - m.content.trim() == incomingMsg.content.trim()); - - if (index != -1) { - originalClientId = activeChat[index].id; - } - } - - if (index != -1) { - await db.delete( - 'action_queue', - where: 'id = ?', - whereArgs: [originalClientId], - ); - - if (originalClientId != incomingMsg.id) { - await db.delete( - 'messages', - where: 'id = ?', - whereArgs: [originalClientId], - ); - } - - await db.insert('messages', { - 'id': incomingMsg.id, - 'chat_id': dbChatId, - 'sender_id': incomingMsg.senderId, - 'content': incomingMsg.content, - 'created_at': incomingMsg.createdAt.millisecondsSinceEpoch, - 'is_read': 1, - 'reply_to_id': incomingMsg.replyToMessageId, - 'sync_status': 'synced', - }, conflictAlgorithm: ConflictAlgorithm.replace); - - activeChat[index] = activeChat[index].copyWith( - id: incomingMsg.id, - syncStatus: 'synced', - ); - activeChat = [...activeChat]; - notifyListeners(); - } else { - await db.insert('messages', { - 'id': incomingMsg.id, - 'chat_id': dbChatId, - 'sender_id': incomingMsg.senderId, - 'content': incomingMsg.content, - 'created_at': incomingMsg.createdAt.millisecondsSinceEpoch, - 'is_read': isCurrentChat ? 1 : 0, - 'reply_to_id': incomingMsg.replyToMessageId, - 'sync_status': 'synced', - }, conflictAlgorithm: ConflictAlgorithm.replace); - - if (isCurrentChat) { - if (!activeChat.any((msg) => msg.id == incomingMsg.id)) { - activeChat = [...activeChat, incomingMsg]; - notifyListeners(); - } - _ws.sendReadReceipt( - receiverId: isCurrentChatGroup ? null : currentChatUserId, - groupId: isCurrentChatGroup ? currentChatUserId : null, - ); - } - } - } else { - await db.insert('messages', { - 'id': incomingMsg.id, - 'chat_id': dbChatId, - 'sender_id': incomingMsg.senderId, - 'content': incomingMsg.content, - 'created_at': incomingMsg.createdAt.millisecondsSinceEpoch, - 'is_read': isCurrentChat ? 1 : 0, - 'reply_to_id': incomingMsg.replyToMessageId, - 'sync_status': 'synced', - }, conflictAlgorithm: ConflictAlgorithm.replace); - - if (isCurrentChat) { - if (!activeChat.any((msg) => msg.id == incomingMsg.id)) { - activeChat = [...activeChat, incomingMsg]; - } - _ws.sendReadReceipt( - receiverId: isCurrentChatGroup ? null : currentChatUserId, - groupId: isCurrentChatGroup ? currentChatUserId : null, - ); - notifyListeners(); - } - } - } catch (e) { - debugPrint("Failed to save incoming message to DB: $e"); - } - - final String cleanSenderId = incomingMsg.senderId.trim().toLowerCase(); - final String? myId = _user.currentUser?.id.trim().toLowerCase(); - final bool isMe = (cleanSenderId == 'me' || cleanSenderId == myId); - - _updateLocalInboxState( - dbChatId, - incomingMsg.content, - incomingMsg.createdAt, - !isCurrentChat, - senderId: isMe ? 'me' : incomingMsg.senderId, - syncStatus: 'synced', - isRead: isCurrentChat, - ); - - break; - - case 'typing': - if (isCurrentChat) { - final bool nowTyping = - data['content'] == 'true' || data['content'] == true; - if (isPeerTyping != nowTyping) { - isPeerTyping = nowTyping; - notifyListeners(); - } - } - break; - - case 'read_receipt': - final String payloadSender = (data['sender_id'] ?? '') - .toString() - .toLowerCase(); - final String payloadReceiver = (data['receiver_id'] ?? '') - .toString() - .toLowerCase(); - final String payloadGroup = (data['group_id'] ?? '') - .toString() - .toLowerCase(); - final String safeChatId = (currentChatUserId ?? '').toLowerCase(); - - bool isRelevantToThisChat = false; - String dbTargetChatId = ""; - - if (safeChatId.isNotEmpty) { - if (isCurrentChatGroup) { - isRelevantToThisChat = (payloadGroup == safeChatId); - dbTargetChatId = payloadGroup; - } else { - isRelevantToThisChat = - (payloadSender == safeChatId || payloadReceiver == safeChatId); - dbTargetChatId = safeChatId; - } - } - - if (dbTargetChatId.isNotEmpty) { - try { - final db = await DatabaseHelper.instance.database; - await db.update( - 'messages', - {'is_read': 1}, - where: 'chat_id = ? COLLATE NOCASE AND sender_id = ?', - whereArgs: [dbTargetChatId, 'me'], - ); - - final int inboxIndex = inbox.indexWhere((item) => item.id == dbTargetChatId); - if (inboxIndex != -1) { - inbox[inboxIndex].lastMessageIsRead = true; - - await db.update( - 'inbox', - {'last_message_is_read': 1}, - where: 'id = ?', - whereArgs: [dbTargetChatId], - ); - } - - } catch (e) { - debugPrint("Failed to update read receipts in DB: $e"); - } - } - - if (isRelevantToThisChat) { - bool updated = false; - - activeChat = activeChat.map((msg) { - final String msgSenderId = msg.senderId.trim().toLowerCase(); - if (!msg.isRead && - (msgSenderId == 'me' || msgSenderId != safeChatId)) { - updated = true; - return msg.copyWith(isRead: true); - } - return msg; - }).toList(); - - if (updated) { - notifyListeners(); - } - } - break; - } - } - - Future syncActiveChatSilently() async { - if (currentChatUserId == null) return; - final String targetUid = currentChatUserId!; - - try { - final res = await _api.getChatHistory( - targetUid, - isGroup: isCurrentChatGroup, - ); - if (currentChatUserId != targetUid) return; - - final targetList = _extractDataList(res.data, ['messages']); - final loadedMessages = targetList.reversed - .map((json) => Message.fromJson(json)) - .toList(); - - if (currentChatUserId != targetUid) return; - - if (loadedMessages.isNotEmpty) { - for (int i = 0; i < loadedMessages.length; i++) { - final existingMsg = activeChat.firstWhere( - (m) => m.id == loadedMessages[i].id, - orElse: () => loadedMessages[i], - ); - - if (existingMsg.quotedMessage != null && - loadedMessages[i].quotedMessage != null) { - if (loadedMessages[i].quotedMessage!.senderDisplayName.isEmpty) { - loadedMessages[i] = Message( - id: loadedMessages[i].id, - senderId: loadedMessages[i].senderId, - receiverId: loadedMessages[i].receiverId, - content: loadedMessages[i].content, - createdAt: loadedMessages[i].createdAt, - isRead: loadedMessages[i].isRead, - replyToMessageId: loadedMessages[i].replyToMessageId, - quotedMessage: existingMsg.quotedMessage, - ); - } - } - } - - final pendingMessages = activeChat - .where((m) => m.syncStatus == 'pending') - .toList(); - - pendingMessages.removeWhere( - (pending) => loadedMessages.any((loaded) => - loaded.id == pending.id || - loaded.content.trim() == pending.content.trim()), - ); - - final mergedMessages = [...loadedMessages, ...pendingMessages]; - - try { - final db = await DatabaseHelper.instance.database; - Batch batch = db.batch(); - for (var msg in loadedMessages) { - batch.insert('messages', { - 'id': msg.id, - 'chat_id': targetUid, - 'sender_id': msg.senderId, - 'content': msg.content, - 'created_at': msg.createdAt.millisecondsSinceEpoch, - 'is_read': msg.isRead ? 1 : 0, - 'reply_to_id': msg.replyToMessageId, - 'sync_status': 'synced', - }, conflictAlgorithm: ConflictAlgorithm.replace); - } - await batch.commit(noResult: true); - } catch (dbError) { - debugPrint("Silent Sync DB save failed: $dbError"); - } - - bool hasChanges = activeChat.length != mergedMessages.length; - if (!hasChanges && activeChat.isNotEmpty && mergedMessages.isNotEmpty) { - hasChanges = - activeChat.last.id != mergedMessages.last.id || - activeChat.first.id != mergedMessages.first.id; - } - - if (hasChanges) { - activeChat = mergedMessages; - notifyListeners(); - _ws.sendReadReceipt( - receiverId: isCurrentChatGroup ? null : targetUid, - groupId: isCurrentChatGroup ? targetUid : null, - ); - } - } - } catch (e) { - debugPrint("Silent chat sync fail: $e"); - } - } - - List _extractDataList(dynamic data, List fallbackKeys) { - if (data == null) return []; - if (data is List) return data; - if (data is Map) { - if (data['data'] is List) return data['data']; - for (final key in fallbackKeys) { - if (data[key] is List) return data[key]; - } - } - return []; - } - - Future loadInbox() async { - try { - final db = await DatabaseHelper.instance.database; - final localData = await db.query('inbox', orderBy: 'timestamp DESC'); - - if (localData.isNotEmpty) { - inbox = localData.map((map) => InboxItem.fromMap(map)).toList(); - notifyListeners(); - } - } catch (e) { - debugPrint("Failed to load local inbox cache: $e"); - } - - if (isOffline) return; - - try { - final response = await _api.getConversations(); - final rawData = _parseResponse(response.data, ['conversations']); - - List combinedInbox = []; - for (var json in rawData) { - try { - final bool isGroup = - json['is_group'] == true || json['type'] == 'group'; - if (isGroup) { - combinedInbox.add(InboxItem.fromGroup(Group.fromJson(json))); - final String? groupId = json['id']; - - if (groupId != null && !_fetchedGroups.contains(groupId)) { - _fetchedGroups.add(groupId); - _api - .getGroupMembers(groupId) - .then((res) { - final members = _extractDataList(res.data, ['members']); - bool updatedCache = false; - for (var m in members) { - final uid = m['user_id'].toString(); - if (userCache[uid] != (m['display_name'] ?? 'Member')) { - userCache[uid] = m['display_name'] ?? 'Member'; - updatedCache = true; - } - } - if (updatedCache) notifyListeners(); - }) - .catchError((_) => _fetchedGroups.remove(groupId)); - } - } else { - combinedInbox.add( - InboxItem.fromConversation(Conversation.fromJson(json)), - ); - } - } catch (e) { - debugPrint("BAD JSON OBJECT: $json"); - } - } - - combinedInbox.sort((a, b) => b.timestamp.compareTo(a.timestamp)); - - List chatsToCatchUp = []; - for (var newConv in combinedInbox) { - final oldConvIndex = inbox.indexWhere((c) => c.id == newConv.id); - - if (oldConvIndex == -1 || - inbox[oldConvIndex].timestamp.isBefore(newConv.timestamp)) { - chatsToCatchUp.add(newConv); - } - } - - if (_hasInboxChanged(inbox, combinedInbox)) { - inbox = combinedInbox; - notifyListeners(); - - if (currentChatUserId != null) { - unawaited(syncActiveChatSilently()); - } - - try { - final db = await DatabaseHelper.instance.database; - Batch batch = db.batch(); - for (var item in inbox) { - batch.insert( - 'inbox', - item.toMap(), - conflictAlgorithm: ConflictAlgorithm.replace, - ); - } - await batch.commit(noResult: true); - } catch (dbError) { - debugPrint("Failed to save fresh inbox to DB: $dbError"); - } - } - - for (var missedChat in chatsToCatchUp) { - if (missedChat.id != currentChatUserId) { - unawaited( - _backgroundSyncChatHistoryToDb(missedChat.id, missedChat.isGroup), - ); - } - } - } catch (e) { - debugPrint( - "Network Inbox Read Error (Ignored because we have local cache): $e", - ); - } - } - - List _parseResponse(dynamic data, List primaryKeys) { - if (data is List) return data; - - if (data is Map) { - for (var key in primaryKeys) { - if (data.containsKey(key) && data[key] is List) return data[key]; - } - if (data.containsKey('data') && data['data'] is List) return data['data']; - } - return []; - } - - bool _hasInboxChanged(List oldList, List newList) { - if (oldList.length != newList.length) return true; - for (int i = 0; i < oldList.length; i++) { - final old = oldList[i]; - final current = newList[i]; - if (old.id != current.id || - old.lastMessage != current.lastMessage || - old.timestamp != current.timestamp || - old.unreadCount != current.unreadCount) { - return true; - } - } - return false; - } -} \ No newline at end of file diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 1bd6594..625d9ce 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -1,13 +1,20 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import 'package:mobile/providers/group_controller_provider.dart'; import 'package:mobile/themes/theme_provider.dart'; -import 'package:provider/provider.dart'; + import 'core/constants.dart'; -import 'controllers/auth_state.dart'; -import 'controllers/chat_controller.dart'; import 'services/auth_service.dart'; import 'pages/auth/login_page.dart'; import 'pages/home/home_page.dart'; +import 'controllers/auth_state.dart'; + +import 'controllers/chat/active_chat_controller.dart'; +import 'controllers/chat/chat_connection_controller.dart'; +import 'controllers/chat/chat_search_controller.dart'; +import 'controllers/chat/group_details_controller.dart'; +import 'controllers/chat/inbox_controller.dart'; +import 'services/chat/chat_event_handler.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -25,8 +32,37 @@ void main() async { providers: [ ChangeNotifierProvider.value(value: themeProvider), ChangeNotifierProvider(create: (_) => AuthState()), - ChangeNotifierProvider(create: (_) => ChatController()), ChangeNotifierProvider(create: (_) => GroupController()), + + ChangeNotifierProvider(create: (_) => InboxController()), + ChangeNotifierProvider(create: (_) => ActiveChatController()), + ChangeNotifierProvider(create: (_) => ChatSearchController()), + ChangeNotifierProvider(create: (_) => GroupDetailsController()), + + ChangeNotifierProxyProvider3< + AuthState, + InboxController, + ActiveChatController, + ChatConnectionController + >( + create: (context) => ChatConnectionController( + eventHandler: ChatEventHandler( + inboxController: context.read(), + activeChatController: context.read(), + currentUserId: context.read().currentUser?.id ?? '', + ), + ), + update: (context, auth, inbox, activeChat, previousConnection) { + return previousConnection ?? + ChatConnectionController( + eventHandler: ChatEventHandler( + inboxController: inbox, + activeChatController: activeChat, + currentUserId: auth.currentUser?.id ?? '', + ), + ); + }, + ), ], child: const MyApp(), ), @@ -72,13 +108,18 @@ class _SessionGatewayState extends State super.dispose(); } + void _initChatSession() { + context.read().connectWebSocket(); + context.read().loadInbox(); + } + void _performInitialAutoLoginCheck() async { final auth = context.read(); final token = await auth.checkAutoLogin(); if (token != null && mounted) { _lastInitializedToken = token; - context.read().initSession(); + _initChatSession(); } if (mounted) { @@ -106,7 +147,7 @@ class _SessionGatewayState extends State if (authState.token != null && authState.token != _lastInitializedToken) { _lastInitializedToken = authState.token; WidgetsBinding.instance.addPostFrameCallback((_) { - context.read().initSession(); + _initChatSession(); }); } diff --git a/mobile/lib/models/inbox_item.dart b/mobile/lib/models/inbox_item.dart index f6b2f0b..cc547f4 100644 --- a/mobile/lib/models/inbox_item.dart +++ b/mobile/lib/models/inbox_item.dart @@ -11,7 +11,7 @@ class InboxItem { final bool isRead; int unreadCount; String? lastMessageSender; - String? lastMessageSyncStatus; // 'pending' or 'synced' + String? lastMessageSyncStatus; bool? lastMessageIsRead; InboxItem({ diff --git a/mobile/lib/pages/chat/chat_details_page.dart b/mobile/lib/pages/chat/chat_details_page.dart index 6940b26..bfaa77c 100644 --- a/mobile/lib/pages/chat/chat_details_page.dart +++ b/mobile/lib/pages/chat/chat_details_page.dart @@ -3,7 +3,9 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:mobile/controllers/auth_state.dart'; -import 'package:mobile/controllers/chat_controller.dart'; +import 'package:mobile/controllers/chat/chat_search_controller.dart'; +import 'package:mobile/controllers/chat/group_details_controller.dart'; +import 'package:mobile/controllers/chat/inbox_controller.dart'; import 'package:mobile/pages/chat/chat_page.dart'; import 'package:mobile/providers/group_controller_provider.dart'; import 'package:provider/provider.dart'; @@ -58,14 +60,14 @@ class _ChatDetailsPageState extends State { super.initState(); if (widget.isGroup) { WidgetsBinding.instance.addPostFrameCallback((_) { - context.read().fetchGroupMembers(widget.chatId); + context.read().fetchGroupMembers(widget.chatId); }); } } @override Widget build(BuildContext context) { - final chatController = context.watch(); + final groupDetailsState = context.watch(); return Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, @@ -83,7 +85,7 @@ class _ChatDetailsPageState extends State { const SizedBox(height: 10), if (widget.isGroup) - _buildGroupMembersSection(chatController) + _buildGroupMembersSection(groupDetailsState) else _buildOneOnOneDetails(), @@ -140,9 +142,7 @@ class _ChatDetailsPageState extends State { mainAxisSize: MainAxisSize.min, children: [Text('0'), Icon(Icons.chevron_right)], ), - onTap: () { - // TODO: Navigate to Media Page (API calls later) - }, + onTap: () {}, ), ); } @@ -199,9 +199,9 @@ class _ChatDetailsPageState extends State { ); } - Widget _buildGroupMembersSection(ChatController chatController) { - final members = chatController.currentGroupMembers; - final isLoading = chatController.isLoadingDetails; + Widget _buildGroupMembersSection(GroupDetailsController groupState) { + final members = groupState.currentGroupMembers; + final isLoading = groupState.isLoadingDetails; final currentUserId = context.read().currentUser?.id; final currentUserMember = members @@ -320,13 +320,6 @@ class _ChatDetailsPageState extends State { ); }, ), - // feels redundent to have a view profile option since we don't have a profile page yet - // ListTile( - // title: Text('View Profile (@${member.username})'), - // onTap: () { - // Navigator.pop(context); - // }, - // ), if (isCurrentUserAdmin && member.userId != context.read().currentUser?.id) @@ -383,9 +376,9 @@ class _ChatDetailsPageState extends State { content: Text('${member.displayName} removed.'), ), ); - context.read().fetchGroupMembers( - widget.chatId, - ); + context + .read() + .fetchGroupMembers(widget.chatId); } else if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -463,12 +456,12 @@ class _AddParticipantSheetState extends State<_AddParticipantSheet> { super.dispose(); } - void _onSearchChanged(String value, ChatController chatState) { + void _onSearchChanged(String value, ChatSearchController searchState) { if (_debounceTimer?.isActive ?? false) _debounceTimer!.cancel(); _debounceTimer = Timer(const Duration(milliseconds: 300), () { if (value.length >= 3) { - chatState.queryUsers(value); + searchState.queryUsers(value); } }); setState(() {}); @@ -478,7 +471,7 @@ class _AddParticipantSheetState extends State<_AddParticipantSheet> { FocusScope.of(context).unfocus(); final groupCtrl = context.read(); - final chatCtrl = context.read(); + final groupDetailsCtrl = context.read(); final success = await groupCtrl.addMemberToGroup(widget.chatId, userId); @@ -486,7 +479,7 @@ class _AddParticipantSheetState extends State<_AddParticipantSheet> { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('$displayName added to the group!')), ); - chatCtrl.fetchGroupMembers(widget.chatId); + groupDetailsCtrl.fetchGroupMembers(widget.chatId); Navigator.pop(context); } else if (mounted) { ScaffoldMessenger.of( @@ -497,14 +490,16 @@ class _AddParticipantSheetState extends State<_AddParticipantSheet> { @override Widget build(BuildContext context) { - final chatState = context.watch(); + final groupDetailsState = context.watch(); + final searchState = context.watch(); + final inboxState = context.watch(); final groupState = context.watch(); final String searchInput = _searchController.text.trim(); final bool isSearching = searchInput.isNotEmpty; final bool hasValidQueryLength = searchInput.length >= 3; - final currentMemberIds = chatState.currentGroupMembers + final currentMemberIds = groupDetailsState.currentGroupMembers .map((m) => m.userId) .toSet(); @@ -529,7 +524,7 @@ class _AddParticipantSheetState extends State<_AddParticipantSheet> { padding: const EdgeInsets.symmetric(horizontal: 16.0), child: TextField( controller: _searchController, - onChanged: (val) => _onSearchChanged(val, chatState), + onChanged: (val) => _onSearchChanged(val, searchState), decoration: InputDecoration( hintText: "Search name or username", prefixIcon: const Icon(Icons.search), @@ -538,7 +533,7 @@ class _AddParticipantSheetState extends State<_AddParticipantSheet> { icon: const Icon(Icons.clear), onPressed: () { _searchController.clear(); - chatState.queryUsers(""); + searchState.queryUsers(""); setState(() {}); }, ) @@ -565,11 +560,11 @@ class _AddParticipantSheetState extends State<_AddParticipantSheet> { Expanded( child: isSearching ? _buildSearchResults( - chatState, + searchState, currentMemberIds, hasValidQueryLength, ) - : _buildRecentContacts(chatState, currentMemberIds), + : _buildRecentContacts(inboxState, currentMemberIds), ), ], ), @@ -580,7 +575,7 @@ class _AddParticipantSheetState extends State<_AddParticipantSheet> { } Widget _buildSearchResults( - ChatController chatState, + ChatSearchController searchState, Set currentMemberIds, bool hasValidQueryLength, ) { @@ -589,11 +584,11 @@ class _AddParticipantSheetState extends State<_AddParticipantSheet> { child: Text("Type at least 3 characters to search..."), ); } - if (chatState.isSearchLoading) { + if (searchState.isSearchLoading) { return const Center(child: CircularProgressIndicator()); } - final results = chatState.contactSearchResults + final results = searchState.contactSearchResults .where((user) => !currentMemberIds.contains(user['id'])) .toList(); @@ -626,10 +621,10 @@ class _AddParticipantSheetState extends State<_AddParticipantSheet> { } Widget _buildRecentContacts( - ChatController chatState, + InboxController inboxState, Set currentMemberIds, ) { - final recents = chatState.inbox + final recents = inboxState.inbox .where( (thread) => !thread.isGroup && !currentMemberIds.contains(thread.id), ) diff --git a/mobile/lib/pages/chat/chat_page.dart b/mobile/lib/pages/chat/chat_page.dart index 72708c7..33b4493 100644 --- a/mobile/lib/pages/chat/chat_page.dart +++ b/mobile/lib/pages/chat/chat_page.dart @@ -5,10 +5,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; import 'package:mobile/controllers/auth_state.dart'; +import 'package:mobile/controllers/chat/active_chat_controller.dart'; +import 'package:mobile/controllers/chat/group_details_controller.dart'; import 'package:mobile/pages/chat/chat_details_page.dart'; import 'package:provider/provider.dart'; import 'package:mobile/widgets/chat_page_widgets.dart'; -import 'package:mobile/controllers/chat_controller.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; class ChatPage extends StatefulWidget { @@ -42,7 +43,7 @@ class _ChatPageState extends State with WidgetsBindingObserver { bool _isNearBottom = true; final Set _selectedIndices = {}; dynamic _replyingToMessage; - late ChatController _chatController; + late ActiveChatController _chatController; late AuthState _authState; Timer? _highlightTimer; @@ -58,11 +59,18 @@ class _ChatPageState extends State with WidgetsBindingObserver { _itemPositionsListener.itemPositions.addListener(_scrollListener); - _chatController = context.read(); + _chatController = context.read(); _authState = context.read(); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { _chatController.openChat(widget.chatUserId, isGroup: widget.isGroup); + if (widget.isGroup) { + final groupState = context.read(); + if (!groupState.hasFetchedGroup(widget.chatUserId)) { + groupState.fetchGroupMembers(widget.chatUserId); + } + } + if (widget.isGroup && widget.isNew) { _sendMessage('Hey Everyone!!'); } @@ -106,7 +114,7 @@ class _ChatPageState extends State with WidgetsBindingObserver { _searchResults.clear(); }); - final chatState = context.read(); + final chatState = context.read(); final bool isTyping = chatState.isPeerTyping; final activeChat = chatState.activeChat; @@ -142,6 +150,7 @@ class _ChatPageState extends State with WidgetsBindingObserver { ); } } + void _scrollListener() { final positions = _itemPositionsListener.itemPositions.value; if (positions.isEmpty) return; @@ -190,7 +199,7 @@ class _ChatPageState extends State with WidgetsBindingObserver { replyName = isMe ? "You" : widget.displayName; } - context.read().sendTextMessage( + context.read().sendTextMessage( text, replyingTo: _replyingToMessage, replyingToName: replyName, @@ -226,7 +235,7 @@ class _ChatPageState extends State with WidgetsBindingObserver { final sortedIndices = _selectedIndices.toList()..sort(); final messages = await context - .read() + .read() .getLocalMessagesForChat(widget.chatUserId); final selectedTexts = sortedIndices @@ -250,7 +259,7 @@ class _ChatPageState extends State with WidgetsBindingObserver { _clearSelection(); } - UserStatus _getPresenceStatusText(ChatController state) { + UserStatus _getPresenceStatusText(ActiveChatController state) { return state.isPeerOnline ? UserStatus.online : UserStatus.offline; } @@ -301,7 +310,7 @@ class _ChatPageState extends State with WidgetsBindingObserver { return; } - final chatState = context.read(); + final chatState = context.read(); final messages = await chatState.getLocalMessagesForChat( widget.chatUserId, ); @@ -319,7 +328,8 @@ class _ChatPageState extends State with WidgetsBindingObserver { @override Widget build(BuildContext context) { - final chatState = context.watch(); + final chatState = context.watch(); + final groupDetailsState = context.watch(); final isSelectionMode = _selectedIndices.isNotEmpty; final theme = Theme.of(context); @@ -572,7 +582,6 @@ class _ChatPageState extends State with WidgetsBindingObserver { final String cleanSenderId = msg.senderId .trim() .toLowerCase(); - widget.chatUserId.trim().toLowerCase(); final bool isMe = cleanSenderId == 'me' || @@ -585,7 +594,8 @@ class _ChatPageState extends State with WidgetsBindingObserver { .trim() .toLowerCase(); final String? displayName = widget.isGroup - ? (chatState.groupMemberNames[senderId] ?? + ? (groupDetailsState + .groupMemberNames[senderId] ?? 'Unknown') : null; @@ -729,8 +739,10 @@ class _ChatPageState extends State with WidgetsBindingObserver { } else if (widget.isGroup) { sender = context - .read() - .groupMemberNames[msg.senderId] ?? + .read() + .groupMemberNames[msg.senderId + .trim() + .toLowerCase()] ?? 'Someone'; } else { sender = widget.displayName; @@ -888,7 +900,7 @@ class _ChatPageState extends State with WidgetsBindingObserver { ChatInputArea( onSendMessage: _sendMessage, onTypingChanged: (isTyping) => context - .read() + .read() .sendTypingNotification(isTyping), ), ], diff --git a/mobile/lib/pages/home/home_page.dart b/mobile/lib/pages/home/home_page.dart index f95bbe5..effb736 100644 --- a/mobile/lib/pages/home/home_page.dart +++ b/mobile/lib/pages/home/home_page.dart @@ -1,7 +1,8 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; -import 'package:mobile/controllers/chat_controller.dart'; +import 'package:mobile/controllers/chat/chat_connection_controller.dart'; +import 'package:mobile/controllers/chat/inbox_controller.dart'; import 'package:mobile/pages/settings/settings_page.dart'; import 'package:mobile/services/auth_service.dart'; import 'package:mobile/widgets/home_page_widgets.dart'; @@ -43,15 +44,16 @@ class _HomePageState extends State { }); WidgetsBinding.instance.addPostFrameCallback((_) async { - final chatController = context.read(); + final connectionController = context.read(); + final inboxContoller = context.read(); final token = await AuthService().getToken(); if (!mounted) return; if (token != null) { - await chatController.initSession(); - chatController.loadInbox(); + await connectionController.connectWebSocket(); + inboxContoller.loadInbox(); } }); } @@ -232,13 +234,16 @@ class _HomePageState extends State { ); } - Widget buildHomeTab(ChatController chatState) { + Widget buildHomeTab( + InboxController inboxState, + ChatConnectionController connectionState, + ) { final theme = Theme.of(context); - final isOffline = chatState.isOffline; + final isOffline = connectionState.isOffline; return RefreshIndicator( color: theme.colorScheme.primary, - onRefresh: _isSelectionMode ? () async {} : () => chatState.loadInbox(), + onRefresh: _isSelectionMode ? () async {} : () => inboxState.loadInbox(), child: CustomScrollView( controller: _scrollController, keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, @@ -395,7 +400,7 @@ class _HomePageState extends State { switch (value) { case 'Select all': setState(() { - for (final thread in chatState.inbox) { + for (final thread in inboxState.inbox) { _selectedChatIds.add(thread.id); } }); @@ -437,7 +442,7 @@ class _HomePageState extends State { ), ), ), - chatState.inbox.isEmpty + inboxState.inbox.isEmpty ? SliverFillRemaining( hasScrollBody: false, child: Center( @@ -450,9 +455,9 @@ class _HomePageState extends State { ), ) : SliverList.builder( - itemCount: chatState.inbox.length, + itemCount: inboxState.inbox.length, itemBuilder: (context, index) { - final thread = chatState.inbox[index]; + final thread = inboxState.inbox[index]; final bool isSelected = _selectedChatIds.contains( thread.id, @@ -489,7 +494,8 @@ class _HomePageState extends State { @override Widget build(BuildContext context) { - final chatState = context.watch(); + final inboxState = context.watch(); + final connectionState = context.watch(); final theme = Theme.of(context); return PopScope( canPop: _selectedChatIds.isEmpty, @@ -515,7 +521,7 @@ class _HomePageState extends State { ), ), ), - buildHomeTab(chatState), + buildHomeTab(inboxState, connectionState), Center( child: Text( "No recent calls", diff --git a/mobile/lib/pages/new chat/new_chat_page.dart b/mobile/lib/pages/new chat/new_chat_page.dart index 30059ec..738e838 100644 --- a/mobile/lib/pages/new chat/new_chat_page.dart +++ b/mobile/lib/pages/new chat/new_chat_page.dart @@ -1,10 +1,12 @@ import 'dart:async'; import 'dart:ui'; import 'package:flutter/material.dart'; +import 'package:mobile/controllers/chat/active_chat_controller.dart'; +import 'package:mobile/controllers/chat/chat_search_controller.dart'; +import 'package:mobile/controllers/chat/inbox_controller.dart'; import 'package:mobile/pages/new%20chat/select_contact_page.dart'; import 'package:mobile/pages/settings/settings_page.dart'; import 'package:provider/provider.dart'; -import 'package:mobile/controllers/chat_controller.dart'; import '../chat/chat_page.dart'; class NewChatPage extends StatefulWidget { @@ -25,11 +27,11 @@ class _NewChatPageState extends State { super.dispose(); } - void _onSearchChanged(String value, ChatController chatState) { + void _onSearchChanged(String value, ChatSearchController searchChat) { if (_debounceTimer?.isActive ?? false) _debounceTimer!.cancel(); _debounceTimer = Timer(const Duration(milliseconds: 300), () { - chatState.queryUsers(value); + searchChat.queryUsers(value); }); } @@ -87,7 +89,7 @@ class _NewChatPageState extends State { onPressed: () { final text = inputController.text.trim(); if (text.length >= 3) { - context.read().queryUsers(text); + context.read().queryUsers(text); setState(() { _searchController.text = text; }); @@ -151,7 +153,9 @@ class _NewChatPageState extends State { @override Widget build(BuildContext context) { - final chatState = context.watch(); + final searchState = context.watch(); + final inboxState = context.watch(); + final activeChatState = context.read(); final String searchInput = _searchController.text.trim(); final bool isSearching = searchInput.isNotEmpty; final bool hasValidQueryLength = searchInput.length >= 3; @@ -198,7 +202,7 @@ class _NewChatPageState extends State { padding: const EdgeInsets.all(12.0), child: TextField( controller: _searchController, - onChanged: (val) => _onSearchChanged(val, chatState), + onChanged: (val) => _onSearchChanged(val, searchState), style: TextStyle(color: Theme.of(context).colorScheme.onSurface), decoration: InputDecoration( hintText: "Name, username or number", @@ -218,7 +222,7 @@ class _NewChatPageState extends State { ), onPressed: () { _searchController.clear(); - chatState.queryUsers(""); + searchState.queryUsers(""); }, ) : null, @@ -248,13 +252,13 @@ class _NewChatPageState extends State { ), ), ) - : chatState.isSearchLoading + : searchState.isSearchLoading ? Center( child: CircularProgressIndicator( color: Theme.of(context).colorScheme.primary, ), ) - : chatState.contactSearchResults.isEmpty + : searchState.contactSearchResults.isEmpty ? Center( child: Text( "No users found", @@ -267,9 +271,10 @@ class _NewChatPageState extends State { ), ) : ListView.builder( - itemCount: chatState.contactSearchResults.length, + itemCount: searchState.contactSearchResults.length, itemBuilder: (context, index) { - final user = chatState.contactSearchResults[index]; + final user = + searchState.contactSearchResults[index]; final String displayName = user['display_name'] ?? 'User'; final String username = user['username'] ?? ''; @@ -307,7 +312,7 @@ class _NewChatPageState extends State { ), onTap: () { final String uid = user['id']; - chatState.openChat(uid); + activeChatState.openChat(uid); Navigator.pushReplacement( context, MaterialPageRoute( @@ -362,8 +367,8 @@ class _NewChatPageState extends State { ), ), ), - if (chatState.inbox.isNotEmpty) - ...chatState.inbox + if (inboxState.inbox.isNotEmpty) + ...inboxState.inbox .where((thread) => !thread.isGroup) .map( (thread) => ListTile( @@ -400,7 +405,7 @@ class _NewChatPageState extends State { ), ), onTap: () { - chatState.openChat(thread.id); + activeChatState.openChat(thread.id); Navigator.pushReplacement( context, MaterialPageRoute( diff --git a/mobile/lib/pages/new chat/select_contact_page.dart b/mobile/lib/pages/new chat/select_contact_page.dart index 57b741c..b370a43 100644 --- a/mobile/lib/pages/new chat/select_contact_page.dart +++ b/mobile/lib/pages/new chat/select_contact_page.dart @@ -1,7 +1,8 @@ import 'dart:async'; import 'dart:ui'; import 'package:flutter/material.dart'; -import 'package:mobile/controllers/chat_controller.dart'; +import 'package:mobile/controllers/chat/chat_search_controller.dart'; +import 'package:mobile/controllers/chat/inbox_controller.dart'; import 'package:mobile/pages/chat/chat_page.dart'; import 'package:mobile/providers/group_controller_provider.dart'; import 'package:provider/provider.dart'; @@ -27,7 +28,7 @@ class _SelectContactPageState extends State { _debounceTimer = Timer(const Duration(milliseconds: 300), () { if (mounted) { - context.read().queryUsers(value); + context.read().queryUsers(value); } }); } @@ -110,7 +111,8 @@ class _SelectContactPageState extends State { @override Widget build(BuildContext context) { - final chatState = context.watch(); + final searchState = context.watch(); + final inboxState = context.watch(); final bool isSearching = _searchController.text.trim().isNotEmpty; return Scaffold( @@ -156,7 +158,9 @@ class _SelectContactPageState extends State { ), onPressed: () { _searchController.clear(); - context.read().queryUsers(""); + context.read().queryUsers( + "", + ); }, ) : null, @@ -193,7 +197,7 @@ class _SelectContactPageState extends State { _isSearchOpen = !_isSearchOpen; if (!_isSearchOpen) { _searchController.clear(); - context.read().queryUsers(""); + context.read().queryUsers(""); } }); }, @@ -232,13 +236,13 @@ class _SelectContactPageState extends State { ), Expanded( child: isSearching - ? (chatState.isSearchLoading + ? (searchState.isSearchLoading ? Center( child: CircularProgressIndicator( color: Theme.of(context).colorScheme.primary, ), ) - : chatState.contactSearchResults.isEmpty + : searchState.contactSearchResults.isEmpty ? Center( child: Text( "No users found (try entering at least 3 characters to search)", @@ -252,9 +256,10 @@ class _SelectContactPageState extends State { ), ) : ListView.builder( - itemCount: chatState.contactSearchResults.length, + itemCount: searchState.contactSearchResults.length, itemBuilder: (context, index) { - final user = chatState.contactSearchResults[index]; + final user = + searchState.contactSearchResults[index]; final String id = (user['id'] ?? user['user_id'] ?? '') .toString(); @@ -316,8 +321,8 @@ class _SelectContactPageState extends State { ), ), ), - if (chatState.inbox.isNotEmpty) - ...chatState.inbox + if (inboxState.inbox.isNotEmpty) + ...inboxState.inbox .where( (thread) => !thread.isGroup && @@ -471,7 +476,7 @@ class _SelectContactPageState extends State { return; context - .read() + .read() .loadInbox(); Navigator.of( diff --git a/mobile/lib/pages/settings/accounts.dart b/mobile/lib/pages/settings/accounts.dart index d9f9078..1e4ed9f 100644 --- a/mobile/lib/pages/settings/accounts.dart +++ b/mobile/lib/pages/settings/accounts.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:mobile/controllers/auth_state.dart'; -import 'package:mobile/controllers/chat_controller.dart'; +import 'package:mobile/controllers/chat/chat_connection_controller.dart'; +import 'package:mobile/controllers/chat/group_details_controller.dart'; +import 'package:mobile/controllers/chat/inbox_controller.dart'; import 'package:mobile/main.dart'; import 'package:mobile/providers/group_controller_provider.dart'; import 'package:provider/provider.dart'; @@ -182,7 +184,12 @@ class AccountsSettings extends StatelessWidget { await context.read().logout(); if (context.mounted) { - await context.read().clearSessionData(); + context.read().disconnectWebSocket(); + + context.read().clearCache(); + + context.read().clearInbox(); + context.read().clearGroupData(); Navigator.pushAndRemoveUntil( context, diff --git a/mobile/lib/services/chat/chat_event_handler.dart b/mobile/lib/services/chat/chat_event_handler.dart new file mode 100644 index 0000000..f3cc4ef --- /dev/null +++ b/mobile/lib/services/chat/chat_event_handler.dart @@ -0,0 +1,303 @@ +import 'package:flutter/material.dart'; +import 'package:sqflite_sqlcipher/sqflite.dart'; +import 'package:mobile/controllers/chat/active_chat_controller.dart'; +import 'package:mobile/controllers/chat/inbox_controller.dart'; +import '../../models/message.dart'; +import '../db_services.dart'; +import '../../services/ws_service.dart'; + +class ChatEventHandler { + final InboxController inboxController; + final ActiveChatController activeChatController; + final String currentUserId; + final WebSocketService _ws = + WebSocketService(); + + ChatEventHandler({ + required this.inboxController, + required this.activeChatController, + required this.currentUserId, + }); + + Future handleIncomingEvent(Map data) async { + final String? type = data['type']; + if (type == null) return; + + final String? cleanCurrentChat = activeChatController.currentChatUserId + ?.trim() + .toLowerCase(); + bool isCurrentChat = false; + + if (cleanCurrentChat != null) { + if (activeChatController.isCurrentChatGroup) { + final String? eventGroupId = data['group_id'] + ?.toString() + .trim() + .toLowerCase(); + isCurrentChat = (eventGroupId == cleanCurrentChat); + } else { + final String? eventSenderId = + (data['sender_id'] ?? data['sender'] ?? data['receiver_id']) + ?.toString() + .trim() + .toLowerCase(); + isCurrentChat = (eventSenderId == cleanCurrentChat); + } + } + + switch (type) { + case 'user_status': + case 'status': + final String? eventUserId = (data['user_id'] ?? data['id']) + ?.toString() + .trim() + .toLowerCase(); + if (eventUserId == cleanCurrentChat && + !activeChatController.isCurrentChatGroup) { + activeChatController.isPeerOnline = + data['online'] == true || data['content'] == 'online'; + activeChatController.refreshUI(); + } + break; + + case 'chat': + case 'message': + Message incomingMsg; + try { + incomingMsg = Message.fromJson(data); + } catch (e) { + debugPrint("❌ Failed to parse incoming WS message: $e"); + debugPrint("❌ Raw data was: $data"); + return; + } + + final String echoId = + data['message_id'] ?? + data['messageId'] ?? + data['client_message_id'] ?? + incomingMsg.id; + + final String cleanSenderId = incomingMsg.senderId.trim().toLowerCase(); + final String myId = currentUserId.trim().toLowerCase(); + final bool isMe = (cleanSenderId == 'me' || cleanSenderId == myId); + + final String dbChatId = data['group_id'] != null + ? data['group_id'].toString() + : (isMe ? incomingMsg.receiverId : incomingMsg.senderId); + + try { + final db = await DatabaseHelper.instance.database; + final queuedItems = await db.query( + 'action_queue', + where: 'id = ?', + whereArgs: [echoId], + ); + + final bool isOurMessage = queuedItems.isNotEmpty || isMe; + + if (isOurMessage) { + int index = activeChatController.activeChat.indexWhere( + (m) => m.id == echoId || m.id == incomingMsg.id, + ); + String originalClientId = echoId; + + if (index == -1) { + index = activeChatController.activeChat.lastIndexWhere( + (m) => + m.syncStatus == 'pending' && + m.content.trim() == incomingMsg.content.trim(), + ); + if (index != -1) { + originalClientId = activeChatController.activeChat[index].id; + } + } + + if (index != -1) { + await db.delete( + 'action_queue', + where: 'id = ?', + whereArgs: [originalClientId], + ); + if (originalClientId != incomingMsg.id) { + await db.delete( + 'messages', + where: 'id = ?', + whereArgs: [originalClientId], + ); + } + + await db.insert('messages', { + 'id': incomingMsg.id, + 'chat_id': dbChatId, + 'sender_id': incomingMsg.senderId, + 'content': incomingMsg.content, + 'created_at': incomingMsg.createdAt.millisecondsSinceEpoch, + 'is_read': 1, + 'reply_to_id': incomingMsg.replyToMessageId, + 'sync_status': 'synced', + }, conflictAlgorithm: ConflictAlgorithm.replace); + + activeChatController.activeChat[index] = activeChatController + .activeChat[index] + .copyWith(id: incomingMsg.id, syncStatus: 'synced'); + activeChatController.activeChat = [ + ...activeChatController.activeChat, + ]; + activeChatController.refreshUI(); + } else { + await db.insert('messages', { + 'id': incomingMsg.id, + 'chat_id': dbChatId, + 'sender_id': incomingMsg.senderId, + 'content': incomingMsg.content, + 'created_at': incomingMsg.createdAt.millisecondsSinceEpoch, + 'is_read': isCurrentChat ? 1 : 0, + 'reply_to_id': incomingMsg.replyToMessageId, + 'sync_status': 'synced', + }, conflictAlgorithm: ConflictAlgorithm.replace); + + if (isCurrentChat) { + if (!activeChatController.activeChat.any( + (msg) => msg.id == incomingMsg.id, + )) { + activeChatController.activeChat = [ + ...activeChatController.activeChat, + incomingMsg, + ]; + activeChatController.refreshUI(); + } + _ws.sendReadReceipt( + receiverId: activeChatController.isCurrentChatGroup + ? null + : activeChatController.currentChatUserId, + groupId: activeChatController.isCurrentChatGroup + ? activeChatController.currentChatUserId + : null, + ); + } + } + } else { + await db.insert('messages', { + 'id': incomingMsg.id, + 'chat_id': dbChatId, + 'sender_id': incomingMsg.senderId, + 'content': incomingMsg.content, + 'created_at': incomingMsg.createdAt.millisecondsSinceEpoch, + 'is_read': isCurrentChat ? 1 : 0, + 'reply_to_id': incomingMsg.replyToMessageId, + 'sync_status': 'synced', + }, conflictAlgorithm: ConflictAlgorithm.replace); + + if (isCurrentChat) { + if (!activeChatController.activeChat.any( + (msg) => msg.id == incomingMsg.id, + )) { + activeChatController.activeChat = [ + ...activeChatController.activeChat, + incomingMsg, + ]; + } + _ws.sendReadReceipt( + receiverId: activeChatController.isCurrentChatGroup + ? null + : activeChatController.currentChatUserId, + groupId: activeChatController.isCurrentChatGroup + ? activeChatController.currentChatUserId + : null, + ); + activeChatController.refreshUI(); + } + } + } catch (e) { + debugPrint("Failed to save incoming message to DB: $e"); + } + + inboxController.updateLocalInboxState( + dbChatId, + incomingMsg.content, + incomingMsg.createdAt, + !isCurrentChat && + !isMe, + senderId: isMe ? 'me' : incomingMsg.senderId, + syncStatus: 'synced', + isRead: isCurrentChat || isMe, + ); + break; + + case 'typing': + if (isCurrentChat) { + final bool nowTyping = + data['content'] == 'true' || data['content'] == true; + if (activeChatController.isPeerTyping != nowTyping) { + activeChatController.isPeerTyping = nowTyping; + activeChatController.refreshUI(); + } + } + break; + + case 'read_receipt': + final String payloadSender = (data['sender_id'] ?? '') + .toString() + .toLowerCase(); + final String payloadReceiver = (data['receiver_id'] ?? '') + .toString() + .toLowerCase(); + final String payloadGroup = (data['group_id'] ?? '') + .toString() + .toLowerCase(); + final String safeChatId = (activeChatController.currentChatUserId ?? '') + .toLowerCase(); + + bool isRelevantToThisChat = false; + String dbTargetChatId = ""; + + if (safeChatId.isNotEmpty) { + if (activeChatController.isCurrentChatGroup) { + isRelevantToThisChat = (payloadGroup == safeChatId); + dbTargetChatId = payloadGroup; + } else { + isRelevantToThisChat = + (payloadSender == safeChatId || payloadReceiver == safeChatId); + dbTargetChatId = safeChatId; + } + } + + if (dbTargetChatId.isNotEmpty) { + try { + final db = await DatabaseHelper.instance.database; + await db.update( + 'messages', + {'is_read': 1}, + where: 'chat_id = ? COLLATE NOCASE AND sender_id = ?', + whereArgs: [dbTargetChatId, 'me'], + ); + + inboxController.markInboxItemAsRead(dbTargetChatId); + } catch (e) { + debugPrint("Failed to update read receipts in DB: $e"); + } + } + + if (isRelevantToThisChat) { + bool updated = false; + + activeChatController.activeChat = activeChatController.activeChat.map( + (msg) { + final String msgSenderId = msg.senderId.trim().toLowerCase(); + if (!msg.isRead && + (msgSenderId == 'me' || msgSenderId != safeChatId)) { + updated = true; + return msg.copyWith(isRead: true); + } + return msg; + }, + ).toList(); + + if (updated) { + activeChatController.refreshUI(); + } + } + break; + } + } +} diff --git a/mobile/lib/services/chat/chat_sync_service.dart b/mobile/lib/services/chat/chat_sync_service.dart new file mode 100644 index 0000000..89a8d41 --- /dev/null +++ b/mobile/lib/services/chat/chat_sync_service.dart @@ -0,0 +1,56 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import '../../services/ws_service.dart'; +import '../db_services.dart'; + +class ChatSyncService { + final WebSocketService _ws = WebSocketService(); + + Future processOfflineQueue(String currentUserId) async { + final db = await DatabaseHelper.instance.database; + + final pendingActions = await db.query( + 'action_queue', + orderBy: 'created_at ASC', + ); + + if (pendingActions.isEmpty) return; + + debugPrint("Processing ${pendingActions.length} queued actions..."); + + for (var action in pendingActions) { + final actionId = action['id'] as String; + final type = action['action_type'] as String; + final payload = jsonDecode(action['payload'] as String); + + try { + if (type == 'send_chat' || type == 'send_group_chat') { + if (_ws.isConnected) { + if (type == 'send_group_chat') { + _ws.sendGroupChat( + messageId: payload['messageId'], + groupId: payload['groupId'], + content: payload['content'], + senderId: currentUserId.isNotEmpty ? currentUserId : 'me', + replyToMessageId: payload['replyToMessageId'], + ); + } else { + _ws.sendChat( + messageId: payload['messageId'], + receiverId: payload['receiverId'], + content: payload['content'], + replyToMessageId: payload['replyToMessageId'], + ); + } + } + } + } catch (e) { + debugPrint("Failed to process queue action $actionId: $e"); + await db.rawUpdate( + 'UPDATE action_queue SET retry_count = retry_count + 1 WHERE id = ?', + [actionId], + ); + } + } + } +} diff --git a/mobile/lib/cache/database/services/db_services.dart b/mobile/lib/services/db_services.dart similarity index 96% rename from mobile/lib/cache/database/services/db_services.dart rename to mobile/lib/services/db_services.dart index 789182d..85424a7 100644 --- a/mobile/lib/cache/database/services/db_services.dart +++ b/mobile/lib/services/db_services.dart @@ -21,9 +21,7 @@ class DatabaseHelper { String? key = await _secureStorage.read(key: keyName); if (key == null) { - final secureKey = base64Url.encode( - List.generate(32, (i) => i + 1), - ); + final secureKey = base64Url.encode(List.generate(32, (i) => i + 1)); await _secureStorage.write(key: keyName, value: secureKey); key = secureKey; } diff --git a/mobile/lib/services/ws_service.dart b/mobile/lib/services/ws_service.dart index ff6b81a..2b5f3fa 100644 --- a/mobile/lib/services/ws_service.dart +++ b/mobile/lib/services/ws_service.dart @@ -5,6 +5,14 @@ import 'package:web_socket_channel/web_socket_channel.dart'; import '../core/constants.dart'; class WebSocketService { + static final WebSocketService _instance = WebSocketService._internal(); + + factory WebSocketService() { + return _instance; + } + + WebSocketService._internal(); + WebSocketChannel? _channel; bool _isConnected = false; Timer? _heartbeatTimer; @@ -22,7 +30,7 @@ class WebSocketService { _isConnected = true; debugPrint("WebSocket Pipeline Connected straight to: ${Env.wsBaseUrl}"); - + _startHeartbeat(); return true; } catch (e) { @@ -56,7 +64,7 @@ class WebSocketService { emit({ "type": "chat", "message_id": messageId, - "receiver_id": receiverId, + "receiver_id": receiverId, "content": content, "reply_to_message_id": replyToMessageId, }); @@ -111,4 +119,4 @@ class WebSocketService { _channel = null; debugPrint("WebSocket Pipeline Terminated Cleanly."); } -} \ No newline at end of file +} diff --git a/mobile/lib/widgets/home_page_widgets.dart b/mobile/lib/widgets/home_page_widgets.dart index 9815e05..abe66b9 100644 --- a/mobile/lib/widgets/home_page_widgets.dart +++ b/mobile/lib/widgets/home_page_widgets.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:mobile/controllers/auth_state.dart'; -import 'package:mobile/controllers/chat_controller.dart'; +import 'package:mobile/controllers/chat/group_details_controller.dart'; import 'package:provider/provider.dart'; import '../models/inbox_item.dart'; import '../pages/chat/chat_page.dart'; @@ -36,12 +36,23 @@ class CustomChatCard extends StatelessWidget { } } - @override @override Widget build(BuildContext context) { final String timeLabel = _formatTimestamp(conversation.timestamp); final bool hasUnread = conversation.unreadCount > 0; - final chatState = context.watch(); + + final groupDetailsState = context.watch(); + + if (conversation.isGroup && + !groupDetailsState.hasFetchedGroup(conversation.id)) { + Future.microtask(() { + if (context.mounted) { + context.read().preloadGroupMembers( + conversation.id, + ); + } + }); + } final currentUserId = context.read().currentUser?.id; @@ -59,7 +70,8 @@ class CustomChatCard extends StatelessWidget { senderName = "You"; } else { senderName = - chatState.userCache[conversation.lastMessageSender!] ?? "Member"; + groupDetailsState.userCache[conversation.lastMessageSender!] ?? + "Member"; } } From 67780a2db0325a6a2372bb11d7ab1c6fef78765d Mon Sep 17 00:00:00 2001 From: ChandruWritesCode Date: Sun, 2 Aug 2026 22:59:45 +0530 Subject: [PATCH 2/2] fixed almost all issues but some still remain --- .../chat/active_chat_controller.dart | 29 +- .../controllers/chat/inbox_controller.dart | 3 +- mobile/lib/models/message.dart | 20 +- mobile/lib/pages/chat/chat_page.dart | 68 ++++- .../lib/services/chat/chat_event_handler.dart | 253 ++++++++---------- 5 files changed, 223 insertions(+), 150 deletions(-) diff --git a/mobile/lib/controllers/chat/active_chat_controller.dart b/mobile/lib/controllers/chat/active_chat_controller.dart index 1a11bfe..ef1e2d9 100644 --- a/mobile/lib/controllers/chat/active_chat_controller.dart +++ b/mobile/lib/controllers/chat/active_chat_controller.dart @@ -216,7 +216,8 @@ class ActiveChatController extends ChangeNotifier { if (!hasChanges && activeChat.isNotEmpty && mergedMessages.isNotEmpty) { hasChanges = activeChat.last.id != mergedMessages.last.id || - activeChat.first.id != mergedMessages.first.id; + activeChat.first.id != mergedMessages.first.id || + activeChat.any((m) => m.syncStatus == 'pending'); } if (hasChanges) { @@ -246,7 +247,6 @@ class ActiveChatController extends ChangeNotifier { final clientMessageId = _uuid.v4(); QuotedMessage? quoted; - if (replyingTo != null) { quoted = QuotedMessage( id: replyingTo.id, @@ -311,6 +311,11 @@ class ActiveChatController extends ChangeNotifier { content: cleanContent, replyToMessageId: replyingTo?.id, ); + + if (_ws.isConnected) { + markMessageAsSynced(clientMessageId); + } + } catch (e) { debugPrint("Immediate send failed, message queued: $e"); } @@ -360,8 +365,7 @@ class ActiveChatController extends ChangeNotifier { 'messages', where: 'chat_id = ? COLLATE NOCASE', whereArgs: [chatId], - orderBy: - 'created_at ASC', + orderBy: 'created_at ASC', ); return maps.map((map) => Message.fromJson(map)).toList(); @@ -375,7 +379,10 @@ class ActiveChatController extends ChangeNotifier { final index = activeChat.indexWhere((m) => m.id == messageId); if (index != -1) { final existingMsg = activeChat[index]; - activeChat[index] = Message( + + final newList = List.from(activeChat); + + newList[index] = Message( id: existingMsg.id, senderId: existingMsg.senderId, receiverId: existingMsg.receiverId, @@ -386,6 +393,8 @@ class ActiveChatController extends ChangeNotifier { quotedMessage: existingMsg.quotedMessage, syncStatus: 'synced', ); + + activeChat = newList; notifyListeners(); try { @@ -417,7 +426,9 @@ class ActiveChatController extends ChangeNotifier { newMsg.receiverId == currentChatUserId)); if (belongsToCurrentChat) { - if (!activeChat.any((m) => m.id == newMsg.id)) { + final existingIndex = activeChat.indexWhere((m) => m.id == newMsg.id); + + if (existingIndex == -1) { activeChat = [...activeChat, newMsg]; notifyListeners(); @@ -425,7 +436,11 @@ class ActiveChatController extends ChangeNotifier { receiverId: isCurrentChatGroup ? null : currentChatUserId, groupId: isCurrentChatGroup ? currentChatUserId : null, ); + } else { + if (activeChat[existingIndex].syncStatus == 'pending') { + markMessageAsSynced(newMsg.id); + } } } } -} +} \ No newline at end of file diff --git a/mobile/lib/controllers/chat/inbox_controller.dart b/mobile/lib/controllers/chat/inbox_controller.dart index f430239..593449e 100644 --- a/mobile/lib/controllers/chat/inbox_controller.dart +++ b/mobile/lib/controllers/chat/inbox_controller.dart @@ -204,7 +204,8 @@ class InboxController extends ChangeNotifier { if (old.id != current.id || old.lastMessage != current.lastMessage || old.timestamp != current.timestamp || - old.unreadCount != current.unreadCount) { + old.unreadCount != current.unreadCount || + old.lastMessageSyncStatus != current.lastMessageSyncStatus) { return true; } } diff --git a/mobile/lib/models/message.dart b/mobile/lib/models/message.dart index d2a778e..8f553d5 100644 --- a/mobile/lib/models/message.dart +++ b/mobile/lib/models/message.dart @@ -54,11 +54,23 @@ class Message { factory Message.fromJson(Map json) { DateTime parsedDate = DateTime.now(); - if (json['created_at'] != null) { - if (json['created_at'] is int) { - parsedDate = DateTime.fromMillisecondsSinceEpoch(json['created_at']); + + var dateData = json['created_at'] ?? json['timestamp']; + + if (dateData != null) { + if (dateData is int) { + parsedDate = DateTime.fromMillisecondsSinceEpoch(dateData); } else { - parsedDate = DateTime.parse(json['created_at'].toString()).toLocal(); + String dateString = dateData.toString(); + if (dateString.startsWith('0001-01-01')) { + parsedDate = DateTime.now(); + } else { + try { + parsedDate = DateTime.parse(dateString).toLocal(); + } catch (e) { + parsedDate = DateTime.now(); + } + } } } diff --git a/mobile/lib/pages/chat/chat_page.dart b/mobile/lib/pages/chat/chat_page.dart index 33b4493..112520a 100644 --- a/mobile/lib/pages/chat/chat_page.dart +++ b/mobile/lib/pages/chat/chat_page.dart @@ -7,7 +7,9 @@ import 'package:intl/intl.dart'; import 'package:mobile/controllers/auth_state.dart'; import 'package:mobile/controllers/chat/active_chat_controller.dart'; import 'package:mobile/controllers/chat/group_details_controller.dart'; +import 'package:mobile/controllers/chat/inbox_controller.dart'; import 'package:mobile/pages/chat/chat_details_page.dart'; +import 'package:mobile/services/ws_service.dart'; import 'package:provider/provider.dart'; import 'package:mobile/widgets/chat_page_widgets.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; @@ -46,6 +48,9 @@ class _ChatPageState extends State with WidgetsBindingObserver { late ActiveChatController _chatController; late AuthState _authState; + Timer? _typingDebounce; + bool _isCurrentlyTyping = false; + Timer? _highlightTimer; String? _highlightedMessageId; @@ -91,6 +96,7 @@ class _ChatPageState extends State with WidgetsBindingObserver { }); _highlightTimer?.cancel(); + _typingDebounce?.cancel(); super.dispose(); } @@ -107,6 +113,26 @@ class _ChatPageState extends State with WidgetsBindingObserver { } } + void _handleTypingChange(String text) { + if (text.isNotEmpty && !_isCurrentlyTyping) { + _isCurrentlyTyping = true; + context.read().sendTypingNotification(true); + } else if (text.isEmpty && _isCurrentlyTyping) { + _isCurrentlyTyping = false; + context.read().sendTypingNotification(false); + } + + _typingDebounce?.cancel(); + if (text.isNotEmpty) { + _typingDebounce = Timer(const Duration(seconds: 2), () { + if (mounted && _isCurrentlyTyping) { + _isCurrentlyTyping = false; + context.read().sendTypingNotification(false); + } + }); + } + } + void _scrollToAndHighlight(String messageId) { setState(() { _isSearchMode = false; @@ -206,6 +232,17 @@ class _ChatPageState extends State with WidgetsBindingObserver { senderId: _authState.currentUser!.displayName, ); + final bool isConnected = WebSocketService().isConnected; + context.read().updateLocalInboxState( + widget.chatUserId, + text.trim(), + DateTime.now(), + false, + senderId: 'me', + syncStatus: isConnected ? 'synced' : 'pending', + isRead: false, + ); + setState(() { _replyingToMessage = null; }); @@ -899,9 +936,34 @@ class _ChatPageState extends State with WidgetsBindingObserver { ), ChatInputArea( onSendMessage: _sendMessage, - onTypingChanged: (isTyping) => context - .read() - .sendTypingNotification(isTyping), + onTypingChanged: (isTyping) { + if (isTyping && !_isCurrentlyTyping) { + _isCurrentlyTyping = true; + context + .read() + .sendTypingNotification(true); + } + + _typingDebounce?.cancel(); + if (isTyping) { + _typingDebounce = Timer( + const Duration(seconds: 2), + () { + if (mounted && _isCurrentlyTyping) { + _isCurrentlyTyping = false; + context + .read() + .sendTypingNotification(false); + } + }, + ); + } else if (!isTyping && _isCurrentlyTyping) { + _isCurrentlyTyping = false; + context + .read() + .sendTypingNotification(false); + } + }, ), ], ), diff --git a/mobile/lib/services/chat/chat_event_handler.dart b/mobile/lib/services/chat/chat_event_handler.dart index f3cc4ef..c9f0251 100644 --- a/mobile/lib/services/chat/chat_event_handler.dart +++ b/mobile/lib/services/chat/chat_event_handler.dart @@ -10,8 +10,7 @@ class ChatEventHandler { final InboxController inboxController; final ActiveChatController activeChatController; final String currentUserId; - final WebSocketService _ws = - WebSocketService(); + final WebSocketService _ws = WebSocketService(); ChatEventHandler({ required this.inboxController, @@ -36,12 +35,11 @@ class ChatEventHandler { .toLowerCase(); isCurrentChat = (eventGroupId == cleanCurrentChat); } else { - final String? eventSenderId = - (data['sender_id'] ?? data['sender'] ?? data['receiver_id']) - ?.toString() - .trim() - .toLowerCase(); - isCurrentChat = (eventSenderId == cleanCurrentChat); + final String? eventSenderId = data['sender_id']?.toString().trim().toLowerCase() ?? + data['sender']?.toString().trim().toLowerCase(); + final String? eventReceiverId = data['receiver_id']?.toString().trim().toLowerCase(); + + isCurrentChat = (eventSenderId == cleanCurrentChat || eventReceiverId == cleanCurrentChat); } } @@ -62,28 +60,21 @@ class ChatEventHandler { case 'chat': case 'message': - Message incomingMsg; - try { - incomingMsg = Message.fromJson(data); - } catch (e) { - debugPrint("❌ Failed to parse incoming WS message: $e"); - debugPrint("❌ Raw data was: $data"); - return; - } + final String? incomingId = data['id']?.toString(); + final String echoId = data['message_id']?.toString() ?? + data['messageId']?.toString() ?? + data['client_message_id']?.toString() ?? + incomingId ?? + ''; - final String echoId = - data['message_id'] ?? - data['messageId'] ?? - data['client_message_id'] ?? - incomingMsg.id; - - final String cleanSenderId = incomingMsg.senderId.trim().toLowerCase(); + final String cleanSenderId = (data['sender_id'] ?? data['sender'] ?? '').toString().trim().toLowerCase(); + final String cleanReceiverId = (data['receiver_id'] ?? '').toString().trim().toLowerCase(); final String myId = currentUserId.trim().toLowerCase(); final bool isMe = (cleanSenderId == 'me' || cleanSenderId == myId); final String dbChatId = data['group_id'] != null ? data['group_id'].toString() - : (isMe ? incomingMsg.receiverId : incomingMsg.senderId); + : (isMe ? cleanReceiverId : cleanSenderId); try { final db = await DatabaseHelper.instance.database; @@ -96,16 +87,17 @@ class ChatEventHandler { final bool isOurMessage = queuedItems.isNotEmpty || isMe; if (isOurMessage) { + final String newMsgId = incomingId ?? echoId; + int index = activeChatController.activeChat.indexWhere( - (m) => m.id == echoId || m.id == incomingMsg.id, + (m) => m.id == echoId || m.id == newMsgId, ); String originalClientId = echoId; if (index == -1) { + final content = data['content']?.toString().trim() ?? ''; index = activeChatController.activeChat.lastIndexWhere( - (m) => - m.syncStatus == 'pending' && - m.content.trim() == incomingMsg.content.trim(), + (m) => m.syncStatus == 'pending' && m.content.trim() == content, ); if (index != -1) { originalClientId = activeChatController.activeChat[index].id; @@ -113,115 +105,104 @@ class ChatEventHandler { } if (index != -1) { - await db.delete( - 'action_queue', - where: 'id = ?', - whereArgs: [originalClientId], + final existingMsg = activeChatController.activeChat[index]; + + final newList = List.from(activeChatController.activeChat); + newList[index] = Message( + id: newMsgId, + senderId: existingMsg.senderId, + receiverId: existingMsg.receiverId, + content: existingMsg.content, + createdAt: existingMsg.createdAt, + isRead: existingMsg.isRead, + replyToMessageId: existingMsg.replyToMessageId, + quotedMessage: existingMsg.quotedMessage, + syncStatus: 'synced', ); - if (originalClientId != incomingMsg.id) { - await db.delete( - 'messages', - where: 'id = ?', - whereArgs: [originalClientId], - ); - } + activeChatController.activeChat = newList; + activeChatController.refreshUI(); + + await db.delete('action_queue', where: 'id = ?', whereArgs: [originalClientId]); + if (originalClientId != newMsgId) { + await db.delete('messages', where: 'id = ?', whereArgs: [originalClientId]); + } + await db.insert('messages', { - 'id': incomingMsg.id, + 'id': newMsgId, 'chat_id': dbChatId, - 'sender_id': incomingMsg.senderId, - 'content': incomingMsg.content, - 'created_at': incomingMsg.createdAt.millisecondsSinceEpoch, + 'sender_id': existingMsg.senderId, + 'content': existingMsg.content, + 'created_at': existingMsg.createdAt.millisecondsSinceEpoch, 'is_read': 1, - 'reply_to_id': incomingMsg.replyToMessageId, + 'reply_to_id': existingMsg.replyToMessageId, 'sync_status': 'synced', }, conflictAlgorithm: ConflictAlgorithm.replace); - activeChatController.activeChat[index] = activeChatController - .activeChat[index] - .copyWith(id: incomingMsg.id, syncStatus: 'synced'); + inboxController.updateLocalInboxState( + dbChatId, + existingMsg.content, + existingMsg.createdAt, + false, + senderId: 'me', + syncStatus: 'synced', + isRead: true, + ); + + return; + } + } + + Message incomingMsg; + try { + incomingMsg = Message.fromJson(data); + } catch (e) { + debugPrint("❌ Failed to parse peer WS message: $e"); + return; + } + + await db.insert('messages', { + 'id': incomingMsg.id, + 'chat_id': dbChatId, + 'sender_id': incomingMsg.senderId, + 'content': incomingMsg.content, + 'created_at': incomingMsg.createdAt.millisecondsSinceEpoch, + 'is_read': isCurrentChat ? 1 : 0, + 'reply_to_id': incomingMsg.replyToMessageId, + 'sync_status': 'synced', + }, conflictAlgorithm: ConflictAlgorithm.replace); + + if (isCurrentChat) { + if (!activeChatController.activeChat.any((msg) => msg.id == incomingMsg.id)) { activeChatController.activeChat = [ ...activeChatController.activeChat, + incomingMsg, ]; - activeChatController.refreshUI(); - } else { - await db.insert('messages', { - 'id': incomingMsg.id, - 'chat_id': dbChatId, - 'sender_id': incomingMsg.senderId, - 'content': incomingMsg.content, - 'created_at': incomingMsg.createdAt.millisecondsSinceEpoch, - 'is_read': isCurrentChat ? 1 : 0, - 'reply_to_id': incomingMsg.replyToMessageId, - 'sync_status': 'synced', - }, conflictAlgorithm: ConflictAlgorithm.replace); - - if (isCurrentChat) { - if (!activeChatController.activeChat.any( - (msg) => msg.id == incomingMsg.id, - )) { - activeChatController.activeChat = [ - ...activeChatController.activeChat, - incomingMsg, - ]; - activeChatController.refreshUI(); - } - _ws.sendReadReceipt( - receiverId: activeChatController.isCurrentChatGroup - ? null - : activeChatController.currentChatUserId, - groupId: activeChatController.isCurrentChatGroup - ? activeChatController.currentChatUserId - : null, - ); - } - } - } else { - await db.insert('messages', { - 'id': incomingMsg.id, - 'chat_id': dbChatId, - 'sender_id': incomingMsg.senderId, - 'content': incomingMsg.content, - 'created_at': incomingMsg.createdAt.millisecondsSinceEpoch, - 'is_read': isCurrentChat ? 1 : 0, - 'reply_to_id': incomingMsg.replyToMessageId, - 'sync_status': 'synced', - }, conflictAlgorithm: ConflictAlgorithm.replace); - - if (isCurrentChat) { - if (!activeChatController.activeChat.any( - (msg) => msg.id == incomingMsg.id, - )) { - activeChatController.activeChat = [ - ...activeChatController.activeChat, - incomingMsg, - ]; - } - _ws.sendReadReceipt( - receiverId: activeChatController.isCurrentChatGroup - ? null - : activeChatController.currentChatUserId, - groupId: activeChatController.isCurrentChatGroup - ? activeChatController.currentChatUserId - : null, - ); - activeChatController.refreshUI(); } + _ws.sendReadReceipt( + receiverId: activeChatController.isCurrentChatGroup + ? null + : activeChatController.currentChatUserId, + groupId: activeChatController.isCurrentChatGroup + ? activeChatController.currentChatUserId + : null, + ); + activeChatController.refreshUI(); } + + inboxController.updateLocalInboxState( + dbChatId, + incomingMsg.content, + incomingMsg.createdAt, + !isCurrentChat, + senderId: incomingMsg.senderId, + syncStatus: 'synced', + isRead: isCurrentChat, + ); + } catch (e) { debugPrint("Failed to save incoming message to DB: $e"); } - - inboxController.updateLocalInboxState( - dbChatId, - incomingMsg.content, - incomingMsg.createdAt, - !isCurrentChat && - !isMe, - senderId: isMe ? 'me' : incomingMsg.senderId, - syncStatus: 'synced', - isRead: isCurrentChat || isMe, - ); break; case 'typing': @@ -236,17 +217,10 @@ class ChatEventHandler { break; case 'read_receipt': - final String payloadSender = (data['sender_id'] ?? '') - .toString() - .toLowerCase(); - final String payloadReceiver = (data['receiver_id'] ?? '') - .toString() - .toLowerCase(); - final String payloadGroup = (data['group_id'] ?? '') - .toString() - .toLowerCase(); - final String safeChatId = (activeChatController.currentChatUserId ?? '') - .toLowerCase(); + final String payloadSender = (data['sender_id'] ?? '').toString().toLowerCase(); + final String payloadReceiver = (data['receiver_id'] ?? '').toString().toLowerCase(); + final String payloadGroup = (data['group_id'] ?? '').toString().toLowerCase(); + final String safeChatId = (activeChatController.currentChatUserId ?? '').toLowerCase(); bool isRelevantToThisChat = false; String dbTargetChatId = ""; @@ -271,7 +245,6 @@ class ChatEventHandler { where: 'chat_id = ? COLLATE NOCASE AND sender_id = ?', whereArgs: [dbTargetChatId, 'me'], ); - inboxController.markInboxItemAsRead(dbTargetChatId); } catch (e) { debugPrint("Failed to update read receipts in DB: $e"); @@ -287,7 +260,17 @@ class ChatEventHandler { if (!msg.isRead && (msgSenderId == 'me' || msgSenderId != safeChatId)) { updated = true; - return msg.copyWith(isRead: true); + return Message( + id: msg.id, + senderId: msg.senderId, + receiverId: msg.receiverId, + content: msg.content, + createdAt: msg.createdAt, + isRead: true, + replyToMessageId: msg.replyToMessageId, + quotedMessage: msg.quotedMessage, + syncStatus: msg.syncStatus, + ); } return msg; }, @@ -300,4 +283,4 @@ class ChatEventHandler { break; } } -} +} \ No newline at end of file