From a14981963133a638f184cc6ef818395d0c6d47a4 Mon Sep 17 00:00:00 2001 From: ChandruBtechCSE Date: Wed, 8 Jul 2026 21:16:16 +0530 Subject: [PATCH 1/3] group issue fixed --- ...otlin-compiler-13013338712485162840.salive | 0 mobile/lib/controllers/chat.dart | 3 ++ mobile/lib/pages/select_contact_page.dart | 49 +++++++++++++------ mobile/lib/pages/settings pages/accounts.dart | 7 ++- .../providers/group_controller_provider.dart | 40 ++++++++++----- 5 files changed, 70 insertions(+), 29 deletions(-) create mode 100644 mobile/android/.kotlin/sessions/kotlin-compiler-13013338712485162840.salive diff --git a/mobile/android/.kotlin/sessions/kotlin-compiler-13013338712485162840.salive b/mobile/android/.kotlin/sessions/kotlin-compiler-13013338712485162840.salive new file mode 100644 index 0000000..e69de29 diff --git a/mobile/lib/controllers/chat.dart b/mobile/lib/controllers/chat.dart index 337d3aa..f9bc849 100644 --- a/mobile/lib/controllers/chat.dart +++ b/mobile/lib/controllers/chat.dart @@ -90,6 +90,9 @@ class ChatController extends ChangeNotifier with WidgetsBindingObserver { inbox.clear(); activeChat.clear(); contactSearchResults.clear(); + groupMemberNames.clear(); + userCache.clear(); + _currentGroupMembers.clear(); currentChatUserId = null; isPeerTyping = false; isPeerOnline = false; diff --git a/mobile/lib/pages/select_contact_page.dart b/mobile/lib/pages/select_contact_page.dart index e0d52b3..e646b0b 100644 --- a/mobile/lib/pages/select_contact_page.dart +++ b/mobile/lib/pages/select_contact_page.dart @@ -21,11 +21,13 @@ class _SelectContactPageState extends State { final _searchController = TextEditingController(); final _groupNameController = TextEditingController(); - void _onSearchChanged(String value, ChatController chatState) { + void _onSearchChanged(String value) { if (_debounceTimer?.isActive ?? false) _debounceTimer!.cancel(); _debounceTimer = Timer(const Duration(milliseconds: 300), () { - chatState.queryUsers(value); + if (mounted) { + context.read().queryUsers(value); + } }); } @@ -93,7 +95,7 @@ class _SelectContactPageState extends State { ), ), subtitle: Text( - "@$username", + username.startsWith('@') ? username : "@$username", style: const TextStyle(color: Colors.black45), ), onTap: () => _toggleSelection(id, displayName, username), @@ -119,15 +121,18 @@ class _SelectContactPageState extends State { begin: const Offset(0.5, 0.0), end: Offset.zero, ); - final offsetAnimation = tween.animate(animation); - return SlideTransition(position: offsetAnimation, child: child); + return SlideTransition( + position: tween.animate(animation), + child: child, + ); }, child: _isSearchOpen ? SizedBox( height: 40, child: TextField( controller: _searchController, - onChanged: (val) => _onSearchChanged(val, chatState), + onChanged: + _onSearchChanged, style: const TextStyle(color: Colors.black87), autofocus: true, decoration: InputDecoration( @@ -144,7 +149,9 @@ class _SelectContactPageState extends State { ), onPressed: () { _searchController.clear(); - chatState.queryUsers(""); + context.read().queryUsers( + "", + ); }, ) : null, @@ -179,7 +186,9 @@ class _SelectContactPageState extends State { _isSearchOpen = !_isSearchOpen; if (!_isSearchOpen) { _searchController.clear(); - chatState.queryUsers(""); + context.read().queryUsers( + "", + ); } }); }, @@ -297,7 +306,9 @@ class _SelectContactPageState extends State { (thread) => _buildContactTile( id: thread.id, displayName: thread.title, - username: thread.title, + username: thread.title + .replaceAll(' ', '') + .toLowerCase(), ), ), ], @@ -311,7 +322,7 @@ class _SelectContactPageState extends State { elevation: 4, child: const Icon(Icons.arrow_forward, color: Colors.white), onPressed: () { - context.read().addContacts(_selectedContacts); + context.read().setContacts(_selectedContacts); showModalBottomSheet( context: context, @@ -323,7 +334,7 @@ class _SelectContactPageState extends State { ), builder: (BuildContext bottomSheetContext) { return Consumer( - builder: (context, groupState, child) { + builder: (modalContext, groupState, child) { return Padding( padding: EdgeInsets.only( bottom: MediaQuery.of( @@ -369,7 +380,11 @@ class _SelectContactPageState extends State { .keys .toList(); - final newGroup = await context + final rootNavigator = Navigator.of( + context, + ); + + final newGroup = await modalContext .read() .createGroup( groupName: groupName, @@ -386,22 +401,24 @@ class _SelectContactPageState extends State { Navigator.of( bottomSheetContext, ).pop(); - Navigator.of( - context, - ).pushReplacement( + + _groupNameController.clear(); + + rootNavigator.pushReplacement( MaterialPageRoute( builder: (context) => ChatPage( chatUserId: newGroup.id, displayName: newGroup.name, + isGroup: true, ), ), ); context .read() - .openChat(newGroup.id); + .openChat(newGroup.id, isGroup: true); } else { if (!context.mounted) return; ScaffoldMessenger.of( diff --git a/mobile/lib/pages/settings pages/accounts.dart b/mobile/lib/pages/settings pages/accounts.dart index 553389a..144ed7b 100644 --- a/mobile/lib/pages/settings pages/accounts.dart +++ b/mobile/lib/pages/settings pages/accounts.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:mobile/controllers/auth.dart'; import 'package:mobile/controllers/chat.dart'; import 'package:mobile/main.dart'; +import 'package:mobile/providers/group_controller_provider.dart'; import 'package:provider/provider.dart'; class AccountsSettings extends StatelessWidget { @@ -138,15 +139,17 @@ class AccountsSettings extends StatelessWidget { backgroundColor: WidgetStatePropertyAll(Colors.redAccent), ), onPressed: () async { - context.read().clearSessionData(); await context.read().logout(); + if (context.mounted) { + context.read().clearSessionData(); + context.read().clearGroupData(); Navigator.pushAndRemoveUntil( context, MaterialPageRoute( builder: (context) => const SessionGateway(), ), - (route) => false, + (Route route) => false, ); } }, diff --git a/mobile/lib/providers/group_controller_provider.dart b/mobile/lib/providers/group_controller_provider.dart index 8b96ef2..18229bb 100644 --- a/mobile/lib/providers/group_controller_provider.dart +++ b/mobile/lib/providers/group_controller_provider.dart @@ -5,16 +5,24 @@ import 'package:mobile/services/api.dart'; class GroupController extends ChangeNotifier { final Map> _selectedContacts = {}; - Map> get selectedContacts => _selectedContacts; - void addContacts(Map> contacts) { + void setContacts(Map> contacts) { _selectedContacts.clear(); _selectedContacts.addAll(contacts); notifyListeners(); } +//hhhh.1585 + void toggleContact(String id, Map contactData) { + if (_selectedContacts.containsKey(id)) { + _selectedContacts.remove(id); + } else { + _selectedContacts[id] = contactData; + } + notifyListeners(); + } - // Group Creation + // --- Group Creation --- final ApiService _api = ApiService(); @@ -47,10 +55,18 @@ class GroupController extends ChangeNotifier { if (memberIds.isNotEmpty) { final memberRequests = memberIds.map((userId) { - return _api.post( - '/groups/${newGroup.id}/members', - data: {"user_id": userId}, - ); + return _api + .post( + '/groups/${newGroup.id}/members', + data: {"user_id": userId}, + ) + .catchError((error) { + debugPrint("Failed to add user $userId to group: $error"); + return Response( + requestOptions: RequestOptions(path: ''), + statusCode: 500, + ); + }); }); await Future.wait(memberRequests); @@ -62,8 +78,6 @@ class GroupController extends ChangeNotifier { return null; } on DioException catch (e) { debugPrint("Dio Error: ${e.message}"); - debugPrint("URL: ${e.requestOptions.uri}"); - if (e.response != null) { debugPrint("Payload: ${e.response?.data}"); } @@ -100,8 +114,6 @@ class GroupController extends ChangeNotifier { } } - // Inside GroupController - Future removeMemberFromGroup(String groupId, String userId) async { try { _isLoading = true; @@ -121,4 +133,10 @@ class GroupController extends ChangeNotifier { notifyListeners(); } } + + void clearGroupData() { + _selectedContacts.clear(); + _isLoading = false; + notifyListeners(); + } } From 42f49495550ccbb4c8d48303f14c7b01472707ca Mon Sep 17 00:00:00 2001 From: ChandruBtechCSE Date: Wed, 8 Jul 2026 21:16:53 +0530 Subject: [PATCH 2/3] minor change --- .../.kotlin/sessions/kotlin-compiler-13013338712485162840.salive | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 mobile/android/.kotlin/sessions/kotlin-compiler-13013338712485162840.salive diff --git a/mobile/android/.kotlin/sessions/kotlin-compiler-13013338712485162840.salive b/mobile/android/.kotlin/sessions/kotlin-compiler-13013338712485162840.salive deleted file mode 100644 index e69de29..0000000 From b9d7c95a9959929618c80dba18680a9c45b083e0 Mon Sep 17 00:00:00 2001 From: ChandruBtechCSE Date: Thu, 9 Jul 2026 00:06:08 +0530 Subject: [PATCH 3/3] search in chat here --- mobile/lib/pages/chat_details_page.dart | 422 ++++++++++++++- mobile/lib/pages/chat_page.dart | 490 ++++++++++++------ .../widgets/chat_screen_modular_widgets.dart | 208 +------- 3 files changed, 733 insertions(+), 387 deletions(-) diff --git a/mobile/lib/pages/chat_details_page.dart b/mobile/lib/pages/chat_details_page.dart index d7c11bb..336a61f 100644 --- a/mobile/lib/pages/chat_details_page.dart +++ b/mobile/lib/pages/chat_details_page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:mobile/controllers/auth.dart'; import 'package:mobile/controllers/chat.dart'; @@ -59,6 +61,109 @@ class _ChatDetailsPageState extends State { } } + void _showAddParticipantSheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) { + return DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.5, + maxChildSize: 0.9, + expand: false, + builder: (context, scrollController) { + return Column( + children: [ + const Padding( + padding: EdgeInsets.all(16.0), + child: Text( + 'Add Participants', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: TextField( + decoration: InputDecoration( + hintText: 'Search users...', + prefixIcon: const Icon(Icons.search), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onChanged: (query) { + // TODO: Implement your user search logic here + }, + ), + ), + const SizedBox(height: 10), + Expanded( + child: Consumer( + builder: (context, groupController, child) { + if (groupController.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + // TODO: Replace with your actual searched users list + final searchedUsers = + []; // e.g., authController.searchedUsers + + if (searchedUsers.isEmpty) { + return const Center( + child: Text('Search for users to add.'), + ); + } + + return ListView.builder( + controller: scrollController, + itemCount: searchedUsers.length, + itemBuilder: (context, index) { + final user = searchedUsers[index]; + return ListTile( + leading: const CircleAvatar( + child: Icon(Icons.person), + ), + title: Text(user.displayName), + trailing: IconButton( + icon: const Icon( + Icons.add_circle, + color: Colors.teal, + ), + onPressed: () async { + final success = await context + .read() + .addMemberToGroup(widget.chatId, user.id); + + if (success && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${user.displayName} added!', + ), + ), + ); + // Refresh the group members list behind the sheet + context + .read() + .fetchGroupMembers(widget.chatId); + Navigator.pop(context); // Close sheet + } + }, + ), + ); + }, + ); + }, + ), + ), + ], + ); + }, + ); + }, + ); + } + @override Widget build(BuildContext context) { final chatController = context.watch(); @@ -119,7 +224,10 @@ class _ChatDetailsPageState extends State { _ActionIcon( icon: Icons.search, label: 'Search', - ), // Search in chat feature + onTap: (){ + Navigator.pop(context, 'start_search'); + } + ), ], ), ); @@ -199,7 +307,6 @@ class _ChatDetailsPageState extends State { final members = chatController.currentGroupMembers; final isLoading = chatController.isLoadingDetails; - // Check if the CURRENT user is an admin by finding their ID in the members list final currentUserId = context.read().currentUser?.id; final currentUserMember = members .where((m) => m.userId == currentUserId) @@ -222,7 +329,6 @@ class _ChatDetailsPageState extends State { ), ), - // Only show Add button if the current user has the 'admin' role if (isCurrentUserAdmin) ListTile( leading: const CircleAvatar( @@ -231,7 +337,15 @@ class _ChatDetailsPageState extends State { ), title: const Text('Add participants'), onTap: () { - // TODO: Trigger Add Member Flow + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => _AddParticipantSheet(chatId: widget.chatId), + ); }, ), @@ -258,9 +372,7 @@ class _ChatDetailsPageState extends State { ? const Icon(Icons.person) : null, ), - // FIXED: Using display_name instead of name title: Text(member.displayName), - // FIXED: Dynamically rendering Admin tag based on API role subtitle: isThisUserAdmin ? const Text( 'Admin', @@ -307,13 +419,57 @@ class _ChatDetailsPageState extends State { ), onTap: () async { Navigator.pop(context); - final success = await context - .read() - .removeMemberFromGroup(widget.chatId, member.userId); - if (success && mounted) { - context.read().fetchGroupMembers( - widget.chatId, + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Remove Participant'), + content: Text( + 'Are you sure you want to remove ${member.displayName} from this group?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text( + 'Remove', + style: TextStyle(color: Colors.red), + ), + ), + ], + ), + ); + + if (confirm == true && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Removing member...'), + duration: Duration(seconds: 1), + ), ); + + final success = await context + .read() + .removeMemberFromGroup(widget.chatId, member.userId); + + if (success && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${member.displayName} removed.'), + ), + ); + context.read().fetchGroupMembers( + widget.chatId, + ); + } else if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to remove member.'), + ), + ); + } } }, ), @@ -340,13 +496,14 @@ class _SectionContainer extends StatelessWidget { class _ActionIcon extends StatelessWidget { final IconData icon; final String label; + final GestureTapCallback? onTap; - const _ActionIcon({required this.icon, required this.label}); + const _ActionIcon({required this.icon, required this.label, this.onTap}); @override Widget build(BuildContext context) { return InkWell( - onTap: () {}, + onTap: onTap, child: Padding( padding: const EdgeInsets.symmetric(vertical: 16.0), child: Column( @@ -363,3 +520,240 @@ class _ActionIcon extends StatelessWidget { ); } } + +class _AddParticipantSheet extends StatefulWidget { + final String chatId; + + const _AddParticipantSheet({required this.chatId}); + + @override + State<_AddParticipantSheet> createState() => _AddParticipantSheetState(); +} + +class _AddParticipantSheetState extends State<_AddParticipantSheet> { + final _searchController = TextEditingController(); + Timer? _debounceTimer; + + @override + void dispose() { + _debounceTimer?.cancel(); + _searchController.dispose(); + super.dispose(); + } + + void _onSearchChanged(String value, ChatController chatState) { + if (_debounceTimer?.isActive ?? false) _debounceTimer!.cancel(); + + _debounceTimer = Timer(const Duration(milliseconds: 300), () { + if (value.length >= 3) { + chatState.queryUsers(value); + } + }); + setState(() { + + }); + } + + Future _addMember(String userId, String displayName) async { + // Hide keyboard + FocusScope.of(context).unfocus(); + + final groupCtrl = context.read(); + final chatCtrl = context.read(); + + final success = await groupCtrl.addMemberToGroup(widget.chatId, userId); + + if (success && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('$displayName added to the group!')), + ); + // Refresh the group members list + chatCtrl.fetchGroupMembers(widget.chatId); + Navigator.pop(context); // Close the sheet + } else if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Failed to add $displayName.'))); + } + } + + @override + Widget build(BuildContext context) { + final chatState = context.watch(); + final groupState = context.watch(); + + final String searchInput = _searchController.text.trim(); + final bool isSearching = searchInput.isNotEmpty; + final bool hasValidQueryLength = searchInput.length >= 3; + + // Get current member IDs to filter them out of search results & recents + final currentMemberIds = chatState.currentGroupMembers + .map((m) => m.userId) + .toSet(); + + return Padding( + // Ensure the sheet rises with the keyboard + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Container( + height: + MediaQuery.of(context).size.height * 0.7, // Take up 70% of screen + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + children: [ + const Text( + 'Add Participants', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: TextField( + controller: _searchController, + onChanged: (val) => _onSearchChanged(val, chatState), + decoration: InputDecoration( + hintText: "Search name or username", + prefixIcon: const Icon(Icons.search), + suffixIcon: isSearching + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + chatState.queryUsers(""); + setState((){}); + }, + ) + : null, + filled: true, + fillColor: Colors.grey[100], + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ), + const SizedBox(height: 10), + + // Show Loading Indicator for Add API call + if (groupState.isLoading) + const Padding( + padding: EdgeInsets.all(8.0), + child: LinearProgressIndicator(), + ), + + Expanded( + child: isSearching + ? _buildSearchResults( + chatState, + currentMemberIds, + hasValidQueryLength, + ) + : _buildRecentContacts(chatState, currentMemberIds), + ), + ], + ), + ), + ); + } + + Widget _buildSearchResults( + ChatController chatState, + Set currentMemberIds, + bool hasValidQueryLength, + ) { + if (!hasValidQueryLength) { + return const Center( + child: Text("Type at least 3 characters to search..."), + ); + } + if (chatState.isSearchLoading) { + return const Center(child: CircularProgressIndicator()); + } + + // Filter out users who are already in the group + final results = chatState.contactSearchResults + .where((user) => !currentMemberIds.contains(user['id'])) + .toList(); + + if (results.isEmpty) { + return const Center(child: Text("No new users found.")); + } + + return ListView.builder( + itemCount: results.length, + itemBuilder: (context, index) { + final user = results[index]; + final String displayName = user['display_name'] ?? 'User'; + final String username = user['username'] ?? ''; + final String uid = user['id']; + + return ListTile( + leading: const CircleAvatar(child: Icon(Icons.person)), + title: Text(displayName), + subtitle: Text("@$username"), + trailing: IconButton( + icon: const Icon(Icons.add_circle, color: Colors.teal), + onPressed: () => _addMember(uid, displayName), + ), + ); + }, + ); + } + + Widget _buildRecentContacts( + ChatController chatState, + Set currentMemberIds, + ) { + // Filter to only 1-on-1 chats of users NOT currently in the group + final recents = chatState.inbox + .where( + (thread) => !thread.isGroup && !currentMemberIds.contains(thread.id), + ) + .toList(); + + if (recents.isEmpty) { + return const Center(child: Text("No recent contacts to add.")); + } + + return ListView.builder( + itemCount: recents.length, + itemBuilder: (context, index) { + final thread = recents[index]; + if (index == 0) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(left: 16, top: 8, bottom: 8), + child: Text( + "Recent Contacts", + style: TextStyle( + color: Colors.black38, + fontWeight: FontWeight.bold, + fontSize: 13, + ), + ), + ), + _buildRecentListTile(thread), + ], + ); + } + return _buildRecentListTile(thread); + }, + ); + } + + Widget _buildRecentListTile(dynamic thread) { + return ListTile( + leading: const CircleAvatar(child: Icon(Icons.person)), + title: Text(thread.title), + subtitle: Text("@${thread.username}"), + trailing: IconButton( + icon: const Icon(Icons.add_circle, color: Colors.teal), + onPressed: () => _addMember(thread.id, thread.title), + ), + ); + } +} diff --git a/mobile/lib/pages/chat_page.dart b/mobile/lib/pages/chat_page.dart index 7042e62..6c58442 100644 --- a/mobile/lib/pages/chat_page.dart +++ b/mobile/lib/pages/chat_page.dart @@ -1,6 +1,10 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:intl/intl.dart'; import 'package:mobile/controllers/auth.dart'; +import 'package:mobile/pages/chat_details_page.dart'; import 'package:provider/provider.dart'; import 'package:mobile/widgets/chat_screen_modular_widgets.dart'; import 'package:mobile/controllers/chat.dart'; @@ -23,6 +27,10 @@ class ChatPage extends StatefulWidget { } class _ChatPageState extends State with WidgetsBindingObserver { + bool _isSearchMode = false; + final TextEditingController _chatSearchController = TextEditingController(); + List _searchResults = []; + final ItemScrollController _itemScrollController = ItemScrollController(); final ItemPositionsListener _itemPositionsListener = ItemPositionsListener.create(); @@ -35,6 +43,8 @@ class _ChatPageState extends State with WidgetsBindingObserver { late ChatController _chatController; late AuthState _authState; + Timer? _highlightTimer; + String? _highlightedMessageId; int _previousMessageCount = 0; @@ -65,6 +75,8 @@ class _ChatPageState extends State with WidgetsBindingObserver { _chatController.closeChat(); }); + _highlightTimer?.cancel(); + super.dispose(); } @@ -102,7 +114,8 @@ class _ChatPageState extends State with WidgetsBindingObserver { _highlightedMessageId = messageId; }); - Future.delayed(const Duration(milliseconds: 1500), () { + _highlightTimer?.cancel(); + _highlightTimer = Timer(const Duration(milliseconds: 1500), () { if (mounted) { setState(() { _highlightedMessageId = null; @@ -125,8 +138,7 @@ class _ChatPageState extends State with WidgetsBindingObserver { ), ); - final isScrolledUp = - bottomItem.index == -1 || bottomItem.itemLeadingEdge < -0.1; + final isScrolledUp = bottomItem.index > 2 || (bottomItem.index == -1); if (isScrolledUp != _showScrollToBottom) { setState(() { @@ -197,8 +209,16 @@ class _ChatPageState extends State with WidgetsBindingObserver { final sortedIndices = _selectedIndices.toList()..sort(); final selectedTexts = sortedIndices - .map((index) => activeChat[index].content.toString()) - .join('\n'); + .map((index) { + final msg = activeChat[index]; + final time = DateFormat.jm().format(msg.createdAt); + final senderName = msg.senderId == _authState.currentUser!.id + ? "Me" + : widget.displayName; + + return '[$time] $senderName: ${msg.content}'; + }) + .join('\n\n'); Clipboard.setData(ClipboardData(text: selectedTexts)); @@ -228,6 +248,45 @@ class _ChatPageState extends State with WidgetsBindingObserver { return "${date.day}/${date.month}/${date.year}"; } + Widget _buildSearchAppBar(ThemeData theme) { + return AppBar( + backgroundColor: theme.colorScheme.surface, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + setState(() { + _isSearchMode = false; + _chatSearchController.clear(); + _searchResults.clear(); + }); + }, + ), + title: TextField( + controller: _chatSearchController, + autofocus: true, + decoration: const InputDecoration( + hintText: 'Search in chat...', + border: InputBorder.none, + ), + onChanged: (query) { + if (query.trim().isEmpty) { + setState(() => _searchResults = []); + return; + } + + final chatState = context.read(); + final lowercaseQuery = query.toLowerCase(); + + setState(() { + _searchResults = chatState.activeChat.where((msg) { + return msg.content.toLowerCase().contains(lowercaseQuery); + }).toList(); + }); + }, + ), + ); + } + @override Widget build(BuildContext context) { final chatState = context.watch(); @@ -261,64 +320,89 @@ class _ChatPageState extends State with WidgetsBindingObserver { 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, + child: _isSearchMode + ? _buildSearchAppBar(theme) + : 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( + isGroup: widget.isGroup, + key: const ValueKey('GlassAppBar'), + name: widget.displayName, + status: widget.isGroup + ? null + : _getPresenceStatusText(chatState), + onTitleTap: () async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatDetailsPage( + isGroup: widget.isGroup, + chatId: chatState.currentChatUserId!, + chatName: widget.displayName, + chatImageUrl: "", + ), + ), + ); + + // If the user clicked "Search" in ChatDetailsPage + if (result == 'start_search') { + setState(() { + _isSearchMode = true; + }); + } + }, ), - onPressed: () { - _clearSelection(); - }, - ), - ], - ) - : GlassAppBar( - isGroup: widget.isGroup, - chatId: chatState.currentChatUserId!, - key: const ValueKey('GlassAppBar'), - name: widget.displayName, - status: widget.isGroup - ? null - : _getPresenceStatusText(chatState), - ), - ), + ), ), body: Stack( children: [ @@ -449,9 +533,7 @@ class _ChatPageState extends State with WidgetsBindingObserver { final String cleanSenderId = msg.senderId .trim() .toLowerCase(); - widget.chatUserId - .trim() - .toLowerCase(); + widget.chatUserId.trim().toLowerCase(); final bool isMe = cleanSenderId == 'me' || @@ -468,9 +550,9 @@ class _ChatPageState extends State with WidgetsBindingObserver { : null; bool showSenderName = widget.isGroup; - if (widget.isGroup && - realIndex < activeChat.length - 1) { - final previousMsg = activeChat[realIndex + 1]; + if (widget.isGroup && realIndex > 0) { + final previousMsg = activeChat[realIndex - 1]; + if (previousMsg.senderId.trim().toLowerCase() == cleanSenderId) { showSenderName = false; @@ -564,118 +646,188 @@ class _ChatPageState extends State with WidgetsBindingObserver { }, ), ), - 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), + + if (_isSearchMode) + Positioned.fill( + top: kToolbarHeight + MediaQuery.of(context).padding.top, + child: Container( + color: theme.scaffoldBackgroundColor, + child: _searchResults.isEmpty + ? Center( + child: Text( + _chatSearchController.text.isEmpty + ? 'Type to search...' + : 'No messages found.', + style: TextStyle( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ) + : ListView.separated( + itemCount: _searchResults.length, + separatorBuilder: (context, index) => + const Divider(height: 1), + itemBuilder: (context, index) { + final msg = _searchResults[index]; + + String sender = "Unknown"; + if (msg.senderId == _authState.currentUser?.id) { + sender = "You"; + } else if (widget.isGroup) { + sender = + context + .read() + .groupMemberNames[msg.senderId] ?? + 'Someone'; + } else { + sender = widget.displayName; + } + + return ListTile( + title: Text( + msg.content, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + '$sender • ${DateFormat.yMd().add_jm().format(msg.createdAt)}', + style: TextStyle( + color: theme.colorScheme.primary, + ), + ), + onTap: () { + // Close search mode + setState(() { + _isSearchMode = false; + _chatSearchController.clear(); + _searchResults.clear(); + }); + + FocusScope.of(context).unfocus(); + + _scrollToAndHighlight(msg.id); + }, + ); + }, + ), + ), + ), + + if (!_isSearchMode) + 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, + if (!_isSearchMode) + 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, ), - 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, + 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, + 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, + IconButton( + icon: Icon( + Icons.close, + size: 20, + color: theme.iconTheme.color, + ), + onPressed: () => setState( + () => _replyingToMessage = null, + ), ), - ), - ], - ), - ) - : const SizedBox(width: double.infinity, height: 0), + ], + ), + ) + : const SizedBox(width: double.infinity, height: 0), + ), ), - ), - ChatInputArea( - onSendMessage: _sendMessage, - onTypingChanged: (isTyping) => context - .read() - .sendTypingNotification(isTyping), - ), - ], + ChatInputArea( + onSendMessage: _sendMessage, + onTypingChanged: (isTyping) => context + .read() + .sendTypingNotification(isTyping), + ), + ], + ), ), - ), ], ), ), diff --git a/mobile/lib/widgets/chat_screen_modular_widgets.dart b/mobile/lib/widgets/chat_screen_modular_widgets.dart index 517a1a2..74af9dc 100644 --- a/mobile/lib/widgets/chat_screen_modular_widgets.dart +++ b/mobile/lib/widgets/chat_screen_modular_widgets.dart @@ -5,7 +5,6 @@ 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:mobile/pages/chat_details_page.dart'; enum UserStatus { offline, online } @@ -13,14 +12,14 @@ class GlassAppBar extends StatelessWidget implements PreferredSizeWidget { final String name; final UserStatus? status; final bool isGroup; - final String chatId; + final GestureTapCallback? onTitleTap; const GlassAppBar({ super.key, required this.name, required this.status, required this.isGroup, - required this.chatId, + this.onTitleTap, }); @override @@ -39,19 +38,7 @@ class GlassAppBar extends StatelessWidget implements PreferredSizeWidget { onPressed: () => Navigator.pop(context), ), title: GestureDetector( - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ChatDetailsPage( - isGroup: isGroup, - chatId: chatId, - chatName: name, - chatImageUrl: "", - ), - ), - ); - }, + onTap: onTitleTap, child: Row( children: [ Hero( @@ -493,191 +480,4 @@ class _ChatInputAreaState extends State { ), ); } -} - -class _AnimatedMessageItem extends StatefulWidget { - final Widget child; - - const _AnimatedMessageItem({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)); - - _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) { - 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, - ), - ], - ), - ); - } -} +} \ No newline at end of file