diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 183f7ca..018e17a 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -9,6 +9,7 @@ # packages, and plugins designed to encourage good coding practices. analyzer: errors: + invalid_return_type_for_catch_error: ignore use_build_context_synchronously: ignore use_null_aware_elements: ignore include: package:flutter_lints/flutter.yaml diff --git a/mobile/lib/controllers/auth.dart b/mobile/lib/controllers/auth.dart index fd113ae..86d88d8 100644 --- a/mobile/lib/controllers/auth.dart +++ b/mobile/lib/controllers/auth.dart @@ -64,13 +64,29 @@ class AuthState extends ChangeNotifier { try { final res = await _authService.getCurrentUser(_token!); final data = res.data; - final userData = data['data'] ?? data['user'] ?? data; _currentUser = UserModel.fromJson(userData); + + await _authService.saveUserProfile(jsonEncode(userData)); notifyListeners(); + } catch (e) { - debugPrint("Failed to load user profile: $e"); + debugPrint("Network failed, attempting to load cached user profile: $e"); + + try { + final cachedData = await _authService.getCachedUserProfile(); + if (cachedData != null) { + final decodedData = jsonDecode(cachedData); + _currentUser = UserModel.fromJson(decodedData); + notifyListeners(); + } else { + _errorMessage = "No internet connection and no cached profile."; + notifyListeners(); + } + } catch (cacheError) { + debugPrint("Cache read failed: $cacheError"); + } } } diff --git a/mobile/lib/controllers/chat.dart b/mobile/lib/controllers/chat.dart index f9bc849..0107748 100644 --- a/mobile/lib/controllers/chat.dart +++ b/mobile/lib/controllers/chat.dart @@ -1,10 +1,14 @@ import 'dart:async'; import 'dart:convert'; +import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/material.dart'; -// import 'package:mobile/controllers/auth.dart'; +import 'package:mobile/controllers/auth.dart'; import 'package:mobile/models/group.dart'; import 'package:mobile/models/inbox_item.dart'; import 'package:mobile/pages/chat_details_page.dart'; +import 'package:mobile/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.dart'; @@ -15,7 +19,11 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { final ApiService _api = ApiService(); final WebSocketService _ws = WebSocketService(); final AuthService _auth = AuthService(); - // final AuthState _user = AuthState(); + final AuthState _user = AuthState(); + final Uuid _uuid = const Uuid(); + final Set _fetchedGroups = {}; + + StreamSubscription? _connectivitySubscription; List activeChat = []; List contactSearchResults = []; @@ -30,6 +38,8 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { bool isChatHistoryLoading = false; bool isCurrentChatGroup = false; + bool isOffline = false; + bool _isLoadingDetails = false; bool get isLoadingDetails => _isLoadingDetails; @@ -37,12 +47,36 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { 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 @@ -50,6 +84,7 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { if (state == AppLifecycleState.resumed) { _isWsInitialized = false; _connectWebSocket(); + _processOfflineQueue(); loadInbox(); } else if (state == AppLifecycleState.paused) { _reconnectTimer?.cancel(); @@ -63,10 +98,57 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { 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(); @@ -74,8 +156,7 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { currentChatUserId = null; isPeerTyping = false; isPeerOnline = false; - - // _startBackgroundSync(); + _chatOpenCount = 0; if (_isWsInitialized) return; _isWsInitialized = true; @@ -84,9 +165,11 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } - void clearSessionData() { + Future clearSessionData() async { _ws.disconnect(); _isWsInitialized = false; + _reconnectTimer?.cancel(); + inbox.clear(); activeChat.clear(); contactSearchResults.clear(); @@ -96,59 +179,265 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { 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; - if (_ws.isConnected) return; + try { + _reconnectTimer?.cancel(); - _reconnectTimer?.cancel(); + final freshToken = await _auth.getToken(); + if (freshToken == null) { + _isWsConnecting = false; + return; + } - final freshToken = await _auth.getToken(); - if (freshToken == null) return; + await _wsSubscription?.cancel(); + _ws.disconnect(); - await _wsSubscription?.cancel(); - _ws.disconnect(); - await _ws.connect(freshToken); + final bool connected = await _ws.connect(freshToken); - _wsSubscription = _ws.stream?.listen( - (rawFrame) { - unawaited(loadInbox()); + if (!connected) { + _triggerReconnectLoop(); + return; + } - try { - final decoded = jsonDecode(rawFrame); - if (decoded is Map) { - _handleIncomingWebSocketEvent(decoded); + _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"); } - } catch (e) { - debugPrint("WebSocket payload error: $e"); - } - }, - onError: (err) => debugPrint("WS Pipeline Error: $err"), - onDone: () { - _ws.disconnect(); - _isWsInitialized = false; - - _reconnectTimer?.cancel(); - _reconnectTimer = Timer(const Duration(seconds: 3), () { - _isWsInitialized = true; - _connectWebSocket(); - }); - }, - ); + }, + 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; - activeChat.clear(); isPeerTyping = false; isPeerOnline = false; - isChatHistoryLoading = true; 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 @@ -169,13 +458,9 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { } notifyListeners(); }) - .catchError((e) { - debugPrint("Failed to load group members: $e"); - }); + .catchError((e) => debugPrint("Failed to load group members: $e")); } - notifyListeners(); - try { final res = await _api.getChatHistory(targetUid, isGroup: isGroup); if (currentChatUserId != targetUid) return; @@ -187,17 +472,45 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { if (currentChatUserId != targetUid) return; - activeChat = loadedMessages; + 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("Timeline tracking fail: $e"); + debugPrint("API Timeline tracking fail (Offline?): $e"); } finally { isChatHistoryLoading = false; notifyListeners(); @@ -248,12 +561,19 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { } } - void closeChat() { - currentChatUserId = null; - isPeerTyping = false; - isPeerOnline = false; - activeChat.clear(); - 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( @@ -265,7 +585,8 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { final cleanContent = text.trim(); if (currentChatUserId == null || cleanContent.isEmpty) return; final targetId = currentChatUserId!; - final clientMessageId = "cli_${DateTime.now().millisecondsSinceEpoch}"; + + final clientMessageId = _uuid.v4(); QuotedMessage? quoted; @@ -287,30 +608,65 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { isRead: false, replyToMessageId: replyingTo?.id, quotedMessage: quoted, + syncStatus: 'pending', + ); + + activeChat = [...activeChat, optimisticMsg]; + + _updateLocalInboxState( + targetId, + cleanContent, + optimisticMsg.createdAt, + false, + senderId: 'me', + syncStatus: 'pending', + isRead: false, ); - activeChat.add(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: "", + messageId: clientMessageId, groupId: targetId, content: cleanContent, senderId: senderId, replyToMessageId: replyingTo?.id, ) : _ws.sendChat( - messageId: "", + messageId: clientMessageId, receiverId: targetId, content: cleanContent, replyToMessageId: replyingTo?.id, ); unawaited(loadInbox()); } catch (e) { - debugPrint("Failed to send message: $e"); - activeChat.removeWhere((msg) => msg.id == clientMessageId); - notifyListeners(); + debugPrint("Immediate send failed, message queued: $e"); } } @@ -324,7 +680,7 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { } } - void _handleIncomingWebSocketEvent(Map data) { + Future _handleIncomingWebSocketEvent(Map data) async { final String? type = data['type']; if (type == null) return; @@ -358,21 +714,148 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { if (eventUserId == cleanCurrentChat && !isCurrentChatGroup) { isPeerOnline = data['online'] == true || data['content'] == 'online'; notifyListeners(); - // if (isPeerOnline) unawaited(syncActiveChatSilently()); } break; case 'chat': case 'message': - if (isCurrentChat) { - activeChat.add(Message.fromJson(data)); - _ws.sendReadReceipt( - receiverId: isCurrentChatGroup ? null : currentChatUserId, - groupId: isCurrentChatGroup ? currentChatUserId : null, + 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], ); - notifyListeners(); + + 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"); } - loadInbox(); + + 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': @@ -382,12 +865,6 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { if (isPeerTyping != nowTyping) { isPeerTyping = nowTyping; notifyListeners(); - - // if (!isPeerTyping) { - // Future.delayed(const Duration(milliseconds: 500), () { - // unawaited(syncActiveChatSilently()); - // }); - // } } } break; @@ -405,12 +882,43 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { 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"); } } @@ -430,7 +938,6 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { if (updated) { notifyListeners(); } - // unawaited(syncActiveChatSilently()); } break; } @@ -454,43 +961,77 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { if (currentChatUserId != targetUid) return; - for (int i = 0; i < loadedMessages.length; i++) { - final existingMsg = activeChat.firstWhere( - (m) => m.id == loadedMessages[i].id, - orElse: () => loadedMessages[i], + 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()), ); - 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 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 != loadedMessages.length; - if (!hasChanges && activeChat.isNotEmpty && loadedMessages.isNotEmpty) { - hasChanges = - activeChat.last.id != loadedMessages.last.id || - activeChat.first.id != loadedMessages.first.id; - } + 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 = loadedMessages; - notifyListeners(); - _ws.sendReadReceipt( - receiverId: isCurrentChatGroup ? null : targetUid, - groupId: isCurrentChatGroup ? targetUid : null, - ); + if (hasChanges) { + activeChat = mergedMessages; + notifyListeners(); + _ws.sendReadReceipt( + receiverId: isCurrentChatGroup ? null : targetUid, + groupId: isCurrentChatGroup ? targetUid : null, + ); + } } } catch (e) { debugPrint("Silent chat sync fail: $e"); @@ -510,22 +1051,35 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { } 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(); - // debugPrint("RAW INBOX DATA: ${response.data}"); 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) { + 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) { @@ -533,39 +1087,72 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { 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; + if (userCache[uid] != (m['display_name'] ?? 'Member')) { + userCache[uid] = m['display_name'] ?? 'Member'; updatedCache = true; } } if (updatedCache) notifyListeners(); }) - .catchError((_) {}); + .catchError((_) => _fetchedGroups.remove(groupId)); } - - } else { - combinedInbox.add( - InboxItem.fromConversation(Conversation.fromJson(json)), - ); - }} catch (e){ - debugPrint("❌ CRASH ON ITEM PARSE: $e"); - debugPrint("❌ BAD JSON OBJECT: $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(); + 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"); + } } - } catch (e, stackTrace) { - debugPrint("❌ MAJOR INBOX READ ERROR: $e"); - debugPrint("❌ STACK TRACE: $stackTrace"); + + 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", + ); } } @@ -595,4 +1182,4 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { } return false; } -} +} \ No newline at end of file diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 3ce98cd..1c0f9c6 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:mobile/providers/basic_providers.dart'; import 'package:mobile/providers/group_controller_provider.dart'; import 'package:mobile/themes/theme_provider.dart'; import 'package:provider/provider.dart'; @@ -25,7 +24,6 @@ void main() async { MultiProvider( providers: [ ChangeNotifierProvider.value(value: themeProvider), - ChangeNotifierProvider(create: (_) => BasicProviders()), ChangeNotifierProvider(create: (_) => AuthState()), ChangeNotifierProvider(create: (_) => ChatController()), ChangeNotifierProvider(create: (_) => GroupController()), diff --git a/mobile/lib/models/conversation.dart b/mobile/lib/models/conversation.dart index 7004675..c311dc4 100644 --- a/mobile/lib/models/conversation.dart +++ b/mobile/lib/models/conversation.dart @@ -7,6 +7,7 @@ class Conversation { final String senderId; final bool isRead; final int unreadCount; + final String? lastMessageSenderId; Conversation({ required this.chatUserId, @@ -16,7 +17,7 @@ class Conversation { required this.lastMessageTime, required this.senderId, required this.isRead, - required this.unreadCount, + required this.unreadCount, this.lastMessageSenderId, }); factory Conversation.fromJson(Map json) { @@ -39,6 +40,7 @@ class Conversation { senderId: json['sender_id'] ?? '', isRead: json['is_read'] ?? false, unreadCount: json['unread_count'] ?? 0, + lastMessageSenderId: json['last_message_sender_id'] ?? json['lastMessageSender'], ); } } diff --git a/mobile/lib/models/inbox_item.dart b/mobile/lib/models/inbox_item.dart index ed603a1..f6b2f0b 100644 --- a/mobile/lib/models/inbox_item.dart +++ b/mobile/lib/models/inbox_item.dart @@ -5,12 +5,14 @@ class InboxItem { final String id; final String title; final String? username; - final String lastMessage; - final DateTime timestamp; + String lastMessage; + DateTime timestamp; final bool isGroup; final bool isRead; - final int unreadCount; - final String? lastMessageSender; + int unreadCount; + String? lastMessageSender; + String? lastMessageSyncStatus; // 'pending' or 'synced' + bool? lastMessageIsRead; InboxItem({ required this.id, @@ -22,6 +24,8 @@ class InboxItem { this.unreadCount = 0, this.username, this.lastMessageSender, + this.lastMessageIsRead, + this.lastMessageSyncStatus, }); InboxItem copyWith({ @@ -33,6 +37,9 @@ class InboxItem { bool? isGroup, bool? isRead, int? unreadCount, + String? lastMessageSender, + String? lastMessageSyncStatus, + bool? lastMessageIsRead, }) { return InboxItem( id: id ?? this.id, @@ -43,6 +50,9 @@ class InboxItem { isGroup: isGroup ?? this.isGroup, isRead: isRead ?? this.isRead, unreadCount: unreadCount ?? this.unreadCount, + lastMessageSender: lastMessageSender ?? this.lastMessageSender, + lastMessageSyncStatus: lastMessageSyncStatus ?? this.lastMessageSyncStatus, + lastMessageIsRead: lastMessageIsRead ?? this.lastMessageIsRead, ); } @@ -56,6 +66,9 @@ class InboxItem { isGroup: false, isRead: conv.isRead, unreadCount: conv.unreadCount, + lastMessageSender: conv.lastMessageSenderId, + lastMessageSyncStatus: 'synced', + lastMessageIsRead: conv.isRead, ); } @@ -69,6 +82,42 @@ class InboxItem { unreadCount: 0, lastMessageSender: group.lastMessageSender, lastMessage: group.lastMessage, + lastMessageSyncStatus: 'synced', + lastMessageIsRead: true, ); } -} + + Map toMap() { + return { + 'id': id, + 'title': title, + 'username': username, + 'last_message': lastMessage, + 'timestamp': timestamp.millisecondsSinceEpoch, + 'is_group': isGroup ? 1 : 0, + 'is_read': isRead ? 1 : 0, + 'unread_count': unreadCount, + 'last_message_sender': lastMessageSender, + 'last_message_sync_status': lastMessageSyncStatus ?? 'synced', + 'last_message_is_read': (lastMessageIsRead ?? false) ? 1 : 0, + }; + } + + factory InboxItem.fromMap(Map map) { + return InboxItem( + id: map['id'] as String, + title: map['title'] as String, + username: map['username'] as String?, + lastMessage: map['last_message'] as String? ?? '', + timestamp: DateTime.fromMillisecondsSinceEpoch(map['timestamp'] as int), + isGroup: (map['is_group'] as int) == 1, + isRead: (map['is_read'] as int) == 1, + unreadCount: (map['unread_count'] as int?) ?? 0, + lastMessageSender: map['last_message_sender'] as String?, + lastMessageSyncStatus: map['last_message_sync_status'] as String? ?? 'synced', + lastMessageIsRead: map['last_message_is_read'] != null + ? (map['last_message_is_read'] as int) == 1 + : false, + ); + } +} \ No newline at end of file diff --git a/mobile/lib/models/message.dart b/mobile/lib/models/message.dart index fcc0044..d2a778e 100644 --- a/mobile/lib/models/message.dart +++ b/mobile/lib/models/message.dart @@ -13,11 +13,11 @@ class QuotedMessage { factory QuotedMessage.fromJson(Map json) { return QuotedMessage( - id: json['id'] ?? '', - senderId: json['sender_id'] ?? '', - senderDisplayName: - json['sender_display_name'] ?? json['sender_name'] ?? '', - content: json['content'] ?? '', + id: json['id']?.toString() ?? '', + senderId: json['sender_id']?.toString() ?? '', + senderDisplayName: json['sender_display_name']?.toString() ?? + json['sender_name']?.toString() ?? '', + content: json['content']?.toString() ?? '', ); } @@ -38,6 +38,7 @@ class Message { final bool isRead; final String? replyToMessageId; final QuotedMessage? quotedMessage; + final String syncStatus; Message({ required this.id, @@ -48,28 +49,56 @@ class Message { required this.isRead, this.replyToMessageId, this.quotedMessage, + this.syncStatus = 'synced', }); 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']); + } else { + parsedDate = DateTime.parse(json['created_at'].toString()).toLocal(); + } + } + return Message( - id: json['client_message_id'] ?? json['message_id'] ?? json['id'] ?? '', - senderId: json['sender_id'] ?? '', - receiverId: json['receiver_id'] ?? '', - content: json['content'] ?? '', - createdAt: json['created_at'] != null - ? DateTime.parse(json['created_at']).toLocal() - : DateTime.now(), - isRead: json['is_read'] ?? false, - replyToMessageId: json['reply_to_message_id'], + id: json['client_message_id']?.toString() ?? + json['message_id']?.toString() ?? + json['id']?.toString() ?? '', + senderId: json['sender_id']?.toString() ?? '', + receiverId: json['receiver_id']?.toString() ?? '', + content: json['content']?.toString() ?? '', + createdAt: parsedDate, + isRead: json['is_read'] == 1 || json['is_read'] == true, + replyToMessageId: json['reply_to_message_id']?.toString(), quotedMessage: json['quoted_message'] != null ? QuotedMessage.fromJson(json['quoted_message']) : null, + syncStatus: json['sync_status']?.toString() ?? 'synced', ); } - Message copyWith({bool? isRead}) { + Map toMap() { + return { + 'id': id, + 'sender_id': senderId, + 'receiver_id': receiverId, + 'content': content, + 'created_at': createdAt.millisecondsSinceEpoch, + 'is_read': isRead ? 1 : 0, + 'reply_to_id': replyToMessageId, + 'sync_status': syncStatus, + }; + } + + Message copyWith({ + bool? isRead, + String? id, + String? syncStatus, + }) { return Message( - id: id, + id: id ?? this.id, senderId: senderId, receiverId: receiverId, content: content, @@ -77,6 +106,7 @@ class Message { isRead: isRead ?? this.isRead, replyToMessageId: replyToMessageId, quotedMessage: quotedMessage, + syncStatus: syncStatus ?? this.syncStatus, ); } -} +} \ No newline at end of file diff --git a/mobile/lib/pages/chat_page.dart b/mobile/lib/pages/chat_page.dart index c97dc5a..2458d5b 100644 --- a/mobile/lib/pages/chat_page.dart +++ b/mobile/lib/pages/chat_page.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; import 'package:mobile/controllers/auth.dart'; +import 'package:mobile/models/message.dart'; import 'package:mobile/pages/chat_details_page.dart'; import 'package:provider/provider.dart'; import 'package:mobile/widgets/chat_screen_modular_widgets.dart'; @@ -39,7 +40,6 @@ class _ChatPageState extends State with WidgetsBindingObserver { ItemPositionsListener.create(); bool _showScrollToBottom = false; - // ignore: prefer_final_fields bool _isNearBottom = true; final Set _selectedIndices = {}; dynamic _replyingToMessage; @@ -77,8 +77,10 @@ class _ChatPageState extends State with WidgetsBindingObserver { _itemPositionsListener.itemPositions.removeListener(_scrollListener); + final closedChatId = widget.chatUserId; + Future.microtask(() { - _chatController.closeChat(); + _chatController.closeChat(closedChatId); }); _highlightTimer?.cancel(); @@ -98,7 +100,7 @@ class _ChatPageState extends State with WidgetsBindingObserver { } } - void _scrollToAndHighlight(String messageId) async { + void _scrollToAndHighlight(String messageId) { setState(() { _isSearchMode = false; _chatSearchController.clear(); @@ -106,20 +108,21 @@ class _ChatPageState extends State with WidgetsBindingObserver { }); final chatState = context.read(); - final activeChat = chatState.activeChat; final bool isTyping = chatState.isPeerTyping; + final activeChat = chatState.activeChat; + final targetIndex = activeChat.indexWhere((msg) => msg.id == messageId); if (targetIndex != -1 && _itemScrollController.isAttached) { final int realVisualIndex = (activeChat.length - 1 - targetIndex) + (isTyping ? 3 : 2); - await _itemScrollController.scrollTo( + _itemScrollController.scrollTo( index: realVisualIndex, - duration: const Duration(milliseconds: 1000), + duration: const Duration(milliseconds: 600), curve: Curves.easeInOutCubic, - alignment: 0.4, + alignment: 0.3, ); setState(() { @@ -134,9 +137,12 @@ class _ChatPageState extends State with WidgetsBindingObserver { }); } }); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Message is too far back to scroll to.')), + ); } } - void _scrollListener() { final positions = _itemPositionsListener.itemPositions.value; if (positions.isEmpty) return; @@ -217,12 +223,16 @@ class _ChatPageState extends State with WidgetsBindingObserver { }); } - void _copySelectedMessages(List activeChat) { + void _copySelectedMessages() async { final sortedIndices = _selectedIndices.toList()..sort(); + final messages = await context + .read() + .getLocalMessagesForChat(widget.chatUserId); + final selectedTexts = sortedIndices .map((index) { - final msg = activeChat[index]; + final msg = messages[index]; final time = DateFormat.jm().format(msg.createdAt); final senderName = msg.senderId == _authState.currentUser!.id ? "Me" @@ -286,17 +296,20 @@ class _ChatPageState extends State with WidgetsBindingObserver { hintText: 'Search in chat...', border: InputBorder.none, ), - onChanged: (query) { + onChanged: (query) async { if (query.trim().isEmpty) { setState(() => _searchResults = []); return; } final chatState = context.read(); + final messages = await chatState.getLocalMessagesForChat( + widget.chatUserId, + ); final lowercaseQuery = query.toLowerCase(); setState(() { - _searchResults = chatState.activeChat.where((msg) { + _searchResults = messages.where((msg) { return msg.content.toLowerCase().contains(lowercaseQuery); }).toList(); }); @@ -308,21 +321,9 @@ class _ChatPageState extends State with WidgetsBindingObserver { @override Widget build(BuildContext context) { final chatState = context.watch(); - final activeChat = chatState.activeChat; final isSelectionMode = _selectedIndices.isNotEmpty; final theme = Theme.of(context); - if (activeChat.length != _previousMessageCount) { - final isNewMessage = activeChat.length > _previousMessageCount; - _previousMessageCount = activeChat.length; - - if (isNewMessage) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _scrollToBottom(animated: true); - }); - } - } - return PopScope( canPop: !isSelectionMode, onPopInvokedWithResult: (didPop, result) { @@ -378,8 +379,7 @@ class _ChatPageState extends State with WidgetsBindingObserver { Icons.copy, color: theme.colorScheme.onPrimary, ), - onPressed: () => - _copySelectedMessages(activeChat), + onPressed: () => _copySelectedMessages(), ), IconButton( icon: Icon( @@ -412,7 +412,6 @@ class _ChatPageState extends State with WidgetsBindingObserver { ), ); - // If the user clicked "Search" in ChatDetailsPage if (result == 'start_search') { setState(() { _isSearchMode = true; @@ -424,245 +423,282 @@ class _ChatPageState extends State with WidgetsBindingObserver { ), body: Stack( children: [ - AnimatedSwitcher( - duration: const Duration(milliseconds: 400), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, - transitionBuilder: (child, animation) { - return FadeTransition( - opacity: animation, - child: SlideTransition( - position: Tween( - begin: const Offset(0, 0.05), - end: Offset.zero, - ).animate(animation), - child: child, - ), - ); - }, - child: chatState.isChatHistoryLoading && activeChat.isEmpty - ? Center( - key: const ValueKey('loading'), - child: CircularProgressIndicator( - color: theme.colorScheme.primary, - ), - ) - : activeChat.isEmpty - ? Center( - key: const ValueKey('empty'), - child: Text( - "No messages yet.\nSay hi!", - textAlign: TextAlign.center, - style: TextStyle( - color: theme.colorScheme.onSurfaceVariant, - fontSize: 14, - ), - ), - ) - : ScrollablePositionedList.builder( - key: const ValueKey('list'), - itemScrollController: _itemScrollController, - itemPositionsListener: _itemPositionsListener, - reverse: true, - physics: const AlwaysScrollableScrollPhysics( - parent: BouncingScrollPhysics(), - ), - padding: const EdgeInsets.only( - // bottom: 140, - top: 140, - left: 16, - right: 16, + Builder( + builder: (context) { + final activeChat = chatState.activeChat; + + if (activeChat.length > _previousMessageCount) { + _previousMessageCount = activeChat.length; + + if (!_showScrollToBottom && + _itemScrollController.isAttached) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _itemScrollController.jumpTo(index: 0); + }); + } + } else { + _previousMessageCount = activeChat.length; + } + + return AnimatedSwitcher( + duration: const Duration(milliseconds: 400), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) { + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.05), + end: Offset.zero, + ).animate(animation), + child: child, ), - itemCount: - activeChat.length + - (chatState.isPeerTyping ? 2 : 1) + - 1, - itemBuilder: (context, index) { - if (index == 0) { - return const SizedBox(height: 140); - } - if (index == 1) { - return AnimatedSize( - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutCubic, - child: SizedBox( - height: _replyingToMessage != null ? 80 : 0, + ); + }, + child: chatState.isChatHistoryLoading && activeChat.isEmpty + ? Center( + key: const ValueKey('loading'), + child: CircularProgressIndicator( + color: theme.colorScheme.primary, + ), + ) + : activeChat.isEmpty + ? Center( + key: const ValueKey('empty'), + child: Text( + "No messages yet.\nSay hi!", + textAlign: TextAlign.center, + style: TextStyle( + color: theme.colorScheme.onSurfaceVariant, + fontSize: 14, ), - ); - } - - if (chatState.isPeerTyping && index == 2) { - return _AnimatedMessageItem( - key: const ValueKey('typing_indicator'), - child: Align( - alignment: Alignment.centerLeft, - child: Container( - margin: const EdgeInsets.only( - bottom: 8, - top: 4, - ), - padding: const EdgeInsets.symmetric( - vertical: 12, - horizontal: 16, - ), - decoration: BoxDecoration( - color: - theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(16) - .copyWith( - bottomLeft: const Radius.circular(0), - ), - ), - child: Text( - "Typing...", - style: TextStyle( - color: theme.colorScheme.primary, - fontStyle: FontStyle.italic, - fontWeight: FontWeight.w500, - fontSize: 14, - ), + ), + ) + : ScrollablePositionedList.builder( + key: const ValueKey('list'), + itemScrollController: _itemScrollController, + itemPositionsListener: _itemPositionsListener, + reverse: true, + physics: const AlwaysScrollableScrollPhysics( + parent: BouncingScrollPhysics(), + ), + padding: const EdgeInsets.only( + top: 140, + left: 16, + right: 16, + ), + itemCount: + activeChat.length + + (chatState.isPeerTyping ? 2 : 1) + + 1, + itemBuilder: (context, index) { + if (index == 0) { + return const SizedBox(height: 140); + } + if (index == 1) { + return AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + child: SizedBox( + height: _replyingToMessage != null ? 80 : 0, ), - ), - ), - ); - } - - final int msgIndex = - index - (chatState.isPeerTyping ? 3 : 2); - final int realIndex = activeChat.length - 1 - msgIndex; - final msg = activeChat[realIndex]; - - final bool isHighlighted = - msg.id == _highlightedMessageId; - final bool isSelected = _selectedIndices.contains( - realIndex, - ); + ); + } - bool showDateSeparator = false; - if (realIndex == 0) { - showDateSeparator = true; - } else { - final prevMsg = activeChat[realIndex - 1]; - if (!_isSameDay(msg.createdAt, prevMsg.createdAt)) { - showDateSeparator = true; - } - } - - final String cleanSenderId = msg.senderId - .trim() - .toLowerCase(); - widget.chatUserId.trim().toLowerCase(); - - final bool isMe = - cleanSenderId == 'me' || - (_authState.currentUser?.id != null && - cleanSenderId == - _authState.currentUser!.id.toLowerCase()); - - final String senderId = msg.senderId - .trim() - .toLowerCase(); - final String? displayName = widget.isGroup - ? (chatState.groupMemberNames[senderId] ?? - 'Unknown') - : null; - - bool showSenderName = widget.isGroup; - if (widget.isGroup && realIndex > 0) { - final previousMsg = activeChat[realIndex - 1]; - - if (previousMsg.senderId.trim().toLowerCase() == - cleanSenderId) { - showSenderName = false; - } - } - - return _AnimatedMessageItem( - key: ValueKey(msg.id.toString()), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (showDateSeparator) - Center( + if (chatState.isPeerTyping && index == 2) { + return _AnimatedMessageItem( + key: const ValueKey('typing_indicator'), + child: Align( + alignment: Alignment.centerLeft, child: Container( - margin: const EdgeInsets.symmetric( - vertical: 16, + margin: const EdgeInsets.only( + bottom: 8, + top: 4, ), padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 4, + vertical: 12, + horizontal: 16, ), decoration: BoxDecoration( color: theme .colorScheme .surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(16) + .copyWith( + bottomLeft: const Radius.circular( + 0, + ), + ), ), child: Text( - _formatDateSeparator(msg.createdAt), + "Typing...", style: TextStyle( - fontSize: 12, - color: - theme.colorScheme.onSurfaceVariant, + color: theme.colorScheme.primary, + fontStyle: FontStyle.italic, + fontWeight: FontWeight.w500, + fontSize: 14, ), ), ), ), - GestureDetector( - onLongPress: () { - HapticFeedback.selectionClick(); - _toggleSelection(realIndex); - }, - onTap: () { - if (isSelectionMode) { - _toggleSelection(realIndex); - } - }, - child: AnimatedScale( - scale: isSelected ? 0.95 : 1.0, - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutCubic, - child: AnimatedContainer( - duration: const Duration(milliseconds: 350), - decoration: BoxDecoration( - color: isSelected || isHighlighted - ? theme.colorScheme.primary - .withValues(alpha: 0.25) - : Colors.transparent, - borderRadius: BorderRadius.circular(12), + ); + } + + final int msgIndex = + index - (chatState.isPeerTyping ? 3 : 2); + final int realIndex = + activeChat.length - 1 - msgIndex; + final msg = activeChat[realIndex]; + + final bool isHighlighted = + msg.id == _highlightedMessageId; + final bool isSelected = _selectedIndices.contains( + realIndex, + ); + + bool showDateSeparator = false; + if (realIndex == 0) { + showDateSeparator = true; + } else { + final prevMsg = activeChat[realIndex - 1]; + if (!_isSameDay( + msg.createdAt, + prevMsg.createdAt, + )) { + showDateSeparator = true; + } + } + + final String cleanSenderId = msg.senderId + .trim() + .toLowerCase(); + widget.chatUserId.trim().toLowerCase(); + + final bool isMe = + cleanSenderId == 'me' || + (_authState.currentUser?.id != null && + cleanSenderId == + _authState.currentUser!.id + .toLowerCase()); + + final String senderId = msg.senderId + .trim() + .toLowerCase(); + final String? displayName = widget.isGroup + ? (chatState.groupMemberNames[senderId] ?? + 'Unknown') + : null; + + bool showSenderName = widget.isGroup; + if (widget.isGroup && realIndex > 0) { + final previousMsg = activeChat[realIndex - 1]; + + if (previousMsg.senderId.trim().toLowerCase() == + cleanSenderId) { + showSenderName = false; + } + } + + return _AnimatedMessageItem( + key: ValueKey(msg.id.toString()), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (showDateSeparator) + Center( + child: Container( + margin: const EdgeInsets.symmetric( + vertical: 16, + ), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + decoration: BoxDecoration( + color: theme + .colorScheme + .surfaceContainerHighest, + borderRadius: BorderRadius.circular( + 12, + ), + ), + child: Text( + _formatDateSeparator(msg.createdAt), + style: TextStyle( + fontSize: 12, + color: theme + .colorScheme + .onSurfaceVariant, + ), + ), + ), ), - child: SwipeToReply( - isMe: isMe, - onReply: () { - setState(() { - _replyingToMessage = msg; - }); - }, - child: ChatBubble( - message: msg.content, - isMe: isMe, - timestamp: msg.createdAt, - isRead: msg.isRead, - quotedMessage: msg.quotedMessage, - isGroup: widget.isGroup, - senderName: showSenderName - ? displayName - : null, - onQuoteTap: msg.quotedMessage != null - ? () => _scrollToAndHighlight( - msg.quotedMessage!.id, - ) - : null, + GestureDetector( + onLongPress: () { + HapticFeedback.selectionClick(); + _toggleSelection(realIndex); + }, + onTap: () { + if (isSelectionMode) { + _toggleSelection(realIndex); + } + }, + child: AnimatedScale( + scale: isSelected ? 0.95 : 1.0, + duration: const Duration( + milliseconds: 200, + ), + curve: Curves.easeOutCubic, + child: AnimatedContainer( + duration: const Duration( + milliseconds: 350, + ), + decoration: BoxDecoration( + color: isSelected || isHighlighted + ? theme.colorScheme.primary + .withValues(alpha: 0.25) + : Colors.transparent, + borderRadius: BorderRadius.circular( + 12, + ), + ), + child: SwipeToReply( + isMe: isMe, + onReply: () { + setState(() { + _replyingToMessage = msg; + }); + }, + child: ChatBubble( + message: msg.content, + isMe: isMe, + timestamp: msg.createdAt, + isRead: msg.isRead, + syncStatus: msg.syncStatus, + quotedMessage: msg.quotedMessage, + isGroup: widget.isGroup, + senderName: showSenderName + ? displayName + : null, + onQuoteTap: + msg.quotedMessage != null + ? () => _scrollToAndHighlight( + msg.quotedMessage!.id, + ) + : null, + ), + ), ), ), ), - ), + ], ), - ], - ), - ); - }, - ), + ); + }, + ), + ); + }, ), if (_isSearchMode) @@ -716,7 +752,6 @@ class _ChatPageState extends State with WidgetsBindingObserver { ), ), onTap: () { - // Close search mode setState(() { _isSearchMode = false; _chatSearchController.clear(); diff --git a/mobile/lib/pages/home_page.dart b/mobile/lib/pages/home_page.dart index a127910..bfb12ec 100644 --- a/mobile/lib/pages/home_page.dart +++ b/mobile/lib/pages/home_page.dart @@ -234,6 +234,7 @@ class _HomePageState extends State { Widget buildHomeTab(ChatController chatState) { final theme = Theme.of(context); + final isOffline = chatState.isOffline; return RefreshIndicator( color: theme.colorScheme.primary, @@ -423,6 +424,19 @@ class _HomePageState extends State { const SizedBox(width: 4), ], ), + if (isOffline) + SliverToBoxAdapter( + child: Container( + color: Colors.redAccent, + width: double.infinity, + padding: const EdgeInsets.all(4), + child: const Text( + "You are offline. Showing cached chats.", + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white, fontSize: 12), + ), + ), + ), chatState.inbox.isEmpty ? SliverFillRemaining( hasScrollBody: false, @@ -477,7 +491,6 @@ class _HomePageState extends State { Widget build(BuildContext context) { final chatState = context.watch(); final theme = Theme.of(context); - return PopScope( canPop: _selectedChatIds.isEmpty, onPopInvokedWithResult: (didPop, result) { @@ -525,8 +538,7 @@ class _HomePageState extends State { opacity: (_isFabVisible && !_isSelectionMode) ? 1.0 : 0.0, child: Padding( padding: const EdgeInsets.only(bottom: 10), - child: - SizedBox( + child: SizedBox( width: 56, height: 56, child: ClipRRect( diff --git a/mobile/lib/pages/settings pages/accounts.dart b/mobile/lib/pages/settings pages/accounts.dart index 82e7ef8..de86253 100644 --- a/mobile/lib/pages/settings pages/accounts.dart +++ b/mobile/lib/pages/settings pages/accounts.dart @@ -32,7 +32,6 @@ class AccountsSettings extends StatelessWidget { width: double.infinity, height: 150, child: Padding( - // Moved padding OUTSIDE the Hero padding: const EdgeInsets.only( left: 16.0, top: 10.0, @@ -183,7 +182,7 @@ class AccountsSettings extends StatelessWidget { await context.read().logout(); if (context.mounted) { - context.read().clearSessionData(); + await context.read().clearSessionData(); context.read().clearGroupData(); Navigator.pushAndRemoveUntil( context, diff --git a/mobile/lib/pages/settings pages/appearance.dart b/mobile/lib/pages/settings pages/appearance.dart index 709d0ee..94443ce 100644 --- a/mobile/lib/pages/settings pages/appearance.dart +++ b/mobile/lib/pages/settings pages/appearance.dart @@ -16,7 +16,7 @@ class AppearanceSettings extends StatelessWidget { title: const Text('Appearance'), backgroundColor: Theme.of( context, - ).colorScheme.surface, // Glass effect color + ).colorScheme.surface, elevation: 0, ), body: SafeArea( @@ -35,7 +35,7 @@ class AppearanceSettings extends StatelessWidget { crossAxisCount: 2, crossAxisSpacing: 16, mainAxisSpacing: 16, - childAspectRatio: 0.65, // Makes the cards taller like a phone + childAspectRatio: 0.65, children: [ _ThemeCard( title: 'Frost', @@ -116,7 +116,6 @@ class _ThemeCard extends StatelessWidget { borderRadius: BorderRadius.circular(21), child: Stack( children: [ - // Mock Chat bubble Positioned( right: 16, top: 60, @@ -129,7 +128,6 @@ class _ThemeCard extends StatelessWidget { ), ), ), - // Mock Glass AppBar Positioned( top: 0, left: 0, @@ -139,7 +137,6 @@ class _ThemeCard extends StatelessWidget { color: themeData.colorScheme.surface, ), ), - // Mock Glass NavBar Positioned( bottom: 0, left: 0, @@ -149,7 +146,6 @@ class _ThemeCard extends StatelessWidget { color: themeData.colorScheme.surface, ), ), - // Theme Title Positioned( bottom: 50, left: 0, diff --git a/mobile/lib/pages/settings_page.dart b/mobile/lib/pages/settings_page.dart index 6debb3a..050da33 100644 --- a/mobile/lib/pages/settings_page.dart +++ b/mobile/lib/pages/settings_page.dart @@ -8,7 +8,6 @@ import 'package:mobile/pages/settings%20pages/chats_media.dart'; import 'package:mobile/pages/settings%20pages/help_about.dart'; import 'package:mobile/pages/settings%20pages/notifications_settings.dart'; import 'package:mobile/pages/settings%20pages/privacy_security.dart'; -import 'package:mobile/providers/basic_providers.dart'; import 'package:provider/provider.dart'; import 'package:qr_flutter/qr_flutter.dart'; @@ -243,15 +242,9 @@ class SettingsPage extends StatelessWidget { icon: Icons.notifications, settingName: 'Notifications', whereTo: NotificationsSettingsPage(), - trailing: Consumer( - builder: (context, basicProvider, child) { - return Switch( - value: basicProvider.notificationsSwitch, - onChanged: (value) { - basicProvider.toggleNotifications(); - }, - ); - }, + trailing: Switch( + value: false, + onChanged: (value) {}, ), ), Divider(), diff --git a/mobile/lib/pages/temp/phone_number_page.dart b/mobile/lib/pages/temp/phone_number_page.dart deleted file mode 100644 index 3eecdc5..0000000 --- a/mobile/lib/pages/temp/phone_number_page.dart +++ /dev/null @@ -1,171 +0,0 @@ -import 'package:mobile/pages/temp/profile_setup_page.dart'; -import 'package:mobile/providers/timer_provider.dart'; -import 'package:flutter/material.dart'; -import 'package:intl_phone_field/intl_phone_field.dart'; -import 'package:pinput/pinput.dart'; -import 'package:provider/provider.dart'; - -class PhoneNumberPage extends StatelessWidget { - PhoneNumberPage({super.key}); - - final TextEditingController numberController = TextEditingController(); - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(), - body: Padding( - padding: const EdgeInsets.all(10), - child: Column( - crossAxisAlignment: .start, - children: [ - const Text( - 'Your phone number', - style: TextStyle(fontSize: 20, fontWeight: .bold), - ), - const SizedBox(height: 10), - const Text( - 'Please confirm your country code and enter you phone number. We will send you a verification code via SMS.', - ), - SizedBox(height: 20), - IntlPhoneField( - initialCountryCode: 'IN', - controller: numberController, - decoration: InputDecoration(border: OutlineInputBorder()), - onChanged: (value) {}, - onCountryChanged: (value) {}, - ), - Spacer(), - Container( - margin: .symmetric(horizontal: 20), - child: FilledButton( - style: ButtonStyle( - minimumSize: WidgetStatePropertyAll( - Size(double.infinity, 40), - ), - ), - onPressed: - () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => ChangeNotifierProvider( - create: (_) => OtpProvider()..startTimer(), - child: NumberVerificationPage(), - ), - ), - ); - }, - child: Text('Next'), - ), - ), - ], - ), - ), - ); - } -} - -class NumberVerificationPage extends StatelessWidget { - const NumberVerificationPage({super.key}); - - @override - Widget build(BuildContext context) { - final TextEditingController otpController = TextEditingController(); - return Scaffold( - appBar: AppBar(), - body: Center( - child: SingleChildScrollView( - child: Column( - mainAxisAlignment: .center, - crossAxisAlignment: .center, - children: [ - const Text( - 'Verify your number', - style: TextStyle(fontWeight: .bold, fontSize: 25), - ), - const SizedBox(height: 10), - Text( - "We've sent a 6-digit code to\n", - textAlign: .center, - ), - const SizedBox(height: 25), - Pinput( - onChanged: context.read().updateOtp, - controller: otpController, - length: 6, - autofocus: true, - enabled: true, - focusedPinTheme: PinTheme( - width: 50, - height: 55, - textStyle: TextStyle(fontSize: 25, fontWeight: .w300), - decoration: BoxDecoration( - border: BoxBorder.all(color: Colors.blue, width: 3), - borderRadius: BorderRadius.all(Radius.circular(10)), - color: Theme.of(context).primaryColor.withAlpha(100), - ), - ), - defaultPinTheme: PinTheme( - width: 50, - height: 55, - textStyle: TextStyle(fontSize: 25, fontWeight: .w300), - decoration: BoxDecoration( - border: BoxBorder.all(color: Colors.grey), - borderRadius: BorderRadius.all(Radius.circular(10)), - color: Theme.of(context).primaryColor.withAlpha(100), - ), - ), - ), - const SizedBox(height: 25), - const Text("Didn't receive the code?"), - Row( - mainAxisAlignment: .center, - children: [ - Icon(Icons.timer_outlined, color: Colors.grey), - Consumer( - builder: (context, otp, child) { - return otp.canResend - ? TextButton( - onPressed: () { - otp.resetTimer(); - }, - child: const Text('Resend OTP'), - ) - : Text(' ${otp.secondsRemaining}s'); - }, - ), - ], - ), - const SizedBox(height: 20), - Container( - margin: .symmetric(horizontal: 20), - child: Consumer( - builder: (context, otp, child) { - return FilledButton( - style: ButtonStyle( - minimumSize: WidgetStatePropertyAll( - Size(double.infinity, 40), - ), - ), - onPressed: otp.isOtpComplete - ? () { - otp.updateOtp(otpController.text); - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ProfileSetupPage(), - ), - ); - } - : null, - child: const Text('Verify'), - ); - }, - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/mobile/lib/pages/temp/profile_setup_page.dart b/mobile/lib/pages/temp/profile_setup_page.dart deleted file mode 100644 index 11d5fed..0000000 --- a/mobile/lib/pages/temp/profile_setup_page.dart +++ /dev/null @@ -1,94 +0,0 @@ -import 'package:mobile/pages/home_page.dart'; -import 'package:mobile/providers/image_picker_provider.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -class ProfileSetupPage extends StatelessWidget { - const ProfileSetupPage({super.key}); - - @override - Widget build(BuildContext context) { - final profile = context.watch(); - return Scaffold( - appBar: AppBar(), - body: Center( - child: SingleChildScrollView( - child: Column( - mainAxisAlignment: .center, - children: [ - const Text( - 'Set up your profile', - style: TextStyle(fontWeight: .bold, fontSize: 25), - ), - const SizedBox( - width: 300, - child: Text( - 'Add a photo and display name so your contacts can securely recognize you.', - style: TextStyle(fontWeight: .w400), - textAlign: .center, - ), - ), - const SizedBox(height: 25), - CircleAvatar( - radius: 60, - backgroundImage: profile.selectedImage != null - ? FileImage(profile.selectedImage!) - : null, - child: profile.selectedImage == null - ? Icon(Icons.person) - : null, - ), - const SizedBox(height: 10), - TextButton( - onPressed: context.read().pickImage, - child: Text( - 'Upload', - style: TextStyle(fontWeight: .w300, fontSize: 15), - ), - ), - const SizedBox(height: 25), - Container( - padding: EdgeInsets.all(20), - child: TextField( - decoration: InputDecoration( - hint: Text( - 'e.g. Alex', - style: TextStyle(color: Colors.grey), - ), - label: Text('Display Name'), - border: OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(10)), - ), - ), - ), - ), - const SizedBox(height: 25), - Container( - margin: .symmetric(horizontal: 20), - child: FilledButton( - style: ButtonStyle( - minimumSize: WidgetStatePropertyAll( - Size(double.infinity, 40), - ), - ), - onPressed: () { - Navigator.popUntil(context, (route) => false); - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const HomePage()), - ); - // context.read().logInSave(); - }, - child: Row( - mainAxisAlignment: .center, - children: [Text('Finish'), Icon(Icons.arrow_forward)], - ), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/mobile/lib/providers/basic_providers.dart b/mobile/lib/providers/basic_providers.dart deleted file mode 100644 index 5f5729a..0000000 --- a/mobile/lib/providers/basic_providers.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -class BasicProviders extends ChangeNotifier { - bool _notificationsSwitch = false; - - bool get notificationsSwitch => _notificationsSwitch; - - void initNotificationsSwitch() async { - final pref = await SharedPreferences.getInstance(); - _notificationsSwitch = pref.getBool('notificationToggle') ?? false; - notifyListeners(); - } - - void toggleNotifications() async { - _notificationsSwitch = !_notificationsSwitch; - notifyListeners(); - - final pref = await SharedPreferences.getInstance(); - pref.setBool('notificationToggle', _notificationsSwitch); - } - -} diff --git a/mobile/lib/providers/group_controller_provider.dart b/mobile/lib/providers/group_controller_provider.dart index 18229bb..2a046bf 100644 --- a/mobile/lib/providers/group_controller_provider.dart +++ b/mobile/lib/providers/group_controller_provider.dart @@ -12,7 +12,7 @@ class GroupController extends ChangeNotifier { _selectedContacts.addAll(contacts); notifyListeners(); } -//hhhh.1585 + void toggleContact(String id, Map contactData) { if (_selectedContacts.containsKey(id)) { _selectedContacts.remove(id); diff --git a/mobile/lib/providers/image_picker_provider.dart b/mobile/lib/providers/image_picker_provider.dart deleted file mode 100644 index 97b3162..0000000 --- a/mobile/lib/providers/image_picker_provider.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:image_picker/image_picker.dart'; - -class ProfileImageProvider extends ChangeNotifier { - final ImagePicker _imagePicker = ImagePicker(); - File? selectedImage; - - Future pickImage() async { - final XFile? image = await _imagePicker.pickImage( - source: ImageSource.gallery, - ); - if (image == null) { - return null; - } - selectedImage = File(image.path); - notifyListeners(); - return File(image.path); - } -} diff --git a/mobile/lib/services/auth.dart b/mobile/lib/services/auth.dart index 5a0fd0c..e917dee 100644 --- a/mobile/lib/services/auth.dart +++ b/mobile/lib/services/auth.dart @@ -31,6 +31,14 @@ class AuthService { await _storage.write(key: "refresh_token", value: refreshToken); } + Future saveUserProfile(String userJson) async { + await _storage.write(key: "cached_user_profile", value: userJson); + } + + Future getCachedUserProfile() async { + return await _storage.read(key: "cached_user_profile"); + } + Future logout() async { _cachedAccessToken = null; _cachedRefreshToken = null; diff --git a/mobile/lib/services/db_services.dart b/mobile/lib/services/db_services.dart new file mode 100644 index 0000000..789182d --- /dev/null +++ b/mobile/lib/services/db_services.dart @@ -0,0 +1,118 @@ +import 'dart:convert'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:path/path.dart'; +import 'package:sqflite_sqlcipher/sqflite.dart'; + +class DatabaseHelper { + static final DatabaseHelper instance = DatabaseHelper._init(); + static Database? _database; + final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); + + DatabaseHelper._init(); + + Future get database async { + if (_database != null) return _database!; + _database = await _initDB('secure_chat.db'); + return _database!; + } + + Future _getEncryptionKey() async { + const keyName = 'db_encryption_key'; + String? key = await _secureStorage.read(key: keyName); + + if (key == null) { + final secureKey = base64Url.encode( + List.generate(32, (i) => i + 1), + ); + await _secureStorage.write(key: keyName, value: secureKey); + key = secureKey; + } + return key; + } + + Future _initDB(String filePath) async { + final dbPath = await getDatabasesPath(); + final path = join(dbPath, filePath); + final password = await _getEncryptionKey(); + + return await openDatabase( + path, + version: 1, + password: password, + onCreate: _createDB, + ); + } + + Future _createDB(Database db, int version) async { + await db.execute(''' + CREATE TABLE messages ( + id TEXT PRIMARY KEY, + chat_id TEXT NOT NULL, + sender_id TEXT NOT NULL, + content TEXT NOT NULL, + created_at INTEGER NOT NULL, + is_read INTEGER NOT NULL, + reply_to_id TEXT, + sync_status TEXT NOT NULL -- 'synced' or 'pending' + ) + '''); + + await db.execute(''' + CREATE TABLE action_queue ( + id TEXT PRIMARY KEY, + action_type TEXT NOT NULL, -- e.g., 'send_message', 'read_receipt' + payload TEXT NOT NULL, -- JSON string of the data + created_at INTEGER NOT NULL, + retry_count INTEGER DEFAULT 0 + ) + '''); + + await db.execute(''' + CREATE TABLE inbox ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + username TEXT, + last_message TEXT NOT NULL, + timestamp INTEGER NOT NULL, + is_group INTEGER NOT NULL, + is_read INTEGER NOT NULL, + unread_count INTEGER DEFAULT 0, + last_message_sender TEXT, + last_message_sync_status TEXT, -- MUST BE ADDED + last_message_is_read INTEGER + ) + '''); + + await db.execute(''' + CREATE TABLE group_members ( + group_id TEXT NOT NULL, + user_id TEXT NOT NULL, + display_name TEXT, + PRIMARY KEY (group_id, user_id) + ) + '''); + } + + Future insertMessage(Map messageData) async { + final db = await instance.database; + await db.insert( + 'messages', + messageData, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + Future queueAction( + String id, + String type, + Map payload, + ) async { + final db = await instance.database; + await db.insert('action_queue', { + 'id': id, + 'action_type': type, + 'payload': jsonEncode(payload), + 'created_at': DateTime.now().millisecondsSinceEpoch, + }); + } +} diff --git a/mobile/lib/services/ws.dart b/mobile/lib/services/ws.dart index 279fc54..ff6b81a 100644 --- a/mobile/lib/services/ws.dart +++ b/mobile/lib/services/ws.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; @@ -6,12 +7,13 @@ import '../core/constants.dart'; class WebSocketService { WebSocketChannel? _channel; bool _isConnected = false; + Timer? _heartbeatTimer; Stream? get stream => _channel?.stream; bool get isConnected => _isConnected; - Future connect(String token) async { - if (_isConnected) return; + Future connect(String token) async { + if (_isConnected) return true; try { final wsUrl = Uri.parse("${Env.wsBaseUrl}?token=$token"); _channel = WebSocketChannel.connect(wsUrl); @@ -20,14 +22,27 @@ class WebSocketService { _isConnected = true; debugPrint("WebSocket Pipeline Connected straight to: ${Env.wsBaseUrl}"); + + _startHeartbeat(); + return true; } catch (e) { _isConnected = false; debugPrint("WebSocket connection failure (Handshake rejected): $e"); + return false; } } + void _startHeartbeat() { + _heartbeatTimer?.cancel(); + _heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (timer) { + if (_isConnected) { + emit({"type": "ping"}); + } + }); + } + void emit(Map payload) { - if (!_isConnected) return; + if (!_isConnected || _channel == null) return; debugPrint("Sending Payload to WS: ${jsonEncode(payload)}"); _channel?.sink.add(jsonEncode(payload)); } @@ -41,7 +56,7 @@ class WebSocketService { emit({ "type": "chat", "message_id": messageId, - "receiver_id": receiverId, + "receiver_id": receiverId, "content": content, "reply_to_message_id": replyToMessageId, }); @@ -90,9 +105,10 @@ class WebSocketService { } void disconnect() { + _heartbeatTimer?.cancel(); _channel?.sink.close(); _isConnected = false; _channel = null; debugPrint("WebSocket Pipeline Terminated Cleanly."); } -} +} \ No newline at end of file diff --git a/mobile/lib/widgets/chat_screen_modular_widgets.dart b/mobile/lib/widgets/chat_screen_modular_widgets.dart index 5f4fba1..8c81aa2 100644 --- a/mobile/lib/widgets/chat_screen_modular_widgets.dart +++ b/mobile/lib/widgets/chat_screen_modular_widgets.dart @@ -52,28 +52,32 @@ class GlassAppBar extends StatelessWidget implements PreferredSizeWidget { ), ), const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - name, - style: TextStyle( - color: theme.textTheme.titleLarge?.color, - fontSize: 16, - fontWeight: FontWeight.bold, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: theme.textTheme.titleLarge?.color, + fontSize: 16, + fontWeight: FontWeight.bold, + ), ), - ), - status != null - ? Text( - status! == UserStatus.online ? "Online" : "Offline", - style: TextStyle( - color: theme.textTheme.bodySmall?.color, - fontSize: 12, - fontWeight: FontWeight.normal, - ), - ) - : SizedBox.shrink(), - ], + status != null + ? Text( + status! == UserStatus.online ? "Online" : "Offline", + style: TextStyle( + color: theme.textTheme.bodySmall?.color, + fontSize: 12, + fontWeight: FontWeight.normal, + ), + ) + : SizedBox.shrink(), + ], + ), ), ], ), @@ -96,6 +100,7 @@ class ChatBubble extends StatelessWidget { final QuotedMessage? quotedMessage; final String? senderName; final bool isGroup; + final String syncStatus; const ChatBubble({ super.key, @@ -107,6 +112,7 @@ class ChatBubble extends StatelessWidget { this.senderName, this.isGroup = false, this.onQuoteTap, + this.syncStatus = 'synced', }); Color _getSenderColor(String name) { @@ -273,12 +279,14 @@ class ChatBubble extends StatelessWidget { if (isMe) ...[ const SizedBox(width: 4), Icon( - isRead ? Icons.done_all : Icons.done, + syncStatus == 'pending' + ? Icons.access_time + : (isRead ? Icons.done_all : Icons.done), size: 14, - color: isRead - ? (isMe - ? Colors.lightBlueAccent - : theme.colorScheme.primary) + color: syncStatus == 'pending' + ? theme.colorScheme.onPrimary.withValues(alpha: 0.5) + : isRead + ? Colors.lightBlueAccent : theme.colorScheme.onPrimary.withValues( alpha: 0.7, ), diff --git a/mobile/lib/widgets/custom_cards.dart b/mobile/lib/widgets/custom_cards.dart index 4a8e71a..536fa54 100644 --- a/mobile/lib/widgets/custom_cards.dart +++ b/mobile/lib/widgets/custom_cards.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:mobile/controllers/auth.dart'; import 'package:mobile/controllers/chat.dart'; import 'package:provider/provider.dart'; -import 'package:simple_rich_text/simple_rich_text.dart'; import '../models/inbox_item.dart'; import '../pages/chat_page.dart'; @@ -37,6 +36,7 @@ class CustomChatCard extends StatelessWidget { } } + @override @override Widget build(BuildContext context) { final String timeLabel = _formatTimestamp(conversation.timestamp); @@ -45,9 +45,17 @@ class CustomChatCard extends StatelessWidget { final currentUserId = context.read().currentUser?.id; + final String? safeSenderId = conversation.lastMessageSender + ?.trim() + .toLowerCase(); + final String? safeMyId = currentUserId?.trim().toLowerCase(); + + final bool isMe = + safeSenderId == 'me' || (safeMyId != null && safeSenderId == safeMyId); + String? senderName; if (conversation.isGroup && conversation.lastMessageSender != null) { - if (conversation.lastMessageSender == currentUserId) { + if (isMe) { senderName = "You"; } else { senderName = @@ -55,6 +63,9 @@ class CustomChatCard extends StatelessWidget { } } + final String syncStatus = conversation.lastMessageSyncStatus ?? 'synced'; + final bool isRead = conversation.lastMessageIsRead ?? false; + return Material( color: isSelected ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.12) @@ -136,18 +147,45 @@ class CustomChatCard extends StatelessWidget { ), ), const SizedBox(height: 4), - SimpleRichText( - senderName != null - ? "*$senderName:* ${conversation.lastMessage.replaceAll('\n', ' ')}" - : conversation.lastMessage.replaceAll('\n', ' '), - maxLines: 1, - textOverflow: TextOverflow.ellipsis, - style: TextStyle( - color: hasUnread - ? Theme.of(context).colorScheme.onSurface - : Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 15, - ), + Row( + children: [ + if (isMe) ...[ + Icon( + syncStatus == 'pending' + ? Icons.access_time + : (isRead ? Icons.done_all : Icons.done), + size: 16, + color: syncStatus == 'pending' + ? Theme.of(context).colorScheme.onSurfaceVariant + .withValues(alpha: 0.5) + : isRead + ? Colors.lightBlueAccent + : Theme.of(context).colorScheme.onSurfaceVariant + .withValues(alpha: 0.7), + ), + const SizedBox(width: 4), + ], + Expanded( + child: Text( + senderName != null + ? "*$senderName:* ${conversation.lastMessage.replaceAll('\n', ' ')}" + : conversation.lastMessage.replaceAll( + '\n', + ' ', + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: hasUnread + ? Theme.of(context).colorScheme.onSurface + : Theme.of( + context, + ).colorScheme.onSurfaceVariant, + fontSize: 15, + ), + ), + ), + ], ), ], ), diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 3df5aa7..e2136cd 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -113,22 +113,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" - cross_cache: - dependency: transitive + connectivity_plus: + dependency: "direct main" description: - name: cross_cache - sha256: "4983a16603cc99b0a14de6a772fa8ee4533411f46f3c423f1386fea7566049c5" + name: connectivity_plus + sha256: "762c99f890ca8bf87f7337236f99edd42793843bc6c3631da294a76653a54bd0" url: "https://pub.dev" source: hosted - version: "1.1.0" - cross_file: + version: "7.3.1" + connectivity_plus_platform_interface: dependency: transitive description: - name: cross_file - sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" + name: connectivity_plus_platform_interface + sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed" url: "https://pub.dev" source: hosted - version: "0.3.5+4" + version: "2.1.0" crypto: dependency: transitive description: @@ -137,22 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" - url: "https://pub.dev" - source: hosted - version: "1.0.9" - diffutil_dart: + dbus: dependency: transitive description: - name: diffutil_dart - sha256: "5e74883aedf87f3b703cb85e815bdc1ed9208b33501556e4a8a5572af9845c81" + name: dbus + sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91" url: "https://pub.dev" source: hosted - version: "4.0.1" + version: "0.7.13" dio: dependency: "direct main" description: @@ -169,14 +161,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" - equatable: - dependency: transitive - description: - name: equatable - sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" - url: "https://pub.dev" - source: hosted - version: "2.1.0" fake_async: dependency: transitive description: @@ -209,38 +193,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" - file_selector_linux: - dependency: transitive - description: - name: file_selector_linux - sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" - url: "https://pub.dev" - source: hosted - version: "0.9.4" - file_selector_macos: + fixnum: dependency: transitive description: - name: file_selector_macos - sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be url: "https://pub.dev" source: hosted - version: "0.9.5" - file_selector_platform_interface: - dependency: transitive - description: - name: file_selector_platform_interface - sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" - url: "https://pub.dev" - source: hosted - version: "2.7.0" - file_selector_windows: - dependency: transitive - description: - name: file_selector_windows - sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" - url: "https://pub.dev" - source: hosted - version: "0.9.3+5" + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -254,30 +214,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.2" - flutter_chat_core: - dependency: transitive - description: - name: flutter_chat_core - sha256: "8c46790f64f106bf6e610e2a7324b3844320e9e295867c06d45d9deb134d848d" - url: "https://pub.dev" - source: hosted - version: "2.9.0" - flutter_chat_types: - dependency: "direct main" - description: - name: flutter_chat_types - sha256: e285b588f6d19d907feb1f6d912deaf22e223656769c34093b64e1c59b094fb9 - url: "https://pub.dev" - source: hosted - version: "3.6.2" - flutter_chat_ui: - dependency: "direct main" - description: - name: flutter_chat_ui - sha256: cfbaac38f429beb33d9cc1ca920ae7ccbadbed282c99335d590d61306d3a3d0f - url: "https://pub.dev" - source: hosted - version: "2.11.1" flutter_launcher_icons: dependency: "direct main" description: @@ -302,14 +238,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.12" - flutter_plugin_android_lifecycle: - dependency: transitive - description: - name: flutter_plugin_android_lifecycle - sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" - url: "https://pub.dev" - source: hosted - version: "2.0.35" flutter_secure_storage: dependency: "direct main" description: @@ -376,14 +304,6 @@ packages: description: flutter source: sdk version: "0.0.0" - freezed_annotation: - dependency: transitive - description: - name: freezed_annotation - sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" - url: "https://pub.dev" - source: hosted - version: "3.1.0" gtk: dependency: transitive description: @@ -400,14 +320,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" - http: - dependency: "direct main" - description: - name: http - sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" - url: "https://pub.dev" - source: hosted - version: "1.6.0" http_parser: dependency: transitive description: @@ -416,14 +328,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" - idb_shim: - dependency: transitive - description: - name: idb_shim - sha256: "3448298f244bc76a14aca71461eeab6add2b8a877930521c42c2e8b71b644590" - url: "https://pub.dev" - source: hosted - version: "2.9.6+2" image: dependency: transitive description: @@ -432,70 +336,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.9.1" - image_picker: - dependency: "direct main" - description: - name: image_picker - sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667 - url: "https://pub.dev" - source: hosted - version: "1.2.3" - image_picker_android: - dependency: transitive - description: - name: image_picker_android - sha256: "6f3a1995eafb000333174fae92202622033b0ee7fd917a6cd3730295264df84a" - url: "https://pub.dev" - source: hosted - version: "0.8.13+19" - image_picker_for_web: - dependency: transitive - description: - name: image_picker_for_web - sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" - url: "https://pub.dev" - source: hosted - version: "3.1.1" - image_picker_ios: - dependency: transitive - description: - name: image_picker_ios - sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 - url: "https://pub.dev" - source: hosted - version: "0.8.13+6" - image_picker_linux: - dependency: transitive - description: - name: image_picker_linux - sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" - url: "https://pub.dev" - source: hosted - version: "0.2.2" - image_picker_macos: - dependency: transitive - description: - name: image_picker_macos - sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" - url: "https://pub.dev" - source: hosted - version: "0.2.2+1" - image_picker_platform_interface: - dependency: transitive - description: - name: image_picker_platform_interface - sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" - url: "https://pub.dev" - source: hosted - version: "2.11.1" - image_picker_windows: - dependency: transitive - description: - name: image_picker_windows - sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae - url: "https://pub.dev" - source: hosted - version: "0.2.2" intl: dependency: "direct main" description: @@ -504,14 +344,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.3" - intl_phone_field: - dependency: "direct main" - description: - name: intl_phone_field - sha256: "73819d3dfcb68d2c85663606f6842597c3ddf6688ac777f051b17814fe767bbf" - url: "https://pub.dev" - source: hosted - version: "3.2.0" jni: dependency: transitive description: @@ -632,6 +464,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" objective_c: dependency: transitive description: @@ -649,7 +489,7 @@ packages: source: hosted version: "2.2.0" path: - dependency: "direct main" + dependency: transitive description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" @@ -657,7 +497,7 @@ packages: source: hosted version: "1.9.1" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 @@ -712,14 +552,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.2" - pinput: - dependency: "direct main" - description: - name: pinput - sha256: "4c3f1b84768b47a56a1abdaca551bd7cef4ac673b882209039ecdf803a5d6e68" - url: "https://pub.dev" - source: hosted - version: "6.0.2" platform: dependency: transitive description: @@ -792,22 +624,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.8" - scrollview_observer: - dependency: transitive - description: - name: scrollview_observer - sha256: "5ce907c5757d0805de974de8b17c0185f982dfe805dde22a0acb016b39182ece" - url: "https://pub.dev" - source: hosted - version: "1.27.0" - sembast: - dependency: transitive - description: - name: sembast - sha256: a58b26925e23071cf0f4754d8449aabe829e9a9930a872fb18e6ae4c2c88e025 - url: "https://pub.dev" - source: hosted - version: "3.8.9+1" shared_preferences: dependency: "direct main" description: @@ -820,10 +636,10 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: "93ae5884a9df5d3bb696825bceb3a17590754548b5d740eba51500afc8d088f5" + sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7" url: "https://pub.dev" source: hosted - version: "2.4.26" + version: "2.4.27" shared_preferences_foundation: dependency: transitive description: @@ -864,14 +680,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.1" - simple_rich_text: - dependency: "direct main" - description: - name: simple_rich_text - sha256: ba678f8075336995604ccc1e8bf407dc6455e0d6dee17c2aabadba38a9dbd23f - url: "https://pub.dev" - source: hosted - version: "2.0.49" sky_engine: dependency: transitive description: flutter @@ -885,22 +693,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" - sqflite: - dependency: "direct main" - description: - name: sqflite - sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082" - url: "https://pub.dev" - source: hosted - version: "2.4.3" - sqflite_android: - dependency: transitive - description: - name: sqflite_android - sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b - url: "https://pub.dev" - source: hosted - version: "2.4.3" sqflite_common: dependency: transitive description: @@ -909,22 +701,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.5.11" - sqflite_darwin: - dependency: transitive - description: - name: sqflite_darwin - sha256: c86ca18b8f666bbf903924687fe21cc16fc385d086005067e26619ca530bef9f - url: "https://pub.dev" - source: hosted - version: "2.4.3+1" - sqflite_platform_interface: - dependency: transitive + sqflite_sqlcipher: + dependency: "direct main" description: - name: sqflite_platform_interface - sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50 + name: sqflite_sqlcipher + sha256: ba7733c5514cf0ccb0331997b771a890f73678bcd84cdfb5f7487a88a71f1738 url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "3.4.0" stack_trace: dependency: transitive description: @@ -1045,6 +829,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.5" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" vector_math: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index ce6858a..bae9813 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -31,30 +31,24 @@ dependencies: flutter: sdk: flutter - http: ^1.6.0 web_socket_channel: ^3.0.3 - sqflite: ^2.4.3 - path: ^1.9.1 - cupertino_icons: ^1.0.8 - intl_phone_field: ^3.2.0 - pinput: ^6.0.2 provider: ^6.1.5+1 - image_picker: ^1.2.2 launcher_name: ^1.0.2 - shared_preferences: ^2.5.5 flutter_launcher_icons: ^0.14.4 - flutter_chat_ui: ^2.11.1 - flutter_chat_types: ^3.6.2 flutter_secure_storage: ^10.3.1 dio: ^5.10.0 flutter_markdown_plus: ^1.0.7 intl: ^0.20.3 - simple_rich_text: ^2.0.49 qr_flutter: ^4.1.0 - scrollable_positioned_list: ^0.3.8 flutter_animate: ^4.5.2 url_launcher: ^6.3.2 app_links: ^7.2.1 + sqflite_sqlcipher: ^3.4.0 + path_provider: ^2.1.6 + connectivity_plus: ^7.3.1 + uuid: ^4.6.0 + shared_preferences: ^2.5.5 + scrollable_positioned_list: ^0.3.8 launcher_name: default: "Elephant"