diff --git a/README.md b/README.md index 244791e..ac12805 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # Flutter Community Chat + Flutter Community Chat is a package which gives the possibility to add a (personal or group) chat to your Flutter-application. Default this package adds support for a Firebase back-end. You can add your custom back-end (like a Websocket-API) by extending the `CommunityChatInterface` interface from the `flutter_community_chat_interface` package. ![Flutter Community Chat GIF](example.gif) @@ -7,11 +8,12 @@ Figma Design that defines this component (only accessible for Iconica developers Figma clickable prototype that demonstrates this component (only accessible for Iconica developers): https://www.figma.com/proto/PRJoVXQ5aOjAICfkQdAq2A/Iconica-User-Stories?page-id=1%3A2&type=design&node-id=56-6837&viewport=279%2C2452%2C0.2&t=E7Al3Xng2WXnbCEQ-1&scaling=scale-down&starting-point-node-id=56%3A6837&mode=design ## Setup + To use this package, add flutter_community_chat as a dependency in your pubspec.yaml file: ``` flutter_community_chat: - git: + git: url: https://github.com/Iconica-Development/flutter_community_chat.git path: packages/flutter_community_chat ``` @@ -20,43 +22,102 @@ If you are going to use Firebase as the back-end of the Community Chat, you shou ``` flutter_community_chat_firebase: - git: + git: url: https://github.com/Iconica-Development/flutter_community_chat.git path: packages/flutter_community_chat_firebase ``` +Create a Firebase project for your application and add firebase firestore and storage. + ## How to use -To use the module within your Flutter-application you should add the following code to the build-method of a chosen widget. + +To use the module within your Flutter-application with predefined `Go_router` routes you should add the following: + +Add go_router as dependency to your project. +Add the following configuration to your flutter_application: ``` -CommunityChat( - dataProvider: FirebaseCommunityChatDataProvider(), -) +List getCommunityChatRoutes() => getCommunityChatStoryRoutes( + CommunityChatUserStoryConfiguration( + service: FirebaseChatService(userService: FirebaseUserService()), + userService: FirebaseUserService(), + messageService: + FirebaseMessageService(userService: FirebaseUserService()), + chatOptionsBuilder: (ctx) => const ChatOptions(), + ), + ); ``` -In this example we provide a `FirebaseCommunityChatDataProvider` as a data provider. You can also specify your own implementation here of the `CommunityChatInterface` interface. - -You can also include your custom configuration for both the Community Chat itself as the Image Picker which is included in this package. You can specify those configurations as a parameter: +Add the `getCommunityChatRoutes()` to your go_router routes like so: ``` -CommunityChat( - dataProvider: FirebaseCommunityChatDataProvider(), - imagePickerTheme: ImagePickerTheme(), - chatOptions: ChatOptions(), -) +final GoRouter _router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (BuildContext context, GoRouterState state) { + return const MyHomePage( + title: "home", + ); + }, + ), + ...getCommunityChatRoutes() + ], +); +``` + +To use the module within your Flutter-application without predefined `Go_router` routes add the following code to the build-method of a chosen widget: + +To add the `ChatScreen` add the following code: + +```` +ChatScreen( + options: options, + onPressStartChat: onPressStartChat, + onPressChat: onPressChat, + onDeleteChat: onDeleteChat, + service: service, + pageSize: pageSize, + ); +``` + +To add the `ChatDetailScreen` add the following code: + +``` +ChatDetailScreen( + options: options, + onMessageSubmit: onMessageSubmit, + onUploadImage: onUploadImage, + onReadChat: onReadChat, + service: service, + chatUserService: chatUserService, + messageService: messageService, + pageSize: pageSize, + ); +``` + +To add the `NewChatScreen` add the following code: + +``` +NewChatScreen( + options: options, + onPressCreateChat: onPressCreateChat, + service: service, + userService: userService, + ); ``` The `ChatOptions` has its own parameters, as specified below: | Parameter | Explanation | |-----------|-------------| -| newChatButtonBuilder | Builds the 'New Chat' button, to initiate a new chat session. This button is displayed on the chat overview. | -| messageInputBuilder | Builds the text input which is displayed within the chat view, responsible for sending text messages. | -| chatRowContainerBuilder | Builds a chat row. A row with the users' avatar, name and eventually the last massage sended in the chat. This builder is used both in the *chat overview screen* as in the *new chat screen*. | -| imagePickerContainerBuilder | Builds the container around the ImagePicker. | +| newChatButtonBuilder | Builds the 'New Chat' button, to initiate a new chat session. This button is displayed on the chat overview. | +| messageInputBuilder | Builds the text input which is displayed within the chat view, responsible for sending text messages. | +| chatRowContainerBuilder | Builds a chat row. A row with the users' avatar, name and eventually the last massage sended in the chat. This builder is used both in the _chat overview screen_ as in the _new chat screen_. | +| imagePickerContainerBuilder | Builds the container around the ImagePicker. | | closeImagePickerButtonBuilder | Builds the close button for the Image Picker pop-up window. | -| scaffoldBuilder | Builds the default Scaffold-widget around the Community Chat. The chat title is displayed within the Scaffolds' title for example. | +| scaffoldBuilder | Builds the default Scaffold-widget around the Community Chat. The chat title is displayed within the Scaffolds' title for example. | -The `ImagePickerTheme` also has its own parameters, how to use these parameters can be found in [the documentation of the flutter_image_picker package](https://github.com/Iconica-Development/flutter_image_picker). +The `ImagePickerTheme` also has its own parameters, how to use these parameters can be found in [the documentation of the flutter_image_picker package](https://github.com/Iconica-Development/flutter_image_picker). ## Issues @@ -69,3 +130,4 @@ If you would like to contribute to the plugin (e.g. by improving the documentati ## Author This `flutter_community_chat` for Flutter is developed by [Iconica](https://iconica.nl). You can contact us at +```` diff --git a/packages/flutter_community_chat/lib/src/flutter_community_chat_userstory.dart b/packages/flutter_community_chat/lib/src/flutter_community_chat_userstory.dart index 9ed3965..9fc0052 100644 --- a/packages/flutter_community_chat/lib/src/flutter_community_chat_userstory.dart +++ b/packages/flutter_community_chat/lib/src/flutter_community_chat_userstory.dart @@ -17,19 +17,26 @@ List getCommunityChatStoryRoutes( path: CommunityChatUserStoryRoutes.chatScreen, pageBuilder: (context, state) { var chatScreen = ChatScreen( + pageSize: configuration.pageSize, service: configuration.service, options: configuration.chatOptionsBuilder(context), - onNoChats: () => - context.push(CommunityChatUserStoryRoutes.newChatScreen), - onPressStartChat: () => - configuration.onPressStartChat?.call() ?? - context.push(CommunityChatUserStoryRoutes.newChatScreen), + onNoChats: () async => + await context.push(CommunityChatUserStoryRoutes.newChatScreen), + onPressStartChat: () async { + if (configuration.onPressStartChat != null) { + return await configuration.onPressStartChat?.call(); + } + + return await context + .push(CommunityChatUserStoryRoutes.newChatScreen); + }, onPressChat: (chat) => configuration.onPressChat?.call(context, chat) ?? context.push( CommunityChatUserStoryRoutes.chatDetailViewPath(chat.id!)), onDeleteChat: (chat) => - configuration.onDeleteChat?.call(context, chat), + configuration.onDeleteChat?.call(context, chat) ?? + configuration.service.deleteChat(chat), deleteChatDialog: configuration.deleteChatDialog, translations: configuration.translations, ); @@ -52,6 +59,7 @@ List getCommunityChatStoryRoutes( var chatId = state.pathParameters['id']; var chat = PersonalChatModel(user: ChatUserModel(), id: chatId); var chatDetailScreen = ChatDetailScreen( + pageSize: configuration.messagePageSize, options: configuration.chatOptionsBuilder(context), translations: configuration.translations, chatUserService: configuration.userService, @@ -110,8 +118,9 @@ List getCommunityChatStoryRoutes( ); } if (context.mounted) { - context.push(CommunityChatUserStoryRoutes.chatDetailViewPath( - chat.id ?? '')); + await context.push( + CommunityChatUserStoryRoutes.chatDetailViewPath( + chat.id ?? '')); } }); return buildScreenWithoutTransition( diff --git a/packages/flutter_community_chat/lib/src/models/community_chat_configuration.dart b/packages/flutter_community_chat/lib/src/models/community_chat_configuration.dart index 8285517..6d8e04d 100644 --- a/packages/flutter_community_chat/lib/src/models/community_chat_configuration.dart +++ b/packages/flutter_community_chat/lib/src/models/community_chat_configuration.dart @@ -14,6 +14,7 @@ class CommunityChatUserStoryConfiguration { required this.messageService, required this.service, required this.chatOptionsBuilder, + this.pageSize = 10, this.onPressStartChat, this.onPressChat, this.onDeleteChat, @@ -29,6 +30,7 @@ class CommunityChatUserStoryConfiguration { this.chatPageBuilder, this.onPressChatTitle, this.afterMessageSent, + this.messagePageSize = 20, }); final ChatService service; final ChatUserService userService; @@ -48,6 +50,8 @@ class CommunityChatUserStoryConfiguration { /// If true, the user will be routed to the new chat screen if there are no chats. final bool routeToNewChatIfEmpty; + final int pageSize; + final int messagePageSize; final Future Function(BuildContext, ChatModel)? deleteChatDialog; final Function(BuildContext context, ChatModel chat)? onPressChatTitle; diff --git a/packages/flutter_community_chat/pubspec.yaml b/packages/flutter_community_chat/pubspec.yaml index 30ebdc6..ca93837 100644 --- a/packages/flutter_community_chat/pubspec.yaml +++ b/packages/flutter_community_chat/pubspec.yaml @@ -4,7 +4,7 @@ name: flutter_community_chat description: A new Flutter package project. -version: 0.6.0 +version: 1.0.0 publish_to: none @@ -15,17 +15,17 @@ environment: dependencies: flutter: sdk: flutter - go_router: ^12.1.1 + go_router: any flutter_community_chat_view: git: url: https://github.com/Iconica-Development/flutter_community_chat path: packages/flutter_community_chat_view - ref: 0.6.0 + ref: 1.0.0 flutter_community_chat_interface: git: url: https://github.com/Iconica-Development/flutter_community_chat path: packages/flutter_community_chat_interface - ref: 0.6.0 + ref: 1.0.0 dev_dependencies: flutter_lints: ^2.0.0 diff --git a/packages/flutter_community_chat_firebase/lib/config/firebase_chat_options.dart b/packages/flutter_community_chat_firebase/lib/config/firebase_chat_options.dart index 894e21a..73c6319 100644 --- a/packages/flutter_community_chat_firebase/lib/config/firebase_chat_options.dart +++ b/packages/flutter_community_chat_firebase/lib/config/firebase_chat_options.dart @@ -11,18 +11,26 @@ class FirebaseChatOptions { this.chatsCollectionName = 'chats', this.messagesCollectionName = 'messages', this.usersCollectionName = 'users', + this.chatsMetaDataCollectionName = 'chat_metadata', + this.userChatsCollectionName = 'chats', }); final String groupChatsCollectionName; final String chatsCollectionName; final String messagesCollectionName; final String usersCollectionName; + final String chatsMetaDataCollectionName; + + ///This is the collection inside the user document. + final String userChatsCollectionName; FirebaseChatOptions copyWith({ String? groupChatsCollectionName, String? chatsCollectionName, String? messagesCollectionName, String? usersCollectionName, + String? chatsMetaDataCollectionName, + String? userChatsCollectionName, }) { return FirebaseChatOptions( groupChatsCollectionName: @@ -31,6 +39,10 @@ class FirebaseChatOptions { messagesCollectionName: messagesCollectionName ?? this.messagesCollectionName, usersCollectionName: usersCollectionName ?? this.usersCollectionName, + chatsMetaDataCollectionName: + chatsMetaDataCollectionName ?? this.chatsMetaDataCollectionName, + userChatsCollectionName: + userChatsCollectionName ?? this.userChatsCollectionName, ); } } diff --git a/packages/flutter_community_chat_firebase/lib/service/firebase_chat_service.dart b/packages/flutter_community_chat_firebase/lib/service/firebase_chat_service.dart index 38a9c15..cd6178e 100644 --- a/packages/flutter_community_chat_firebase/lib/service/firebase_chat_service.dart +++ b/packages/flutter_community_chat_firebase/lib/service/firebase_chat_service.dart @@ -15,6 +15,10 @@ class FirebaseChatService implements ChatService { late FirebaseStorage _storage; late ChatUserService _userService; late FirebaseChatOptions _options; + DocumentSnapshot? lastUserDocument; + String? lastGroupId; + List chatIds = []; + int pageNumber = 1; FirebaseChatService({ required ChatUserService userService, @@ -37,7 +41,7 @@ class FirebaseChatService implements ChatService { var snapshots = _db .collection(_options.usersCollectionName) .doc(userId) - .collection('chats') + .collection(_options.userChatsCollectionName) .doc(chatId) .snapshots(); @@ -52,7 +56,7 @@ class FirebaseChatService implements ChatService { Function(List) onReceivedChats, ) { var snapshots = _db - .collection(_options.chatsCollectionName) + .collection(_options.chatsMetaDataCollectionName) .where( FieldPath.documentId, whereIn: chatIds, @@ -228,19 +232,29 @@ class FirebaseChatService implements ChatService { } @override - Stream> getChatsStream() { + Stream> getChatsStream(int pageSize) { late StreamController> controller; StreamSubscription? chatsSubscription; controller = StreamController( onListen: () async { + QuerySnapshot> userSnapshot; + List userChatIds; var currentUser = await _userService.getCurrentUser(); - var userSnapshot = await _db + var userQuery = _db .collection(_options.usersCollectionName) .doc(currentUser?.id) - .collection('chats') - .get(); - var userChatIds = userSnapshot.docs.map((chat) => chat.id).toList(); + .collection(_options.userChatsCollectionName); + if (lastUserDocument == null) { + userSnapshot = await userQuery.limit(pageSize).get(); + userChatIds = userSnapshot.docs.map((chat) => chat.id).toList(); + } else { + userSnapshot = await userQuery + .limit(pageSize) + .startAfterDocument(lastUserDocument!) + .get(); + userChatIds = userSnapshot.docs.map((chat) => chat.id).toList(); + } var userGroupChatIds = await _db .collection(_options.usersCollectionName) @@ -248,10 +262,30 @@ class FirebaseChatService implements ChatService { .get() .then((userCollection) => userCollection.data()?[_options.groupChatsCollectionName]) - .then((groupChatLabels) => groupChatLabels?.cast()); + .then((groupChatLabels) => groupChatLabels?.cast()) + .then((groupChatIds) { + var startIndex = (pageNumber - 1) * pageSize; + var endIndex = startIndex + pageSize; - var chatsStream = - _getSpecificChatsStream([...userChatIds, ...userGroupChatIds]); + if (groupChatIds != null) { + if (startIndex >= groupChatIds.length) { + return []; + } + var groupIds = groupChatIds.sublist( + startIndex, endIndex.clamp(0, groupChatIds.length)); + lastGroupId = groupIds.last; + return groupIds; + } + return []; + }); + + if (userSnapshot.docs.isNotEmpty) { + lastUserDocument = userSnapshot.docs.last; + } + + pageNumber++; + chatIds.addAll([...userChatIds, ...userGroupChatIds]); + var chatsStream = _getSpecificChatsStream(chatIds); chatsSubscription = chatsStream.listen((event) { controller.add(event); @@ -270,7 +304,7 @@ class FirebaseChatService implements ChatService { var collection = await _db .collection(_options.usersCollectionName) .doc(currentUser?.id) - .collection('chats') + .collection(_options.userChatsCollectionName) .where('users', arrayContains: user.id) .get(); @@ -288,7 +322,7 @@ class FirebaseChatService implements ChatService { var chatCollection = await _db .collection(_options.usersCollectionName) .doc(currentUser?.id) - .collection('chats') + .collection(_options.userChatsCollectionName) .doc(chatId) .get(); @@ -333,7 +367,7 @@ class FirebaseChatService implements ChatService { @override Future deleteChat(ChatModel chat) async { var chatCollection = await _db - .collection(_options.chatsCollectionName) + .collection(_options.chatsMetaDataCollectionName) .doc(chat.id) .withConverter( fromFirestore: (snapshot, _) => @@ -349,7 +383,7 @@ class FirebaseChatService implements ChatService { _db .collection(_options.usersCollectionName) .doc(userId) - .collection('chats') + .collection(_options.userChatsCollectionName) .doc(chat.id) .delete(); } @@ -387,7 +421,7 @@ class FirebaseChatService implements ChatService { ]; var reference = await _db - .collection(_options.chatsCollectionName) + .collection(_options.chatsMetaDataCollectionName) .withConverter( fromFirestore: (snapshot, _) => FirebaseChatDocument.fromJson(snapshot.data()!, snapshot.id), @@ -406,12 +440,13 @@ class FirebaseChatService implements ChatService { await _db .collection(_options.usersCollectionName) .doc(userId) - .collection('chats') + .collection(_options.userChatsCollectionName) .doc(reference.id) .set({'users': userIds}); } chat.id = reference.id; + chatIds.add(chat.id!); } else if (chat is GroupChatModel) { if (currentUser?.id == null) { return chat; @@ -448,6 +483,7 @@ class FirebaseChatService implements ChatService { } chat.id = reference.id; + chatIds.add(chat.id!); } else { throw Exception('Chat type not supported for firebase'); } @@ -467,7 +503,7 @@ class FirebaseChatService implements ChatService { var userSnapshot = _db .collection(_options.usersCollectionName) .doc(currentUser?.id) - .collection('chats') + .collection(_options.userChatsCollectionName) .snapshots(); unreadChatSubscription = userSnapshot.listen((event) { @@ -498,7 +534,7 @@ class FirebaseChatService implements ChatService { await _db .collection(_options.usersCollectionName) .doc(currentUser!.id!) - .collection('chats') + .collection(_options.userChatsCollectionName) .doc(chat.id) .set({'amount_unread_messages': 0}, SetOptions(merge: true)); } diff --git a/packages/flutter_community_chat_firebase/lib/service/firebase_message_service.dart b/packages/flutter_community_chat_firebase/lib/service/firebase_message_service.dart index 6ca4e18..6ad72c8 100644 --- a/packages/flutter_community_chat_firebase/lib/service/firebase_message_service.dart +++ b/packages/flutter_community_chat_firebase/lib/service/firebase_message_service.dart @@ -13,7 +13,7 @@ import 'package:flutter_community_chat_firebase/dto/firebase_message_document.da import 'package:flutter_community_chat_interface/flutter_community_chat_interface.dart'; import 'package:uuid/uuid.dart'; -class FirebaseMessageService implements MessageService { +class FirebaseMessageService with ChangeNotifier implements MessageService { late final FirebaseFirestore _db; late final FirebaseStorage _storage; late final ChatUserService _userService; @@ -21,6 +21,11 @@ class FirebaseMessageService implements MessageService { StreamController>? _controller; StreamSubscription? _subscription; + DocumentSnapshot? lastMessage; + List _cumulativeMessages = []; + ChatModel? lastChat; + int? chatPageSize; + DateTime timestampToFilter = DateTime.now(); FirebaseMessageService({ required ChatUserService userService, @@ -54,13 +59,28 @@ class FirebaseMessageService implements MessageService { ) .doc(chat.id); - await chatReference + var newMessage = await chatReference .collection( _options.messagesCollectionName, ) .add(message); - await chatReference.update({ + if (_cumulativeMessages.length == 1) { + lastMessage = await chatReference + .collection( + _options.messagesCollectionName, + ) + .doc(newMessage.id) + .get(); + } + + var metadataReference = _db + .collection( + _options.chatsMetaDataCollectionName, + ) + .doc(chat.id); + + await metadataReference.update({ 'last_used': DateTime.now(), 'last_message': message, }); @@ -76,7 +96,7 @@ class FirebaseMessageService implements MessageService { // update the chat counter for the other users // get all users from the chat // there is a field in the chat document called users that has a list of user ids - var fetchedChat = await chatReference.get(); + var fetchedChat = await metadataReference.get(); var chatUsers = fetchedChat.data()?['users'] as List; // for all users except the message sender update the unread counter for (var userId in chatUsers) { @@ -86,7 +106,7 @@ class FirebaseMessageService implements MessageService { _options.usersCollectionName, ) .doc(userId) - .collection('chats') + .collection(_options.userChatsCollectionName) .doc(chat.id); // what if the amount_unread_messages field does not exist? // it should be created when the chat is create @@ -110,13 +130,14 @@ class FirebaseMessageService implements MessageService { Future sendTextMessage({ required String text, required ChatModel chat, - }) => - _sendMessage( - chat, - { - 'text': text, - }, - ); + }) { + return _sendMessage( + chat, + { + 'text': text, + }, + ); + } @override Future sendImageMessage({ @@ -144,24 +165,122 @@ class FirebaseMessageService implements MessageService { ); } - Query _getMessagesQuery(ChatModel chat) => _db - .collection(_options.chatsCollectionName) - .doc(chat.id) - .collection(_options.messagesCollectionName) - .orderBy('timestamp', descending: false) - .withConverter( + Query _getMessagesQuery(ChatModel chat) { + if (lastChat == null) { + lastChat = chat; + } else if (lastChat?.id != chat.id) { + _cumulativeMessages = []; + lastChat = chat; + lastMessage = null; + } + + var query = _db + .collection(_options.chatsCollectionName) + .doc(chat.id) + .collection(_options.messagesCollectionName) + .orderBy('timestamp', descending: true) + .limit(chatPageSize!); + + if (lastMessage == null) { + return query.withConverter( fromFirestore: (snapshot, _) => FirebaseMessageDocument.fromJson(snapshot.data()!, snapshot.id), toFirestore: (user, _) => user.toJson(), ); + } + return query + .startAfterDocument(lastMessage!) + .withConverter( + fromFirestore: (snapshot, _) => + FirebaseMessageDocument.fromJson(snapshot.data()!, snapshot.id), + toFirestore: (user, _) => user.toJson(), + ); + } @override Stream> getMessagesStream(ChatModel chat) { _controller = StreamController>( onListen: () { - if (chat.id != null) { - _subscription = _startListeningForMessages(chat); - } + var messagesCollection = _db + .collection(_options.chatsCollectionName) + .doc(chat.id) + .collection(_options.messagesCollectionName) + .withConverter( + fromFirestore: (snapshot, _) => FirebaseMessageDocument.fromJson( + snapshot.data()!, snapshot.id), + toFirestore: (user, _) => user.toJson(), + ); + var query = messagesCollection + .where( + 'timestamp', + isGreaterThan: timestampToFilter, + ) + .withConverter( + fromFirestore: (snapshot, _) => FirebaseMessageDocument.fromJson( + snapshot.data()!, snapshot.id), + toFirestore: (user, _) => user.toJson(), + ); + + var stream = query.snapshots(); + // Subscribe to the stream and process the updates + _subscription = stream.listen((snapshot) async { + var messages = []; + + for (var messageDoc in snapshot.docs) { + var messageData = messageDoc.data(); + var timestamp = DateTime.fromMillisecondsSinceEpoch( + (messageData.timestamp).millisecondsSinceEpoch, + ); + + // Check if the message is already in the list to avoid duplicates + if (timestampToFilter.isBefore(timestamp)) { + if (!messages.any((message) { + var timestamp = DateTime.fromMillisecondsSinceEpoch( + (messageData.timestamp).millisecondsSinceEpoch, + ); + return timestamp == message.timestamp; + })) { + var sender = await _userService.getUser(messageData.sender); + + if (sender != null) { + var timestamp = DateTime.fromMillisecondsSinceEpoch( + (messageData.timestamp).millisecondsSinceEpoch, + ); + + messages.add( + messageData.imageUrl != null + ? ChatImageMessageModel( + sender: sender, + imageUrl: messageData.imageUrl!, + timestamp: timestamp, + ) + : ChatTextMessageModel( + sender: sender, + text: messageData.text!, + timestamp: timestamp, + ), + ); + } + } + } + } + + // Add the filtered messages to the controller + _controller?.add(messages); + _cumulativeMessages = [ + ..._cumulativeMessages, + ...messages, + ]; + + // remove all double elements + List uniqueObjects = + _cumulativeMessages.toSet().toList(); + _cumulativeMessages = uniqueObjects; + _cumulativeMessages + .sort((a, b) => a.timestamp.compareTo(b.timestamp)); + notifyListeners(); + timestampToFilter = DateTime.now(); + }); }, onCancel: () { _subscription?.cancel(); @@ -169,47 +288,145 @@ class FirebaseMessageService implements MessageService { debugPrint('Canceling messages stream'); }, ); - return _controller!.stream; } StreamSubscription _startListeningForMessages(ChatModel chat) { debugPrint('Start listening for messages in chat ${chat.id}'); - var snapshots = _getMessagesQuery(chat).snapshots(); - return snapshots.listen( (snapshot) async { - var messages = []; + List messages = + List.from(_cumulativeMessages); - for (var messageDoc in snapshot.docs) { - var messageData = messageDoc.data(); + if (snapshot.docs.isNotEmpty) { + lastMessage = snapshot.docs.last; - var sender = await _userService.getUser(messageData.sender); + for (var messageDoc in snapshot.docs) { + var messageData = messageDoc.data(); - if (sender != null) { - var timestamp = DateTime.fromMillisecondsSinceEpoch( - (messageData.timestamp).millisecondsSinceEpoch, - ); + // Check if the message is already in the list to avoid duplicates + if (!messages.any((message) { + var timestamp = DateTime.fromMillisecondsSinceEpoch( + (messageData.timestamp).millisecondsSinceEpoch, + ); + return timestamp == message.timestamp; + })) { + var sender = await _userService.getUser(messageData.sender); - messages.add( - messageData.imageUrl != null - ? ChatImageMessageModel( - sender: sender, - imageUrl: messageData.imageUrl!, - timestamp: timestamp, - ) - : ChatTextMessageModel( - sender: sender, - text: messageData.text!, - timestamp: timestamp, - ), - ); + if (sender != null) { + var timestamp = DateTime.fromMillisecondsSinceEpoch( + (messageData.timestamp).millisecondsSinceEpoch, + ); + + messages.add( + messageData.imageUrl != null + ? ChatImageMessageModel( + sender: sender, + imageUrl: messageData.imageUrl!, + timestamp: timestamp, + ) + : ChatTextMessageModel( + sender: sender, + text: messageData.text!, + timestamp: timestamp, + ), + ); + } + } } } + _cumulativeMessages = messages; + + messages.sort((a, b) => a.timestamp.compareTo(b.timestamp)); + _controller?.add(messages); + notifyListeners(); }, ); } + + @override + Future fetchMoreMessage(int pageSize, ChatModel chat) async { + if (lastChat == null) { + lastChat = chat; + } else if (lastChat?.id != chat.id) { + _cumulativeMessages = []; + lastChat = chat; + lastMessage = null; + } + // get the x amount of last messages from the oldest message that is in cumulative messages and add that to the list + List messages = []; + QuerySnapshot? messagesQuerySnapshot; + var query = _db + .collection(_options.chatsCollectionName) + .doc(chat.id) + .collection(_options.messagesCollectionName) + .orderBy('timestamp', descending: true) + .limit(pageSize); + if (lastMessage == null) { + messagesQuerySnapshot = await query + .withConverter( + fromFirestore: (snapshot, _) => + FirebaseMessageDocument.fromJson(snapshot.data()!, snapshot.id), + toFirestore: (user, _) => user.toJson(), + ) + .get(); + if (messagesQuerySnapshot.docs.isNotEmpty) { + lastMessage = messagesQuerySnapshot.docs.last; + } + } else { + messagesQuerySnapshot = await query + .startAfterDocument(lastMessage!) + .withConverter( + fromFirestore: (snapshot, _) => + FirebaseMessageDocument.fromJson(snapshot.data()!, snapshot.id), + toFirestore: (user, _) => user.toJson(), + ) + .get(); + if (messagesQuerySnapshot.docs.isNotEmpty) { + lastMessage = messagesQuerySnapshot.docs.last; + } + } + + List messageDocuments = messagesQuerySnapshot.docs + .map((QueryDocumentSnapshot doc) => doc.data()) + .toList(); + + for (var message in messageDocuments) { + var sender = await _userService.getUser(message.sender); + if (sender != null) { + var timestamp = DateTime.fromMillisecondsSinceEpoch( + (message.timestamp).millisecondsSinceEpoch, + ); + + messages.add( + message.imageUrl != null + ? ChatImageMessageModel( + sender: sender, + imageUrl: message.imageUrl!, + timestamp: timestamp, + ) + : ChatTextMessageModel( + sender: sender, + text: message.text!, + timestamp: timestamp, + ), + ); + } + } + + _cumulativeMessages = [ + ...messages, + ..._cumulativeMessages, + ]; + _cumulativeMessages.sort((a, b) => a.timestamp.compareTo(b.timestamp)); + notifyListeners(); + } + + @override + List getMessages() { + return _cumulativeMessages; + } } diff --git a/packages/flutter_community_chat_firebase/pubspec.yaml b/packages/flutter_community_chat_firebase/pubspec.yaml index c30d386..c0d976c 100644 --- a/packages/flutter_community_chat_firebase/pubspec.yaml +++ b/packages/flutter_community_chat_firebase/pubspec.yaml @@ -4,7 +4,7 @@ name: flutter_community_chat_firebase description: A new Flutter package project. -version: 0.6.0 +version: 1.0.0 publish_to: none environment: @@ -23,7 +23,7 @@ dependencies: git: url: https://github.com/Iconica-Development/flutter_community_chat path: packages/flutter_community_chat_interface - ref: 0.6.0 + ref: 1.0.0 dev_dependencies: flutter_lints: ^2.0.0 diff --git a/packages/flutter_community_chat_interface/lib/src/service/chat_service.dart b/packages/flutter_community_chat_interface/lib/src/service/chat_service.dart index be0248f..e0e668b 100644 --- a/packages/flutter_community_chat_interface/lib/src/service/chat_service.dart +++ b/packages/flutter_community_chat_interface/lib/src/service/chat_service.dart @@ -1,7 +1,7 @@ import 'package:flutter_community_chat_interface/flutter_community_chat_interface.dart'; abstract class ChatService { - Stream> getChatsStream(); + Stream> getChatsStream(int pageSize); Future getChatByUser(ChatUserModel user); Future getChatById(String id); Future deleteChat(ChatModel chat); diff --git a/packages/flutter_community_chat_interface/lib/src/service/message_service.dart b/packages/flutter_community_chat_interface/lib/src/service/message_service.dart index ca84fbd..d317b17 100644 --- a/packages/flutter_community_chat_interface/lib/src/service/message_service.dart +++ b/packages/flutter_community_chat_interface/lib/src/service/message_service.dart @@ -1,7 +1,8 @@ import 'dart:typed_data'; +import 'package:flutter/material.dart'; import 'package:flutter_community_chat_interface/flutter_community_chat_interface.dart'; -abstract class MessageService { +abstract class MessageService with ChangeNotifier { Future sendTextMessage({ required ChatModel chat, required String text, @@ -15,4 +16,8 @@ abstract class MessageService { Stream> getMessagesStream( ChatModel chat, ); + + Future fetchMoreMessage(int pageSize, ChatModel chat); + + List getMessages(); } diff --git a/packages/flutter_community_chat_interface/pubspec.yaml b/packages/flutter_community_chat_interface/pubspec.yaml index 039db8d..73753b7 100644 --- a/packages/flutter_community_chat_interface/pubspec.yaml +++ b/packages/flutter_community_chat_interface/pubspec.yaml @@ -4,7 +4,7 @@ name: flutter_community_chat_interface description: A new Flutter package project. -version: 0.6.0 +version: 1.0.0 publish_to: none environment: diff --git a/packages/flutter_community_chat_view/ios/Flutter/Generated.xcconfig b/packages/flutter_community_chat_view/ios/Flutter/Generated.xcconfig new file mode 100644 index 0000000..1167b5a --- /dev/null +++ b/packages/flutter_community_chat_view/ios/Flutter/Generated.xcconfig @@ -0,0 +1,14 @@ +// This is a generated file; do not edit or check into version control. +FLUTTER_ROOT=/opt/homebrew/Caskroom/flutter/3.10.2/flutter +FLUTTER_APPLICATION_PATH=/Users/mikedoornenbal/Documents/iconica/flutter_community_chat/packages/flutter_community_chat_view +COCOAPODS_PARALLEL_CODE_SIGN=true +FLUTTER_TARGET=lib/main.dart +FLUTTER_BUILD_DIR=build +FLUTTER_BUILD_NAME=0.6.0 +FLUTTER_BUILD_NUMBER=0.6.0 +EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386 +EXCLUDED_ARCHS[sdk=iphoneos*]=armv7 +DART_OBFUSCATION=false +TRACK_WIDGET_CREATION=true +TREE_SHAKE_ICONS=false +PACKAGE_CONFIG=.dart_tool/package_config.json diff --git a/packages/flutter_community_chat_view/ios/Flutter/flutter_export_environment.sh b/packages/flutter_community_chat_view/ios/Flutter/flutter_export_environment.sh new file mode 100755 index 0000000..8559eeb --- /dev/null +++ b/packages/flutter_community_chat_view/ios/Flutter/flutter_export_environment.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# This is a generated file; do not edit or check into version control. +export "FLUTTER_ROOT=/opt/homebrew/Caskroom/flutter/3.10.2/flutter" +export "FLUTTER_APPLICATION_PATH=/Users/mikedoornenbal/Documents/iconica/flutter_community_chat/packages/flutter_community_chat_view" +export "COCOAPODS_PARALLEL_CODE_SIGN=true" +export "FLUTTER_TARGET=lib/main.dart" +export "FLUTTER_BUILD_DIR=build" +export "FLUTTER_BUILD_NAME=0.6.0" +export "FLUTTER_BUILD_NUMBER=0.6.0" +export "DART_OBFUSCATION=false" +export "TRACK_WIDGET_CREATION=true" +export "TREE_SHAKE_ICONS=false" +export "PACKAGE_CONFIG=.dart_tool/package_config.json" diff --git a/packages/flutter_community_chat_view/ios/Podfile b/packages/flutter_community_chat_view/ios/Podfile new file mode 100644 index 0000000..ec43b51 --- /dev/null +++ b/packages/flutter_community_chat_view/ios/Podfile @@ -0,0 +1,41 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '11.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/packages/flutter_community_chat_view/ios/Runner/GeneratedPluginRegistrant.h b/packages/flutter_community_chat_view/ios/Runner/GeneratedPluginRegistrant.h new file mode 100644 index 0000000..7a89092 --- /dev/null +++ b/packages/flutter_community_chat_view/ios/Runner/GeneratedPluginRegistrant.h @@ -0,0 +1,19 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GeneratedPluginRegistrant_h +#define GeneratedPluginRegistrant_h + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface GeneratedPluginRegistrant : NSObject ++ (void)registerWithRegistry:(NSObject*)registry; +@end + +NS_ASSUME_NONNULL_END +#endif /* GeneratedPluginRegistrant_h */ diff --git a/packages/flutter_community_chat_view/ios/Runner/GeneratedPluginRegistrant.m b/packages/flutter_community_chat_view/ios/Runner/GeneratedPluginRegistrant.m new file mode 100644 index 0000000..76146f8 --- /dev/null +++ b/packages/flutter_community_chat_view/ios/Runner/GeneratedPluginRegistrant.m @@ -0,0 +1,63 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#import "GeneratedPluginRegistrant.h" + +#if __has_include() +#import +#else +@import cloud_firestore; +#endif + +#if __has_include() +#import +#else +@import firebase_auth; +#endif + +#if __has_include() +#import +#else +@import firebase_core; +#endif + +#if __has_include() +#import +#else +@import firebase_storage; +#endif + +#if __has_include() +#import +#else +@import image_picker_ios; +#endif + +#if __has_include() +#import +#else +@import path_provider_foundation; +#endif + +#if __has_include() +#import +#else +@import sqflite; +#endif + +@implementation GeneratedPluginRegistrant + ++ (void)registerWithRegistry:(NSObject*)registry { + [FLTFirebaseFirestorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseFirestorePlugin"]]; + [FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]]; + [FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]]; + [FLTFirebaseStoragePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseStoragePlugin"]]; + [FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]]; + [PathProviderPlugin registerWithRegistrar:[registry registrarForPlugin:@"PathProviderPlugin"]]; + [SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]]; +} + +@end diff --git a/packages/flutter_community_chat_view/lib/src/components/chat_detail_row.dart b/packages/flutter_community_chat_view/lib/src/components/chat_detail_row.dart index e4d2619..217b037 100644 --- a/packages/flutter_community_chat_view/lib/src/components/chat_detail_row.dart +++ b/packages/flutter_community_chat_view/lib/src/components/chat_detail_row.dart @@ -34,22 +34,18 @@ class _ChatDetailRowState extends State { @override Widget build(BuildContext context) { var isNewDate = widget.previousMessage != null && - widget.message.timestamp.day != widget.previousMessage!.timestamp.day; + widget.message.timestamp.day != widget.previousMessage?.timestamp.day; + var isSameSender = widget.previousMessage == null || + widget.previousMessage?.sender.id != widget.message.sender.id; + return Padding( padding: EdgeInsets.only( - top: isNewDate || - widget.previousMessage == null || - widget.previousMessage?.sender.id != widget.message.sender.id - ? 25.0 - : 0, + top: isNewDate || isSameSender ? 25.0 : 0, ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (isNewDate || - widget.previousMessage == null || - widget.previousMessage?.sender.id != - widget.message.sender.id) ...[ + if (isNewDate || isSameSender) ...[ Padding( padding: const EdgeInsets.only(left: 10.0), child: widget.message.sender.imageUrl != null && @@ -75,19 +71,20 @@ class _ChatDetailRowState extends State { crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start, children: [ - if (isNewDate || - widget.previousMessage == null || - widget.previousMessage?.sender.id != - widget.message.sender.id) + if (isNewDate || isSameSender) Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( widget.message.sender.fullName?.toUpperCase() ?? widget.translations.anonymousUser, - style: const TextStyle( + style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, + color: Theme.of(context) + .textTheme + .labelMedium + ?.color, ), ), Padding( @@ -112,7 +109,13 @@ class _ChatDetailRowState extends State { text: TextSpan( text: (widget.message as ChatTextMessageModel) .text, - style: const TextStyle(fontSize: 16), + style: TextStyle( + fontSize: 16, + color: Theme.of(context) + .textTheme + .labelMedium + ?.color, + ), children: [ if (widget.showTime) TextSpan( diff --git a/packages/flutter_community_chat_view/lib/src/components/chat_image.dart b/packages/flutter_community_chat_view/lib/src/components/chat_image.dart index 83e3bb0..f10fe08 100644 --- a/packages/flutter_community_chat_view/lib/src/components/chat_image.dart +++ b/packages/flutter_community_chat_view/lib/src/components/chat_image.dart @@ -24,9 +24,11 @@ class ChatImage extends StatelessWidget { ), width: size, height: size, - child: CachedNetworkImage( - imageUrl: image, - fit: BoxFit.cover, - ), + child: image != '' + ? CachedNetworkImage( + imageUrl: image, + fit: BoxFit.cover, + ) + : null, ); } diff --git a/packages/flutter_community_chat_view/lib/src/config/chat_options.dart b/packages/flutter_community_chat_view/lib/src/config/chat_options.dart index a71155b..ad72a7d 100644 --- a/packages/flutter_community_chat_view/lib/src/config/chat_options.dart +++ b/packages/flutter_community_chat_view/lib/src/config/chat_options.dart @@ -75,7 +75,7 @@ Widget _createImagePickerContainer( ) => Container( padding: const EdgeInsets.all(8.0), - color: Colors.black, + color: Colors.white, child: ImagePicker( customButton: ElevatedButton( onPressed: onClose, diff --git a/packages/flutter_community_chat_view/lib/src/screens/chat_detail_screen.dart b/packages/flutter_community_chat_view/lib/src/screens/chat_detail_screen.dart index 1f7b324..26f6ae5 100644 --- a/packages/flutter_community_chat_view/lib/src/screens/chat_detail_screen.dart +++ b/packages/flutter_community_chat_view/lib/src/screens/chat_detail_screen.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_community_chat_view/flutter_community_chat_view.dart'; import 'package:flutter_community_chat_view/src/components/chat_bottom.dart'; import 'package:flutter_community_chat_view/src/components/chat_detail_row.dart'; @@ -20,6 +21,7 @@ class ChatDetailScreen extends StatefulWidget { required this.service, required this.chatUserService, required this.messageService, + required this.pageSize, this.translations = const ChatTranslations(), this.chat, this.onPressChatTitle, @@ -47,6 +49,7 @@ class ChatDetailScreen extends StatefulWidget { final ChatService service; final ChatUserService chatUserService; final MessageService messageService; + final int pageSize; @override State createState() => _ChatDetailScreenState(); @@ -54,32 +57,29 @@ class ChatDetailScreen extends StatefulWidget { class _ChatDetailScreenState extends State { // stream listener that needs to be disposed later - StreamSubscription>? _chatMessagesSubscription; - Stream>? _chatMessages; - ChatModel? chat; ChatUserModel? currentUser; + ScrollController controller = ScrollController(); + bool showIndicator = false; + late MessageService messageSubscription; + Stream>? stream; + ChatMessageModel? previousMessage; + List detailRows = []; @override void initState() { super.initState(); - // create a broadcast stream from the chat messages + messageSubscription = widget.messageService; + messageSubscription.addListener(onListen); if (widget.chat != null) { - _chatMessages = widget.messageService - .getMessagesStream(widget.chat!) - .asBroadcastStream(); - } - _chatMessagesSubscription = _chatMessages?.listen((event) { - // check if the last message is from the current user - // if so, set the chat to read + stream = widget.messageService.getMessagesStream(widget.chat!); + stream?.listen((event) {}); Future.delayed(Duration.zero, () async { - currentUser = await widget.chatUserService.getCurrentUser(); + if (detailRows.isEmpty) { + await widget.messageService + .fetchMoreMessage(widget.pageSize, widget.chat!); + } }); - if (event.isNotEmpty && - event.last.sender.id != currentUser?.id && - widget.chat != null) { - widget.onReadChat(widget.chat!); - } - }); + } WidgetsBinding.instance.addPostFrameCallback((_) { if (widget.chat != null) { widget.onReadChat(widget.chat!); @@ -87,9 +87,34 @@ class _ChatDetailScreenState extends State { }); } + void onListen() { + var chatMessages = []; + chatMessages = widget.messageService.getMessages(); + detailRows = []; + previousMessage = null; + for (var message in chatMessages) { + detailRows.add( + ChatDetailRow( + showTime: true, + message: message, + translations: widget.translations, + userAvatarBuilder: widget.options.userAvatarBuilder, + previousMessage: previousMessage, + ), + ); + previousMessage = message; + } + detailRows = detailRows.reversed.toList(); + + widget.onReadChat(widget.chat!); + if (mounted) { + setState(() {}); + } + } + @override void dispose() { - _chatMessagesSubscription?.cancel(); + messageSubscription.removeListener(onListen); super.dispose(); } @@ -121,7 +146,6 @@ class _ChatDetailScreenState extends State { future: widget.service.getChatById(widget.chat?.id ?? ''), builder: (context, AsyncSnapshot snapshot) { var chatModel = snapshot.data; - return Scaffold( appBar: AppBar( centerTitle: true, @@ -166,33 +190,48 @@ class _ChatDetailScreenState extends State { body: Column( children: [ Expanded( - child: StreamBuilder>( - stream: _chatMessages, - builder: (context, snapshot) { - var messages = snapshot.data ?? chatModel?.messages ?? []; - ChatMessageModel? previousMessage; + child: Listener( + onPointerMove: (event) async { + var isTop = controller.position.pixels == + controller.position.maxScrollExtent; - var messageWidgets = []; - - for (var message in messages) { - messageWidgets.add( - ChatDetailRow( - previousMessage: previousMessage, - showTime: widget.showTime, - translations: widget.translations, - message: message, - userAvatarBuilder: widget.options.userAvatarBuilder, - ), - ); - previousMessage = message; + if (showIndicator == false && + !isTop && + controller.position.userScrollDirection == + ScrollDirection.reverse) { + setState(() { + showIndicator = true; + }); + await widget.messageService + .fetchMoreMessage(widget.pageSize, widget.chat!); + Future.delayed(const Duration(seconds: 2), () { + if (mounted) { + setState(() { + showIndicator = false; + }); + } + }); } - - return ListView( - reverse: true, - padding: const EdgeInsets.only(top: 24.0), - children: messageWidgets.reversed.toList(), - ); }, + child: ListView( + shrinkWrap: true, + physics: const AlwaysScrollableScrollPhysics(), + controller: controller, + reverse: true, + padding: const EdgeInsets.only(top: 24.0), + children: [ + ...detailRows, + if (showIndicator) ...[ + const SizedBox( + height: 10, + ), + const Center(child: CircularProgressIndicator()), + const SizedBox( + height: 10, + ), + ], + ], + ), ), ), if (chatModel != null) diff --git a/packages/flutter_community_chat_view/lib/src/screens/chat_screen.dart b/packages/flutter_community_chat_view/lib/src/screens/chat_screen.dart index d5542a4..524c1b2 100644 --- a/packages/flutter_community_chat_view/lib/src/screens/chat_screen.dart +++ b/packages/flutter_community_chat_view/lib/src/screens/chat_screen.dart @@ -4,7 +4,10 @@ // ignore_for_file: lines_longer_than_80_chars +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_community_chat_view/flutter_community_chat_view.dart'; import 'package:flutter_community_chat_view/src/services/date_formatter.dart'; @@ -15,6 +18,7 @@ class ChatScreen extends StatefulWidget { required this.onPressChat, required this.onDeleteChat, required this.service, + required this.pageSize, this.onNoChats, this.deleteChatDialog, this.translations = const ChatTranslations(), @@ -25,10 +29,11 @@ class ChatScreen extends StatefulWidget { final ChatOptions options; final ChatTranslations translations; final ChatService service; - final VoidCallback? onPressStartChat; - final VoidCallback? onNoChats; + final Function()? onPressStartChat; + final Function()? onNoChats; final void Function(ChatModel chat) onDeleteChat; final void Function(ChatModel chat) onPressChat; + final int pageSize; /// Disable the swipe to dismiss feature for chats that are not deletable final bool disableDismissForPermanentChats; @@ -42,6 +47,28 @@ class ChatScreen extends StatefulWidget { class _ChatScreenState extends State { final DateFormatter _dateFormatter = DateFormatter(); bool _hasCalledOnNoChats = false; + ScrollController controller = ScrollController(); + bool showIndicator = false; + Stream>? chats; + List deletedChats = []; + + @override + void initState() { + getChats(); + super.initState(); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + void getChats() { + setState(() { + chats = widget.service.getChatsStream(widget.pageSize); + }); + } @override Widget build(BuildContext context) { @@ -72,145 +99,197 @@ class _ChatScreenState extends State { Column( children: [ Expanded( - child: ListView( - padding: const EdgeInsets.only(top: 15.0), - children: [ - StreamBuilder>( - stream: widget.service.getChatsStream(), - builder: (BuildContext context, snapshot) { - // if the stream is done, empty and noChats is set we should call that - if (snapshot.connectionState == ConnectionState.done && - (snapshot.data?.isEmpty ?? true)) { - if (widget.onNoChats != null && !_hasCalledOnNoChats) { - _hasCalledOnNoChats = true; // Set the flag to true - WidgetsBinding.instance.addPostFrameCallback((_) { - widget.onNoChats!.call(); - }); - } - } else { - _hasCalledOnNoChats = - false; // Reset the flag if there are chats + child: Listener( + onPointerMove: (event) { + var isTop = controller.position.pixels == + controller.position.maxScrollExtent; + + if (showIndicator == false && + !isTop && + controller.position.userScrollDirection == + ScrollDirection.reverse) { + setState(() { + showIndicator = true; + }); + getChats(); + Future.delayed(const Duration(seconds: 2), () { + if (mounted) { + setState(() { + showIndicator = false; + }); } - return Column( - children: [ - for (ChatModel chat in snapshot.data ?? []) ...[ - Builder( - builder: (context) => !(widget - .disableDismissForPermanentChats && - !chat.canBeDeleted) - ? Dismissible( - confirmDismiss: (_) => - widget.deleteChatDialog - ?.call(context, chat) ?? - showModalBottomSheet( - context: context, - builder: (BuildContext context) => - Container( - padding: const EdgeInsets.all(16.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - chat.canBeDeleted - ? translations - .deleteChatModalTitle - : translations - .chatCantBeDeleted, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 16), - if (chat.canBeDeleted) + }); + } + }, + child: ListView( + controller: controller, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only(top: 15.0), + children: [ + StreamBuilder>( + stream: chats, + builder: (BuildContext context, snapshot) { + // if the stream is done, empty and noChats is set we should call that + if (snapshot.connectionState == ConnectionState.done && + (snapshot.data?.isEmpty ?? true)) { + if (widget.onNoChats != null && !_hasCalledOnNoChats) { + _hasCalledOnNoChats = true; // Set the flag to true + WidgetsBinding.instance + .addPostFrameCallback((_) async { + await widget.onNoChats!.call(); + getChats(); + }); + } + } else { + _hasCalledOnNoChats = + false; // Reset the flag if there are chats + } + return Column( + children: [ + for (ChatModel chat in (snapshot.data ?? []).where( + (chat) => !deletedChats.contains(chat.id), + )) ...[ + Builder( + builder: (context) => !(widget + .disableDismissForPermanentChats && + !chat.canBeDeleted) + ? Dismissible( + confirmDismiss: (_) async => + widget.deleteChatDialog + ?.call(context, chat) ?? + showModalBottomSheet( + context: context, + builder: (BuildContext context) => + Container( + padding: + const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ Text( - translations - .deleteChatModalDescription, + chat.canBeDeleted + ? translations + .deleteChatModalTitle + : translations + .chatCantBeDeleted, style: const TextStyle( - fontSize: 16, + fontSize: 20, + fontWeight: + FontWeight.bold, ), ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - TextButton( - child: Text( - translations - .deleteChatModalCancel, - style: const TextStyle( - fontSize: 16, - ), + const SizedBox(height: 16), + if (chat.canBeDeleted) + Text( + translations + .deleteChatModalDescription, + style: const TextStyle( + fontSize: 16, ), - onPressed: () => - Navigator.of(context) - .pop(false), ), - if (chat.canBeDeleted) - ElevatedButton( - onPressed: () => - Navigator.of( - context, - ).pop(true), + const SizedBox(height: 16), + Row( + mainAxisAlignment: + MainAxisAlignment + .center, + children: [ + TextButton( child: Text( translations - .deleteChatModalConfirm, + .deleteChatModalCancel, style: const TextStyle( fontSize: 16, ), ), + onPressed: () => + Navigator.of( + context, + ).pop(false), ), - ], - ), - ], + if (chat.canBeDeleted) + ElevatedButton( + onPressed: () => + Navigator.of( + context, + ).pop(true), + child: Text( + translations + .deleteChatModalConfirm, + style: + const TextStyle( + fontSize: 16, + ), + ), + ), + ], + ), + ], + ), + ), + ), + onDismissed: (_) { + setState(() { + deletedChats.add(chat.id!); + }); + widget.onDeleteChat(chat); + }, + background: Container( + color: Colors.red, + child: Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + translations.deleteChatButton, ), ), ), - onDismissed: (_) => - widget.onDeleteChat(chat), - background: Container( - color: Colors.red, - child: Align( - alignment: Alignment.centerRight, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - translations.deleteChatButton, - ), - ), ), - ), - key: ValueKey( - chat.id.toString(), - ), - child: ChatListItem( + key: ValueKey( + chat.id.toString(), + ), + child: ChatListItem( + widget: widget, + chat: chat, + translations: translations, + dateFormatter: _dateFormatter, + ), + ) + : ChatListItem( widget: widget, chat: chat, translations: translations, dateFormatter: _dateFormatter, ), - ) - : ChatListItem( - widget: widget, - chat: chat, - translations: translations, - dateFormatter: _dateFormatter, - ), - ), + ), + ], + if (showIndicator && + snapshot.connectionState != + ConnectionState.done) ...[ + const SizedBox( + height: 10, + ), + const CircularProgressIndicator(), + const SizedBox( + height: 10, + ), + ], ], - ], - ); - }, - ), - ], + ); + }, + ), + ], + ), ), ), if (widget.onPressStartChat != null) widget.options.newChatButtonBuilder( context, - widget.onPressStartChat!, + () async { + await widget.onPressStartChat!.call(); + getChats(); + }, translations, ), ], @@ -237,48 +316,51 @@ class ChatListItem extends StatelessWidget { Widget build(BuildContext context) { return GestureDetector( onTap: () => widget.onPressChat(chat), - child: widget.options.chatRowContainerBuilder( - (chat is PersonalChatModel) - ? ChatRow( - unreadMessages: chat.unreadMessages ?? 0, - avatar: widget.options.userAvatarBuilder( - (chat as PersonalChatModel).user, - 40.0, + child: Container( + color: Colors.transparent, + child: widget.options.chatRowContainerBuilder( + (chat is PersonalChatModel) + ? ChatRow( + unreadMessages: chat.unreadMessages ?? 0, + avatar: widget.options.userAvatarBuilder( + (chat as PersonalChatModel).user, + 40.0, + ), + title: (chat as PersonalChatModel).user.fullName ?? + translations.anonymousUser, + subTitle: chat.lastMessage != null + ? chat.lastMessage is ChatTextMessageModel + ? (chat.lastMessage! as ChatTextMessageModel).text + : '📷 ' + '${translations.image}' + : '', + lastUsed: chat.lastUsed != null + ? _dateFormatter.format( + date: chat.lastUsed!, + ) + : null, + ) + : ChatRow( + title: (chat as GroupChatModel).title, + unreadMessages: chat.unreadMessages ?? 0, + subTitle: chat.lastMessage != null + ? chat.lastMessage is ChatTextMessageModel + ? (chat.lastMessage! as ChatTextMessageModel).text + : '📷 ' + '${translations.image}' + : '', + avatar: widget.options.groupAvatarBuilder( + (chat as GroupChatModel).title, + (chat as GroupChatModel).imageUrl, + 40.0, + ), + lastUsed: chat.lastUsed != null + ? _dateFormatter.format( + date: chat.lastUsed!, + ) + : null, ), - title: (chat as PersonalChatModel).user.fullName ?? - translations.anonymousUser, - subTitle: chat.lastMessage != null - ? chat.lastMessage is ChatTextMessageModel - ? (chat.lastMessage! as ChatTextMessageModel).text - : '📷 ' - '${translations.image}' - : '', - lastUsed: chat.lastUsed != null - ? _dateFormatter.format( - date: chat.lastUsed!, - ) - : null, - ) - : ChatRow( - title: (chat as GroupChatModel).title, - unreadMessages: chat.unreadMessages ?? 0, - subTitle: chat.lastMessage != null - ? chat.lastMessage is ChatTextMessageModel - ? (chat.lastMessage! as ChatTextMessageModel).text - : '📷 ' - '${translations.image}' - : '', - avatar: widget.options.groupAvatarBuilder( - (chat as GroupChatModel).title, - (chat as GroupChatModel).imageUrl, - 40.0, - ), - lastUsed: chat.lastUsed != null - ? _dateFormatter.format( - date: chat.lastUsed!, - ) - : null, - ), + ), ), ); } diff --git a/packages/flutter_community_chat_view/lib/src/screens/new_chat_screen.dart b/packages/flutter_community_chat_view/lib/src/screens/new_chat_screen.dart index 77fc819..80f0610 100644 --- a/packages/flutter_community_chat_view/lib/src/screens/new_chat_screen.dart +++ b/packages/flutter_community_chat_view/lib/src/screens/new_chat_screen.dart @@ -122,7 +122,9 @@ class _NewChatScreenState extends State { title: user.fullName ?? widget.translations.anonymousUser, ), ), - onTap: () => widget.onPressCreateChat(user), + onTap: () async { + await widget.onPressCreateChat(user); + }, ); }, ); diff --git a/packages/flutter_community_chat_view/pubspec.yaml b/packages/flutter_community_chat_view/pubspec.yaml index 0547797..ba3f64c 100644 --- a/packages/flutter_community_chat_view/pubspec.yaml +++ b/packages/flutter_community_chat_view/pubspec.yaml @@ -4,7 +4,7 @@ name: flutter_community_chat_view description: A standard flutter package. -version: 0.6.0 +version: 1.0.0 publish_to: none @@ -20,7 +20,7 @@ dependencies: git: url: https://github.com/Iconica-Development/flutter_community_chat path: packages/flutter_community_chat_interface - ref: 0.6.0 + ref: 1.0.0 cached_network_image: ^3.2.2 flutter_image_picker: git: