diff --git a/mobile/lib/controllers/auth.dart b/mobile/lib/controllers/auth.dart index d228e43..3e1cbfc 100644 --- a/mobile/lib/controllers/auth.dart +++ b/mobile/lib/controllers/auth.dart @@ -2,6 +2,7 @@ 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 { @@ -12,18 +13,43 @@ class AuthState extends ChangeNotifier { bool _isLoading = false; String? _errorMessage; + UserModel? _currentUser; + String? get token => _token; bool get isLoading => _isLoading; String? get errorMessage => _errorMessage; + UserModel? get currentUser => _currentUser; + static VoidCallback? onGlobalUnauthorized; AuthState() { onGlobalUnauthorized = logoutSilently; } + Future loadUserProfile() async { + if (_token == null) return; + + try { + final res = await _authService.getCurrentUser(_token!); + final data = res.data; + + final userData = data['data'] ?? data['user'] ?? data; + + _currentUser = UserModel.fromJson(userData); + notifyListeners(); + } catch (e) { + debugPrint("Failed to load user profile: $e"); + } + } + Future checkAutoLogin() async { _token = await _storage.read(key: "access_token"); + + if (_token != null) { + await loadUserProfile(); + } + notifyListeners(); return _token; } @@ -53,6 +79,9 @@ class AuthState extends ChangeNotifier { tokens['access_token'], tokens['refresh_token'], ); + + await loadUserProfile(); + _isLoading = false; notifyListeners(); return true; @@ -100,6 +129,9 @@ class AuthState extends ChangeNotifier { tokens['access_token'], tokens['refresh_token'], ); + + await loadUserProfile(); + _isLoading = false; notifyListeners(); return true; @@ -119,6 +151,7 @@ class AuthState extends ChangeNotifier { Future logout() async { _token = null; + _currentUser = null; await _authService.logout(); notifyListeners(); } @@ -126,6 +159,7 @@ class AuthState extends ChangeNotifier { void logoutSilently() { if (_token != null) { _token = null; + _currentUser = null; _authService.logout(); notifyListeners(); } diff --git a/mobile/lib/controllers/chat.dart b/mobile/lib/controllers/chat.dart index 4c58cdc..c73f11b 100644 --- a/mobile/lib/controllers/chat.dart +++ b/mobile/lib/controllers/chat.dart @@ -1,33 +1,90 @@ import 'dart:async'; import 'dart:convert'; import 'dart:isolate'; +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; +import 'package:mobile/models/group.dart'; +import 'package:mobile/models/inbox_item.dart'; import '../models/message.dart'; import '../models/conversation.dart'; import '../services/api.dart'; import '../services/ws.dart'; -class ChatController extends ChangeNotifier { +class ChatController extends ChangeNotifier with WidgetsBindingObserver { final ApiService _api = ApiService(); final WebSocketService _ws = WebSocketService(); - List inbox = []; List activeChat = []; List contactSearchResults = []; + List inbox = []; String? currentChatUserId; + String? _sessionToken; bool isPeerTyping = false; bool isPeerOnline = false; bool isSearchLoading = false; bool _isWsInitialized = false; + bool isChatHistoryLoading = false; + + StreamSubscription? _wsSubscription; + Timer? _backgroundSyncTimer; + + ChatController() { + WidgetsBinding.instance.addObserver(this); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + if (_sessionToken != null) { + _isWsInitialized = false; + _connectWebSocket(); + loadInbox(); + _startBackgroundSync(); + } + } else if (state == AppLifecycleState.paused) { + _backgroundSyncTimer?.cancel(); + } + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _wsSubscription?.cancel(); + _backgroundSyncTimer?.cancel(); + super.dispose(); + } Future initSession(String token) async { + _sessionToken = token; + _startBackgroundSync(); if (_isWsInitialized) return; _isWsInitialized = true; + _connectWebSocket(); + await loadInbox(); + } - await _ws.connect(token); - _ws.stream?.listen( + void _startBackgroundSync() { + _backgroundSyncTimer?.cancel(); + _backgroundSyncTimer = Timer.periodic(const Duration(seconds: 4), (_) { + loadInbox(); + + if (currentChatUserId != null) { + syncActiveChatSilently(); + } + }); + } + + void _connectWebSocket() async { + if (_sessionToken == null) return; + + await _wsSubscription?.cancel(); + await _ws.connect(_sessionToken!); + + _wsSubscription = _ws.stream?.listen( (rawFrame) { + unawaited(loadInbox()); + try { final decoded = jsonDecode(rawFrame); if (decoded is Map) { @@ -41,63 +98,54 @@ class ChatController extends ChangeNotifier { onDone: () { _ws.disconnect(); _isWsInitialized = false; + Future.delayed(const Duration(seconds: 3), () { + if (_sessionToken != null) { + _isWsInitialized = true; + _connectWebSocket(); + } + }); }, ); - - await loadInbox(); - } - - Future loadInbox() async { - try { - final res = await _api.getConversations(); - final targetList = _extractDataList(res.data, ['conversations']); - - inbox = targetList.map((json) => Conversation.fromJson(json)).toList(); - notifyListeners(); - } catch (e) { - debugPrint("Inbox read error: $e"); - } } Future openChat(String targetUid) async { + if (targetUid.isEmpty || targetUid == 'null') return; + currentChatUserId = targetUid; activeChat.clear(); isPeerTyping = false; isPeerOnline = false; + isChatHistoryLoading = true; notifyListeners(); try { final res = await _api.getChatHistory(targetUid); - final targetList = _extractDataList(res.data, ['messages']); + if (currentChatUserId != targetUid) return; - activeChat = await Isolate.run(() { + final targetList = _extractDataList(res.data, ['messages']); + final loadedMessages = await Isolate.run(() { return targetList.reversed .map((json) => Message.fromJson(json)) .toList(); }); + if (currentChatUserId != targetUid) return; + + activeChat = loadedMessages; _ws.sendReadReceipt(targetId: targetUid); _ws.sendRequestStatus(targetId: targetUid); - - notifyListeners(); - unawaited(loadInbox()); } catch (e) { debugPrint("Timeline tracking fail: $e"); + } finally { + isChatHistoryLoading = false; notifyListeners(); } } Future queryUsers(String term) async { final String cleanTerm = term.trim(); - - if (cleanTerm.isEmpty) { - contactSearchResults.clear(); - notifyListeners(); - return; - } - - if (cleanTerm.length < 3) { + if (cleanTerm.isEmpty || cleanTerm.length < 3) { contactSearchResults.clear(); notifyListeners(); return; @@ -126,14 +174,26 @@ class ChatController extends ChangeNotifier { notifyListeners(); } - void sendTextMessage(String text) { + Future sendTextMessage( + String text, { + Message? replyingTo, + String? replyingToName, + }) async { final cleanContent = text.trim(); if (currentChatUserId == null || cleanContent.isEmpty) return; final targetId = currentChatUserId!; final clientMessageId = "cli_${DateTime.now().millisecondsSinceEpoch}"; - _ws.sendChat(messageId: "", targetId: targetId, content: cleanContent); + QuotedMessage? quoted; + if (replyingTo != null) { + quoted = QuotedMessage( + id: replyingTo.id, + senderId: replyingTo.senderId, + senderDisplayName: replyingToName ?? 'Unknown', + content: replyingTo.content, + ); + } final optimisticMsg = Message( id: clientMessageId, @@ -142,14 +202,30 @@ class ChatController extends ChangeNotifier { content: cleanContent, createdAt: DateTime.now(), isRead: false, + replyToMessageId: replyingTo?.id, + quotedMessage: quoted, ); activeChat.add(optimisticMsg); notifyListeners(); - loadInbox(); + try { + await _api.sendMessage( + targetId, + 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"); + } + activeChat.removeWhere((msg) => msg.id == clientMessageId); + notifyListeners(); + } } - void sendTypingNotification(bool typing) { if (currentChatUserId != null) { _ws.sendTyping(targetId: currentChatUserId!, isTyping: typing); @@ -177,6 +253,8 @@ class ChatController extends ChangeNotifier { if (eventUserId == cleanCurrentChat) { isPeerOnline = data['online'] == true || data['content'] == 'online'; notifyListeners(); + + if (isPeerOnline) unawaited(syncActiveChatSilently()); } break; @@ -192,17 +270,43 @@ class ChatController extends ChangeNotifier { case 'typing': if (isCurrentChat) { - isPeerTyping = data['content'] == 'true'; - notifyListeners(); + 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()); + }); + } + } } break; case 'read_receipt': - if (isCurrentChat) { + final String payloadSender = (data['sender_id'] ?? '') + .toString() + .toLowerCase(); + final String payloadReceiver = (data['receiver_id'] ?? '') + .toString() + .toLowerCase(); + final String safeChatId = (currentChatUserId ?? '').toLowerCase(); + + final bool isRelevantToThisChat = + safeChatId.isNotEmpty && + (payloadSender == safeChatId || payloadReceiver == safeChatId); + + if (isRelevantToThisChat) { bool updated = false; + activeChat = activeChat.map((msg) { - final String sId = msg.senderId.trim().toLowerCase(); - if (!msg.isRead && (sId == 'me' || sId != cleanCurrentChat)) { + final String msgSenderId = msg.senderId.trim().toLowerCase(); + + if (!msg.isRead && + (msgSenderId == 'me' || msgSenderId != safeChatId)) { updated = true; return msg.copyWith(isRead: true); } @@ -211,13 +315,72 @@ class ChatController extends ChangeNotifier { if (updated) { notifyListeners(); - loadInbox(); } + + unawaited(syncActiveChatSilently()); } break; } } + Future syncActiveChatSilently() async { + if (currentChatUserId == null) return; + final String targetUid = currentChatUserId!; + + try { + final res = await _api.getChatHistory(targetUid); + if (currentChatUserId != targetUid) return; + + final targetList = _extractDataList(res.data, ['messages']); + final loadedMessages = await Isolate.run(() { + return targetList.reversed + .map((json) => Message.fromJson(json)) + .toList(); + }); + + 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 (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, + ); + } + } + } + + 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; + } + + if (hasChanges) { + activeChat = loadedMessages; + notifyListeners(); + _ws.sendReadReceipt(targetId: targetUid); + } + } catch (e) { + debugPrint("Silent chat sync fail: $e"); + } + } + List _extractDataList(dynamic data, List fallbackKeys) { if (data == null) return []; if (data is List) return data; @@ -229,4 +392,47 @@ class ChatController extends ChangeNotifier { } return []; } + + 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 mappedGroups = rawGroups + .map((json) => Group.fromJson(json)) + .map((group) => InboxItem.fromGroup(group)) + .toList(); + + 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)) { + inbox = combinedInbox; + notifyListeners(); + } + } catch (e) { + debugPrint("Inbox read error: $e"); + } + } } diff --git a/mobile/lib/models/conversation.dart b/mobile/lib/models/conversation.dart index 4d06b6b..7004675 100644 --- a/mobile/lib/models/conversation.dart +++ b/mobile/lib/models/conversation.dart @@ -20,15 +20,25 @@ class Conversation { }); factory Conversation.fromJson(Map json) { + final String extractedId = + json['chat_user_id'] ?? + json['user_id'] ?? + json['id'] ?? + json['partner_id'] ?? + ''; + return Conversation( - chatUserId: json['chat_user_id'] ?? '', - username: json['username'] ?? '', + chatUserId: extractedId, + username: + json['name'] ?? json['username'] ?? json['user_name'] ?? 'Unknown', displayName: json['display_name'] ?? '', lastMessage: json['last_message'] ?? '', - lastMessageTime: DateTime.parse(json['last_message_time'] ?? DateTime.now().toIso8601String()).toLocal(), + lastMessageTime: DateTime.parse( + json['last_message_time'] ?? DateTime.now().toIso8601String(), + ).toLocal(), senderId: json['sender_id'] ?? '', isRead: json['is_read'] ?? false, unreadCount: json['unread_count'] ?? 0, ); } -} \ No newline at end of file +} diff --git a/mobile/lib/models/inbox_item.dart b/mobile/lib/models/inbox_item.dart new file mode 100644 index 0000000..759fb4c --- /dev/null +++ b/mobile/lib/models/inbox_item.dart @@ -0,0 +1,71 @@ +import 'package:mobile/models/conversation.dart'; +import 'package:mobile/models/group.dart'; + +class InboxItem { + final String id; + final String title; + final String? username; + final String lastMessage; + final DateTime timestamp; + final bool isGroup; + final bool isRead; + final int unreadCount; + + InboxItem({ + required this.id, + required this.title, + required this.lastMessage, + required this.timestamp, + required this.isGroup, + this.isRead = true, + this.unreadCount = 0, + this.username, + }); + + InboxItem copyWith({ + String? id, + String? title, + String? lastMessage, + String? username, + DateTime? timestamp, + bool? isGroup, + bool? isRead, + int? unreadCount, + }) { + return InboxItem( + id: id ?? this.id, + username: username ?? this.username, + title: title ?? this.title, + lastMessage: lastMessage ?? this.lastMessage, + timestamp: timestamp ?? this.timestamp, + isGroup: isGroup ?? this.isGroup, + isRead: isRead ?? this.isRead, + unreadCount: unreadCount ?? this.unreadCount, + ); + } + + factory InboxItem.fromConversation(Conversation conv) { + return InboxItem( + id: conv.chatUserId, + username: conv.username, + title: conv.displayName, + lastMessage: conv.lastMessage, + timestamp: conv.lastMessageTime, + isGroup: false, + isRead: conv.isRead, + unreadCount: conv.unreadCount, + ); + } + + factory InboxItem.fromGroup(Group group) { + return InboxItem( + id: group.id, + title: group.name, + lastMessage: 'Tap to view group', + timestamp: group.createdAt, + isGroup: true, + isRead: true, + unreadCount: 0, + ); + } +} diff --git a/mobile/lib/models/message.dart b/mobile/lib/models/message.dart index b523be8..fcc0044 100644 --- a/mobile/lib/models/message.dart +++ b/mobile/lib/models/message.dart @@ -1,3 +1,34 @@ +class QuotedMessage { + final String id; + final String senderId; + final String senderDisplayName; + final String content; + + QuotedMessage({ + required this.id, + required this.senderId, + required this.senderDisplayName, + required this.content, + }); + + 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'] ?? '', + ); + } + + Map toJson() => { + 'id': id, + 'sender_id': senderId, + 'sender_display_name': senderDisplayName, + 'content': content, + }; +} + class Message { final String id; final String senderId; @@ -5,6 +36,8 @@ class Message { final String content; final DateTime createdAt; final bool isRead; + final String? replyToMessageId; + final QuotedMessage? quotedMessage; Message({ required this.id, @@ -13,30 +46,27 @@ class Message { required this.content, required this.createdAt, required this.isRead, + this.replyToMessageId, + this.quotedMessage, }); factory Message.fromJson(Map json) { return Message( - id: json['id'] ?? json['message_id'] ?? '', + id: json['client_message_id'] ?? json['message_id'] ?? json['id'] ?? '', senderId: json['sender_id'] ?? '', receiverId: json['receiver_id'] ?? '', content: json['content'] ?? '', - createdAt: DateTime.parse( - json['created_at'] ?? json['timestamp'] ?? DateTime.now().toIso8601String(), - ).toLocal(), + createdAt: json['created_at'] != null + ? DateTime.parse(json['created_at']).toLocal() + : DateTime.now(), isRead: json['is_read'] ?? false, + replyToMessageId: json['reply_to_message_id'], + quotedMessage: json['quoted_message'] != null + ? QuotedMessage.fromJson(json['quoted_message']) + : null, ); } - Map toJson() => { - 'id': id, - 'sender_id': senderId, - 'receiver_id': receiverId, - 'content': content, - 'created_at': createdAt.toIso8601String(), - 'is_read': isRead, - }; - Message copyWith({bool? isRead}) { return Message( id: id, @@ -45,6 +75,8 @@ class Message { content: content, createdAt: createdAt, isRead: isRead ?? this.isRead, + replyToMessageId: replyToMessageId, + quotedMessage: quotedMessage, ); } -} \ No newline at end of file +} diff --git a/mobile/lib/models/user.dart b/mobile/lib/models/user.dart new file mode 100644 index 0000000..b3d61fa --- /dev/null +++ b/mobile/lib/models/user.dart @@ -0,0 +1,25 @@ +class UserModel { + final String id; + final String username; + final String displayName; + final String? email; + final String? avatarUrl; + + UserModel({ + required this.id, + required this.username, + required this.displayName, + this.email, + this.avatarUrl, + }); + + factory UserModel.fromJson(Map json) { + return UserModel( + id: json['id']?.toString() ?? '', + username: json['username'] ?? '', + displayName: json['display_name'] ?? json['displayName'] ?? 'User', + email: json['email'], + avatarUrl: json['avatar_url'] ?? json['avatarUrl'], + ); + } +} diff --git a/mobile/lib/pages/chat_page.dart b/mobile/lib/pages/chat_page.dart index f85ae75..5b2e0c1 100644 --- a/mobile/lib/pages/chat_page.dart +++ b/mobile/lib/pages/chat_page.dart @@ -25,6 +25,8 @@ class _ChatPageState extends State with WidgetsBindingObserver { final Set _selectedIndices = {}; dynamic _replyingToMessage; + int _previousMessageCount = 0; + @override void initState() { super.initState(); @@ -32,7 +34,9 @@ class _ChatPageState extends State with WidgetsBindingObserver { _scrollController.addListener(_scrollListener); WidgetsBinding.instance.addPostFrameCallback((_) { - _scrollToBottom(animated: false); + if (mounted) { + context.read().openChat(widget.chatUserId); + } }); } @@ -64,12 +68,9 @@ class _ChatPageState extends State with WidgetsBindingObserver { void _scrollListener() { if (!_scrollController.hasClients) return; - final maxScrollExtent = _scrollController.position.maxScrollExtent; final offset = _scrollController.offset; - - _isNearBottom = maxScrollExtent - offset <= 100; - - final isScrolledUp = maxScrollExtent - offset > 100; + _isNearBottom = offset <= 300; + final isScrolledUp = offset > 300; if (isScrolledUp != _showScrollToBottom) { setState(() { @@ -83,19 +84,32 @@ class _ChatPageState extends State with WidgetsBindingObserver { if (animated) { _scrollController.animateTo( - _scrollController.position.maxScrollExtent, + 0.0, duration: const Duration(milliseconds: 350), curve: Curves.easeOutCubic, ); } else { - _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + _scrollController.jumpTo(0.0); } } void _sendMessage(String text) { if (text.trim().isEmpty) return; - context.read().sendTextMessage(text); + String? replyName; + if (_replyingToMessage != null) { + final String pId = _replyingToMessage.senderId.trim().toLowerCase(); + final bool isMe = + pId == 'me' || + (pId.isNotEmpty && pId != widget.chatUserId.trim().toLowerCase()); + replyName = isMe ? "You" : widget.displayName; + } + + context.read().sendTextMessage( + text, + replyingTo: _replyingToMessage, + replyingToName: replyName, + ); setState(() { _replyingToMessage = null; @@ -139,7 +153,6 @@ class _ChatPageState extends State with WidgetsBindingObserver { } UserStatus _getPresenceStatusText(ChatController state) { - if (state.isPeerTyping) return UserStatus.typing; return state.isPeerOnline ? UserStatus.online : UserStatus.offline; } @@ -165,12 +178,25 @@ class _ChatPageState extends State with WidgetsBindingObserver { 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) { - setState(() { - _selectedIndices.clear(); - }); + if (!didPop) { + setState(() { + _selectedIndices.clear(); + }); + } }, child: Scaffold( backgroundColor: theme.scaffoldBackgroundColor, @@ -179,7 +205,19 @@ class _ChatPageState extends State with WidgetsBindingObserver { appBar: PreferredSize( preferredSize: const Size.fromHeight(kToolbarHeight), child: AnimatedSwitcher( - duration: const Duration(milliseconds: 250), + 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'), @@ -223,129 +261,218 @@ class _ChatPageState extends State with WidgetsBindingObserver { ), body: Stack( children: [ - activeChat.isEmpty - ? Center( - child: Text( - "No messages yet", - style: TextStyle( - color: theme.colorScheme.onSurfaceVariant, - fontSize: 14, + 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, ), - ), - ) - : ListView.builder( - controller: _scrollController, - physics: const AlwaysScrollableScrollPhysics( - parent: BouncingScrollPhysics(), - ), - keyboardDismissBehavior: - ScrollViewKeyboardDismissBehavior.onDrag, - padding: EdgeInsets.only( - top: 120, - bottom: _replyingToMessage != null ? 220 : 140, - left: 16, - right: 16, - ), - itemCount: activeChat.length, - itemBuilder: (context, index) { - final msg = activeChat[index]; - final bool isSelected = _selectedIndices.contains(index); - - bool showDateSeparator = false; - if (index == 0) { - showDateSeparator = true; - } else { - final prevMsg = activeChat[index - 1]; - if (!_isSameDay(msg.createdAt, prevMsg.createdAt)) { - showDateSeparator = true; + ) + : 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, + ), + ), + ) + : 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, + ), + ); } - } - - final String cleanSenderId = msg.senderId - .trim() - .toLowerCase(); - final String cleanPeerId = widget.chatUserId - .trim() - .toLowerCase(); - final bool isMe = - cleanSenderId == 'me' || - (cleanSenderId.isNotEmpty && - cleanSenderId != cleanPeerId); - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (showDateSeparator) - Center( + + if (chatState.isPeerTyping && index == 1) { + 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: () => _toggleSelection(index), - onTap: () { - if (isSelectionMode) _toggleSelection(index); - }, - child: Container( - color: isSelected - ? theme.colorScheme.primary.withValues( - alpha: 0.2, - ) - : Colors.transparent, - child: Dismissible( - key: ValueKey( - msg.createdAt.toString() + index.toString(), + ); + } + + 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); + + return _AnimatedMessageItem( + key: ValueKey( + msg.id?.toString() ?? + msg.createdAt.millisecondsSinceEpoch.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, + ), + ), + ), ), - direction: DismissDirection.startToEnd, - confirmDismiss: (direction) async { - setState(() { - _replyingToMessage = msg; - }); - return false; + GestureDetector( + onLongPress: () { + HapticFeedback.selectionClick(); + _toggleSelection(realIndex); + }, + onTap: () { + if (isSelectionMode) { + _toggleSelection(realIndex); + } }, - background: Container( - alignment: Alignment.centerLeft, - padding: const EdgeInsets.only(left: 16), - color: Colors.transparent, - child: Icon( - Icons.reply, - color: theme.colorScheme.primary.withValues( - alpha: 0.7, + 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: msg.quotedMessage, + ), ), ), ), - child: ChatBubble( - message: msg.content, - isMe: isMe, - timestamp: msg.createdAt, - isRead: msg.isRead, - ), ), - ), + ], ), - ], - ); - }, - ), - Positioned( + ); + }, + ), + ), + AnimatedPositioned( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, right: 16, bottom: _replyingToMessage != null ? 180 : 100, child: AnimatedScale( @@ -371,64 +498,81 @@ class _ChatPageState extends State with WidgetsBindingObserver { child: Column( mainAxisSize: MainAxisSize.min, children: [ - if (_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 message", - style: TextStyle( - fontWeight: FontWeight.bold, + 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, - fontSize: 12, + width: 4, ), - ), - const SizedBox(height: 4), - Text( - _replyingToMessage.content, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: theme.colorScheme.onSurface, + top: BorderSide( + color: theme.dividerColor, + width: 1, ), ), - ], - ), - ), - IconButton( - icon: Icon( - Icons.close, - size: 20, - color: theme.iconTheme.color, - ), - onPressed: () => - setState(() => _replyingToMessage = null), - ), - ], - ), + 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 message", + 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 @@ -444,3 +588,181 @@ class _ChatPageState extends State with WidgetsBindingObserver { ); } } + +class _AnimatedMessageItem extends StatefulWidget { + final Widget child; + + const _AnimatedMessageItem({super.key, required this.child}); + + @override + State<_AnimatedMessageItem> createState() => _AnimatedMessageItemState(); +} + +class _AnimatedMessageItemState extends State<_AnimatedMessageItem> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _fadeAnimation; + late Animation _slideAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 350), + ); + + _fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic)); + + _slideAnimation = Tween( + begin: const Offset(0, 0.15), + end: Offset.zero, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic)); + + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: _fadeAnimation, + child: SlideTransition(position: _slideAnimation, child: widget.child), + ); + } +} + +class SwipeToReply extends StatefulWidget { + final Widget child; + final VoidCallback onReply; + final bool isMe; + + const SwipeToReply({ + super.key, + required this.child, + required this.onReply, + required this.isMe, + }); + + @override + State createState() => _SwipeToReplyState(); +} + +class _SwipeToReplyState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + double _dragOffset = 0.0; + bool _vibrated = false; + final double _replyThreshold = 60.0; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 250), + ); + _animation = Tween( + begin: 0.0, + end: 0.0, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic)); + _controller.addListener(() { + setState(() { + _dragOffset = _animation.value; + }); + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _onDragUpdate(DragUpdateDetails details) { + setState(() { + _dragOffset += details.primaryDelta!; + + if (widget.isMe) { + if (_dragOffset > 0) _dragOffset = 0; + if (_dragOffset < -_replyThreshold) { + _dragOffset = + -_replyThreshold + ((_dragOffset + _replyThreshold) * 0.15); + } + } else { + if (_dragOffset < 0) _dragOffset = 0; + if (_dragOffset > _replyThreshold) { + _dragOffset = + _replyThreshold + ((_dragOffset - _replyThreshold) * 0.15); + } + } + + if (_dragOffset.abs() >= _replyThreshold && !_vibrated) { + HapticFeedback.lightImpact(); + _vibrated = true; + } else if (_dragOffset.abs() < _replyThreshold) { + _vibrated = false; + } + }); + } + + void _onDragEnd(DragEndDetails details) { + if (_dragOffset.abs() >= _replyThreshold) { + widget.onReply(); + } + _vibrated = false; + + _animation = Tween( + begin: _dragOffset, + end: 0.0, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic)); + _controller.forward(from: 0.0); + } + + @override + Widget build(BuildContext context) { + final double iconOpacity = (_dragOffset.abs() / _replyThreshold).clamp( + 0.0, + 1.0, + ); + + return GestureDetector( + onHorizontalDragUpdate: _onDragUpdate, + onHorizontalDragEnd: _onDragEnd, + behavior: HitTestBehavior.opaque, + child: Stack( + alignment: Alignment.center, + children: [ + Positioned( + left: widget.isMe ? null : 20, + right: widget.isMe ? 20 : null, + child: Opacity( + opacity: iconOpacity, + child: Transform.scale( + scale: 0.5 + (0.5 * iconOpacity), + child: Icon( + Icons.reply, + color: Theme.of(context).colorScheme.primary, + size: 28, + ), + ), + ), + ), + Transform.translate( + offset: Offset(_dragOffset, 0), + child: widget.child, + ), + ], + ), + ); + } +} diff --git a/mobile/lib/pages/home_page.dart b/mobile/lib/pages/home_page.dart index 2e6842d..ea04f23 100644 --- a/mobile/lib/pages/home_page.dart +++ b/mobile/lib/pages/home_page.dart @@ -50,7 +50,6 @@ class _HomePageState extends State { final token = await authService.getToken(); if (token != null) { await chatController.initSession(token); - await chatController.loadInbox(); } }); } @@ -267,9 +266,7 @@ class _HomePageState extends State { onTap: () { Navigator.push( context, - MaterialPageRoute( - builder: (context) => AccountsSettings(), - ), + MaterialPageRoute(builder: (context) => SettingsPage()), ); }, child: Hero( @@ -375,21 +372,7 @@ class _HomePageState extends State { !_isSelectionMode ? PopupMenuButton( iconColor: theme.colorScheme.onSurface, - onSelected: (String value) { - switch (value) { - case 'settings': - context - .read() - .initNotificationsSwitch(); - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const SettingsPage(), - ), - ); - break; - } - }, + onSelected: (String value) {}, borderRadius: BorderRadius.circular(20), itemBuilder: (context) => [ const PopupMenuItem( @@ -400,10 +383,6 @@ class _HomePageState extends State { value: 'read all', child: Text('Read all'), ), - const PopupMenuItem( - value: 'settings', - child: Text('Settings'), - ), ], ) : PopupMenuButton( @@ -413,7 +392,7 @@ class _HomePageState extends State { case 'Select all': setState(() { for (final thread in chatState.inbox) { - _selectedChatIds.add(thread.chatUserId); + _selectedChatIds.add(thread.id); } }); break; @@ -457,8 +436,9 @@ class _HomePageState extends State { itemCount: chatState.inbox.length, itemBuilder: (context, index) { final thread = chatState.inbox[index]; + final bool isSelected = _selectedChatIds.contains( - thread.chatUserId, + thread.id, ); return CustomChatCard( @@ -468,16 +448,16 @@ class _HomePageState extends State { onLongPress: () { if (!_isSearchOpen) { setState(() { - _selectedChatIds.add(thread.chatUserId); + _selectedChatIds.add(thread.id); }); } }, onTapInSelection: () { setState(() { - if (_selectedChatIds.contains(thread.chatUserId)) { - _selectedChatIds.remove(thread.chatUserId); + if (_selectedChatIds.contains(thread.id)) { + _selectedChatIds.remove(thread.id); } else { - _selectedChatIds.add(thread.chatUserId); + _selectedChatIds.add(thread.id); } }); }, diff --git a/mobile/lib/pages/new_chat_page.dart b/mobile/lib/pages/new_chat_page.dart index f293b01..36d311a 100644 --- a/mobile/lib/pages/new_chat_page.dart +++ b/mobile/lib/pages/new_chat_page.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:mobile/pages/select_contact_page.dart'; +import 'package:mobile/pages/settings_page.dart'; import 'package:provider/provider.dart'; import 'package:mobile/controllers/chat.dart'; import 'chat_page.dart'; @@ -146,8 +147,19 @@ class _NewChatPageState extends State { ), actions: [ PopupMenuButton( + onSelected: (value) { + switch (value) { + case 'qr': + showUserCard(context); + break; + default: + } + }, itemBuilder: (context) => [ - PopupMenuItem(child: Text("Share username by QR")), + const PopupMenuItem( + value: 'qr', + child: Text("Share username by QR"), + ), ], ), ], @@ -264,7 +276,7 @@ class _NewChatPageState extends State { Navigator.push( context, MaterialPageRoute( - builder: (context) => SelectContactPage(), + builder: (context) => const SelectContactPage(), ), ); }, @@ -291,41 +303,46 @@ class _NewChatPageState extends State { ), ), if (chatState.inbox.isNotEmpty) - ...chatState.inbox.map( - (thread) => ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 4, - ), - leading: const CircleAvatar( - backgroundColor: Color(0xFFE8E8E8), - child: Icon(Icons.person, color: Colors.black54), - ), - title: Text( - thread.displayName, - style: const TextStyle( - color: Colors.black87, - fontWeight: FontWeight.w500, - ), - ), - subtitle: Text( - "@${thread.username}", - style: const TextStyle(color: Colors.black45), - ), - onTap: () { - chatState.openChat(thread.chatUserId); - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (_) => ChatPage( - chatUserId: thread.chatUserId, - displayName: thread.displayName, + ...chatState.inbox + .where((thread) => !thread.isGroup) + .map( + (thread) => ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + leading: const CircleAvatar( + backgroundColor: Color(0xFFE8E8E8), + child: Icon( + Icons.person, + color: Colors.black54, ), ), - ); - }, - ), - ), + title: Text( + thread.title, + style: const TextStyle( + color: Colors.black87, + fontWeight: FontWeight.w500, + ), + ), + subtitle: Text( + "@${thread.username}", + style: const TextStyle(color: Colors.black45), + ), + onTap: () { + chatState.openChat(thread.id); + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => ChatPage( + chatUserId: thread.id, + displayName: thread.title, + ), + ), + ); + }, + ), + ), ], ), ), diff --git a/mobile/lib/pages/select_contact_page.dart b/mobile/lib/pages/select_contact_page.dart index 4d4a474..e0d52b3 100644 --- a/mobile/lib/pages/select_contact_page.dart +++ b/mobile/lib/pages/select_contact_page.dart @@ -289,15 +289,15 @@ class _SelectContactPageState extends State { if (chatState.inbox.isNotEmpty) ...chatState.inbox .where( - (thread) => !_selectedContacts.containsKey( - thread.chatUserId, - ), + (thread) => + !thread.isGroup && + !_selectedContacts.containsKey(thread.id), ) .map( (thread) => _buildContactTile( - id: thread.chatUserId, - displayName: thread.displayName, - username: thread.username, + id: thread.id, + displayName: thread.title, + username: thread.title, ), ), ], @@ -390,12 +390,12 @@ class _SelectContactPageState extends State { context, ).pushReplacement( MaterialPageRoute( - builder: (context) => ChatPage( - chatUserId: newGroup - .id, - displayName: newGroup - .name, - ), + builder: (context) => + ChatPage( + chatUserId: newGroup.id, + displayName: + newGroup.name, + ), ), ); diff --git a/mobile/lib/pages/settings pages/accounts.dart b/mobile/lib/pages/settings pages/accounts.dart index 7be8a3c..b85a4db 100644 --- a/mobile/lib/pages/settings pages/accounts.dart +++ b/mobile/lib/pages/settings pages/accounts.dart @@ -1,4 +1,5 @@ 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/main.dart'; @@ -9,6 +10,7 @@ class AccountsSettings extends StatelessWidget { @override Widget build(BuildContext context) { + final user = context.watch().currentUser; return Scaffold( backgroundColor: Colors.white, appBar: AppBar( @@ -23,10 +25,11 @@ class AccountsSettings extends StatelessWidget { const SizedBox( width: double.infinity, height: 150, - child: Hero( - tag: 'User Profile', - child: Padding( - padding: EdgeInsets.only(left: 16.0, top: 10.0, bottom: 10.0), + child: Padding( + // Moved padding OUTSIDE the Hero + padding: EdgeInsets.only(left: 16.0, top: 10.0, bottom: 10.0), + child: Hero( + tag: 'User Profile', child: CircleAvatar( backgroundColor: Color(0xFFD6E4FF), child: Icon(Icons.person, color: Color(0xFF1890FF), size: 40), @@ -34,7 +37,102 @@ class AccountsSettings extends StatelessWidget { ), ), ), - const SizedBox(height: 100), + Center( + child: Hero( + tag: 'User Data', + child: Material( + type: MaterialType.transparency, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + user != null ? user.displayName : 'Profile N/A', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + GestureDetector( + onTap: () async { + await Clipboard.setData( + ClipboardData(text: user!.username), + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Copied to clipboard!"), + duration: Duration(seconds: 2), + ), + ); + } + }, + child: Text( + user != null + ? '@${user.username}' + : 'Could not load profile', + style: const TextStyle(fontSize: 12), + ), + ), + ], + ), + ), + ), + ), + const SizedBox(height: 32), + const Padding( + padding: EdgeInsets.only(left: 16, bottom: 8), + child: Text( + "Details", + style: TextStyle( + color: Colors.black38, + fontWeight: FontWeight.bold, + fontSize: 13, + ), + ), + ), + + // Email Tile + ListTile( + leading: const Icon(Icons.email_outlined, color: Colors.black54), + title: const Text("Email"), + subtitle: Text( + user?.email != null && user!.email!.isNotEmpty + ? user.email! + : "Not provided", + style: const TextStyle(color: Colors.black87), + ), + trailing: const Icon(Icons.edit, size: 18, color: Colors.black38), + onTap: () { + // TODO: Navigate to edit email page + }, + ), + + // Phone Number Tile + ListTile( + leading: const Icon(Icons.phone_outlined, color: Colors.black54), + title: const Text("Phone Number"), + subtitle: const Text( + "Not provided", + style: TextStyle(color: Colors.black87), + ), + trailing: const Icon(Icons.edit, size: 18, color: Colors.black38), + onTap: () {}, + ), + + // Bio Tile + ListTile( + leading: const Icon(Icons.info_outline, color: Colors.black54), + title: const Text("About"), + subtitle: const Text( + "Hey there! I am using Elephant.", + style: TextStyle(color: Colors.black87), + ), + trailing: const Icon(Icons.edit, size: 18, color: Colors.black38), + onTap: () {}, + ), + + const SizedBox(height: 30), FilledButton( style: const ButtonStyle( backgroundColor: WidgetStatePropertyAll(Colors.redAccent), @@ -44,8 +142,6 @@ class AccountsSettings extends StatelessWidget { await context.read().logOutSave(); if (context.mounted) { - // context.read().clearSessionData(); - Navigator.pushAndRemoveUntil( context, MaterialPageRoute( diff --git a/mobile/lib/pages/settings_page.dart b/mobile/lib/pages/settings_page.dart index 6f8d6d6..9b342c7 100644 --- a/mobile/lib/pages/settings_page.dart +++ b/mobile/lib/pages/settings_page.dart @@ -1,6 +1,7 @@ import 'dart:ui'; import 'package:flutter/material.dart'; +import 'package:mobile/controllers/auth.dart'; import 'package:mobile/pages/settings%20pages/accounts.dart'; import 'package:mobile/pages/settings%20pages/appearance.dart'; import 'package:mobile/pages/settings%20pages/chats_media.dart'; @@ -9,12 +10,82 @@ 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'; + +void showUserCard(BuildContext context) { + showDialog( + context: context, + builder: (context) { + final user = context.read().currentUser; + final String qrData = user?.username ?? 'unknown'; + + return Dialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Share Your QR!', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + const SizedBox(height: 24), + + QrImageView( + data: qrData, + version: QrVersions.auto, + size: 200.0, + backgroundColor: Colors.white, + eyeStyle: const QrEyeStyle( + eyeShape: QrEyeShape.square, + color: Colors.black87, + ), + dataModuleStyle: const QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.square, + color: Colors.black87, + ), + ), + + const SizedBox(height: 16), + Text( + '@$qrData', + style: const TextStyle( + fontSize: 16, + color: Colors.black54, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 24), + FilledButton( + onPressed: () => Navigator.pop(context), + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF1890FF), + minimumSize: const Size(double.infinity, 45), + ), + child: const Text('Close'), + ), + ], + ), + ), + ); + }, + ); +} + class SettingsPage extends StatelessWidget { const SettingsPage({super.key}); @override Widget build(BuildContext context) { + final user = context.watch().currentUser; + return Scaffold( body: CustomScrollView( slivers: [ @@ -42,6 +113,15 @@ class SettingsPage extends StatelessWidget { style: TextStyle(letterSpacing: 1, fontWeight: FontWeight.w400), key: ValueKey('title'), ), + + actions: [ + IconButton( + onPressed: () { + showUserCard(context); + }, + icon: Icon(Icons.qr_code), + ), + ], ), SliverList( delegate: SliverChildListDelegate([ @@ -58,29 +138,45 @@ class SettingsPage extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.start, children: [ - CircleAvatar( - radius: 40, - child: Icon(Icons.person, size: 50), + Hero( + tag: 'User Profile', + child: CircleAvatar( + radius: 40, + backgroundImage: user?.avatarUrl != null + ? NetworkImage(user!.avatarUrl!) + : null, + child: user?.avatarUrl == null + ? const Icon(Icons.person, size: 50) + : null, + ), ), SizedBox(width: 20), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Spacer(), - Text( - 'User Name', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), + Hero( + tag: 'User Data', + child: Material( + type: MaterialType.transparency, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Spacer(), + Text( + user != null ? user.displayName : 'Profile N/A', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + Text( + user != null + ? '@${user.username}' + : 'Could not load profile', + style: TextStyle(fontSize: 12), + ), + Spacer(), + ], ), - // SizedBox(height: 5), - Text('Activity status', style: TextStyle(fontSize: 12)), - Spacer(), - ], + ), ), - Spacer(), - Icon(Icons.chevron_right_rounded), ], ), ), @@ -167,7 +263,6 @@ Widget _settingsOption({ Widget? whereTo, }) { return InkWell( - // splashColor: const Color.fromARGB(255, 255, 0, 0), onTap: () { if (whereTo != null) { Navigator.push( diff --git a/mobile/lib/providers/group_controller_provider.dart b/mobile/lib/providers/group_controller_provider.dart index 8f68448..4811571 100644 --- a/mobile/lib/providers/group_controller_provider.dart +++ b/mobile/lib/providers/group_controller_provider.dart @@ -33,7 +33,7 @@ class GroupController extends ChangeNotifier { final createResponse = await _api.post( '/groups', - data: {"name": groupName}, + data: {"name": groupName.trim()}, ); if (createResponse.statusCode == 200 || diff --git a/mobile/lib/services/api.dart b/mobile/lib/services/api.dart index 593b6e8..5f1b62b 100644 --- a/mobile/lib/services/api.dart +++ b/mobile/lib/services/api.dart @@ -67,6 +67,11 @@ class ApiService { return await _dio.get("/messages/conversations"); } + Future getGroups() async { + return await _dio.get("/groups"); + } + + Future getChatHistory(String partnerId, {String? before}) async { final Map params = {"with": partnerId, "limit": 40}; if (before != null) params["before"] = before; @@ -86,7 +91,22 @@ 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, @@ -94,4 +114,5 @@ class ApiService { }) async { return await _dio.post(path, data: data, queryParameters: queryParameters); } + } diff --git a/mobile/lib/services/auth.dart b/mobile/lib/services/auth.dart index a0dd078..80bbf48 100644 --- a/mobile/lib/services/auth.dart +++ b/mobile/lib/services/auth.dart @@ -75,4 +75,11 @@ class AuthService { data: {"refresh_token": refreshToken}, ); } + + Future getCurrentUser(String token) async { + return await _dio.get( + '/users/me', + options: Options(headers: {'Authorization': 'Bearer $token'}), + ); + } } diff --git a/mobile/lib/services/ws.dart b/mobile/lib/services/ws.dart index f86ca8b..c03a62a 100644 --- a/mobile/lib/services/ws.dart +++ b/mobile/lib/services/ws.dart @@ -63,4 +63,5 @@ 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 8b9e4eb..0099d23 100644 --- a/mobile/lib/widgets/chat_screen_modular_widgets.dart +++ b/mobile/lib/widgets/chat_screen_modular_widgets.dart @@ -4,8 +4,11 @@ import 'package:flutter/material.dart'; 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'; -enum UserStatus { offline, online, typing } +enum UserStatus { offline, online } class GlassAppBar extends StatelessWidget implements PreferredSizeWidget { final String name; @@ -14,14 +17,7 @@ class GlassAppBar extends StatelessWidget implements PreferredSizeWidget { const GlassAppBar({super.key, required this.name, required this.status}); String _getStatusText() { - switch (status) { - case UserStatus.typing: - return "typing..."; - case UserStatus.online: - return "Online"; - case UserStatus.offline: - return "Offline"; - } + return status == UserStatus.online ? "Online" : "Offline"; } @override @@ -60,13 +56,9 @@ class GlassAppBar extends StatelessWidget implements PreferredSizeWidget { Text( _getStatusText(), style: TextStyle( - color: status == UserStatus.typing - ? Colors.green - : theme.textTheme.bodySmall?.color, + color: theme.textTheme.bodySmall?.color, fontSize: 12, - fontWeight: status == UserStatus.typing - ? FontWeight.bold - : FontWeight.normal, + fontWeight: FontWeight.normal, ), ), ], @@ -87,6 +79,7 @@ class ChatBubble extends StatelessWidget { final bool isMe; final DateTime timestamp; final bool isRead; + final QuotedMessage? quotedMessage; const ChatBubble({ super.key, @@ -94,6 +87,7 @@ class ChatBubble extends StatelessWidget { required this.isMe, required this.timestamp, required this.isRead, + this.quotedMessage, }); MarkdownStyleSheet _getMarkdownStyle( @@ -156,46 +150,105 @@ class ChatBubble extends StatelessWidget { bottomRight: Radius.circular(isMe ? 4 : 16), ), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: [ - MarkdownBody( - data: message, - selectable: false, - styleSheet: _getMarkdownStyle( - isMe, - theme.colorScheme.primary, - theme.colorScheme.onPrimary, - theme.colorScheme.onSurface, - ), - ), - const SizedBox(height: 4), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - formattedTime, - style: TextStyle( + child: IntrinsicWidth( + child: Column( + 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.7) - : theme.colorScheme.onSurfaceVariant, - fontSize: 10, + ? 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 (isMe) ...[ - const SizedBox(width: 4), - Icon( - isRead ? Icons.done_all : Icons.done, - size: 14, - color: isRead - ? Colors.lightBlueAccent - : theme.colorScheme.onPrimary.withValues(alpha: 0.7), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + quotedMessage!.senderDisplayName, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: isMe + ? theme.colorScheme.onPrimary + : theme.colorScheme.primary, + ), + ), + 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, + ), + ), + ], ), - ], - ], - ), - ], + ), + MarkdownBody( + data: message, + selectable: false, + styleSheet: _getMarkdownStyle( + isMe, + theme.colorScheme.primary, + theme.colorScheme.onPrimary, + theme.colorScheme.onSurface, + ), + ), + const SizedBox(height: 4), + Align( + alignment: Alignment.centerRight, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + formattedTime, + style: TextStyle( + color: isMe + ? theme.colorScheme.onPrimary.withValues(alpha: 0.7) + : theme.colorScheme.onSurfaceVariant, + fontSize: 10, + ), + ), + if (isMe) ...[ + const SizedBox(width: 4), + Icon( + isRead ? Icons.done_all : Icons.done, + size: 14, + color: isRead + ? Colors.lightBlueAccent + : theme.colorScheme.onPrimary.withValues( + alpha: 0.7, + ), + ), + ], + ], + ), + ), + ], + ), ), ), ); @@ -222,20 +275,9 @@ class _ChatInputAreaState extends State { bool _isTyping = false; Timer? _typingTimer; - bool _showFormattingToolbar = false; final int _charLimit = 5000; bool _isOverLimit = false; - @override - void initState() { - super.initState(); - _focusNode.addListener(() { - setState(() { - _showFormattingToolbar = _focusNode.hasFocus; - }); - }); - } - @override void dispose() { _typingTimer?.cancel(); @@ -279,35 +321,6 @@ class _ChatInputAreaState extends State { } } - void _insertMarkdown(String prefix, String suffix) { - final text = _controller.text; - final selection = _controller.selection; - - if (!selection.isValid || selection.start == selection.end) { - final newText = text + prefix + suffix; - _controller.value = TextEditingValue( - text: newText, - selection: TextSelection.collapsed( - offset: newText.length - suffix.length, - ), - ); - } else { - final selectedText = text.substring(selection.start, selection.end); - final newText = text.replaceRange( - selection.start, - selection.end, - '$prefix$selectedText$suffix', - ); - _controller.value = TextEditingValue( - text: newText, - selection: TextSelection.collapsed( - offset: selection.start + prefix.length + selectedText.length, - ), - ); - } - _handleOnChange(_controller.text); - } - void _submit() { _isOverLimit = false; final text = _controller.text.trim(); @@ -337,162 +350,919 @@ class _ChatInputAreaState extends State { child: Container( color: Colors.transparent, padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 20), - child: Column( - mainAxisSize: MainAxisSize.min, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, children: [ - AnimatedSize( - duration: const Duration(milliseconds: 200), - child: _showFormattingToolbar - ? Container( - padding: const EdgeInsets.only(bottom: 8), + 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.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, + ), + title: Text( + '${_selectedIndices.length} Selected', + style: TextStyle(color: theme.colorScheme.onPrimary), + ), + 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( - mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - _FormatButton( - icon: Icons.format_bold, - onPressed: () => _insertMarkdown('**', '**'), - ), - _FormatButton( - icon: Icons.format_italic, - onPressed: () => _insertMarkdown('*', '*'), - ), - _FormatButton( - icon: Icons.format_strikethrough, - onPressed: () => _insertMarkdown('~~', '~~'), - ), - _FormatButton( - icon: Icons.code, - onPressed: () => _insertMarkdown('`', '`'), - ), - _FormatButton( - icon: Icons.link, - onPressed: () => _insertMarkdown('[', '](url)'), + Text( + "typing...", + style: TextStyle( + color: theme.colorScheme.primary, + fontStyle: FontStyle.italic, + fontWeight: FontWeight.w500, + fontSize: 14, + ), ), ], ), - ) - : const SizedBox.shrink(), - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - 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.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, + ), + ); + } + + 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(), + ), + 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, + ), + ), + ), ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, + 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, + ), + ), + ), ), ), - ), - ), - const SizedBox(width: 8), - IconButton( - icon: _isOverLimit - ? const Icon(Icons.not_interested) - : Icon(Icons.send, color: theme.colorScheme.primary), - onPressed: _isOverLimit ? null : _submit, + ], ), - ], + ); + }, + ), + 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), + ), + ], + ), + ), + ], ), ), ); } } -class _FormatButton extends StatelessWidget { - final IconData icon; - final VoidCallback onPressed; +class _AnimatedMessageItem extends StatefulWidget { + final Widget child; + + const _AnimatedMessageItem({super.key, required this.child}); + + @override + State<_AnimatedMessageItem> createState() => _AnimatedMessageItemState(); +} + +class _AnimatedMessageItemState extends State<_AnimatedMessageItem> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _fadeAnimation; + late Animation _slideAnimation; + late Animation _scaleAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 350), + ); + + _fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic)); + + _slideAnimation = Tween( + begin: const Offset(0, 0.15), + end: Offset.zero, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic)); - const _FormatButton({required this.icon, required this.onPressed}); + _scaleAnimation = Tween( + begin: 0.8, + end: 1.0, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutBack)); + + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { - final theme = Theme.of(context); - return IconButton( - icon: Icon( - icon, - size: 20, - color: theme.iconTheme.color?.withValues(alpha: 0.6) ?? Colors.black54, + return FadeTransition( + opacity: _fadeAnimation, + child: SlideTransition( + position: _slideAnimation, + child: ScaleTransition(scale: _scaleAnimation, child: widget.child), + ), + ); + } +} + +class SwipeToReply extends StatefulWidget { + final Widget child; + final VoidCallback onReply; + final bool isMe; + + const SwipeToReply({ + super.key, + required this.child, + required this.onReply, + required this.isMe, + }); + + @override + State createState() => _SwipeToReplyState(); +} + +class _SwipeToReplyState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + double _dragOffset = 0.0; + bool _vibrated = false; + final double _replyThreshold = 60.0; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 250), + ); + _animation = Tween( + begin: 0.0, + end: 0.0, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic)); + _controller.addListener(() { + setState(() { + _dragOffset = _animation.value; + }); + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _onDragUpdate(DragUpdateDetails details) { + setState(() { + _dragOffset += details.primaryDelta!; + + if (widget.isMe) { + if (_dragOffset > 0) _dragOffset = 0; + if (_dragOffset < -_replyThreshold) { + _dragOffset = + -_replyThreshold + ((_dragOffset + _replyThreshold) * 0.15); + } + } else { + if (_dragOffset < 0) _dragOffset = 0; + if (_dragOffset > _replyThreshold) { + _dragOffset = + _replyThreshold + ((_dragOffset - _replyThreshold) * 0.15); + } + } + + if (_dragOffset.abs() >= _replyThreshold && !_vibrated) { + HapticFeedback.lightImpact(); + _vibrated = true; + } else if (_dragOffset.abs() < _replyThreshold) { + _vibrated = false; + } + }); + } + + void _onDragEnd(DragEndDetails details) { + if (_dragOffset.abs() >= _replyThreshold) { + widget.onReply(); + } + _vibrated = false; + + _animation = Tween( + begin: _dragOffset, + end: 0.0, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic)); + _controller.forward(from: 0.0); + } + + @override + Widget build(BuildContext context) { + final double iconOpacity = (_dragOffset.abs() / _replyThreshold).clamp( + 0.0, + 1.0, + ); + + return GestureDetector( + onHorizontalDragUpdate: _onDragUpdate, + onHorizontalDragEnd: _onDragEnd, + behavior: HitTestBehavior.opaque, + child: Stack( + alignment: Alignment.center, + children: [ + Positioned( + left: widget.isMe ? null : 20, + right: widget.isMe ? 20 : null, + child: Opacity( + opacity: iconOpacity, + child: Transform.scale( + scale: 0.5 + (0.5 * iconOpacity), + child: Icon( + Icons.reply, + color: Theme.of(context).colorScheme.primary, + size: 28, + ), + ), + ), + ), + Transform.translate( + offset: Offset(_dragOffset, 0), + child: widget.child, + ), + ], ), - onPressed: onPressed, - visualDensity: VisualDensity.compact, - padding: const EdgeInsets.symmetric(horizontal: 4), - constraints: const BoxConstraints(), ); } } diff --git a/mobile/lib/widgets/custom_cards.dart b/mobile/lib/widgets/custom_cards.dart index deaba04..e1c83be 100644 --- a/mobile/lib/widgets/custom_cards.dart +++ b/mobile/lib/widgets/custom_cards.dart @@ -1,12 +1,10 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; import 'package:simple_rich_text/simple_rich_text.dart'; -import '../models/conversation.dart'; +import '../models/inbox_item.dart'; import '../pages/chat_page.dart'; -import '../controllers/chat.dart'; class CustomChatCard extends StatelessWidget { - final Conversation conversation; + final InboxItem conversation; final bool isSelected; final bool isSelectionMode; final GestureLongPressCallback? onLongPress; @@ -38,7 +36,7 @@ class CustomChatCard extends StatelessWidget { @override Widget build(BuildContext context) { - final String timeLabel = _formatTimestamp(conversation.lastMessageTime); + final String timeLabel = _formatTimestamp(conversation.timestamp); final bool hasUnread = conversation.unreadCount > 0; return Material( @@ -51,16 +49,13 @@ class CustomChatCard extends StatelessWidget { if (isSelectionMode) { onTapInSelection(); } else { - final controller = context.read(); - await controller.openChat(conversation.chatUserId); - if (context.mounted) { Navigator.push( context, MaterialPageRoute( builder: (_) => ChatPage( - chatUserId: conversation.chatUserId, - displayName: conversation.displayName, + chatUserId: conversation.id, + displayName: conversation.title, ), ), ); @@ -77,10 +72,13 @@ class CustomChatCard extends StatelessWidget { child: Stack( fit: StackFit.expand, children: [ - const CircleAvatar( + CircleAvatar( radius: 26, - backgroundColor: Color(0xFFD6E4FF), - child: Icon(Icons.person, color: Color(0xFF1890FF)), + backgroundColor: const Color(0xFFD6E4FF), + child: Icon( + conversation.isGroup ? Icons.group : Icons.person, + color: const Color(0xFF1890FF), + ), ), Positioned( right: -2, @@ -111,7 +109,7 @@ class CustomChatCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - conversation.displayName, + conversation.title, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, @@ -119,62 +117,8 @@ class CustomChatCard extends StatelessWidget { ), ), const SizedBox(height: 4), - // SizedBox( - // height: 25, - // width: double.infinity, - // child: ClipRect( - // child: MarkdownBody( - // data: conversation.lastMessage, - // selectable: false, - // shrinkWrap: true, - // softLineBreak: true, - // styleSheet: MarkdownStyleSheet( - // p: TextStyle( - // color: hasUnread - // ? Colors.black87 - // : Colors.black54, - // fontSize: 15, - // ), - // strong: TextStyle( - // color: hasUnread - // ? Colors.black87 - // : const Color.fromARGB(171, 0, 0, 0), - // fontWeight: FontWeight.bold, - // ), - // em: TextStyle( - // color: hasUnread - // ? Colors.black87 - // : Colors.black54, - // fontStyle: FontStyle.italic, - // ), - // del: TextStyle( - // color: hasUnread - // ? Colors.black87 - // : Colors.black54, - // decoration: TextDecoration.lineThrough, - // ), - // code: TextStyle( - // color: hasUnread - // ? Colors.black87 - // : Colors.black54, - // fontFamily: 'monospace', - // ), - // a: TextStyle( - // color: hasUnread - // ? Colors.black87 - // : Colors.black54, - // decoration: TextDecoration.underline, - // ), - // ), - // ), - // ), - // ), - // Example using simple_rich_text SimpleRichText( - conversation.lastMessage.replaceAll( - '\n', - ' ', - ), + conversation.lastMessage.replaceAll('\n', ' '), maxLines: 1, textOverflow: TextOverflow.ellipsis, style: TextStyle( @@ -182,18 +126,6 @@ class CustomChatCard extends StatelessWidget { fontSize: 15, ), ), - // Text( - // conversation.lastMessage, - // maxLines: 1, - // overflow: TextOverflow.ellipsis, - // style: TextStyle( - // fontSize: 14, - // color: hasUnread ? Colors.black87 : Colors.black54, - // fontWeight: hasUnread - // ? FontWeight.w600 - // : FontWeight.normal, - // ), - // ), ], ), ), diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 7d963b8..01d93fb 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -720,6 +720,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + qr_flutter: + dependency: "direct main" + description: + name: qr_flutter + sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" + url: "https://pub.dev" + source: hosted + version: "4.1.0" record_use: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index cd24f72..0bdcb15 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: mobile description: "A new Flutter project." # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +publish_to: "none" # Remove this line if you wish to publish to pub.dev # The following defines the version and build number for your application. # A version number is three numbers separated by dots, like 1.2.43 @@ -51,6 +51,7 @@ dependencies: flutter_markdown_plus: ^1.0.7 intl: ^0.20.3 simple_rich_text: ^2.0.49 + qr_flutter: ^4.1.0 launcher_name: default: "Elephant" @@ -75,7 +76,6 @@ dev_dependencies: # The following section is specific to Flutter packages. flutter: - # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class.