diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 0d29021..183f7ca 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -7,6 +7,10 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. +analyzer: + errors: + use_build_context_synchronously: ignore + use_null_aware_elements: ignore include: package:flutter_lints/flutter.yaml linter: diff --git a/mobile/lib/controllers/auth.dart b/mobile/lib/controllers/auth.dart index 3e1cbfc..49ec2d1 100644 --- a/mobile/lib/controllers/auth.dart +++ b/mobile/lib/controllers/auth.dart @@ -1,13 +1,11 @@ import 'dart:convert'; import 'package:flutter/material.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:dio/dio.dart'; import 'package:mobile/models/user.dart'; import '../services/auth.dart'; class AuthState extends ChangeNotifier { final AuthService _authService = AuthService(); - final FlutterSecureStorage _storage = const FlutterSecureStorage(); String? _token; bool _isLoading = false; @@ -44,7 +42,7 @@ class AuthState extends ChangeNotifier { } Future checkAutoLogin() async { - _token = await _storage.read(key: "access_token"); + _token = await _authService.getToken(); if (_token != null) { await loadUserProfile(); diff --git a/mobile/lib/controllers/chat.dart b/mobile/lib/controllers/chat.dart index c73f11b..337d3aa 100644 --- a/mobile/lib/controllers/chat.dart +++ b/mobile/lib/controllers/chat.dart @@ -1,33 +1,45 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:isolate'; -import 'package:dio/dio.dart'; import 'package:flutter/material.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 '../models/message.dart'; import '../models/conversation.dart'; import '../services/api.dart'; import '../services/ws.dart'; +import '../services/auth.dart'; class ChatController extends ChangeNotifier with WidgetsBindingObserver { final ApiService _api = ApiService(); final WebSocketService _ws = WebSocketService(); + final AuthService _auth = AuthService(); + // final AuthState _user = AuthState(); List activeChat = []; List contactSearchResults = []; List inbox = []; + Map groupMemberNames = {}; + Map userCache = {}; String? currentChatUserId; - String? _sessionToken; bool isPeerTyping = false; bool isPeerOnline = false; bool isSearchLoading = false; - bool _isWsInitialized = false; bool isChatHistoryLoading = false; + bool isCurrentChatGroup = false; + + bool _isLoadingDetails = false; + bool get isLoadingDetails => _isLoadingDetails; + + List _currentGroupMembers = []; + List get currentGroupMembers => _currentGroupMembers; + + bool _isWsInitialized = false; StreamSubscription? _wsSubscription; - Timer? _backgroundSyncTimer; + Timer? _reconnectTimer; ChatController() { WidgetsBinding.instance.addObserver(this); @@ -36,14 +48,13 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { - if (_sessionToken != null) { - _isWsInitialized = false; - _connectWebSocket(); - loadInbox(); - _startBackgroundSync(); - } + _isWsInitialized = false; + _connectWebSocket(); + loadInbox(); } else if (state == AppLifecycleState.paused) { - _backgroundSyncTimer?.cancel(); + _reconnectTimer?.cancel(); + _ws.disconnect(); + _isWsInitialized = false; } } @@ -51,35 +62,52 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { void dispose() { WidgetsBinding.instance.removeObserver(this); _wsSubscription?.cancel(); - _backgroundSyncTimer?.cancel(); + _reconnectTimer?.cancel(); + _ws.disconnect(); super.dispose(); } - Future initSession(String token) async { - _sessionToken = token; - _startBackgroundSync(); + Future initSession() async { + inbox.clear(); + activeChat.clear(); + contactSearchResults.clear(); + currentChatUserId = null; + isPeerTyping = false; + isPeerOnline = false; + + // _startBackgroundSync(); + if (_isWsInitialized) return; _isWsInitialized = true; + _connectWebSocket(); - await loadInbox(); + notifyListeners(); } - void _startBackgroundSync() { - _backgroundSyncTimer?.cancel(); - _backgroundSyncTimer = Timer.periodic(const Duration(seconds: 4), (_) { - loadInbox(); - - if (currentChatUserId != null) { - syncActiveChatSilently(); - } - }); + void clearSessionData() { + _ws.disconnect(); + _isWsInitialized = false; + inbox.clear(); + activeChat.clear(); + contactSearchResults.clear(); + currentChatUserId = null; + isPeerTyping = false; + isPeerOnline = false; + notifyListeners(); } void _connectWebSocket() async { - if (_sessionToken == null) return; + + if (_ws.isConnected) return; + + _reconnectTimer?.cancel(); + + final freshToken = await _auth.getToken(); + if (freshToken == null) return; await _wsSubscription?.cancel(); - await _ws.connect(_sessionToken!); + _ws.disconnect(); + await _ws.connect(freshToken); _wsSubscription = _ws.stream?.listen( (rawFrame) { @@ -98,42 +126,72 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { onDone: () { _ws.disconnect(); _isWsInitialized = false; - Future.delayed(const Duration(seconds: 3), () { - if (_sessionToken != null) { - _isWsInitialized = true; - _connectWebSocket(); - } + + _reconnectTimer?.cancel(); + _reconnectTimer = Timer(const Duration(seconds: 3), () { + _isWsInitialized = true; + _connectWebSocket(); }); }, ); } - Future openChat(String targetUid) async { + Future openChat(String targetUid, {bool isGroup = false}) async { if (targetUid.isEmpty || targetUid == 'null') return; currentChatUserId = targetUid; + isCurrentChatGroup = isGroup; activeChat.clear(); isPeerTyping = false; isPeerOnline = false; isChatHistoryLoading = true; + groupMemberNames.clear(); + + 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"); + }); + } + notifyListeners(); try { - final res = await _api.getChatHistory(targetUid); + final res = await _api.getChatHistory(targetUid, isGroup: isGroup); if (currentChatUserId != targetUid) return; final targetList = _extractDataList(res.data, ['messages']); - final loadedMessages = await Isolate.run(() { - return targetList.reversed - .map((json) => Message.fromJson(json)) - .toList(); - }); + final loadedMessages = targetList.reversed + .map((json) => Message.fromJson(json)) + .toList(); if (currentChatUserId != targetUid) return; activeChat = loadedMessages; - _ws.sendReadReceipt(targetId: targetUid); + _ws.sendReadReceipt( + receiverId: isCurrentChatGroup ? null : targetUid, + groupId: isCurrentChatGroup ? targetUid : null, + ); + _ws.sendRequestStatus(targetId: targetUid); + unawaited(loadInbox()); } catch (e) { debugPrint("Timeline tracking fail: $e"); @@ -143,6 +201,27 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { } } + 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) { @@ -178,14 +257,15 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { String text, { Message? replyingTo, String? replyingToName, + String? senderId, }) async { final cleanContent = text.trim(); if (currentChatUserId == null || cleanContent.isEmpty) return; - final targetId = currentChatUserId!; final clientMessageId = "cli_${DateTime.now().millisecondsSinceEpoch}"; QuotedMessage? quoted; + if (replyingTo != null) { quoted = QuotedMessage( id: replyingTo.id, @@ -205,30 +285,39 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { replyToMessageId: replyingTo?.id, quotedMessage: quoted, ); - activeChat.add(optimisticMsg); notifyListeners(); try { - await _api.sendMessage( - targetId, - cleanContent, - replyToMessageId: replyingTo?.id, - ); + isCurrentChatGroup && senderId != null + ? _ws.sendGroupChat( + messageId: "", + groupId: targetId, + content: cleanContent, + senderId: senderId, + replyToMessageId: replyingTo?.id, + ) + : _ws.sendChat( + messageId: "", + receiverId: targetId, + content: cleanContent, + replyToMessageId: replyingTo?.id, + ); unawaited(loadInbox()); } catch (e) { - if (e is DioException) { - debugPrint("BACKEND REJECTION REASON: ${e.response?.data}"); - } else { - debugPrint("Failed to send message: $e"); - } + debugPrint("Failed to send message: $e"); activeChat.removeWhere((msg) => msg.id == clientMessageId); notifyListeners(); } } + void sendTypingNotification(bool typing) { if (currentChatUserId != null) { - _ws.sendTyping(targetId: currentChatUserId!, isTyping: typing); + _ws.sendTyping( + receiverId: isCurrentChatGroup ? null : currentChatUserId, + groupId: isCurrentChatGroup ? currentChatUserId : null, + isTyping: typing, + ); } } @@ -236,12 +325,25 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { final String? type = data['type']; if (type == null) return; - final String? senderId = - data['sender_id'] ?? data['sender'] ?? data['receiver_id']; - final String? cleanSender = senderId?.trim().toLowerCase(); final String? cleanCurrentChat = currentChatUserId?.trim().toLowerCase(); - final bool isCurrentChat = - cleanSender != null && cleanSender == cleanCurrentChat; + + 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': @@ -250,11 +352,10 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { ?.toString() .trim() .toLowerCase(); - if (eventUserId == cleanCurrentChat) { + if (eventUserId == cleanCurrentChat && !isCurrentChatGroup) { isPeerOnline = data['online'] == true || data['content'] == 'online'; notifyListeners(); - - if (isPeerOnline) unawaited(syncActiveChatSilently()); + // if (isPeerOnline) unawaited(syncActiveChatSilently()); } break; @@ -262,7 +363,10 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { case 'message': if (isCurrentChat) { activeChat.add(Message.fromJson(data)); - _ws.sendReadReceipt(targetId: currentChatUserId!); + _ws.sendReadReceipt( + receiverId: isCurrentChatGroup ? null : currentChatUserId, + groupId: isCurrentChatGroup ? currentChatUserId : null, + ); notifyListeners(); } loadInbox(); @@ -272,16 +376,15 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { if (isCurrentChat) { final bool nowTyping = data['content'] == 'true' || data['content'] == true; - if (isPeerTyping != nowTyping) { isPeerTyping = nowTyping; notifyListeners(); - if (!isPeerTyping) { - Future.delayed(const Duration(milliseconds: 500), () { - unawaited(syncActiveChatSilently()); - }); - } + // if (!isPeerTyping) { + // Future.delayed(const Duration(milliseconds: 500), () { + // unawaited(syncActiveChatSilently()); + // }); + // } } } break; @@ -293,18 +396,26 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { final String payloadReceiver = (data['receiver_id'] ?? '') .toString() .toLowerCase(); + final String payloadGroup = (data['group_id'] ?? '') + .toString() + .toLowerCase(); final String safeChatId = (currentChatUserId ?? '').toLowerCase(); - final bool isRelevantToThisChat = - safeChatId.isNotEmpty && - (payloadSender == safeChatId || payloadReceiver == safeChatId); + bool isRelevantToThisChat = false; + if (safeChatId.isNotEmpty) { + if (isCurrentChatGroup) { + isRelevantToThisChat = (payloadGroup == safeChatId); + } else { + isRelevantToThisChat = + (payloadSender == safeChatId || payloadReceiver == safeChatId); + } + } 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; @@ -316,8 +427,7 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { if (updated) { notifyListeners(); } - - unawaited(syncActiveChatSilently()); + // unawaited(syncActiveChatSilently()); } break; } @@ -328,15 +438,16 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { final String targetUid = currentChatUserId!; try { - final res = await _api.getChatHistory(targetUid); + final res = await _api.getChatHistory( + targetUid, + isGroup: isCurrentChatGroup, + ); if (currentChatUserId != targetUid) return; final targetList = _extractDataList(res.data, ['messages']); - final loadedMessages = await Isolate.run(() { - return targetList.reversed - .map((json) => Message.fromJson(json)) - .toList(); - }); + final loadedMessages = targetList.reversed + .map((json) => Message.fromJson(json)) + .toList(); if (currentChatUserId != targetUid) return; @@ -357,8 +468,7 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { createdAt: loadedMessages[i].createdAt, isRead: loadedMessages[i].isRead, replyToMessageId: loadedMessages[i].replyToMessageId, - quotedMessage: - existingMsg.quotedMessage, + quotedMessage: existingMsg.quotedMessage, ); } } @@ -374,7 +484,10 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { if (hasChanges) { activeChat = loadedMessages; notifyListeners(); - _ws.sendReadReceipt(targetId: targetUid); + _ws.sendReadReceipt( + receiverId: isCurrentChatGroup ? null : targetUid, + groupId: isCurrentChatGroup ? targetUid : null, + ); } } catch (e) { debugPrint("Silent chat sync fail: $e"); @@ -395,44 +508,88 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { Future loadInbox() async { try { - final responses = await Future.wait([ - _api.getConversations(), - _api.getGroups(), - ]); - - final conversationRes = responses[0]; - final groupRes = responses[1]; - - final rawConversations = _extractDataList(conversationRes.data, [ - 'conversations', - ]); - final rawGroups = _extractDataList(groupRes.data, ['groups']); - - final mappedConversations = rawConversations - .map((json) => Conversation.fromJson(json)) - .map((conv) => InboxItem.fromConversation(conv)) - .toList(); + 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) { + _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(); + final name = m['display_name'] ?? 'Member'; + if (userCache[uid] != name) { + userCache[uid] = name; + updatedCache = true; + } + } + if (updatedCache) notifyListeners(); + }) + .catchError((_) {}); + } - final mappedGroups = rawGroups - .map((json) => Group.fromJson(json)) - .map((group) => InboxItem.fromGroup(group)) - .toList(); + } else { + combinedInbox.add( + InboxItem.fromConversation(Conversation.fromJson(json)), + ); + }} catch (e){ + debugPrint("❌ CRASH ON ITEM PARSE: $e"); + debugPrint("❌ BAD JSON OBJECT: $json"); + } + } - final combinedInbox = [...mappedConversations, ...mappedGroups]; combinedInbox.sort((a, b) => b.timestamp.compareTo(a.timestamp)); - if (inbox.length != combinedInbox.length || - (inbox.isNotEmpty && - combinedInbox.isNotEmpty && - inbox.first.id != combinedInbox.first.id) || - (inbox.isNotEmpty && - combinedInbox.isNotEmpty && - inbox.first.lastMessage != combinedInbox.first.lastMessage)) { + if (_hasInboxChanged(inbox, combinedInbox)) { inbox = combinedInbox; notifyListeners(); + if (currentChatUserId != null) { + unawaited(syncActiveChatSilently()); + } + } + } catch (e, stackTrace) { + debugPrint("❌ MAJOR INBOX READ ERROR: $e"); + debugPrint("❌ STACK TRACE: $stackTrace"); + } + } + + 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; } - } catch (e) { - debugPrint("Inbox read error: $e"); } + return false; } } diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index a1be3d8..29a2361 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -5,12 +5,15 @@ import 'package:provider/provider.dart'; import 'core/constants.dart'; import 'controllers/auth.dart'; import 'controllers/chat.dart'; +import 'services/auth.dart'; import 'pages/login.dart'; import 'pages/home_page.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); await Env.init(); + await AuthService().initTokens(); + runApp( MultiProvider( providers: [ @@ -65,7 +68,7 @@ class _SessionGatewayState extends State { if (token != null && mounted) { _lastInitializedToken = token; - context.read().initSession(token); + context.read().initSession(); } if (mounted) { @@ -91,7 +94,7 @@ class _SessionGatewayState extends State { if (authState.token != null && authState.token != _lastInitializedToken) { _lastInitializedToken = authState.token; WidgetsBinding.instance.addPostFrameCallback((_) { - context.read().initSession(authState.token!); + context.read().initSession(); }); } diff --git a/mobile/lib/models/group.dart b/mobile/lib/models/group.dart index 4532bcc..e91e8aa 100644 --- a/mobile/lib/models/group.dart +++ b/mobile/lib/models/group.dart @@ -3,20 +3,49 @@ class Group { final String name; final String createdBy; final DateTime createdAt; + final String? lastMessageSender; + final String lastMessage; + final DateTime lastMessageAt; Group({ required this.id, required this.name, required this.createdBy, required this.createdAt, + this.lastMessageSender, + required this.lastMessage, + required this.lastMessageAt, }); factory Group.fromJson(Map json) { + String content = 'No messages yet'; + + DateTime msgTime = DateTime.now(); + if (json['last_message_time'] != null) { + msgTime = DateTime.parse(json['last_message_time']); + } else if (json['created_at'] != null) { + msgTime = DateTime.parse(json['created_at']); + } + + final dynamic lastMsgData = json['last_message']; + if (lastMsgData != null) { + if (lastMsgData is Map) { + content = lastMsgData['content'] ?? content; + } else if (lastMsgData is String) { + content = lastMsgData; + } + } + return Group( id: json['id'] ?? '', name: json['name'] ?? '', createdBy: json['created_by'] ?? '', - createdAt: DateTime.parse(json['created_at'] ?? DateTime.now().toIso8601String()), + createdAt: json['created_at'] != null + ? DateTime.parse(json['created_at']) + : DateTime.now(), + lastMessageSender: json['sender_id'], + lastMessage: content, + lastMessageAt: msgTime, ); } -} \ No newline at end of file +} diff --git a/mobile/lib/models/inbox_item.dart b/mobile/lib/models/inbox_item.dart index 759fb4c..ed603a1 100644 --- a/mobile/lib/models/inbox_item.dart +++ b/mobile/lib/models/inbox_item.dart @@ -10,6 +10,7 @@ class InboxItem { final bool isGroup; final bool isRead; final int unreadCount; + final String? lastMessageSender; InboxItem({ required this.id, @@ -20,6 +21,7 @@ class InboxItem { this.isRead = true, this.unreadCount = 0, this.username, + this.lastMessageSender, }); InboxItem copyWith({ @@ -61,11 +63,12 @@ class InboxItem { return InboxItem( id: group.id, title: group.name, - lastMessage: 'Tap to view group', - timestamp: group.createdAt, + timestamp: group.lastMessageAt, isGroup: true, isRead: true, unreadCount: 0, + lastMessageSender: group.lastMessageSender, + lastMessage: group.lastMessage, ); } } diff --git a/mobile/lib/pages/chat_details_page.dart b/mobile/lib/pages/chat_details_page.dart new file mode 100644 index 0000000..d7c11bb --- /dev/null +++ b/mobile/lib/pages/chat_details_page.dart @@ -0,0 +1,365 @@ +import 'package:flutter/material.dart'; +import 'package:mobile/controllers/auth.dart'; +import 'package:mobile/controllers/chat.dart'; +import 'package:mobile/providers/group_controller_provider.dart'; +import 'package:provider/provider.dart'; + +class ChatMember { + final String userId; + final String username; + final String displayName; + final String role; + final String? avatarUrl; + + ChatMember({ + required this.userId, + required this.username, + required this.displayName, + required this.role, + this.avatarUrl, + }); + + factory ChatMember.fromJson(Map json) { + return ChatMember( + userId: json['user_id'].toString(), + username: json['username'] ?? '', + displayName: json['display_name'] ?? json['username'] ?? 'Unknown', + role: json['role'] ?? 'member', + avatarUrl: json['avatarUrl'], + ); + } +} + +class ChatDetailsPage extends StatefulWidget { + final bool isGroup; + final String chatName; + final String chatImageUrl; + final String chatId; + + const ChatDetailsPage({ + super.key, + required this.isGroup, + required this.chatName, + required this.chatImageUrl, + required this.chatId, + }); + + @override + State createState() => _ChatDetailsPageState(); +} + +class _ChatDetailsPageState extends State { + @override + void initState() { + super.initState(); + if (widget.isGroup) { + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().fetchGroupMembers(widget.chatId); + }); + } + } + + @override + Widget build(BuildContext context) { + final chatController = context.watch(); + + return Scaffold( + backgroundColor: Colors.grey[200], + body: CustomScrollView( + slivers: [ + _buildSliverAppBar(), + SliverList( + delegate: SliverChildListDelegate([ + const SizedBox(height: 10), + _buildCommonActions(), + const SizedBox(height: 10), + _buildMediaSection(), + const SizedBox(height: 10), + _buildNotificationSettings(), + const SizedBox(height: 10), + + if (widget.isGroup) + _buildGroupMembersSection(chatController) + else + _buildOneOnOneDetails(), + + const SizedBox(height: 40), + ]), + ), + ], + ), + ); + } + + Widget _buildSliverAppBar() { + return SliverAppBar( + expandedHeight: 250.0, + pinned: true, + flexibleSpace: FlexibleSpaceBar( + centerTitle: true, + title: Text(widget.chatName), + background: Hero( + tag: 'profile', + child: Padding( + padding: const EdgeInsets.all(50), + child: CircleAvatar(child: Icon(Icons.person, size: 100)), + ), + ), + ), + ); + } + + Widget _buildCommonActions() { + return _SectionContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _ActionIcon(icon: Icons.call, label: 'Audio'), + _ActionIcon(icon: Icons.videocam, label: 'Video'), + _ActionIcon( + icon: Icons.search, + label: 'Search', + ), // Search in chat feature + ], + ), + ); + } + + Widget _buildMediaSection() { + return _SectionContainer( + child: ListTile( + title: const Text('Media, links, and docs'), + trailing: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('0'), // Replace with actual count later + Icon(Icons.chevron_right), + ], + ), + onTap: () { + // TODO: Navigate to Media Page (API calls later) + }, + ), + ); + } + + Widget _buildNotificationSettings() { + return _SectionContainer( + child: Column( + children: [ + ListTile( + leading: const Icon(Icons.notifications_off), + title: const Text('Mute notifications'), + trailing: Switch(value: false, onChanged: (val) {}), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.music_note), + title: const Text('Custom notifications'), + onTap: () {}, + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.image), + title: const Text('Media visibility'), + onTap: () {}, + ), + ], + ), + ); + } + + Widget _buildOneOnOneDetails() { + return _SectionContainer( + child: Column( + children: [ + ListTile( + leading: const Icon(Icons.lock), + title: const Text('Encryption'), + subtitle: const Text( + 'Messages and calls are end-to-end encrypted.', + ), + onTap: () {}, + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.info_outline), + title: const Text('About and phone number'), + subtitle: const Text( + '+1 234 567 8900\nHey there! I am using this app.', + ), + onTap: () {}, + ), + ], + ), + ); + } + + Widget _buildGroupMembersSection(ChatController chatController) { + final members = chatController.currentGroupMembers; + final isLoading = chatController.isLoadingDetails; + + // Check if the CURRENT user is an admin by finding their ID in the members list + final currentUserId = context.read().currentUser?.id; + final currentUserMember = members + .where((m) => m.userId == currentUserId) + .firstOrNull; + final isCurrentUserAdmin = currentUserMember?.role == 'admin'; + + return Material( + color: Colors.white, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + '${members.length} participants', + style: const TextStyle( + color: Colors.grey, + fontWeight: FontWeight.bold, + ), + ), + ), + + // Only show Add button if the current user has the 'admin' role + if (isCurrentUserAdmin) + ListTile( + leading: const CircleAvatar( + backgroundColor: Colors.teal, + child: Icon(Icons.person_add, color: Colors.white), + ), + title: const Text('Add participants'), + onTap: () { + // TODO: Trigger Add Member Flow + }, + ), + + if (isLoading && members.isEmpty) + const Padding( + padding: EdgeInsets.all(32.0), + child: Center(child: CircularProgressIndicator()), + ) + else + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: members.length, + itemBuilder: (context, index) { + final member = members[index]; + final isThisUserAdmin = member.role == 'admin'; + + return ListTile( + leading: CircleAvatar( + backgroundImage: member.avatarUrl != null + ? NetworkImage(member.avatarUrl!) + : null, + child: member.avatarUrl == null + ? const Icon(Icons.person) + : null, + ), + // FIXED: Using display_name instead of name + title: Text(member.displayName), + // FIXED: Dynamically rendering Admin tag based on API role + subtitle: isThisUserAdmin + ? const Text( + 'Admin', + style: TextStyle(color: Colors.teal, fontSize: 12), + ) + : null, + onTap: () => + _showMemberDetailsDialog(member, isCurrentUserAdmin), + ); + }, + ), + ], + ), + ); + } + + // Action when clicking a group member + void _showMemberDetailsDialog(ChatMember member, bool isCurrentUserAdmin) { + showModalBottomSheet( + context: context, + builder: (context) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text('Message ${member.displayName}'), + onTap: () { + /* Navigate to 1-on-1 chat */ + }, + ), + ListTile( + title: Text('View Profile (@${member.username})'), + onTap: () { + Navigator.pop(context); + }, + ), + if (isCurrentUserAdmin && + member.userId != context.read().currentUser?.id) + ListTile( + title: const Text( + 'Remove from group', + style: TextStyle(color: Colors.red), + ), + onTap: () async { + Navigator.pop(context); + final success = await context + .read() + .removeMemberFromGroup(widget.chatId, member.userId); + if (success && mounted) { + context.read().fetchGroupMembers( + widget.chatId, + ); + } + }, + ), + ], + ), + ); + }, + ); + } +} + +// --- Reusable Helper Widgets --- + +class _SectionContainer extends StatelessWidget { + final Widget child; + const _SectionContainer({required this.child}); + + @override + Widget build(BuildContext context) { + return Material(color: Colors.white, child: child); + } +} + +class _ActionIcon extends StatelessWidget { + final IconData icon; + final String label; + + const _ActionIcon({required this.icon, required this.label}); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () {}, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: Column( + children: [ + Icon(icon, color: Theme.of(context).primaryColor, size: 28), + const SizedBox(height: 8), + Text( + label, + style: TextStyle(color: Theme.of(context).primaryColor), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/pages/chat_page.dart b/mobile/lib/pages/chat_page.dart index 5b2e0c1..7042e62 100644 --- a/mobile/lib/pages/chat_page.dart +++ b/mobile/lib/pages/chat_page.dart @@ -1,17 +1,21 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:mobile/controllers/auth.dart'; import 'package:provider/provider.dart'; import 'package:mobile/widgets/chat_screen_modular_widgets.dart'; import 'package:mobile/controllers/chat.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; class ChatPage extends StatefulWidget { final String chatUserId; final String displayName; + final bool isGroup; const ChatPage({ super.key, required this.chatUserId, required this.displayName, + this.isGroup = false, }); @override @@ -19,11 +23,19 @@ class ChatPage extends StatefulWidget { } class _ChatPageState extends State with WidgetsBindingObserver { - final ScrollController _scrollController = ScrollController(); + final ItemScrollController _itemScrollController = ItemScrollController(); + final ItemPositionsListener _itemPositionsListener = + ItemPositionsListener.create(); + bool _showScrollToBottom = false; + // ignore: prefer_final_fields bool _isNearBottom = true; final Set _selectedIndices = {}; dynamic _replyingToMessage; + late ChatController _chatController; + late AuthState _authState; + + String? _highlightedMessageId; int _previousMessageCount = 0; @@ -31,11 +43,14 @@ class _ChatPageState extends State with WidgetsBindingObserver { void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); - _scrollController.addListener(_scrollListener); + _itemPositionsListener.itemPositions.addListener(_scrollListener); + + _chatController = context.read(); + _authState = context.read(); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { - context.read().openChat(widget.chatUserId); + _chatController.openChat(widget.chatUserId, isGroup: widget.isGroup); } }); } @@ -43,13 +58,13 @@ class _ChatPageState extends State with WidgetsBindingObserver { @override void dispose() { WidgetsBinding.instance.removeObserver(this); - _scrollController.removeListener(_scrollListener); - _scrollController.dispose(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - context.read().closeChat(); - } + + _itemPositionsListener.itemPositions.removeListener(_scrollListener); + + Future.microtask(() { + _chatController.closeChat(); }); + super.dispose(); } @@ -65,12 +80,53 @@ class _ChatPageState extends State with WidgetsBindingObserver { } } + void _scrollToAndHighlight(String messageId) async { + final chatState = context.read(); + final activeChat = chatState.activeChat; + + final targetIndex = activeChat.indexWhere((msg) => msg.id == messageId); + + if (targetIndex != -1 && _itemScrollController.isAttached) { + final int realVisualIndex = + (activeChat.length - 1 - targetIndex) + + (chatState.isPeerTyping ? 2 : 1); + + await _itemScrollController.scrollTo( + index: realVisualIndex, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOutCubic, + alignment: 0.5, + ); + + setState(() { + _highlightedMessageId = messageId; + }); + + Future.delayed(const Duration(milliseconds: 1500), () { + if (mounted) { + setState(() { + _highlightedMessageId = null; + }); + } + }); + } + } + void _scrollListener() { - if (!_scrollController.hasClients) return; + final positions = _itemPositionsListener.itemPositions.value; + if (positions.isEmpty) return; + + final bottomItem = positions.firstWhere( + (p) => p.index == 0, + orElse: () => const ItemPosition( + index: -1, + itemLeadingEdge: 0, + itemTrailingEdge: 0, + ), + ); - final offset = _scrollController.offset; - _isNearBottom = offset <= 300; - final isScrolledUp = offset > 300; + final isScrolledUp = + bottomItem.index == -1 || bottomItem.itemLeadingEdge < -0.1; if (isScrolledUp != _showScrollToBottom) { setState(() { @@ -80,16 +136,16 @@ class _ChatPageState extends State with WidgetsBindingObserver { } void _scrollToBottom({bool animated = true}) { - if (!_scrollController.hasClients) return; + if (!_itemScrollController.isAttached) return; if (animated) { - _scrollController.animateTo( - 0.0, + _itemScrollController.scrollTo( + index: 0, duration: const Duration(milliseconds: 350), curve: Curves.easeOutCubic, ); } else { - _scrollController.jumpTo(0.0); + _itemScrollController.jumpTo(index: 0); } } @@ -109,6 +165,7 @@ class _ChatPageState extends State with WidgetsBindingObserver { text, replyingTo: _replyingToMessage, replyingToName: replyName, + senderId: _authState.currentUser!.displayName, ); setState(() { @@ -253,9 +310,13 @@ class _ChatPageState extends State with WidgetsBindingObserver { ], ) : GlassAppBar( + isGroup: widget.isGroup, + chatId: chatState.currentChatUserId!, key: const ValueKey('GlassAppBar'), name: widget.displayName, - status: _getPresenceStatusText(chatState), + status: widget.isGroup + ? null + : _getPresenceStatusText(chatState), ), ), ), @@ -296,25 +357,29 @@ class _ChatPageState extends State with WidgetsBindingObserver { ), ), ) - : ListView.builder( + : ScrollablePositionedList.builder( key: const ValueKey('list'), - controller: _scrollController, + itemScrollController: _itemScrollController, + itemPositionsListener: _itemPositionsListener, reverse: true, physics: const AlwaysScrollableScrollPhysics( parent: BouncingScrollPhysics(), ), - keyboardDismissBehavior: - ScrollViewKeyboardDismissBehavior.onDrag, padding: const EdgeInsets.only( - bottom: 140, + // bottom: 140, top: 140, left: 16, right: 16, ), itemCount: - activeChat.length + (chatState.isPeerTyping ? 2 : 1), + 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, @@ -324,7 +389,7 @@ class _ChatPageState extends State with WidgetsBindingObserver { ); } - if (chatState.isPeerTyping && index == 1) { + if (chatState.isPeerTyping && index == 2) { return _AnimatedMessageItem( key: const ValueKey('typing_indicator'), child: Align( @@ -361,9 +426,12 @@ class _ChatPageState extends State with WidgetsBindingObserver { } final int msgIndex = - index - (chatState.isPeerTyping ? 2 : 1); + 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, ); @@ -381,19 +449,36 @@ class _ChatPageState extends State with WidgetsBindingObserver { final String cleanSenderId = msg.senderId .trim() .toLowerCase(); - final String cleanPeerId = widget.chatUserId + widget.chatUserId .trim() .toLowerCase(); + final bool isMe = cleanSenderId == 'me' || - (cleanSenderId.isNotEmpty && - cleanSenderId != cleanPeerId); + (_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 < activeChat.length - 1) { + final previousMsg = activeChat[realIndex + 1]; + if (previousMsg.senderId.trim().toLowerCase() == + cleanSenderId) { + showSenderName = false; + } + } return _AnimatedMessageItem( - key: ValueKey( - msg.id?.toString() ?? - msg.createdAt.millisecondsSinceEpoch.toString(), - ), + key: ValueKey(msg.id.toString()), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -438,11 +523,11 @@ class _ChatPageState extends State with WidgetsBindingObserver { duration: const Duration(milliseconds: 200), curve: Curves.easeOutCubic, child: AnimatedContainer( - duration: const Duration(milliseconds: 200), + duration: const Duration(milliseconds: 350), decoration: BoxDecoration( - color: isSelected + color: isSelected || isHighlighted ? theme.colorScheme.primary - .withValues(alpha: 0.15) + .withValues(alpha: 0.25) : Colors.transparent, borderRadius: BorderRadius.circular(12), ), @@ -459,6 +544,15 @@ class _ChatPageState extends State with WidgetsBindingObserver { 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, ), ), ), diff --git a/mobile/lib/pages/home_page.dart b/mobile/lib/pages/home_page.dart index ea04f23..8ee5a74 100644 --- a/mobile/lib/pages/home_page.dart +++ b/mobile/lib/pages/home_page.dart @@ -2,9 +2,7 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:mobile/controllers/chat.dart'; -import 'package:mobile/pages/settings%20pages/accounts.dart'; import 'package:mobile/pages/settings_page.dart'; -import 'package:mobile/providers/basic_providers.dart'; import 'package:mobile/services/auth.dart'; import 'package:mobile/widgets/custom_cards.dart'; import 'package:provider/provider.dart'; @@ -46,10 +44,14 @@ class _HomePageState extends State { WidgetsBinding.instance.addPostFrameCallback((_) async { final chatController = context.read(); - final authService = AuthService(); - final token = await authService.getToken(); + + final token = await AuthService().getToken(); + + if (!mounted) return; + if (token != null) { - await chatController.initSession(token); + await chatController.initSession(); + chatController.loadInbox(); } }); } diff --git a/mobile/lib/pages/phone_number_page.dart b/mobile/lib/pages/phone_number_page.dart index a448a01..81304ac 100644 --- a/mobile/lib/pages/phone_number_page.dart +++ b/mobile/lib/pages/phone_number_page.dart @@ -1,5 +1,4 @@ import 'package:mobile/pages/profile_setup_page.dart'; -import 'package:mobile/providers/basic_providers.dart'; import 'package:mobile/providers/timer_provider.dart'; import 'package:flutter/material.dart'; import 'package:intl_phone_field/intl_phone_field.dart'; @@ -32,18 +31,8 @@ class PhoneNumberPage extends StatelessWidget { initialCountryCode: 'IN', controller: numberController, decoration: InputDecoration(border: OutlineInputBorder()), - onChanged: (value) { - context.read().updateConCode(value.countryCode); - context.read().updateNumber(value.number); - context.read().setNumberValid( - value.isValidNumber(), - ); - }, - onCountryChanged: (value) { - context.read().updateConCode( - '+${value.dialCode}', - ); - }, + onChanged: (value) {}, + onCountryChanged: (value) {}, ), Spacer(), Container( @@ -54,11 +43,8 @@ class PhoneNumberPage extends StatelessWidget { Size(double.infinity, 40), ), ), - onPressed: context.watch().isNumberValid - ? () { - context.read().updateNumber( - numberController.text, - ); + onPressed: + () { Navigator.of(context).push( MaterialPageRoute( builder: (context) => ChangeNotifierProvider( @@ -67,8 +53,7 @@ class PhoneNumberPage extends StatelessWidget { ), ), ); - } - : null, + }, child: Text('Next'), ), ), @@ -99,7 +84,7 @@ class NumberVerificationPage extends StatelessWidget { ), const SizedBox(height: 10), Text( - "We've sent a 6-digit code to\n${context.read().getCompleteNumber}", + "We've sent a 6-digit code to\n", textAlign: .center, ), const SizedBox(height: 25), diff --git a/mobile/lib/pages/profile_setup_page.dart b/mobile/lib/pages/profile_setup_page.dart index 57161f5..11d5fed 100644 --- a/mobile/lib/pages/profile_setup_page.dart +++ b/mobile/lib/pages/profile_setup_page.dart @@ -1,5 +1,4 @@ import 'package:mobile/pages/home_page.dart'; -import 'package:mobile/providers/basic_providers.dart'; import 'package:mobile/providers/image_picker_provider.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -78,7 +77,7 @@ class ProfileSetupPage extends StatelessWidget { context, MaterialPageRoute(builder: (context) => const HomePage()), ); - context.read().logInSave(); + // context.read().logInSave(); }, child: Row( mainAxisAlignment: .center, diff --git a/mobile/lib/pages/settings pages/accounts.dart b/mobile/lib/pages/settings pages/accounts.dart index b85a4db..553389a 100644 --- a/mobile/lib/pages/settings pages/accounts.dart +++ b/mobile/lib/pages/settings pages/accounts.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:mobile/controllers/auth.dart'; -import 'package:mobile/providers/basic_providers.dart'; +import 'package:mobile/controllers/chat.dart'; import 'package:mobile/main.dart'; import 'package:provider/provider.dart'; @@ -138,9 +138,8 @@ class AccountsSettings extends StatelessWidget { backgroundColor: WidgetStatePropertyAll(Colors.redAccent), ), onPressed: () async { + context.read().clearSessionData(); await context.read().logout(); - await context.read().logOutSave(); - if (context.mounted) { Navigator.pushAndRemoveUntil( context, diff --git a/mobile/lib/providers/basic_providers.dart b/mobile/lib/providers/basic_providers.dart index e4d752c..5f5729a 100644 --- a/mobile/lib/providers/basic_providers.dart +++ b/mobile/lib/providers/basic_providers.dart @@ -2,12 +2,6 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; class BasicProviders extends ChangeNotifier { - String _phoneNumber = ''; - String _conCode = ''; - bool _isNumberValid = false; - bool _hasLoggedIn = false; - bool _isInitialized = false; - bool _notificationsSwitch = false; bool get notificationsSwitch => _notificationsSwitch; @@ -26,52 +20,4 @@ class BasicProviders extends ChangeNotifier { pref.setBool('notificationToggle', _notificationsSwitch); } - bool get isInitialized => _isInitialized; - - bool get hasLoggedIn => _hasLoggedIn; - bool get isNumberValid => _isNumberValid; - String get getCompleteNumber => '$_conCode $_phoneNumber'; - - BasicProviders() { - _loadLoginState(); - } - - Future _loadLoginState() async { - final pref = await SharedPreferences.getInstance(); - _hasLoggedIn = pref.getBool('hasLoggedIn') ?? false; - _isInitialized = true; - - notifyListeners(); - } - - Future logInSave() async { - _hasLoggedIn = true; - notifyListeners(); - - final pref = await SharedPreferences.getInstance(); - await pref.setBool('hasLoggedIn', true); - } - - Future logOutSave() async { - _hasLoggedIn = false; - notifyListeners(); - - final pref = await SharedPreferences.getInstance(); - await pref.setBool('hasLoggedIn', false); - } - - void setNumberValid(bool val) { - _isNumberValid = val; - notifyListeners(); - } - - void updateNumber(String num) { - _phoneNumber = num; - notifyListeners(); - } - - void updateConCode(String val) { - _conCode = val; - notifyListeners(); - } } diff --git a/mobile/lib/providers/group_controller_provider.dart b/mobile/lib/providers/group_controller_provider.dart index 4811571..8b96ef2 100644 --- a/mobile/lib/providers/group_controller_provider.dart +++ b/mobile/lib/providers/group_controller_provider.dart @@ -76,4 +76,49 @@ class GroupController extends ChangeNotifier { notifyListeners(); } } + + Future addMemberToGroup(String groupId, String userId) async { + try { + _isLoading = true; + notifyListeners(); + + final response = await _api.post( + '/groups/$groupId/members', + data: {"user_id": userId}, + ); + + if (response.statusCode == 200 || response.statusCode == 201) { + return true; + } + return false; + } on DioException catch (e) { + debugPrint("Add Member Error: ${e.response?.data}"); + return false; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + // Inside GroupController + + Future removeMemberFromGroup(String groupId, String userId) async { + try { + _isLoading = true; + notifyListeners(); + + final response = await _api.delete('/groups/$groupId/members/$userId'); + + if (response.statusCode == 200) { + return true; + } + return false; + } on DioException catch (e) { + debugPrint("Remove Member Error: ${e.response?.data}"); + return false; + } finally { + _isLoading = false; + notifyListeners(); + } + } } diff --git a/mobile/lib/services/api.dart b/mobile/lib/services/api.dart index 5f1b62b..00be614 100644 --- a/mobile/lib/services/api.dart +++ b/mobile/lib/services/api.dart @@ -7,6 +7,8 @@ class ApiService { final Dio _dio = Dio(); final AuthService _auth = AuthService(); + Future? _refreshFuture; + ApiService() { _dio.interceptors.add( InterceptorsWrapper( @@ -23,38 +25,21 @@ class ApiService { }, onError: (DioException e, handler) async { if (e.response?.statusCode == 401) { - final refreshToken = await _auth.getRefreshToken(); - - if (refreshToken != null) { - try { - final refreshResponse = await _auth.refreshAccessToken( - refreshToken, - ); - final resData = refreshResponse.data; - - if (resData['success'] == true) { - final newAccessToken = resData['data']['access_token']; - final newRefreshToken = resData['data']['refresh_token']; - - await _auth.saveTokens(newAccessToken, newRefreshToken); - - final cloneOptions = e.requestOptions; - cloneOptions.headers["Authorization"] = - "Bearer $newAccessToken"; - - final retryResponse = await _dio.fetch(cloneOptions); - return handler.resolve(retryResponse); - } - } catch (refreshError) { - if (AuthState.onGlobalUnauthorized != null) { - AuthState.onGlobalUnauthorized!(); - } - return handler.next(e); - } - } - - if (AuthState.onGlobalUnauthorized != null) { - AuthState.onGlobalUnauthorized!(); + try { + _refreshFuture ??= _refreshToken(); + await _refreshFuture; + + final clone = e.requestOptions; + clone.headers["Authorization"] = + "Bearer ${await _auth.getToken()}"; + + final retry = await _dio.fetch(clone); + return handler.resolve(retry); + } catch (refreshError) { + AuthState.onGlobalUnauthorized?.call(); + rethrow; + } finally { + _refreshFuture = null; } } return handler.next(e); @@ -63,19 +48,55 @@ class ApiService { ); } + Future _refreshToken() async { + final refreshToken = await _auth.getRefreshToken(); + + if (refreshToken == null) { + throw Exception("No refresh token"); + } + + final response = await _auth.refreshAccessToken(refreshToken); + final data = response.data; + + if (data["success"] != true) { + throw Exception("Refresh failed"); + } + + await _auth.saveTokens( + data["data"]["access_token"], + data["data"]["refresh_token"], + ); + } + Future getConversations() async { return await _dio.get("/messages/conversations"); } - Future getGroups() async { - return await _dio.get("/groups"); - } + Future getChatHistory( + String targetId, { + String? before, + bool isGroup = false, + }) async { + final Map params = {"limit": 40}; + if (before != null) params["before"] = before; + if (isGroup) { + return await _dio.get( + "/groups/$targetId/messages", + queryParameters: params, + ); + } else { + params["with"] = targetId; + return await _dio.get("/messages", queryParameters: params); + } + } - Future getChatHistory(String partnerId, {String? before}) async { - final Map params = {"with": partnerId, "limit": 40}; - if (before != null) params["before"] = before; - return await _dio.get("/messages", queryParameters: params); + Future getGroupMembers(String groupId) async { + try { + return await _dio.get("/groups/$groupId/members"); + } catch (e) { + rethrow; + } } Future searchUsers(String query) async { @@ -92,21 +113,6 @@ class ApiService { ); } - Future sendMessage( - String receiverId, - String content, { - String? replyToMessageId, - }) async { - return await _dio.post( - '/messages', - data: { - "receiver_id": receiverId, - "content": content, - if (replyToMessageId != null) "reply_to_message_id": replyToMessageId, - }, - ); - } - Future post( String path, { dynamic data, @@ -115,4 +121,22 @@ class ApiService { return await _dio.post(path, data: data, queryParameters: queryParameters); } + Future get( + String path, { + Map? queryParameters, + }) async { + return await _dio.get(path, queryParameters: queryParameters); + } + + Future delete( + String path, { + dynamic data, + Map? queryParameters, + }) async { + return await _dio.delete( + path, + data: data, + queryParameters: queryParameters, + ); + } } diff --git a/mobile/lib/services/auth.dart b/mobile/lib/services/auth.dart index 80bbf48..5e8cc1a 100644 --- a/mobile/lib/services/auth.dart +++ b/mobile/lib/services/auth.dart @@ -1,48 +1,39 @@ import 'package:dio/dio.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../core/constants.dart'; -import '../controllers/auth.dart'; class AuthService { final Dio _dio = Dio(); final FlutterSecureStorage _storage = const FlutterSecureStorage(); - AuthService() { - _dio.interceptors.add( - InterceptorsWrapper( - onRequest: (options, handler) { - options.baseUrl = Env.httpBaseUrl; - return handler.next(options); - }, - onResponse: (response, handler) { - return handler.next(response); - }, - onError: (DioException e, handler) { - if (e.response?.statusCode == 401) { - if (AuthState.onGlobalUnauthorized != null) { - AuthState.onGlobalUnauthorized!(); - } - } - return handler.next(e); - }, - ), - ); - } + static final AuthService _instance = AuthService._internal(); + factory AuthService() => _instance; - Future getToken() async { - return await _storage.read(key: "access_token"); + AuthService._internal() { + _dio.options.baseUrl = Env.httpBaseUrl; } - Future getRefreshToken() async { - return await _storage.read(key: "refresh_token"); + String? _cachedAccessToken; + String? _cachedRefreshToken; + + Future initTokens() async { + _cachedAccessToken = await _storage.read(key: "access_token"); + _cachedRefreshToken = await _storage.read(key: "refresh_token"); } + Future getToken() async => _cachedAccessToken; + Future getRefreshToken() async => _cachedRefreshToken; + Future saveTokens(String accessToken, String refreshToken) async { + _cachedAccessToken = accessToken; + _cachedRefreshToken = refreshToken; await _storage.write(key: "access_token", value: accessToken); await _storage.write(key: "refresh_token", value: refreshToken); } Future logout() async { + _cachedAccessToken = null; + _cachedRefreshToken = null; await _storage.delete(key: "access_token"); await _storage.delete(key: "refresh_token"); } @@ -82,4 +73,5 @@ class AuthService { options: Options(headers: {'Authorization': 'Bearer $token'}), ); } + } diff --git a/mobile/lib/services/ws.dart b/mobile/lib/services/ws.dart index c03a62a..279fc54 100644 --- a/mobile/lib/services/ws.dart +++ b/mobile/lib/services/ws.dart @@ -15,42 +15,74 @@ class WebSocketService { try { final wsUrl = Uri.parse("${Env.wsBaseUrl}?token=$token"); _channel = WebSocketChannel.connect(wsUrl); + + await _channel!.ready; + _isConnected = true; debugPrint("WebSocket Pipeline Connected straight to: ${Env.wsBaseUrl}"); } catch (e) { _isConnected = false; - debugPrint("WebSocket connection failure: $e"); + debugPrint("WebSocket connection failure (Handshake rejected): $e"); } } void emit(Map payload) { if (!_isConnected) return; + debugPrint("Sending Payload to WS: ${jsonEncode(payload)}"); _channel?.sink.add(jsonEncode(payload)); } void sendChat({ required String messageId, - required String targetId, + required String receiverId, + required String content, + String? replyToMessageId, + }) { + emit({ + "type": "chat", + "message_id": messageId, + "receiver_id": receiverId, + "content": content, + "reply_to_message_id": replyToMessageId, + }); + } + + void sendGroupChat({ + required String messageId, + required String groupId, required String content, + String? replyToMessageId, + required String senderId, }) { emit({ "type": "chat", "message_id": messageId, - "receiver_id": targetId, + "group_id": groupId, "content": content, + "reply_to_message_id": replyToMessageId, + "sender_id": senderId, }); } - void sendTyping({required String targetId, required bool isTyping}) { + void sendTyping({ + String? receiverId, + String? groupId, + required bool isTyping, + }) { emit({ "type": "typing", - "receiver_id": targetId, + if (receiverId != null) "receiver_id": receiverId, + if (groupId != null) "group_id": groupId, "content": isTyping.toString(), }); } - void sendReadReceipt({required String targetId}) { - emit({"type": "read_receipt", "receiver_id": targetId}); + void sendReadReceipt({String? receiverId, String? groupId}) { + emit({ + "type": "read_receipt", + if (receiverId != null) "receiver_id": receiverId, + if (groupId != null) "group_id": groupId, + }); } void sendRequestStatus({required String targetId}) { @@ -63,5 +95,4 @@ class WebSocketService { _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 0099d23..517a1a2 100644 --- a/mobile/lib/widgets/chat_screen_modular_widgets.dart +++ b/mobile/lib/widgets/chat_screen_modular_widgets.dart @@ -5,20 +5,23 @@ import 'package:flutter/services.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:intl/intl.dart'; import 'package:mobile/models/message.dart'; -import 'package:provider/provider.dart'; -import 'package:mobile/controllers/chat.dart'; +import 'package:mobile/pages/chat_details_page.dart'; enum UserStatus { offline, online } class GlassAppBar extends StatelessWidget implements PreferredSizeWidget { final String name; - final UserStatus status; + final UserStatus? status; + final bool isGroup; + final String chatId; - const GlassAppBar({super.key, required this.name, required this.status}); - - String _getStatusText() { - return status == UserStatus.online ? "Online" : "Offline"; - } + const GlassAppBar({ + super.key, + required this.name, + required this.status, + required this.isGroup, + required this.chatId, + }); @override Widget build(BuildContext context) { @@ -35,35 +38,55 @@ class GlassAppBar extends StatelessWidget implements PreferredSizeWidget { icon: Icon(Icons.arrow_back, color: theme.iconTheme.color), onPressed: () => Navigator.pop(context), ), - title: Row( - children: [ - CircleAvatar( - backgroundColor: theme.colorScheme.primaryContainer, - child: Icon(Icons.person, color: theme.colorScheme.primary), - ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - name, - style: TextStyle( - color: theme.textTheme.titleLarge?.color, - fontSize: 16, - fontWeight: FontWeight.bold, - ), + title: GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatDetailsPage( + isGroup: isGroup, + chatId: chatId, + chatName: name, + chatImageUrl: "", ), - Text( - _getStatusText(), - style: TextStyle( - color: theme.textTheme.bodySmall?.color, - fontSize: 12, - fontWeight: FontWeight.normal, - ), + ), + ); + }, + child: Row( + children: [ + Hero( + tag: 'profile', + child: CircleAvatar( + backgroundColor: theme.colorScheme.primaryContainer, + child: Icon(Icons.person, color: theme.colorScheme.primary), ), - ], - ), - ], + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + 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(), + ], + ), + ], + ), ), ), ), @@ -75,11 +98,14 @@ class GlassAppBar extends StatelessWidget implements PreferredSizeWidget { } class ChatBubble extends StatelessWidget { + final VoidCallback? onQuoteTap; final String message; final bool isMe; final DateTime timestamp; final bool isRead; final QuotedMessage? quotedMessage; + final String? senderName; + final bool isGroup; const ChatBubble({ super.key, @@ -88,8 +114,24 @@ class ChatBubble extends StatelessWidget { required this.timestamp, required this.isRead, this.quotedMessage, + this.senderName, + this.isGroup = false, + this.onQuoteTap, }); + Color _getSenderColor(String name) { + final hash = name.hashCode; + final colors = [ + Colors.red, + Colors.blue, + Colors.green, + Colors.orange, + Colors.purple, + Colors.teal, + ]; + return colors[hash % colors.length]; + } + MarkdownStyleSheet _getMarkdownStyle( bool isMe, Color primaryColor, @@ -155,56 +197,60 @@ class ChatBubble extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - if (quotedMessage != null) - Container( - padding: const EdgeInsets.all(8), - margin: const EdgeInsets.only(bottom: 6), - decoration: BoxDecoration( - color: isMe - ? theme.colorScheme.onPrimary.withValues(alpha: 0.15) - : theme.colorScheme.onSurfaceVariant.withValues( - alpha: 0.15, - ), - borderRadius: BorderRadius.circular(8), - border: Border( - left: BorderSide( - color: isMe - ? theme.colorScheme.onPrimary - : theme.colorScheme.primary, - width: 3, - ), + if (isGroup && !isMe && senderName != null) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + senderName!, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: _getSenderColor(senderName!), ), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - quotedMessage!.senderDisplayName, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, + ), + if (quotedMessage != null) + GestureDetector( + onTap: onQuoteTap, + child: Container( + padding: const EdgeInsets.all(8), + margin: const EdgeInsets.only(bottom: 6), + decoration: BoxDecoration( + color: isMe + ? theme.colorScheme.onPrimary.withValues(alpha: 0.15) + : theme.colorScheme.onSurfaceVariant.withValues( + alpha: 0.15, + ), + borderRadius: BorderRadius.circular(8), + border: Border( + left: BorderSide( color: isMe ? theme.colorScheme.onPrimary : theme.colorScheme.primary, + width: 3, ), ), - const SizedBox(height: 2), - Text( - quotedMessage!.content, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12, - fontStyle: FontStyle.italic, - color: isMe - ? theme.colorScheme.onPrimary.withValues( - alpha: 0.8, - ) - : theme.colorScheme.onSurfaceVariant, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + quotedMessage!.content, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + fontStyle: FontStyle.italic, + color: isMe + ? theme.colorScheme.onPrimary.withValues( + alpha: 0.8, + ) + : theme.colorScheme.onSurfaceVariant, + ), ), - ), - ], + ], + ), ), ), MarkdownBody( @@ -340,740 +386,109 @@ class _ChatInputAreaState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final bottomPadding = MediaQuery.of(context).padding.bottom; - return SafeArea( - top: false, - bottom: true, - child: ClipRRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), - child: Container( - color: Colors.transparent, - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 20), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: TextField( - controller: _controller, - focusNode: _focusNode, - onChanged: _handleOnChange, - keyboardType: TextInputType.multiline, - minLines: 1, - maxLines: 5, - maxLength: _charLimit, - maxLengthEnforcement: MaxLengthEnforcement.none, - style: TextStyle( + return ClipRRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), + child: Container( + color: Colors.transparent, + padding: EdgeInsets.only( + left: 12.0, + right: 12.0, + top: 20, + bottom: 20 + bottomPadding, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextField( + controller: _controller, + focusNode: _focusNode, + onChanged: _handleOnChange, + keyboardType: TextInputType.multiline, + minLines: 1, + maxLines: 5, + maxLength: _charLimit, + maxLengthEnforcement: MaxLengthEnforcement.none, + style: TextStyle( + color: _isOverLimit + ? theme.colorScheme.error + : theme.colorScheme.onSurface, + fontSize: 15, + ), + buildCounter: + ( + context, { + required currentLength, + required isFocused, + required maxLength, + }) => _isOverLimit + ? Text('$currentLength/$maxLength') + : null, + decoration: InputDecoration( + hintText: "Write a message...", + hintStyle: TextStyle( + color: theme.colorScheme.onSurfaceVariant, + ), + fillColor: _isOverLimit + ? theme.colorScheme.error.withValues(alpha: 0.1) + : theme.colorScheme.surfaceContainerHighest, + filled: true, + errorText: _isOverLimit ? 'character limit exceeded' : null, + counterStyle: TextStyle( color: _isOverLimit ? theme.colorScheme.error - : theme.colorScheme.onSurface, - fontSize: 15, + : theme.colorScheme.onSurfaceVariant, + fontWeight: _isOverLimit + ? FontWeight.bold + : FontWeight.normal, ), - buildCounter: - ( - context, { - required currentLength, - required isFocused, - required maxLength, - }) => _isOverLimit - ? Text('$currentLength/$maxLength') - : null, - decoration: InputDecoration( - hintText: "Write a message...", - hintStyle: TextStyle( - color: theme.colorScheme.onSurfaceVariant, - ), - fillColor: _isOverLimit - ? theme.colorScheme.error.withValues(alpha: 0.1) - : theme.colorScheme.surfaceContainerHighest, - filled: true, - errorText: _isOverLimit - ? 'character limit exceeded' - : null, - counterStyle: TextStyle( - color: _isOverLimit - ? theme.colorScheme.error - : theme.colorScheme.onSurfaceVariant, - fontWeight: _isOverLimit - ? FontWeight.bold - : FontWeight.normal, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - borderSide: _isOverLimit - ? BorderSide( - color: theme.colorScheme.error, - width: 1.5, - ) - : BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - borderSide: _isOverLimit - ? BorderSide( - color: theme.colorScheme.error, - width: 2, - ) - : BorderSide.none, - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - borderSide: _isOverLimit - ? BorderSide( - color: theme.colorScheme.error, - width: 1.5, - ) - : BorderSide.none, - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, - ), - ), - ), - ), - const SizedBox(width: 8), - Padding( - padding: const EdgeInsets.only(bottom: 4), - child: IconButton( - icon: _isOverLimit - ? const Icon(Icons.not_interested) - : Icon(Icons.send, color: theme.colorScheme.primary), - onPressed: _isOverLimit ? null : _submit, - ), - ), - ], - ), - ), - ), - ), - ); - } -} - -class ChatPage extends StatefulWidget { - final String chatUserId; - final String displayName; - - const ChatPage({ - super.key, - required this.chatUserId, - required this.displayName, - }); - - @override - State createState() => _ChatPageState(); -} - -class _ChatPageState extends State with WidgetsBindingObserver { - final ScrollController _scrollController = ScrollController(); - bool _showScrollToBottom = false; - bool _isNearBottom = true; - final Set _selectedIndices = {}; - dynamic _replyingToMessage; - - int _previousMessageCount = 0; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addObserver(this); - _scrollController.addListener(_scrollListener); - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - context.read().openChat(widget.chatUserId); - } - }); - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - _scrollController.removeListener(_scrollListener); - _scrollController.dispose(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - context.read().closeChat(); - } - }); - super.dispose(); - } - - @override - void didChangeMetrics() { - super.didChangeMetrics(); - if (_isNearBottom) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _scrollToBottom(animated: true); - } - }); - } - } - - void _scrollListener() { - if (!_scrollController.hasClients) return; - - final offset = _scrollController.offset; - _isNearBottom = offset <= 300; - final isScrolledUp = offset > 300; - - if (isScrolledUp != _showScrollToBottom) { - setState(() { - _showScrollToBottom = isScrolledUp; - }); - } - } - - void _scrollToBottom({bool animated = true}) { - if (!_scrollController.hasClients) return; - - if (animated) { - _scrollController.animateTo( - 0.0, - duration: const Duration(milliseconds: 350), - curve: Curves.easeOutCubic, - ); - } else { - _scrollController.jumpTo(0.0); - } - } - - void _sendMessage(String text) { - if (text.trim().isEmpty) return; - - context.read().sendTextMessage( - text, - replyingTo: _replyingToMessage, - ); - - setState(() { - _replyingToMessage = null; - }); - - WidgetsBinding.instance.addPostFrameCallback((_) { - _scrollToBottom(); - }); - } - - void _toggleSelection(int index) { - setState(() { - if (_selectedIndices.contains(index)) { - _selectedIndices.remove(index); - } else { - _selectedIndices.add(index); - } - }); - } - - void _clearSelection() { - setState(() { - _selectedIndices.clear(); - }); - } - - void _copySelectedMessages(List activeChat) { - final sortedIndices = _selectedIndices.toList()..sort(); - - final selectedTexts = sortedIndices - .map((index) => activeChat[index].content.toString()) - .join('\n'); - - Clipboard.setData(ClipboardData(text: selectedTexts)); - - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Copied to clipboard'))); - - _clearSelection(); - } - - UserStatus _getPresenceStatusText(ChatController state) { - return state.isPeerOnline ? UserStatus.online : UserStatus.offline; - } - - bool _isSameDay(DateTime date1, DateTime date2) { - return date1.year == date2.year && - date1.month == date2.month && - date1.day == date2.day; - } - - String _formatDateSeparator(DateTime date) { - final now = DateTime.now(); - if (_isSameDay(date, now)) return 'Today'; - if (_isSameDay(date, now.subtract(const Duration(days: 1)))) { - return 'Yesterday'; - } - return "${date.day}/${date.month}/${date.year}"; - } - - @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) { - if (!didPop) { - setState(() { - _selectedIndices.clear(); - }); - } - }, - child: Scaffold( - backgroundColor: theme.scaffoldBackgroundColor, - extendBody: true, - extendBodyBehindAppBar: true, - appBar: PreferredSize( - preferredSize: const Size.fromHeight(kToolbarHeight), - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - transitionBuilder: (Widget child, Animation animation) { - return FadeTransition( - opacity: animation, - child: SlideTransition( - position: Tween( - begin: const Offset(0.0, -0.2), - end: Offset.zero, - ).animate(animation), - child: child, - ), - ); - }, - child: isSelectionMode - ? AppBar( - key: const ValueKey('SelectionAppBar'), - backgroundColor: theme.colorScheme.primary, - leading: IconButton( - icon: Icon( - Icons.close, - color: theme.colorScheme.onPrimary, - ), - onPressed: _clearSelection, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: _isOverLimit + ? BorderSide( + color: theme.colorScheme.error, + width: 1.5, + ) + : BorderSide.none, ), - title: Text( - '${_selectedIndices.length} Selected', - style: TextStyle(color: theme.colorScheme.onPrimary), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: _isOverLimit + ? BorderSide(color: theme.colorScheme.error, width: 2) + : BorderSide.none, ), - actions: [ - IconButton( - icon: Icon( - Icons.copy, - color: theme.colorScheme.onPrimary, - ), - onPressed: () => _copySelectedMessages(activeChat), - ), - IconButton( - icon: Icon( - Icons.delete, - color: theme.colorScheme.onPrimary, - ), - onPressed: () { - _clearSelection(); - }, - ), - ], - ) - : GlassAppBar( - key: const ValueKey('GlassAppBar'), - name: widget.displayName, - status: _getPresenceStatusText(chatState), - ), - ), - ), - body: Stack( - children: [ - if (chatState.isChatHistoryLoading && activeChat.isEmpty) - Center( - child: CircularProgressIndicator( - color: theme.colorScheme.primary, - ), - ) - else if (activeChat.isEmpty) - Center( - child: Text( - "No messages yet.\nSay hi!", - textAlign: TextAlign.center, - style: TextStyle( - color: theme.colorScheme.onSurfaceVariant, - fontSize: 14, - ), - ), - ) - else - ListView.builder( - key: const ValueKey('list'), - controller: _scrollController, - reverse: true, - physics: const AlwaysScrollableScrollPhysics( - parent: BouncingScrollPhysics(), - ), - keyboardDismissBehavior: - ScrollViewKeyboardDismissBehavior.onDrag, - padding: const EdgeInsets.only( - bottom: 140, - top: 140, - left: 16, - right: 16, - ), - itemCount: activeChat.length + (chatState.isPeerTyping ? 2 : 1), - itemBuilder: (context, index) { - if (index == 0) { - return AnimatedSize( - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutCubic, - child: SizedBox( - height: _replyingToMessage != null ? 80 : 0, - ), - ); - } - - if (chatState.isPeerTyping && index == 1) { - 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(4)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - "typing...", - style: TextStyle( - color: theme.colorScheme.primary, - fontStyle: FontStyle.italic, - fontWeight: FontWeight.w500, - fontSize: 14, - ), - ), - ], - ), - ), - ), - ); - } - - final int msgIndex = index - (chatState.isPeerTyping ? 2 : 1); - final int realIndex = activeChat.length - 1 - msgIndex; - final msg = activeChat[realIndex]; - 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(); - final String cleanPeerId = widget.chatUserId - .trim() - .toLowerCase(); - final bool isMe = - cleanSenderId == 'me' || - (cleanSenderId.isNotEmpty && - cleanSenderId != cleanPeerId); - - QuotedMessage? displayQuote = msg.quotedMessage; - - if (msg.replyToMessageId != null && - msg.replyToMessageId!.isNotEmpty) { - if (displayQuote == null || - displayQuote.senderDisplayName.isEmpty) { - Message? parentMsg; - try { - parentMsg = activeChat.firstWhere( - (m) => m.id == msg.replyToMessageId, - ); - } catch (_) {} - - if (parentMsg != null) { - final String pSenderId = parentMsg.senderId - .trim() - .toLowerCase(); - displayQuote = QuotedMessage( - id: parentMsg.id, - senderId: pSenderId, - senderDisplayName: - (pSenderId == 'me' || - (pSenderId.isNotEmpty && - pSenderId != cleanPeerId)) - ? "You" - : widget.displayName, - content: parentMsg.content, - ); - } - } - } - - final String? targetReplyId = - msg.replyToMessageId ?? displayQuote?.id; - - if (targetReplyId != null && targetReplyId.isNotEmpty) { - Message? parentMsg; - try { - parentMsg = activeChat.firstWhere( - (m) => m.id == targetReplyId, - ); - } catch (_) {} - - final String pSenderId = - (parentMsg?.senderId ?? displayQuote?.senderId ?? '') - .trim() - .toLowerCase(); - - String calculatedName = - displayQuote?.senderDisplayName ?? ''; - if (calculatedName.trim().isEmpty || - calculatedName == 'Unknown' || - calculatedName == 'null') { - calculatedName = "Previous Message"; - } - - if (pSenderId.isNotEmpty) { - if (pSenderId == 'me' || - (pSenderId != cleanPeerId && - pSenderId != - widget.chatUserId.trim().toLowerCase())) { - calculatedName = "You"; - } else { - calculatedName = widget.displayName; - } - } - - displayQuote = QuotedMessage( - id: targetReplyId, - senderId: pSenderId, - senderDisplayName: calculatedName, - content: - displayQuote?.content ?? - parentMsg?.content ?? - 'Message not loaded', - ); - } - - return _AnimatedMessageItem( - key: ValueKey( - msg.id?.toString() ?? - msg.createdAt.millisecondsSinceEpoch.toString(), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: _isOverLimit + ? BorderSide( + color: theme.colorScheme.error, + width: 1.5, + ) + : BorderSide.none, ), - 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, - ), - ), - ), - ), - 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: 200), - decoration: BoxDecoration( - color: isSelected - ? theme.colorScheme.primary.withValues( - alpha: 0.15, - ) - : 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, - quotedMessage: displayQuote, - ), - ), - ), - ), - ), - ], + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, ), - ); - }, - ), - AnimatedPositioned( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, - right: 16, - bottom: _replyingToMessage != null ? 180 : 100, - child: AnimatedScale( - scale: _showScrollToBottom ? 1.0 : 0.0, - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutBack, - child: AnimatedOpacity( - opacity: _showScrollToBottom ? 1.0 : 0.0, - duration: const Duration(milliseconds: 200), - child: FloatingActionButton( - mini: true, - backgroundColor: theme.colorScheme.surface, - foregroundColor: theme.colorScheme.primary, - elevation: 4, - onPressed: () => _scrollToBottom(animated: true), - child: const Icon(Icons.keyboard_arrow_down), ), ), ), - ), - Align( - alignment: Alignment.bottomCenter, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AnimatedSize( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, - alignment: Alignment.bottomCenter, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 250), - opacity: _replyingToMessage != null ? 1.0 : 0.0, - child: _replyingToMessage != null - ? Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: theme.colorScheme.surface, - border: Border( - left: BorderSide( - color: theme.colorScheme.primary, - width: 4, - ), - top: BorderSide( - color: theme.dividerColor, - width: 1, - ), - ), - boxShadow: [ - BoxShadow( - color: theme.shadowColor.withValues( - alpha: 0.1, - ), - blurRadius: 4, - offset: const Offset(0, -2), - ), - ], - ), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - "Replying to ${_replyingToMessage != null && (_replyingToMessage.senderId.trim().toLowerCase() == 'me' || _replyingToMessage.senderId.trim().toLowerCase() != widget.chatUserId.trim().toLowerCase()) ? 'You' : widget.displayName}", - style: TextStyle( - fontWeight: FontWeight.bold, - color: theme.colorScheme.primary, - fontSize: 12, - ), - ), - const SizedBox(height: 4), - Text( - _replyingToMessage.content, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: theme.colorScheme.onSurface, - ), - ), - ], - ), - ), - IconButton( - icon: Icon( - Icons.close, - size: 20, - color: theme.iconTheme.color, - ), - onPressed: () => setState( - () => _replyingToMessage = null, - ), - ), - ], - ), - ) - : const SizedBox(width: double.infinity, height: 0), - ), - ), - ChatInputArea( - onSendMessage: _sendMessage, - onTypingChanged: (isTyping) => context - .read() - .sendTypingNotification(isTyping), - ), - ], + const SizedBox(width: 8), + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: IconButton( + icon: _isOverLimit + ? const Icon(Icons.not_interested) + : Icon(Icons.send, color: theme.colorScheme.primary), + onPressed: _isOverLimit ? null : _submit, + ), ), - ), - ], + ], + ), ), ), ); @@ -1083,7 +498,7 @@ class _ChatPageState extends State with WidgetsBindingObserver { class _AnimatedMessageItem extends StatefulWidget { final Widget child; - const _AnimatedMessageItem({super.key, required this.child}); + const _AnimatedMessageItem({required this.child}); @override State<_AnimatedMessageItem> createState() => _AnimatedMessageItemState(); diff --git a/mobile/lib/widgets/custom_cards.dart b/mobile/lib/widgets/custom_cards.dart index e1c83be..f8dcc53 100644 --- a/mobile/lib/widgets/custom_cards.dart +++ b/mobile/lib/widgets/custom_cards.dart @@ -1,4 +1,7 @@ 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'; @@ -38,6 +41,19 @@ class CustomChatCard extends StatelessWidget { Widget build(BuildContext context) { final String timeLabel = _formatTimestamp(conversation.timestamp); final bool hasUnread = conversation.unreadCount > 0; + final chatState = context.watch(); + + final currentUserId = context.read().currentUser?.id; + + String? senderName; + if (conversation.isGroup && conversation.lastMessageSender != null) { + if (conversation.lastMessageSender == currentUserId) { + senderName = "You"; + } else { + senderName = + chatState.userCache[conversation.lastMessageSender!] ?? "Member"; + } + } return Material( color: isSelected @@ -56,6 +72,7 @@ class CustomChatCard extends StatelessWidget { builder: (_) => ChatPage( chatUserId: conversation.id, displayName: conversation.title, + isGroup: conversation.isGroup, ), ), ); @@ -118,7 +135,9 @@ class CustomChatCard extends StatelessWidget { ), const SizedBox(height: 4), SimpleRichText( - conversation.lastMessage.replaceAll('\n', ' '), + senderName != null + ? "*$senderName:* ${conversation.lastMessage.replaceAll('\n', ' ')}" + : conversation.lastMessage.replaceAll('\n', ' '), maxLines: 1, textOverflow: TextOverflow.ellipsis, style: TextStyle( diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 01d93fb..b9d1046 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -744,6 +744,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + scrollable_positioned_list: + dependency: "direct main" + description: + name: scrollable_positioned_list + sha256: "1b54d5f1329a1e263269abc9e2543d90806131aa14fe7c6062a8054d57249287" + url: "https://pub.dev" + source: hosted + version: "0.3.8" scrollview_observer: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 0bdcb15..a4e9646 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -52,6 +52,7 @@ dependencies: intl: ^0.20.3 simple_rich_text: ^2.0.49 qr_flutter: ^4.1.0 + scrollable_positioned_list: ^0.3.8 launcher_name: default: "Elephant"