Merge pull request #38 from Iconica-Development/1.0.0

feat: pagination
This commit is contained in:
mike doornenbal 2023-12-29 15:13:43 +01:00 committed by GitHub
commit f8ca89a762
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 943 additions and 320 deletions

102
README.md
View file

@ -1,4 +1,5 @@
# Flutter Community Chat # 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 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) ![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 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 ## Setup
To use this package, add flutter_community_chat as a dependency in your pubspec.yaml file: To use this package, add flutter_community_chat as a dependency in your pubspec.yaml file:
``` ```
flutter_community_chat: flutter_community_chat:
git: git:
url: https://github.com/Iconica-Development/flutter_community_chat.git url: https://github.com/Iconica-Development/flutter_community_chat.git
path: packages/flutter_community_chat 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: flutter_community_chat_firebase:
git: git:
url: https://github.com/Iconica-Development/flutter_community_chat.git url: https://github.com/Iconica-Development/flutter_community_chat.git
path: packages/flutter_community_chat_firebase path: packages/flutter_community_chat_firebase
``` ```
Create a Firebase project for your application and add firebase firestore and storage.
## How to use ## 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( List<GoRoute> getCommunityChatRoutes() => getCommunityChatStoryRoutes(
dataProvider: FirebaseCommunityChatDataProvider(), 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. Add the `getCommunityChatRoutes()` to your go_router routes like so:
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:
``` ```
CommunityChat( final GoRouter _router = GoRouter(
dataProvider: FirebaseCommunityChatDataProvider(), routes: <RouteBase>[
imagePickerTheme: ImagePickerTheme(), GoRoute(
chatOptions: ChatOptions(), 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: The `ChatOptions` has its own parameters, as specified below:
| Parameter | Explanation | | Parameter | Explanation |
|-----------|-------------| |-----------|-------------|
| newChatButtonBuilder | Builds the 'New Chat' button, to initiate a new chat session. This button is displayed on the chat overview. | | 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. | | 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*. | | 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. | | imagePickerContainerBuilder | Builds the container around the ImagePicker. |
| closeImagePickerButtonBuilder | Builds the close button for the Image Picker pop-up window. | | 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 ## Issues
@ -69,3 +130,4 @@ If you would like to contribute to the plugin (e.g. by improving the documentati
## Author ## Author
This `flutter_community_chat` for Flutter is developed by [Iconica](https://iconica.nl). You can contact us at <support@iconica.nl> This `flutter_community_chat` for Flutter is developed by [Iconica](https://iconica.nl). You can contact us at <support@iconica.nl>
````

View file

@ -17,19 +17,26 @@ List<GoRoute> getCommunityChatStoryRoutes(
path: CommunityChatUserStoryRoutes.chatScreen, path: CommunityChatUserStoryRoutes.chatScreen,
pageBuilder: (context, state) { pageBuilder: (context, state) {
var chatScreen = ChatScreen( var chatScreen = ChatScreen(
pageSize: configuration.pageSize,
service: configuration.service, service: configuration.service,
options: configuration.chatOptionsBuilder(context), options: configuration.chatOptionsBuilder(context),
onNoChats: () => onNoChats: () async =>
context.push(CommunityChatUserStoryRoutes.newChatScreen), await context.push(CommunityChatUserStoryRoutes.newChatScreen),
onPressStartChat: () => onPressStartChat: () async {
configuration.onPressStartChat?.call() ?? if (configuration.onPressStartChat != null) {
context.push(CommunityChatUserStoryRoutes.newChatScreen), return await configuration.onPressStartChat?.call();
}
return await context
.push(CommunityChatUserStoryRoutes.newChatScreen);
},
onPressChat: (chat) => onPressChat: (chat) =>
configuration.onPressChat?.call(context, chat) ?? configuration.onPressChat?.call(context, chat) ??
context.push( context.push(
CommunityChatUserStoryRoutes.chatDetailViewPath(chat.id!)), CommunityChatUserStoryRoutes.chatDetailViewPath(chat.id!)),
onDeleteChat: (chat) => onDeleteChat: (chat) =>
configuration.onDeleteChat?.call(context, chat), configuration.onDeleteChat?.call(context, chat) ??
configuration.service.deleteChat(chat),
deleteChatDialog: configuration.deleteChatDialog, deleteChatDialog: configuration.deleteChatDialog,
translations: configuration.translations, translations: configuration.translations,
); );
@ -52,6 +59,7 @@ List<GoRoute> getCommunityChatStoryRoutes(
var chatId = state.pathParameters['id']; var chatId = state.pathParameters['id'];
var chat = PersonalChatModel(user: ChatUserModel(), id: chatId); var chat = PersonalChatModel(user: ChatUserModel(), id: chatId);
var chatDetailScreen = ChatDetailScreen( var chatDetailScreen = ChatDetailScreen(
pageSize: configuration.messagePageSize,
options: configuration.chatOptionsBuilder(context), options: configuration.chatOptionsBuilder(context),
translations: configuration.translations, translations: configuration.translations,
chatUserService: configuration.userService, chatUserService: configuration.userService,
@ -110,8 +118,9 @@ List<GoRoute> getCommunityChatStoryRoutes(
); );
} }
if (context.mounted) { if (context.mounted) {
context.push(CommunityChatUserStoryRoutes.chatDetailViewPath( await context.push(
chat.id ?? '')); CommunityChatUserStoryRoutes.chatDetailViewPath(
chat.id ?? ''));
} }
}); });
return buildScreenWithoutTransition( return buildScreenWithoutTransition(

View file

@ -14,6 +14,7 @@ class CommunityChatUserStoryConfiguration {
required this.messageService, required this.messageService,
required this.service, required this.service,
required this.chatOptionsBuilder, required this.chatOptionsBuilder,
this.pageSize = 10,
this.onPressStartChat, this.onPressStartChat,
this.onPressChat, this.onPressChat,
this.onDeleteChat, this.onDeleteChat,
@ -29,6 +30,7 @@ class CommunityChatUserStoryConfiguration {
this.chatPageBuilder, this.chatPageBuilder,
this.onPressChatTitle, this.onPressChatTitle,
this.afterMessageSent, this.afterMessageSent,
this.messagePageSize = 20,
}); });
final ChatService service; final ChatService service;
final ChatUserService userService; 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. /// If true, the user will be routed to the new chat screen if there are no chats.
final bool routeToNewChatIfEmpty; final bool routeToNewChatIfEmpty;
final int pageSize;
final int messagePageSize;
final Future<bool?> Function(BuildContext, ChatModel)? deleteChatDialog; final Future<bool?> Function(BuildContext, ChatModel)? deleteChatDialog;
final Function(BuildContext context, ChatModel chat)? onPressChatTitle; final Function(BuildContext context, ChatModel chat)? onPressChatTitle;

View file

@ -4,7 +4,7 @@
name: flutter_community_chat name: flutter_community_chat
description: A new Flutter package project. description: A new Flutter package project.
version: 0.6.0 version: 1.0.0
publish_to: none publish_to: none
@ -15,17 +15,17 @@ environment:
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
go_router: ^12.1.1 go_router: any
flutter_community_chat_view: flutter_community_chat_view:
git: git:
url: https://github.com/Iconica-Development/flutter_community_chat url: https://github.com/Iconica-Development/flutter_community_chat
path: packages/flutter_community_chat_view path: packages/flutter_community_chat_view
ref: 0.6.0 ref: 1.0.0
flutter_community_chat_interface: flutter_community_chat_interface:
git: git:
url: https://github.com/Iconica-Development/flutter_community_chat url: https://github.com/Iconica-Development/flutter_community_chat
path: packages/flutter_community_chat_interface path: packages/flutter_community_chat_interface
ref: 0.6.0 ref: 1.0.0
dev_dependencies: dev_dependencies:
flutter_lints: ^2.0.0 flutter_lints: ^2.0.0

View file

@ -11,18 +11,26 @@ class FirebaseChatOptions {
this.chatsCollectionName = 'chats', this.chatsCollectionName = 'chats',
this.messagesCollectionName = 'messages', this.messagesCollectionName = 'messages',
this.usersCollectionName = 'users', this.usersCollectionName = 'users',
this.chatsMetaDataCollectionName = 'chat_metadata',
this.userChatsCollectionName = 'chats',
}); });
final String groupChatsCollectionName; final String groupChatsCollectionName;
final String chatsCollectionName; final String chatsCollectionName;
final String messagesCollectionName; final String messagesCollectionName;
final String usersCollectionName; final String usersCollectionName;
final String chatsMetaDataCollectionName;
///This is the collection inside the user document.
final String userChatsCollectionName;
FirebaseChatOptions copyWith({ FirebaseChatOptions copyWith({
String? groupChatsCollectionName, String? groupChatsCollectionName,
String? chatsCollectionName, String? chatsCollectionName,
String? messagesCollectionName, String? messagesCollectionName,
String? usersCollectionName, String? usersCollectionName,
String? chatsMetaDataCollectionName,
String? userChatsCollectionName,
}) { }) {
return FirebaseChatOptions( return FirebaseChatOptions(
groupChatsCollectionName: groupChatsCollectionName:
@ -31,6 +39,10 @@ class FirebaseChatOptions {
messagesCollectionName: messagesCollectionName:
messagesCollectionName ?? this.messagesCollectionName, messagesCollectionName ?? this.messagesCollectionName,
usersCollectionName: usersCollectionName ?? this.usersCollectionName, usersCollectionName: usersCollectionName ?? this.usersCollectionName,
chatsMetaDataCollectionName:
chatsMetaDataCollectionName ?? this.chatsMetaDataCollectionName,
userChatsCollectionName:
userChatsCollectionName ?? this.userChatsCollectionName,
); );
} }
} }

View file

@ -15,6 +15,10 @@ class FirebaseChatService implements ChatService {
late FirebaseStorage _storage; late FirebaseStorage _storage;
late ChatUserService _userService; late ChatUserService _userService;
late FirebaseChatOptions _options; late FirebaseChatOptions _options;
DocumentSnapshot<Object?>? lastUserDocument;
String? lastGroupId;
List<String> chatIds = [];
int pageNumber = 1;
FirebaseChatService({ FirebaseChatService({
required ChatUserService userService, required ChatUserService userService,
@ -37,7 +41,7 @@ class FirebaseChatService implements ChatService {
var snapshots = _db var snapshots = _db
.collection(_options.usersCollectionName) .collection(_options.usersCollectionName)
.doc(userId) .doc(userId)
.collection('chats') .collection(_options.userChatsCollectionName)
.doc(chatId) .doc(chatId)
.snapshots(); .snapshots();
@ -52,7 +56,7 @@ class FirebaseChatService implements ChatService {
Function(List<ChatModel>) onReceivedChats, Function(List<ChatModel>) onReceivedChats,
) { ) {
var snapshots = _db var snapshots = _db
.collection(_options.chatsCollectionName) .collection(_options.chatsMetaDataCollectionName)
.where( .where(
FieldPath.documentId, FieldPath.documentId,
whereIn: chatIds, whereIn: chatIds,
@ -228,19 +232,29 @@ class FirebaseChatService implements ChatService {
} }
@override @override
Stream<List<ChatModel>> getChatsStream() { Stream<List<ChatModel>> getChatsStream(int pageSize) {
late StreamController<List<ChatModel>> controller; late StreamController<List<ChatModel>> controller;
StreamSubscription? chatsSubscription; StreamSubscription? chatsSubscription;
controller = StreamController( controller = StreamController(
onListen: () async { onListen: () async {
QuerySnapshot<Map<String, dynamic>> userSnapshot;
List<String> userChatIds;
var currentUser = await _userService.getCurrentUser(); var currentUser = await _userService.getCurrentUser();
var userSnapshot = await _db var userQuery = _db
.collection(_options.usersCollectionName) .collection(_options.usersCollectionName)
.doc(currentUser?.id) .doc(currentUser?.id)
.collection('chats') .collection(_options.userChatsCollectionName);
.get(); if (lastUserDocument == null) {
var userChatIds = userSnapshot.docs.map((chat) => chat.id).toList(); 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 var userGroupChatIds = await _db
.collection(_options.usersCollectionName) .collection(_options.usersCollectionName)
@ -248,10 +262,30 @@ class FirebaseChatService implements ChatService {
.get() .get()
.then((userCollection) => .then((userCollection) =>
userCollection.data()?[_options.groupChatsCollectionName]) userCollection.data()?[_options.groupChatsCollectionName])
.then((groupChatLabels) => groupChatLabels?.cast<String>()); .then((groupChatLabels) => groupChatLabels?.cast<String>())
.then((groupChatIds) {
var startIndex = (pageNumber - 1) * pageSize;
var endIndex = startIndex + pageSize;
var chatsStream = if (groupChatIds != null) {
_getSpecificChatsStream([...userChatIds, ...userGroupChatIds]); 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) { chatsSubscription = chatsStream.listen((event) {
controller.add(event); controller.add(event);
@ -270,7 +304,7 @@ class FirebaseChatService implements ChatService {
var collection = await _db var collection = await _db
.collection(_options.usersCollectionName) .collection(_options.usersCollectionName)
.doc(currentUser?.id) .doc(currentUser?.id)
.collection('chats') .collection(_options.userChatsCollectionName)
.where('users', arrayContains: user.id) .where('users', arrayContains: user.id)
.get(); .get();
@ -288,7 +322,7 @@ class FirebaseChatService implements ChatService {
var chatCollection = await _db var chatCollection = await _db
.collection(_options.usersCollectionName) .collection(_options.usersCollectionName)
.doc(currentUser?.id) .doc(currentUser?.id)
.collection('chats') .collection(_options.userChatsCollectionName)
.doc(chatId) .doc(chatId)
.get(); .get();
@ -333,7 +367,7 @@ class FirebaseChatService implements ChatService {
@override @override
Future<void> deleteChat(ChatModel chat) async { Future<void> deleteChat(ChatModel chat) async {
var chatCollection = await _db var chatCollection = await _db
.collection(_options.chatsCollectionName) .collection(_options.chatsMetaDataCollectionName)
.doc(chat.id) .doc(chat.id)
.withConverter( .withConverter(
fromFirestore: (snapshot, _) => fromFirestore: (snapshot, _) =>
@ -349,7 +383,7 @@ class FirebaseChatService implements ChatService {
_db _db
.collection(_options.usersCollectionName) .collection(_options.usersCollectionName)
.doc(userId) .doc(userId)
.collection('chats') .collection(_options.userChatsCollectionName)
.doc(chat.id) .doc(chat.id)
.delete(); .delete();
} }
@ -387,7 +421,7 @@ class FirebaseChatService implements ChatService {
]; ];
var reference = await _db var reference = await _db
.collection(_options.chatsCollectionName) .collection(_options.chatsMetaDataCollectionName)
.withConverter( .withConverter(
fromFirestore: (snapshot, _) => fromFirestore: (snapshot, _) =>
FirebaseChatDocument.fromJson(snapshot.data()!, snapshot.id), FirebaseChatDocument.fromJson(snapshot.data()!, snapshot.id),
@ -406,12 +440,13 @@ class FirebaseChatService implements ChatService {
await _db await _db
.collection(_options.usersCollectionName) .collection(_options.usersCollectionName)
.doc(userId) .doc(userId)
.collection('chats') .collection(_options.userChatsCollectionName)
.doc(reference.id) .doc(reference.id)
.set({'users': userIds}); .set({'users': userIds});
} }
chat.id = reference.id; chat.id = reference.id;
chatIds.add(chat.id!);
} else if (chat is GroupChatModel) { } else if (chat is GroupChatModel) {
if (currentUser?.id == null) { if (currentUser?.id == null) {
return chat; return chat;
@ -448,6 +483,7 @@ class FirebaseChatService implements ChatService {
} }
chat.id = reference.id; chat.id = reference.id;
chatIds.add(chat.id!);
} else { } else {
throw Exception('Chat type not supported for firebase'); throw Exception('Chat type not supported for firebase');
} }
@ -467,7 +503,7 @@ class FirebaseChatService implements ChatService {
var userSnapshot = _db var userSnapshot = _db
.collection(_options.usersCollectionName) .collection(_options.usersCollectionName)
.doc(currentUser?.id) .doc(currentUser?.id)
.collection('chats') .collection(_options.userChatsCollectionName)
.snapshots(); .snapshots();
unreadChatSubscription = userSnapshot.listen((event) { unreadChatSubscription = userSnapshot.listen((event) {
@ -498,7 +534,7 @@ class FirebaseChatService implements ChatService {
await _db await _db
.collection(_options.usersCollectionName) .collection(_options.usersCollectionName)
.doc(currentUser!.id!) .doc(currentUser!.id!)
.collection('chats') .collection(_options.userChatsCollectionName)
.doc(chat.id) .doc(chat.id)
.set({'amount_unread_messages': 0}, SetOptions(merge: true)); .set({'amount_unread_messages': 0}, SetOptions(merge: true));
} }

View file

@ -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:flutter_community_chat_interface/flutter_community_chat_interface.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
class FirebaseMessageService implements MessageService { class FirebaseMessageService with ChangeNotifier implements MessageService {
late final FirebaseFirestore _db; late final FirebaseFirestore _db;
late final FirebaseStorage _storage; late final FirebaseStorage _storage;
late final ChatUserService _userService; late final ChatUserService _userService;
@ -21,6 +21,11 @@ class FirebaseMessageService implements MessageService {
StreamController<List<ChatMessageModel>>? _controller; StreamController<List<ChatMessageModel>>? _controller;
StreamSubscription<QuerySnapshot>? _subscription; StreamSubscription<QuerySnapshot>? _subscription;
DocumentSnapshot<Object>? lastMessage;
List<ChatMessageModel> _cumulativeMessages = [];
ChatModel? lastChat;
int? chatPageSize;
DateTime timestampToFilter = DateTime.now();
FirebaseMessageService({ FirebaseMessageService({
required ChatUserService userService, required ChatUserService userService,
@ -54,13 +59,28 @@ class FirebaseMessageService implements MessageService {
) )
.doc(chat.id); .doc(chat.id);
await chatReference var newMessage = await chatReference
.collection( .collection(
_options.messagesCollectionName, _options.messagesCollectionName,
) )
.add(message); .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_used': DateTime.now(),
'last_message': message, 'last_message': message,
}); });
@ -76,7 +96,7 @@ class FirebaseMessageService implements MessageService {
// update the chat counter for the other users // update the chat counter for the other users
// get all users from the chat // get all users from the chat
// there is a field in the chat document called users that has a list of user ids // 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<dynamic>; var chatUsers = fetchedChat.data()?['users'] as List<dynamic>;
// for all users except the message sender update the unread counter // for all users except the message sender update the unread counter
for (var userId in chatUsers) { for (var userId in chatUsers) {
@ -86,7 +106,7 @@ class FirebaseMessageService implements MessageService {
_options.usersCollectionName, _options.usersCollectionName,
) )
.doc(userId) .doc(userId)
.collection('chats') .collection(_options.userChatsCollectionName)
.doc(chat.id); .doc(chat.id);
// what if the amount_unread_messages field does not exist? // what if the amount_unread_messages field does not exist?
// it should be created when the chat is create // it should be created when the chat is create
@ -110,13 +130,14 @@ class FirebaseMessageService implements MessageService {
Future<void> sendTextMessage({ Future<void> sendTextMessage({
required String text, required String text,
required ChatModel chat, required ChatModel chat,
}) => }) {
_sendMessage( return _sendMessage(
chat, chat,
{ {
'text': text, 'text': text,
}, },
); );
}
@override @override
Future<void> sendImageMessage({ Future<void> sendImageMessage({
@ -144,24 +165,122 @@ class FirebaseMessageService implements MessageService {
); );
} }
Query<FirebaseMessageDocument> _getMessagesQuery(ChatModel chat) => _db Query<FirebaseMessageDocument> _getMessagesQuery(ChatModel chat) {
.collection(_options.chatsCollectionName) if (lastChat == null) {
.doc(chat.id) lastChat = chat;
.collection(_options.messagesCollectionName) } else if (lastChat?.id != chat.id) {
.orderBy('timestamp', descending: false) _cumulativeMessages = [];
.withConverter<FirebaseMessageDocument>( 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<FirebaseMessageDocument>(
fromFirestore: (snapshot, _) => fromFirestore: (snapshot, _) =>
FirebaseMessageDocument.fromJson(snapshot.data()!, snapshot.id), FirebaseMessageDocument.fromJson(snapshot.data()!, snapshot.id),
toFirestore: (user, _) => user.toJson(), toFirestore: (user, _) => user.toJson(),
); );
}
return query
.startAfterDocument(lastMessage!)
.withConverter<FirebaseMessageDocument>(
fromFirestore: (snapshot, _) =>
FirebaseMessageDocument.fromJson(snapshot.data()!, snapshot.id),
toFirestore: (user, _) => user.toJson(),
);
}
@override @override
Stream<List<ChatMessageModel>> getMessagesStream(ChatModel chat) { Stream<List<ChatMessageModel>> getMessagesStream(ChatModel chat) {
_controller = StreamController<List<ChatMessageModel>>( _controller = StreamController<List<ChatMessageModel>>(
onListen: () { onListen: () {
if (chat.id != null) { var messagesCollection = _db
_subscription = _startListeningForMessages(chat); .collection(_options.chatsCollectionName)
} .doc(chat.id)
.collection(_options.messagesCollectionName)
.withConverter<FirebaseMessageDocument>(
fromFirestore: (snapshot, _) => FirebaseMessageDocument.fromJson(
snapshot.data()!, snapshot.id),
toFirestore: (user, _) => user.toJson(),
);
var query = messagesCollection
.where(
'timestamp',
isGreaterThan: timestampToFilter,
)
.withConverter<FirebaseMessageDocument>(
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 = <ChatMessageModel>[];
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<ChatMessageModel> uniqueObjects =
_cumulativeMessages.toSet().toList();
_cumulativeMessages = uniqueObjects;
_cumulativeMessages
.sort((a, b) => a.timestamp.compareTo(b.timestamp));
notifyListeners();
timestampToFilter = DateTime.now();
});
}, },
onCancel: () { onCancel: () {
_subscription?.cancel(); _subscription?.cancel();
@ -169,47 +288,145 @@ class FirebaseMessageService implements MessageService {
debugPrint('Canceling messages stream'); debugPrint('Canceling messages stream');
}, },
); );
return _controller!.stream; return _controller!.stream;
} }
StreamSubscription<QuerySnapshot> _startListeningForMessages(ChatModel chat) { StreamSubscription<QuerySnapshot> _startListeningForMessages(ChatModel chat) {
debugPrint('Start listening for messages in chat ${chat.id}'); debugPrint('Start listening for messages in chat ${chat.id}');
var snapshots = _getMessagesQuery(chat).snapshots(); var snapshots = _getMessagesQuery(chat).snapshots();
return snapshots.listen( return snapshots.listen(
(snapshot) async { (snapshot) async {
var messages = <ChatMessageModel>[]; List<ChatMessageModel> messages =
List<ChatMessageModel>.from(_cumulativeMessages);
for (var messageDoc in snapshot.docs) { if (snapshot.docs.isNotEmpty) {
var messageData = messageDoc.data(); lastMessage = snapshot.docs.last;
var sender = await _userService.getUser(messageData.sender); for (var messageDoc in snapshot.docs) {
var messageData = messageDoc.data();
if (sender != null) { // Check if the message is already in the list to avoid duplicates
var timestamp = DateTime.fromMillisecondsSinceEpoch( if (!messages.any((message) {
(messageData.timestamp).millisecondsSinceEpoch, var timestamp = DateTime.fromMillisecondsSinceEpoch(
); (messageData.timestamp).millisecondsSinceEpoch,
);
return timestamp == message.timestamp;
})) {
var sender = await _userService.getUser(messageData.sender);
messages.add( if (sender != null) {
messageData.imageUrl != null var timestamp = DateTime.fromMillisecondsSinceEpoch(
? ChatImageMessageModel( (messageData.timestamp).millisecondsSinceEpoch,
sender: sender, );
imageUrl: messageData.imageUrl!,
timestamp: timestamp, messages.add(
) messageData.imageUrl != null
: ChatTextMessageModel( ? ChatImageMessageModel(
sender: sender, sender: sender,
text: messageData.text!, imageUrl: messageData.imageUrl!,
timestamp: timestamp, timestamp: timestamp,
), )
); : ChatTextMessageModel(
sender: sender,
text: messageData.text!,
timestamp: timestamp,
),
);
}
}
} }
} }
_cumulativeMessages = messages;
messages.sort((a, b) => a.timestamp.compareTo(b.timestamp));
_controller?.add(messages); _controller?.add(messages);
notifyListeners();
}, },
); );
} }
@override
Future<void> 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<ChatMessageModel> messages = [];
QuerySnapshot<FirebaseMessageDocument>? 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<FirebaseMessageDocument>(
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<FirebaseMessageDocument>(
fromFirestore: (snapshot, _) =>
FirebaseMessageDocument.fromJson(snapshot.data()!, snapshot.id),
toFirestore: (user, _) => user.toJson(),
)
.get();
if (messagesQuerySnapshot.docs.isNotEmpty) {
lastMessage = messagesQuerySnapshot.docs.last;
}
}
List<FirebaseMessageDocument> messageDocuments = messagesQuerySnapshot.docs
.map((QueryDocumentSnapshot<FirebaseMessageDocument> 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<ChatMessageModel> getMessages() {
return _cumulativeMessages;
}
} }

View file

@ -4,7 +4,7 @@
name: flutter_community_chat_firebase name: flutter_community_chat_firebase
description: A new Flutter package project. description: A new Flutter package project.
version: 0.6.0 version: 1.0.0
publish_to: none publish_to: none
environment: environment:
@ -23,7 +23,7 @@ dependencies:
git: git:
url: https://github.com/Iconica-Development/flutter_community_chat url: https://github.com/Iconica-Development/flutter_community_chat
path: packages/flutter_community_chat_interface path: packages/flutter_community_chat_interface
ref: 0.6.0 ref: 1.0.0
dev_dependencies: dev_dependencies:
flutter_lints: ^2.0.0 flutter_lints: ^2.0.0

View file

@ -1,7 +1,7 @@
import 'package:flutter_community_chat_interface/flutter_community_chat_interface.dart'; import 'package:flutter_community_chat_interface/flutter_community_chat_interface.dart';
abstract class ChatService { abstract class ChatService {
Stream<List<ChatModel>> getChatsStream(); Stream<List<ChatModel>> getChatsStream(int pageSize);
Future<ChatModel> getChatByUser(ChatUserModel user); Future<ChatModel> getChatByUser(ChatUserModel user);
Future<ChatModel> getChatById(String id); Future<ChatModel> getChatById(String id);
Future<void> deleteChat(ChatModel chat); Future<void> deleteChat(ChatModel chat);

View file

@ -1,7 +1,8 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_community_chat_interface/flutter_community_chat_interface.dart'; import 'package:flutter_community_chat_interface/flutter_community_chat_interface.dart';
abstract class MessageService { abstract class MessageService with ChangeNotifier {
Future<void> sendTextMessage({ Future<void> sendTextMessage({
required ChatModel chat, required ChatModel chat,
required String text, required String text,
@ -15,4 +16,8 @@ abstract class MessageService {
Stream<List<ChatMessageModel>> getMessagesStream( Stream<List<ChatMessageModel>> getMessagesStream(
ChatModel chat, ChatModel chat,
); );
Future<void> fetchMoreMessage(int pageSize, ChatModel chat);
List<ChatMessageModel> getMessages();
} }

View file

@ -4,7 +4,7 @@
name: flutter_community_chat_interface name: flutter_community_chat_interface
description: A new Flutter package project. description: A new Flutter package project.
version: 0.6.0 version: 1.0.0
publish_to: none publish_to: none
environment: environment:

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -0,0 +1,19 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GeneratedPluginRegistrant_h
#define GeneratedPluginRegistrant_h
#import <Flutter/Flutter.h>
NS_ASSUME_NONNULL_BEGIN
@interface GeneratedPluginRegistrant : NSObject
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry;
@end
NS_ASSUME_NONNULL_END
#endif /* GeneratedPluginRegistrant_h */

View file

@ -0,0 +1,63 @@
//
// Generated file. Do not edit.
//
// clang-format off
#import "GeneratedPluginRegistrant.h"
#if __has_include(<cloud_firestore/FLTFirebaseFirestorePlugin.h>)
#import <cloud_firestore/FLTFirebaseFirestorePlugin.h>
#else
@import cloud_firestore;
#endif
#if __has_include(<firebase_auth/FLTFirebaseAuthPlugin.h>)
#import <firebase_auth/FLTFirebaseAuthPlugin.h>
#else
@import firebase_auth;
#endif
#if __has_include(<firebase_core/FLTFirebaseCorePlugin.h>)
#import <firebase_core/FLTFirebaseCorePlugin.h>
#else
@import firebase_core;
#endif
#if __has_include(<firebase_storage/FLTFirebaseStoragePlugin.h>)
#import <firebase_storage/FLTFirebaseStoragePlugin.h>
#else
@import firebase_storage;
#endif
#if __has_include(<image_picker_ios/FLTImagePickerPlugin.h>)
#import <image_picker_ios/FLTImagePickerPlugin.h>
#else
@import image_picker_ios;
#endif
#if __has_include(<path_provider_foundation/PathProviderPlugin.h>)
#import <path_provider_foundation/PathProviderPlugin.h>
#else
@import path_provider_foundation;
#endif
#if __has_include(<sqflite/SqflitePlugin.h>)
#import <sqflite/SqflitePlugin.h>
#else
@import sqflite;
#endif
@implementation GeneratedPluginRegistrant
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)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

View file

@ -34,22 +34,18 @@ class _ChatDetailRowState extends State<ChatDetailRow> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var isNewDate = widget.previousMessage != null && 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( return Padding(
padding: EdgeInsets.only( padding: EdgeInsets.only(
top: isNewDate || top: isNewDate || isSameSender ? 25.0 : 0,
widget.previousMessage == null ||
widget.previousMessage?.sender.id != widget.message.sender.id
? 25.0
: 0,
), ),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (isNewDate || if (isNewDate || isSameSender) ...[
widget.previousMessage == null ||
widget.previousMessage?.sender.id !=
widget.message.sender.id) ...[
Padding( Padding(
padding: const EdgeInsets.only(left: 10.0), padding: const EdgeInsets.only(left: 10.0),
child: widget.message.sender.imageUrl != null && child: widget.message.sender.imageUrl != null &&
@ -75,19 +71,20 @@ class _ChatDetailRowState extends State<ChatDetailRow> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
children: [ children: [
if (isNewDate || if (isNewDate || isSameSender)
widget.previousMessage == null ||
widget.previousMessage?.sender.id !=
widget.message.sender.id)
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( Text(
widget.message.sender.fullName?.toUpperCase() ?? widget.message.sender.fullName?.toUpperCase() ??
widget.translations.anonymousUser, widget.translations.anonymousUser,
style: const TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: Theme.of(context)
.textTheme
.labelMedium
?.color,
), ),
), ),
Padding( Padding(
@ -112,7 +109,13 @@ class _ChatDetailRowState extends State<ChatDetailRow> {
text: TextSpan( text: TextSpan(
text: (widget.message as ChatTextMessageModel) text: (widget.message as ChatTextMessageModel)
.text, .text,
style: const TextStyle(fontSize: 16), style: TextStyle(
fontSize: 16,
color: Theme.of(context)
.textTheme
.labelMedium
?.color,
),
children: <TextSpan>[ children: <TextSpan>[
if (widget.showTime) if (widget.showTime)
TextSpan( TextSpan(

View file

@ -24,9 +24,11 @@ class ChatImage extends StatelessWidget {
), ),
width: size, width: size,
height: size, height: size,
child: CachedNetworkImage( child: image != ''
imageUrl: image, ? CachedNetworkImage(
fit: BoxFit.cover, imageUrl: image,
), fit: BoxFit.cover,
)
: null,
); );
} }

View file

@ -75,7 +75,7 @@ Widget _createImagePickerContainer(
) => ) =>
Container( Container(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
color: Colors.black, color: Colors.white,
child: ImagePicker( child: ImagePicker(
customButton: ElevatedButton( customButton: ElevatedButton(
onPressed: onClose, onPressed: onClose,

View file

@ -6,6 +6,7 @@ import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; 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/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_bottom.dart';
import 'package:flutter_community_chat_view/src/components/chat_detail_row.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.service,
required this.chatUserService, required this.chatUserService,
required this.messageService, required this.messageService,
required this.pageSize,
this.translations = const ChatTranslations(), this.translations = const ChatTranslations(),
this.chat, this.chat,
this.onPressChatTitle, this.onPressChatTitle,
@ -47,6 +49,7 @@ class ChatDetailScreen extends StatefulWidget {
final ChatService service; final ChatService service;
final ChatUserService chatUserService; final ChatUserService chatUserService;
final MessageService messageService; final MessageService messageService;
final int pageSize;
@override @override
State<ChatDetailScreen> createState() => _ChatDetailScreenState(); State<ChatDetailScreen> createState() => _ChatDetailScreenState();
@ -54,32 +57,29 @@ class ChatDetailScreen extends StatefulWidget {
class _ChatDetailScreenState extends State<ChatDetailScreen> { class _ChatDetailScreenState extends State<ChatDetailScreen> {
// stream listener that needs to be disposed later // stream listener that needs to be disposed later
StreamSubscription<List<ChatMessageModel>>? _chatMessagesSubscription;
Stream<List<ChatMessageModel>>? _chatMessages;
ChatModel? chat;
ChatUserModel? currentUser; ChatUserModel? currentUser;
ScrollController controller = ScrollController();
bool showIndicator = false;
late MessageService messageSubscription;
Stream<List<ChatMessageModel>>? stream;
ChatMessageModel? previousMessage;
List<Widget> detailRows = [];
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// create a broadcast stream from the chat messages messageSubscription = widget.messageService;
messageSubscription.addListener(onListen);
if (widget.chat != null) { if (widget.chat != null) {
_chatMessages = widget.messageService stream = widget.messageService.getMessagesStream(widget.chat!);
.getMessagesStream(widget.chat!) stream?.listen((event) {});
.asBroadcastStream();
}
_chatMessagesSubscription = _chatMessages?.listen((event) {
// check if the last message is from the current user
// if so, set the chat to read
Future.delayed(Duration.zero, () async { 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((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (widget.chat != null) { if (widget.chat != null) {
widget.onReadChat(widget.chat!); widget.onReadChat(widget.chat!);
@ -87,9 +87,34 @@ class _ChatDetailScreenState extends State<ChatDetailScreen> {
}); });
} }
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 @override
void dispose() { void dispose() {
_chatMessagesSubscription?.cancel(); messageSubscription.removeListener(onListen);
super.dispose(); super.dispose();
} }
@ -121,7 +146,6 @@ class _ChatDetailScreenState extends State<ChatDetailScreen> {
future: widget.service.getChatById(widget.chat?.id ?? ''), future: widget.service.getChatById(widget.chat?.id ?? ''),
builder: (context, AsyncSnapshot<ChatModel> snapshot) { builder: (context, AsyncSnapshot<ChatModel> snapshot) {
var chatModel = snapshot.data; var chatModel = snapshot.data;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
centerTitle: true, centerTitle: true,
@ -166,33 +190,48 @@ class _ChatDetailScreenState extends State<ChatDetailScreen> {
body: Column( body: Column(
children: [ children: [
Expanded( Expanded(
child: StreamBuilder<List<ChatMessageModel>>( child: Listener(
stream: _chatMessages, onPointerMove: (event) async {
builder: (context, snapshot) { var isTop = controller.position.pixels ==
var messages = snapshot.data ?? chatModel?.messages ?? []; controller.position.maxScrollExtent;
ChatMessageModel? previousMessage;
var messageWidgets = <Widget>[]; if (showIndicator == false &&
!isTop &&
for (var message in messages) { controller.position.userScrollDirection ==
messageWidgets.add( ScrollDirection.reverse) {
ChatDetailRow( setState(() {
previousMessage: previousMessage, showIndicator = true;
showTime: widget.showTime, });
translations: widget.translations, await widget.messageService
message: message, .fetchMoreMessage(widget.pageSize, widget.chat!);
userAvatarBuilder: widget.options.userAvatarBuilder, Future.delayed(const Duration(seconds: 2), () {
), if (mounted) {
); setState(() {
previousMessage = message; 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) if (chatModel != null)

View file

@ -4,7 +4,10 @@
// ignore_for_file: lines_longer_than_80_chars // ignore_for_file: lines_longer_than_80_chars
import 'dart:async';
import 'package:flutter/material.dart'; 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/flutter_community_chat_view.dart';
import 'package:flutter_community_chat_view/src/services/date_formatter.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.onPressChat,
required this.onDeleteChat, required this.onDeleteChat,
required this.service, required this.service,
required this.pageSize,
this.onNoChats, this.onNoChats,
this.deleteChatDialog, this.deleteChatDialog,
this.translations = const ChatTranslations(), this.translations = const ChatTranslations(),
@ -25,10 +29,11 @@ class ChatScreen extends StatefulWidget {
final ChatOptions options; final ChatOptions options;
final ChatTranslations translations; final ChatTranslations translations;
final ChatService service; final ChatService service;
final VoidCallback? onPressStartChat; final Function()? onPressStartChat;
final VoidCallback? onNoChats; final Function()? onNoChats;
final void Function(ChatModel chat) onDeleteChat; final void Function(ChatModel chat) onDeleteChat;
final void Function(ChatModel chat) onPressChat; final void Function(ChatModel chat) onPressChat;
final int pageSize;
/// Disable the swipe to dismiss feature for chats that are not deletable /// Disable the swipe to dismiss feature for chats that are not deletable
final bool disableDismissForPermanentChats; final bool disableDismissForPermanentChats;
@ -42,6 +47,28 @@ class ChatScreen extends StatefulWidget {
class _ChatScreenState extends State<ChatScreen> { class _ChatScreenState extends State<ChatScreen> {
final DateFormatter _dateFormatter = DateFormatter(); final DateFormatter _dateFormatter = DateFormatter();
bool _hasCalledOnNoChats = false; bool _hasCalledOnNoChats = false;
ScrollController controller = ScrollController();
bool showIndicator = false;
Stream<List<ChatModel>>? chats;
List<String> deletedChats = [];
@override
void initState() {
getChats();
super.initState();
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
void getChats() {
setState(() {
chats = widget.service.getChatsStream(widget.pageSize);
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -72,145 +99,197 @@ class _ChatScreenState extends State<ChatScreen> {
Column( Column(
children: [ children: [
Expanded( Expanded(
child: ListView( child: Listener(
padding: const EdgeInsets.only(top: 15.0), onPointerMove: (event) {
children: [ var isTop = controller.position.pixels ==
StreamBuilder<List<ChatModel>>( controller.position.maxScrollExtent;
stream: widget.service.getChatsStream(),
builder: (BuildContext context, snapshot) { if (showIndicator == false &&
// if the stream is done, empty and noChats is set we should call that !isTop &&
if (snapshot.connectionState == ConnectionState.done && controller.position.userScrollDirection ==
(snapshot.data?.isEmpty ?? true)) { ScrollDirection.reverse) {
if (widget.onNoChats != null && !_hasCalledOnNoChats) { setState(() {
_hasCalledOnNoChats = true; // Set the flag to true showIndicator = true;
WidgetsBinding.instance.addPostFrameCallback((_) { });
widget.onNoChats!.call(); getChats();
}); Future.delayed(const Duration(seconds: 2), () {
} if (mounted) {
} else { setState(() {
_hasCalledOnNoChats = showIndicator = false;
false; // Reset the flag if there are chats });
} }
return Column( });
children: [ }
for (ChatModel chat in snapshot.data ?? []) ...[ },
Builder( child: ListView(
builder: (context) => !(widget controller: controller,
.disableDismissForPermanentChats && physics: const AlwaysScrollableScrollPhysics(),
!chat.canBeDeleted) padding: const EdgeInsets.only(top: 15.0),
? Dismissible( children: [
confirmDismiss: (_) => StreamBuilder<List<ChatModel>>(
widget.deleteChatDialog stream: chats,
?.call(context, chat) ?? builder: (BuildContext context, snapshot) {
showModalBottomSheet( // if the stream is done, empty and noChats is set we should call that
context: context, if (snapshot.connectionState == ConnectionState.done &&
builder: (BuildContext context) => (snapshot.data?.isEmpty ?? true)) {
Container( if (widget.onNoChats != null && !_hasCalledOnNoChats) {
padding: const EdgeInsets.all(16.0), _hasCalledOnNoChats = true; // Set the flag to true
child: Column( WidgetsBinding.instance
mainAxisSize: MainAxisSize.min, .addPostFrameCallback((_) async {
children: [ await widget.onNoChats!.call();
Text( getChats();
chat.canBeDeleted });
? translations }
.deleteChatModalTitle } else {
: translations _hasCalledOnNoChats =
.chatCantBeDeleted, false; // Reset the flag if there are chats
style: const TextStyle( }
fontSize: 20, return Column(
fontWeight: FontWeight.bold, children: [
), for (ChatModel chat in (snapshot.data ?? []).where(
), (chat) => !deletedChats.contains(chat.id),
const SizedBox(height: 16), )) ...[
if (chat.canBeDeleted) 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( Text(
translations chat.canBeDeleted
.deleteChatModalDescription, ? translations
.deleteChatModalTitle
: translations
.chatCantBeDeleted,
style: const TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 20,
fontWeight:
FontWeight.bold,
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Row( if (chat.canBeDeleted)
mainAxisAlignment: Text(
MainAxisAlignment.center, translations
children: [ .deleteChatModalDescription,
TextButton( style: const TextStyle(
child: Text( fontSize: 16,
translations
.deleteChatModalCancel,
style: const TextStyle(
fontSize: 16,
),
), ),
onPressed: () =>
Navigator.of(context)
.pop(false),
), ),
if (chat.canBeDeleted) const SizedBox(height: 16),
ElevatedButton( Row(
onPressed: () => mainAxisAlignment:
Navigator.of( MainAxisAlignment
context, .center,
).pop(true), children: [
TextButton(
child: Text( child: Text(
translations translations
.deleteChatModalConfirm, .deleteChatModalCancel,
style: style:
const TextStyle( const TextStyle(
fontSize: 16, 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(
key: ValueKey( chat.id.toString(),
chat.id.toString(), ),
), child: ChatListItem(
child: ChatListItem( widget: widget,
chat: chat,
translations: translations,
dateFormatter: _dateFormatter,
),
)
: ChatListItem(
widget: widget, widget: widget,
chat: chat, chat: chat,
translations: translations, translations: translations,
dateFormatter: _dateFormatter, dateFormatter: _dateFormatter,
), ),
) ),
: ChatListItem( ],
widget: widget, if (showIndicator &&
chat: chat, snapshot.connectionState !=
translations: translations, ConnectionState.done) ...[
dateFormatter: _dateFormatter, const SizedBox(
), height: 10,
), ),
const CircularProgressIndicator(),
const SizedBox(
height: 10,
),
],
], ],
], );
); },
}, ),
), ],
], ),
), ),
), ),
if (widget.onPressStartChat != null) if (widget.onPressStartChat != null)
widget.options.newChatButtonBuilder( widget.options.newChatButtonBuilder(
context, context,
widget.onPressStartChat!, () async {
await widget.onPressStartChat!.call();
getChats();
},
translations, translations,
), ),
], ],
@ -237,48 +316,51 @@ class ChatListItem extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return GestureDetector(
onTap: () => widget.onPressChat(chat), onTap: () => widget.onPressChat(chat),
child: widget.options.chatRowContainerBuilder( child: Container(
(chat is PersonalChatModel) color: Colors.transparent,
? ChatRow( child: widget.options.chatRowContainerBuilder(
unreadMessages: chat.unreadMessages ?? 0, (chat is PersonalChatModel)
avatar: widget.options.userAvatarBuilder( ? ChatRow(
(chat as PersonalChatModel).user, unreadMessages: chat.unreadMessages ?? 0,
40.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,
),
), ),
); );
} }

View file

@ -122,7 +122,9 @@ class _NewChatScreenState extends State<NewChatScreen> {
title: user.fullName ?? widget.translations.anonymousUser, title: user.fullName ?? widget.translations.anonymousUser,
), ),
), ),
onTap: () => widget.onPressCreateChat(user), onTap: () async {
await widget.onPressCreateChat(user);
},
); );
}, },
); );

View file

@ -4,7 +4,7 @@
name: flutter_community_chat_view name: flutter_community_chat_view
description: A standard flutter package. description: A standard flutter package.
version: 0.6.0 version: 1.0.0
publish_to: none publish_to: none
@ -20,7 +20,7 @@ dependencies:
git: git:
url: https://github.com/Iconica-Development/flutter_community_chat url: https://github.com/Iconica-Development/flutter_community_chat
path: packages/flutter_community_chat_interface path: packages/flutter_community_chat_interface
ref: 0.6.0 ref: 1.0.0
cached_network_image: ^3.2.2 cached_network_image: ^3.2.2
flutter_image_picker: flutter_image_picker:
git: git: