diff --git a/mobile/lib/controllers/chat.dart b/mobile/lib/controllers/chat.dart index 7cf1379..81beb07 100644 --- a/mobile/lib/controllers/chat.dart +++ b/mobile/lib/controllers/chat.dart @@ -12,6 +12,7 @@ class ChatController extends ChangeNotifier { List inbox = []; List activeChat = []; List contactSearchResults = []; + String? currentChatUserId; bool isPeerTyping = false; bool isPeerOnline = false; @@ -22,9 +23,12 @@ class ChatController extends ChangeNotifier { _ws.stream?.listen( (rawFrame) { try { - _handleIncomingWebSocketEvent(jsonDecode(rawFrame)); + final decoded = jsonDecode(rawFrame); + if (decoded is Map) { + _handleIncomingWebSocketEvent(decoded); + } } catch (e) { - debugPrint("WebSocket stream payload error: $e"); + debugPrint("WebSocket payload error: $e"); } }, onError: (err) => debugPrint("WS Pipeline Error: $err"), @@ -36,18 +40,10 @@ class ChatController extends ChangeNotifier { Future loadInbox() async { try { final res = await _api.getConversations(); - dynamic targetData; - - if (res.data is List) { - targetData = res.data; - } else if (res.data is Map) { - targetData = res.data['data'] ?? res.data['conversations'] ?? res.data; - } + final targetList = _extractDataList(res.data, ['conversations']); - if (targetData is List) { - inbox = targetData.map((json) => Conversation.fromJson(json)).toList(); - notifyListeners(); - } + inbox = targetList.map((json) => Conversation.fromJson(json)).toList(); + notifyListeners(); } catch (e) { debugPrint("Inbox read error: $e"); } @@ -62,29 +58,23 @@ class ChatController extends ChangeNotifier { try { final res = await _api.getChatHistory(targetUid); - dynamic targetData; - - if (res.data is List) { - targetData = res.data; - } else if (res.data is Map) { - targetData = res.data['data'] ?? res.data['messages'] ?? res.data; - } + final targetList = _extractDataList(res.data, ['messages']); - if (targetData is List) { - activeChat = targetData - .map((json) => Message.fromJson(json)) - .toList() - .reversed - .toList(); - } + activeChat = targetList + .map((json) => Message.fromJson(json)) + .toList() + .reversed + .toList(); _ws.sendReadReceipt(targetId: targetUid); _ws.sendRequestStatus(targetId: targetUid); + + notifyListeners(); await loadInbox(); } catch (e) { debugPrint("Timeline tracking fail: $e"); + notifyListeners(); } - notifyListeners(); } Future queryUsers(String term) async { @@ -101,26 +91,15 @@ class ChatController extends ChangeNotifier { notifyListeners(); return; } + isSearchLoading = true; notifyListeners(); try { - final res = await _api.searchUsers(cleanTerm); - if (res.data == null) { - contactSearchResults = []; - } else if (res.data is List) { - contactSearchResults = res.data; - } else if (res.data is Map) { - if (res.data['data'] != null && res.data['data'] is List) { - contactSearchResults = res.data['data']; - } else if (res.data['users'] != null && res.data['users'] is List) { - contactSearchResults = res.data['users']; - } else { - contactSearchResults = []; - } - } + final res = await _api.searchUsers(term); + contactSearchResults = _extractDataList(res.data, ['users']); } catch (e) { - debugPrint("User query pipeline failure: $e"); + debugPrint("User query failure: $e"); contactSearchResults.clear(); } finally { isSearchLoading = false; @@ -137,16 +116,15 @@ class ChatController extends ChangeNotifier { } void sendTextMessage(String text) { - if (currentChatUserId == null || text.trim().isEmpty) return; + final cleanContent = text.trim(); + if (currentChatUserId == null || cleanContent.isEmpty) return; - final String clientMessageId = - "cli_${DateTime.now().millisecondsSinceEpoch}"; - final String targetId = currentChatUserId!; - final String cleanContent = text.trim(); + final targetId = currentChatUserId!; + final clientMessageId = "cli_${DateTime.now().millisecondsSinceEpoch}"; _ws.sendChat(messageId: "", targetId: targetId, content: cleanContent); - final Message optimisticMsg = Message( + final optimisticMsg = Message( id: clientMessageId, senderId: "me", receiverId: targetId, @@ -157,6 +135,7 @@ class ChatController extends ChangeNotifier { activeChat.add(optimisticMsg); notifyListeners(); + loadInbox(); } @@ -168,13 +147,14 @@ class ChatController extends ChangeNotifier { void _handleIncomingWebSocketEvent(Map data) { final String? type = data['type']; - final String? senderId = - data['sender_id'] ?? data['sender'] ?? data['receiver_id']; - 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; switch (type) { case 'user_status': @@ -188,35 +168,54 @@ class ChatController extends ChangeNotifier { notifyListeners(); } break; + case 'chat': case 'message': - if (cleanSender != null && cleanSender == cleanCurrentChat) { + if (isCurrentChat) { activeChat.add(Message.fromJson(data)); _ws.sendReadReceipt(targetId: currentChatUserId!); notifyListeners(); - } else { - loadInbox(); } + loadInbox(); break; + case 'typing': - if (cleanSender != null && cleanSender == cleanCurrentChat) { + if (isCurrentChat) { isPeerTyping = data['content'] == 'true'; notifyListeners(); } break; + case 'read_receipt': - if (cleanSender != null && cleanSender == cleanCurrentChat) { + if (isCurrentChat) { + bool updated = false; activeChat = activeChat.map((msg) { final String sId = msg.senderId.trim().toLowerCase(); - if (sId == 'me' || sId != cleanCurrentChat) { + if (!msg.isRead && (sId == 'me' || sId != cleanCurrentChat)) { + updated = true; return msg.copyWith(isRead: true); } return msg; }).toList(); - notifyListeners(); - loadInbox(); + + if (updated) { + notifyListeners(); + loadInbox(); + } } break; } } + + List _extractDataList(dynamic data, List fallbackKeys) { + if (data == null) return []; + if (data is List) return data; + if (data is Map) { + if (data['data'] is List) return data['data']; + for (final key in fallbackKeys) { + if (data[key] is List) return data[key]; + } + } + return []; + } } diff --git a/mobile/lib/pages/chat_page.dart b/mobile/lib/pages/chat_page.dart index 56beef0..393134c 100644 --- a/mobile/lib/pages/chat_page.dart +++ b/mobile/lib/pages/chat_page.dart @@ -20,11 +20,8 @@ class ChatPage extends StatefulWidget { class _ChatPageState extends State { final ScrollController _scrollController = ScrollController(); - bool _showScrollToBottom = false; - final Set _selectedIndices = {}; - dynamic _replyingToMessage; @override @@ -33,7 +30,7 @@ class _ChatPageState extends State { _scrollController.addListener(_scrollListener); WidgetsBinding.instance.addPostFrameCallback((_) { - _scrollToBottom(); + _scrollToBottom(animated: false); }); } @@ -51,9 +48,11 @@ class _ChatPageState extends State { void _scrollListener() { if (!_scrollController.hasClients) return; + final isScrolledUp = _scrollController.position.maxScrollExtent - _scrollController.offset > 100; + if (isScrolledUp != _showScrollToBottom) { setState(() { _showScrollToBottom = isScrolledUp; @@ -61,13 +60,17 @@ class _ChatPageState extends State { } } - void _scrollToBottom() { - if (_scrollController.hasClients) { + void _scrollToBottom({bool animated = true}) { + if (!_scrollController.hasClients) return; + + if (animated) { _scrollController.animateTo( _scrollController.position.maxScrollExtent, - duration: const Duration(milliseconds: 300), - curve: Curves.easeOut, + duration: const Duration(milliseconds: 350), + curve: Curves.easeOutCubic, ); + } else { + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); } } @@ -80,7 +83,9 @@ class _ChatPageState extends State { _replyingToMessage = null; }); - Future.delayed(const Duration(milliseconds: 100), _scrollToBottom); + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToBottom(); + }); } void _toggleSelection(int index) { @@ -108,6 +113,10 @@ class _ChatPageState extends State { Clipboard.setData(ClipboardData(text: selectedTexts)); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Copied to clipboard'))); + _clearSelection(); } @@ -137,222 +146,261 @@ class _ChatPageState extends State { final activeChat = chatState.activeChat; final isSelectionMode = _selectedIndices.isNotEmpty; - return Scaffold( - backgroundColor: Colors.white, - extendBody: true, - extendBodyBehindAppBar: true, - - appBar: isSelectionMode - ? AppBar( - backgroundColor: Colors.blueAccent, - leading: IconButton( - icon: const Icon(Icons.close, color: Colors.white), - onPressed: _clearSelection, - ), - title: Text( - '${_selectedIndices.length} Selected', - style: const TextStyle(color: Colors.white), - ), - actions: [ - IconButton( - icon: const Icon(Icons.copy, color: Colors.white), - onPressed: () => _copySelectedMessages(activeChat), - ), - IconButton( - icon: const Icon(Icons.delete, color: Colors.white), - onPressed: () { - // TODO delete on DB - _clearSelection(); - }, - ), - ], - ) - : GlassAppBar( - name: widget.displayName, - status: _getPresenceStatusText(chatState), - ) - as PreferredSizeWidget, - - body: Stack( - children: [ - activeChat.isEmpty - ? const Center( - child: Text( - "No messages yet", - style: TextStyle(color: Colors.grey, fontSize: 14), - ), - ) - : ListView.builder( - controller: _scrollController, - padding: EdgeInsets.only( - top: 120, - bottom: _replyingToMessage != null ? 180 : 120, - left: 16, - right: 16, + return PopScope( + canPop: !isSelectionMode, + onPopInvokedWithResult: (didPop, result) { + setState(() { + _selectedIndices.clear(); + }); + }, + child: Scaffold( + backgroundColor: Colors.white, + extendBody: true, + extendBodyBehindAppBar: true, + + appBar: PreferredSize( + preferredSize: const Size.fromHeight(kToolbarHeight), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + child: isSelectionMode + ? AppBar( + key: const ValueKey('SelectionAppBar'), + backgroundColor: Colors.blueAccent, + leading: IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: _clearSelection, + ), + title: Text( + '${_selectedIndices.length} Selected', + style: const TextStyle(color: Colors.white), + ), + actions: [ + IconButton( + icon: const Icon(Icons.copy, color: Colors.white), + onPressed: () => _copySelectedMessages(activeChat), + ), + IconButton( + icon: const Icon(Icons.delete, color: Colors.white), + onPressed: () { + // TODO: delete on DB + _clearSelection(); + }, + ), + ], + ) + : GlassAppBar( + key: const ValueKey('GlassAppBar'), + name: widget.displayName, + status: _getPresenceStatusText(chatState), ), - 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)) { + ), + ), + + body: Stack( + children: [ + activeChat.isEmpty + ? const Center( + child: Text( + "No messages yet", + style: TextStyle(color: Colors.grey, fontSize: 14), + ), + ) + : ListView.builder( + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics( + parent: BouncingScrollPhysics(), + ), + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + padding: EdgeInsets.only( + top: 120, + bottom: _replyingToMessage != null ? 180 : 120, + 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; + } } - } - - 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( - child: Container( - margin: const EdgeInsets.symmetric(vertical: 16), - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 4, - ), - decoration: BoxDecoration( - color: Colors.grey[200], - borderRadius: BorderRadius.circular(12), - ), - child: Text( - _formatDateSeparator(msg.createdAt), - style: const TextStyle( - fontSize: 12, - color: Colors.black54, + + 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( + child: Container( + margin: const EdgeInsets.symmetric( + vertical: 16, + ), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _formatDateSeparator(msg.createdAt), + style: const TextStyle( + fontSize: 12, + color: Colors.black54, + ), ), ), ), - ), - GestureDetector( - onLongPress: () => _toggleSelection(index), - onTap: () { - if (isSelectionMode) _toggleSelection(index); - }, - child: Container( - color: isSelected - ? Colors.blue.withValues(alpha: 0.2) - : Colors.transparent, - child: Dismissible( - key: ValueKey( - msg.createdAt.toString() + index.toString(), - ), - direction: DismissDirection.startToEnd, - confirmDismiss: (direction) async { - setState(() { - _replyingToMessage = msg; - }); - return false; - }, - background: Container( - alignment: Alignment.centerLeft, - padding: const EdgeInsets.only(left: 16), - color: Colors.transparent, - child: const Icon( - Icons.reply, - color: Colors.grey, + GestureDetector( + onLongPress: () => _toggleSelection(index), + onTap: () { + if (isSelectionMode) _toggleSelection(index); + }, + child: Container( + color: isSelected + ? Colors.blue.withValues(alpha: 0.2) + : Colors.transparent, + child: Dismissible( + key: ValueKey( + msg.createdAt.toString() + index.toString(), + ), + direction: DismissDirection.startToEnd, + confirmDismiss: (direction) async { + setState(() { + _replyingToMessage = msg; + }); + return false; + }, + background: Container( + alignment: Alignment.centerLeft, + padding: const EdgeInsets.only(left: 16), + color: Colors.transparent, + child: const Icon( + Icons.reply, + color: Colors.grey, + ), + ), + child: ChatBubble( + message: msg.content, + isMe: isMe, + timestamp: msg.createdAt, + isRead: msg.isRead, ), - ), - child: ChatBubble( - message: msg.content, - isMe: isMe, - timestamp: msg.createdAt, - isRead: msg.isRead, ), ), ), - ), - ], - ); - }, - ), + ], + ); + }, + ), - if (_showScrollToBottom) Positioned( right: 16, bottom: _replyingToMessage != null ? 140 : 90, - child: FloatingActionButton( - mini: true, - backgroundColor: Colors.white, - foregroundColor: Colors.blue, - onPressed: _scrollToBottom, - child: const Icon(Icons.keyboard_arrow_down), + 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: Colors.white, + foregroundColor: Colors.blue, + elevation: 4, + onPressed: () => _scrollToBottom(animated: true), + child: const Icon(Icons.keyboard_arrow_down), + ), + ), ), ), - Align( - alignment: Alignment.bottomCenter, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (_replyingToMessage != null) - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.grey[200], - border: const Border( - left: BorderSide(color: Colors.blue, width: 4), + Align( + alignment: Alignment.bottomCenter, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_replyingToMessage != null) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + border: const Border( + left: BorderSide(color: Colors.blue, width: 4), + top: BorderSide(color: Colors.black12, width: 1), + ), + boxShadow: const [ + BoxShadow( + color: Colors.black12, + blurRadius: 4, + offset: Offset(0, -2), + ), + ], ), - ), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - "Replying to message", - style: TextStyle( - fontWeight: FontWeight.bold, - color: Colors.blue, - fontSize: 12, + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Replying to message", + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.blue, + fontSize: 12, + ), ), - ), - const SizedBox(height: 4), - Text( - _replyingToMessage.content, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Colors.black87), - ), - ], + const SizedBox(height: 4), + Text( + _replyingToMessage.content, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.black87), + ), + ], + ), ), - ), - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: () => - setState(() => _replyingToMessage = null), - ), - ], + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => + setState(() => _replyingToMessage = null), + ), + ], + ), ), + ChatInputArea( + onSendMessage: _sendMessage, + onTypingChanged: (isTyping) => context + .read() + .sendTypingNotification(isTyping), ), - ChatInputArea( - onSendMessage: _sendMessage, - onTypingChanged: (isTyping) => context - .read() - .sendTypingNotification(isTyping), - ), - ], + ], + ), ), - ), - ], + ], + ), ), ); } diff --git a/mobile/lib/pages/home_page.dart b/mobile/lib/pages/home_page.dart index 7adb89c..a2f320c 100644 --- a/mobile/lib/pages/home_page.dart +++ b/mobile/lib/pages/home_page.dart @@ -1,5 +1,6 @@ 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'; @@ -22,20 +23,33 @@ class _HomePageState extends State { final _searchController = TextEditingController(); final Set _selectedChatIds = {}; + late final ScrollController _scrollController; + bool _isFabVisible = true; + bool get _isSelectionMode => _selectedChatIds.isNotEmpty; @override void initState() { super.initState(); _isSearchOpen = false; + + _scrollController = ScrollController(); + _scrollController.addListener(() { + if (_scrollController.position.userScrollDirection == + ScrollDirection.reverse) { + if (_isFabVisible) setState(() => _isFabVisible = false); + } else if (_scrollController.position.userScrollDirection == + ScrollDirection.forward) { + if (!_isFabVisible) setState(() => _isFabVisible = true); + } + }); + WidgetsBinding.instance.addPostFrameCallback((_) async { final chatController = context.read(); final authService = AuthService(); final token = await authService.getToken(); if (token != null) { - // Initialize WebSocket session immediately on app load await chatController.initSession(token); - // Load the initial inbox await chatController.loadInbox(); } }); @@ -44,6 +58,8 @@ class _HomePageState extends State { @override void dispose() { _searchController.dispose(); + _scrollController + .dispose(); super.dispose(); } @@ -215,6 +231,11 @@ class _HomePageState extends State { color: const Color(0xFF1890FF), onRefresh: _isSelectionMode ? () async {} : () => chatState.loadInbox(), child: CustomScrollView( + controller: _scrollController, + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + physics: const AlwaysScrollableScrollPhysics( + parent: BouncingScrollPhysics(), + ), slivers: [ SliverAppBar( shadowColor: Colors.transparent, @@ -446,9 +467,8 @@ class _HomePageState extends State { ); }, ), - SliverList( - delegate: SliverChildListDelegate([const SizedBox(height: 120)]), - ), + + const SliverPadding(padding: EdgeInsets.only(bottom: 120)), ], ), ); @@ -489,18 +509,28 @@ class _HomePageState extends State { ], ), floatingActionButton: _page == 1 - ? Padding( - padding: const EdgeInsets.only(bottom: 10), - child: FloatingActionButton( - heroTag: "fab_pen", - backgroundColor: const Color(0xFF1890FF), - child: const Icon(Icons.edit, color: Colors.white), - onPressed: () { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => const NewChatPage()), - ); - }, + ? AnimatedSlide( + duration: const Duration(milliseconds: 300), + offset: _isFabVisible ? Offset.zero : const Offset(0, 2), + child: AnimatedOpacity( + duration: const Duration(milliseconds: 300), + opacity: _isFabVisible ? 1.0 : 0.0, + child: Padding( + padding: const EdgeInsets.only(bottom: 10), + child: FloatingActionButton( + heroTag: "fab_pen", + backgroundColor: const Color(0xFF1890FF), + child: const Icon(Icons.edit, color: Colors.white), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const NewChatPage(), + ), + ); + }, + ), + ), ), ) : null, diff --git a/mobile/lib/pages/new_chat_page.dart b/mobile/lib/pages/new_chat_page.dart index 82ba109..f293b01 100644 --- a/mobile/lib/pages/new_chat_page.dart +++ b/mobile/lib/pages/new_chat_page.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:mobile/pages/select_contact_page.dart'; import 'package:provider/provider.dart'; import 'package:mobile/controllers/chat.dart'; import 'chat_page.dart'; @@ -144,9 +145,10 @@ class _NewChatPageState extends State { ), ), actions: [ - IconButton( - icon: const Icon(Icons.more_vert, color: Colors.black54), - onPressed: () {}, + PopupMenuButton( + itemBuilder: (context) => [ + PopupMenuItem(child: Text("Share username by QR")), + ], ), ], ), @@ -258,7 +260,14 @@ class _NewChatPageState extends State { _buildActionRow( icon: Icons.group, label: "New group", - onTap: () {}, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SelectContactPage(), + ), + ); + }, ), _buildActionRow( icon: Icons.alternate_email, diff --git a/mobile/lib/pages/phone_number_page.dart b/mobile/lib/pages/phone_number_page.dart index 2177d63..a448a01 100644 --- a/mobile/lib/pages/phone_number_page.dart +++ b/mobile/lib/pages/phone_number_page.dart @@ -142,7 +142,6 @@ class NumberVerificationPage extends StatelessWidget { ? TextButton( onPressed: () { otp.resetTimer(); - // resend OTP }, child: const Text('Resend OTP'), ) diff --git a/mobile/lib/pages/select_contact_page.dart b/mobile/lib/pages/select_contact_page.dart new file mode 100644 index 0000000..113582d --- /dev/null +++ b/mobile/lib/pages/select_contact_page.dart @@ -0,0 +1,315 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:mobile/controllers/chat.dart'; +import 'package:provider/provider.dart'; + +class SelectContactPage extends StatefulWidget { + const SelectContactPage({super.key}); + + @override + State createState() => _SelectContactPageState(); +} + +class _SelectContactPageState extends State { + final Map> _selectedContacts = {}; + + Timer? _debounceTimer; + bool _isSearchOpen = false; + final _searchController = TextEditingController(); + + void _onSearchChanged(String value, ChatController chatState) { + if (_debounceTimer?.isActive ?? false) _debounceTimer!.cancel(); + + _debounceTimer = Timer(const Duration(milliseconds: 300), () { + chatState.queryUsers(value); + }); + } + + void _toggleSelection(String id, String displayName, String username) { + setState(() { + if (_selectedContacts.containsKey(id)) { + _selectedContacts.remove(id); + } else { + _selectedContacts[id] = { + 'displayName': displayName, + 'username': username, + }; + } + }); + } + + @override + void dispose() { + _debounceTimer?.cancel(); + _searchController.dispose(); + super.dispose(); + } + + Widget _buildContactTile({ + required String id, + required String displayName, + required String username, + }) { + final bool isSelected = _selectedContacts.containsKey(id); + + return ListTile( + selected: isSelected, + selectedTileColor: Colors.blue.withValues(alpha: 0.1), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + leading: Stack( + children: [ + const CircleAvatar( + backgroundColor: Color(0xFFD6E4FF), + child: Icon(Icons.person, color: Color(0xFF1890FF)), + ), + if (isSelected) + Positioned( + right: 0, + bottom: 0, + child: Container( + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.check_circle, + color: Colors.blue, + size: 18, + ), + ), + ), + ], + ), + title: Text( + displayName, + style: const TextStyle( + color: Colors.black87, + fontWeight: FontWeight.w600, + ), + ), + subtitle: Text( + "@$username", + style: const TextStyle(color: Colors.black45), + ), + onTap: () => _toggleSelection(id, displayName, username), + ); + } + + @override + Widget build(BuildContext context) { + final chatState = context.watch(); + final bool isSearching = _searchController.text.trim().isNotEmpty; + + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + scrolledUnderElevation: 0, + title: AnimatedSwitcher( + switchInCurve: Curves.decelerate, + switchOutCurve: Curves.decelerate, + duration: const Duration(milliseconds: 300), + transitionBuilder: (child, animation) { + final tween = Tween( + begin: const Offset(0.5, 0.0), + end: Offset.zero, + ); + final offsetAnimation = tween.animate(animation); + return SlideTransition(position: offsetAnimation, child: child); + }, + child: _isSearchOpen + ? SizedBox( + height: 40, + child: TextField( + controller: _searchController, + onChanged: (val) => _onSearchChanged(val, chatState), + style: const TextStyle(color: Colors.black87), + autofocus: true, + decoration: InputDecoration( + hintText: "Name, username or number", + hintStyle: const TextStyle( + color: Colors.black38, + fontSize: 15, + ), + suffixIcon: isSearching + ? IconButton( + icon: const Icon( + Icons.clear, + color: Colors.black38, + ), + onPressed: () { + _searchController.clear(); + chatState.queryUsers(""); + }, + ) + : null, + filled: true, + fillColor: const Color(0xFFF5F5F5), + contentPadding: const EdgeInsets.symmetric( + vertical: 10, + horizontal: 16, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide.none, + ), + ), + ), + ) + : const Text( + 'Select Contacts', + style: TextStyle( + letterSpacing: 0.5, + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + key: ValueKey('title'), + ), + ), + actions: [ + IconButton( + onPressed: () { + setState(() { + _isSearchOpen = !_isSearchOpen; + if (!_isSearchOpen) { + _searchController.clear(); + chatState.queryUsers(""); + } + }); + }, + icon: Icon( + _isSearchOpen ? Icons.close : Icons.search, + color: Colors.black87, + ), + ), + ], + ), + body: Column( + children: [ + AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + child: _selectedContacts.isNotEmpty + ? Container( + width: double.infinity, + color: Colors.blue.withValues(alpha: 0.1), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + child: Text( + '${_selectedContacts.length} selected', + style: const TextStyle( + color: Colors.blue, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ) + : const SizedBox.shrink(), + ), + Expanded( + child: isSearching + ? (chatState.isSearchLoading + ? const Center( + child: CircularProgressIndicator( + color: Color(0xFF1890FF), + ), + ) + : chatState.contactSearchResults.isEmpty + ? const Center( + child: Text( + "No users found", + style: TextStyle(color: Colors.grey, fontSize: 15), + ), + ) + : ListView.builder( + itemCount: chatState.contactSearchResults.length, + itemBuilder: (context, index) { + final user = chatState.contactSearchResults[index]; + final String id = + (user['id'] ?? user['user_id'] ?? '') + .toString(); + final String displayName = + user['display_name'] ?? 'User'; + final String username = user['username'] ?? ''; + + if (id.isEmpty) return const SizedBox.shrink(); + + return _buildContactTile( + id: id, + displayName: displayName, + username: username, + ); + }, + )) + : ListView( + children: [ + if (_selectedContacts.isNotEmpty) ...[ + const Padding( + padding: EdgeInsets.only( + left: 16, + top: 20, + bottom: 8, + ), + child: Text( + "Selected Contacts", + style: TextStyle( + color: Colors.black38, + fontWeight: FontWeight.bold, + fontSize: 13, + ), + ), + ), + ..._selectedContacts.entries.map( + (entry) => _buildContactTile( + id: entry.key, + displayName: entry.value['displayName']!, + username: entry.value['username']!, + ), + ), + ], + + const Padding( + padding: EdgeInsets.only(left: 16, top: 20, bottom: 8), + child: Text( + "Recent Contacts", + style: TextStyle( + color: Colors.black38, + fontWeight: FontWeight.bold, + fontSize: 13, + ), + ), + ), + + if (chatState.inbox.isNotEmpty) + ...chatState.inbox + .where( + (thread) => !_selectedContacts.containsKey( + thread.chatUserId, + ), + ) + .map( + (thread) => _buildContactTile( + id: thread.chatUserId, + displayName: thread.displayName, + username: thread.username, + ), + ), + ], + ), + ), + ], + ), + floatingActionButton: _selectedContacts.isNotEmpty + ? FloatingActionButton( + onPressed: () {}, + backgroundColor: Colors.blue, + elevation: 4, + child: const Icon(Icons.arrow_forward, color: Colors.white), + ) + : null, + ); + } +} diff --git a/mobile/lib/widgets/custom_cards.dart b/mobile/lib/widgets/custom_cards.dart index f6a8c24..a939295 100644 --- a/mobile/lib/widgets/custom_cards.dart +++ b/mobile/lib/widgets/custom_cards.dart @@ -20,116 +20,166 @@ class CustomChatCard extends StatelessWidget { required this.isSelectionMode, }); - @override - Widget build(BuildContext context) { - final String timeLabel = - "${conversation.lastMessageTime.hour.toString().padLeft(2, '0')}:${conversation.lastMessageTime.minute.toString().padLeft(2, '0')}"; + String _formatTimestamp(DateTime time) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final yesterday = today.subtract(const Duration(days: 1)); + final messageDate = DateTime(time.year, time.month, time.day); - return InkWell( - onLongPress: onLongPress, + if (messageDate == today) { + return "${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}"; + } else if (messageDate == yesterday) { + return "Yesterday"; + } else { + return "${time.day}/${time.month}/${time.year}"; + } + } - onTap: () async { - if (isSelectionMode) { - onTapInSelection(); - } else { - final controller = context.read(); - await controller.openChat(conversation.chatUserId); + @override + Widget build(BuildContext context) { + final String timeLabel = _formatTimestamp(conversation.lastMessageTime); + final bool hasUnread = conversation.unreadCount > 0; - if (context.mounted) { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => ChatPage( - chatUserId: conversation.chatUserId, - displayName: conversation.displayName, + return Material( + color: isSelected + ? Colors.blue.withValues(alpha: 0.1) + : Colors.transparent, + child: InkWell( + onLongPress: onLongPress, + onTap: () async { + 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, + ), ), - ), - ); + ); + } } - } - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), - color: isSelected? Colors.blue.withAlpha(20) : null, - child: Row( - children: [ - SizedBox( - width: 50, - height: 50, - child: Stack( - fit: StackFit.expand, - children: [ - const CircleAvatar( - radius: 24, - backgroundColor: Color(0xFFD6E4FF), - child: Icon(Icons.person, color: Color(0xFF1890FF)), - ), - isSelected - ? Align( - alignment: AlignmentGeometry.bottomRight, + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + children: [ + SizedBox( + width: 52, + height: 52, + child: Stack( + fit: StackFit.expand, + children: [ + const CircleAvatar( + radius: 26, + backgroundColor: Color(0xFFD6E4FF), + child: Icon(Icons.person, color: Color(0xFF1890FF)), + ), + Positioned( + right: -2, + bottom: -2, + child: AnimatedScale( + scale: isSelected ? 1.0 : 0.0, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutBack, + child: Container( + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), child: const Icon( Icons.check_circle, color: Colors.blue, + size: 22, ), - ) - : SizedBox.shrink(), - ], - ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - conversation.displayName, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.black87, + ), + ), ), - ), - const SizedBox(height: 4), - Text( - conversation.lastMessage, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 14, color: Colors.black54), - ), - ], - ), - ), - const SizedBox(width: 10), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - timeLabel, - style: const TextStyle(color: Colors.black38, fontSize: 12), + ], ), - const SizedBox(height: 6), - if (conversation.unreadCount > 0) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 7, - vertical: 3, - ), - decoration: const BoxDecoration( - color: Color(0xFF1890FF), - shape: BoxShape.circle, - ), - child: Text( - "${conversation.unreadCount}", + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + conversation.displayName, style: const TextStyle( - color: Colors.white, - fontSize: 10, + fontSize: 16, fontWeight: FontWeight.bold, + color: Colors.black87, ), ), + const SizedBox(height: 4), + Text( + conversation.lastMessage, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14, + color: hasUnread ? Colors.black87 : Colors.black54, + fontWeight: hasUnread + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + ], + ), + ), + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + timeLabel, + style: TextStyle( + color: hasUnread + ? const Color(0xFF1890FF) + : Colors.black38, + fontSize: 12, + fontWeight: hasUnread + ? FontWeight.w600 + : FontWeight.normal, + ), ), - ], - ), - ], + const SizedBox(height: 6), + if (hasUnread) + Container( + constraints: const BoxConstraints( + minWidth: 20, + minHeight: 20, + ), + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: const Color(0xFF1890FF), + borderRadius: BorderRadius.circular(10), + ), + alignment: Alignment.center, + child: Text( + conversation.unreadCount > 99 + ? "99+" + : "${conversation.unreadCount}", + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ], + ), ), ), );