mirror of
https://github.com/Iconica-Development/flutter_chat.git
synced 2025-05-20 11:23:50 +02:00
Compare commits
112 commits
Author | SHA1 | Date | |
---|---|---|---|
f7f15ef750 | |||
c155531b11 | |||
|
90610caabd | ||
|
e1e23e7b35 | ||
9b365d573d | |||
3b4b456db2 | |||
|
ad615133e4 | ||
f286e7fb79 | |||
3f1caa912b | |||
7d634e54c1 | |||
|
3fbcf5d076 | ||
84cc630c6e | |||
|
3cec2ee1c6 | ||
|
b8e22425a1 | ||
|
61b588cfd5 | ||
|
02ae2aa884 | ||
|
d2f000c8a7 | ||
52562746b6 | |||
a48806fe98 | |||
bcf2c0484b | |||
4e0967cc33 | |||
|
c63efadd2c | ||
|
ad4cf1e37b | ||
|
d14ad4716a | ||
|
c1e20e84ff | ||
5126b8dab7 | |||
|
371ff6c335 | ||
|
b3b8b1828e | ||
|
30fc7b4368 | ||
6ecf073f15 | |||
d3f839dc94 | |||
|
ba818158fb | ||
|
c1181d4e84 | ||
|
b0b4121a25 | ||
|
313ead2029 | ||
|
5975e2f1c0 | ||
|
a1fc65aba2 | ||
|
11d8c81161 | ||
|
590a339d0d | ||
|
2508789a6d | ||
|
b0d379284d | ||
|
62f04e2d9b | ||
|
6a63429efd | ||
|
1a7b4a2cda | ||
|
c82df25aed | ||
|
23f61dd5ff | ||
e7bb4909ba | |||
|
02ae851d13 | ||
|
7a0fd49070 | ||
|
70eeb816e2 | ||
|
f57ba9a736 | ||
|
ab8a9d9e6f | ||
|
e1ca5aab71 | ||
|
8112e939e1 | ||
|
7c80341ff5 | ||
|
5d29e733aa | ||
|
d475cf7298 | ||
|
77d6f7257e | ||
|
990a89199b | ||
|
281188c2b7 | ||
|
8604ccada7 | ||
|
a9b52ef5d9 | ||
|
a9eb1a8df4 | ||
|
b1909689f2 | ||
|
4ec7da429e | ||
|
ff28f91524 | ||
|
d66942893f | ||
|
15604bf264 | ||
|
4c48cf8cd4 | ||
|
55be653975 | ||
|
ed72545cc4 | ||
|
070a4d5adc | ||
|
22884ea395 | ||
|
3e03dd755e | ||
|
7457602afe | ||
|
1ea2887e27 | ||
|
91420fde78 | ||
d5b7183df5 | |||
bd14f5cd6d | |||
|
b6fc7b2cb0 | ||
|
4ee5445809 | ||
|
ed95dbd15c | ||
|
f8bffceb4b | ||
61de7ae44a | |||
1f3dc09f44 | |||
ec89961e07 | |||
|
44579ca306 | ||
|
8f13d87a23 | ||
|
644615f026 | ||
|
9be096154f | ||
|
f5040d5809 | ||
c7fe7152b3 | |||
2564c74066 | |||
|
15f15748b6 | ||
|
2f2e2be6dd | ||
|
d46c83e847 | ||
|
d13a8013ac | ||
|
8b4ada7edc | ||
|
61d901c741 | ||
|
5e4a9c7ab4 | ||
|
1141aea83c | ||
|
5464766747 | ||
7cfd8087a1 | |||
|
3d3153d2ce | ||
|
c9a11758d8 | ||
|
82448ab9e0 | ||
|
efd6fc138c | ||
|
1eb5f99b7b | ||
|
146ec3a1a9 | ||
|
b5656d5f3a | ||
|
86c50f47f6 | ||
|
06167d202e |
135 changed files with 8362 additions and 5662 deletions
3
.fvmrc
Normal file
3
.fvmrc
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"flutter": "3.24.3"
|
||||
}
|
3
.github/workflows/melos-ci.yml
vendored
3
.github/workflows/melos-ci.yml
vendored
|
@ -11,4 +11,5 @@ jobs:
|
|||
secrets: inherit
|
||||
permissions: write-all
|
||||
with:
|
||||
subfolder: '.' # add optional subfolder to run workflow in
|
||||
subfolder: '.' # add optional subfolder to run workflow in
|
||||
flutter_version: 3.24.3
|
||||
|
|
15
.github/workflows/release.yml
vendored
Normal file
15
.github/workflows/release.yml
vendored
Normal file
|
@ -0,0 +1,15 @@
|
|||
name: Iconica Standard Component Release Workflow
|
||||
# Workflow Caller version: 1.0.0
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
call-global-iconica-workflow:
|
||||
uses: Iconica-Development/.github/.github/workflows/component-release.yml@master
|
||||
secrets: inherit
|
||||
permissions: write-all
|
||||
|
15
.gitignore
vendored
15
.gitignore
vendored
|
@ -39,8 +39,17 @@ build/
|
|||
|
||||
pubspec.lock
|
||||
packages/flutter_chat/pubspec.lock
|
||||
packages/flutter_chat_firebase/pubspec.lock
|
||||
packages/flutter_chat_interface/pubspec.lock
|
||||
packages/flutter_chat_view/pubspec.lock
|
||||
packages/firebase_chat_repository/pubspec.lock
|
||||
packages/chat_repository_interface/pubspec.lock
|
||||
|
||||
android
|
||||
linux
|
||||
macos
|
||||
web
|
||||
windows
|
||||
|
||||
pubspec_overrides.yaml
|
||||
|
||||
# FVM Version Cache
|
||||
.fvm/
|
||||
.fvmrc
|
||||
|
|
84
CHANGELOG.md
84
CHANGELOG.md
|
@ -1,3 +1,87 @@
|
|||
## 6.0.0
|
||||
- Added pending message repository to temporarily store messages that are not yet received by the backend
|
||||
- Added pending message icons next to time on default messages
|
||||
- Added pending image uploading by base64encoding the data and putting it in the image url
|
||||
- Added image pre-loading to handle error and loading states
|
||||
- Added reload button in case of an image loading error
|
||||
- Added messageStatus field to MessageModel to differentiate between sent and pending messages
|
||||
|
||||
## 5.1.2
|
||||
- Added correct padding inbetween time indicators and names
|
||||
- Show names if a new day occurs and an indicator is shown
|
||||
|
||||
## 5.1.1
|
||||
- Expose default indicator builder from the indicator options
|
||||
|
||||
## 5.1.0
|
||||
- Added optional time indicator in chat detail screens to show which day the message is posted
|
||||
|
||||
## 5.0.0
|
||||
- Removed the default values for the ChatOptions that are now nullable so they resolve to the ThemeData values
|
||||
- Added chatAlignment to change the alignment of the chat messages
|
||||
- Added messageType to the ChatMessageModel to allow for different type of messages, it is nullable to remain backwards compatible
|
||||
- Get the color for the imagepicker from the Theme's primaryColor
|
||||
- Added chatMessageBuilder to the userstory configuration to customize the chat messages
|
||||
- Update the default chat message builder to a new design
|
||||
- Added ChatScope that can be used to get the ChatService and ChatTranslations from the context. If you use individual components instead of the userstory you need to wrap them with the ChatScope. The options and service will be removed from all the component constructors.
|
||||
- Added getAllUsersForChat to UserRepositoryInterface for fetching all users for a chat
|
||||
- Added flutter_hooks as a dependency for easier state management
|
||||
- Added FlutterChatDetailNavigatorUserstory that can be used to start the userstory from the chat detail screen without having the chat overview screen
|
||||
- Changed the ChatDetailScreen to use the chatId instead of the ChatModel, the screen will now fetch the chat from the ChatService
|
||||
- Changed baseScreenBuilder to include a chatTitle that can be used to show provide the title logic to apps that use the baseScreenBuilder
|
||||
- Added loadNewMessagesAfter, loadOldMessagesBefore and removed pagination from getMessages in the ChatRepositoryInterface to change pagination behavior to rely on the stream and two methods indicating that more messages should be added to the stream
|
||||
- Added chatTitleResolver that can be used to resolve the chat title from the chat model or return null to allow for default behavior
|
||||
- Added ChatPaginationControls to the ChatOptions to allow for more control over the pagination
|
||||
- Fixed that chat message is automatically sent when the user presses enter on the keyboard in the chat input
|
||||
- Added sender and chatId to uploadImage in the ChatRepositoryInterface
|
||||
- Added imagePickerBuilder to the builders in the ChatOptions to override the image picker with a custom implementation that needs to return a Future<Uint8List?>
|
||||
- Changed the ChatBottomInputSection to be multiline and go from 45px to 120px in height depending on how many lines are in the textfield
|
||||
- Added chatScreenBuilder to the userstory configuration to customize the specific chat screen with a ChatModel as argument
|
||||
- Added senderTitleResolver to the ChatOptions to resolve the title of the sender in the chat message
|
||||
- Added imageProviderResolver to the ChatOptions to resolve ImageProvider for all images in the userstory
|
||||
- Added enabled boolean to the messageInputBuilder and made parameters named
|
||||
- Added autoScrollTriggerOffset to the ChatPaginationControls to adjust when the auto scroll should be enabled
|
||||
- Added the ability to set the color of the CircularProgressIndicator of the ImageLoadingSnackbar by theme.snackBarTheme.actionTextColor
|
||||
- Added semantics for variable text, buttons and textfields
|
||||
- Added flag to enable/disable loading new and old messages on scrolling to the end of the current view.
|
||||
- Updated description of packages to be more descriptive
|
||||
|
||||
## 4.0.0
|
||||
- Move to the new user story architecture
|
||||
|
||||
## 3.1.0
|
||||
- Fix center the texts for no users found with search and type first message
|
||||
- Fix styling for the whole userstory
|
||||
- Add groupchat profile picture, and bio to the groupchat creation screen
|
||||
- Updated profile of users and groups
|
||||
|
||||
|
||||
## 3.0.1
|
||||
|
||||
- fix bug where you could make multiple groups quickly by routing back to the previous screen
|
||||
- fix bug where you would route back to the user selection screen insterad of routing back to the chat overview screen
|
||||
- Add onPopInvoked callback to the userstory to add custom behaviour for the back button on the chatscreen
|
||||
- Handle overflows for users with a long name.
|
||||
- Remove the scaffold backgrounds because they should be inherited from the scaffold theme
|
||||
|
||||
## 3.0.0
|
||||
|
||||
- Add theming
|
||||
- add validator for group name
|
||||
- fix spamming buttons
|
||||
- fix user list flickering on the group creation screen
|
||||
|
||||
## 2.0.0
|
||||
|
||||
- Add a serviceBuilder to the userstory configuration
|
||||
- Add a translationsBuilder to the userstory configuration
|
||||
- Change onPressUserProfile callback to use a ChatUserModel instead of a String
|
||||
- Add a enableGroupChatCreation boolean to the userstory configuration to enable or disable group chat creation
|
||||
- Change the ChatTranslations constructor to require all translations or use the ChatTranslations.empty constructor if you don't want to specify all translations
|
||||
- Remove the Divider between the users on the new chat screen
|
||||
- Add option to set a custom padding around the list of chats
|
||||
- Fix nullpointer when firstWhere returns null because there is only 1 person in a groupchat
|
||||
|
||||
## 1.4.3
|
||||
|
||||
- Added default styling.
|
||||
|
|
2
LICENSE
2
LICENSE
|
@ -1,4 +1,4 @@
|
|||
Copyright (c) 2022 Iconica, All rights reserved.
|
||||
Copyright (c) 2024 Iconica, All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
|
||||
|
|
113
README.md
113
README.md
|
@ -1,17 +1,20 @@
|
|||
# Flutter Chat
|
||||
|
||||
Flutter 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 `ChatInterface` interface from the `flutter_chat_interface` package.
|
||||
Flutter Chat is a package which gives the possibility to add a (personal or group) chat to your Flutter-application.
|
||||
By default this package adds support for a Firebase back-end but you can also add a custom back-end (like a Websocket-API) by extending the `ChatInterface` interface from the `flutter_chat_interface` package.
|
||||
|
||||

|
||||
|
||||
Figma Design that defines this component (only accessible for Iconica developers): https://www.figma.com/file/4WkjwynOz5wFeFBRqTHPeP/Iconica-Design-System?type=design&node-id=357%3A3342&mode=design&t=XulkAJNPQ32ARxWh-1
|
||||
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
|
||||
The default UI is based on a Figma design that defines this component, however it's only accessible by Iconica developers:
|
||||
[Figma design](https://www.figma.com/file/4WkjwynOz5wFeFBRqTHPeP/Iconica-Design-System?type=design&node-id=357%3A3342&mode=design&t=XulkAJNPQ32ARxWh-1).
|
||||
There is also a Figma clickable prototype that demonstrates this component:
|
||||
[Figma clickable prototype)[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_chat as a dependency in your pubspec.yaml file:
|
||||
To use this package, add flutter_chat as a dependency in your `pubspec.yaml` file:
|
||||
|
||||
```
|
||||
```yaml
|
||||
flutter_chat:
|
||||
git:
|
||||
url: https://github.com/Iconica-Development/flutter_chat
|
||||
|
@ -20,28 +23,28 @@ To use this package, add flutter_chat as a dependency in your pubspec.yaml file:
|
|||
|
||||
You can use the `LocalChatService` to test the package in your project:
|
||||
|
||||
```
|
||||
```dart
|
||||
ChatUserStoryConfiguration(
|
||||
chatService: LocalChatService(),
|
||||
),
|
||||
```
|
||||
|
||||
If you are going to use Firebase as the back-end of the Chat, you should also add the following package as a dependency to your pubspec.yaml file:
|
||||
If you are going to use Firebase as the back-end of the Chat, you should also add the following package as a dependency to your `pubspec.yaml` file:
|
||||
|
||||
```
|
||||
```yaml
|
||||
flutter_chat_firebase:
|
||||
git:
|
||||
url: https://github.com/Iconica-Development/flutter_chat
|
||||
path: packages/flutter_chat_firebase
|
||||
```
|
||||
|
||||
Create a Firebase project for your application and add firebase firestore and storage.
|
||||
Create a Firebase project for your application and add Firebase Firestore and Storage.
|
||||
|
||||
make sure you are authenticated using the `Firebase_auth` package or adjust your firebase rules, otherwise you won't be able to retreive data.
|
||||
Make sure you are authenticated using the `firebase_auth` package or adjust your firebase rules, otherwise you won't be able to retreive data.
|
||||
|
||||
Also make sure you have the corresponding collections in your firebase project as defined in `FirebaseChatOptions`, you can override the
|
||||
Also make sure you have the corresponding collections in your Firebase project as defined in `FirebaseChatOptions`, you can override the
|
||||
default paths as you wish:
|
||||
```
|
||||
```dart
|
||||
const FirebaseChatOptions({
|
||||
this.groupChatsCollectionName = 'group_chats',
|
||||
this.chatsCollectionName = 'chats',
|
||||
|
@ -51,9 +54,10 @@ default paths as you wish:
|
|||
this.userChatsCollectionName = 'chats',
|
||||
});
|
||||
```
|
||||
Also the structure of your data should be equal to our predefined models, you can implement any model by making your own model and implementing one of the predefined interfaces like so:
|
||||
|
||||
```
|
||||
Also the structure of your data should be equal to our predefined models, you can implement any model by making your own model and implementing one of the predefined interfaces like so:
|
||||
|
||||
```dart
|
||||
class ChatMessageModel implements ChatMessageModelInterface {
|
||||
ChatMessageModel({
|
||||
required this.sender,
|
||||
|
@ -62,47 +66,47 @@ class ChatMessageModel implements ChatMessageModelInterface {
|
|||
|
||||
@override
|
||||
final ChatUserModel sender;
|
||||
|
||||
@override
|
||||
final DateTime timestamp;
|
||||
}
|
||||
```
|
||||
|
||||
below a list of interfaces you can implement;
|
||||
The various interfaces you can implement:
|
||||
|
||||
`ChatUserModelInterface`,
|
||||
`ChatImageMessageModelInterface`,
|
||||
`ChatTextMessageModelInterface`
|
||||
`ChatMessageModelInterface`,
|
||||
`ChatModelInterface`,
|
||||
`GroupChatModelInterface`,
|
||||
`PersonalChatModelInterface`,
|
||||
- `ChatUserModelInterface`,
|
||||
- `ChatImageMessageModelInterface`,
|
||||
- `ChatTextMessageModelInterface`
|
||||
- `ChatMessageModelInterface`,
|
||||
- `ChatModelInterface`,
|
||||
- `GroupChatModelInterface`,
|
||||
- `PersonalChatModelInterface`,
|
||||
|
||||
To use the camera or photo library to send photos add the following to your project:
|
||||
Add the following to your project to use the camera or photo library to send pictures:
|
||||
|
||||
For ios add the following lines to your info.plist:
|
||||
|
||||
```
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Access camera</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Library</string>
|
||||
```plist
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Access camera</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Library</string>
|
||||
```
|
||||
|
||||
For android add the following lines to your AndroidManifest.xml:
|
||||
|
||||
```
|
||||
```xml
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.GALLERY"/>
|
||||
```
|
||||
|
||||
## How to use
|
||||
|
||||
To use the module within your Flutter-application with predefined `Go_router` routes you should add the following:
|
||||
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:
|
||||
Add go_router as dependency to your project, then add the following configuration to your flutter_application:
|
||||
|
||||
```
|
||||
```dart
|
||||
List<GoRoute> getChatRoutes() => getChatStoryRoutes(
|
||||
ChatUserStoryConfiguration(
|
||||
chatService: chatService,
|
||||
|
@ -115,7 +119,7 @@ You can override any method in the `ChatUserStoryConfiguration`.
|
|||
|
||||
Add the `getChatRoutes()` to your go_router routes like so:
|
||||
|
||||
```
|
||||
```dart
|
||||
final GoRouter _router = GoRouter(
|
||||
routes: <RouteBase>[
|
||||
GoRoute(
|
||||
|
@ -135,20 +139,20 @@ The routes that can be used to navigate are:
|
|||
|
||||
For routing to the `ChatScreen`:
|
||||
|
||||
```
|
||||
```dart
|
||||
static const String chatScreen = '/chat';
|
||||
```
|
||||
|
||||
For routing to the `ChatDetailScreen`:
|
||||
|
||||
```
|
||||
```dart
|
||||
static String chatDetailViewPath(String chatId) => '/chat-detail/$chatId';
|
||||
static const String chatDetailScreen = '/chat-detail/:id';
|
||||
```
|
||||
|
||||
For routing to the `NewChatScreen`:
|
||||
|
||||
```
|
||||
```dart
|
||||
static const String newChatScreen = '/new-chat';
|
||||
```
|
||||
|
||||
|
@ -156,15 +160,15 @@ For routing to the `ChatProfileScreen`:
|
|||
you can see the information about a person or group you started a chat with.
|
||||
If the userId is null a group profile screen will be shown otherwise the profile of a single person will be shown.
|
||||
|
||||
```
|
||||
```dart
|
||||
static String chatProfileScreenPath(String chatId, String? userId) =>
|
||||
'/chat-profile/$chatId/$userId';
|
||||
static const String chatProfileScreen = '/chat-profile/:id/:userId';
|
||||
```
|
||||
|
||||
To use the module within your Flutter-application without predefined `Go_router` routes but with Navigator routes add the following code to the build-method of a chosen widget:
|
||||
Add the following code to the build-method of a chosen widget to use the module within your Flutter-application without predefined `go_router` routes but with Navigator routes:
|
||||
|
||||
```
|
||||
```dart
|
||||
chatNavigatorUserStory(
|
||||
ChatUserStoryConfiguration(
|
||||
chatService: ChatService,
|
||||
|
@ -174,12 +178,13 @@ chatNavigatorUserStory(
|
|||
);
|
||||
```
|
||||
|
||||
Just like with the `Go_router` routes you can override any methods in the `ChatUserStoryConfiguration`.
|
||||
Just like with the `go_router` routes you can override any methods in the `ChatUserStoryConfiguration`.
|
||||
|
||||
Or create your own routing using the Screens:
|
||||
To add the `ChatScreen` add the following code:
|
||||
Or create your own routing using the screens.
|
||||
|
||||
```
|
||||
Add the following code to add the `ChatScreen`:
|
||||
|
||||
```dart
|
||||
ChatScreen(
|
||||
options: options,
|
||||
onPressStartChat: onPressStartChat,
|
||||
|
@ -193,7 +198,7 @@ ChatScreen(
|
|||
The `ChatDetailScreen` shows the messages that are in the current chat you selected.
|
||||
To add the `ChatDetailScreen` add the following code:
|
||||
|
||||
```
|
||||
```dart
|
||||
ChatDetailScreen(
|
||||
options: options,
|
||||
onMessageSubmit: onMessageSubmit,
|
||||
|
@ -204,9 +209,10 @@ ChatDetailScreen(
|
|||
```
|
||||
|
||||
On the `NewChatScreen` you can select a person to chat.
|
||||
To add the `NewChatScreen` add the following code:
|
||||
|
||||
```
|
||||
Add the following coe to add the `NewChatScreen`:
|
||||
|
||||
```dart
|
||||
NewChatScreen(
|
||||
options: options,
|
||||
onPressCreateChat: onPressCreateChat,
|
||||
|
@ -215,9 +221,9 @@ NewChatScreen(
|
|||
```
|
||||
|
||||
On the `ChatProfileScreen` you can see the information about a person or group you started a chat with.
|
||||
If the userId is null a group profile screen will be shown otherwise the profile of a single person will be shown.
|
||||
A group profile screen will be shown if the userId is null, otherwise the profile of a single person will be shown.
|
||||
|
||||
```
|
||||
```dart
|
||||
ChatProfileScreen(
|
||||
chatService: chatservice,
|
||||
chatId: chatId,
|
||||
|
@ -227,13 +233,12 @@ ChatProfileScreen(
|
|||
);
|
||||
```
|
||||
|
||||
The `ChatEntryWidget` is a widget you can put anywhere in your app.
|
||||
It displays the amount of unread messages you currently have.
|
||||
You can choose to add a onTap to the `ChatEntryWidget` so it routes to the `ChatScreen`.
|
||||
The `ChatEntryWidget` is a widget you can put anywhere in your app, it displays the amount of unread messages you currently have.
|
||||
You can choose to add an `onTap` to the `ChatEntryWidget` so it routes to the `ChatScreen`.
|
||||
|
||||
To add the `ChatEntryWidget` add the follwoing code:
|
||||
Add the following code to add the `ChatEntryWidget`:
|
||||
|
||||
```
|
||||
```dart
|
||||
ChatEntryWidget(
|
||||
chatService: chatService,
|
||||
onTap: onTap,
|
||||
|
|
29
packages/chat_repository_interface/.gitignore
vendored
Normal file
29
packages/chat_repository_interface/.gitignore
vendored
Normal file
|
@ -0,0 +1,29 @@
|
|||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||
/pubspec.lock
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
build/
|
1
packages/chat_repository_interface/CHANGELOG.md
Symbolic link
1
packages/chat_repository_interface/CHANGELOG.md
Symbolic link
|
@ -0,0 +1 @@
|
|||
../../CHANGELOG.md
|
1
packages/chat_repository_interface/LICENSE
Symbolic link
1
packages/chat_repository_interface/LICENSE
Symbolic link
|
@ -0,0 +1 @@
|
|||
../../LICENSE
|
|
@ -1,4 +1,4 @@
|
|||
include: package:flutter_iconica_analysis/analysis_options.yaml
|
||||
include: package:flutter_iconica_analysis/components_options.yaml
|
||||
|
||||
# Possible to overwrite the rules from the package
|
||||
|
||||
|
@ -6,4 +6,4 @@ analyzer:
|
|||
exclude:
|
||||
|
||||
linter:
|
||||
rules:
|
||||
rules:
|
|
@ -0,0 +1,16 @@
|
|||
// exceptions
|
||||
export "src/exceptions/chat.dart";
|
||||
// Interfaces
|
||||
export "src/interfaces/chat_repostory_interface.dart";
|
||||
export "src/interfaces/pending_message_repository_interface.dart";
|
||||
export "src/interfaces/user_repository_interface.dart";
|
||||
// Local implementations
|
||||
export "src/local/local_chat_repository.dart";
|
||||
export "src/local/local_pending_message_repository.dart";
|
||||
export "src/local/local_user_repository.dart";
|
||||
// Models
|
||||
export "src/models/chat_model.dart";
|
||||
export "src/models/message_model.dart";
|
||||
export "src/models/user_model.dart";
|
||||
// Services
|
||||
export "src/services/chat_service.dart";
|
|
@ -0,0 +1,23 @@
|
|||
/// An exception that is used to indicate the failure to load a chat for given
|
||||
/// [chatId]
|
||||
class ChatNotFoundException implements Exception {
|
||||
/// Create an instance of the chat not found exception
|
||||
const ChatNotFoundException({
|
||||
required this.chatId,
|
||||
});
|
||||
|
||||
/// The chat that was attempted to load, but never found.
|
||||
final String chatId;
|
||||
}
|
||||
|
||||
/// An exception that is used to indicate the failure to load the messages for a
|
||||
/// given [chatId]
|
||||
class ChatMessagesNotFoundException implements Exception {
|
||||
/// Create an instance of the chatmessages not found exception
|
||||
const ChatMessagesNotFoundException({
|
||||
required this.chatId,
|
||||
});
|
||||
|
||||
/// The chat for which messages were attempted to load, but never found.
|
||||
final String chatId;
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import "dart:convert";
|
||||
import "dart:typed_data";
|
||||
import "package:mime/mime.dart";
|
||||
|
||||
/// Error thrown when there is no
|
||||
/// mimetype found
|
||||
class MimetypeMissingError extends Error {
|
||||
@override
|
||||
String toString() => "You can only provide files that contain a mimetype";
|
||||
}
|
||||
|
||||
/// Extension that provides a converter function from
|
||||
/// Uin8List to a base64Encoded data uri.
|
||||
extension ToDataUri on Uint8List {
|
||||
/// This function converts the Uint8List into
|
||||
/// a uri with a data-scheme.
|
||||
String toDataUri() {
|
||||
var mimeType = lookupMimeType("", headerBytes: this);
|
||||
if (mimeType == null) throw MimetypeMissingError();
|
||||
|
||||
var base64Data = base64Encode(this);
|
||||
|
||||
return "data:$mimeType;base64,$base64Data";
|
||||
}
|
||||
}
|
|
@ -0,0 +1,133 @@
|
|||
import "dart:typed_data";
|
||||
|
||||
import "package:chat_repository_interface/src/models/chat_model.dart";
|
||||
import "package:chat_repository_interface/src/models/message_model.dart";
|
||||
import "package:chat_repository_interface/src/models/user_model.dart";
|
||||
|
||||
/// The chat repository interface
|
||||
/// Implement this interface to create a chat
|
||||
/// repository with a given data source.
|
||||
abstract class ChatRepositoryInterface {
|
||||
/// Create a chat with the given parameters.
|
||||
/// [users] is a list of [UserModel] that will be part of the chat.
|
||||
/// [chatName] is the name of the chat.
|
||||
/// [description] is the description of the chat.
|
||||
/// [imageUrl] is the image url of the chat.
|
||||
/// [messages] is a list of [MessageModel] that will be part of the chat.
|
||||
Future<void> createChat({
|
||||
required List<String> users,
|
||||
required bool isGroupChat,
|
||||
String? chatName,
|
||||
String? description,
|
||||
String? imageUrl,
|
||||
List<MessageModel>? messages,
|
||||
});
|
||||
|
||||
/// Update the chat with the given parameters.
|
||||
/// [chat] is the chat that will be updated.
|
||||
Future<void> updateChat({
|
||||
required ChatModel chat,
|
||||
});
|
||||
|
||||
/// Get the chat with the given [chatId].
|
||||
/// Returns a [ChatModel] stream.
|
||||
Stream<ChatModel> getChat({
|
||||
required String chatId,
|
||||
});
|
||||
|
||||
/// Get the chats for the given [userId].
|
||||
/// Returns a list of [ChatModel] stream.
|
||||
Stream<List<ChatModel>?> getChats({
|
||||
required String userId,
|
||||
});
|
||||
|
||||
/// Get the messages for the given [chatId].
|
||||
/// Returns a list of [MessageModel] stream.
|
||||
/// [userId] is the user id.
|
||||
/// [chatId] is the chat id.
|
||||
/// Returns a list of [MessageModel] stream.
|
||||
Stream<List<MessageModel>?> getMessages({
|
||||
required String chatId,
|
||||
required String userId,
|
||||
});
|
||||
|
||||
/// Get the message with the given [messageId].
|
||||
/// [chatId] is the chat id.
|
||||
/// Returns a [MessageModel] stream.
|
||||
Stream<MessageModel?> getMessage({
|
||||
required String chatId,
|
||||
required String messageId,
|
||||
});
|
||||
|
||||
/// Signals that new messages should be loaded after the given message.
|
||||
/// The stream should emit the new messages.
|
||||
Future<void> loadNewMessagesAfter({
|
||||
required String userId,
|
||||
required MessageModel lastMessage,
|
||||
});
|
||||
|
||||
/// Signals that old messages should be loaded before the given message.
|
||||
/// The stream should emit the new messages.
|
||||
Future<void> loadOldMessagesBefore({
|
||||
required String userId,
|
||||
required MessageModel firstMessage,
|
||||
});
|
||||
|
||||
/// Retrieve the next unused message id given a current chat.
|
||||
///
|
||||
/// The resulting string should be at least unique per [chatId]. The userId
|
||||
/// is provided in case the specific user has influence on the id.
|
||||
///
|
||||
/// Imagine returning a UUID, the next integer in a counter or the document
|
||||
/// id in firebase.
|
||||
Future<String> getNextMessageId({
|
||||
required String userId,
|
||||
required String chatId,
|
||||
});
|
||||
|
||||
/// Send a message with the given parameters.
|
||||
///
|
||||
/// [chatId] is the chat id.
|
||||
/// [senderId] is the sender id.
|
||||
/// [messageId] is the identifier for this message
|
||||
/// [text] is the message text.
|
||||
/// [imageUrl] is the image url.
|
||||
/// [messageType] is a way to identify a difference in messages
|
||||
/// [timestamp] is the moment of sending.
|
||||
Future<void> sendMessage({
|
||||
required String chatId,
|
||||
required String senderId,
|
||||
required String messageId,
|
||||
String? text,
|
||||
String? imageUrl,
|
||||
String? messageType,
|
||||
DateTime? timestamp,
|
||||
});
|
||||
|
||||
/// Delete the chat with the given [chatId].
|
||||
Future<void> deleteChat({
|
||||
required String chatId,
|
||||
});
|
||||
|
||||
/// Get the unread messages count for the given [userId].
|
||||
/// [chatId] is the chat id. If not provided, it will return the
|
||||
/// total unread messages count.
|
||||
/// Returns an integer stream.
|
||||
Stream<int> getUnreadMessagesCount({
|
||||
required String userId,
|
||||
String? chatId,
|
||||
});
|
||||
|
||||
/// Upload an image with the given parameters.
|
||||
/// [path] is the path of the image.
|
||||
/// [image] is the image data.
|
||||
/// [senderId] is the sender id.
|
||||
/// [chatId] is the chat id.
|
||||
/// Returns the image url.
|
||||
Future<String> uploadImage({
|
||||
required String path,
|
||||
required String senderId,
|
||||
required String chatId,
|
||||
required Uint8List image,
|
||||
});
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
import "package:chat_repository_interface/src/models/message_model.dart";
|
||||
|
||||
/// The pending chat messages repository interface
|
||||
/// Implement this interface to create a pending chat
|
||||
/// messages repository with a given data source.
|
||||
abstract class PendingMessageRepositoryInterface {
|
||||
/// Get the messages for the given [chatId].
|
||||
/// Returns a list of [MessageModel] stream.
|
||||
/// [userId] is the user id.
|
||||
/// [chatId] is the chat id.
|
||||
/// Returns a list of [MessageModel] stream.
|
||||
Stream<List<MessageModel>> getMessages({
|
||||
required String chatId,
|
||||
required String userId,
|
||||
});
|
||||
|
||||
/// Create a message in the pending messages and return the created message.
|
||||
///
|
||||
/// [chatId] is the chat id.
|
||||
/// [senderId] is the sender id.
|
||||
/// [messageId] is the identifier for this message
|
||||
/// [text] is the message text.
|
||||
/// [imageUrl] is the image url.
|
||||
/// [messageType] is a way to identify a difference in messages
|
||||
/// [timestamp] is the moment of sending.
|
||||
Future<MessageModel> createMessage({
|
||||
required String chatId,
|
||||
required String senderId,
|
||||
required String messageId,
|
||||
String? text,
|
||||
String? imageUrl,
|
||||
String? messageType,
|
||||
DateTime? timestamp,
|
||||
});
|
||||
|
||||
/// Mark a message as being succesfully sent to the server,
|
||||
/// so that it can be removed from this data source.
|
||||
Future<void> markMessageSent({
|
||||
required String chatId,
|
||||
required String messageId,
|
||||
});
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
import "package:chat_repository_interface/src/models/user_model.dart";
|
||||
|
||||
/// The user repository interface
|
||||
/// Implement this interface to create a user
|
||||
/// repository with a given data source.
|
||||
abstract class UserRepositoryInterface {
|
||||
/// Get the user with the given [userId].
|
||||
/// Returns a [UserModel] stream.
|
||||
Stream<UserModel> getUser({required String userId});
|
||||
|
||||
/// Get all the users.
|
||||
/// Returns a list of [UserModel] stream.
|
||||
Stream<List<UserModel>> getAllUsers();
|
||||
|
||||
/// Get all the users for the given [chatId].
|
||||
/// Returns a list of [UserModel] stream.
|
||||
Stream<List<UserModel>> getAllUsersForChat({required String chatId});
|
||||
}
|
|
@ -0,0 +1,290 @@
|
|||
import "dart:async";
|
||||
import "dart:math" as math;
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:chat_repository_interface/chat_repository_interface.dart";
|
||||
import "package:chat_repository_interface/src/extension/uint8list_data_uri.dart";
|
||||
import "package:chat_repository_interface/src/local/local_memory_db.dart";
|
||||
import "package:collection/collection.dart";
|
||||
import "package:rxdart/rxdart.dart";
|
||||
|
||||
/// The local chat repository
|
||||
class LocalChatRepository implements ChatRepositoryInterface {
|
||||
/// The local chat repository constructor
|
||||
LocalChatRepository();
|
||||
|
||||
final StreamController<List<ChatModel>> _chatsController =
|
||||
BehaviorSubject<List<ChatModel>>();
|
||||
|
||||
final StreamController<ChatModel> _chatController =
|
||||
BehaviorSubject<ChatModel>();
|
||||
|
||||
final StreamController<List<MessageModel>> _messageController =
|
||||
BehaviorSubject<List<MessageModel>>();
|
||||
|
||||
final Map<String, int> _startIndexMap = {};
|
||||
final Map<String, int> _endIndexMap = {};
|
||||
|
||||
@override
|
||||
Future<void> createChat({
|
||||
required List<String> users,
|
||||
required bool isGroupChat,
|
||||
String? chatName,
|
||||
String? description,
|
||||
String? imageUrl,
|
||||
List<MessageModel>? messages,
|
||||
}) async {
|
||||
var chat = ChatModel(
|
||||
id: DateTime.now().toString(),
|
||||
isGroupChat: isGroupChat,
|
||||
users: users,
|
||||
chatName: chatName,
|
||||
description: description,
|
||||
imageUrl: imageUrl,
|
||||
);
|
||||
|
||||
chats.add(chat);
|
||||
_chatsController.add(chats);
|
||||
|
||||
if (messages != null) {
|
||||
for (var message in messages) {
|
||||
await sendMessage(
|
||||
messageId: message.id,
|
||||
chatId: chat.id,
|
||||
senderId: message.senderId,
|
||||
text: message.text,
|
||||
messageType: message.messageType,
|
||||
imageUrl: message.imageUrl,
|
||||
timestamp: message.timestamp,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateChat({
|
||||
required ChatModel chat,
|
||||
}) async {
|
||||
var index = chats.indexWhere((e) => e.id == chat.id);
|
||||
|
||||
if (index != -1) {
|
||||
chats[index] = chat;
|
||||
_chatsController.add(chats);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteChat({
|
||||
required String chatId,
|
||||
}) async {
|
||||
try {
|
||||
chats.removeWhere((e) => e.id == chatId);
|
||||
_chatsController.add(chats);
|
||||
} on Exception catch (_) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<ChatModel> getChat({
|
||||
required String chatId,
|
||||
}) {
|
||||
var chat = chats.firstWhereOrNull((e) => e.id == chatId);
|
||||
|
||||
if (chat != null) {
|
||||
_chatController.add(chat);
|
||||
|
||||
if (chat.imageUrl?.isNotEmpty ?? false) {
|
||||
chat.copyWith(imageUrl: "https://picsum.photos/200/300");
|
||||
}
|
||||
}
|
||||
|
||||
return _chatController.stream;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<ChatModel>?> getChats({
|
||||
required String userId,
|
||||
}) {
|
||||
_chatsController.add(chats);
|
||||
|
||||
return _chatsController.stream;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<MessageModel>?> getMessages({
|
||||
required String chatId,
|
||||
required String userId,
|
||||
}) {
|
||||
var foundChat =
|
||||
chats.firstWhereOrNull((chatModel) => chatModel.id == chatId);
|
||||
|
||||
if (foundChat == null) {
|
||||
_messageController.add([]);
|
||||
} else {
|
||||
var allMessages = List<MessageModel>.from(
|
||||
chatMessages[chatId] ?? [],
|
||||
);
|
||||
allMessages.sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
||||
|
||||
_startIndexMap[chatId] ??= math.max(0, allMessages.length - chunkSize);
|
||||
_endIndexMap[chatId] ??= allMessages.length;
|
||||
|
||||
var displayedMessages = allMessages.sublist(
|
||||
_startIndexMap[chatId]!,
|
||||
_endIndexMap[chatId],
|
||||
);
|
||||
_messageController.add(displayedMessages);
|
||||
}
|
||||
|
||||
return _messageController.stream;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> loadNewMessagesAfter({
|
||||
required String userId,
|
||||
required MessageModel lastMessage,
|
||||
}) async {
|
||||
var allMessages = List<MessageModel>.from(
|
||||
chatMessages[lastMessage.chatId] ?? [],
|
||||
)..sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
||||
|
||||
var lastMessageIndex = allMessages
|
||||
.indexWhere((messageModel) => messageModel.id == lastMessage.id);
|
||||
if (lastMessageIndex == -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
var currentEndIndex =
|
||||
_endIndexMap[lastMessage.chatId] ?? allMessages.length;
|
||||
_endIndexMap[lastMessage.chatId] = math.min(
|
||||
allMessages.length,
|
||||
currentEndIndex + chunkSize,
|
||||
);
|
||||
|
||||
var displayedMessages = allMessages.sublist(
|
||||
_startIndexMap[lastMessage.chatId] ?? 0,
|
||||
_endIndexMap[lastMessage.chatId],
|
||||
);
|
||||
_messageController.add(displayedMessages);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> loadOldMessagesBefore({
|
||||
required String userId,
|
||||
required MessageModel firstMessage,
|
||||
}) async {
|
||||
var allMessages = List<MessageModel>.from(
|
||||
chatMessages[firstMessage.chatId] ?? [],
|
||||
)..sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
||||
|
||||
var firstMessageIndex = allMessages
|
||||
.indexWhere((messageModel) => messageModel.id == firstMessage.id);
|
||||
if (firstMessageIndex == -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
var currentStartIndex = _startIndexMap[firstMessage.chatId] ?? 0;
|
||||
_startIndexMap[firstMessage.chatId] = math.max(
|
||||
0,
|
||||
currentStartIndex - chunkSize,
|
||||
);
|
||||
|
||||
var displayedMessages = allMessages.sublist(
|
||||
_startIndexMap[firstMessage.chatId]!,
|
||||
_endIndexMap[firstMessage.chatId] ?? allMessages.length,
|
||||
);
|
||||
_messageController.add(displayedMessages);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<MessageModel?> getMessage({
|
||||
required String chatId,
|
||||
required String messageId,
|
||||
}) {
|
||||
var message =
|
||||
chatMessages[chatId]?.firstWhereOrNull((e) => e.id == messageId);
|
||||
|
||||
return Stream.value(message);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> getNextMessageId({
|
||||
required String userId,
|
||||
required String chatId,
|
||||
}) async =>
|
||||
"$chatId-$userId-${DateTime.now()}";
|
||||
|
||||
@override
|
||||
Future<void> sendMessage({
|
||||
required String chatId,
|
||||
required String senderId,
|
||||
required String messageId,
|
||||
String? text,
|
||||
String? imageUrl,
|
||||
String? messageType,
|
||||
DateTime? timestamp,
|
||||
}) async {
|
||||
var message = MessageModel(
|
||||
chatId: chatId,
|
||||
id: messageId,
|
||||
timestamp: timestamp ?? DateTime.now(),
|
||||
text: text,
|
||||
messageType: messageType,
|
||||
senderId: senderId,
|
||||
imageUrl: imageUrl,
|
||||
status: MessageStatus.sent,
|
||||
);
|
||||
|
||||
var chat = chats.firstWhereOrNull((e) => e.id == chatId);
|
||||
|
||||
if (chat == null) throw Exception("Chat not found");
|
||||
|
||||
var messages = List<MessageModel>.from(chatMessages[chatId] ?? []);
|
||||
messages.add(message);
|
||||
chatMessages[chatId] = messages;
|
||||
|
||||
var newChat = chat.copyWith(
|
||||
lastMessage: messageId,
|
||||
unreadMessageCount: chat.unreadMessageCount + 1,
|
||||
lastUsed: DateTime.now(),
|
||||
);
|
||||
|
||||
chats[chats.indexWhere((e) => e.id == chatId)] = newChat;
|
||||
|
||||
_chatsController.add(chats);
|
||||
_messageController.add(chatMessages[chatId] ?? []);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<int> getUnreadMessagesCount({
|
||||
required String userId,
|
||||
String? chatId,
|
||||
}) =>
|
||||
_chatsController.stream.map((chats) {
|
||||
var count = 0;
|
||||
|
||||
for (var chat in chats) {
|
||||
if (chat.users.contains(userId)) {
|
||||
count += chat.unreadMessageCount;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
});
|
||||
|
||||
@override
|
||||
Future<String> uploadImage({
|
||||
required String path,
|
||||
required Uint8List image,
|
||||
required String chatId,
|
||||
required String senderId,
|
||||
}) =>
|
||||
Future.value(image.toDataUri());
|
||||
|
||||
/// All the chats of the local memory database
|
||||
List<ChatModel> get getLocalChats => chats;
|
||||
|
||||
/// The chunkSize used for pagination
|
||||
int get getChunkSize => chunkSize;
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
import "package:chat_repository_interface/src/models/chat_model.dart";
|
||||
import "package:chat_repository_interface/src/models/message_model.dart";
|
||||
import "package:chat_repository_interface/src/models/user_model.dart";
|
||||
|
||||
/// The chunkSize for the LocalChatRepository
|
||||
const int chunkSize = 10;
|
||||
|
||||
/// All the chats of the local memory database
|
||||
final List<ChatModel> chats = [];
|
||||
|
||||
/// All the messages of the local memory database mapped by chat id
|
||||
final Map<String, List<MessageModel>> chatMessages = {};
|
||||
|
||||
/// All the pending messages of the local memory database mapped by chat id
|
||||
final Map<String, List<MessageModel>> pendingChatMessages = {};
|
||||
|
||||
/// All the users of the local memory database
|
||||
final List<UserModel> users = [
|
||||
const UserModel(
|
||||
id: "1",
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
imageUrl: "https://picsum.photos/200/300",
|
||||
),
|
||||
const UserModel(
|
||||
id: "2",
|
||||
firstName: "Jane",
|
||||
lastName: "Doe",
|
||||
imageUrl: "https://picsum.photos/200/300",
|
||||
),
|
||||
const UserModel(
|
||||
id: "3",
|
||||
firstName: "Frans",
|
||||
lastName: "Timmermans",
|
||||
imageUrl: "https://picsum.photos/200/300",
|
||||
),
|
||||
const UserModel(
|
||||
id: "4",
|
||||
firstName: "Hendrik-Jan",
|
||||
lastName: "De derde",
|
||||
imageUrl: "https://picsum.photos/200/300",
|
||||
),
|
||||
];
|
|
@ -0,0 +1,90 @@
|
|||
import "dart:async";
|
||||
|
||||
import "package:chat_repository_interface/chat_repository_interface.dart";
|
||||
import "package:chat_repository_interface/src/local/local_memory_db.dart";
|
||||
import "package:collection/collection.dart";
|
||||
import "package:rxdart/rxdart.dart";
|
||||
|
||||
/// The local pending message repository
|
||||
class LocalPendingMessageRepository
|
||||
implements PendingMessageRepositoryInterface {
|
||||
/// The local pending message repository constructor
|
||||
LocalPendingMessageRepository();
|
||||
|
||||
final StreamController<List<MessageModel>> _messageController =
|
||||
BehaviorSubject<List<MessageModel>>();
|
||||
|
||||
@override
|
||||
Stream<List<MessageModel>> getMessages({
|
||||
required String chatId,
|
||||
required String userId,
|
||||
}) {
|
||||
var foundChat =
|
||||
chats.firstWhereOrNull((chatModel) => chatModel.id == chatId);
|
||||
|
||||
if (foundChat == null) {
|
||||
_messageController.add([]);
|
||||
} else {
|
||||
var allMessages = List<MessageModel>.from(
|
||||
pendingChatMessages[chatId] ?? [],
|
||||
);
|
||||
allMessages.sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
||||
|
||||
_messageController.add(allMessages);
|
||||
}
|
||||
|
||||
return _messageController.stream;
|
||||
}
|
||||
|
||||
Future<void> _chatExists(String chatId) async {
|
||||
var chat = chats.firstWhereOrNull((e) => e.id == chatId);
|
||||
if (chat == null) throw Exception("Chat not found");
|
||||
}
|
||||
|
||||
@override
|
||||
Future<MessageModel> createMessage({
|
||||
required String chatId,
|
||||
required String senderId,
|
||||
required String messageId,
|
||||
String? text,
|
||||
String? imageUrl,
|
||||
String? messageType,
|
||||
DateTime? timestamp,
|
||||
}) async {
|
||||
var message = MessageModel(
|
||||
chatId: chatId,
|
||||
id: messageId,
|
||||
timestamp: timestamp ?? DateTime.now(),
|
||||
text: text,
|
||||
messageType: messageType,
|
||||
senderId: senderId,
|
||||
imageUrl: imageUrl,
|
||||
status: MessageStatus.sending,
|
||||
);
|
||||
|
||||
await _chatExists(chatId);
|
||||
|
||||
var messages = List<MessageModel>.from(pendingChatMessages[chatId] ?? []);
|
||||
messages.add(message);
|
||||
|
||||
pendingChatMessages[chatId] = messages;
|
||||
|
||||
_messageController.add(pendingChatMessages[chatId] ?? []);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> markMessageSent({
|
||||
required String chatId,
|
||||
required String messageId,
|
||||
}) async {
|
||||
await _chatExists(chatId);
|
||||
var messages = List<MessageModel>.from(pendingChatMessages[chatId] ?? []);
|
||||
|
||||
MessageModel markSent(MessageModel message) =>
|
||||
(message.id == messageId) ? message.markSent() : message;
|
||||
|
||||
pendingChatMessages[chatId] = messages.map(markSent).toList();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
import "dart:async";
|
||||
|
||||
import "package:chat_repository_interface/src/interfaces/user_repository_interface.dart";
|
||||
import "package:chat_repository_interface/src/local/local_memory_db.dart";
|
||||
import "package:chat_repository_interface/src/models/user_model.dart";
|
||||
import "package:rxdart/rxdart.dart";
|
||||
|
||||
/// The local user repository
|
||||
class LocalUserRepository implements UserRepositoryInterface {
|
||||
final StreamController<List<UserModel>> _usersController =
|
||||
BehaviorSubject<List<UserModel>>();
|
||||
|
||||
@override
|
||||
Stream<UserModel> getUser({
|
||||
required String userId,
|
||||
}) =>
|
||||
getAllUsers().map(
|
||||
(users) => users.firstWhere(
|
||||
(e) => e.id == userId,
|
||||
orElse: () => throw Exception(),
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Stream<List<UserModel>> getAllUsers() {
|
||||
_usersController.add(users);
|
||||
|
||||
return _usersController.stream;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<UserModel>> getAllUsersForChat({
|
||||
required String chatId,
|
||||
}) =>
|
||||
Stream.value(
|
||||
chats
|
||||
.firstWhere(
|
||||
(chat) => chat.id == chatId,
|
||||
orElse: () => throw Exception("Chat not found"),
|
||||
)
|
||||
.users
|
||||
.map(
|
||||
(userId) => users.firstWhere(
|
||||
(user) => user.id == userId,
|
||||
orElse: () => throw Exception("User not found"),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
|
||||
/// All the users of the local memory database
|
||||
List<UserModel> get getLocalUsers => users;
|
||||
}
|
|
@ -0,0 +1,121 @@
|
|||
/// The chat model
|
||||
/// A model that represents a chat.
|
||||
/// [id] is the chat id.
|
||||
/// [users] is a list of [UserModel] that are part of the chat.
|
||||
/// [chatName] is the name of the chat.
|
||||
/// [description] is the description of the chat.
|
||||
/// [imageUrl] is the image url of the chat.
|
||||
/// [canBeDeleted] is a boolean that indicates if the chat can be deleted.
|
||||
/// [lastUsed] is the last time the chat was used.
|
||||
/// [lastMessage] is the last message of the chat.
|
||||
/// [unreadMessageCount] is the number of unread messages in the chat.
|
||||
/// Returns a [ChatModel] instance.
|
||||
class ChatModel {
|
||||
/// The chat model constructor
|
||||
const ChatModel({
|
||||
required this.id,
|
||||
required this.users,
|
||||
required this.isGroupChat,
|
||||
this.chatName,
|
||||
this.description,
|
||||
this.imageUrl,
|
||||
this.canBeDeleted = true,
|
||||
this.lastUsed,
|
||||
this.lastMessage,
|
||||
this.unreadMessageCount = 0,
|
||||
});
|
||||
|
||||
/// The factory chat model that creates a chat model from a map
|
||||
factory ChatModel.fromMap(String id, Map<String, dynamic> data) => ChatModel(
|
||||
id: id,
|
||||
users: List<String>.from(data["users"]),
|
||||
isGroupChat: data["isGroupChat"],
|
||||
chatName: data["chatName"],
|
||||
description: data["description"],
|
||||
imageUrl: data["imageUrl"],
|
||||
canBeDeleted: data["canBeDeleted"] ?? true,
|
||||
lastUsed: data["lastUsed"] != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(data["lastUsed"])
|
||||
: null,
|
||||
lastMessage: data["lastMessage"],
|
||||
unreadMessageCount: data["unreadMessageCount"] ?? 0,
|
||||
);
|
||||
|
||||
/// The chat id
|
||||
final String id;
|
||||
|
||||
/// The chat users
|
||||
final List<String> users;
|
||||
|
||||
/// The chat name
|
||||
final String? chatName;
|
||||
|
||||
/// The chat description
|
||||
final String? description;
|
||||
|
||||
/// The chat image url
|
||||
final String? imageUrl;
|
||||
|
||||
/// A boolean that indicates if the chat can be deleted
|
||||
final bool canBeDeleted;
|
||||
|
||||
/// The last time the chat was used
|
||||
final DateTime? lastUsed;
|
||||
|
||||
/// The last message of the chat
|
||||
final String? lastMessage;
|
||||
|
||||
/// The number of unread messages in the chat
|
||||
final int unreadMessageCount;
|
||||
|
||||
/// A boolean that indicates if the chat is a group chat
|
||||
final bool isGroupChat;
|
||||
|
||||
/// The chat model copy with method
|
||||
ChatModel copyWith({
|
||||
String? id,
|
||||
List<String>? users,
|
||||
String? chatName,
|
||||
String? description,
|
||||
String? imageUrl,
|
||||
bool? canBeDeleted,
|
||||
DateTime? lastUsed,
|
||||
String? lastMessage,
|
||||
int? unreadMessageCount,
|
||||
bool? isGroupChat,
|
||||
}) =>
|
||||
ChatModel(
|
||||
id: id ?? this.id,
|
||||
users: users ?? this.users,
|
||||
chatName: chatName ?? this.chatName,
|
||||
isGroupChat: isGroupChat ?? this.isGroupChat,
|
||||
description: description ?? this.description,
|
||||
imageUrl: imageUrl ?? this.imageUrl,
|
||||
canBeDeleted: canBeDeleted ?? this.canBeDeleted,
|
||||
lastUsed: lastUsed ?? this.lastUsed,
|
||||
lastMessage: lastMessage ?? this.lastMessage,
|
||||
unreadMessageCount: unreadMessageCount ?? this.unreadMessageCount,
|
||||
);
|
||||
|
||||
/// Creates a map representation of this object
|
||||
Map<String, dynamic> toMap() => {
|
||||
"users": users,
|
||||
"isGroupChat": isGroupChat,
|
||||
"chatName": chatName,
|
||||
"description": description,
|
||||
"imageUrl": imageUrl,
|
||||
"canBeDeleted": canBeDeleted,
|
||||
"lastUsed": lastUsed?.millisecondsSinceEpoch,
|
||||
"lastMessage": lastMessage,
|
||||
"unreadMessageCount": unreadMessageCount,
|
||||
};
|
||||
}
|
||||
|
||||
/// The chat model extension
|
||||
/// An extension that adds extra functionality to the chat model.
|
||||
/// [getOtherUser] is a method that returns the other user in the chat.
|
||||
extension GetOtherUser on ChatModel {
|
||||
/// The get other user method
|
||||
String getOtherUser(String userId) =>
|
||||
users.firstWhere((user) => user != userId);
|
||||
}
|
|
@ -0,0 +1,123 @@
|
|||
/// Message status enumeration
|
||||
enum MessageStatus {
|
||||
/// Status when a message has not yet been received by the server.
|
||||
sending,
|
||||
|
||||
/// Status used when a message has been received by the server.
|
||||
sent;
|
||||
|
||||
/// Attempt to parse [MessageStatus] from String
|
||||
static MessageStatus? tryParse(String name) =>
|
||||
MessageStatus.values.where((status) => status.name == name).firstOrNull;
|
||||
|
||||
/// Parse [MessageStatus] from String
|
||||
/// or throw a [FormatException]
|
||||
static MessageStatus parse(String name) =>
|
||||
tryParse(name) ??
|
||||
(throw const FormatException(
|
||||
"MessageStatus with that name does not exist",
|
||||
));
|
||||
}
|
||||
|
||||
/// Message model
|
||||
/// Represents a message in a chat
|
||||
/// [id] is the message id.
|
||||
/// [text] is the message text.
|
||||
/// [imageUrl] is the message image url.
|
||||
/// [timestamp] is the message timestamp.
|
||||
/// [senderId] is the sender id.
|
||||
class MessageModel {
|
||||
/// Message model constructor
|
||||
const MessageModel({
|
||||
required this.chatId,
|
||||
required this.id,
|
||||
required this.text,
|
||||
required this.messageType,
|
||||
required this.imageUrl,
|
||||
required this.timestamp,
|
||||
required this.senderId,
|
||||
this.status = MessageStatus.sent,
|
||||
});
|
||||
|
||||
/// Creates a message model instance given a map instance
|
||||
factory MessageModel.fromMap(String id, Map<String, dynamic> map) =>
|
||||
MessageModel(
|
||||
chatId: map["chatId"],
|
||||
id: id,
|
||||
text: map["text"],
|
||||
messageType: map["messageType"],
|
||||
imageUrl: map["imageUrl"],
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(map["timestamp"]),
|
||||
senderId: map["senderId"],
|
||||
status: MessageStatus.tryParse(map["status"]) ?? MessageStatus.sent,
|
||||
);
|
||||
|
||||
/// The chat id
|
||||
final String chatId;
|
||||
|
||||
/// The message id
|
||||
final String id;
|
||||
|
||||
/// The message text
|
||||
final String? text;
|
||||
|
||||
/// The type of message for instance (user, system, etc)
|
||||
final String? messageType;
|
||||
|
||||
/// The message image url
|
||||
final String? imageUrl;
|
||||
|
||||
/// The message timestamp
|
||||
final DateTime timestamp;
|
||||
|
||||
/// The sender id
|
||||
final String senderId;
|
||||
|
||||
/// The message status
|
||||
final MessageStatus status;
|
||||
|
||||
/// The message model copy with method
|
||||
MessageModel copyWith({
|
||||
String? chatId,
|
||||
String? id,
|
||||
String? text,
|
||||
String? messageType,
|
||||
String? imageUrl,
|
||||
DateTime? timestamp,
|
||||
String? senderId,
|
||||
MessageStatus? status,
|
||||
}) =>
|
||||
MessageModel(
|
||||
chatId: chatId ?? this.chatId,
|
||||
id: id ?? this.id,
|
||||
text: text ?? this.text,
|
||||
messageType: messageType ?? this.messageType,
|
||||
imageUrl: imageUrl ?? this.imageUrl,
|
||||
timestamp: timestamp ?? this.timestamp,
|
||||
senderId: senderId ?? this.senderId,
|
||||
status: status ?? this.status,
|
||||
);
|
||||
|
||||
/// Creates a map representation of this object
|
||||
Map<String, dynamic> toMap() => {
|
||||
"chatId": chatId,
|
||||
"text": text,
|
||||
"messageType": messageType,
|
||||
"imageUrl": imageUrl,
|
||||
"timestamp": timestamp.millisecondsSinceEpoch,
|
||||
"senderId": senderId,
|
||||
"status": status.name,
|
||||
};
|
||||
|
||||
/// marks the message model as sent
|
||||
MessageModel markSent() => copyWith(status: MessageStatus.sent);
|
||||
}
|
||||
|
||||
/// Extension on [MessageModel] to check the message type
|
||||
extension MessageType on MessageModel {
|
||||
/// Check if the message is a text message
|
||||
bool get isTextMessage => text != null;
|
||||
|
||||
/// Check if the message is an image message
|
||||
bool get isImageMessage => imageUrl != null;
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
/// User model
|
||||
/// Represents a user in a chat
|
||||
/// [id] is the user id.
|
||||
/// [firstName] is the user first name.
|
||||
/// [lastName] is the user last name.
|
||||
/// [imageUrl] is the user image url.
|
||||
/// [fullname] is the user full name.
|
||||
class UserModel {
|
||||
/// User model constructor
|
||||
const UserModel({
|
||||
required this.id,
|
||||
this.firstName,
|
||||
this.lastName,
|
||||
this.imageUrl,
|
||||
});
|
||||
|
||||
/// Creates a user based on a given map [data]
|
||||
factory UserModel.fromMap(String id, Map<String, dynamic> data) => UserModel(
|
||||
id: id,
|
||||
firstName: data["first_name"],
|
||||
lastName: data["last_name"],
|
||||
imageUrl: data["image_url"],
|
||||
);
|
||||
|
||||
/// The user id
|
||||
final String id;
|
||||
|
||||
/// The user first name
|
||||
final String? firstName;
|
||||
|
||||
/// The user last name
|
||||
final String? lastName;
|
||||
|
||||
/// The user image url
|
||||
final String? imageUrl;
|
||||
}
|
||||
|
||||
/// Extension on [UserModel] to get the user full name
|
||||
extension Fullname on UserModel {
|
||||
/// Get the user full name
|
||||
String? get fullname {
|
||||
if (firstName == null && lastName == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (firstName == null) {
|
||||
return lastName;
|
||||
}
|
||||
|
||||
if (lastName == null) {
|
||||
return firstName;
|
||||
}
|
||||
|
||||
return "$firstName $lastName";
|
||||
}
|
||||
}
|
|
@ -0,0 +1,343 @@
|
|||
import "dart:async";
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:chat_repository_interface/src/extension/uint8list_data_uri.dart";
|
||||
import "package:chat_repository_interface/src/interfaces/chat_repostory_interface.dart";
|
||||
import "package:chat_repository_interface/src/interfaces/pending_message_repository_interface.dart";
|
||||
import "package:chat_repository_interface/src/interfaces/user_repository_interface.dart";
|
||||
import "package:chat_repository_interface/src/local/local_chat_repository.dart";
|
||||
import "package:chat_repository_interface/src/local/local_pending_message_repository.dart";
|
||||
import "package:chat_repository_interface/src/local/local_user_repository.dart";
|
||||
import "package:chat_repository_interface/src/models/chat_model.dart";
|
||||
import "package:chat_repository_interface/src/models/message_model.dart";
|
||||
import "package:chat_repository_interface/src/models/user_model.dart";
|
||||
import "package:collection/collection.dart";
|
||||
import "package:rxdart/rxdart.dart";
|
||||
|
||||
/// The chat service
|
||||
/// Use this service to interact with the chat repository.
|
||||
/// Optionally provide a [chatRepository] and [userRepository]
|
||||
class ChatService {
|
||||
/// Create a chat service with the given parameters.
|
||||
ChatService({
|
||||
required this.userId,
|
||||
ChatRepositoryInterface? chatRepository,
|
||||
PendingMessageRepositoryInterface? pendingMessageRepository,
|
||||
UserRepositoryInterface? userRepository,
|
||||
}) : chatRepository = chatRepository ?? LocalChatRepository(),
|
||||
userRepository = userRepository ?? LocalUserRepository(),
|
||||
pendingMessageRepository =
|
||||
pendingMessageRepository ?? LocalPendingMessageRepository();
|
||||
|
||||
/// The user ID of the person currently looking at the chat
|
||||
final String userId;
|
||||
|
||||
/// The chat repository
|
||||
final ChatRepositoryInterface chatRepository;
|
||||
|
||||
/// The pending messages repository
|
||||
final PendingMessageRepositoryInterface pendingMessageRepository;
|
||||
|
||||
/// The user repository
|
||||
final UserRepositoryInterface userRepository;
|
||||
|
||||
/// Create a chat with the given parameters.
|
||||
/// [users] is a list of [UserModel] that will be part of the chat.
|
||||
/// [chatName] is the name of the chat.
|
||||
/// [description] is the description of the chat.
|
||||
/// [imageUrl] is the image url of the chat.
|
||||
/// [messages] is a list of [MessageModel] that will be part of the chat.
|
||||
/// Returns a [ChatModel] stream.
|
||||
Future<void> createChat({
|
||||
required List<UserModel> users,
|
||||
required bool isGroupChat,
|
||||
String? chatName,
|
||||
String? description,
|
||||
String? imageUrl,
|
||||
List<MessageModel>? messages,
|
||||
}) {
|
||||
var userIds = users.map((e) => e.id).toList();
|
||||
|
||||
return chatRepository.createChat(
|
||||
isGroupChat: isGroupChat,
|
||||
users: userIds,
|
||||
chatName: chatName,
|
||||
description: description,
|
||||
imageUrl: imageUrl,
|
||||
messages: messages,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get the chats for the user with the given [userId].
|
||||
/// Returns a list of [ChatModel] stream.
|
||||
Stream<List<ChatModel>?> getChats() =>
|
||||
chatRepository.getChats(userId: userId);
|
||||
|
||||
/// Get the chat with the given [chatId].
|
||||
/// Returns a [ChatModel] stream.
|
||||
Stream<ChatModel> getChat({
|
||||
required String chatId,
|
||||
}) =>
|
||||
chatRepository.getChat(chatId: chatId);
|
||||
|
||||
/// Get the chat with the given [currentUser] and [otherUser].
|
||||
/// Returns a [ChatModel] stream.
|
||||
/// Returns null if the chat does not exist.
|
||||
Future<ChatModel?> getChatByUser({
|
||||
required String currentUser,
|
||||
required String otherUser,
|
||||
}) async {
|
||||
var chats = await chatRepository.getChats(userId: currentUser).first;
|
||||
|
||||
var personalChats =
|
||||
chats?.where((element) => element.users.length == 2).toList();
|
||||
|
||||
return personalChats?.firstWhereOrNull(
|
||||
(element) => element.users.where((e) => e == otherUser).isNotEmpty,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get the group chats with the given [currentUser] and [otherUsers].
|
||||
/// Returns a [ChatModel] stream.
|
||||
Future<ChatModel?> getGroupChatByUser({
|
||||
required String currentUser,
|
||||
required List<UserModel> otherUsers,
|
||||
required String chatName,
|
||||
required String description,
|
||||
}) async {
|
||||
try {
|
||||
var chats = await chatRepository.getChats(userId: currentUser).first;
|
||||
|
||||
var personalChats =
|
||||
chats?.where((element) => element.isGroupChat).toList();
|
||||
|
||||
var groupChats = personalChats
|
||||
?.where(
|
||||
(chats) =>
|
||||
otherUsers.every((user) => chats.users.contains(user.id)),
|
||||
)
|
||||
.toList();
|
||||
|
||||
return groupChats?.firstWhereOrNull(
|
||||
(element) =>
|
||||
element.chatName == chatName && element.description == description,
|
||||
);
|
||||
// ignore: avoid_catches_without_on_clauses
|
||||
} catch (_) {
|
||||
throw Exception("Chat not found");
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the message with the given [messageId].
|
||||
/// [chatId] is the chat id.
|
||||
/// Returns a [MessageModel] stream.
|
||||
Stream<MessageModel?> getMessage({
|
||||
required String chatId,
|
||||
required String messageId,
|
||||
}) =>
|
||||
chatRepository.getMessage(chatId: chatId, messageId: messageId);
|
||||
|
||||
/// Get the messages for the given [chatId].
|
||||
/// Returns a list of [MessageModel] stream.
|
||||
/// [pageSize] is the number of messages to be fetched.
|
||||
/// [page] is the page number.
|
||||
/// [chatId] is the chat id.
|
||||
/// Returns a list of [MessageModel] stream.
|
||||
Stream<List<MessageModel>?> getMessages({
|
||||
required String chatId,
|
||||
}) {
|
||||
List<MessageModel> mergePendingMessages(
|
||||
List<MessageModel> messages,
|
||||
List<MessageModel> pendingMessages,
|
||||
) =>
|
||||
{
|
||||
...Map.fromEntries(
|
||||
pendingMessages.map((message) => MapEntry(message.id, message)),
|
||||
),
|
||||
...Map.fromEntries(
|
||||
messages.map((message) => MapEntry(message.id, message)),
|
||||
),
|
||||
}.values.toList().sorted(
|
||||
(a, b) => a.timestamp.compareTo(b.timestamp),
|
||||
);
|
||||
|
||||
return Rx.combineLatest2(
|
||||
chatRepository.getMessages(userId: userId, chatId: chatId),
|
||||
pendingMessageRepository.getMessages(userId: userId, chatId: chatId),
|
||||
(chatMessages, pendingChatMessages) {
|
||||
// TODO(Quirille): This is because chatRepository.getMessages
|
||||
// might return null, when really it should've just thrown
|
||||
// an exception instead.
|
||||
if (chatMessages == null) return null;
|
||||
return mergePendingMessages(chatMessages, pendingChatMessages);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Signals that new messages should be loaded after the given message.
|
||||
/// The stream should emit the new messages.
|
||||
Future<void> loadNewMessagesAfter({
|
||||
required MessageModel lastMessage,
|
||||
}) =>
|
||||
chatRepository.loadNewMessagesAfter(
|
||||
userId: userId,
|
||||
lastMessage: lastMessage,
|
||||
);
|
||||
|
||||
/// Signals that old messages should be loaded before the given message.
|
||||
/// The stream should emit the new messages.
|
||||
Future<void> loadOldMessagesBefore({
|
||||
required MessageModel firstMessage,
|
||||
}) =>
|
||||
chatRepository.loadOldMessagesBefore(
|
||||
userId: userId,
|
||||
firstMessage: firstMessage,
|
||||
);
|
||||
|
||||
/// Send a message with the given parameters.
|
||||
/// [chatId] is the chat id.
|
||||
/// [senderId] is the sender id.
|
||||
/// [text] is the message text.
|
||||
/// [imageUrl] is the image url.
|
||||
Future<void> sendMessage({
|
||||
required String chatId,
|
||||
required String senderId,
|
||||
String? presetMessageId,
|
||||
String? text,
|
||||
String? messageType,
|
||||
String? imageUrl,
|
||||
Uint8List? imageData,
|
||||
}) async {
|
||||
var messageId = presetMessageId ??
|
||||
await chatRepository.getNextMessageId(userId: userId, chatId: chatId);
|
||||
|
||||
await pendingMessageRepository.createMessage(
|
||||
chatId: chatId,
|
||||
senderId: senderId,
|
||||
messageId: messageId,
|
||||
text: text,
|
||||
messageType: messageType,
|
||||
imageUrl: imageData?.toDataUri() ?? imageUrl,
|
||||
);
|
||||
|
||||
unawaited(
|
||||
chatRepository
|
||||
.sendMessage(
|
||||
chatId: chatId,
|
||||
messageId: messageId,
|
||||
text: text,
|
||||
messageType: messageType,
|
||||
senderId: senderId,
|
||||
imageUrl: imageUrl,
|
||||
)
|
||||
.then(
|
||||
(_) => pendingMessageRepository.markMessageSent(
|
||||
chatId: chatId,
|
||||
messageId: messageId,
|
||||
),
|
||||
)
|
||||
.onError(
|
||||
(e, s) {
|
||||
// TODO(Quirille): handle exception when message sending has failed.
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Method for sending an image and a message at the same time.
|
||||
Future<void> sendImageMessage({
|
||||
required String chatId,
|
||||
required String userId,
|
||||
required Uint8List data,
|
||||
}) async {
|
||||
var messageId = await chatRepository.getNextMessageId(
|
||||
userId: userId,
|
||||
chatId: chatId,
|
||||
);
|
||||
|
||||
var path = await uploadImage(
|
||||
path: "chats/$messageId",
|
||||
image: data,
|
||||
chatId: chatId,
|
||||
);
|
||||
|
||||
await sendMessage(
|
||||
presetMessageId: messageId,
|
||||
chatId: chatId,
|
||||
senderId: userId,
|
||||
imageUrl: path,
|
||||
imageData: data,
|
||||
);
|
||||
}
|
||||
|
||||
/// Delete the chat with the given parameters.
|
||||
/// [chatId] is the chat id.
|
||||
Future<void> deleteChat({
|
||||
required String chatId,
|
||||
}) =>
|
||||
chatRepository.deleteChat(chatId: chatId);
|
||||
|
||||
/// Get user with the given [userId].
|
||||
/// Returns a [UserModel] stream.
|
||||
Stream<UserModel> getUser({required String userId}) =>
|
||||
userRepository.getUser(userId: userId);
|
||||
|
||||
/// Get all the users.
|
||||
/// Returns a list of [UserModel] stream.
|
||||
Stream<List<UserModel>> getAllUsers() => userRepository.getAllUsers();
|
||||
|
||||
/// Get the unread messages count for a user [chatId].
|
||||
/// [chatId] is the chat id. If not provided, it will return the
|
||||
/// total unread messages count.
|
||||
/// Returns a [Stream] of [int].
|
||||
Stream<int> getUnreadMessagesCount({
|
||||
String? chatId,
|
||||
}) =>
|
||||
chatRepository.getUnreadMessagesCount(userId: userId, chatId: chatId);
|
||||
|
||||
/// Upload an image with the given parameters.
|
||||
/// [path] is the image path.
|
||||
/// [image] is the image bytes.
|
||||
/// [chatId] is the chat id.
|
||||
/// Returns a [Future] of [String].
|
||||
Future<String> uploadImage({
|
||||
required String path,
|
||||
required Uint8List image,
|
||||
required String chatId,
|
||||
}) =>
|
||||
chatRepository.uploadImage(
|
||||
path: path,
|
||||
image: image,
|
||||
senderId: userId,
|
||||
chatId: chatId,
|
||||
);
|
||||
|
||||
/// Mark the chat as read with the given parameters.
|
||||
/// [chatId] is the chat id.
|
||||
/// Returns a [Future] of [void].
|
||||
Future<void> markAsRead({
|
||||
required String chatId,
|
||||
}) async {
|
||||
var chat = await chatRepository.getChat(chatId: chatId).first;
|
||||
|
||||
if (chat.lastMessage == null) return;
|
||||
|
||||
var lastMessage = await chatRepository
|
||||
.getMessage(chatId: chatId, messageId: chat.lastMessage!)
|
||||
.first;
|
||||
|
||||
if (lastMessage != null && lastMessage.senderId == userId) return;
|
||||
|
||||
var newChat = chat.copyWith(
|
||||
lastUsed: DateTime.now(),
|
||||
unreadMessageCount: 0,
|
||||
);
|
||||
|
||||
await chatRepository.updateChat(chat: newChat);
|
||||
}
|
||||
|
||||
/// Get all the users for the given [chatId].
|
||||
/// Returns a list of [UserModel] stream.
|
||||
Stream<List<UserModel>> getAllUsersForChat({required String chatId}) =>
|
||||
userRepository.getAllUsersForChat(chatId: chatId);
|
||||
}
|
21
packages/chat_repository_interface/pubspec.yaml
Normal file
21
packages/chat_repository_interface/pubspec.yaml
Normal file
|
@ -0,0 +1,21 @@
|
|||
name: chat_repository_interface
|
||||
description: "The interface for a chat repository"
|
||||
version: 6.0.0
|
||||
homepage: "https://github.com/Iconica-Development"
|
||||
|
||||
publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub/
|
||||
|
||||
environment:
|
||||
sdk: ">=3.4.3 <4.0.0"
|
||||
|
||||
dependencies:
|
||||
mime: any
|
||||
rxdart: any
|
||||
collection: any
|
||||
|
||||
dev_dependencies:
|
||||
test: ^1.24.0
|
||||
flutter_iconica_analysis:
|
||||
git:
|
||||
url: https://github.com/Iconica-Development/flutter_iconica_analysis
|
||||
ref: 7.0.0
|
29
packages/firebase_chat_repository/.gitignore
vendored
Normal file
29
packages/firebase_chat_repository/.gitignore
vendored
Normal file
|
@ -0,0 +1,29 @@
|
|||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||
/pubspec.lock
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
build/
|
1
packages/firebase_chat_repository/CHANGELOG.md
Symbolic link
1
packages/firebase_chat_repository/CHANGELOG.md
Symbolic link
|
@ -0,0 +1 @@
|
|||
../../CHANGELOG.md
|
1
packages/firebase_chat_repository/LICENSE
Symbolic link
1
packages/firebase_chat_repository/LICENSE
Symbolic link
|
@ -0,0 +1 @@
|
|||
../../LICENSE
|
16
packages/firebase_chat_repository/README.md
Normal file
16
packages/firebase_chat_repository/README.md
Normal file
|
@ -0,0 +1,16 @@
|
|||
# Firebase chat repository
|
||||
The firebase implementation of the chat_repository_interface
|
||||
|
||||
## Usage
|
||||
```dart
|
||||
chatService: ChatService(
|
||||
chatRepository: FirebaseChatRepository(
|
||||
chatCollection: 'chats',
|
||||
messageCollection: 'messages',
|
||||
mediaPath: 'chat',
|
||||
),
|
||||
userRepository: FirebaseUserRepository(
|
||||
userCollection: 'users',
|
||||
),
|
||||
),
|
||||
```
|
|
@ -1,4 +1,4 @@
|
|||
include: package:flutter_iconica_analysis/analysis_options.yaml
|
||||
include: package:flutter_iconica_analysis/components_options.yaml
|
||||
|
||||
# Possible to overwrite the rules from the package
|
||||
|
||||
|
@ -6,4 +6,4 @@ analyzer:
|
|||
exclude:
|
||||
|
||||
linter:
|
||||
rules:
|
||||
rules:
|
|
@ -0,0 +1,2 @@
|
|||
export "src/firebase_chat_repository.dart";
|
||||
export "src/firebase_user_repository.dart";
|
|
@ -0,0 +1,220 @@
|
|||
import "dart:typed_data";
|
||||
|
||||
import "package:chat_repository_interface/chat_repository_interface.dart";
|
||||
import "package:cloud_firestore/cloud_firestore.dart";
|
||||
import "package:firebase_storage/firebase_storage.dart";
|
||||
|
||||
/// Firebase implementation of the chat repository
|
||||
class FirebaseChatRepository implements ChatRepositoryInterface {
|
||||
/// Creates a firebase implementation of the chat repository
|
||||
FirebaseChatRepository({
|
||||
FirebaseFirestore? firestore,
|
||||
FirebaseStorage? storage,
|
||||
String chatCollection = "chats",
|
||||
String messageCollection = "messages",
|
||||
String mediaPath = "chat",
|
||||
}) : _mediaPath = mediaPath,
|
||||
_messageCollection = messageCollection,
|
||||
_chatCollection = chatCollection,
|
||||
_firestore = firestore ?? FirebaseFirestore.instance,
|
||||
_storage = storage ?? FirebaseStorage.instance;
|
||||
final FirebaseFirestore _firestore;
|
||||
final FirebaseStorage _storage;
|
||||
final String _chatCollection;
|
||||
final String _messageCollection;
|
||||
final String _mediaPath;
|
||||
|
||||
@override
|
||||
Future<void> createChat({
|
||||
required List<String> users,
|
||||
required bool isGroupChat,
|
||||
String? chatName,
|
||||
String? description,
|
||||
String? imageUrl,
|
||||
List<MessageModel>? messages,
|
||||
}) async {
|
||||
var chatData = {
|
||||
"users": users,
|
||||
"isGroupChat": isGroupChat,
|
||||
"chatName": chatName,
|
||||
"description": description,
|
||||
"imageUrl": imageUrl,
|
||||
"createdAt": DateTime.now().millisecondsSinceEpoch,
|
||||
};
|
||||
await _firestore.collection(_chatCollection).add(chatData);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteChat({required String chatId}) async {
|
||||
await _firestore.collection(_chatCollection).doc(chatId).delete();
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<ChatModel> getChat({required String chatId}) => _firestore
|
||||
.collection(_chatCollection)
|
||||
.doc(chatId)
|
||||
.snapshots()
|
||||
.map((snapshot) {
|
||||
var data = snapshot.data()!;
|
||||
return ChatModel.fromMap(snapshot.id, data);
|
||||
});
|
||||
|
||||
@override
|
||||
Stream<List<ChatModel>?> getChats({required String userId}) => _firestore
|
||||
.collection(_chatCollection)
|
||||
.where("users", arrayContains: userId)
|
||||
.snapshots()
|
||||
.map(
|
||||
(querySnapshot) => querySnapshot.docs.map((doc) {
|
||||
var data = doc.data();
|
||||
return ChatModel.fromMap(doc.id, data);
|
||||
}).toList(),
|
||||
);
|
||||
|
||||
@override
|
||||
Stream<MessageModel?> getMessage({
|
||||
required String chatId,
|
||||
required String messageId,
|
||||
}) =>
|
||||
_firestore
|
||||
.collection(_chatCollection)
|
||||
.doc(chatId)
|
||||
.collection(_messageCollection)
|
||||
.doc(messageId)
|
||||
.snapshots()
|
||||
.map((snapshot) {
|
||||
var data = snapshot.data()!;
|
||||
return MessageModel.fromMap(
|
||||
snapshot.id,
|
||||
data,
|
||||
);
|
||||
});
|
||||
|
||||
@override
|
||||
Stream<List<MessageModel>?> getMessages({
|
||||
required String chatId,
|
||||
required String userId,
|
||||
}) =>
|
||||
_firestore
|
||||
.collection(_chatCollection)
|
||||
.doc(chatId)
|
||||
.collection(_messageCollection)
|
||||
.orderBy("timestamp")
|
||||
.snapshots()
|
||||
.map(
|
||||
(query) => query.docs
|
||||
.map(
|
||||
(snapshot) => MessageModel.fromMap(
|
||||
snapshot.id,
|
||||
snapshot.data(),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
|
||||
@override
|
||||
Stream<int> getUnreadMessagesCount({
|
||||
required String userId,
|
||||
String? chatId,
|
||||
}) async* {
|
||||
var query = _firestore
|
||||
.collection(_chatCollection)
|
||||
.where("users", arrayContains: userId)
|
||||
.where("unreadMessageCount", isGreaterThan: 0)
|
||||
.snapshots();
|
||||
|
||||
await for (var snapshot in query) {
|
||||
var count = 0;
|
||||
for (var doc in snapshot.docs) {
|
||||
var data = doc.data();
|
||||
var lastMessageKey = data["lastMessage"];
|
||||
|
||||
var message =
|
||||
await getMessage(chatId: doc.id, messageId: lastMessageKey).first;
|
||||
if (message?.senderId != userId) {
|
||||
count += data["unreadMessageCount"] as int;
|
||||
}
|
||||
}
|
||||
|
||||
yield count;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> getNextMessageId({
|
||||
required String userId,
|
||||
required String chatId,
|
||||
}) async =>
|
||||
"$chatId-$userId-${DateTime.now()}";
|
||||
|
||||
@override
|
||||
Future<void> sendMessage({
|
||||
required String chatId,
|
||||
required String senderId,
|
||||
required String messageId,
|
||||
String? text,
|
||||
String? imageUrl,
|
||||
String? messageType,
|
||||
DateTime? timestamp,
|
||||
}) async {
|
||||
var message = MessageModel(
|
||||
chatId: chatId,
|
||||
id: messageId,
|
||||
text: text,
|
||||
imageUrl: imageUrl,
|
||||
messageType: messageType,
|
||||
timestamp: timestamp ?? DateTime.now(),
|
||||
senderId: senderId,
|
||||
);
|
||||
|
||||
await _firestore
|
||||
.collection(_chatCollection)
|
||||
.doc(chatId)
|
||||
.collection(_messageCollection)
|
||||
.doc(messageId)
|
||||
.set(
|
||||
message.toMap(),
|
||||
);
|
||||
|
||||
await _firestore.collection(_chatCollection).doc(chatId).update(
|
||||
{
|
||||
"lastMessage": messageId,
|
||||
"unreadMessageCount": FieldValue.increment(1),
|
||||
"lastUsed": DateTime.now().millisecondsSinceEpoch,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateChat({required ChatModel chat}) async {
|
||||
await _firestore
|
||||
.collection(_chatCollection)
|
||||
.doc(chat.id)
|
||||
.update(chat.toMap());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> uploadImage({
|
||||
required String path,
|
||||
required Uint8List image,
|
||||
required String chatId,
|
||||
required String senderId,
|
||||
}) async {
|
||||
var ref = _storage.ref().child(_mediaPath).child(path);
|
||||
var uploadTask = ref.putData(image);
|
||||
var snapshot = await uploadTask.whenComplete(() => {});
|
||||
return snapshot.ref.getDownloadURL();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> loadNewMessagesAfter({
|
||||
required String userId,
|
||||
required MessageModel lastMessage,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
Future<void> loadOldMessagesBefore({
|
||||
required String userId,
|
||||
required MessageModel firstMessage,
|
||||
}) async {}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
import "package:chat_repository_interface/chat_repository_interface.dart";
|
||||
import "package:cloud_firestore/cloud_firestore.dart";
|
||||
|
||||
/// Firebase implementation of a user respository for chats.
|
||||
class FirebaseUserRepository implements UserRepositoryInterface {
|
||||
/// Creates a firebase implementation of a user respository for chats.
|
||||
FirebaseUserRepository({
|
||||
FirebaseFirestore? firestore,
|
||||
String userCollection = "users",
|
||||
}) : _userCollection = userCollection,
|
||||
_firestore = firestore ?? FirebaseFirestore.instance;
|
||||
final FirebaseFirestore _firestore;
|
||||
final String _userCollection;
|
||||
|
||||
@override
|
||||
Stream<List<UserModel>> getAllUsers() =>
|
||||
_firestore.collection(_userCollection).snapshots().map(
|
||||
(querySnapshot) => querySnapshot.docs
|
||||
.map(
|
||||
(doc) => UserModel.fromMap(
|
||||
doc.id,
|
||||
doc.data(),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
|
||||
@override
|
||||
Stream<UserModel> getUser({required String userId}) =>
|
||||
_firestore.collection(_userCollection).doc(userId).snapshots().map(
|
||||
(snapshot) => UserModel.fromMap(
|
||||
snapshot.id,
|
||||
snapshot.data()!,
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Stream<List<UserModel>> getAllUsersForChat({required String chatId}) =>
|
||||
_firestore
|
||||
.collection(_userCollection)
|
||||
.where("chats", arrayContains: chatId)
|
||||
.snapshots()
|
||||
.map(
|
||||
(querySnapshot) => querySnapshot.docs
|
||||
.map(
|
||||
(doc) => UserModel.fromMap(
|
||||
doc.id,
|
||||
doc.data(),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
29
packages/firebase_chat_repository/pubspec.yaml
Normal file
29
packages/firebase_chat_repository/pubspec.yaml
Normal file
|
@ -0,0 +1,29 @@
|
|||
name: firebase_chat_repository
|
||||
description: "Firebase repository implementation for the chat domain repository interface"
|
||||
version: 6.0.0
|
||||
homepage: "https://github.com/Iconica-Development"
|
||||
|
||||
publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub/
|
||||
|
||||
environment:
|
||||
sdk: ">=3.4.3 <4.0.0"
|
||||
flutter: ">=1.17.0"
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
chat_repository_interface:
|
||||
hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub
|
||||
version: ^6.0.0
|
||||
|
||||
firebase_storage: any
|
||||
cloud_firestore: any
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_iconica_analysis:
|
||||
git:
|
||||
url: https://github.com/Iconica-Development/flutter_iconica_analysis
|
||||
ref: 7.0.0
|
29
packages/flutter_chat/.gitignore
vendored
Normal file
29
packages/flutter_chat/.gitignore
vendored
Normal file
|
@ -0,0 +1,29 @@
|
|||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||
/pubspec.lock
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
build/
|
1
packages/flutter_chat/CHANGELOG.md
Symbolic link
1
packages/flutter_chat/CHANGELOG.md
Symbolic link
|
@ -0,0 +1 @@
|
|||
../../CHANGELOG.md
|
1
packages/flutter_chat/LICENSE
Symbolic link
1
packages/flutter_chat/LICENSE
Symbolic link
|
@ -0,0 +1 @@
|
|||
../../LICENSE
|
|
@ -1,4 +1,4 @@
|
|||
include: package:flutter_iconica_analysis/analysis_options.yaml
|
||||
include: package:flutter_iconica_analysis/components_options.yaml
|
||||
|
||||
# Possible to overwrite the rules from the package
|
||||
|
||||
|
@ -6,4 +6,4 @@ analyzer:
|
|||
exclude:
|
||||
|
||||
linter:
|
||||
rules:
|
||||
rules:
|
13
packages/flutter_chat/example/.gitignore
vendored
13
packages/flutter_chat/example/.gitignore
vendored
|
@ -5,9 +5,11 @@
|
|||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
|
@ -15,7 +17,6 @@ migrate_working_dir/
|
|||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
ios
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
|
@ -32,14 +33,6 @@ ios
|
|||
.pub/
|
||||
/build/
|
||||
|
||||
# Platform-specific folders
|
||||
**/android/
|
||||
**/ios/
|
||||
**/web/
|
||||
**/windows/
|
||||
**/macos/
|
||||
**/linux/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
|
@ -50,3 +43,5 @@ app.*.map.json
|
|||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
firebase_options.dart
|
1
packages/flutter_chat/example/firebase.json
Normal file
1
packages/flutter_chat/example/firebase.json
Normal file
|
@ -0,0 +1 @@
|
|||
{"flutter":{"platforms":{"dart":{"lib/firebase_options.dart":{"projectId":"appshell-demo","configurations":{"android":"1:431820621472:android:6a0f2bc3559d17781babc5","web":"1:431820621472:web:f4b27eea24be24fd1babc5"}}},"android":{"default":{"projectId":"appshell-demo","appId":"1:431820621472:android:6a0f2bc3559d17781babc5","fileOutput":"android/app/google-services.json"}}}}}
|
|
@ -1,35 +1,63 @@
|
|||
// import 'package:example/firebase_options.dart';
|
||||
// import 'package:firebase_auth/firebase_auth.dart';
|
||||
// import 'package:firebase_chat_repository/firebase_chat_repository.dart';
|
||||
// import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_chat/flutter_chat.dart';
|
||||
|
||||
void main(List<String> args) async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
runApp(const App());
|
||||
void main() {
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
class App extends StatelessWidget {
|
||||
const App({super.key});
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const MaterialApp(
|
||||
home: Home(),
|
||||
return MaterialApp(
|
||||
title: 'Flutter Demo',
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const MyHomePage(),
|
||||
// home: FutureBuilder(
|
||||
// future: Firebase.initializeApp(
|
||||
// options: DefaultFirebaseOptions.currentPlatform,
|
||||
// ),
|
||||
// builder: (context, snapshot) {
|
||||
// if (snapshot.connectionState != ConnectionState.done) {
|
||||
// return const Center(
|
||||
// child: CircularProgressIndicator(),
|
||||
// );
|
||||
// }
|
||||
// return const MyHomePage();
|
||||
// },
|
||||
// ),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Home extends StatelessWidget {
|
||||
const Home({super.key});
|
||||
class MyHomePage extends StatefulWidget {
|
||||
const MyHomePage({super.key});
|
||||
|
||||
@override
|
||||
State<MyHomePage> createState() => _MyHomePageState();
|
||||
}
|
||||
|
||||
class _MyHomePageState extends State<MyHomePage> {
|
||||
// @override
|
||||
// void initState() {
|
||||
// FirebaseAuth.instance.signInAnonymously();
|
||||
// super.initState();
|
||||
// }
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: chatNavigatorUserStory(context,
|
||||
configuration: ChatUserStoryConfiguration(
|
||||
chatService: LocalChatService(),
|
||||
chatOptionsBuilder: (ctx) => ChatOptions(
|
||||
noChatsPlaceholderBuilder: (translations) =>
|
||||
Text(translations.noUsersFound),
|
||||
))));
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
body: const Center(),
|
||||
floatingActionButton: const FlutterChatEntryWidget(
|
||||
userId: '1',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,26 +1,28 @@
|
|||
name: example
|
||||
description: "A new Flutter project."
|
||||
publish_to: "none"
|
||||
publish_to: 'none'
|
||||
version: 1.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: ">=3.2.5 <4.0.0"
|
||||
sdk: ^3.5.0
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
cupertino_icons: ^1.0.2
|
||||
firebase_core: ^2.24.2
|
||||
firebase_auth: ^4.16.0
|
||||
|
||||
cupertino_icons: ^1.0.8
|
||||
flutter_chat:
|
||||
path: ../
|
||||
flutter_chat_firebase:
|
||||
path: ../../flutter_chat_firebase
|
||||
# firebase_chat_repository:
|
||||
# path: ../../firebase_chat_repository
|
||||
# firebase_core: any
|
||||
# firebase_auth: any
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^2.0.0
|
||||
|
||||
flutter_lints: ^4.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
|
|
@ -5,10 +5,26 @@
|
|||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||
// tree, read text, and verify that the values of widget properties are correct.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:example/main.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||
expect(true, true);
|
||||
// Build our app and trigger a frame.
|
||||
await tester.pumpWidget(const MyApp());
|
||||
|
||||
// Verify that our counter starts at 0.
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
expect(find.text('1'), findsNothing);
|
||||
|
||||
// Tap the '+' icon and trigger a frame.
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
await tester.pump();
|
||||
|
||||
// Verify that our counter has incremented.
|
||||
expect(find.text('0'), findsNothing);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,14 +1,31 @@
|
|||
// SPDX-FileCopyrightText: 2022 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
///
|
||||
library flutter_chat;
|
||||
// Core
|
||||
export "package:chat_repository_interface/chat_repository_interface.dart";
|
||||
|
||||
export 'package:flutter_chat/src/chat_entry_widget.dart';
|
||||
export 'package:flutter_chat/src/flutter_chat_navigator_userstory.dart';
|
||||
export 'package:flutter_chat/src/flutter_chat_userstory.dart';
|
||||
export 'package:flutter_chat/src/models/chat_configuration.dart';
|
||||
export 'package:flutter_chat/src/routes.dart';
|
||||
export 'package:flutter_chat_interface/flutter_chat_interface.dart';
|
||||
export 'package:flutter_chat_local/local_chat_service.dart';
|
||||
export 'package:flutter_chat_view/flutter_chat_view.dart';
|
||||
// User story
|
||||
export "package:flutter_chat/src/flutter_chat_entry_widget.dart";
|
||||
export "package:flutter_chat/src/flutter_chat_navigator_userstories.dart";
|
||||
|
||||
// Options
|
||||
export "src/config/chat_builders.dart";
|
||||
export "src/config/chat_options.dart";
|
||||
export "src/config/chat_time_indicator_options.dart";
|
||||
export "src/config/chat_translations.dart";
|
||||
export "src/config/screen_types.dart";
|
||||
|
||||
// Screens and widgets
|
||||
export "src/screens/chat_detail/chat_detail_screen.dart";
|
||||
export "src/screens/chat_detail/widgets/default_message_builder.dart";
|
||||
export "src/screens/chat_detail/widgets/old_message_builder.dart";
|
||||
export "src/screens/chat_profile_screen.dart";
|
||||
export "src/screens/chat_screen.dart";
|
||||
export "src/screens/creation/new_chat_screen.dart";
|
||||
export "src/screens/creation/new_group_chat_overview.dart";
|
||||
export "src/screens/creation/new_group_chat_screen.dart";
|
||||
|
||||
// Services
|
||||
export "src/services/date_formatter.dart";
|
||||
export "src/services/pop_handler.dart";
|
||||
|
||||
// Utils
|
||||
export "src/util/scope.dart";
|
||||
export "src/util/utils.dart";
|
||||
|
|
|
@ -1,182 +0,0 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_chat/flutter_chat.dart';
|
||||
|
||||
/// A widget representing an entry point for a chat UI.
|
||||
class ChatEntryWidget extends StatefulWidget {
|
||||
/// Constructs a [ChatEntryWidget].
|
||||
const ChatEntryWidget({
|
||||
this.chatService,
|
||||
this.onTap,
|
||||
this.widgetSize = 75,
|
||||
this.backgroundColor = Colors.grey,
|
||||
this.icon = Icons.chat,
|
||||
this.iconColor = Colors.black,
|
||||
this.counterBackgroundColor = Colors.red,
|
||||
this.textStyle,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The chat service associated with the widget.
|
||||
final ChatService? chatService;
|
||||
|
||||
/// Background color of the widget.
|
||||
final Color backgroundColor;
|
||||
|
||||
/// Size of the widget.
|
||||
final double widgetSize;
|
||||
|
||||
/// Background color of the counter.
|
||||
final Color counterBackgroundColor;
|
||||
|
||||
/// Callback function triggered when the widget is tapped.
|
||||
final Function()? onTap;
|
||||
|
||||
/// Icon to be displayed.
|
||||
final IconData icon;
|
||||
|
||||
/// Color of the icon.
|
||||
final Color iconColor;
|
||||
|
||||
/// Text style for the counter.
|
||||
final TextStyle? textStyle;
|
||||
|
||||
@override
|
||||
State<ChatEntryWidget> createState() => _ChatEntryWidgetState();
|
||||
}
|
||||
|
||||
/// State class for [ChatEntryWidget].
|
||||
class _ChatEntryWidgetState extends State<ChatEntryWidget> {
|
||||
ChatService? chatService;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
chatService ??= widget.chatService ?? LocalChatService();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => GestureDetector(
|
||||
onTap: () async =>
|
||||
widget.onTap?.call() ??
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => chatNavigatorUserStory(
|
||||
context,
|
||||
configuration: ChatUserStoryConfiguration(
|
||||
chatService: chatService!,
|
||||
chatOptionsBuilder: (ctx) => const ChatOptions(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: StreamBuilder<int>(
|
||||
stream: chatService!.chatOverviewService.getUnreadChatsCountStream(),
|
||||
builder: (BuildContext context, snapshot) => Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: widget.widgetSize,
|
||||
height: widget.widgetSize,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: widget.backgroundColor,
|
||||
),
|
||||
child: _AnimatedNotificationIcon(
|
||||
icon: Icon(
|
||||
widget.icon,
|
||||
color: widget.iconColor,
|
||||
size: widget.widgetSize / 1.5,
|
||||
),
|
||||
notifications: snapshot.data ?? 0,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 0.0,
|
||||
top: 0.0,
|
||||
child: Container(
|
||||
width: widget.widgetSize / 2,
|
||||
height: widget.widgetSize / 2,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: widget.counterBackgroundColor,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${snapshot.data ?? 0}',
|
||||
style: widget.textStyle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Stateful widget representing an animated notification icon.
|
||||
class _AnimatedNotificationIcon extends StatefulWidget {
|
||||
const _AnimatedNotificationIcon({
|
||||
required this.notifications,
|
||||
required this.icon,
|
||||
});
|
||||
|
||||
/// The number of notifications.
|
||||
final int notifications;
|
||||
|
||||
/// The icon to be displayed.
|
||||
final Icon icon;
|
||||
|
||||
@override
|
||||
State<_AnimatedNotificationIcon> createState() =>
|
||||
_AnimatedNotificationIconState();
|
||||
}
|
||||
|
||||
/// State class for [_AnimatedNotificationIcon].
|
||||
class _AnimatedNotificationIconState extends State<_AnimatedNotificationIcon>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
);
|
||||
|
||||
if (widget.notifications != 0) {
|
||||
unawaited(_runAnimation());
|
||||
}
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
_animationController.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _AnimatedNotificationIcon oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
if (oldWidget.notifications != widget.notifications) {
|
||||
unawaited(_runAnimation());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _runAnimation() async {
|
||||
await _animationController.forward();
|
||||
await _animationController.reverse();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => RotationTransition(
|
||||
turns: Tween(begin: 0.0, end: -.1)
|
||||
.chain(CurveTween(curve: Curves.elasticIn))
|
||||
.animate(_animationController),
|
||||
child: widget.icon,
|
||||
);
|
||||
}
|
204
packages/flutter_chat/lib/src/config/chat_builders.dart
Normal file
204
packages/flutter_chat/lib/src/config/chat_builders.dart
Normal file
|
@ -0,0 +1,204 @@
|
|||
import "dart:typed_data";
|
||||
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_chat/flutter_chat.dart";
|
||||
import "package:flutter_chat/src/screens/chat_detail/widgets/default_loader.dart";
|
||||
import "package:flutter_chat/src/screens/creation/widgets/default_image_picker.dart";
|
||||
|
||||
/// The chat builders
|
||||
class ChatBuilders {
|
||||
/// The chat builders constructor
|
||||
const ChatBuilders({
|
||||
this.chatMessagesErrorBuilder,
|
||||
this.baseScreenBuilder,
|
||||
this.chatScreenBuilder,
|
||||
this.messageInputBuilder,
|
||||
this.chatRowContainerBuilder,
|
||||
this.groupAvatarBuilder,
|
||||
this.imagePickerContainerBuilder,
|
||||
this.userAvatarBuilder,
|
||||
this.deleteChatDialogBuilder,
|
||||
this.newChatButtonBuilder,
|
||||
this.noUsersPlaceholderBuilder,
|
||||
this.chatTitleBuilder,
|
||||
this.chatMessageBuilder = DefaultChatMessageBuilder.builder,
|
||||
this.imagePickerBuilder = DefaultImagePickerDialog.builder,
|
||||
this.usernameBuilder,
|
||||
this.loadingWidgetBuilder = DefaultChatLoadingOverlay.builder,
|
||||
this.loadingChatMessageBuilder = DefaultChatMessageLoader.builder,
|
||||
});
|
||||
|
||||
/// The base screen builder
|
||||
/// This builder is used to build the base screen for the chat
|
||||
/// You can switch on the [screenType] to build different screens
|
||||
/// ```dart
|
||||
/// baseScreenBuilder: (context, screenType, appBar, body) {
|
||||
/// switch (screenType) {
|
||||
/// case ScreenType.chatScreen:
|
||||
/// return Scaffold(
|
||||
/// appBar: appBar,
|
||||
/// body: body,
|
||||
/// );
|
||||
/// case ScreenType.chatDetailScreen:
|
||||
/// // And so on....
|
||||
/// ```
|
||||
final BaseScreenBuilder? baseScreenBuilder;
|
||||
|
||||
/// The chat screen builder
|
||||
/// This builder is used instead of the [baseScreenBuilder] when building the
|
||||
/// chat screen. While the chat is still loading the [chat] will be null
|
||||
final ChatScreenBuilder? chatScreenBuilder;
|
||||
|
||||
/// The message input builder
|
||||
final TextInputBuilder? messageInputBuilder;
|
||||
|
||||
/// The chat row container builder
|
||||
final ContainerBuilder? chatRowContainerBuilder;
|
||||
|
||||
/// The group avatar builder
|
||||
final GroupAvatarBuilder? groupAvatarBuilder;
|
||||
|
||||
/// The user avatar builder
|
||||
final UserAvatarBuilder? userAvatarBuilder;
|
||||
|
||||
/// The delete chat dialog builder
|
||||
final Future<bool?> Function(BuildContext, ChatModel)?
|
||||
deleteChatDialogBuilder;
|
||||
|
||||
/// The new chat button builder
|
||||
final ButtonBuilder? newChatButtonBuilder;
|
||||
|
||||
/// The no users placeholder builder
|
||||
final NoUsersPlaceholderBuilder? noUsersPlaceholderBuilder;
|
||||
|
||||
/// The chat title builder
|
||||
final Widget Function(String chatTitle)? chatTitleBuilder;
|
||||
|
||||
/// The chat message builder
|
||||
final ChatMessageBuilder chatMessageBuilder;
|
||||
|
||||
/// The username builder
|
||||
final Widget Function(String userFullName)? usernameBuilder;
|
||||
|
||||
/// The image picker container builder
|
||||
final ImagePickerContainerBuilder? imagePickerContainerBuilder;
|
||||
|
||||
/// A way to provide your own image picker implementation
|
||||
/// If not provided the [DefaultImagePicker.builder] will be used which
|
||||
/// shows a modal buttom sheet with the option for a camera or gallery image
|
||||
final ImagePickerBuilder imagePickerBuilder;
|
||||
|
||||
/// The loading widget builder
|
||||
/// This is used to build the loading widget that is displayed on the chat
|
||||
/// screen when loading the chat
|
||||
final WidgetBuilder loadingWidgetBuilder;
|
||||
|
||||
/// The loading widget builder for chat messages
|
||||
/// This is displayed in the list of chat messages when loading more messages
|
||||
/// can be above and below the list
|
||||
final WidgetBuilder loadingChatMessageBuilder;
|
||||
|
||||
/// Errorbuilder for when messages are not loading correctly on the detail
|
||||
/// screen of a chat.
|
||||
final ChatErrorBuilder? chatMessagesErrorBuilder;
|
||||
}
|
||||
|
||||
/// The button builder
|
||||
typedef ButtonBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
VoidCallback onPressed,
|
||||
ChatTranslations translations,
|
||||
);
|
||||
|
||||
/// The image picker container builder
|
||||
typedef ImagePickerContainerBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
VoidCallback onClose,
|
||||
ChatTranslations translations,
|
||||
);
|
||||
|
||||
/// Builder definition for providing an image picker implementation
|
||||
typedef ImagePickerBuilder = Future<Uint8List?> Function(
|
||||
BuildContext context,
|
||||
);
|
||||
|
||||
/// The text input builder
|
||||
typedef TextInputBuilder = Widget Function(
|
||||
BuildContext context, {
|
||||
required TextEditingController textEditingController,
|
||||
required Widget suffixIcon,
|
||||
required ChatTranslations translations,
|
||||
required VoidCallback onSubmit,
|
||||
required bool enabled,
|
||||
});
|
||||
|
||||
/// The base screen builder
|
||||
/// [title] is the title of the screen and can be null while loading
|
||||
typedef BaseScreenBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
ScreenType screenType,
|
||||
PreferredSizeWidget appBar,
|
||||
String? title,
|
||||
Widget body,
|
||||
);
|
||||
|
||||
/// The chat screen builder
|
||||
typedef ChatScreenBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
ChatModel? chat,
|
||||
PreferredSizeWidget appBar,
|
||||
String? title,
|
||||
Widget body,
|
||||
);
|
||||
|
||||
/// The container builder
|
||||
typedef ContainerBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
Widget child,
|
||||
);
|
||||
|
||||
/// The chat message builder
|
||||
/// This builder is used to override the default chat message widget
|
||||
/// If null is returned, the default chat message widget will be used so you can
|
||||
/// override for specific cases
|
||||
/// [previousMessage] is the previous message in the chat
|
||||
/// [sender] is the sender of the message and null if no user sent the message
|
||||
typedef ChatMessageBuilder = Widget? Function(
|
||||
BuildContext context,
|
||||
MessageModel message,
|
||||
MessageModel? previousMessage,
|
||||
UserModel? sender,
|
||||
Function(UserModel sender) onPressSender,
|
||||
String semanticIdTitle,
|
||||
String semanticIdText,
|
||||
String semanticIdTime,
|
||||
);
|
||||
|
||||
/// The group avatar builder
|
||||
typedef GroupAvatarBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
String groupName,
|
||||
String? imageUrl,
|
||||
double size,
|
||||
);
|
||||
|
||||
/// The user avatar builder
|
||||
typedef UserAvatarBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
UserModel user,
|
||||
double size,
|
||||
);
|
||||
|
||||
/// The no users placeholder builder
|
||||
typedef NoUsersPlaceholderBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
ChatTranslations translations,
|
||||
);
|
||||
|
||||
/// Builder for when there is an error on a chatscreen
|
||||
typedef ChatErrorBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
Object error,
|
||||
StackTrace stackTrace,
|
||||
ChatOptions options,
|
||||
);
|
374
packages/flutter_chat/lib/src/config/chat_options.dart
Normal file
374
packages/flutter_chat/lib/src/config/chat_options.dart
Normal file
|
@ -0,0 +1,374 @@
|
|||
import "package:cached_network_image/cached_network_image.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_chat/flutter_chat.dart";
|
||||
import "package:flutter_chat/src/config/chat_semantics.dart";
|
||||
|
||||
/// The chat options
|
||||
/// Use this class to configure the chat options.
|
||||
class ChatOptions {
|
||||
/// The chat options constructor
|
||||
ChatOptions({
|
||||
this.dateformat,
|
||||
this.groupChatEnabled = true,
|
||||
this.enableLoadingIndicator = true,
|
||||
this.translations = const ChatTranslations.empty(),
|
||||
this.semantics = const ChatSemantics.standard(),
|
||||
this.builders = const ChatBuilders(),
|
||||
this.spacing = const ChatSpacing(),
|
||||
this.paginationControls = const ChatPaginationControls(),
|
||||
this.messageTheme,
|
||||
this.messageThemeResolver = _defaultMessageThemeResolver,
|
||||
this.chatTitleResolver,
|
||||
this.senderTitleResolver,
|
||||
this.iconEnabledColor,
|
||||
this.iconDisabledColor,
|
||||
this.chatAlignment,
|
||||
this.onNoChats,
|
||||
this.imageQuality = 20,
|
||||
this.imageProviderResolver = _defaultImageProviderResolver,
|
||||
this.timeIndicatorOptions = const ChatTimeIndicatorOptions(),
|
||||
ChatRepositoryInterface? chatRepository,
|
||||
UserRepositoryInterface? userRepository,
|
||||
PendingMessageRepositoryInterface? pendingMessagesRepository,
|
||||
}) : chatRepository = chatRepository ?? LocalChatRepository(),
|
||||
userRepository = userRepository ?? LocalUserRepository(),
|
||||
pendingMessagesRepository =
|
||||
pendingMessagesRepository ?? LocalPendingMessageRepository();
|
||||
|
||||
/// The implementation for communication with persistance layer for chats
|
||||
final ChatRepositoryInterface chatRepository;
|
||||
|
||||
/// The implementation for communication with persistance layer
|
||||
/// for pending messages
|
||||
final PendingMessageRepositoryInterface pendingMessagesRepository;
|
||||
|
||||
/// The implementation for communication with persistance layer for users
|
||||
final UserRepositoryInterface userRepository;
|
||||
|
||||
/// [dateformat] is a function that formats the date.
|
||||
// ignore: avoid_positional_boolean_parameters
|
||||
final String Function(bool showFullDate, DateTime date)? dateformat;
|
||||
|
||||
/// [translations] is the chat translations.
|
||||
final ChatTranslations translations;
|
||||
|
||||
/// [semantics] is the chat semantics.
|
||||
final ChatSemantics semantics;
|
||||
|
||||
/// [builders] is the chat builders.
|
||||
final ChatBuilders builders;
|
||||
|
||||
//// The spacing between elements of the chat
|
||||
final ChatSpacing spacing;
|
||||
|
||||
/// The pagination settings for the chat
|
||||
final ChatPaginationControls paginationControls;
|
||||
|
||||
/// [groupChatEnabled] is a boolean that indicates if group chat is enabled.
|
||||
final bool groupChatEnabled;
|
||||
|
||||
/// [iconEnabledColor] is the color of the enabled icon.
|
||||
/// Defaults to the [IconThemeData.color] of the current [Theme]
|
||||
final Color? iconEnabledColor;
|
||||
|
||||
/// [iconDisabledColor] is the color of the disabled icon.
|
||||
/// Defaults to the [ThemeData.disabledColor] of the current [Theme]
|
||||
final Color? iconDisabledColor;
|
||||
|
||||
/// The default [MessageTheme] for the chat messages.
|
||||
/// If not set, the default values are based on the current [Theme].
|
||||
final MessageTheme? messageTheme;
|
||||
|
||||
/// If [messageThemeResolver] is set and returns null for a message,
|
||||
/// the [messageTheme] will be used.
|
||||
final MessageThemeResolver messageThemeResolver;
|
||||
|
||||
/// If [chatTitleResolver] is set, it will be used to get the title of
|
||||
/// the chat in the ChatDetailScreen.
|
||||
final ChatTitleResolver? chatTitleResolver;
|
||||
|
||||
/// If [senderTitleResolver] is set, it will be used to get the title of
|
||||
/// the sender in a chat message. If not set, the [sender.firstName] is used.
|
||||
/// [sender] can be null if the message is an event.
|
||||
final SenderTitleResolver? senderTitleResolver;
|
||||
|
||||
/// The alignment of the chatmessages in the ChatDetailScreen.
|
||||
/// Defaults to [Alignment.bottomCenter]
|
||||
final Alignment? chatAlignment;
|
||||
|
||||
/// Enable the loading indicator that is over the entire chat screen while
|
||||
/// loading messages. Defaults to false. The streambuilder for chat messages
|
||||
/// already shows a loading indicator. So this is an additional loading that
|
||||
/// can be used for more customization.
|
||||
final bool enableLoadingIndicator;
|
||||
|
||||
/// [onNoChats] is a function that is triggered when there are no chats.
|
||||
final Function? onNoChats;
|
||||
|
||||
/// [imageQuality] sets the quality of the image to send over with chat image
|
||||
/// messages. This should be a value between 1 and 100 where 1 is the worst
|
||||
/// image quality and 100 is the best image quality. Note that the higher the
|
||||
/// image quality is set, the larger te iage is, that is being sent over.
|
||||
final int imageQuality;
|
||||
|
||||
/// If [imageProviderResolver] is set, it will be used to get the images for
|
||||
/// the images in the entire userstory. If not provided, CachedNetworkImage
|
||||
/// will be used.
|
||||
final ImageProviderResolver imageProviderResolver;
|
||||
|
||||
/// Options regarding the time indicator in chat screens
|
||||
final ChatTimeIndicatorOptions timeIndicatorOptions;
|
||||
}
|
||||
|
||||
/// Typedef for the chatTitleResolver function that is used to get a title for
|
||||
/// a chat.
|
||||
typedef ChatTitleResolver = String? Function(ChatModel chat);
|
||||
|
||||
/// Typedef for the senderTitleResolver function that is used to get a title for
|
||||
/// a sender.
|
||||
typedef SenderTitleResolver = String? Function(UserModel? user);
|
||||
|
||||
/// Typedef for the imageProviderResolver function that is used to get images
|
||||
/// for the userstory.
|
||||
typedef ImageProviderResolver = ImageProvider Function(
|
||||
BuildContext context,
|
||||
Uri image,
|
||||
);
|
||||
|
||||
/// Typedef for the messageThemeResolver function that is used to get a
|
||||
/// [MessageTheme] for a message. This can return null so you can fall back to
|
||||
/// default values for some messages.
|
||||
typedef MessageThemeResolver = MessageTheme? Function(
|
||||
BuildContext context,
|
||||
MessageModel message,
|
||||
MessageModel? previousMessage,
|
||||
UserModel? sender,
|
||||
);
|
||||
|
||||
/// The message theme
|
||||
class MessageTheme {
|
||||
/// The message theme constructor
|
||||
const MessageTheme({
|
||||
this.backgroundColor,
|
||||
this.nameColor,
|
||||
this.borderColor,
|
||||
this.textColor,
|
||||
this.timeTextColor,
|
||||
this.imageBackgroundColor,
|
||||
this.borderRadius,
|
||||
this.messageAlignment,
|
||||
this.messageSidePadding,
|
||||
this.textAlignment,
|
||||
this.showName,
|
||||
this.showTime,
|
||||
this.showFullDate,
|
||||
});
|
||||
|
||||
/// Creates a [MessageTheme] from a [ThemeData]
|
||||
factory MessageTheme.fromTheme(ThemeData theme) => MessageTheme(
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
nameColor: theme.colorScheme.onPrimary,
|
||||
borderColor: theme.colorScheme.primary,
|
||||
textColor: theme.colorScheme.onPrimary,
|
||||
timeTextColor: theme.colorScheme.onPrimary,
|
||||
imageBackgroundColor: theme.colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
textAlignment: TextAlign.start,
|
||||
messageSidePadding: 144.0,
|
||||
messageAlignment: null,
|
||||
showName: null,
|
||||
showTime: true,
|
||||
showFullDate: null,
|
||||
);
|
||||
|
||||
/// The alignment of the message in the chat
|
||||
/// By default, the current user is aligned to the right and the other senders
|
||||
/// are aligned to the left.
|
||||
final TextAlign? messageAlignment;
|
||||
|
||||
/// The alignment of the text in the message
|
||||
/// Defaults to [TextAlign.start]
|
||||
final TextAlign? textAlignment;
|
||||
|
||||
/// The color of the message text
|
||||
/// Defaults to [ThemeData.colorScheme.onPrimary]
|
||||
final Color? textColor;
|
||||
|
||||
/// The color of the text displaying the time
|
||||
/// Defaults to [ThemeData.colorScheme.onPrimary]
|
||||
final Color? timeTextColor;
|
||||
|
||||
/// The color of the sender name
|
||||
/// Defaults to [ThemeData.colorScheme.onPrimary]
|
||||
final Color? nameColor;
|
||||
|
||||
/// The color of the message container background
|
||||
/// Defaults to [ThemeData.colorScheme.primary]
|
||||
final Color? backgroundColor;
|
||||
|
||||
/// The color of the border around the message
|
||||
/// Defaults to [ThemeData.colorScheme.primaryColor]
|
||||
final Color? borderColor;
|
||||
|
||||
/// The color of the background when an image is loading, the image is
|
||||
/// transparent or there is an error.
|
||||
///
|
||||
/// Defaults to [ThemeData.colorScheme.secondaryContainer]
|
||||
final Color? imageBackgroundColor;
|
||||
|
||||
/// The border radius of the message container
|
||||
/// Defaults to [BorderRadius.circular(12)]
|
||||
final BorderRadius? borderRadius;
|
||||
|
||||
/// The padding on the side of the message
|
||||
/// If not set, the padding is 144.0
|
||||
final double? messageSidePadding;
|
||||
|
||||
/// If the name of the sender should be shown above the message
|
||||
/// If not set the name will be shown if the previous message was not from the
|
||||
/// same sender.
|
||||
final bool? showName;
|
||||
|
||||
/// If the time of the message should be shown below the message
|
||||
/// Defaults to true
|
||||
final bool? showTime;
|
||||
|
||||
/// If the full date should be shown with the time in the message
|
||||
/// If not set the date will be shown if the previous message was not on the
|
||||
/// same day.
|
||||
/// If [showTime] is false, this value is ignored.
|
||||
final bool? showFullDate;
|
||||
|
||||
/// Creates a copy of the current object with the provided values
|
||||
MessageTheme copyWith({
|
||||
Color? backgroundColor,
|
||||
Color? nameColor,
|
||||
Color? borderColor,
|
||||
Color? textColor,
|
||||
Color? timeTextColor,
|
||||
Color? imageBackgroundColor,
|
||||
BorderRadius? borderRadius,
|
||||
double? messageSidePadding,
|
||||
TextAlign? messageAlignment,
|
||||
TextAlign? textAlignment,
|
||||
bool? showName,
|
||||
bool? showTime,
|
||||
bool? showFullDate,
|
||||
}) =>
|
||||
MessageTheme(
|
||||
backgroundColor: backgroundColor ?? this.backgroundColor,
|
||||
nameColor: nameColor ?? this.nameColor,
|
||||
borderColor: borderColor ?? this.borderColor,
|
||||
textColor: textColor ?? this.textColor,
|
||||
timeTextColor: timeTextColor ?? this.timeTextColor,
|
||||
imageBackgroundColor: imageBackgroundColor ?? this.imageBackgroundColor,
|
||||
borderRadius: borderRadius ?? this.borderRadius,
|
||||
messageSidePadding: messageSidePadding ?? this.messageSidePadding,
|
||||
messageAlignment: messageAlignment ?? this.messageAlignment,
|
||||
textAlignment: textAlignment ?? this.textAlignment,
|
||||
showName: showName ?? this.showName,
|
||||
showTime: showTime ?? this.showTime,
|
||||
showFullDate: showFullDate ?? this.showFullDate,
|
||||
);
|
||||
|
||||
/// If a value is null in the first object, the value from the second object
|
||||
/// is used.
|
||||
MessageTheme operator |(MessageTheme other) => MessageTheme(
|
||||
backgroundColor: backgroundColor ?? other.backgroundColor,
|
||||
nameColor: nameColor ?? other.nameColor,
|
||||
borderColor: borderColor ?? other.borderColor,
|
||||
textColor: textColor ?? other.textColor,
|
||||
timeTextColor: timeTextColor ?? other.timeTextColor,
|
||||
imageBackgroundColor:
|
||||
imageBackgroundColor ?? other.imageBackgroundColor,
|
||||
borderRadius: borderRadius ?? other.borderRadius,
|
||||
messageSidePadding: messageSidePadding ?? other.messageSidePadding,
|
||||
messageAlignment: messageAlignment ?? other.messageAlignment,
|
||||
textAlignment: textAlignment ?? other.textAlignment,
|
||||
showName: showName ?? other.showName,
|
||||
showTime: showTime ?? other.showTime,
|
||||
showFullDate: showFullDate ?? other.showFullDate,
|
||||
);
|
||||
}
|
||||
|
||||
MessageTheme? _defaultMessageThemeResolver(
|
||||
BuildContext context,
|
||||
MessageModel message,
|
||||
MessageModel? previousMessage,
|
||||
UserModel? sender,
|
||||
) =>
|
||||
null;
|
||||
|
||||
ImageProvider _defaultImageProviderResolver(
|
||||
BuildContext context,
|
||||
Uri image,
|
||||
) =>
|
||||
switch (image.scheme) {
|
||||
"data" => MemoryImage(image.data!.contentAsBytes()),
|
||||
_ => CachedNetworkImageProvider(image.toString()),
|
||||
};
|
||||
|
||||
/// All configurable paddings and whitespaces within the userstory
|
||||
class ChatSpacing {
|
||||
/// Creates a ChatSpacing object
|
||||
const ChatSpacing({
|
||||
this.chatBetweenMessagesPadding = 16.0,
|
||||
this.chatSidePadding = 20.0,
|
||||
});
|
||||
|
||||
/// The padding between the chat messages and the screen edge
|
||||
final double chatSidePadding;
|
||||
|
||||
/// The padding between different chat messages if they are not from the same
|
||||
/// sender.
|
||||
final double chatBetweenMessagesPadding;
|
||||
}
|
||||
|
||||
/// The chat pagination controls
|
||||
/// Use this to define how sensitive the chat pagination should be.
|
||||
class ChatPaginationControls {
|
||||
/// The chat pagination controls constructor
|
||||
const ChatPaginationControls({
|
||||
this.scrollOffset = 50.0,
|
||||
this.autoScrollTriggerOffset = 50.0,
|
||||
this.loadingIndicatorForNewMessages = true,
|
||||
this.loadingIndicatorForOldMessages = true,
|
||||
this.loadNewMessagesOnScroll = true,
|
||||
this.loadOldMessagesOnScroll = true,
|
||||
this.loadingNewMessageMinDuration = Duration.zero,
|
||||
this.loadingOldMessageMinDuration = Duration.zero,
|
||||
});
|
||||
|
||||
/// The minimum scroll offset to trigger the pagination to call for more pages
|
||||
/// on both sides of the chat. Defaults to 50.0
|
||||
final double scrollOffset;
|
||||
|
||||
/// The minimum scroll offset to trigger the auto scroll to the bottom of the
|
||||
/// chat. Defaults to 50.0
|
||||
final double autoScrollTriggerOffset;
|
||||
|
||||
/// Whether to load new messages when someone scrolls to the end of the chat
|
||||
final bool loadNewMessagesOnScroll;
|
||||
|
||||
/// Whether to load older messages when scrolling towards the start of the
|
||||
/// chat.
|
||||
///
|
||||
/// If the messages are loaded by pagination, it is smart to add this.
|
||||
final bool loadOldMessagesOnScroll;
|
||||
|
||||
/// Whether to show a loading indicator for new messages loading
|
||||
final bool loadingIndicatorForNewMessages;
|
||||
|
||||
/// Whether to show a loading indicator for old messages loading
|
||||
final bool loadingIndicatorForOldMessages;
|
||||
|
||||
/// The minimum duration for the loading indicator for new messages
|
||||
/// to be shown. The loading indicator will wait for this duration and the
|
||||
/// completion of [ChatService.loadNewMessagesAfter]
|
||||
final Duration loadingNewMessageMinDuration;
|
||||
|
||||
/// The minimum duration for the loading indicator for old messages
|
||||
/// to be shown. The loading indicator will wait for this duration and the
|
||||
/// completion of [ChatService.loadOldMessagesBefore]
|
||||
final Duration loadingOldMessageMinDuration;
|
||||
}
|
271
packages/flutter_chat/lib/src/config/chat_semantics.dart
Normal file
271
packages/flutter_chat/lib/src/config/chat_semantics.dart
Normal file
|
@ -0,0 +1,271 @@
|
|||
// SPDX-FileCopyrightText: 2022 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// ignore_for_file: public_member_api_docs
|
||||
|
||||
/// Class that holds all the semantic ids for the chat component view and
|
||||
/// the corresponding userstory
|
||||
class ChatSemantics {
|
||||
/// ChatSemantics constructor where everything is required use this
|
||||
/// if you want to be sure to have all translations specified
|
||||
/// If you just want the default values use the standard constructor
|
||||
/// and optionally override the values with the copyWith method
|
||||
const ChatSemantics({
|
||||
required this.profileTitle,
|
||||
required this.profileDescription,
|
||||
required this.chatUnreadMessages,
|
||||
required this.chatChatTitle,
|
||||
required this.chatNoMessages,
|
||||
required this.newChatGetUsersError,
|
||||
required this.newGroupChatMemberAmount,
|
||||
required this.newGroupChatGetUsersError,
|
||||
required this.newChatUserListUserFullName,
|
||||
required this.chatBubbleTitle,
|
||||
required this.chatBubbleTime,
|
||||
required this.chatBubbleText,
|
||||
required this.chatsChatTitle,
|
||||
required this.chatsChatSubTitle,
|
||||
required this.chatsChatLastUsed,
|
||||
required this.chatsChatUnreadMessages,
|
||||
required this.chatMessageInput,
|
||||
required this.newChatNameInput,
|
||||
required this.newChatBioInput,
|
||||
required this.newChatSearchInput,
|
||||
required this.newGroupChatSearchInput,
|
||||
required this.profileStartChatButton,
|
||||
required this.chatsStartChatButton,
|
||||
required this.chatsDeleteConfirmButton,
|
||||
required this.newChatCreateGroupChatButton,
|
||||
required this.newGroupChatCreateGroupChatButton,
|
||||
required this.newGroupChatNextButton,
|
||||
required this.imagePickerCancelButton,
|
||||
required this.chatSelectImageIconButton,
|
||||
required this.chatSendMessageIconButton,
|
||||
required this.newChatSearchIconButton,
|
||||
required this.newGroupChatSearchIconButton,
|
||||
required this.chatBackButton,
|
||||
required this.chatTitleButton,
|
||||
required this.newGroupChatSelectImage,
|
||||
required this.newGroupChatRemoveImage,
|
||||
required this.newGroupChatRemoveUser,
|
||||
required this.profileTapUserButton,
|
||||
required this.chatsOpenChatButton,
|
||||
required this.userListTapUser,
|
||||
});
|
||||
|
||||
/// Default translations for the chat component view
|
||||
const ChatSemantics.standard({
|
||||
this.profileTitle = "text_profile_title",
|
||||
this.profileDescription = "text_profile_description",
|
||||
this.chatUnreadMessages = "text_unread_messages",
|
||||
this.chatChatTitle = "text_chat_title",
|
||||
this.chatNoMessages = "text_no_messages",
|
||||
this.newChatGetUsersError = "text_get_users_error",
|
||||
this.newGroupChatMemberAmount = "text_member_amount",
|
||||
this.newGroupChatGetUsersError = "text_get_users_error",
|
||||
this.newChatUserListUserFullName = _defaultNewChatUserListUserFullName,
|
||||
this.chatBubbleTitle = _defaultChatBubbleTitle,
|
||||
this.chatBubbleTime = _defaultChatBubbleTime,
|
||||
this.chatBubbleText = _defaultChatBubbleText,
|
||||
this.chatsChatTitle = _defaultChatsChatTitle,
|
||||
this.chatsChatSubTitle = _defaultChatsChatSubTitle,
|
||||
this.chatsChatLastUsed = _defaultChatsChatLastUsed,
|
||||
this.chatsChatUnreadMessages = _defaultChatsChatUnreadMessages,
|
||||
this.chatMessageInput = "input_text_message",
|
||||
this.newChatNameInput = "input_text_name",
|
||||
this.newChatBioInput = "input_text_bio",
|
||||
this.newChatSearchInput = "input_text_search",
|
||||
this.newGroupChatSearchInput = "input_text_search",
|
||||
this.profileStartChatButton = "button_start_chat",
|
||||
this.chatsStartChatButton = "button_start_chat",
|
||||
this.chatsDeleteConfirmButton = "button_delete_chat_confirm",
|
||||
this.newChatCreateGroupChatButton = "button_create_group_chat",
|
||||
this.newGroupChatCreateGroupChatButton = "button_create_group_chat",
|
||||
this.newGroupChatNextButton = "button_next",
|
||||
this.imagePickerCancelButton = "button_cancel",
|
||||
this.chatSelectImageIconButton = "button_icon_select_image",
|
||||
this.chatSendMessageIconButton = "button_icon_send_message",
|
||||
this.newChatSearchIconButton = "button_icon_search",
|
||||
this.newGroupChatSearchIconButton = "button_icon_search",
|
||||
this.chatBackButton = "button_back",
|
||||
this.chatTitleButton = "button_open_profile",
|
||||
this.newGroupChatSelectImage = "button_select_image",
|
||||
this.newGroupChatRemoveImage = "button_remove_image",
|
||||
this.newGroupChatRemoveUser = "button_remove_user",
|
||||
this.profileTapUserButton = _defaultProfileTapUserButton,
|
||||
this.chatsOpenChatButton = _defaultChatsOpenChatButton,
|
||||
this.userListTapUser = _defaultUserListTapUser,
|
||||
});
|
||||
|
||||
// Text
|
||||
final String profileTitle;
|
||||
final String profileDescription;
|
||||
final String chatUnreadMessages;
|
||||
final String chatChatTitle;
|
||||
final String chatNoMessages;
|
||||
final String newChatGetUsersError;
|
||||
final String newGroupChatMemberAmount;
|
||||
final String newGroupChatGetUsersError;
|
||||
|
||||
// Indexed text
|
||||
final String Function(int index) newChatUserListUserFullName;
|
||||
final String Function(int index) chatBubbleTitle;
|
||||
final String Function(int index) chatBubbleTime;
|
||||
final String Function(int index) chatBubbleText;
|
||||
final String Function(int index) chatsChatTitle;
|
||||
final String Function(int index) chatsChatSubTitle;
|
||||
final String Function(int index) chatsChatLastUsed;
|
||||
final String Function(int index) chatsChatUnreadMessages;
|
||||
|
||||
// Input texts
|
||||
final String chatMessageInput;
|
||||
final String newChatNameInput;
|
||||
final String newChatBioInput;
|
||||
final String newChatSearchInput;
|
||||
final String newGroupChatSearchInput;
|
||||
|
||||
// Buttons
|
||||
final String profileStartChatButton;
|
||||
final String chatsStartChatButton;
|
||||
final String chatsDeleteConfirmButton;
|
||||
final String newChatCreateGroupChatButton;
|
||||
final String newGroupChatCreateGroupChatButton;
|
||||
final String newGroupChatNextButton;
|
||||
final String imagePickerCancelButton;
|
||||
|
||||
// Icon buttons
|
||||
final String chatSelectImageIconButton;
|
||||
final String chatSendMessageIconButton;
|
||||
final String newChatSearchIconButton;
|
||||
final String newGroupChatSearchIconButton;
|
||||
|
||||
// Inkwells
|
||||
final String chatBackButton;
|
||||
final String chatTitleButton;
|
||||
final String newGroupChatSelectImage;
|
||||
final String newGroupChatRemoveImage;
|
||||
final String newGroupChatRemoveUser;
|
||||
|
||||
// Indexed inkwells
|
||||
final String Function(int index) profileTapUserButton;
|
||||
final String Function(int index) chatsOpenChatButton;
|
||||
final String Function(int index) userListTapUser;
|
||||
|
||||
ChatSemantics copyWith({
|
||||
String? profileTitle,
|
||||
String? profileDescription,
|
||||
String? chatUnreadMessages,
|
||||
String? chatChatTitle,
|
||||
String? chatNoMessages,
|
||||
String? newChatGetUsersError,
|
||||
String? newGroupChatMemberAmount,
|
||||
String? newGroupChatGetUsersError,
|
||||
String Function(int)? newChatUserListUserFullName,
|
||||
String Function(int)? chatBubbleTitle,
|
||||
String Function(int)? chatBubbleTime,
|
||||
String Function(int)? chatBubbleText,
|
||||
String Function(int)? chatsChatTitle,
|
||||
String Function(int)? chatsChatSubTitle,
|
||||
String Function(int)? chatsChatLastUsed,
|
||||
String Function(int)? chatsChatUnreadMessages,
|
||||
String? chatMessageInput,
|
||||
String? newChatNameInput,
|
||||
String? newChatBioInput,
|
||||
String? newChatSearchInput,
|
||||
String? newGroupChatSearchInput,
|
||||
String? profileStartChatButton,
|
||||
String? chatsStartChatButton,
|
||||
String? chatsDeleteConfirmButton,
|
||||
String? newChatCreateGroupChatButton,
|
||||
String? newGroupChatCreateGroupChatButton,
|
||||
String? newGroupChatNextButton,
|
||||
String? imagePickerCancelButton,
|
||||
String? chatSelectImageIconButton,
|
||||
String? chatSendMessageIconButton,
|
||||
String? newChatSearchIconButton,
|
||||
String? newGroupChatSearchIconButton,
|
||||
String? chatBackButton,
|
||||
String? chatTitleButton,
|
||||
String? newGroupChatSelectImage,
|
||||
String? newGroupChatRemoveImage,
|
||||
String? newGroupChatRemoveUser,
|
||||
String Function(int)? profileTapUserButton,
|
||||
String Function(int)? chatsOpenChatButton,
|
||||
String Function(int)? userListTapUser,
|
||||
}) =>
|
||||
ChatSemantics(
|
||||
profileTitle: profileTitle ?? this.profileTitle,
|
||||
profileDescription: profileDescription ?? this.profileDescription,
|
||||
chatUnreadMessages: chatUnreadMessages ?? this.chatUnreadMessages,
|
||||
chatChatTitle: chatChatTitle ?? this.chatChatTitle,
|
||||
chatNoMessages: chatNoMessages ?? this.chatNoMessages,
|
||||
newChatGetUsersError: newChatGetUsersError ?? this.newChatGetUsersError,
|
||||
newGroupChatMemberAmount:
|
||||
newGroupChatMemberAmount ?? this.newGroupChatMemberAmount,
|
||||
newGroupChatGetUsersError:
|
||||
newGroupChatGetUsersError ?? this.newGroupChatGetUsersError,
|
||||
newChatUserListUserFullName:
|
||||
newChatUserListUserFullName ?? this.newChatUserListUserFullName,
|
||||
chatBubbleTitle: chatBubbleTitle ?? this.chatBubbleTitle,
|
||||
chatBubbleTime: chatBubbleTime ?? this.chatBubbleTime,
|
||||
chatBubbleText: chatBubbleText ?? this.chatBubbleText,
|
||||
chatsChatTitle: chatsChatTitle ?? this.chatsChatTitle,
|
||||
chatsChatSubTitle: chatsChatSubTitle ?? this.chatsChatSubTitle,
|
||||
chatsChatLastUsed: chatsChatLastUsed ?? this.chatsChatLastUsed,
|
||||
chatsChatUnreadMessages:
|
||||
chatsChatUnreadMessages ?? this.chatsChatUnreadMessages,
|
||||
chatMessageInput: chatMessageInput ?? this.chatMessageInput,
|
||||
newChatNameInput: newChatNameInput ?? this.newChatNameInput,
|
||||
newChatBioInput: newChatBioInput ?? this.newChatBioInput,
|
||||
newChatSearchInput: newChatSearchInput ?? this.newChatSearchInput,
|
||||
newGroupChatSearchInput:
|
||||
newGroupChatSearchInput ?? this.newGroupChatSearchInput,
|
||||
profileStartChatButton:
|
||||
profileStartChatButton ?? this.profileStartChatButton,
|
||||
chatsStartChatButton: chatsStartChatButton ?? this.chatsStartChatButton,
|
||||
chatsDeleteConfirmButton:
|
||||
chatsDeleteConfirmButton ?? this.chatsDeleteConfirmButton,
|
||||
newChatCreateGroupChatButton:
|
||||
newChatCreateGroupChatButton ?? this.newChatCreateGroupChatButton,
|
||||
newGroupChatCreateGroupChatButton: newGroupChatCreateGroupChatButton ??
|
||||
this.newGroupChatCreateGroupChatButton,
|
||||
newGroupChatNextButton:
|
||||
newGroupChatNextButton ?? this.newGroupChatNextButton,
|
||||
imagePickerCancelButton:
|
||||
imagePickerCancelButton ?? this.imagePickerCancelButton,
|
||||
chatSelectImageIconButton:
|
||||
chatSelectImageIconButton ?? this.chatSelectImageIconButton,
|
||||
chatSendMessageIconButton:
|
||||
chatSendMessageIconButton ?? this.chatSendMessageIconButton,
|
||||
newChatSearchIconButton:
|
||||
newChatSearchIconButton ?? this.newChatSearchIconButton,
|
||||
newGroupChatSearchIconButton:
|
||||
newGroupChatSearchIconButton ?? this.newGroupChatSearchIconButton,
|
||||
chatBackButton: chatBackButton ?? this.chatBackButton,
|
||||
chatTitleButton: chatTitleButton ?? this.chatTitleButton,
|
||||
newGroupChatSelectImage:
|
||||
newGroupChatSelectImage ?? this.newGroupChatSelectImage,
|
||||
newGroupChatRemoveImage:
|
||||
newGroupChatRemoveImage ?? this.newGroupChatRemoveImage,
|
||||
newGroupChatRemoveUser:
|
||||
newGroupChatRemoveUser ?? this.newGroupChatRemoveUser,
|
||||
profileTapUserButton: profileTapUserButton ?? this.profileTapUserButton,
|
||||
chatsOpenChatButton: chatsOpenChatButton ?? this.chatsOpenChatButton,
|
||||
userListTapUser: userListTapUser ?? this.userListTapUser,
|
||||
);
|
||||
}
|
||||
|
||||
String _defaultNewChatUserListUserFullName(int index) =>
|
||||
"text_user_fullname_$index";
|
||||
String _defaultChatBubbleTitle(int index) => "text_chat_bubble_title_$index";
|
||||
String _defaultChatBubbleTime(int index) => "text_chat_bubble_time_$index";
|
||||
String _defaultChatBubbleText(int index) => "text_chat_bubble_text_$index";
|
||||
String _defaultChatsChatTitle(int index) => "text_chat_title_$index";
|
||||
String _defaultChatsChatSubTitle(int index) => "text_chat_sub_title_$index";
|
||||
String _defaultChatsChatLastUsed(int index) => "text_chat_last_used_$index";
|
||||
String _defaultChatsChatUnreadMessages(int index) =>
|
||||
"text_chat_unread_messages_$index";
|
||||
String _defaultProfileTapUserButton(int index) => "button_tap_user_$index";
|
||||
String _defaultChatsOpenChatButton(int index) => "button_open_chat_$index";
|
||||
String _defaultUserListTapUser(int index) => "button_tap_user_$index";
|
|
@ -0,0 +1,105 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:flutter_chat/flutter_chat.dart";
|
||||
export "package:flutter_chat/src/screens/chat_detail/widgets/default_chat_time_indicator.dart";
|
||||
|
||||
/// All options related to the time indicator
|
||||
class ChatTimeIndicatorOptions {
|
||||
/// Create default ChatTimeIndicator options
|
||||
const ChatTimeIndicatorOptions({
|
||||
this.indicatorBuilder = DefaultChatTimeIndicator.builder,
|
||||
this.labelResolver = defaultChatTimeIndicatorLabelResolver,
|
||||
this.sectionCheck = defaultChatTimeIndicatorSectionChecker,
|
||||
});
|
||||
|
||||
/// This completely disables the chat time indicator feature
|
||||
const ChatTimeIndicatorOptions.none()
|
||||
: indicatorBuilder = DefaultChatTimeIndicator.builder,
|
||||
labelResolver = defaultChatTimeIndicatorLabelResolver,
|
||||
sectionCheck = neverShowChatTimeIndicatorSectionChecker;
|
||||
|
||||
/// The general builder for the indicator
|
||||
final ChatTimeIndicatorBuilder indicatorBuilder;
|
||||
|
||||
/// A function that translates offset / time to a string label
|
||||
final ChatTimeIndicatorLabelResolver labelResolver;
|
||||
|
||||
/// A function that determines when a new section starts
|
||||
///
|
||||
/// By default, all messages are prefixed with a message.
|
||||
/// You can disable this using the [skipFirstChatTimeIndicatorSectionChecker]
|
||||
/// instead of the default, which would skip the first section
|
||||
final ChatTimeIndicatorSectionChecker sectionCheck;
|
||||
|
||||
/// public method on the options for readability
|
||||
bool isMessageInNewTimeSection(
|
||||
BuildContext context,
|
||||
MessageModel? previousMessage,
|
||||
MessageModel currentMessage,
|
||||
) =>
|
||||
sectionCheck(
|
||||
context,
|
||||
previousMessage,
|
||||
currentMessage,
|
||||
);
|
||||
}
|
||||
|
||||
/// A function that would generate a string given the current window/datetime
|
||||
typedef ChatTimeIndicatorLabelResolver = String Function(
|
||||
BuildContext context,
|
||||
int dayOffset,
|
||||
DateTime currentWindow,
|
||||
);
|
||||
|
||||
/// A function that would determine if a chat indicator has to render
|
||||
typedef ChatTimeIndicatorSectionChecker = bool Function(
|
||||
BuildContext context,
|
||||
MessageModel? previousMessage,
|
||||
MessageModel currentMessage,
|
||||
);
|
||||
|
||||
/// Build used to render time indicators on chat detail screens
|
||||
typedef ChatTimeIndicatorBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
String timeLabel,
|
||||
);
|
||||
|
||||
///
|
||||
String defaultChatTimeIndicatorLabelResolver(
|
||||
BuildContext context,
|
||||
int dayOffset,
|
||||
DateTime currentWindow,
|
||||
) {
|
||||
var translations = ChatScope.of(context).options.translations;
|
||||
return translations.chatTimeIndicatorLabel(dayOffset, currentWindow);
|
||||
}
|
||||
|
||||
/// A function that disables the time indicator in chat
|
||||
bool neverShowChatTimeIndicatorSectionChecker(
|
||||
BuildContext context,
|
||||
MessageModel? previousMessage,
|
||||
MessageModel currentMessage,
|
||||
) =>
|
||||
false;
|
||||
|
||||
/// Variant of the default implementation for determining if a new section
|
||||
/// starts, where the first section is skipped.
|
||||
///
|
||||
/// Renders a new indicator every new section, skipping the first section
|
||||
bool skipFirstChatTimeIndicatorSectionChecker(
|
||||
BuildContext context,
|
||||
MessageModel? previousMessage,
|
||||
MessageModel currentMessage,
|
||||
) =>
|
||||
previousMessage != null &&
|
||||
previousMessage.timestamp.date.isBefore(currentMessage.timestamp.date);
|
||||
|
||||
/// Default implementation for determining if a new section starts.
|
||||
///
|
||||
/// Renders a new indicator every new section
|
||||
bool defaultChatTimeIndicatorSectionChecker(
|
||||
BuildContext context,
|
||||
MessageModel? previousMessage,
|
||||
MessageModel currentMessage,
|
||||
) =>
|
||||
previousMessage == null ||
|
||||
previousMessage.timestamp.date.isBefore(currentMessage.timestamp.date);
|
273
packages/flutter_chat/lib/src/config/chat_translations.dart
Normal file
273
packages/flutter_chat/lib/src/config/chat_translations.dart
Normal file
|
@ -0,0 +1,273 @@
|
|||
// SPDX-FileCopyrightText: 2022 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// ignore_for_file: public_member_api_docs
|
||||
|
||||
import "package:intl/intl.dart";
|
||||
|
||||
/// Class that holds all the translations for the chat component view and
|
||||
/// the corresponding userstory
|
||||
class ChatTranslations {
|
||||
/// ChatTranslations constructor where everything is required use this
|
||||
/// if you want to be sure to have all translations specified
|
||||
/// If you just want the default values use the empty constructor
|
||||
/// and optionally override the values with the copyWith method
|
||||
const ChatTranslations({
|
||||
required this.chatsTitle,
|
||||
required this.chatsUnread,
|
||||
required this.newChatButton,
|
||||
required this.newGroupChatButton,
|
||||
required this.newChatTitle,
|
||||
required this.image,
|
||||
required this.searchPlaceholder,
|
||||
required this.startTyping,
|
||||
required this.cancelImagePickerBtn,
|
||||
required this.messagePlaceholder,
|
||||
required this.writeMessageToStartChat,
|
||||
required this.writeFirstMessageInGroupChat,
|
||||
required this.imageUploading,
|
||||
required this.deleteChatModalTitle,
|
||||
required this.deleteChatModalDescription,
|
||||
required this.deleteChatModalCancel,
|
||||
required this.deleteChatModalConfirm,
|
||||
required this.noUsersFound,
|
||||
required this.noChatsFound,
|
||||
required this.chatProfileUsers,
|
||||
required this.imagePickerTitle,
|
||||
required this.uploadFile,
|
||||
required this.takePicture,
|
||||
required this.anonymousUser,
|
||||
required this.groupNameValidatorEmpty,
|
||||
required this.groupNameValidatorTooLong,
|
||||
required this.groupNameHintText,
|
||||
required this.newGroupChatTitle,
|
||||
required this.groupBioHintText,
|
||||
required this.groupProfileBioHeader,
|
||||
required this.groupBioValidatorEmpty,
|
||||
required this.groupChatNameFieldHeader,
|
||||
required this.groupBioFieldHeader,
|
||||
required this.selectedMembersHeader,
|
||||
required this.createGroupChatButton,
|
||||
required this.groupNameEmpty,
|
||||
required this.messagesLoadingError,
|
||||
required this.next,
|
||||
required this.chatTimeIndicatorLabel,
|
||||
});
|
||||
|
||||
/// Default translations for the chat component view
|
||||
const ChatTranslations.empty({
|
||||
this.chatsTitle = "Chats",
|
||||
this.chatsUnread = "unread",
|
||||
this.newChatButton = "Start chat",
|
||||
this.newGroupChatButton = "Start a groupchat",
|
||||
this.newChatTitle = "Start a chat",
|
||||
this.image = "Image",
|
||||
this.searchPlaceholder = "Search...",
|
||||
this.startTyping = "Start typing to find a user to chat with",
|
||||
this.cancelImagePickerBtn = "Cancel",
|
||||
this.messagePlaceholder = "Write your message here...",
|
||||
this.writeMessageToStartChat = "Write a message to start the chat",
|
||||
this.writeFirstMessageInGroupChat =
|
||||
"Write the first message in this group chat",
|
||||
this.imageUploading = "Image is uploading...",
|
||||
this.deleteChatModalTitle = "Delete chat",
|
||||
this.deleteChatModalDescription =
|
||||
"Are you sure you want to delete this chat?",
|
||||
this.deleteChatModalCancel = "Cancel",
|
||||
this.deleteChatModalConfirm = "Confirm",
|
||||
this.noUsersFound = "No users were found to start a chat with",
|
||||
this.noChatsFound = "Click on 'Start a chat' to create a new chat",
|
||||
this.anonymousUser = "Anonymous user",
|
||||
this.chatProfileUsers = "Members:",
|
||||
this.imagePickerTitle = "Do you want to upload a file or take a picture?",
|
||||
this.uploadFile = "UPLOAD FILE",
|
||||
this.takePicture = "TAKE PICTURE",
|
||||
this.groupNameHintText = "Groupchat name",
|
||||
this.groupNameValidatorEmpty = "Please enter a group chat name",
|
||||
this.groupNameValidatorTooLong =
|
||||
"Group name is too long, max 15 characters",
|
||||
this.newGroupChatTitle = "start a groupchat",
|
||||
this.groupBioHintText = "Bio",
|
||||
this.groupProfileBioHeader = "Bio",
|
||||
this.groupBioValidatorEmpty = "Please enter a bio",
|
||||
this.groupChatNameFieldHeader = "Chat name",
|
||||
this.groupBioFieldHeader = "Additional information for members",
|
||||
this.selectedMembersHeader = "Members: ",
|
||||
this.createGroupChatButton = "Create groupchat",
|
||||
this.groupNameEmpty = "Group",
|
||||
this.messagesLoadingError = "Error loading messages, you can reload below:",
|
||||
this.next = "Next",
|
||||
this.chatTimeIndicatorLabel =
|
||||
ChatTranslations.defaultChatTimeIndicatorLabel,
|
||||
});
|
||||
|
||||
final String chatsTitle;
|
||||
final String chatsUnread;
|
||||
final String newChatButton;
|
||||
final String newGroupChatButton;
|
||||
final String newChatTitle;
|
||||
final String image;
|
||||
final String searchPlaceholder;
|
||||
final String startTyping;
|
||||
final String cancelImagePickerBtn;
|
||||
final String messagePlaceholder;
|
||||
final String writeMessageToStartChat;
|
||||
final String writeFirstMessageInGroupChat;
|
||||
final String imageUploading;
|
||||
final String deleteChatModalTitle;
|
||||
final String deleteChatModalDescription;
|
||||
final String deleteChatModalCancel;
|
||||
final String deleteChatModalConfirm;
|
||||
final String noUsersFound;
|
||||
final String noChatsFound;
|
||||
final String chatProfileUsers;
|
||||
final String imagePickerTitle;
|
||||
final String uploadFile;
|
||||
final String takePicture;
|
||||
final String groupChatNameFieldHeader;
|
||||
final String groupBioFieldHeader;
|
||||
final String selectedMembersHeader;
|
||||
final String createGroupChatButton;
|
||||
|
||||
/// Shown when the user has no name
|
||||
final String anonymousUser;
|
||||
final String groupNameValidatorEmpty;
|
||||
final String groupNameValidatorTooLong;
|
||||
final String groupNameHintText;
|
||||
final String newGroupChatTitle;
|
||||
final String groupBioHintText;
|
||||
final String groupProfileBioHeader;
|
||||
final String groupBioValidatorEmpty;
|
||||
final String groupNameEmpty;
|
||||
|
||||
/// message shown in the default chat screen when the chat messages are unable
|
||||
/// to be loaded.
|
||||
final String messagesLoadingError;
|
||||
|
||||
/// The message of a label given a certain offset.
|
||||
///
|
||||
/// The offset determines whether it is today (0), yesterday (-1), or earlier.
|
||||
///
|
||||
/// [dateOffset] will rarely be a +1, however if anyone ever wants to see
|
||||
/// future chat messages, then this number will be positive.
|
||||
///
|
||||
/// use the given [time] format to display exact time information.
|
||||
final String Function(int dateOffset, DateTime time) chatTimeIndicatorLabel;
|
||||
|
||||
/// Standard function to convert an offset to a String.
|
||||
///
|
||||
/// Recommended to always override this in any production app with an
|
||||
/// app localizations implementation.
|
||||
static String defaultChatTimeIndicatorLabel(
|
||||
int dateOffset,
|
||||
DateTime time,
|
||||
) =>
|
||||
switch (dateOffset) {
|
||||
0 => "Today",
|
||||
-1 => "Yesterday",
|
||||
1 => "Tomorrow",
|
||||
int value when value < 5 && value > 1 => "In $value days",
|
||||
int value when value < -1 && value > -5 => "${value.abs()} days ago",
|
||||
_ => DateFormat("dd-MM-YYYY").format(time),
|
||||
};
|
||||
|
||||
final String next;
|
||||
|
||||
// copyWith method to override the default values
|
||||
ChatTranslations copyWith({
|
||||
String? chatsTitle,
|
||||
String? chatsUnread,
|
||||
String? newChatButton,
|
||||
String? newGroupChatButton,
|
||||
String? newChatTitle,
|
||||
String? image,
|
||||
String? searchPlaceholder,
|
||||
String? startTyping,
|
||||
String? cancelImagePickerBtn,
|
||||
String? messagePlaceholder,
|
||||
String? writeMessageToStartChat,
|
||||
String? writeFirstMessageInGroupChat,
|
||||
String? imageUploading,
|
||||
String? deleteChatModalTitle,
|
||||
String? deleteChatModalDescription,
|
||||
String? deleteChatModalCancel,
|
||||
String? deleteChatModalConfirm,
|
||||
String? noUsersFound,
|
||||
String? noChatsFound,
|
||||
String? chatProfileUsers,
|
||||
String? imagePickerTitle,
|
||||
String? uploadFile,
|
||||
String? takePicture,
|
||||
String? anonymousUser,
|
||||
String? groupNameValidatorEmpty,
|
||||
String? groupNameValidatorTooLong,
|
||||
String? groupNameHintText,
|
||||
String? newGroupChatTitle,
|
||||
String? groupBioHintText,
|
||||
String? groupProfileBioHeader,
|
||||
String? groupBioValidatorEmpty,
|
||||
String? groupChatNameFieldHeader,
|
||||
String? groupBioFieldHeader,
|
||||
String? selectedMembersHeader,
|
||||
String? createGroupChatButton,
|
||||
String? groupNameEmpty,
|
||||
String? messagesLoadingError,
|
||||
String? next,
|
||||
String Function(int dateOffset, DateTime time)? chatTimeIndicatorLabel,
|
||||
}) =>
|
||||
ChatTranslations(
|
||||
chatsTitle: chatsTitle ?? this.chatsTitle,
|
||||
chatsUnread: chatsUnread ?? this.chatsUnread,
|
||||
newChatButton: newChatButton ?? this.newChatButton,
|
||||
newGroupChatButton: newGroupChatButton ?? this.newGroupChatButton,
|
||||
newChatTitle: newChatTitle ?? this.newChatTitle,
|
||||
image: image ?? this.image,
|
||||
searchPlaceholder: searchPlaceholder ?? this.searchPlaceholder,
|
||||
startTyping: startTyping ?? this.startTyping,
|
||||
cancelImagePickerBtn: cancelImagePickerBtn ?? this.cancelImagePickerBtn,
|
||||
messagePlaceholder: messagePlaceholder ?? this.messagePlaceholder,
|
||||
writeMessageToStartChat:
|
||||
writeMessageToStartChat ?? this.writeMessageToStartChat,
|
||||
writeFirstMessageInGroupChat:
|
||||
writeFirstMessageInGroupChat ?? this.writeFirstMessageInGroupChat,
|
||||
imageUploading: imageUploading ?? this.imageUploading,
|
||||
deleteChatModalTitle: deleteChatModalTitle ?? this.deleteChatModalTitle,
|
||||
deleteChatModalDescription:
|
||||
deleteChatModalDescription ?? this.deleteChatModalDescription,
|
||||
deleteChatModalCancel:
|
||||
deleteChatModalCancel ?? this.deleteChatModalCancel,
|
||||
deleteChatModalConfirm:
|
||||
deleteChatModalConfirm ?? this.deleteChatModalConfirm,
|
||||
noUsersFound: noUsersFound ?? this.noUsersFound,
|
||||
noChatsFound: noChatsFound ?? this.noChatsFound,
|
||||
chatProfileUsers: chatProfileUsers ?? this.chatProfileUsers,
|
||||
imagePickerTitle: imagePickerTitle ?? this.imagePickerTitle,
|
||||
uploadFile: uploadFile ?? this.uploadFile,
|
||||
takePicture: takePicture ?? this.takePicture,
|
||||
anonymousUser: anonymousUser ?? this.anonymousUser,
|
||||
groupNameValidatorEmpty:
|
||||
groupNameValidatorEmpty ?? this.groupNameValidatorEmpty,
|
||||
groupNameValidatorTooLong:
|
||||
groupNameValidatorTooLong ?? this.groupNameValidatorTooLong,
|
||||
groupNameHintText: groupNameHintText ?? this.groupNameHintText,
|
||||
newGroupChatTitle: newGroupChatTitle ?? this.newGroupChatTitle,
|
||||
groupBioHintText: groupBioHintText ?? this.groupBioHintText,
|
||||
groupProfileBioHeader:
|
||||
groupProfileBioHeader ?? this.groupProfileBioHeader,
|
||||
groupBioValidatorEmpty:
|
||||
groupBioValidatorEmpty ?? this.groupBioValidatorEmpty,
|
||||
groupChatNameFieldHeader:
|
||||
groupChatNameFieldHeader ?? this.groupChatNameFieldHeader,
|
||||
groupBioFieldHeader: groupBioFieldHeader ?? this.groupBioFieldHeader,
|
||||
selectedMembersHeader:
|
||||
selectedMembersHeader ?? this.selectedMembersHeader,
|
||||
createGroupChatButton:
|
||||
createGroupChatButton ?? this.createGroupChatButton,
|
||||
groupNameEmpty: groupNameEmpty ?? this.groupNameEmpty,
|
||||
messagesLoadingError: messagesLoadingError ?? this.messagesLoadingError,
|
||||
next: next ?? this.next,
|
||||
chatTimeIndicatorLabel:
|
||||
chatTimeIndicatorLabel ?? this.chatTimeIndicatorLabel,
|
||||
);
|
||||
}
|
41
packages/flutter_chat/lib/src/config/screen_types.dart
Normal file
41
packages/flutter_chat/lib/src/config/screen_types.dart
Normal file
|
@ -0,0 +1,41 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:flutter_chat/src/screens/chat_detail/chat_detail_screen.dart";
|
||||
import "package:flutter_chat/src/screens/chat_profile_screen.dart";
|
||||
import "package:flutter_chat/src/screens/chat_screen.dart";
|
||||
import "package:flutter_chat/src/screens/creation/new_chat_screen.dart";
|
||||
import "package:flutter_chat/src/screens/creation/new_group_chat_overview.dart";
|
||||
import "package:flutter_chat/src/screens/creation/new_group_chat_screen.dart";
|
||||
|
||||
/// Type of screen, used in custom screen builders
|
||||
enum ScreenType {
|
||||
/// Screen displaying an overview of chats
|
||||
chatScreen(screen: ChatScreen),
|
||||
|
||||
/// Screen displaying a single chat
|
||||
chatDetailScreen(screen: ChatDetailScreen),
|
||||
|
||||
/// Screen displaying the profile of a user within a chat
|
||||
chatProfileScreen(screen: ChatProfileScreen),
|
||||
|
||||
/// Screen with a form to create a new chat
|
||||
newChatScreen(screen: NewChatScreen),
|
||||
|
||||
/// Screen with a form to create a new group chat
|
||||
newGroupChatScreen(screen: NewGroupChatScreen),
|
||||
|
||||
/// Screen displaying all group chats
|
||||
newGroupChatOverview(screen: NewGroupChatOverview);
|
||||
|
||||
const ScreenType({
|
||||
required Type screen,
|
||||
}) : _screen = screen;
|
||||
|
||||
final Type _screen;
|
||||
}
|
||||
|
||||
/// Extension for mapping widgets to [ScreenType]s
|
||||
extension MapFromWidget on Widget {
|
||||
/// returns corresponding [ScreenType]
|
||||
ScreenType get mapScreenType =>
|
||||
ScreenType.values.firstWhere((e) => e._screen == runtimeType);
|
||||
}
|
218
packages/flutter_chat/lib/src/flutter_chat_entry_widget.dart
Normal file
218
packages/flutter_chat/lib/src/flutter_chat_entry_widget.dart
Normal file
|
@ -0,0 +1,218 @@
|
|||
import "dart:async";
|
||||
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_accessibility/flutter_accessibility.dart";
|
||||
import "package:flutter_chat/flutter_chat.dart";
|
||||
|
||||
/// A widget representing an entry point for a chat UI.
|
||||
class FlutterChatEntryWidget extends StatefulWidget {
|
||||
/// Constructs a [FlutterChatEntryWidget].
|
||||
const FlutterChatEntryWidget({
|
||||
required this.userId,
|
||||
this.options,
|
||||
this.onTap,
|
||||
this.widgetSize = 75,
|
||||
this.backgroundColor = Colors.grey,
|
||||
this.icon = Icons.chat,
|
||||
this.iconColor = Colors.black,
|
||||
this.counterBackgroundColor = Colors.red,
|
||||
this.textStyle,
|
||||
this.semanticIdUnreadMessages = "text_unread_messages_count",
|
||||
this.semanticIdOpenButton = "button_open_chat",
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The user ID of the person currently looking at the chat
|
||||
final String userId;
|
||||
|
||||
/// Background color of the widget.
|
||||
final Color backgroundColor;
|
||||
|
||||
/// Size of the widget.
|
||||
final double widgetSize;
|
||||
|
||||
/// Background color of the counter.
|
||||
final Color counterBackgroundColor;
|
||||
|
||||
/// Callback function triggered when the widget is tapped.
|
||||
final Function()? onTap;
|
||||
|
||||
/// Icon to be displayed.
|
||||
final IconData icon;
|
||||
|
||||
/// Color of the icon.
|
||||
final Color iconColor;
|
||||
|
||||
/// Text style for the counter.
|
||||
final TextStyle? textStyle;
|
||||
|
||||
/// The chat options
|
||||
final ChatOptions? options;
|
||||
|
||||
/// Semantic Id for the unread messages text
|
||||
final String semanticIdUnreadMessages;
|
||||
|
||||
/// Semantic Id for the unread messages text
|
||||
final String semanticIdOpenButton;
|
||||
|
||||
@override
|
||||
State<FlutterChatEntryWidget> createState() => _FlutterChatEntryWidgetState();
|
||||
}
|
||||
|
||||
/// State class for [FlutterChatEntryWidget].
|
||||
class _FlutterChatEntryWidgetState extends State<FlutterChatEntryWidget> {
|
||||
late ChatService chatService;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initChatService();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant FlutterChatEntryWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.userId != widget.userId ||
|
||||
oldWidget.options != widget.options) {
|
||||
_initChatService();
|
||||
}
|
||||
}
|
||||
|
||||
void _initChatService() {
|
||||
chatService = ChatService(
|
||||
userId: widget.userId,
|
||||
chatRepository: widget.options?.chatRepository,
|
||||
userRepository: widget.options?.userRepository,
|
||||
pendingMessageRepository: widget.options?.pendingMessagesRepository,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => CustomSemantics(
|
||||
identifier: widget.semanticIdOpenButton,
|
||||
buttonWithVariableText: true,
|
||||
child: InkWell(
|
||||
onTap: () async =>
|
||||
widget.onTap?.call() ??
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => FlutterChatNavigatorUserstory(
|
||||
userId: widget.userId,
|
||||
options: widget.options ?? ChatOptions(),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: StreamBuilder<int>(
|
||||
stream: chatService.getUnreadMessagesCount(),
|
||||
builder: (BuildContext context, snapshot) => Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: widget.widgetSize,
|
||||
height: widget.widgetSize,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: widget.backgroundColor,
|
||||
),
|
||||
child: _AnimatedNotificationIcon(
|
||||
icon: Icon(
|
||||
widget.icon,
|
||||
color: widget.iconColor,
|
||||
size: widget.widgetSize / 1.5,
|
||||
),
|
||||
notifications: snapshot.data ?? 0,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 0.0,
|
||||
top: 0.0,
|
||||
child: Container(
|
||||
width: widget.widgetSize / 2,
|
||||
height: widget.widgetSize / 2,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: widget.counterBackgroundColor,
|
||||
),
|
||||
child: Center(
|
||||
child: CustomSemantics(
|
||||
identifier: widget.semanticIdUnreadMessages,
|
||||
value: snapshot.data?.toString() ?? "0",
|
||||
child: Text(
|
||||
snapshot.data?.toString() ?? "0",
|
||||
style: widget.textStyle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Stateful widget representing an animated notification icon.
|
||||
class _AnimatedNotificationIcon extends StatefulWidget {
|
||||
const _AnimatedNotificationIcon({
|
||||
required this.notifications,
|
||||
required this.icon,
|
||||
});
|
||||
|
||||
/// The number of notifications.
|
||||
final int notifications;
|
||||
|
||||
/// The icon to be displayed.
|
||||
final Icon icon;
|
||||
|
||||
@override
|
||||
State<_AnimatedNotificationIcon> createState() =>
|
||||
_AnimatedNotificationIconState();
|
||||
}
|
||||
|
||||
/// State class for [_AnimatedNotificationIcon].
|
||||
class _AnimatedNotificationIconState extends State<_AnimatedNotificationIcon>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
);
|
||||
|
||||
if (widget.notifications != 0) {
|
||||
unawaited(_runAnimation());
|
||||
}
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _AnimatedNotificationIcon oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
if (oldWidget.notifications != widget.notifications) {
|
||||
unawaited(_runAnimation());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _runAnimation() async {
|
||||
await _animationController.forward();
|
||||
await _animationController.reverse();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => RotationTransition(
|
||||
turns: Tween(begin: 0.0, end: -.1)
|
||||
.chain(CurveTween(curve: Curves.elasticIn))
|
||||
.animate(_animationController),
|
||||
child: widget.icon,
|
||||
);
|
||||
}
|
|
@ -0,0 +1,121 @@
|
|||
// SPDX-FileCopyrightText: 2023 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
import "package:chat_repository_interface/chat_repository_interface.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_chat/src/config/chat_options.dart";
|
||||
import "package:flutter_chat/src/routes.dart";
|
||||
import "package:flutter_chat/src/services/pop_handler.dart";
|
||||
import "package:flutter_chat/src/util/scope.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
|
||||
/// Default Chat Userstory that starts at the chat list screen.
|
||||
class FlutterChatNavigatorUserstory extends _BaseChatNavigatorUserstory {
|
||||
/// Constructs a [FlutterChatNavigatorUserstory].
|
||||
const FlutterChatNavigatorUserstory({
|
||||
required super.userId,
|
||||
required super.options,
|
||||
super.onExit,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
MaterialPageRoute buildInitialRoute(
|
||||
BuildContext context,
|
||||
ChatService service,
|
||||
PopHandler popHandler,
|
||||
) =>
|
||||
chatOverviewRoute(
|
||||
userId: userId,
|
||||
chatService: service,
|
||||
onExit: onExit,
|
||||
);
|
||||
}
|
||||
|
||||
/// Chat Userstory that starts directly in a chat detail screen.
|
||||
class FlutterChatDetailNavigatorUserstory extends _BaseChatNavigatorUserstory {
|
||||
/// Constructs a [FlutterChatDetailNavigatorUserstory].
|
||||
const FlutterChatDetailNavigatorUserstory({
|
||||
required super.userId,
|
||||
required super.options,
|
||||
required this.chatId,
|
||||
super.onExit,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The identifier of the chat to start in.
|
||||
/// The [ChatModel] will be fetched from the [ChatRepository]
|
||||
final String chatId;
|
||||
|
||||
@override
|
||||
MaterialPageRoute buildInitialRoute(
|
||||
BuildContext context,
|
||||
ChatService service,
|
||||
PopHandler popHandler,
|
||||
) =>
|
||||
chatDetailRoute(
|
||||
chatId: chatId,
|
||||
userId: userId,
|
||||
chatService: service,
|
||||
onExit: onExit,
|
||||
);
|
||||
}
|
||||
|
||||
/// Base hook widget for chat navigator userstories.
|
||||
abstract class _BaseChatNavigatorUserstory extends HookWidget {
|
||||
/// Constructs a [_BaseChatNavigatorUserstory].
|
||||
const _BaseChatNavigatorUserstory({
|
||||
required this.userId,
|
||||
required this.options,
|
||||
this.onExit,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The user ID of the person starting the chat userstory.
|
||||
final String userId;
|
||||
|
||||
/// The chat userstory configuration.
|
||||
final ChatOptions options;
|
||||
|
||||
/// Callback for when the user wants to navigate back.
|
||||
final VoidCallback? onExit;
|
||||
|
||||
/// Implemented by subclasses to provide the initial route of the userstory.
|
||||
MaterialPageRoute buildInitialRoute(
|
||||
BuildContext context,
|
||||
ChatService service,
|
||||
PopHandler popHandler,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var service = useMemoized(
|
||||
() => ChatService(
|
||||
userId: userId,
|
||||
chatRepository: options.chatRepository,
|
||||
userRepository: options.userRepository,
|
||||
pendingMessageRepository: options.pendingMessagesRepository,
|
||||
),
|
||||
[userId, options],
|
||||
);
|
||||
|
||||
var popHandler = useMemoized(PopHandler.new, []);
|
||||
var nestedNavigatorKey = useMemoized(GlobalKey<NavigatorState>.new, []);
|
||||
|
||||
return ChatScope(
|
||||
userId: userId,
|
||||
options: options,
|
||||
service: service,
|
||||
popHandler: popHandler,
|
||||
child: NavigatorPopHandler(
|
||||
onPop: () => popHandler.handlePop(),
|
||||
child: Navigator(
|
||||
key: nestedNavigatorKey,
|
||||
onGenerateInitialRoutes: (_, __) => [
|
||||
buildInitialRoute(context, service, popHandler),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,298 +0,0 @@
|
|||
// SPDX-FileCopyrightText: 2023 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_chat/flutter_chat.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// Navigates to the chat user story screen.
|
||||
///
|
||||
/// [context]: The build context.
|
||||
/// [configuration]: The configuration for the chat user story.
|
||||
Widget chatNavigatorUserStory(
|
||||
BuildContext context, {
|
||||
ChatUserStoryConfiguration? configuration,
|
||||
}) =>
|
||||
_chatScreenRoute(
|
||||
configuration ??
|
||||
ChatUserStoryConfiguration(
|
||||
chatService: LocalChatService(),
|
||||
chatOptionsBuilder: (ctx) => const ChatOptions(),
|
||||
),
|
||||
context,
|
||||
);
|
||||
|
||||
/// Constructs the chat screen route widget.
|
||||
///
|
||||
/// [configuration]: The configuration for the chat user story.
|
||||
/// [context]: The build context.
|
||||
Widget _chatScreenRoute(
|
||||
ChatUserStoryConfiguration configuration,
|
||||
BuildContext context,
|
||||
) =>
|
||||
ChatScreen(
|
||||
unreadMessageTextStyle: configuration.unreadMessageTextStyle,
|
||||
service: configuration.chatService,
|
||||
options: configuration.chatOptionsBuilder(context),
|
||||
onNoChats: () async => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _newChatScreenRoute(
|
||||
configuration,
|
||||
context,
|
||||
),
|
||||
),
|
||||
),
|
||||
onPressStartChat: () async {
|
||||
if (configuration.onPressStartChat != null) {
|
||||
return await configuration.onPressStartChat?.call();
|
||||
}
|
||||
|
||||
return Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _newChatScreenRoute(
|
||||
configuration,
|
||||
context,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onPressChat: (chat) async =>
|
||||
configuration.onPressChat?.call(context, chat) ??
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _chatDetailScreenRoute(
|
||||
configuration,
|
||||
context,
|
||||
chat.id!,
|
||||
),
|
||||
),
|
||||
),
|
||||
onDeleteChat: (chat) async =>
|
||||
configuration.onDeleteChat?.call(context, chat) ??
|
||||
configuration.chatService.chatOverviewService.deleteChat(chat),
|
||||
deleteChatDialog: configuration.deleteChatDialog,
|
||||
translations: configuration.translations,
|
||||
);
|
||||
|
||||
/// Constructs the chat detail screen route widget.
|
||||
///
|
||||
/// [configuration]: The configuration for the chat user story.
|
||||
/// [context]: The build context.
|
||||
/// [chatId]: The id of the chat.
|
||||
Widget _chatDetailScreenRoute(
|
||||
ChatUserStoryConfiguration configuration,
|
||||
BuildContext context,
|
||||
String chatId,
|
||||
) =>
|
||||
ChatDetailScreen(
|
||||
chatTitleBuilder: configuration.chatTitleBuilder,
|
||||
usernameBuilder: configuration.usernameBuilder,
|
||||
loadingWidgetBuilder: configuration.loadingWidgetBuilder,
|
||||
iconDisabledColor: configuration.iconDisabledColor,
|
||||
pageSize: configuration.messagePageSize,
|
||||
options: configuration.chatOptionsBuilder(context),
|
||||
translations: configuration.translations,
|
||||
service: configuration.chatService,
|
||||
chatId: chatId,
|
||||
textfieldBottomPadding: configuration.textfieldBottomPadding ?? 0,
|
||||
onPressUserProfile: (userId) async {
|
||||
if (configuration.onPressUserProfile != null) {
|
||||
return configuration.onPressUserProfile?.call();
|
||||
}
|
||||
return Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _chatProfileScreenRoute(
|
||||
configuration,
|
||||
context,
|
||||
chatId,
|
||||
userId,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onMessageSubmit: (message) async {
|
||||
if (configuration.onMessageSubmit != null) {
|
||||
await configuration.onMessageSubmit?.call(message);
|
||||
} else {
|
||||
await configuration.chatService.chatDetailService
|
||||
.sendTextMessage(chatId: chatId, text: message);
|
||||
}
|
||||
|
||||
configuration.afterMessageSent?.call(chatId);
|
||||
},
|
||||
onUploadImage: (image) async {
|
||||
if (configuration.onUploadImage != null) {
|
||||
await configuration.onUploadImage?.call(image);
|
||||
} else {
|
||||
await configuration.chatService.chatDetailService
|
||||
.sendImageMessage(chatId: chatId, image: image);
|
||||
}
|
||||
|
||||
configuration.afterMessageSent?.call(chatId);
|
||||
},
|
||||
onReadChat: (chat) async =>
|
||||
configuration.onReadChat?.call(chat) ??
|
||||
configuration.chatService.chatOverviewService.readChat(chat),
|
||||
onPressChatTitle: (context, chat) async {
|
||||
if (configuration.onPressChatTitle?.call(context, chat) != null) {
|
||||
return configuration.onPressChatTitle?.call(context, chat);
|
||||
}
|
||||
|
||||
return Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _chatProfileScreenRoute(
|
||||
configuration,
|
||||
context,
|
||||
chatId,
|
||||
null,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
iconColor: configuration.iconColor,
|
||||
);
|
||||
|
||||
/// Constructs the chat profile screen route widget.
|
||||
///
|
||||
/// [configuration]: The configuration for the chat user story.
|
||||
/// [context]: The build context.
|
||||
/// [chatId]: The id of the chat.
|
||||
/// [userId]: The id of the user.
|
||||
Widget _chatProfileScreenRoute(
|
||||
ChatUserStoryConfiguration configuration,
|
||||
BuildContext context,
|
||||
String chatId,
|
||||
String? userId,
|
||||
) =>
|
||||
ChatProfileScreen(
|
||||
translations: configuration.translations,
|
||||
chatService: configuration.chatService,
|
||||
chatId: chatId,
|
||||
userId: userId,
|
||||
onTapUser: (user) async {
|
||||
if (configuration.onPressUserProfile != null) {
|
||||
return configuration.onPressUserProfile!.call();
|
||||
}
|
||||
|
||||
return Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _chatProfileScreenRoute(
|
||||
configuration,
|
||||
context,
|
||||
chatId,
|
||||
userId,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/// Constructs the new chat screen route widget.
|
||||
///
|
||||
/// [configuration]: The configuration for the chat user story.
|
||||
/// [context]: The build context.
|
||||
Widget _newChatScreenRoute(
|
||||
ChatUserStoryConfiguration configuration,
|
||||
BuildContext context,
|
||||
) =>
|
||||
NewChatScreen(
|
||||
options: configuration.chatOptionsBuilder(context),
|
||||
translations: configuration.translations,
|
||||
service: configuration.chatService,
|
||||
onPressCreateGroupChat: () async {
|
||||
configuration.onPressCreateGroupChat?.call();
|
||||
if (context.mounted) {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _newGroupChatScreenRoute(
|
||||
configuration,
|
||||
context,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onPressCreateChat: (user) async {
|
||||
configuration.onPressCreateChat?.call(user);
|
||||
if (configuration.onPressCreateChat != null) return;
|
||||
var chat = await configuration.chatService.chatOverviewService
|
||||
.getChatByUser(user);
|
||||
debugPrint('Chat is ${chat.id}');
|
||||
if (chat.id == null) {
|
||||
chat = await configuration.chatService.chatOverviewService
|
||||
.storeChatIfNot(
|
||||
PersonalChatModel(
|
||||
user: user,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (context.mounted) {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _chatDetailScreenRoute(
|
||||
configuration,
|
||||
context,
|
||||
chat.id!,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Widget _newGroupChatScreenRoute(
|
||||
ChatUserStoryConfiguration configuration,
|
||||
BuildContext context,
|
||||
) =>
|
||||
NewGroupChatScreen(
|
||||
options: configuration.chatOptionsBuilder(context),
|
||||
translations: configuration.translations,
|
||||
service: configuration.chatService,
|
||||
onPressGroupChatOverview: (users) async => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _newGroupChatOverviewScreenRoute(
|
||||
configuration,
|
||||
context,
|
||||
users,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _newGroupChatOverviewScreenRoute(
|
||||
ChatUserStoryConfiguration configuration,
|
||||
BuildContext context,
|
||||
List<ChatUserModel> users,
|
||||
) =>
|
||||
NewGroupChatOverviewScreen(
|
||||
options: configuration.chatOptionsBuilder(context),
|
||||
translations: configuration.translations,
|
||||
service: configuration.chatService,
|
||||
users: users,
|
||||
onPressCompleteGroupChatCreation: (users, groupChatName) async {
|
||||
configuration.onPressCompleteGroupChatCreation
|
||||
?.call(users, groupChatName);
|
||||
if (configuration.onPressCreateGroupChat != null) return;
|
||||
var chat =
|
||||
await configuration.chatService.chatOverviewService.storeChatIfNot(
|
||||
GroupChatModel(
|
||||
canBeDeleted: true,
|
||||
title: groupChatName,
|
||||
imageUrl: 'https://picsum.photos/200/300',
|
||||
users: users,
|
||||
),
|
||||
);
|
||||
if (context.mounted) {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _chatDetailScreenRoute(
|
||||
configuration,
|
||||
context,
|
||||
chat.id!,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
|
@ -1,263 +0,0 @@
|
|||
// SPDX-FileCopyrightText: 2023 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_chat/flutter_chat.dart';
|
||||
import 'package:flutter_chat/src/go_router.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
List<GoRoute> getChatStoryRoutes(
|
||||
ChatUserStoryConfiguration configuration,
|
||||
) =>
|
||||
<GoRoute>[
|
||||
GoRoute(
|
||||
path: ChatUserStoryRoutes.chatScreen,
|
||||
pageBuilder: (context, state) {
|
||||
var chatScreen = ChatScreen(
|
||||
unreadMessageTextStyle: configuration.unreadMessageTextStyle,
|
||||
service: configuration.chatService,
|
||||
options: configuration.chatOptionsBuilder(context),
|
||||
onNoChats: () async =>
|
||||
context.push(ChatUserStoryRoutes.newChatScreen),
|
||||
onPressStartChat: () async {
|
||||
if (configuration.onPressStartChat != null) {
|
||||
return await configuration.onPressStartChat?.call();
|
||||
}
|
||||
|
||||
return context.push(ChatUserStoryRoutes.newChatScreen);
|
||||
},
|
||||
onPressChat: (chat) async =>
|
||||
configuration.onPressChat?.call(context, chat) ??
|
||||
context.push(ChatUserStoryRoutes.chatDetailViewPath(chat.id!)),
|
||||
onDeleteChat: (chat) async =>
|
||||
configuration.onDeleteChat?.call(context, chat) ??
|
||||
configuration.chatService.chatOverviewService.deleteChat(chat),
|
||||
deleteChatDialog: configuration.deleteChatDialog,
|
||||
translations: configuration.translations,
|
||||
);
|
||||
return buildScreenWithoutTransition(
|
||||
context: context,
|
||||
state: state,
|
||||
child: configuration.chatPageBuilder?.call(
|
||||
context,
|
||||
chatScreen,
|
||||
) ??
|
||||
Scaffold(
|
||||
body: chatScreen,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: ChatUserStoryRoutes.chatDetailScreen,
|
||||
pageBuilder: (context, state) {
|
||||
var chatId = state.pathParameters['id'];
|
||||
var chatDetailScreen = ChatDetailScreen(
|
||||
chatTitleBuilder: configuration.chatTitleBuilder,
|
||||
usernameBuilder: configuration.usernameBuilder,
|
||||
loadingWidgetBuilder: configuration.loadingWidgetBuilder,
|
||||
iconDisabledColor: configuration.iconDisabledColor,
|
||||
pageSize: configuration.messagePageSize,
|
||||
options: configuration.chatOptionsBuilder(context),
|
||||
translations: configuration.translations,
|
||||
service: configuration.chatService,
|
||||
chatId: chatId!,
|
||||
textfieldBottomPadding: configuration.textfieldBottomPadding ?? 0,
|
||||
onPressUserProfile: (userId) async {
|
||||
if (configuration.onPressUserProfile != null) {
|
||||
return configuration.onPressUserProfile?.call();
|
||||
}
|
||||
return context.push(
|
||||
ChatUserStoryRoutes.chatProfileScreenPath(chatId, userId),
|
||||
);
|
||||
},
|
||||
onMessageSubmit: (message) async {
|
||||
if (configuration.onMessageSubmit != null) {
|
||||
await configuration.onMessageSubmit?.call(message);
|
||||
} else {
|
||||
await configuration.chatService.chatDetailService
|
||||
.sendTextMessage(chatId: chatId, text: message);
|
||||
}
|
||||
configuration.afterMessageSent?.call(chatId);
|
||||
},
|
||||
onUploadImage: (image) async {
|
||||
if (configuration.onUploadImage?.call(image) != null) {
|
||||
await configuration.onUploadImage?.call(image);
|
||||
} else {
|
||||
await configuration.chatService.chatDetailService
|
||||
.sendImageMessage(chatId: chatId, image: image);
|
||||
}
|
||||
configuration.afterMessageSent?.call(chatId);
|
||||
},
|
||||
onReadChat: (chat) async =>
|
||||
configuration.onReadChat?.call(chat) ??
|
||||
configuration.chatService.chatOverviewService.readChat(chat),
|
||||
onPressChatTitle: (context, chat) async {
|
||||
if (configuration.onPressChatTitle?.call(context, chat) != null) {
|
||||
return configuration.onPressChatTitle?.call(context, chat);
|
||||
}
|
||||
|
||||
return context.push(
|
||||
ChatUserStoryRoutes.chatProfileScreenPath(chat.id!, null),
|
||||
);
|
||||
},
|
||||
iconColor: configuration.iconColor,
|
||||
);
|
||||
return buildScreenWithoutTransition(
|
||||
context: context,
|
||||
state: state,
|
||||
child: configuration.chatPageBuilder?.call(
|
||||
context,
|
||||
chatDetailScreen,
|
||||
) ??
|
||||
Scaffold(
|
||||
body: chatDetailScreen,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: ChatUserStoryRoutes.newChatScreen,
|
||||
pageBuilder: (context, state) {
|
||||
var newChatScreen = NewChatScreen(
|
||||
options: configuration.chatOptionsBuilder(context),
|
||||
translations: configuration.translations,
|
||||
service: configuration.chatService,
|
||||
onPressCreateChat: (user) async {
|
||||
configuration.onPressCreateChat?.call(user);
|
||||
if (configuration.onPressCreateChat != null) return;
|
||||
var chat = await configuration.chatService.chatOverviewService
|
||||
.getChatByUser(user);
|
||||
if (chat.id == null) {
|
||||
chat = await configuration.chatService.chatOverviewService
|
||||
.storeChatIfNot(
|
||||
PersonalChatModel(
|
||||
user: user,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (context.mounted) {
|
||||
await context.push(
|
||||
ChatUserStoryRoutes.chatDetailViewPath(chat.id ?? ''),
|
||||
);
|
||||
}
|
||||
},
|
||||
onPressCreateGroupChat: () async => context.push(
|
||||
ChatUserStoryRoutes.newGroupChatScreen,
|
||||
),
|
||||
);
|
||||
return buildScreenWithoutTransition(
|
||||
context: context,
|
||||
state: state,
|
||||
child: configuration.chatPageBuilder?.call(
|
||||
context,
|
||||
newChatScreen,
|
||||
) ??
|
||||
Scaffold(
|
||||
body: newChatScreen,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: ChatUserStoryRoutes.newGroupChatScreen,
|
||||
pageBuilder: (context, state) {
|
||||
var newGroupChatScreen = NewGroupChatScreen(
|
||||
options: configuration.chatOptionsBuilder(context),
|
||||
translations: configuration.translations,
|
||||
service: configuration.chatService,
|
||||
onPressGroupChatOverview: (users) async => context.push(
|
||||
ChatUserStoryRoutes.newGroupChatOverviewScreen,
|
||||
extra: users,
|
||||
),
|
||||
);
|
||||
return buildScreenWithoutTransition(
|
||||
context: context,
|
||||
state: state,
|
||||
child: configuration.chatPageBuilder?.call(
|
||||
context,
|
||||
newGroupChatScreen,
|
||||
) ??
|
||||
Scaffold(
|
||||
body: newGroupChatScreen,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: ChatUserStoryRoutes.newGroupChatOverviewScreen,
|
||||
pageBuilder: (context, state) {
|
||||
var users = state.extra! as List<ChatUserModel>;
|
||||
var newGroupChatOverviewScreen = NewGroupChatOverviewScreen(
|
||||
options: configuration.chatOptionsBuilder(context),
|
||||
translations: configuration.translations,
|
||||
service: configuration.chatService,
|
||||
users: users,
|
||||
onPressCompleteGroupChatCreation: (users, groupChatName) async {
|
||||
configuration.onPressCompleteGroupChatCreation
|
||||
?.call(users, groupChatName);
|
||||
var chat = await configuration.chatService.chatOverviewService
|
||||
.storeChatIfNot(
|
||||
GroupChatModel(
|
||||
canBeDeleted: true,
|
||||
title: groupChatName,
|
||||
imageUrl: 'https://picsum.photos/200/300',
|
||||
users: users,
|
||||
),
|
||||
);
|
||||
if (context.mounted) {
|
||||
await context.push(
|
||||
ChatUserStoryRoutes.chatDetailViewPath(chat.id ?? ''),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
return buildScreenWithoutTransition(
|
||||
context: context,
|
||||
state: state,
|
||||
child: configuration.chatPageBuilder?.call(
|
||||
context,
|
||||
newGroupChatOverviewScreen,
|
||||
) ??
|
||||
Scaffold(
|
||||
body: newGroupChatOverviewScreen,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: ChatUserStoryRoutes.chatProfileScreen,
|
||||
pageBuilder: (context, state) {
|
||||
var chatId = state.pathParameters['id'];
|
||||
var userId = state.pathParameters['userId'];
|
||||
var id = userId == 'null' ? null : userId;
|
||||
var profileScreen = ChatProfileScreen(
|
||||
translations: configuration.translations,
|
||||
chatService: configuration.chatService,
|
||||
chatId: chatId!,
|
||||
userId: id,
|
||||
onTapUser: (user) async {
|
||||
if (configuration.onPressUserProfile != null) {
|
||||
return configuration.onPressUserProfile!.call();
|
||||
}
|
||||
|
||||
return context.push(
|
||||
ChatUserStoryRoutes.chatProfileScreenPath(chatId, user),
|
||||
);
|
||||
},
|
||||
);
|
||||
return buildScreenWithoutTransition(
|
||||
context: context,
|
||||
state: state,
|
||||
child: configuration.chatPageBuilder?.call(
|
||||
context,
|
||||
profileScreen,
|
||||
) ??
|
||||
Scaffold(
|
||||
body: profileScreen,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
|
@ -1,40 +0,0 @@
|
|||
// SPDX-FileCopyrightText: 2023 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// Builds a screen with a fade transition.
|
||||
///
|
||||
/// [context]: The build context.
|
||||
/// [state]: The state of the GoRouter.
|
||||
/// [child]: The child widget to be displayed.
|
||||
CustomTransitionPage buildScreenWithFadeTransition<T>({
|
||||
required BuildContext context,
|
||||
required GoRouterState state,
|
||||
required Widget child,
|
||||
}) =>
|
||||
CustomTransitionPage<T>(
|
||||
key: state.pageKey,
|
||||
child: child,
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
);
|
||||
|
||||
/// Builds a screen without any transition.
|
||||
///
|
||||
/// [context]: The build context.
|
||||
/// [state]: The state of the GoRouter.
|
||||
/// [child]: The child widget to be displayed.
|
||||
CustomTransitionPage buildScreenWithoutTransition<T>({
|
||||
required BuildContext context,
|
||||
required GoRouterState state,
|
||||
required Widget child,
|
||||
}) =>
|
||||
CustomTransitionPage<T>(
|
||||
key: state.pageKey,
|
||||
child: child,
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
||||
child,
|
||||
);
|
|
@ -1,110 +0,0 @@
|
|||
// SPDX-FileCopyrightText: 2023 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_chat_view/flutter_chat_view.dart';
|
||||
|
||||
/// `ChatUserStoryConfiguration` is a class that configures the chat user story.
|
||||
@immutable
|
||||
class ChatUserStoryConfiguration {
|
||||
/// Creates a new instance of `ChatUserStoryConfiguration`.
|
||||
const ChatUserStoryConfiguration({
|
||||
required this.chatService,
|
||||
required this.chatOptionsBuilder,
|
||||
this.onPressStartChat,
|
||||
this.onPressChat,
|
||||
this.onDeleteChat,
|
||||
this.onMessageSubmit,
|
||||
this.onReadChat,
|
||||
this.onUploadImage,
|
||||
this.onPressCreateChat,
|
||||
this.onPressCreateGroupChat,
|
||||
this.onPressCompleteGroupChatCreation,
|
||||
this.iconColor = Colors.black,
|
||||
this.deleteChatDialog,
|
||||
this.disableDismissForPermanentChats = false,
|
||||
this.routeToNewChatIfEmpty = true,
|
||||
this.translations = const ChatTranslations(),
|
||||
this.chatPageBuilder,
|
||||
this.onPressChatTitle,
|
||||
this.afterMessageSent,
|
||||
this.messagePageSize = 20,
|
||||
this.onPressUserProfile,
|
||||
this.textfieldBottomPadding = 20,
|
||||
this.iconDisabledColor = Colors.grey,
|
||||
this.unreadMessageTextStyle,
|
||||
this.loadingWidgetBuilder,
|
||||
this.usernameBuilder,
|
||||
this.chatTitleBuilder,
|
||||
});
|
||||
|
||||
/// The service responsible for handling chat-related functionalities.
|
||||
final ChatService chatService;
|
||||
|
||||
/// Callback function triggered when a chat is pressed.
|
||||
final Function(BuildContext, ChatModel)? onPressChat;
|
||||
|
||||
/// Callback function triggered when a chat is deleted.
|
||||
final Function(BuildContext, ChatModel)? onDeleteChat;
|
||||
|
||||
/// Translations for internationalization/localization support.
|
||||
final ChatTranslations translations;
|
||||
|
||||
/// Determines whether dismissing is disabled for permanent chats.
|
||||
final bool disableDismissForPermanentChats;
|
||||
|
||||
/// Callback function for uploading an image.
|
||||
final Future<void> Function(Uint8List image)? onUploadImage;
|
||||
|
||||
/// Callback function for submitting a message.
|
||||
final Future<void> Function(String text)? onMessageSubmit;
|
||||
|
||||
/// Called after a new message is sent. This can be used to do something
|
||||
/// extra like sending a push notification.
|
||||
final Function(String chatId)? afterMessageSent;
|
||||
|
||||
/// Callback function triggered when a chat is read.
|
||||
final Future<void> Function(ChatModel chat)? onReadChat;
|
||||
|
||||
/// Callback function triggered when creating a chat.
|
||||
final Function(ChatUserModel)? onPressCreateChat;
|
||||
|
||||
/// Builder for chat options based on context.
|
||||
final Function(List<ChatUserModel>, String)? onPressCompleteGroupChatCreation;
|
||||
final Function()? onPressCreateGroupChat;
|
||||
final ChatOptions Function(BuildContext context) chatOptionsBuilder;
|
||||
|
||||
/// If true, the user will be routed to the new chat screen if there are
|
||||
/// no chats.
|
||||
final bool routeToNewChatIfEmpty;
|
||||
|
||||
/// The size of each page of messages.
|
||||
final int messagePageSize;
|
||||
|
||||
/// Dialog for confirming chat deletion.
|
||||
final Future<bool?> Function(BuildContext, ChatModel)? deleteChatDialog;
|
||||
|
||||
/// Callback function triggered when chat title is pressed.
|
||||
final Function(BuildContext context, ChatModel chat)? onPressChatTitle;
|
||||
|
||||
/// Color of icons.
|
||||
final Color? iconColor;
|
||||
|
||||
/// Builder for the chat page.
|
||||
final Widget Function(BuildContext context, Widget child)? chatPageBuilder;
|
||||
|
||||
/// Callback function triggered when starting a chat.
|
||||
final Function()? onPressStartChat;
|
||||
|
||||
/// Callback function triggered when user profile is pressed.
|
||||
final Function()? onPressUserProfile;
|
||||
final double? textfieldBottomPadding;
|
||||
final Color? iconDisabledColor;
|
||||
final TextStyle? unreadMessageTextStyle;
|
||||
final Widget? Function(BuildContext context)? loadingWidgetBuilder;
|
||||
final Widget Function(String userFullName)? usernameBuilder;
|
||||
final Widget Function(String chatTitle)? chatTitleBuilder;
|
||||
}
|
|
@ -1,22 +1,323 @@
|
|||
// SPDX-FileCopyrightText: 2023 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
import "dart:async";
|
||||
|
||||
/// Provides route paths for the chat user story.
|
||||
mixin ChatUserStoryRoutes {
|
||||
static const String chatScreen = '/chat';
|
||||
import "package:chat_repository_interface/chat_repository_interface.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_chat/src/screens/chat_detail/chat_detail_screen.dart";
|
||||
import "package:flutter_chat/src/screens/chat_profile_screen.dart";
|
||||
import "package:flutter_chat/src/screens/chat_screen.dart";
|
||||
import "package:flutter_chat/src/screens/creation/new_chat_screen.dart";
|
||||
import "package:flutter_chat/src/screens/creation/new_group_chat_overview.dart";
|
||||
import "package:flutter_chat/src/screens/creation/new_group_chat_screen.dart";
|
||||
|
||||
/// Constructs the path for the chat detail view.
|
||||
static String chatDetailViewPath(String chatId) => '/chat-detail/$chatId';
|
||||
/// Pushes the chat overview screen
|
||||
MaterialPageRoute chatOverviewRoute({
|
||||
required String userId,
|
||||
required ChatService chatService,
|
||||
required VoidCallback? onExit,
|
||||
}) =>
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ChatScreen(
|
||||
onExit: onExit,
|
||||
onPressChat: (chat) async => _routeToScreen(
|
||||
context,
|
||||
chatDetailRoute(
|
||||
chatId: chat.id,
|
||||
userId: userId,
|
||||
chatService: chatService,
|
||||
onExit: () => Navigator.of(context).pop(),
|
||||
).builder(context),
|
||||
),
|
||||
onDeleteChat: (chat) async => chatService.deleteChat(chatId: chat.id),
|
||||
onPressStartChat: () async => _routeToScreen(
|
||||
context,
|
||||
_newChatRoute(
|
||||
userId: userId,
|
||||
chatService: chatService,
|
||||
).builder(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
static const String chatDetailScreen = '/chat-detail/:id';
|
||||
static const String newChatScreen = '/new-chat';
|
||||
/// Pushes the chat detail screen
|
||||
MaterialPageRoute chatDetailRoute({
|
||||
required String chatId,
|
||||
required String userId,
|
||||
required ChatService chatService,
|
||||
required VoidCallback? onExit,
|
||||
}) =>
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ChatDetailScreen(
|
||||
chatId: chatId,
|
||||
onExit: onExit,
|
||||
onReadChat: (chat) async => chatService.markAsRead(chatId: chat.id),
|
||||
onUploadImage: (data) async => chatService.sendImageMessage(
|
||||
chatId: chatId,
|
||||
userId: userId,
|
||||
data: data,
|
||||
),
|
||||
onMessageSubmit: (text) async => chatService.sendMessage(
|
||||
chatId: chatId,
|
||||
senderId: userId,
|
||||
text: text,
|
||||
),
|
||||
onPressChatTitle: (chat) async {
|
||||
if (chat.isGroupChat) {
|
||||
await _routeToScreen(
|
||||
context,
|
||||
_chatProfileRoute(
|
||||
userId: userId,
|
||||
chatService: chatService,
|
||||
chat: chat,
|
||||
onExit: () => Navigator.of(context).pop(),
|
||||
).builder(context),
|
||||
);
|
||||
} else {
|
||||
var otherUserId = chat.getOtherUser(userId);
|
||||
var otherUser =
|
||||
await chatService.getUser(userId: otherUserId).first;
|
||||
if (!context.mounted) return;
|
||||
await _routeToScreen(
|
||||
context,
|
||||
_chatProfileRoute(
|
||||
userId: userId,
|
||||
chatService: chatService,
|
||||
user: otherUser,
|
||||
onExit: () => Navigator.of(context).pop(),
|
||||
).builder(context),
|
||||
);
|
||||
}
|
||||
},
|
||||
onPressUserProfile: (user) async => _routeToScreen(
|
||||
context,
|
||||
_chatProfileRoute(
|
||||
userId: userId,
|
||||
chatService: chatService,
|
||||
user: user,
|
||||
onExit: () => Navigator.of(context).pop(),
|
||||
).builder(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/// Constructs the path for the chat profile screen.
|
||||
static const String newGroupChatScreen = '/new-group-chat';
|
||||
static const String newGroupChatOverviewScreen = '/new-group-chat-overview';
|
||||
static String chatProfileScreenPath(String chatId, String? userId) =>
|
||||
'/chat-profile/$chatId/$userId';
|
||||
MaterialPageRoute _chatProfileRoute({
|
||||
required String userId,
|
||||
required ChatService chatService,
|
||||
required VoidCallback onExit,
|
||||
UserModel? user,
|
||||
ChatModel? chat,
|
||||
}) =>
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ChatProfileScreen(
|
||||
userModel: user,
|
||||
chatModel: chat,
|
||||
onExit: onExit,
|
||||
onTapUser: (userId) async {
|
||||
var user = await chatService.getUser(userId: userId).first;
|
||||
if (!context.mounted) return;
|
||||
await _routeToScreen(
|
||||
context,
|
||||
_chatProfileRoute(
|
||||
userId: userId,
|
||||
chatService: chatService,
|
||||
user: user,
|
||||
onExit: () => Navigator.of(context).pop(),
|
||||
).builder(context),
|
||||
);
|
||||
},
|
||||
onPressStartChat: (userId) async {
|
||||
var chat = await _createChat(userId, chatService, userId);
|
||||
if (!context.mounted) return;
|
||||
await _routeToScreen(
|
||||
context,
|
||||
chatDetailRoute(
|
||||
chatId: chat.id,
|
||||
userId: userId,
|
||||
chatService: chatService,
|
||||
onExit: () => Navigator.of(context).pop(),
|
||||
).builder(context),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
static const String chatProfileScreen = '/chat-profile/:id/:userId';
|
||||
MaterialPageRoute _newChatRoute({
|
||||
required String userId,
|
||||
required ChatService chatService,
|
||||
}) =>
|
||||
MaterialPageRoute(
|
||||
builder: (context) => NewChatScreen(
|
||||
onExit: () => Navigator.of(context).pop(),
|
||||
onPressCreateGroupChat: () async => _routeToScreen(
|
||||
context,
|
||||
_newGroupChatRoute(
|
||||
userId: userId,
|
||||
chatService: chatService,
|
||||
).builder(context),
|
||||
),
|
||||
onPressCreateChat: (user) async {
|
||||
var chat = await _createChat(user.id, chatService, userId);
|
||||
if (!context.mounted) return;
|
||||
await _replaceCurrentScreen(
|
||||
context,
|
||||
chatDetailRoute(
|
||||
chatId: chat.id,
|
||||
userId: userId,
|
||||
chatService: chatService,
|
||||
onExit: () => Navigator.of(context).pop(),
|
||||
).builder(context),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
MaterialPageRoute _newGroupChatRoute({
|
||||
required String userId,
|
||||
required ChatService chatService,
|
||||
}) =>
|
||||
MaterialPageRoute(
|
||||
builder: (context) => NewGroupChatScreen(
|
||||
onExit: () => Navigator.of(context).pop(),
|
||||
onContinue: (users) async => _replaceCurrentScreen(
|
||||
context,
|
||||
_newGroupChatOverviewRoute(
|
||||
userId: userId,
|
||||
chatService: chatService,
|
||||
users: users,
|
||||
).builder(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
MaterialPageRoute _newGroupChatOverviewRoute({
|
||||
required String userId,
|
||||
required ChatService chatService,
|
||||
required List<UserModel> users,
|
||||
}) =>
|
||||
MaterialPageRoute(
|
||||
builder: (context) => NewGroupChatOverview(
|
||||
users: users,
|
||||
onExit: () => Navigator.of(context).pop(),
|
||||
onComplete: (users, title, description, image) async {
|
||||
String? path;
|
||||
if (image != null) {
|
||||
path = await chatService.uploadImage(
|
||||
path: "groups/$title",
|
||||
image: image,
|
||||
chatId: "",
|
||||
);
|
||||
}
|
||||
var chat = await _createGroupChat(
|
||||
users,
|
||||
title,
|
||||
description,
|
||||
path,
|
||||
chatService,
|
||||
userId,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
await _replaceCurrentScreen(
|
||||
context,
|
||||
chatDetailRoute(
|
||||
chatId: chat.id,
|
||||
userId: userId,
|
||||
chatService: chatService,
|
||||
onExit: () => Navigator.of(context).pop(),
|
||||
).builder(context),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/// Helper function to create a chat
|
||||
Future<ChatModel> _createChat(
|
||||
String otherUserId,
|
||||
ChatService chatService,
|
||||
String userId,
|
||||
) async {
|
||||
ChatModel? chat;
|
||||
try {
|
||||
chat = await chatService.getChatByUser(
|
||||
currentUser: userId,
|
||||
otherUser: otherUserId,
|
||||
);
|
||||
} on Exception catch (_) {
|
||||
chat = null;
|
||||
}
|
||||
if (chat == null) {
|
||||
await chatService.createChat(
|
||||
isGroupChat: false,
|
||||
users: [
|
||||
await chatService.getUser(userId: userId).first,
|
||||
await chatService.getUser(userId: otherUserId).first,
|
||||
],
|
||||
);
|
||||
chat = await chatService.getChatByUser(
|
||||
currentUser: userId,
|
||||
otherUser: otherUserId,
|
||||
);
|
||||
if (chat == null) throw Exception("Chat not created");
|
||||
}
|
||||
return chat;
|
||||
}
|
||||
|
||||
/// Helper function to create a group chat
|
||||
Future<ChatModel> _createGroupChat(
|
||||
List<UserModel> userModels,
|
||||
String title,
|
||||
String description,
|
||||
String? imageUrl,
|
||||
ChatService chatService,
|
||||
String userId,
|
||||
) async {
|
||||
ChatModel? chat;
|
||||
try {
|
||||
chat = await chatService.getGroupChatByUser(
|
||||
currentUser: userId,
|
||||
otherUsers: userModels,
|
||||
chatName: title,
|
||||
description: description,
|
||||
);
|
||||
} on Exception catch (_) {
|
||||
chat = null;
|
||||
}
|
||||
|
||||
if (chat == null) {
|
||||
var currentUser = await chatService.getUser(userId: userId).first;
|
||||
var otherUsers = await Future.wait(
|
||||
userModels.map((e) => chatService.getUser(userId: e.id).first),
|
||||
);
|
||||
|
||||
await chatService.createChat(
|
||||
isGroupChat: true,
|
||||
users: [currentUser, ...otherUsers],
|
||||
chatName: title,
|
||||
description: description,
|
||||
imageUrl: imageUrl,
|
||||
);
|
||||
|
||||
chat = await chatService.getGroupChatByUser(
|
||||
currentUser: userId,
|
||||
otherUsers: otherUsers,
|
||||
chatName: title,
|
||||
description: description,
|
||||
);
|
||||
|
||||
if (chat == null) {
|
||||
throw Exception("Group chat not created");
|
||||
}
|
||||
}
|
||||
|
||||
return chat;
|
||||
}
|
||||
|
||||
/// Routes to a new screen for the userstory
|
||||
Future _routeToScreen(BuildContext context, Widget screen) async =>
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => screen),
|
||||
);
|
||||
|
||||
/// Replaces the current screen with a new screen for the userstory
|
||||
Future _replaceCurrentScreen(BuildContext context, Widget screen) async =>
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (context) => screen),
|
||||
);
|
||||
|
|
|
@ -0,0 +1,583 @@
|
|||
import "dart:async";
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_accessibility/flutter_accessibility.dart";
|
||||
import "package:flutter_chat/flutter_chat.dart";
|
||||
import "package:flutter_chat/src/screens/chat_detail/widgets/chat_bottom.dart";
|
||||
import "package:flutter_chat/src/screens/chat_detail/widgets/chat_widgets.dart";
|
||||
import "package:flutter_chat/src/screens/creation/widgets/default_image_picker.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
|
||||
/// Chat detail screen
|
||||
/// Seen when a user clicks on a chat
|
||||
class ChatDetailScreen extends HookWidget {
|
||||
/// Constructs a [ChatDetailScreen].
|
||||
const ChatDetailScreen({
|
||||
required this.chatId,
|
||||
required this.onExit,
|
||||
required this.onPressChatTitle,
|
||||
required this.onPressUserProfile,
|
||||
required this.onUploadImage,
|
||||
required this.onMessageSubmit,
|
||||
required this.onReadChat,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The identifier of the chat that is being viewed.
|
||||
/// The chat will be fetched from the chat service.
|
||||
final String chatId;
|
||||
|
||||
/// Callback function triggered when the chat title is pressed.
|
||||
final Function(ChatModel) onPressChatTitle;
|
||||
|
||||
/// Callback function triggered when the user profile is pressed.
|
||||
final Function(UserModel) onPressUserProfile;
|
||||
|
||||
/// Callback function triggered when an image is uploaded.
|
||||
final Function(Uint8List image) onUploadImage;
|
||||
|
||||
/// Callback function triggered when a message is submitted.
|
||||
final Function(String text) onMessageSubmit;
|
||||
|
||||
/// Callback function triggered when the chat is read.
|
||||
final Function(ChatModel chat) onReadChat;
|
||||
|
||||
/// Callback for when the user wants to navigate back
|
||||
final VoidCallback? onExit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var chatScope = ChatScope.of(context);
|
||||
var options = chatScope.options;
|
||||
var service = chatScope.service;
|
||||
|
||||
var chatTitle = useState<String?>(null);
|
||||
|
||||
var chatStream = useMemoized(
|
||||
() => service.getChat(chatId: chatId),
|
||||
[chatId],
|
||||
);
|
||||
var chatSnapshot = useStream(chatStream);
|
||||
var chat = chatSnapshot.data;
|
||||
|
||||
var allUsersStream = useMemoized(
|
||||
() => service.getAllUsersForChat(chatId: chatId),
|
||||
[chatId],
|
||||
);
|
||||
var usersSnapshot = useStream(allUsersStream);
|
||||
var allUsers = usersSnapshot.data ?? [];
|
||||
|
||||
var chatIsloading =
|
||||
chatSnapshot.connectionState == ConnectionState.waiting ||
|
||||
usersSnapshot.connectionState == ConnectionState.waiting;
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
if (chat == null) return;
|
||||
chatTitle.value = _getChatTitle(
|
||||
chatScope: chatScope,
|
||||
chat: chat,
|
||||
allUsers: allUsers,
|
||||
);
|
||||
return;
|
||||
},
|
||||
[chat, allUsers],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
if (onExit == null) return null;
|
||||
chatScope.popHandler.add(onExit!);
|
||||
return () => chatScope.popHandler.remove(onExit!);
|
||||
},
|
||||
[onExit],
|
||||
);
|
||||
|
||||
var appBar = _ChatAppBar(
|
||||
chatTitle: chatTitle.value,
|
||||
onPressChatTitle: onPressChatTitle,
|
||||
chatModel: chat,
|
||||
onPressBack: onExit,
|
||||
);
|
||||
|
||||
var body = _ChatBody(
|
||||
chatId: chatId,
|
||||
chat: chat,
|
||||
chatUsers: allUsers,
|
||||
onPressUserProfile: onPressUserProfile,
|
||||
onUploadImage: onUploadImage,
|
||||
onMessageSubmit: onMessageSubmit,
|
||||
onReadChat: onReadChat,
|
||||
chatIsLoading: chatIsloading,
|
||||
);
|
||||
|
||||
if (options.builders.chatScreenBuilder != null) {
|
||||
return options.builders.chatScreenBuilder!.call(
|
||||
context,
|
||||
chat,
|
||||
appBar,
|
||||
chatTitle.value,
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
if (options.builders.baseScreenBuilder != null) {
|
||||
return options.builders.baseScreenBuilder!.call(
|
||||
context,
|
||||
mapScreenType,
|
||||
appBar,
|
||||
chatTitle.value,
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: appBar,
|
||||
body: body,
|
||||
);
|
||||
}
|
||||
|
||||
String? _getChatTitle({
|
||||
required ChatScope chatScope,
|
||||
required ChatModel chat,
|
||||
required List<UserModel> allUsers,
|
||||
}) {
|
||||
var options = chatScope.options;
|
||||
var translations = options.translations;
|
||||
var title = options.chatTitleResolver?.call(chat);
|
||||
if (title != null) {
|
||||
return title;
|
||||
}
|
||||
|
||||
if (chat.isGroupChat) {
|
||||
if (chat.chatName?.isNotEmpty ?? false) {
|
||||
return chat.chatName;
|
||||
}
|
||||
return translations.groupNameEmpty;
|
||||
}
|
||||
|
||||
// For one-to-one, pick the 'other' user from the list
|
||||
var otherUser = allUsers
|
||||
.where(
|
||||
(u) => u.id != chatScope.userId,
|
||||
)
|
||||
.firstOrNull;
|
||||
|
||||
return otherUser != null && otherUser.fullname != null
|
||||
? otherUser.fullname
|
||||
: translations.anonymousUser;
|
||||
}
|
||||
}
|
||||
|
||||
/// The app bar widget for the chat detail screen
|
||||
class _ChatAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
const _ChatAppBar({
|
||||
required this.chatTitle,
|
||||
required this.chatModel,
|
||||
required this.onPressChatTitle,
|
||||
this.onPressBack,
|
||||
});
|
||||
|
||||
final String? chatTitle;
|
||||
final ChatModel? chatModel;
|
||||
final Function(ChatModel) onPressChatTitle;
|
||||
final VoidCallback? onPressBack;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var options = ChatScope.of(context).options;
|
||||
var theme = Theme.of(context);
|
||||
|
||||
VoidCallback? onPressChatTitle;
|
||||
if (chatModel != null) {
|
||||
onPressChatTitle = () => this.onPressChatTitle(chatModel!);
|
||||
}
|
||||
|
||||
Widget? appBarIcon;
|
||||
if (onPressBack != null) {
|
||||
appBarIcon = CustomSemantics(
|
||||
identifier: options.semantics.chatBackButton,
|
||||
child: InkWell(
|
||||
onTap: onPressBack,
|
||||
child: const Icon(Icons.arrow_back_ios),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return AppBar(
|
||||
iconTheme: theme.appBarTheme.iconTheme,
|
||||
centerTitle: true,
|
||||
leading: appBarIcon,
|
||||
title: CustomSemantics(
|
||||
identifier: options.semantics.chatTitleButton,
|
||||
buttonWithVariableText: true,
|
||||
child: InkWell(
|
||||
splashColor: Colors.transparent,
|
||||
highlightColor: Colors.transparent,
|
||||
hoverColor: Colors.transparent,
|
||||
onTap: onPressChatTitle,
|
||||
child: CustomSemantics(
|
||||
identifier: options.semantics.chatChatTitle,
|
||||
value: chatTitle ?? "",
|
||||
child: options.builders.chatTitleBuilder?.call(chatTitle ?? "") ??
|
||||
Text(
|
||||
chatTitle ?? "",
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
}
|
||||
|
||||
/// Body for the chat detail screen
|
||||
/// Displays messages, a scrollable list, and a bottom input field.
|
||||
class _ChatBody extends HookWidget {
|
||||
const _ChatBody({
|
||||
required this.chatId,
|
||||
required this.chat,
|
||||
required this.chatUsers,
|
||||
required this.onPressUserProfile,
|
||||
required this.onUploadImage,
|
||||
required this.onMessageSubmit,
|
||||
required this.onReadChat,
|
||||
required this.chatIsLoading,
|
||||
});
|
||||
|
||||
final String chatId;
|
||||
final ChatModel? chat;
|
||||
final List<UserModel> chatUsers;
|
||||
final Function(UserModel) onPressUserProfile;
|
||||
final Function(Uint8List image) onUploadImage;
|
||||
final Function(String text) onMessageSubmit;
|
||||
final Function(ChatModel chat) onReadChat;
|
||||
final bool chatIsLoading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var chatScope = ChatScope.of(context);
|
||||
var service = chatScope.service;
|
||||
var options = chatScope.options;
|
||||
|
||||
var isLoadingOlder = useState(false);
|
||||
var isLoadingNewer = useState(false);
|
||||
|
||||
var hasMoreOlder = useState(true);
|
||||
|
||||
var autoScrollEnabled = useState(true);
|
||||
|
||||
var messagesStream = useMemoized(
|
||||
() => service.getMessages(chatId: chatId),
|
||||
[chatId],
|
||||
);
|
||||
var messagesSnapshot = useStream(messagesStream);
|
||||
var messages = messagesSnapshot.data ?? [];
|
||||
|
||||
var scrollController = useScrollController();
|
||||
|
||||
Future<void> loadOlderMessages() async {
|
||||
if (!hasMoreOlder.value || messages.isEmpty || isLoadingOlder.value) {
|
||||
return;
|
||||
}
|
||||
isLoadingOlder.value = true;
|
||||
|
||||
var oldestMsg = messages.first;
|
||||
var oldOffset = scrollController.offset;
|
||||
var oldMaxScroll = scrollController.position.maxScrollExtent;
|
||||
var oldCount = messages.length;
|
||||
|
||||
try {
|
||||
await Future.wait([
|
||||
service.loadOldMessagesBefore(firstMessage: oldestMsg),
|
||||
Future.delayed(
|
||||
options.paginationControls.loadingOldMessageMinDuration,
|
||||
),
|
||||
]);
|
||||
} finally {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!context.mounted) return;
|
||||
if (!scrollController.hasClients) {
|
||||
isLoadingOlder.value = false;
|
||||
return;
|
||||
}
|
||||
var newCount = messages.length;
|
||||
if (newCount == oldCount) {
|
||||
hasMoreOlder.value = false;
|
||||
} else {
|
||||
var newMaxScroll = scrollController.position.maxScrollExtent;
|
||||
var diff = newMaxScroll - oldMaxScroll;
|
||||
scrollController.jumpTo(oldOffset + diff);
|
||||
}
|
||||
isLoadingOlder.value = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadNewerMessages() async {
|
||||
if (messages.isEmpty || isLoadingNewer.value) return;
|
||||
isLoadingNewer.value = true;
|
||||
|
||||
var newestMsg = messages.last;
|
||||
try {
|
||||
await Future.wait([
|
||||
service.loadNewMessagesAfter(lastMessage: newestMsg),
|
||||
Future.delayed(
|
||||
options.paginationControls.loadingNewMessageMinDuration,
|
||||
),
|
||||
]);
|
||||
} finally {
|
||||
if (context.mounted) {
|
||||
isLoadingNewer.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
void onScroll() {
|
||||
if (!scrollController.hasClients) return;
|
||||
|
||||
var offset = scrollController.offset;
|
||||
var maxScroll = scrollController.position.maxScrollExtent;
|
||||
var threshold = options.paginationControls.scrollOffset;
|
||||
var autoScrollThreshold =
|
||||
options.paginationControls.autoScrollTriggerOffset;
|
||||
|
||||
var distanceFromBottom = maxScroll - offset;
|
||||
|
||||
if (options.paginationControls.loadOldMessagesOnScroll) {
|
||||
if (offset <= threshold && !isLoadingOlder.value) {
|
||||
unawaited(loadOlderMessages());
|
||||
}
|
||||
}
|
||||
|
||||
if (options.paginationControls.loadNewMessagesOnScroll) {
|
||||
if (distanceFromBottom <= threshold &&
|
||||
!isLoadingNewer.value &&
|
||||
!autoScrollEnabled.value) {
|
||||
unawaited(loadNewerMessages());
|
||||
}
|
||||
}
|
||||
|
||||
if (distanceFromBottom > autoScrollThreshold) {
|
||||
autoScrollEnabled.value = false;
|
||||
} else {
|
||||
autoScrollEnabled.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
scrollController.addListener(onScroll);
|
||||
return () => scrollController.removeListener(onScroll);
|
||||
}, [
|
||||
scrollController,
|
||||
isLoadingOlder.value,
|
||||
isLoadingNewer.value,
|
||||
chat,
|
||||
]);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
var disposed = false;
|
||||
|
||||
/// Continuously scroll to the bottom of the chat
|
||||
Future<void> scrollLoop() async {
|
||||
while (!disposed && autoScrollEnabled.value) {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
if (disposed || !autoScrollEnabled.value) break;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (disposed || !autoScrollEnabled.value) return;
|
||||
if (scrollController.hasClients) {
|
||||
scrollController.jumpTo(
|
||||
scrollController.position.maxScrollExtent,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
unawaited(scrollLoop());
|
||||
|
||||
return () => disposed = true;
|
||||
},
|
||||
[autoScrollEnabled.value],
|
||||
);
|
||||
|
||||
var chatBottomInputSection = ChatBottomInputSection(
|
||||
chat: chat,
|
||||
isLoading: chatIsLoading && !messagesSnapshot.hasData,
|
||||
onPressSelectImage: () async => onPressSelectImage(
|
||||
context,
|
||||
options,
|
||||
onUploadImage,
|
||||
),
|
||||
onMessageSubmit: onMessageSubmit,
|
||||
);
|
||||
|
||||
if (messagesSnapshot.hasError) {
|
||||
var errorBuilder = options.builders.chatMessagesErrorBuilder;
|
||||
if (errorBuilder != null) {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: errorBuilder(
|
||||
context,
|
||||
messagesSnapshot.error!,
|
||||
messagesSnapshot.stackTrace!,
|
||||
options,
|
||||
),
|
||||
),
|
||||
chatBottomInputSection,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return ErrorLoadingMessages(
|
||||
options: options,
|
||||
chatBottomInputSection: chatBottomInputSection,
|
||||
);
|
||||
}
|
||||
|
||||
var userMap = <String, UserModel>{};
|
||||
for (var u in chatUsers) {
|
||||
userMap[u.id] = u;
|
||||
}
|
||||
|
||||
var topSpinner = (isLoadingOlder.value &&
|
||||
options.paginationControls.loadingIndicatorForOldMessages)
|
||||
? options.builders.loadingChatMessageBuilder.call(context)
|
||||
: const SizedBox.shrink();
|
||||
|
||||
var bottomSpinner = (isLoadingNewer.value &&
|
||||
options.paginationControls.loadingIndicatorForNewMessages)
|
||||
? options.builders.loadingChatMessageBuilder.call(context)
|
||||
: const SizedBox.shrink();
|
||||
|
||||
var bubbleChildren = <Widget>[];
|
||||
if (messages.isEmpty) {
|
||||
bubbleChildren
|
||||
.add(ChatNoMessages(isGroupChat: chat?.isGroupChat ?? false));
|
||||
} else {
|
||||
for (var (index, currentMessage) in messages.indexed) {
|
||||
var previousMessage = index > 0 ? messages[index - 1] : null;
|
||||
|
||||
if (options.timeIndicatorOptions.isMessageInNewTimeSection(
|
||||
context,
|
||||
previousMessage,
|
||||
currentMessage,
|
||||
)) {
|
||||
bubbleChildren.add(
|
||||
ChatTimeIndicator(
|
||||
forDate: currentMessage.timestamp,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bubbleChildren.add(
|
||||
ChatBubble(
|
||||
message: currentMessage,
|
||||
previousMessage: previousMessage,
|
||||
sender: userMap[currentMessage.senderId],
|
||||
onPressSender: onPressUserProfile,
|
||||
semanticIdTitle: options.semantics.chatBubbleTitle(index),
|
||||
semanticIdTime: options.semantics.chatBubbleTime(index),
|
||||
semanticIdText: options.semantics.chatBubbleText(index),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
var listViewChildren = [
|
||||
topSpinner,
|
||||
...bubbleChildren,
|
||||
bottomSpinner,
|
||||
];
|
||||
|
||||
var messageList = ListView.builder(
|
||||
reverse: false,
|
||||
controller: scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.only(top: 24),
|
||||
itemCount: listViewChildren.length,
|
||||
itemBuilder: (context, index) => listViewChildren[index],
|
||||
);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (chatIsLoading && options.enableLoadingIndicator) ...[
|
||||
Expanded(
|
||||
child: _CloseKeyboardOnTap(
|
||||
child: options.builders.loadingWidgetBuilder.call(context),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
Expanded(
|
||||
child: _CloseKeyboardOnTap(
|
||||
child: messageList,
|
||||
),
|
||||
),
|
||||
],
|
||||
chatBottomInputSection,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CloseKeyboardOnTap extends StatelessWidget {
|
||||
const _CloseKeyboardOnTap({
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTapUp: (_) {
|
||||
var mediaQuery = MediaQuery.of(context);
|
||||
if (mediaQuery.viewInsets.isNonNegative) {
|
||||
FocusScope.of(context).unfocus();
|
||||
}
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
/// Default widget used when displaying an error for chats.
|
||||
class ErrorLoadingMessages extends StatelessWidget {
|
||||
/// Create default error displaying widget for error in loading messages
|
||||
const ErrorLoadingMessages({
|
||||
required this.options,
|
||||
required this.chatBottomInputSection,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// the options of the current chat userstory
|
||||
final ChatOptions options;
|
||||
|
||||
/// The widget
|
||||
final ChatBottomInputSection chatBottomInputSection;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
options.translations.messagesLoadingError,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
chatBottomInputSection,
|
||||
],
|
||||
);
|
||||
}
|
|
@ -0,0 +1,180 @@
|
|||
import "package:chat_repository_interface/chat_repository_interface.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_accessibility/flutter_accessibility.dart";
|
||||
import "package:flutter_chat/src/util/scope.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
|
||||
/// Chat Bottom section where the user can type or upload images.
|
||||
class ChatBottomInputSection extends HookWidget {
|
||||
/// Creates a new [ChatBottomInputSection].
|
||||
const ChatBottomInputSection({
|
||||
required this.chat,
|
||||
required this.isLoading,
|
||||
required this.onMessageSubmit,
|
||||
this.onPressSelectImage,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The chat model.
|
||||
final ChatModel? chat;
|
||||
|
||||
/// Whether the chat is still loading.
|
||||
/// The inputfield is disabled when the chat is loading.
|
||||
final bool isLoading;
|
||||
|
||||
/// Callback function invoked when a message is submitted.
|
||||
final Function(String text) onMessageSubmit;
|
||||
|
||||
/// Callback function invoked when the select image button is pressed.
|
||||
final VoidCallback? onPressSelectImage;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var chatScope = ChatScope.of(context);
|
||||
var options = chatScope.options;
|
||||
var theme = Theme.of(context);
|
||||
|
||||
var textController = useTextEditingController();
|
||||
var isTyping = useState(false);
|
||||
var isSending = useState(false);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
void listener() => isTyping.value = textController.text.isNotEmpty;
|
||||
textController.addListener(listener);
|
||||
return () => textController.removeListener(listener);
|
||||
},
|
||||
[textController],
|
||||
);
|
||||
|
||||
Future<void> sendMessage() async {
|
||||
isSending.value = true;
|
||||
var value = textController.text;
|
||||
if (value.isNotEmpty) {
|
||||
await onMessageSubmit(value);
|
||||
textController.clear();
|
||||
}
|
||||
isSending.value = false;
|
||||
}
|
||||
|
||||
Future<void> Function()? onClickSendMessage;
|
||||
if (isTyping.value && !isSending.value) {
|
||||
onClickSendMessage = () async => sendMessage();
|
||||
}
|
||||
|
||||
/// Image and send buttons
|
||||
var messageSendButtons = Padding(
|
||||
padding: const EdgeInsets.only(right: 6.0),
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CustomSemantics(
|
||||
identifier: options.semantics.chatSelectImageIconButton,
|
||||
child: IconButton(
|
||||
alignment: Alignment.bottomRight,
|
||||
onPressed: isLoading ? null : onPressSelectImage,
|
||||
icon: Icon(
|
||||
Icons.image_outlined,
|
||||
color: options.iconEnabledColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
CustomSemantics(
|
||||
identifier: options.semantics.chatSendMessageIconButton,
|
||||
child: IconButton(
|
||||
alignment: Alignment.bottomRight,
|
||||
disabledColor: options.iconDisabledColor,
|
||||
color: options.iconEnabledColor,
|
||||
onPressed: isLoading ? null : onClickSendMessage,
|
||||
icon: const Icon(Icons.send_rounded),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> onSubmitField() async => sendMessage();
|
||||
|
||||
var defaultInputField = Stack(
|
||||
children: [
|
||||
CustomSemantics(
|
||||
identifier: options.semantics.chatMessageInput,
|
||||
isTextField: true,
|
||||
child: TextField(
|
||||
textAlign: TextAlign.start,
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
style: theme.textTheme.bodySmall,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
textInputAction: TextInputAction.newline,
|
||||
keyboardType: TextInputType.multiline,
|
||||
maxLines: null,
|
||||
controller: textController,
|
||||
enabled: !isLoading,
|
||||
decoration: InputDecoration(
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
borderSide: const BorderSide(color: Colors.black),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
borderSide: const BorderSide(color: Colors.black),
|
||||
),
|
||||
contentPadding: const EdgeInsets.only(
|
||||
left: 16,
|
||||
top: 16,
|
||||
bottom: 16,
|
||||
),
|
||||
// this ensures that that there is space at the end of the
|
||||
// textfield
|
||||
suffixIcon: ExcludeFocus(
|
||||
child: AbsorbPointer(
|
||||
child: Opacity(
|
||||
opacity: 0.0,
|
||||
child: messageSendButtons,
|
||||
),
|
||||
),
|
||||
),
|
||||
hintText: options.translations.messagePlaceholder,
|
||||
hintStyle: theme.textTheme.bodyMedium,
|
||||
fillColor: Colors.white,
|
||||
filled: true,
|
||||
border: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(25)),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) async => onSubmitField(),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: messageSendButtons,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: options.spacing.chatSidePadding,
|
||||
vertical: 16,
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 120, minHeight: 45),
|
||||
child: options.builders.messageInputBuilder?.call(
|
||||
context,
|
||||
textEditingController: textController,
|
||||
suffixIcon: messageSendButtons,
|
||||
translations: options.translations,
|
||||
onSubmit: onSubmitField,
|
||||
enabled: !isLoading,
|
||||
) ??
|
||||
defaultInputField,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,134 @@
|
|||
import "package:chat_repository_interface/chat_repository_interface.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_accessibility/flutter_accessibility.dart";
|
||||
import "package:flutter_chat/src/screens/chat_detail/widgets/default_message_builder.dart";
|
||||
import "package:flutter_chat/src/util/scope.dart";
|
||||
import "package:flutter_chat/src/util/utils.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
|
||||
/// Widget displayed when there are no messages in the chat.
|
||||
class ChatNoMessages extends HookWidget {
|
||||
/// Creates a new [ChatNoMessages] widget.
|
||||
const ChatNoMessages({
|
||||
required this.isGroupChat,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Determines if this chat is a group chat.
|
||||
final bool isGroupChat;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var chatScope = ChatScope.of(context);
|
||||
var options = chatScope.options;
|
||||
var translations = options.translations;
|
||||
var theme = Theme.of(context);
|
||||
|
||||
return Center(
|
||||
child: CustomSemantics(
|
||||
identifier: options.semantics.chatNoMessages,
|
||||
value: isGroupChat
|
||||
? translations.writeFirstMessageInGroupChat
|
||||
: translations.writeMessageToStartChat,
|
||||
child: Text(
|
||||
isGroupChat
|
||||
? translations.writeFirstMessageInGroupChat
|
||||
: translations.writeMessageToStartChat,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A single chat bubble in the chat
|
||||
class ChatBubble extends HookWidget {
|
||||
/// Creates a new [ChatBubble] widget.
|
||||
const ChatBubble({
|
||||
required this.message,
|
||||
required this.sender,
|
||||
required this.onPressSender,
|
||||
required this.semanticIdTitle,
|
||||
required this.semanticIdText,
|
||||
required this.semanticIdTime,
|
||||
this.previousMessage,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The message to display.
|
||||
final MessageModel message;
|
||||
|
||||
/// The user who sent the message. This can be null because some messages are
|
||||
/// not from users
|
||||
final UserModel? sender;
|
||||
|
||||
/// The previous message in the list, if any.
|
||||
final MessageModel? previousMessage;
|
||||
|
||||
/// Callback function when a message sender is pressed.
|
||||
final Function(UserModel user) onPressSender;
|
||||
|
||||
/// Semantic id for message title
|
||||
final String semanticIdTitle;
|
||||
|
||||
/// Semantic id for message time
|
||||
final String semanticIdTime;
|
||||
|
||||
/// Semantic id for message text
|
||||
final String semanticIdText;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var chatScope = ChatScope.of(context);
|
||||
var options = chatScope.options;
|
||||
|
||||
return options.builders.chatMessageBuilder.call(
|
||||
context,
|
||||
message,
|
||||
previousMessage,
|
||||
sender,
|
||||
onPressSender,
|
||||
semanticIdTitle,
|
||||
semanticIdTime,
|
||||
semanticIdText,
|
||||
) ??
|
||||
DefaultChatMessageBuilder(
|
||||
message: message,
|
||||
previousMessage: previousMessage,
|
||||
sender: sender,
|
||||
onPressSender: onPressSender,
|
||||
semanticIdTitle: semanticIdTitle,
|
||||
semanticIdTime: semanticIdTime,
|
||||
semanticIdText: semanticIdText,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The indicator above a set of messages, shown per date.
|
||||
class ChatTimeIndicator extends StatelessWidget {
|
||||
/// Creates a ChatTimeIndicator
|
||||
const ChatTimeIndicator({
|
||||
required this.forDate,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The dateTime at which the new time section starts
|
||||
final DateTime forDate;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var scope = ChatScope.of(context);
|
||||
var indicatorOptions = scope.options.timeIndicatorOptions;
|
||||
|
||||
var today = DateTime.now();
|
||||
var differenceInDays = today.getDateOffsetInDays(forDate);
|
||||
|
||||
var message = indicatorOptions.labelResolver(
|
||||
context,
|
||||
differenceInDays,
|
||||
forDate,
|
||||
);
|
||||
|
||||
return indicatorOptions.indicatorBuilder(context, message);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:flutter_chat/flutter_chat.dart";
|
||||
|
||||
/// The default layout for a chat indicator
|
||||
class DefaultChatTimeIndicator extends StatelessWidget {
|
||||
/// Create a default timeindicator in a chat
|
||||
const DefaultChatTimeIndicator({
|
||||
required this.timeIndicatorString,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The text shown in the time indicator
|
||||
final String timeIndicatorString;
|
||||
|
||||
/// Standard builder for time indication
|
||||
static Widget builder(BuildContext context, String timeIndicatorString) =>
|
||||
DefaultChatTimeIndicator(timeIndicatorString: timeIndicatorString);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var theme = Theme.of(context);
|
||||
var spacing = ChatScope.of(context).options.spacing;
|
||||
return Center(
|
||||
child: Container(
|
||||
margin: EdgeInsets.only(top: spacing.chatBetweenMessagesPadding),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 4,
|
||||
horizontal: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
child: Text(
|
||||
timeIndicatorString,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
import "package:flutter/material.dart";
|
||||
|
||||
/// Default chat loading overlay
|
||||
/// This is displayed over the chat when loading
|
||||
class DefaultChatLoadingOverlay extends StatelessWidget {
|
||||
/// Creates a new default chat loading overlay
|
||||
const DefaultChatLoadingOverlay({super.key});
|
||||
|
||||
/// Builds the default chat loading overlay
|
||||
static Widget builder(BuildContext context) =>
|
||||
const DefaultChatLoadingOverlay();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
/// A small row spinner item to show partial loading
|
||||
class DefaultChatMessageLoader extends StatelessWidget {
|
||||
/// Creates a new default chat message loader
|
||||
const DefaultChatMessageLoader({super.key});
|
||||
|
||||
/// Builds the default chat message loader
|
||||
static Widget builder(BuildContext context) =>
|
||||
const DefaultChatMessageLoader();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
|
@ -0,0 +1,504 @@
|
|||
import "dart:async";
|
||||
|
||||
import "package:chat_repository_interface/chat_repository_interface.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_accessibility/flutter_accessibility.dart";
|
||||
import "package:flutter_chat/src/config/chat_options.dart";
|
||||
import "package:flutter_chat/src/services/date_formatter.dart";
|
||||
import "package:flutter_chat/src/util/scope.dart";
|
||||
|
||||
/// The default chat message builder that shows messages aligned to the left or
|
||||
/// right depending on the sender.
|
||||
/// It can be styled using the [MessageTheme] from the [ChatOptions].
|
||||
class DefaultChatMessageBuilder extends StatelessWidget {
|
||||
/// Creates a new [DefaultChatMessageBuilder]
|
||||
const DefaultChatMessageBuilder({
|
||||
required this.message,
|
||||
required this.previousMessage,
|
||||
required this.sender,
|
||||
required this.onPressSender,
|
||||
required this.semanticIdTitle,
|
||||
required this.semanticIdText,
|
||||
required this.semanticIdTime,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The message that is being built
|
||||
final MessageModel message;
|
||||
|
||||
/// The previous message if any, this can be used to determine if the message
|
||||
/// is from the same sender as the previous message.
|
||||
final MessageModel? previousMessage;
|
||||
|
||||
/// The user that sent the message, can be null if the message is an event
|
||||
final UserModel? sender;
|
||||
|
||||
/// The function that is called when the sender is clicked
|
||||
final Function(UserModel user) onPressSender;
|
||||
|
||||
/// Semantic id for message title
|
||||
final String semanticIdTitle;
|
||||
|
||||
/// Semantic id for message time
|
||||
final String semanticIdTime;
|
||||
|
||||
/// Semantic id for message text
|
||||
final String semanticIdText;
|
||||
|
||||
/// implements [ChatMessageBuilder]
|
||||
static Widget builder(
|
||||
BuildContext context,
|
||||
MessageModel message,
|
||||
MessageModel? previousMessage,
|
||||
UserModel? sender,
|
||||
Function(UserModel sender) onPressSender,
|
||||
String semanticIdTitle,
|
||||
String semanticIdText,
|
||||
String semanticIdTime,
|
||||
) =>
|
||||
DefaultChatMessageBuilder(
|
||||
message: message,
|
||||
previousMessage: previousMessage,
|
||||
sender: sender,
|
||||
onPressSender: onPressSender,
|
||||
semanticIdTitle: semanticIdTitle,
|
||||
semanticIdTime: semanticIdTime,
|
||||
semanticIdText: semanticIdText,
|
||||
);
|
||||
|
||||
/// Merges the [MessageTheme] from the themeresolver with the [MessageTheme]
|
||||
/// from the options and the [MessageTheme] from the theme. Priority is given
|
||||
/// to the [MessageTheme] from the themeresolver.
|
||||
MessageTheme _resolveMessageTheme({
|
||||
required BuildContext context,
|
||||
required ChatOptions options,
|
||||
required MessageModel message,
|
||||
required MessageModel? previousMessage,
|
||||
required UserModel? user,
|
||||
}) =>
|
||||
[
|
||||
options.messageThemeResolver(context, message, previousMessage, user),
|
||||
options.messageTheme,
|
||||
MessageTheme.fromTheme(Theme.of(context)),
|
||||
].whereType<MessageTheme>().reduce((value, element) => value | element);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var chatScope = ChatScope.of(context);
|
||||
var options = chatScope.options;
|
||||
var userId = chatScope.userId;
|
||||
|
||||
var messageTheme = _resolveMessageTheme(
|
||||
context: context,
|
||||
options: options,
|
||||
message: message,
|
||||
previousMessage: previousMessage,
|
||||
user: sender,
|
||||
);
|
||||
|
||||
var isSameSender = previousMessage != null &&
|
||||
previousMessage?.senderId == message.senderId;
|
||||
|
||||
var hasPreviousIndicator = options.timeIndicatorOptions.sectionCheck(
|
||||
context,
|
||||
previousMessage,
|
||||
message,
|
||||
);
|
||||
|
||||
var isMessageFromSelf = message.senderId == userId;
|
||||
|
||||
var chatMessage = _ChatMessageBubble(
|
||||
isSameSender: isSameSender,
|
||||
hasPreviousIndicator: hasPreviousIndicator,
|
||||
isMessageFromSelf: isMessageFromSelf,
|
||||
previousMessage: previousMessage,
|
||||
message: message,
|
||||
messageTheme: messageTheme,
|
||||
sender: sender,
|
||||
semanticIdTitle: semanticIdTitle,
|
||||
semanticIdTime: semanticIdTime,
|
||||
semanticIdText: semanticIdText,
|
||||
);
|
||||
|
||||
var messagePadding = messageTheme.messageSidePadding!;
|
||||
|
||||
var standardAlignmentIfNull =
|
||||
isMessageFromSelf ? TextAlign.right : TextAlign.left;
|
||||
|
||||
var leftPaddingMessage =
|
||||
switch (messageTheme.messageAlignment ?? standardAlignmentIfNull) {
|
||||
TextAlign.left => 0.0,
|
||||
TextAlign.right => messagePadding,
|
||||
_ => messagePadding / 2,
|
||||
};
|
||||
|
||||
var rightPadding =
|
||||
switch (messageTheme.messageAlignment ?? standardAlignmentIfNull) {
|
||||
TextAlign.left => messagePadding,
|
||||
TextAlign.right => 0.0,
|
||||
_ => messagePadding / 2,
|
||||
};
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(width: leftPaddingMessage + options.spacing.chatSidePadding),
|
||||
chatMessage,
|
||||
SizedBox(width: rightPadding + options.spacing.chatSidePadding),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatMessageStatus extends StatelessWidget {
|
||||
const _ChatMessageStatus({
|
||||
required this.messageTheme,
|
||||
required this.status,
|
||||
});
|
||||
|
||||
final MessageTheme messageTheme;
|
||||
final MessageStatus status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => switch (status) {
|
||||
MessageStatus.sending => Icon(
|
||||
Icons.access_time,
|
||||
size: 16.0,
|
||||
color: messageTheme.textColor,
|
||||
),
|
||||
MessageStatus.sent => Icon(
|
||||
Icons.check,
|
||||
size: 16.0,
|
||||
color: messageTheme.textColor,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
class _ChatMessageBubble extends StatelessWidget {
|
||||
const _ChatMessageBubble({
|
||||
required this.isSameSender,
|
||||
required this.hasPreviousIndicator,
|
||||
required this.isMessageFromSelf,
|
||||
required this.message,
|
||||
required this.previousMessage,
|
||||
required this.messageTheme,
|
||||
required this.sender,
|
||||
required this.semanticIdTitle,
|
||||
required this.semanticIdTime,
|
||||
required this.semanticIdText,
|
||||
});
|
||||
|
||||
final bool isSameSender;
|
||||
final bool hasPreviousIndicator;
|
||||
final bool isMessageFromSelf;
|
||||
final MessageModel message;
|
||||
final MessageModel? previousMessage;
|
||||
final MessageTheme messageTheme;
|
||||
final UserModel? sender;
|
||||
final String semanticIdTitle;
|
||||
final String semanticIdTime;
|
||||
final String semanticIdText;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var theme = Theme.of(context);
|
||||
var textTheme = theme.textTheme;
|
||||
var options = ChatScope.of(context).options;
|
||||
var dateFormatter = DateFormatter(options: options);
|
||||
|
||||
var isNewDate = previousMessage != null &&
|
||||
message.timestamp.day != previousMessage?.timestamp.day;
|
||||
|
||||
var showFullDateOnMessage =
|
||||
messageTheme.showFullDate ?? (isNewDate || previousMessage == null);
|
||||
var messageTime = dateFormatter.format(
|
||||
date: message.timestamp.toLocal(),
|
||||
showFullDate: showFullDateOnMessage,
|
||||
);
|
||||
|
||||
var senderTitle =
|
||||
options.senderTitleResolver?.call(sender) ?? sender?.firstName ?? "";
|
||||
var senderTitleText = CustomSemantics(
|
||||
identifier: semanticIdTitle,
|
||||
value: senderTitle,
|
||||
child: Text(
|
||||
senderTitle,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
);
|
||||
|
||||
var messageTimeRow = Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 8.0,
|
||||
right: 8.0,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
CustomSemantics(
|
||||
identifier: semanticIdTime,
|
||||
value: messageTime,
|
||||
child: Text(
|
||||
messageTime,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: messageTheme.textColor,
|
||||
),
|
||||
textAlign: TextAlign.end,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4.0),
|
||||
_ChatMessageStatus(
|
||||
messageTheme: messageTheme,
|
||||
status: message.status,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
var showName =
|
||||
messageTheme.showName ?? (!isSameSender || hasPreviousIndicator);
|
||||
|
||||
var isNewSection = hasPreviousIndicator || showName;
|
||||
|
||||
return Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (isNewSection) ...[
|
||||
SizedBox(height: options.spacing.chatBetweenMessagesPadding),
|
||||
],
|
||||
if (showName) senderTitleText,
|
||||
const SizedBox(height: 4),
|
||||
DefaultChatMessageContainer(
|
||||
backgroundColor: messageTheme.backgroundColor!,
|
||||
borderColor: messageTheme.borderColor!,
|
||||
borderRadius: messageTheme.borderRadius!,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
if (message.imageUrl?.isNotEmpty ?? false) ...[
|
||||
_DefaultChatImage(
|
||||
message: message,
|
||||
messageTheme: messageTheme,
|
||||
options: options,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
],
|
||||
if (message.text?.isNotEmpty ?? false) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 8,
|
||||
left: 12,
|
||||
right: 12,
|
||||
bottom: 4,
|
||||
),
|
||||
child: Semantics(
|
||||
identifier: semanticIdText,
|
||||
value: message.text,
|
||||
child: Text(
|
||||
message.text!,
|
||||
style: textTheme.bodyLarge?.copyWith(
|
||||
color: messageTheme.textColor,
|
||||
),
|
||||
textAlign: messageTheme.textAlignment,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (messageTheme.showTime!) ...[
|
||||
messageTimeRow,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DefaultChatImage extends StatefulWidget {
|
||||
const _DefaultChatImage({
|
||||
required this.message,
|
||||
required this.messageTheme,
|
||||
required this.options,
|
||||
});
|
||||
|
||||
final MessageModel message;
|
||||
final ChatOptions options;
|
||||
final MessageTheme messageTheme;
|
||||
|
||||
@override
|
||||
State<_DefaultChatImage> createState() => _DefaultChatImageState();
|
||||
}
|
||||
|
||||
/// Exception thrown when the image builder fails to recognize the image
|
||||
class InvalidImageUrlException implements Exception {}
|
||||
|
||||
class _DefaultChatImageState extends State<_DefaultChatImage>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
late ImageProvider provider;
|
||||
late Completer imageLoadingCompleter;
|
||||
|
||||
void _preloadImage() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
var uri = Uri.tryParse(widget.message.imageUrl ?? "");
|
||||
if (uri == null) {
|
||||
imageLoadingCompleter.completeError(InvalidImageUrlException());
|
||||
return;
|
||||
}
|
||||
|
||||
provider = widget.options.imageProviderResolver(
|
||||
context,
|
||||
uri,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
await precacheImage(
|
||||
provider,
|
||||
context,
|
||||
onError: imageLoadingCompleter.completeError,
|
||||
);
|
||||
|
||||
imageLoadingCompleter.complete();
|
||||
});
|
||||
}
|
||||
|
||||
void _refreshImage() {
|
||||
setState(() {
|
||||
imageLoadingCompleter = Completer();
|
||||
});
|
||||
_preloadImage();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
imageLoadingCompleter = Completer();
|
||||
_preloadImage();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _DefaultChatImage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.message.imageUrl != widget.message.imageUrl) {
|
||||
_refreshImage();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
||||
var theme = Theme.of(context);
|
||||
|
||||
var asyncImageBuilder = FutureBuilder<void>(
|
||||
future: imageLoadingCompleter.future,
|
||||
builder: (context, snapshot) => switch (snapshot.connectionState) {
|
||||
ConnectionState.waiting => Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: widget.messageTheme.textColor,
|
||||
),
|
||||
),
|
||||
ConnectionState.done when !snapshot.hasError => Image(
|
||||
image: provider,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
_DefaultMessageImageError(
|
||||
messageTheme: widget.messageTheme,
|
||||
onRefresh: _refreshImage,
|
||||
),
|
||||
),
|
||||
_ => _DefaultMessageImageError(
|
||||
messageTheme: widget.messageTheme,
|
||||
onRefresh: _refreshImage,
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) => ConstrainedBox(
|
||||
constraints: BoxConstraints.tightForFinite(
|
||||
width: constraints.maxWidth,
|
||||
height: constraints.maxWidth,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: ColoredBox(
|
||||
color: widget.messageTheme.imageBackgroundColor ??
|
||||
theme.colorScheme.secondaryContainer,
|
||||
child: asyncImageBuilder,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
}
|
||||
|
||||
class _DefaultMessageImageError extends StatelessWidget {
|
||||
const _DefaultMessageImageError({
|
||||
required this.messageTheme,
|
||||
required this.onRefresh,
|
||||
});
|
||||
|
||||
final MessageTheme messageTheme;
|
||||
final VoidCallback onRefresh;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Center(
|
||||
child: IconButton(
|
||||
onPressed: onRefresh,
|
||||
icon: Icon(
|
||||
Icons.refresh,
|
||||
color: messageTheme.textColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A container for the chat message that provides a decoration around the
|
||||
/// message
|
||||
class DefaultChatMessageContainer extends StatelessWidget {
|
||||
/// Creates a new [DefaultChatMessageContainer]
|
||||
const DefaultChatMessageContainer({
|
||||
required this.backgroundColor,
|
||||
required this.borderColor,
|
||||
required this.borderRadius,
|
||||
required this.child,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The color of the message background
|
||||
final Color backgroundColor;
|
||||
|
||||
/// The color of the border around the message
|
||||
final Color borderColor;
|
||||
|
||||
/// The border radius of the message container
|
||||
final BorderRadius borderRadius;
|
||||
|
||||
/// The content of the message
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: borderRadius,
|
||||
border: Border.all(
|
||||
width: 1,
|
||||
color: borderColor,
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
|
@ -0,0 +1,207 @@
|
|||
import "package:chat_repository_interface/chat_repository_interface.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_chat/src/services/date_formatter.dart";
|
||||
import "package:flutter_chat/src/util/scope.dart";
|
||||
import "package:flutter_profile/flutter_profile.dart";
|
||||
|
||||
/// The old chat message builder that shows messages with the user image on the
|
||||
/// left and the message on the right.
|
||||
class OldChatMessageBuilder extends StatelessWidget {
|
||||
/// Creates a new [OldChatMessageBuilder]
|
||||
const OldChatMessageBuilder({
|
||||
required this.message,
|
||||
required this.previousMessage,
|
||||
required this.user,
|
||||
required this.onPressUserProfile,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The message that is being built
|
||||
final MessageModel message;
|
||||
|
||||
/// The previous message if any, this can be used to determine if the message
|
||||
/// is from the same sender as the previous message.
|
||||
final MessageModel? previousMessage;
|
||||
|
||||
/// The user that sent the message
|
||||
final UserModel user;
|
||||
|
||||
/// The function that is called when the user profile is pressed
|
||||
final Function(UserModel user) onPressUserProfile;
|
||||
|
||||
/// implements [ChatMessageBuilder]
|
||||
static Widget builder(
|
||||
BuildContext context,
|
||||
MessageModel message,
|
||||
MessageModel? previousMessage,
|
||||
UserModel user,
|
||||
Function(UserModel user) onPressUserProfile,
|
||||
) =>
|
||||
OldChatMessageBuilder(
|
||||
message: message,
|
||||
previousMessage: previousMessage,
|
||||
user: user,
|
||||
onPressUserProfile: onPressUserProfile,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var chatScope = ChatScope.of(context);
|
||||
var options = chatScope.options;
|
||||
var translations = options.translations;
|
||||
var theme = Theme.of(context);
|
||||
var dateFormatter = DateFormatter(options: options);
|
||||
|
||||
var isNewDate = previousMessage != null &&
|
||||
message.timestamp.day != previousMessage?.timestamp.day;
|
||||
var isSameSender = previousMessage == null ||
|
||||
previousMessage?.senderId != message.senderId;
|
||||
var isSameMinute = previousMessage != null &&
|
||||
message.timestamp.minute == previousMessage?.timestamp.minute;
|
||||
var hasHeader = isNewDate || isSameSender;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: hasHeader ? 25.0 : 0,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (hasHeader) ...[
|
||||
InkWell(
|
||||
onTap: () => onPressUserProfile(user),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 10.0),
|
||||
child: user.imageUrl?.isNotEmpty ?? false
|
||||
? _ChatImage(
|
||||
image: user.imageUrl!,
|
||||
)
|
||||
: options.builders.userAvatarBuilder?.call(
|
||||
context,
|
||||
user,
|
||||
40,
|
||||
) ??
|
||||
Avatar(
|
||||
key: ValueKey(user.id),
|
||||
boxfit: BoxFit.cover,
|
||||
user: User(
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
imageUrl:
|
||||
user.imageUrl != "" ? user.imageUrl : null,
|
||||
),
|
||||
size: 40,
|
||||
),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
const SizedBox(width: 50),
|
||||
],
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 22.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
if (hasHeader) ...[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: options.builders.usernameBuilder?.call(
|
||||
user.fullname ?? "",
|
||||
) ??
|
||||
Text(
|
||||
user.fullname ?? translations.anonymousUser,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 5.0),
|
||||
child: Text(
|
||||
dateFormatter.format(
|
||||
date: message.timestamp,
|
||||
showFullDate: true,
|
||||
),
|
||||
style: theme.textTheme.labelSmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 3.0),
|
||||
child: message.isTextMessage
|
||||
? Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
message.text ?? "",
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
if (!isSameMinute && !isNewDate && !hasHeader)
|
||||
Text(
|
||||
dateFormatter
|
||||
.format(
|
||||
date: message.timestamp,
|
||||
showFullDate: true,
|
||||
)
|
||||
.split(" ")
|
||||
.last,
|
||||
style: theme.textTheme.labelSmall,
|
||||
textAlign: TextAlign.end,
|
||||
),
|
||||
],
|
||||
)
|
||||
: message.isImageMessage
|
||||
? Image(
|
||||
image: options.imageProviderResolver(
|
||||
context,
|
||||
Uri.parse(message.imageUrl!),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatImage extends StatelessWidget {
|
||||
const _ChatImage({
|
||||
required this.image,
|
||||
});
|
||||
|
||||
final String image;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var chatScope = ChatScope.of(context);
|
||||
var options = chatScope.options;
|
||||
|
||||
return Container(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black,
|
||||
borderRadius: BorderRadius.circular(40.0),
|
||||
),
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: image.isNotEmpty
|
||||
? Image(
|
||||
fit: BoxFit.cover,
|
||||
image: options.imageProviderResolver(context, Uri.parse(image)),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
320
packages/flutter_chat/lib/src/screens/chat_profile_screen.dart
Normal file
320
packages/flutter_chat/lib/src/screens/chat_profile_screen.dart
Normal file
|
@ -0,0 +1,320 @@
|
|||
import "package:chat_repository_interface/chat_repository_interface.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_accessibility/flutter_accessibility.dart";
|
||||
import "package:flutter_chat/src/config/screen_types.dart";
|
||||
import "package:flutter_chat/src/util/scope.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:flutter_profile/flutter_profile.dart";
|
||||
|
||||
/// The chat profile screen
|
||||
/// Seen when a user taps on a chat profile
|
||||
/// Also used for group chats
|
||||
class ChatProfileScreen extends HookWidget {
|
||||
/// Constructs a [ChatProfileScreen]
|
||||
const ChatProfileScreen({
|
||||
required this.onExit,
|
||||
required this.userModel,
|
||||
required this.chatModel,
|
||||
required this.onTapUser,
|
||||
required this.onPressStartChat,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The user model of the persons profile to be viewed
|
||||
final UserModel? userModel;
|
||||
|
||||
/// The chat model of the chat being viewed
|
||||
final ChatModel? chatModel;
|
||||
|
||||
/// Callback function triggered when a user is tapped
|
||||
final Function(String)? onTapUser;
|
||||
|
||||
/// Callback function triggered when the start chat button is pressed
|
||||
final Function(String)? onPressStartChat;
|
||||
|
||||
/// Callback for when the user wants to navigate back
|
||||
final VoidCallback onExit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var chatScope = ChatScope.of(context);
|
||||
var options = chatScope.options;
|
||||
|
||||
useEffect(() {
|
||||
chatScope.popHandler.add(onExit);
|
||||
return () => chatScope.popHandler.remove(onExit);
|
||||
});
|
||||
|
||||
var chatTitle = userModel != null
|
||||
? "${userModel!.fullname}"
|
||||
: chatModel != null
|
||||
? chatModel?.chatName ?? options.translations.groupNameEmpty
|
||||
: "";
|
||||
|
||||
var appBar = _AppBar(
|
||||
title: chatTitle,
|
||||
semanticId: options.semantics.profileTitle,
|
||||
);
|
||||
|
||||
var body = _Body(
|
||||
user: userModel,
|
||||
chat: chatModel,
|
||||
onTapUser: onTapUser,
|
||||
onPressStartChat: onPressStartChat,
|
||||
);
|
||||
|
||||
if (options.builders.baseScreenBuilder == null) {
|
||||
return Scaffold(
|
||||
appBar: appBar,
|
||||
body: body,
|
||||
);
|
||||
}
|
||||
|
||||
return options.builders.baseScreenBuilder!.call(
|
||||
context,
|
||||
mapScreenType,
|
||||
appBar,
|
||||
chatTitle,
|
||||
body,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
const _AppBar({
|
||||
required this.title,
|
||||
required this.semanticId,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String semanticId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var theme = Theme.of(context);
|
||||
return AppBar(
|
||||
iconTheme: theme.appBarTheme.iconTheme,
|
||||
title: CustomSemantics(
|
||||
identifier: semanticId,
|
||||
value: title,
|
||||
child: Text(title),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
}
|
||||
|
||||
class _Body extends StatelessWidget {
|
||||
const _Body({
|
||||
required this.user,
|
||||
required this.chat,
|
||||
required this.onPressStartChat,
|
||||
required this.onTapUser,
|
||||
});
|
||||
|
||||
final UserModel? user;
|
||||
final ChatModel? chat;
|
||||
final Function(String)? onTapUser;
|
||||
final Function(String)? onPressStartChat;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var chatScope = ChatScope.of(context);
|
||||
var options = chatScope.options;
|
||||
var service = chatScope.service;
|
||||
var currentUser = chatScope.userId;
|
||||
var theme = Theme.of(context);
|
||||
|
||||
var chatUserDisplay = Wrap(
|
||||
children: [
|
||||
if (chat != null) ...[
|
||||
...chat!.users.asMap().entries.map(
|
||||
(entry) {
|
||||
var index = entry.key;
|
||||
var tappedUser = entry.value;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 8,
|
||||
right: 8,
|
||||
),
|
||||
child: CustomSemantics(
|
||||
identifier: options.semantics.profileTapUserButton(index),
|
||||
child: InkWell(
|
||||
onTap: () => onTapUser?.call(tappedUser),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FutureBuilder<UserModel>(
|
||||
future: service.getUser(userId: tappedUser).first,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState ==
|
||||
ConnectionState.waiting) {
|
||||
return const CircularProgressIndicator();
|
||||
}
|
||||
|
||||
var user = snapshot.data;
|
||||
|
||||
if (user == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return options.builders.userAvatarBuilder?.call(
|
||||
context,
|
||||
user,
|
||||
44,
|
||||
) ??
|
||||
Avatar(
|
||||
boxfit: BoxFit.cover,
|
||||
user: User(
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
imageUrl: user.imageUrl != null ||
|
||||
user.imageUrl != ""
|
||||
? user.imageUrl
|
||||
: null,
|
||||
),
|
||||
size: 60,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
var targetUser = user ??
|
||||
(
|
||||
chat != null
|
||||
? UserModel(
|
||||
id: UniqueKey().toString(),
|
||||
firstName: chat?.chatName,
|
||||
imageUrl: chat?.imageUrl,
|
||||
)
|
||||
: UserModel(
|
||||
id: UniqueKey().toString(),
|
||||
firstName: options.translations.groupNameEmpty,
|
||||
),
|
||||
) as UserModel;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
ListView(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
options.builders.userAvatarBuilder?.call(
|
||||
context,
|
||||
targetUser,
|
||||
60,
|
||||
) ??
|
||||
Avatar(
|
||||
boxfit: BoxFit.cover,
|
||||
user: user != null
|
||||
? User(
|
||||
firstName: user?.firstName,
|
||||
lastName: user?.lastName,
|
||||
imageUrl: user?.imageUrl != null ||
|
||||
user?.imageUrl != ""
|
||||
? user?.imageUrl
|
||||
: null,
|
||||
)
|
||||
: chat != null
|
||||
? User(
|
||||
firstName: chat?.chatName,
|
||||
imageUrl: chat?.imageUrl != null ||
|
||||
chat?.imageUrl != ""
|
||||
? chat?.imageUrl
|
||||
: null,
|
||||
)
|
||||
: User(
|
||||
firstName:
|
||||
options.translations.groupNameEmpty,
|
||||
),
|
||||
size: 60,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(
|
||||
color: Colors.white,
|
||||
thickness: 10,
|
||||
),
|
||||
if (chat != null) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 24,
|
||||
horizontal: 20,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
options.translations.groupProfileBioHeader,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CustomSemantics(
|
||||
identifier: options.semantics.profileDescription,
|
||||
value: chat!.description ?? "",
|
||||
child: Text(
|
||||
chat!.description ?? "",
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
options.translations.chatProfileUsers,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
chatUserDisplay,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (user?.id != currentUser) ...[
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 24,
|
||||
horizontal: 80,
|
||||
),
|
||||
child: CustomSemantics(
|
||||
identifier: options.semantics.profileStartChatButton,
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
onPressStartChat?.call(user!.id);
|
||||
},
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
options.translations.newChatButton,
|
||||
style: theme.textTheme.displayLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
720
packages/flutter_chat/lib/src/screens/chat_screen.dart
Normal file
720
packages/flutter_chat/lib/src/screens/chat_screen.dart
Normal file
|
@ -0,0 +1,720 @@
|
|||
import "package:chat_repository_interface/chat_repository_interface.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_accessibility/flutter_accessibility.dart";
|
||||
import "package:flutter_chat/src/config/chat_translations.dart";
|
||||
import "package:flutter_chat/src/config/screen_types.dart";
|
||||
import "package:flutter_chat/src/services/date_formatter.dart";
|
||||
import "package:flutter_chat/src/util/scope.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:flutter_profile/flutter_profile.dart";
|
||||
|
||||
/// The chat screen
|
||||
/// Seen when a user is chatting
|
||||
class ChatScreen extends HookWidget {
|
||||
/// Constructs a [ChatScreen]
|
||||
const ChatScreen({
|
||||
required this.onPressChat,
|
||||
required this.onDeleteChat,
|
||||
required this.onExit,
|
||||
this.onPressStartChat,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Callback function for starting a chat.
|
||||
final Function()? onPressStartChat;
|
||||
|
||||
/// Callback function for pressing on a chat.
|
||||
final void Function(ChatModel chat) onPressChat;
|
||||
|
||||
/// Callback function for deleting a chat.
|
||||
final void Function(ChatModel chat) onDeleteChat;
|
||||
|
||||
/// Callback for when the user wants to navigate back
|
||||
final VoidCallback? onExit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var chatScope = ChatScope.of(context);
|
||||
var options = chatScope.options;
|
||||
var translations = options.translations;
|
||||
|
||||
useEffect(() {
|
||||
if (onExit == null) return null;
|
||||
chatScope.popHandler.add(onExit!);
|
||||
return () => chatScope.popHandler.remove(onExit!);
|
||||
});
|
||||
|
||||
if (options.builders.baseScreenBuilder == null) {
|
||||
return Scaffold(
|
||||
appBar: const _AppBar(),
|
||||
body: _Body(
|
||||
onPressChat: onPressChat,
|
||||
onPressStartChat: onPressStartChat,
|
||||
onDeleteChat: onDeleteChat,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return options.builders.baseScreenBuilder!.call(
|
||||
context,
|
||||
mapScreenType,
|
||||
const _AppBar(),
|
||||
translations.chatsTitle,
|
||||
_Body(
|
||||
onPressChat: onPressChat,
|
||||
onPressStartChat: onPressStartChat,
|
||||
onDeleteChat: onDeleteChat,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
const _AppBar();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var chatScope = ChatScope.of(context);
|
||||
var service = chatScope.service;
|
||||
var options = chatScope.options;
|
||||
var translations = options.translations;
|
||||
var theme = Theme.of(context);
|
||||
|
||||
return AppBar(
|
||||
title: Text(
|
||||
translations.chatsTitle,
|
||||
),
|
||||
actions: [
|
||||
StreamBuilder<int>(
|
||||
stream: service.getUnreadMessagesCount(),
|
||||
builder: (BuildContext context, snapshot) => Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Visibility(
|
||||
visible: (snapshot.data ?? 0) > 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 22.0),
|
||||
child: CustomSemantics(
|
||||
identifier: options.semantics.chatUnreadMessages,
|
||||
value: "${snapshot.data ?? 0} ${translations.chatsUnread}",
|
||||
child: Text(
|
||||
"${snapshot.data ?? 0} ${translations.chatsUnread}",
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(
|
||||
kToolbarHeight,
|
||||
);
|
||||
}
|
||||
|
||||
class _Body extends StatefulWidget {
|
||||
const _Body({
|
||||
required this.onPressChat,
|
||||
required this.onDeleteChat,
|
||||
this.onPressStartChat,
|
||||
});
|
||||
|
||||
final Function(ChatModel chat) onPressChat;
|
||||
final Function()? onPressStartChat;
|
||||
final Function(ChatModel) onDeleteChat;
|
||||
|
||||
@override
|
||||
State<_Body> createState() => _BodyState();
|
||||
}
|
||||
|
||||
class _BodyState extends State<_Body> {
|
||||
final ScrollController controller = ScrollController();
|
||||
bool _hasCalledOnNoChats = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var chatScope = ChatScope.of(context);
|
||||
var options = chatScope.options;
|
||||
var service = chatScope.service;
|
||||
var translations = options.translations;
|
||||
var theme = Theme.of(context);
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
controller: controller,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 28),
|
||||
children: [
|
||||
StreamBuilder<List<ChatModel>?>(
|
||||
stream: service.getChats(),
|
||||
builder: (BuildContext context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.done &&
|
||||
(snapshot.data?.isEmpty ?? true) ||
|
||||
(snapshot.data != null && snapshot.data!.isEmpty)) {
|
||||
if (options.onNoChats != null && !_hasCalledOnNoChats) {
|
||||
_hasCalledOnNoChats = true; // Set the flag to true
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
// ignore: avoid_dynamic_calls
|
||||
await options.onNoChats!.call();
|
||||
});
|
||||
}
|
||||
return Center(
|
||||
child: Text(
|
||||
translations.noChatsFound,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
for (var (index, ChatModel chat)
|
||||
in (snapshot.data ?? []).indexed) ...[
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.dividerColor,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
var semantics = options.semantics;
|
||||
|
||||
var chatItem = _ChatItem(
|
||||
chat: chat,
|
||||
onPressChat: widget.onPressChat,
|
||||
semanticIdTitle:
|
||||
semantics.chatsChatTitle(index),
|
||||
semanticIdSubTitle:
|
||||
semantics.chatsChatSubTitle(index),
|
||||
semanticIdLastUsed:
|
||||
semantics.chatsChatLastUsed(index),
|
||||
semanticIdUnreadMessages:
|
||||
semantics.chatsChatUnreadMessages(index),
|
||||
semanticIdButton:
|
||||
semantics.chatsOpenChatButton(index),
|
||||
);
|
||||
|
||||
return !chat.canBeDeleted
|
||||
? Dismissible(
|
||||
confirmDismiss: (_) async {
|
||||
await options.builders
|
||||
.deleteChatDialogBuilder
|
||||
?.call(context, chat) ??
|
||||
_deleteDialog(
|
||||
chat,
|
||||
translations,
|
||||
// ignore: use_build_context_synchronously
|
||||
context,
|
||||
);
|
||||
return _deleteDialog(
|
||||
chat,
|
||||
translations,
|
||||
// ignore: use_build_context_synchronously
|
||||
context,
|
||||
);
|
||||
},
|
||||
onDismissed: (_) {
|
||||
widget.onDeleteChat(chat);
|
||||
},
|
||||
secondaryBackground: const ColoredBox(
|
||||
color: Colors.red,
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: Icon(
|
||||
Icons.delete,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
background: const ColoredBox(
|
||||
color: Colors.red,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: Icon(
|
||||
Icons.delete,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
key: ValueKey(
|
||||
chat.id,
|
||||
),
|
||||
child: chatItem,
|
||||
)
|
||||
: chatItem;
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.onPressStartChat != null)
|
||||
options.builders.newChatButtonBuilder?.call(
|
||||
context,
|
||||
widget.onPressStartChat!,
|
||||
translations,
|
||||
) ??
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 24,
|
||||
horizontal: 4,
|
||||
),
|
||||
child: CustomSemantics(
|
||||
identifier: options.semantics.chatsStartChatButton,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
fixedSize: const Size(254, 44),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(56),
|
||||
),
|
||||
),
|
||||
onPressed: widget.onPressStartChat,
|
||||
child: Text(
|
||||
translations.newChatButton,
|
||||
style: theme.textTheme.displayLarge,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatItem extends StatelessWidget {
|
||||
const _ChatItem({
|
||||
required this.chat,
|
||||
required this.onPressChat,
|
||||
required this.semanticIdTitle,
|
||||
required this.semanticIdSubTitle,
|
||||
required this.semanticIdLastUsed,
|
||||
required this.semanticIdUnreadMessages,
|
||||
required this.semanticIdButton,
|
||||
});
|
||||
|
||||
final ChatModel chat;
|
||||
final Function(ChatModel chat) onPressChat;
|
||||
final String semanticIdTitle;
|
||||
final String semanticIdSubTitle;
|
||||
final String semanticIdLastUsed;
|
||||
final String semanticIdUnreadMessages;
|
||||
final String semanticIdButton;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var chatScope = ChatScope.of(context);
|
||||
var options = chatScope.options;
|
||||
var dateFormatter = DateFormatter(
|
||||
options: options,
|
||||
);
|
||||
var theme = Theme.of(context);
|
||||
|
||||
var chatListItem = _ChatListItem(
|
||||
chat: chat,
|
||||
dateFormatter: dateFormatter,
|
||||
semanticIdTitle: semanticIdTitle,
|
||||
semanticIdSubTitle: semanticIdSubTitle,
|
||||
semanticIdLastUsed: semanticIdLastUsed,
|
||||
semanticIdUnreadMessages: semanticIdUnreadMessages,
|
||||
);
|
||||
|
||||
return CustomSemantics(
|
||||
identifier: semanticIdButton,
|
||||
buttonWithVariableText: true,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
onPressChat(chat);
|
||||
},
|
||||
child: options.builders.chatRowContainerBuilder?.call(
|
||||
context,
|
||||
chatListItem,
|
||||
) ??
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.dividerColor,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: chatListItem,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatListItem extends StatelessWidget {
|
||||
const _ChatListItem({
|
||||
required this.chat,
|
||||
required this.dateFormatter,
|
||||
required this.semanticIdTitle,
|
||||
required this.semanticIdSubTitle,
|
||||
required this.semanticIdLastUsed,
|
||||
required this.semanticIdUnreadMessages,
|
||||
});
|
||||
|
||||
final ChatModel chat;
|
||||
final DateFormatter dateFormatter;
|
||||
final String semanticIdTitle;
|
||||
final String semanticIdSubTitle;
|
||||
final String semanticIdLastUsed;
|
||||
final String semanticIdUnreadMessages;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var scope = ChatScope.of(context);
|
||||
var service = scope.service;
|
||||
var options = scope.options;
|
||||
var currentUserId = scope.userId;
|
||||
var translations = options.translations;
|
||||
if (chat.isGroupChat) {
|
||||
return StreamBuilder<MessageModel?>(
|
||||
stream: chat.lastMessage != null
|
||||
? service.getMessage(
|
||||
chatId: chat.id,
|
||||
messageId: chat.lastMessage!,
|
||||
)
|
||||
: const Stream.empty(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
var data = snapshot.data;
|
||||
|
||||
var showUnreadMessageCount =
|
||||
data != null && data.senderId != currentUserId;
|
||||
|
||||
return _ChatRow(
|
||||
title: chat.chatName ?? translations.groupNameEmpty,
|
||||
semanticIdTitle: semanticIdTitle,
|
||||
semanticIdSubTitle: semanticIdSubTitle,
|
||||
semanticIdLastUsed: semanticIdLastUsed,
|
||||
semanticIdUnreadMessages: semanticIdUnreadMessages,
|
||||
unreadMessages:
|
||||
showUnreadMessageCount ? chat.unreadMessageCount : 0,
|
||||
subTitle: data != null
|
||||
? data.isTextMessage
|
||||
? data.text
|
||||
: "📷 "
|
||||
"${translations.image}"
|
||||
: "",
|
||||
avatar: options.builders.groupAvatarBuilder?.call(
|
||||
context,
|
||||
chat.chatName ?? translations.groupNameEmpty,
|
||||
chat.imageUrl,
|
||||
40.0,
|
||||
) ??
|
||||
Avatar(
|
||||
boxfit: BoxFit.cover,
|
||||
user: User(
|
||||
firstName: chat.chatName,
|
||||
lastName: null,
|
||||
imageUrl: chat.imageUrl != null || chat.imageUrl != ""
|
||||
? chat.imageUrl
|
||||
: null,
|
||||
),
|
||||
size: 40.0,
|
||||
),
|
||||
lastUsed: chat.lastUsed != null
|
||||
? dateFormatter.format(
|
||||
date: chat.lastUsed!,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
var otherUser = chat.users.firstWhere(
|
||||
(element) => element != currentUserId,
|
||||
);
|
||||
|
||||
return StreamBuilder<UserModel>(
|
||||
stream: service.getUser(userId: otherUser),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
var otherUser = snapshot.data;
|
||||
|
||||
if (otherUser == null) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
return StreamBuilder<MessageModel?>(
|
||||
stream: chat.lastMessage != null
|
||||
? service.getMessage(
|
||||
chatId: chat.id,
|
||||
messageId: chat.lastMessage!,
|
||||
)
|
||||
: const Stream.empty(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
var data = snapshot.data;
|
||||
|
||||
var showUnreadMessageCount =
|
||||
data != null && data.senderId != currentUserId;
|
||||
|
||||
return _ChatRow(
|
||||
unreadMessages:
|
||||
showUnreadMessageCount ? chat.unreadMessageCount : 0,
|
||||
semanticIdTitle: semanticIdTitle,
|
||||
semanticIdSubTitle: semanticIdSubTitle,
|
||||
semanticIdLastUsed: semanticIdLastUsed,
|
||||
semanticIdUnreadMessages: semanticIdUnreadMessages,
|
||||
avatar: options.builders.userAvatarBuilder?.call(
|
||||
context,
|
||||
otherUser,
|
||||
40.0,
|
||||
) ??
|
||||
Avatar(
|
||||
boxfit: BoxFit.cover,
|
||||
user: User(
|
||||
firstName: otherUser.firstName,
|
||||
lastName: otherUser.lastName,
|
||||
imageUrl:
|
||||
otherUser.imageUrl != null || otherUser.imageUrl != ""
|
||||
? otherUser.imageUrl
|
||||
: null,
|
||||
),
|
||||
size: 40.0,
|
||||
),
|
||||
title: otherUser.fullname ?? translations.anonymousUser,
|
||||
subTitle: data != null
|
||||
? data.isTextMessage
|
||||
? data.text
|
||||
: "📷 "
|
||||
"${translations.image}"
|
||||
: "",
|
||||
lastUsed: chat.lastUsed != null
|
||||
? dateFormatter.format(
|
||||
date: chat.lastUsed!,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool?> _deleteDialog(
|
||||
ChatModel chat,
|
||||
ChatTranslations translations,
|
||||
BuildContext context,
|
||||
) async {
|
||||
var theme = Theme.of(context);
|
||||
|
||||
var scope = ChatScope.of(context);
|
||||
|
||||
var options = scope.options;
|
||||
|
||||
return showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
builder: (BuildContext context) => Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
translations.deleteChatModalTitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 20,
|
||||
),
|
||||
Text(
|
||||
translations.deleteChatModalDescription,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 20,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 60),
|
||||
child: CustomSemantics(
|
||||
identifier: options.semantics.chatsDeleteConfirmButton,
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.of(
|
||||
context,
|
||||
).pop(true);
|
||||
},
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
translations.deleteChatModalConfirm,
|
||||
style: theme.textTheme.displayLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _ChatRow extends StatelessWidget {
|
||||
const _ChatRow({
|
||||
required this.title,
|
||||
required this.semanticIdTitle,
|
||||
required this.semanticIdSubTitle,
|
||||
required this.semanticIdLastUsed,
|
||||
required this.semanticIdUnreadMessages,
|
||||
this.unreadMessages = 0,
|
||||
this.lastUsed,
|
||||
this.subTitle,
|
||||
this.avatar,
|
||||
});
|
||||
|
||||
/// The title of the chat.
|
||||
final String title;
|
||||
final String semanticIdTitle;
|
||||
|
||||
/// The number of unread messages in the chat.
|
||||
final int unreadMessages;
|
||||
final String semanticIdUnreadMessages;
|
||||
|
||||
/// The last time the chat was used.
|
||||
final String? lastUsed;
|
||||
final String semanticIdLastUsed;
|
||||
|
||||
/// The subtitle of the chat.
|
||||
final String? subTitle;
|
||||
final String semanticIdSubTitle;
|
||||
|
||||
/// The avatar associated with the chat.
|
||||
final Widget? avatar;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var theme = Theme.of(context);
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 10.0),
|
||||
child: avatar,
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CustomSemantics(
|
||||
identifier: semanticIdTitle,
|
||||
value: title,
|
||||
child: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
if (subTitle != null) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 3.0),
|
||||
child: CustomSemantics(
|
||||
identifier: semanticIdSubTitle,
|
||||
value: subTitle,
|
||||
child: Text(
|
||||
subTitle!,
|
||||
style: theme.textTheme.bodySmall,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (lastUsed != null) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: CustomSemantics(
|
||||
identifier: semanticIdLastUsed,
|
||||
value: lastUsed,
|
||||
child: Text(
|
||||
lastUsed!,
|
||||
style: theme.textTheme.labelSmall,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (unreadMessages > 0) ...[
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: CustomSemantics(
|
||||
identifier: semanticIdUnreadMessages,
|
||||
value: unreadMessages.toString(),
|
||||
child: Text(
|
||||
unreadMessages.toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,259 @@
|
|||
import "package:chat_repository_interface/chat_repository_interface.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_accessibility/flutter_accessibility.dart";
|
||||
import "package:flutter_chat/src/config/screen_types.dart";
|
||||
import "package:flutter_chat/src/screens/creation/widgets/search_field.dart";
|
||||
import "package:flutter_chat/src/screens/creation/widgets/search_icon.dart";
|
||||
import "package:flutter_chat/src/screens/creation/widgets/user_list.dart";
|
||||
import "package:flutter_chat/src/util/scope.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
|
||||
/// New chat screen
|
||||
/// This screen is used to create a new chat
|
||||
class NewChatScreen extends StatefulHookWidget {
|
||||
/// Constructs a [NewChatScreen]
|
||||
const NewChatScreen({
|
||||
required this.onExit,
|
||||
required this.onPressCreateGroupChat,
|
||||
required this.onPressCreateChat,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Callback function triggered when the create group chat button is pressed
|
||||
final VoidCallback onPressCreateGroupChat;
|
||||
|
||||
/// Callback function triggered when a user is tapped
|
||||
final Function(UserModel) onPressCreateChat;
|
||||
|
||||
/// Callback for when the user wants to navigate back
|
||||
final VoidCallback onExit;
|
||||
|
||||
@override
|
||||
State<NewChatScreen> createState() => _NewChatScreenState();
|
||||
}
|
||||
|
||||
class _NewChatScreenState extends State<NewChatScreen> {
|
||||
final FocusNode _textFieldFocusNode = FocusNode();
|
||||
bool _isSearching = false;
|
||||
String query = "";
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var chatScope = ChatScope.of(context);
|
||||
var options = chatScope.options;
|
||||
|
||||
useEffect(() {
|
||||
chatScope.popHandler.add(widget.onExit);
|
||||
return () => chatScope.popHandler.remove(widget.onExit);
|
||||
});
|
||||
|
||||
if (options.builders.baseScreenBuilder == null) {
|
||||
return Scaffold(
|
||||
appBar: _AppBar(
|
||||
isSearching: _isSearching,
|
||||
onSearch: (query) {
|
||||
setState(() {
|
||||
_isSearching = query.isNotEmpty;
|
||||
this.query = query;
|
||||
});
|
||||
},
|
||||
onPressedSearchIcon: () {
|
||||
setState(() {
|
||||
_isSearching = !_isSearching;
|
||||
query = "";
|
||||
});
|
||||
|
||||
if (_isSearching) {
|
||||
_textFieldFocusNode.requestFocus();
|
||||
}
|
||||
},
|
||||
focusNode: _textFieldFocusNode,
|
||||
),
|
||||
body: _Body(
|
||||
isSearching: _isSearching,
|
||||
onPressCreateGroupChat: widget.onPressCreateGroupChat,
|
||||
onPressCreateChat: widget.onPressCreateChat,
|
||||
query: query,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return options.builders.baseScreenBuilder!.call(
|
||||
context,
|
||||
widget.mapScreenType,
|
||||
_AppBar(
|
||||
isSearching: _isSearching,
|
||||
onSearch: (query) {
|
||||
setState(() {
|
||||
_isSearching = query.isNotEmpty;
|
||||
this.query = query;
|
||||
});
|
||||
},
|
||||
onPressedSearchIcon: () {
|
||||
setState(() {
|
||||
_isSearching = !_isSearching;
|
||||
query = "";
|
||||
});
|
||||
|
||||
if (_isSearching) {
|
||||
_textFieldFocusNode.requestFocus();
|
||||
}
|
||||
},
|
||||
focusNode: _textFieldFocusNode,
|
||||
),
|
||||
options.translations.newChatTitle,
|
||||
_Body(
|
||||
isSearching: _isSearching,
|
||||
onPressCreateGroupChat: widget.onPressCreateGroupChat,
|
||||
onPressCreateChat: widget.onPressCreateChat,
|
||||
query: query,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
const _AppBar({
|
||||
required this.isSearching,
|
||||
required this.onSearch,
|
||||
required this.onPressedSearchIcon,
|
||||
required this.focusNode,
|
||||
});
|
||||
|
||||
final bool isSearching;
|
||||
final Function(String) onSearch;
|
||||
final VoidCallback onPressedSearchIcon;
|
||||
final FocusNode focusNode;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var chatScope = ChatScope.of(context);
|
||||
var options = chatScope.options;
|
||||
var theme = Theme.of(context);
|
||||
|
||||
return AppBar(
|
||||
iconTheme: theme.appBarTheme.iconTheme ??
|
||||
const IconThemeData(color: Colors.white),
|
||||
title: SearchField(
|
||||
isSearching: isSearching,
|
||||
onSearch: onSearch,
|
||||
focusNode: focusNode,
|
||||
text: options.translations.newChatTitle,
|
||||
semanticId: options.semantics.newChatSearchInput,
|
||||
),
|
||||
actions: [
|
||||
SearchIcon(
|
||||
isSearching: isSearching,
|
||||
onPressed: onPressedSearchIcon,
|
||||
semanticId: options.semantics.newChatSearchIconButton,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
}
|
||||
|
||||
class _Body extends StatelessWidget {
|
||||
const _Body({
|
||||
required this.isSearching,
|
||||
required this.onPressCreateGroupChat,
|
||||
required this.onPressCreateChat,
|
||||
required this.query,
|
||||
});
|
||||
|
||||
final bool isSearching;
|
||||
|
||||
final String query;
|
||||
|
||||
final VoidCallback onPressCreateGroupChat;
|
||||
final Function(UserModel) onPressCreateChat;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var chatScope = ChatScope.of(context);
|
||||
var service = chatScope.service;
|
||||
var options = chatScope.options;
|
||||
var userId = chatScope.userId;
|
||||
var translations = options.translations;
|
||||
var theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (options.groupChatEnabled && !isSearching) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 32,
|
||||
right: 32,
|
||||
top: 20,
|
||||
),
|
||||
child: CustomSemantics(
|
||||
identifier: options.semantics.newChatCreateGroupChatButton,
|
||||
child: FilledButton(
|
||||
onPressed: onPressCreateGroupChat,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.groups_2,
|
||||
),
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
),
|
||||
Text(
|
||||
translations.newGroupChatButton,
|
||||
style: theme.textTheme.displayLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
Expanded(
|
||||
child: StreamBuilder<List<UserModel>>(
|
||||
// ignore: discarded_futures
|
||||
stream: service.getAllUsers(),
|
||||
builder: (context, snapshot) {
|
||||
var chatScope = ChatScope.of(context);
|
||||
var options = chatScope.options;
|
||||
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
} else if (snapshot.hasError) {
|
||||
return CustomSemantics(
|
||||
identifier: options.semantics.newChatGetUsersError,
|
||||
value: "Error: ${snapshot.error}",
|
||||
child: Text("Error: ${snapshot.error}"),
|
||||
);
|
||||
} else if (snapshot.hasData) {
|
||||
return UserList(
|
||||
users: snapshot.data!,
|
||||
currentUser: userId,
|
||||
query: query,
|
||||
onPressCreateChat: onPressCreateChat,
|
||||
);
|
||||
} else {
|
||||
return options.builders.noUsersPlaceholderBuilder
|
||||
?.call(context, translations) ??
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: Text(
|
||||
translations.noUsersFound,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,454 @@
|
|||
import "dart:typed_data";
|
||||
|
||||
import "package:chat_repository_interface/chat_repository_interface.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_accessibility/flutter_accessibility.dart";
|
||||
import "package:flutter_chat/src/config/screen_types.dart";
|
||||
import "package:flutter_chat/src/screens/creation/widgets/default_image_picker.dart";
|
||||
import "package:flutter_chat/src/util/scope.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:flutter_profile/flutter_profile.dart";
|
||||
|
||||
/// New group chat overview
|
||||
/// Seen after the user has selected the users they
|
||||
/// want to add to the group chat
|
||||
class NewGroupChatOverview extends HookWidget {
|
||||
/// Constructs a [NewGroupChatOverview]
|
||||
const NewGroupChatOverview({
|
||||
required this.onExit,
|
||||
required this.users,
|
||||
required this.onComplete,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The users to be added to the group chat
|
||||
final List<UserModel> users;
|
||||
|
||||
/// Callback for when the user wants to navigate back
|
||||
final VoidCallback onExit;
|
||||
|
||||
/// Callback function triggered when the group chat is created
|
||||
final Function(
|
||||
List<UserModel> users,
|
||||
String chatName,
|
||||
String description,
|
||||
Uint8List? image,
|
||||
) onComplete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var chatScope = ChatScope.of(context);
|
||||
var options = chatScope.options;
|
||||
|
||||
useEffect(() {
|
||||
chatScope.popHandler.add(onExit);
|
||||
return () => chatScope.popHandler.remove(onExit);
|
||||
});
|
||||
|
||||
if (options.builders.baseScreenBuilder == null) {
|
||||
return Scaffold(
|
||||
appBar: const _AppBar(),
|
||||
body: _Body(
|
||||
users: users,
|
||||
onComplete: onComplete,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return options.builders.baseScreenBuilder!.call(
|
||||
context,
|
||||
mapScreenType,
|
||||
const _AppBar(),
|
||||
options.translations.newGroupChatTitle,
|
||||
_Body(
|
||||
users: users,
|
||||
onComplete: onComplete,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
const _AppBar();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var chatScope = ChatScope.of(context);
|
||||
var options = chatScope.options;
|
||||
var theme = Theme.of(context);
|
||||
return AppBar(
|
||||
iconTheme: theme.appBarTheme.iconTheme ??
|
||||
const IconThemeData(color: Colors.white),
|
||||
backgroundColor: theme.appBarTheme.backgroundColor,
|
||||
title: Text(
|
||||
options.translations.newGroupChatTitle,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
}
|
||||
|
||||
class _Body extends StatefulWidget {
|
||||
const _Body({
|
||||
required this.users,
|
||||
required this.onComplete,
|
||||
});
|
||||
|
||||
final List<UserModel> users;
|
||||
final Function(
|
||||
List<UserModel> users,
|
||||
String chatName,
|
||||
String description,
|
||||
Uint8List? image,
|
||||
) onComplete;
|
||||
|
||||
@override
|
||||
State<_Body> createState() => _BodyState();
|
||||
}
|
||||
|
||||
class _BodyState extends State<_Body> {
|
||||
final TextEditingController _chatNameController = TextEditingController();
|
||||
final TextEditingController _bioController = TextEditingController();
|
||||
final ValueNotifier<bool> isButtonEnabled = ValueNotifier<bool>(false);
|
||||
Uint8List? image;
|
||||
|
||||
GlobalKey<FormState> formKey = GlobalKey<FormState>();
|
||||
bool isPressed = false;
|
||||
|
||||
List<UserModel> users = <UserModel>[];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
users = widget.users;
|
||||
super.initState();
|
||||
_chatNameController.addListener(() {
|
||||
isButtonEnabled.value = _chatNameController.text.isNotEmpty;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_chatNameController.dispose();
|
||||
_bioController.dispose();
|
||||
isButtonEnabled.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var chatScope = ChatScope.of(context);
|
||||
var options = chatScope.options;
|
||||
var theme = Theme.of(context);
|
||||
var translations = options.translations;
|
||||
return Stack(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 40,
|
||||
),
|
||||
Center(
|
||||
child: Stack(
|
||||
children: [
|
||||
CustomSemantics(
|
||||
identifier: options.semantics.newGroupChatSelectImage,
|
||||
child: InkWell(
|
||||
onTap: () async => onPressSelectImage(
|
||||
context,
|
||||
options,
|
||||
(image) {
|
||||
setState(() {
|
||||
this.image = image;
|
||||
});
|
||||
},
|
||||
),
|
||||
child: Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFD9D9D9),
|
||||
borderRadius: BorderRadius.circular(40),
|
||||
image: image != null
|
||||
? DecorationImage(
|
||||
image: MemoryImage(image!),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: image == null
|
||||
? const Icon(Icons.image)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (image != null)
|
||||
Positioned.directional(
|
||||
textDirection: Directionality.of(context),
|
||||
end: 0,
|
||||
child: Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFBCBCBC),
|
||||
borderRadius: BorderRadius.circular(40),
|
||||
),
|
||||
child: Center(
|
||||
child: CustomSemantics(
|
||||
identifier:
|
||||
options.semantics.newGroupChatRemoveImage,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
image = null;
|
||||
});
|
||||
},
|
||||
child: const Icon(
|
||||
Icons.close,
|
||||
size: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 40,
|
||||
),
|
||||
Text(
|
||||
translations.groupChatNameFieldHeader,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 12,
|
||||
),
|
||||
CustomSemantics(
|
||||
identifier: options.semantics.newChatNameInput,
|
||||
isTextField: true,
|
||||
child: TextFormField(
|
||||
style: theme.textTheme.bodySmall,
|
||||
controller: _chatNameController,
|
||||
decoration: InputDecoration(
|
||||
fillColor: Colors.white,
|
||||
filled: true,
|
||||
hintText: translations.groupNameHintText,
|
||||
hintStyle: theme.textTheme.bodyMedium,
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(
|
||||
color: Colors.transparent,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(
|
||||
color: Colors.transparent,
|
||||
),
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return translations.groupNameValidatorEmpty;
|
||||
}
|
||||
if (value.length > 15) {
|
||||
return translations.groupNameValidatorTooLong;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
Text(
|
||||
translations.groupBioFieldHeader,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 12,
|
||||
),
|
||||
CustomSemantics(
|
||||
identifier: options.semantics.newChatBioInput,
|
||||
isTextField: true,
|
||||
child: TextFormField(
|
||||
style: theme.textTheme.bodySmall,
|
||||
controller: _bioController,
|
||||
minLines: null,
|
||||
maxLines: 5,
|
||||
decoration: InputDecoration(
|
||||
fillColor: Colors.white,
|
||||
filled: true,
|
||||
hintText: translations.groupBioHintText,
|
||||
hintStyle: theme.textTheme.bodyMedium,
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(
|
||||
color: Colors.transparent,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(
|
||||
color: Colors.transparent,
|
||||
),
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return translations.groupBioValidatorEmpty;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
CustomSemantics(
|
||||
identifier: options.semantics.newGroupChatMemberAmount,
|
||||
value: "${translations.selectedMembersHeader}"
|
||||
"${users.length}",
|
||||
child: Text(
|
||||
"${translations.selectedMembersHeader}"
|
||||
"${users.length}",
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 12,
|
||||
),
|
||||
Wrap(
|
||||
children: [
|
||||
...users.map(
|
||||
(e) => _SelectedUser(
|
||||
user: e,
|
||||
onRemove: (user) {
|
||||
setState(() {
|
||||
users.remove(user);
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 80,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 24,
|
||||
horizontal: 80,
|
||||
),
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: isButtonEnabled,
|
||||
builder: (context, isEnabled, child) => CustomSemantics(
|
||||
identifier: "",
|
||||
child: FilledButton(
|
||||
onPressed: users.isNotEmpty
|
||||
? () async {
|
||||
if (!isPressed) {
|
||||
isPressed = true;
|
||||
if (formKey.currentState!.validate()) {
|
||||
await widget.onComplete(
|
||||
users,
|
||||
_chatNameController.text,
|
||||
_bioController.text,
|
||||
image,
|
||||
);
|
||||
}
|
||||
isPressed = false;
|
||||
}
|
||||
}
|
||||
: null,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
translations.createGroupChatButton,
|
||||
style: theme.textTheme.displayLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SelectedUser extends StatelessWidget {
|
||||
const _SelectedUser({
|
||||
required this.user,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
final UserModel user;
|
||||
final Function(UserModel) onRemove;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var chatScope = ChatScope.of(context);
|
||||
var options = chatScope.options;
|
||||
return CustomSemantics(
|
||||
identifier: options.semantics.newGroupChatRemoveUser,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
onRemove(user);
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: options.builders.userAvatarBuilder?.call(
|
||||
context,
|
||||
user,
|
||||
40,
|
||||
) ??
|
||||
Avatar(
|
||||
boxfit: BoxFit.cover,
|
||||
user: User(
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
imageUrl: user.imageUrl != "" ? user.imageUrl : null,
|
||||
),
|
||||
size: 40,
|
||||
),
|
||||
),
|
||||
Positioned.directional(
|
||||
textDirection: Directionality.of(context),
|
||||
end: 0,
|
||||
child: const Icon(
|
||||
Icons.cancel,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,298 @@
|
|||
import "package:chat_repository_interface/chat_repository_interface.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_accessibility/flutter_accessibility.dart";
|
||||
import "package:flutter_chat/src/config/screen_types.dart";
|
||||
import "package:flutter_chat/src/screens/creation/widgets/search_field.dart";
|
||||
import "package:flutter_chat/src/screens/creation/widgets/search_icon.dart";
|
||||
import "package:flutter_chat/src/screens/creation/widgets/user_list.dart";
|
||||
import "package:flutter_chat/src/util/scope.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
|
||||
/// New group chat screen
|
||||
/// This screen is used to create a new group chat
|
||||
class NewGroupChatScreen extends StatefulHookWidget {
|
||||
/// Constructs a [NewGroupChatScreen]
|
||||
const NewGroupChatScreen({
|
||||
required this.onExit,
|
||||
required this.onContinue,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Callback for when the user wants to navigate back
|
||||
final VoidCallback onExit;
|
||||
|
||||
/// Callback function triggered when the continue button is pressed
|
||||
final Function(List<UserModel>) onContinue;
|
||||
|
||||
@override
|
||||
State<NewGroupChatScreen> createState() => _NewGroupChatScreenState();
|
||||
}
|
||||
|
||||
class _NewGroupChatScreenState extends State<NewGroupChatScreen> {
|
||||
final FocusNode _textFieldFocusNode = FocusNode();
|
||||
bool _isSearching = false;
|
||||
String query = "";
|
||||
|
||||
List<UserModel> selectedUsers = [];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var chatScope = ChatScope.of(context);
|
||||
var options = chatScope.options;
|
||||
|
||||
useEffect(() {
|
||||
chatScope.popHandler.add(widget.onExit);
|
||||
return () => chatScope.popHandler.remove(widget.onExit);
|
||||
});
|
||||
if (options.builders.baseScreenBuilder == null) {
|
||||
return Scaffold(
|
||||
appBar: _AppBar(
|
||||
isSearching: _isSearching,
|
||||
onSearch: (query) {
|
||||
setState(() {
|
||||
_isSearching = query.isNotEmpty;
|
||||
this.query = query;
|
||||
});
|
||||
},
|
||||
onPressedSearchIcon: () {
|
||||
setState(() {
|
||||
_isSearching = !_isSearching;
|
||||
query = "";
|
||||
});
|
||||
|
||||
if (_isSearching) {
|
||||
_textFieldFocusNode.requestFocus();
|
||||
}
|
||||
},
|
||||
focusNode: _textFieldFocusNode,
|
||||
),
|
||||
body: _Body(
|
||||
onSelectedUser: handleUserTap,
|
||||
selectedUsers: selectedUsers,
|
||||
onPressGroupChatOverview: widget.onContinue,
|
||||
isSearching: _isSearching,
|
||||
query: query,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return options.builders.baseScreenBuilder!.call(
|
||||
context,
|
||||
widget.mapScreenType,
|
||||
_AppBar(
|
||||
isSearching: _isSearching,
|
||||
onSearch: (query) {
|
||||
setState(() {
|
||||
_isSearching = query.isNotEmpty;
|
||||
this.query = query;
|
||||
});
|
||||
},
|
||||
onPressedSearchIcon: () {
|
||||
setState(() {
|
||||
_isSearching = !_isSearching;
|
||||
query = "";
|
||||
});
|
||||
|
||||
if (_isSearching) {
|
||||
_textFieldFocusNode.requestFocus();
|
||||
}
|
||||
},
|
||||
focusNode: _textFieldFocusNode,
|
||||
),
|
||||
options.translations.newGroupChatTitle,
|
||||
_Body(
|
||||
onSelectedUser: handleUserTap,
|
||||
selectedUsers: selectedUsers,
|
||||
onPressGroupChatOverview: widget.onContinue,
|
||||
isSearching: _isSearching,
|
||||
query: query,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void handleUserTap(UserModel user) {
|
||||
if (selectedUsers.contains(user)) {
|
||||
setState(() {
|
||||
selectedUsers.remove(user);
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
selectedUsers.add(user);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _AppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
const _AppBar({
|
||||
required this.isSearching,
|
||||
required this.onSearch,
|
||||
required this.onPressedSearchIcon,
|
||||
required this.focusNode,
|
||||
});
|
||||
|
||||
final bool isSearching;
|
||||
final Function(String) onSearch;
|
||||
final VoidCallback onPressedSearchIcon;
|
||||
final FocusNode focusNode;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var chatScope = ChatScope.of(context);
|
||||
var options = chatScope.options;
|
||||
var theme = Theme.of(context);
|
||||
|
||||
return AppBar(
|
||||
iconTheme: theme.appBarTheme.iconTheme ??
|
||||
const IconThemeData(color: Colors.white),
|
||||
title: SearchField(
|
||||
isSearching: isSearching,
|
||||
onSearch: onSearch,
|
||||
focusNode: focusNode,
|
||||
text: options.translations.newGroupChatTitle,
|
||||
semanticId: options.semantics.newGroupChatSearchInput,
|
||||
),
|
||||
actions: [
|
||||
SearchIcon(
|
||||
isSearching: isSearching,
|
||||
onPressed: onPressedSearchIcon,
|
||||
semanticId: options.semantics.newGroupChatSearchIconButton,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
}
|
||||
|
||||
class _Body extends StatelessWidget {
|
||||
const _Body({
|
||||
required this.isSearching,
|
||||
required this.query,
|
||||
required this.selectedUsers,
|
||||
required this.onSelectedUser,
|
||||
required this.onPressGroupChatOverview,
|
||||
});
|
||||
|
||||
final bool isSearching;
|
||||
|
||||
final String query;
|
||||
|
||||
final List<UserModel> selectedUsers;
|
||||
final Function(UserModel) onSelectedUser;
|
||||
final Function(List<UserModel>) onPressGroupChatOverview;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var chatScope = ChatScope.of(context);
|
||||
var service = chatScope.service;
|
||||
var options = chatScope.options;
|
||||
var userId = chatScope.userId;
|
||||
var translations = options.translations;
|
||||
var theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: StreamBuilder<List<UserModel>>(
|
||||
// ignore: discarded_futures
|
||||
stream: service.getAllUsers(),
|
||||
builder: (context, snapshot) {
|
||||
var chatScope = ChatScope.of(context);
|
||||
var options = chatScope.options;
|
||||
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
} else if (snapshot.hasError) {
|
||||
return Semantics(
|
||||
identifier: options.semantics.newGroupChatGetUsersError,
|
||||
value: "Error: ${snapshot.error}",
|
||||
child: Text("Error: ${snapshot.error}"),
|
||||
);
|
||||
} else if (snapshot.hasData) {
|
||||
return Stack(
|
||||
children: [
|
||||
UserList(
|
||||
users: snapshot.data!,
|
||||
currentUser: userId,
|
||||
query: query,
|
||||
onPressCreateChat: null,
|
||||
creatingGroup: true,
|
||||
selectedUsers: selectedUsers,
|
||||
onSelectedUser: onSelectedUser,
|
||||
),
|
||||
_NextButton(
|
||||
selectedUsers: selectedUsers,
|
||||
onPressGroupChatOverview: onPressGroupChatOverview,
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return options.builders.noUsersPlaceholderBuilder
|
||||
?.call(context, translations) ??
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: Text(
|
||||
translations.noUsersFound,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NextButton extends StatelessWidget {
|
||||
const _NextButton({
|
||||
required this.onPressGroupChatOverview,
|
||||
required this.selectedUsers,
|
||||
});
|
||||
|
||||
final Function(List<UserModel>) onPressGroupChatOverview;
|
||||
final List<UserModel> selectedUsers;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var chatScope = ChatScope.of(context);
|
||||
var options = chatScope.options;
|
||||
var theme = Theme.of(context);
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 24,
|
||||
horizontal: 80,
|
||||
),
|
||||
child: Visibility(
|
||||
visible: selectedUsers.isNotEmpty,
|
||||
child: CustomSemantics(
|
||||
identifier: options.semantics.newGroupChatNextButton,
|
||||
child: FilledButton(
|
||||
onPressed: () async {
|
||||
await onPressGroupChatOverview(selectedUsers);
|
||||
},
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
options.translations.next,
|
||||
style: theme.textTheme.displayLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
import "dart:io";
|
||||
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_accessibility/flutter_accessibility.dart";
|
||||
import "package:flutter_chat/src/config/chat_options.dart";
|
||||
import "package:flutter_chat/src/util/scope.dart";
|
||||
import "package:flutter_image_picker/flutter_image_picker.dart";
|
||||
|
||||
/// The function to call when the user selects an image
|
||||
Future<void> onPressSelectImage(
|
||||
BuildContext context,
|
||||
ChatOptions options,
|
||||
Function(Uint8List image) onUploadImage,
|
||||
) async {
|
||||
var image = await options.builders.imagePickerBuilder.call(context);
|
||||
|
||||
if (image == null) return;
|
||||
await onUploadImage(image);
|
||||
}
|
||||
|
||||
/// Default image picker dialog for selecting an image from the gallery or
|
||||
/// taking a photo.
|
||||
class DefaultImagePickerDialog extends StatelessWidget {
|
||||
/// Creates a new default image picker dialog.
|
||||
const DefaultImagePickerDialog({
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Builds the default image picker dialog.
|
||||
static Future<Uint8List?> builder(BuildContext context) async =>
|
||||
showModalBottomSheet<Uint8List?>(
|
||||
context: context,
|
||||
builder: (context) => const DefaultImagePickerDialog(),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var chatScope = ChatScope.of(context);
|
||||
var options = chatScope.options;
|
||||
var translations = options.translations;
|
||||
var theme = Theme.of(context);
|
||||
var textTheme = theme.textTheme;
|
||||
|
||||
return options.builders.imagePickerContainerBuilder?.call(
|
||||
context,
|
||||
() => Navigator.of(context).pop(),
|
||||
translations,
|
||||
) ??
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
color: Colors.white,
|
||||
child: ImagePicker(
|
||||
config: ImagePickerConfig(
|
||||
imageQuality: options.imageQuality.clamp(0, 100),
|
||||
cameraOption: !kIsWeb && (Platform.isAndroid || Platform.isIOS),
|
||||
),
|
||||
theme: ImagePickerTheme(
|
||||
spaceBetweenIcons: 32.0,
|
||||
iconColor: theme.primaryColor,
|
||||
title: translations.imagePickerTitle,
|
||||
titleStyle: textTheme.titleMedium,
|
||||
iconSize: 60.0,
|
||||
makePhotoText: translations.takePicture,
|
||||
selectImageText: translations.uploadFile,
|
||||
selectImageIcon: Icon(
|
||||
color: theme.primaryColor,
|
||||
Icons.insert_drive_file_rounded,
|
||||
size: 60,
|
||||
),
|
||||
closeButtonBuilder: (ontap) => CustomSemantics(
|
||||
identifier: options.semantics.imagePickerCancelButton,
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(
|
||||
translations.cancelImagePickerBtn,
|
||||
style: textTheme.bodyMedium!.copyWith(
|
||||
fontSize: 18,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:flutter_accessibility/flutter_accessibility.dart";
|
||||
import "package:flutter_chat/src/util/scope.dart";
|
||||
|
||||
/// The search field widget
|
||||
class SearchField extends StatelessWidget {
|
||||
/// Constructs a [SearchField]
|
||||
const SearchField({
|
||||
required this.isSearching,
|
||||
required this.onSearch,
|
||||
required this.focusNode,
|
||||
required this.text,
|
||||
required this.semanticId,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Whether the search field is currently in use
|
||||
final bool isSearching;
|
||||
|
||||
/// Callback function triggered when the search field is used
|
||||
final Function(String query) onSearch;
|
||||
|
||||
/// The focus node of the search field
|
||||
final FocusNode focusNode;
|
||||
|
||||
/// The text to display in the search field
|
||||
final String text;
|
||||
|
||||
/// Semantic id for search field
|
||||
final String semanticId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var chatScope = ChatScope.of(context);
|
||||
var options = chatScope.options;
|
||||
var theme = Theme.of(context);
|
||||
var translations = options.translations;
|
||||
|
||||
if (isSearching) {
|
||||
return CustomSemantics(
|
||||
identifier: semanticId,
|
||||
isTextField: true,
|
||||
child: TextField(
|
||||
focusNode: focusNode,
|
||||
onChanged: onSearch,
|
||||
decoration: InputDecoration(
|
||||
hintText: translations.searchPlaceholder,
|
||||
hintStyle: theme.textTheme.bodyMedium,
|
||||
focusedBorder: UnderlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
style: theme.textTheme.bodySmall,
|
||||
cursorColor: theme.textSelectionTheme.cursorColor ?? Colors.white,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Text(
|
||||
text,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
import "package:flutter/material.dart";
|
||||
|
||||
/// A widget representing a search icon.
|
||||
class SearchIcon extends StatelessWidget {
|
||||
/// Constructs a [SearchIcon].
|
||||
const SearchIcon({
|
||||
required this.isSearching,
|
||||
required this.onPressed,
|
||||
required this.semanticId,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Whether the search icon is currently in use
|
||||
final bool isSearching;
|
||||
|
||||
/// Callback function triggered when the search icon is pressed
|
||||
final VoidCallback onPressed;
|
||||
|
||||
/// Semantic id for icon button
|
||||
final String semanticId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var theme = Theme.of(context);
|
||||
return Semantics(
|
||||
identifier: semanticId,
|
||||
child: IconButton(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(
|
||||
isSearching ? Icons.close : Icons.search,
|
||||
color: theme.appBarTheme.iconTheme?.color ?? Colors.white,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,216 @@
|
|||
import "package:chat_repository_interface/chat_repository_interface.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_accessibility/flutter_accessibility.dart";
|
||||
import "package:flutter_chat/src/util/scope.dart";
|
||||
import "package:flutter_profile/flutter_profile.dart";
|
||||
|
||||
/// The user list widget
|
||||
class UserList extends StatefulWidget {
|
||||
/// Constructs a [UserList]
|
||||
const UserList({
|
||||
required this.users,
|
||||
required this.currentUser,
|
||||
required this.query,
|
||||
required this.onPressCreateChat,
|
||||
this.creatingGroup = false,
|
||||
this.selectedUsers = const [],
|
||||
this.onSelectedUser,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The list of users
|
||||
final List<UserModel> users;
|
||||
|
||||
/// The query to search for
|
||||
final String query;
|
||||
|
||||
/// The current user
|
||||
final String currentUser;
|
||||
|
||||
/// Whether the user is creating a group
|
||||
final bool creatingGroup;
|
||||
|
||||
/// Callback function triggered when a chat is created
|
||||
final Function(UserModel)? onPressCreateChat;
|
||||
|
||||
/// The selected users
|
||||
final List<UserModel> selectedUsers;
|
||||
|
||||
/// Callback function triggered when a user is selected
|
||||
final Function(UserModel)? onSelectedUser;
|
||||
|
||||
@override
|
||||
State<UserList> createState() => _UserListState();
|
||||
}
|
||||
|
||||
class _UserListState extends State<UserList> {
|
||||
List<UserModel> users = [];
|
||||
List<UserModel> filteredUsers = [];
|
||||
bool isPressed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
users = List.from(widget.users);
|
||||
users.removeWhere((user) => user.id == widget.currentUser);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var chatScope = ChatScope.of(context);
|
||||
var options = chatScope.options;
|
||||
var theme = Theme.of(context);
|
||||
|
||||
var translations = options.translations;
|
||||
filteredUsers = widget.query.isNotEmpty
|
||||
? users
|
||||
.where(
|
||||
(user) =>
|
||||
user.fullname?.toLowerCase().contains(
|
||||
widget.query.toLowerCase(),
|
||||
) ??
|
||||
false,
|
||||
)
|
||||
.toList()
|
||||
: users;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8, left: 12, right: 12, bottom: 80),
|
||||
child: ListView.builder(
|
||||
itemCount: filteredUsers.length,
|
||||
itemBuilder: (context, index) {
|
||||
var user = filteredUsers[index];
|
||||
var isSelected = widget.selectedUsers.any((u) => u.id == user.id);
|
||||
return CustomSemantics(
|
||||
identifier: options.semantics.userListTapUser(index),
|
||||
buttonWithVariableText: true,
|
||||
child: InkWell(
|
||||
onTap: () async {
|
||||
if (widget.creatingGroup) {
|
||||
return handleGroupChatTap(user);
|
||||
} else {
|
||||
return handlePersonalChatTap(user);
|
||||
}
|
||||
},
|
||||
child: options.builders.chatRowContainerBuilder?.call(
|
||||
context,
|
||||
Row(
|
||||
children: [
|
||||
options.builders.userAvatarBuilder
|
||||
?.call(context, user, 44) ??
|
||||
Avatar(
|
||||
boxfit: BoxFit.cover,
|
||||
user: User(
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
imageUrl:
|
||||
user.imageUrl != "" ? user.imageUrl : null,
|
||||
),
|
||||
size: 44,
|
||||
),
|
||||
const SizedBox(
|
||||
width: 12,
|
||||
),
|
||||
CustomSemantics(
|
||||
identifier: options.semantics
|
||||
.newChatUserListUserFullName(index),
|
||||
value: user.fullname ?? translations.anonymousUser,
|
||||
child: Text(
|
||||
user.fullname ?? translations.anonymousUser,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
if (widget.creatingGroup) ...[
|
||||
const Spacer(),
|
||||
Checkbox(
|
||||
value: isSelected,
|
||||
onChanged: (value) {
|
||||
handleGroupChatTap(user);
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
width: 12,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
) ??
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.dividerColor,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
options.builders.userAvatarBuilder
|
||||
?.call(context, user, 44) ??
|
||||
Avatar(
|
||||
boxfit: BoxFit.cover,
|
||||
user: User(
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
imageUrl: user.imageUrl != ""
|
||||
? user.imageUrl
|
||||
: null,
|
||||
),
|
||||
size: 44,
|
||||
),
|
||||
const SizedBox(
|
||||
width: 12,
|
||||
),
|
||||
CustomSemantics(
|
||||
identifier: options.semantics
|
||||
.newChatUserListUserFullName(index),
|
||||
value: user.fullname ?? translations.anonymousUser,
|
||||
child: Text(
|
||||
user.fullname ?? translations.anonymousUser,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
if (widget.creatingGroup) ...[
|
||||
const Spacer(),
|
||||
Checkbox(
|
||||
value: isSelected,
|
||||
onChanged: (value) {
|
||||
handleGroupChatTap(user);
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
width: 12,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> handlePersonalChatTap(UserModel user) async {
|
||||
if (!isPressed) {
|
||||
setState(() {
|
||||
isPressed = true;
|
||||
});
|
||||
|
||||
await widget.onPressCreateChat?.call(user);
|
||||
|
||||
setState(() {
|
||||
isPressed = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void handleGroupChatTap(UserModel user) {
|
||||
widget.onSelectedUser?.call(user);
|
||||
}
|
||||
}
|
74
packages/flutter_chat/lib/src/services/date_formatter.dart
Normal file
74
packages/flutter_chat/lib/src/services/date_formatter.dart
Normal file
|
@ -0,0 +1,74 @@
|
|||
// SPDX-FileCopyrightText: 2022 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import "package:flutter_chat/src/config/chat_options.dart";
|
||||
import "package:intl/intl.dart";
|
||||
|
||||
/// The date formatter
|
||||
class DateFormatter {
|
||||
/// Constructs a [DateFormatter]
|
||||
DateFormatter({
|
||||
required this.options,
|
||||
});
|
||||
|
||||
/// The chat options
|
||||
final ChatOptions options;
|
||||
final _now = DateTime.now();
|
||||
|
||||
bool _isToday(DateTime date) =>
|
||||
DateTime(
|
||||
date.year,
|
||||
date.month,
|
||||
date.day,
|
||||
)
|
||||
.difference(
|
||||
DateTime(
|
||||
_now.year,
|
||||
_now.month,
|
||||
_now.day,
|
||||
),
|
||||
)
|
||||
.inDays ==
|
||||
0;
|
||||
|
||||
bool _isYesterday(DateTime date) =>
|
||||
DateTime(
|
||||
date.year,
|
||||
date.month,
|
||||
date.day,
|
||||
)
|
||||
.difference(
|
||||
DateTime(
|
||||
_now.year,
|
||||
_now.month,
|
||||
_now.day,
|
||||
),
|
||||
)
|
||||
.inDays ==
|
||||
-1;
|
||||
|
||||
bool _isThisYear(DateTime date) => date.year == _now.year;
|
||||
|
||||
/// Formats the date
|
||||
String format({
|
||||
required DateTime date,
|
||||
bool showFullDate = false,
|
||||
}) {
|
||||
if (options.dateformat != null) {
|
||||
return options.dateformat!(showFullDate, date);
|
||||
}
|
||||
if (_isToday(date)) {
|
||||
return DateFormat(
|
||||
"HH:mm",
|
||||
).format(date);
|
||||
} else if (_isYesterday(date)) {
|
||||
return "yesterday";
|
||||
} else if (_isThisYear(date)) {
|
||||
return DateFormat("dd-MM${showFullDate ? " HH:mm" : ""}").format(date);
|
||||
} else {
|
||||
return DateFormat("dd-MM-yyyy${showFullDate ? " HH:mm" : ""}")
|
||||
.format(date);
|
||||
}
|
||||
}
|
||||
}
|
24
packages/flutter_chat/lib/src/services/pop_handler.dart
Normal file
24
packages/flutter_chat/lib/src/services/pop_handler.dart
Normal file
|
@ -0,0 +1,24 @@
|
|||
import "package:flutter/material.dart";
|
||||
|
||||
///
|
||||
class PopHandler {
|
||||
/// Constructor
|
||||
PopHandler();
|
||||
|
||||
final List<VoidCallback> _handlers = [];
|
||||
|
||||
/// Registers a new handler
|
||||
void add(VoidCallback handler) {
|
||||
_handlers.add(handler);
|
||||
}
|
||||
|
||||
/// Removes a handler
|
||||
void remove(VoidCallback handler) {
|
||||
_handlers.remove(handler);
|
||||
}
|
||||
|
||||
/// Handles the pop
|
||||
void handlePop() {
|
||||
_handlers.lastOrNull?.call();
|
||||
}
|
||||
}
|
37
packages/flutter_chat/lib/src/util/scope.dart
Normal file
37
packages/flutter_chat/lib/src/util/scope.dart
Normal file
|
@ -0,0 +1,37 @@
|
|||
import "package:chat_repository_interface/chat_repository_interface.dart";
|
||||
import "package:flutter/widgets.dart";
|
||||
import "package:flutter_chat/src/config/chat_options.dart";
|
||||
import "package:flutter_chat/src/services/pop_handler.dart";
|
||||
|
||||
///
|
||||
class ChatScope extends InheritedWidget {
|
||||
///
|
||||
const ChatScope({
|
||||
required this.userId,
|
||||
required this.options,
|
||||
required this.service,
|
||||
required this.popHandler,
|
||||
required super.child,
|
||||
super.key,
|
||||
});
|
||||
|
||||
///
|
||||
final String userId;
|
||||
|
||||
///
|
||||
final ChatOptions options;
|
||||
|
||||
///
|
||||
final ChatService service;
|
||||
|
||||
///
|
||||
final PopHandler popHandler;
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(ChatScope oldWidget) =>
|
||||
oldWidget.userId != userId || oldWidget.options != options;
|
||||
|
||||
///
|
||||
static ChatScope of(BuildContext context) =>
|
||||
context.dependOnInheritedWidgetOfExactType<ChatScope>()!;
|
||||
}
|
18
packages/flutter_chat/lib/src/util/utils.dart
Normal file
18
packages/flutter_chat/lib/src/util/utils.dart
Normal file
|
@ -0,0 +1,18 @@
|
|||
// add generic utils that are used in the package
|
||||
|
||||
/// Extension to simplify detecting how many days relative dates are
|
||||
extension RelativeDates on DateTime {
|
||||
/// Strips timezone information whilst keeping the exact same date
|
||||
DateTime get utcDate => DateTime.utc(year, month, day);
|
||||
|
||||
/// Strips time information from the date
|
||||
DateTime get date => DateTime(year, month, day);
|
||||
|
||||
/// Get relative date in offset from the current position.
|
||||
///
|
||||
/// `today.getDateOffsetInDays(yesterday)` would result in `-1`
|
||||
///
|
||||
/// `yesterday.getDateOffsetInDays(tomorrow)` would result in `2`
|
||||
int getDateOffsetInDays(DateTime other) =>
|
||||
other.utcDate.difference(utcDate).inDays;
|
||||
}
|
|
@ -1,42 +1,37 @@
|
|||
# SPDX-FileCopyrightText: 2022 Iconica
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
name: flutter_chat
|
||||
description: A new Flutter package project.
|
||||
version: 1.4.3
|
||||
|
||||
publish_to: none
|
||||
description: "User story of the chat domain for quick integration into flutter apps"
|
||||
version: 6.0.0
|
||||
publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub/
|
||||
|
||||
environment:
|
||||
sdk: ">=3.1.0 <4.0.0"
|
||||
sdk: ">=3.4.3 <4.0.0"
|
||||
flutter: ">=1.17.0"
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
go_router: any
|
||||
flutter_chat_view:
|
||||
git:
|
||||
url: https://github.com/Iconica-Development/flutter_chat
|
||||
path: packages/flutter_chat_view
|
||||
ref: 1.4.3
|
||||
flutter_chat_interface:
|
||||
git:
|
||||
url: https://github.com/Iconica-Development/flutter_chat
|
||||
path: packages/flutter_chat_interface
|
||||
ref: 1.4.3
|
||||
flutter_chat_local:
|
||||
git:
|
||||
url: https://github.com/Iconica-Development/flutter_chat
|
||||
path: packages/flutter_chat_local
|
||||
ref: 1.4.3
|
||||
uuid: ^4.3.3
|
||||
|
||||
cached_network_image: ^3.2.2
|
||||
intl: any
|
||||
flutter_hooks: ^0.20.5
|
||||
|
||||
flutter_accessibility:
|
||||
hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub
|
||||
version: ^0.0.2
|
||||
flutter_image_picker:
|
||||
hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub
|
||||
version: ^4.0.0
|
||||
flutter_profile:
|
||||
hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub
|
||||
version: ^1.6.0
|
||||
chat_repository_interface:
|
||||
hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub
|
||||
version: ^6.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_iconica_analysis:
|
||||
git:
|
||||
url: https://github.com/Iconica-Development/flutter_iconica_analysis
|
||||
ref: 6.0.0
|
||||
|
||||
flutter:
|
||||
ref: 7.0.0
|
||||
|
|
14
packages/flutter_chat/test/relative_date_test.dart
Normal file
14
packages/flutter_chat/test/relative_date_test.dart
Normal file
|
@ -0,0 +1,14 @@
|
|||
import "package:flutter_chat/src/util/utils.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
|
||||
void main() {
|
||||
group("RelativeDates", () {
|
||||
test("getDateOffsetInDays", () {
|
||||
var dateA = DateTime(2024, 10, 30);
|
||||
var dateB = DateTime(2024, 10, 01);
|
||||
|
||||
expect(dateA.getDateOffsetInDays(dateB), equals(29));
|
||||
expect(dateB.getDateOffsetInDays(dateA), equals(-29));
|
||||
});
|
||||
});
|
||||
}
|
|
@ -1,60 +0,0 @@
|
|||
// SPDX-FileCopyrightText: 2022 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Options for Firebase chat configuration.
|
||||
@immutable
|
||||
class FirebaseChatOptions {
|
||||
/// Creates a new instance of `FirebaseChatOptions`.
|
||||
const FirebaseChatOptions({
|
||||
this.groupChatsCollectionName = 'group_chats',
|
||||
this.chatsCollectionName = 'chats',
|
||||
this.messagesCollectionName = 'messages',
|
||||
this.usersCollectionName = 'users',
|
||||
this.chatsMetaDataCollectionName = 'chat_metadata',
|
||||
this.userChatsCollectionName = 'chats',
|
||||
});
|
||||
|
||||
/// The collection name for group chats.
|
||||
final String groupChatsCollectionName;
|
||||
|
||||
/// The collection name for chats.
|
||||
final String chatsCollectionName;
|
||||
|
||||
/// The collection name for messages.
|
||||
final String messagesCollectionName;
|
||||
|
||||
/// The collection name for users.
|
||||
final String usersCollectionName;
|
||||
|
||||
/// The collection name for chat metadata.
|
||||
final String chatsMetaDataCollectionName;
|
||||
|
||||
/// The collection name for user chats.
|
||||
final String userChatsCollectionName;
|
||||
|
||||
/// Creates a copy of this FirebaseChatOptions but with the given fields
|
||||
/// replaced with the new values.
|
||||
FirebaseChatOptions copyWith({
|
||||
String? groupChatsCollectionName,
|
||||
String? chatsCollectionName,
|
||||
String? messagesCollectionName,
|
||||
String? usersCollectionName,
|
||||
String? chatsMetaDataCollectionName,
|
||||
String? userChatsCollectionName,
|
||||
}) =>
|
||||
FirebaseChatOptions(
|
||||
groupChatsCollectionName:
|
||||
groupChatsCollectionName ?? this.groupChatsCollectionName,
|
||||
chatsCollectionName: chatsCollectionName ?? this.chatsCollectionName,
|
||||
messagesCollectionName:
|
||||
messagesCollectionName ?? this.messagesCollectionName,
|
||||
usersCollectionName: usersCollectionName ?? this.usersCollectionName,
|
||||
chatsMetaDataCollectionName:
|
||||
chatsMetaDataCollectionName ?? this.chatsMetaDataCollectionName,
|
||||
userChatsCollectionName:
|
||||
userChatsCollectionName ?? this.userChatsCollectionName,
|
||||
);
|
||||
}
|
|
@ -1,72 +0,0 @@
|
|||
// SPDX-FileCopyrightText: 2022 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_chat_firebase/dto/firebase_message_document.dart';
|
||||
|
||||
/// Represents a chat document in Firebase.
|
||||
@immutable
|
||||
class FirebaseChatDocument {
|
||||
/// Creates a new instance of `FirebaseChatDocument`.
|
||||
const FirebaseChatDocument({
|
||||
required this.personal,
|
||||
required this.canBeDeleted,
|
||||
this.users = const [],
|
||||
this.id,
|
||||
this.lastUsed,
|
||||
this.title,
|
||||
this.imageUrl,
|
||||
this.lastMessage,
|
||||
});
|
||||
|
||||
/// Constructs a FirebaseChatDocument from JSON.
|
||||
FirebaseChatDocument.fromJson(Map<String, dynamic> json, this.id)
|
||||
: title = json['title'],
|
||||
imageUrl = json['image_url'],
|
||||
personal = json['personal'],
|
||||
canBeDeleted = json['can_be_deleted'] ?? true,
|
||||
lastUsed = json['last_used'],
|
||||
users = json['users'] != null ? List<String>.from(json['users']) : [],
|
||||
lastMessage = json['last_message'] == null
|
||||
? null
|
||||
: FirebaseMessageDocument.fromJson(
|
||||
json['last_message'],
|
||||
null,
|
||||
);
|
||||
|
||||
/// The unique identifier of the chat document.
|
||||
final String? id;
|
||||
|
||||
/// The title of the chat.
|
||||
final String? title;
|
||||
|
||||
/// The image URL of the chat.
|
||||
final String? imageUrl;
|
||||
|
||||
/// Indicates if the chat is personal.
|
||||
final bool personal;
|
||||
|
||||
/// Indicates if the chat can be deleted.
|
||||
final bool canBeDeleted;
|
||||
|
||||
/// The timestamp of when the chat was last used.
|
||||
final Timestamp? lastUsed;
|
||||
|
||||
/// The list of users participating in the chat.
|
||||
final List<String> users;
|
||||
|
||||
/// The last message in the chat.
|
||||
final FirebaseMessageDocument? lastMessage;
|
||||
|
||||
/// Converts the FirebaseChatDocument to JSON format.
|
||||
Map<String, dynamic> toJson() => {
|
||||
'title': title,
|
||||
'image_url': imageUrl,
|
||||
'personal': personal,
|
||||
'last_used': lastUsed,
|
||||
'can_be_deleted': canBeDeleted,
|
||||
'users': users,
|
||||
};
|
||||
}
|
|
@ -1,49 +0,0 @@
|
|||
// SPDX-FileCopyrightText: 2022 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Represents a message document in Firebase.
|
||||
@immutable
|
||||
class FirebaseMessageDocument {
|
||||
/// Creates a new instance of `FirebaseMessageDocument`.
|
||||
const FirebaseMessageDocument({
|
||||
required this.sender,
|
||||
required this.timestamp,
|
||||
this.id,
|
||||
this.text,
|
||||
this.imageUrl,
|
||||
});
|
||||
|
||||
/// Constructs a FirebaseMessageDocument from JSON.
|
||||
FirebaseMessageDocument.fromJson(Map<String, dynamic> json, this.id)
|
||||
: sender = json['sender'],
|
||||
text = json['text'],
|
||||
imageUrl = json['image_url'],
|
||||
timestamp = json['timestamp'];
|
||||
|
||||
/// The unique identifier of the message document.
|
||||
final String? id;
|
||||
|
||||
/// The sender of the message.
|
||||
final String sender;
|
||||
|
||||
/// The text content of the message.
|
||||
final String? text;
|
||||
|
||||
/// The image URL of the message.
|
||||
final String? imageUrl;
|
||||
|
||||
/// The timestamp of when the message was sent.
|
||||
final Timestamp timestamp;
|
||||
|
||||
/// Converts the FirebaseMessageDocument to JSON format.
|
||||
Map<String, dynamic> toJson() => {
|
||||
'sender': sender,
|
||||
'text': text,
|
||||
'image_url': imageUrl,
|
||||
'timestamp': timestamp,
|
||||
};
|
||||
}
|
|
@ -1,50 +0,0 @@
|
|||
// SPDX-FileCopyrightText: 2022 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Represents a user document in Firebase.
|
||||
@immutable
|
||||
class FirebaseUserDocument {
|
||||
/// Creates a new instance of `FirebaseUserDocument`.
|
||||
const FirebaseUserDocument({
|
||||
this.firstName,
|
||||
this.lastName,
|
||||
this.imageUrl,
|
||||
this.id,
|
||||
});
|
||||
|
||||
/// Constructs a FirebaseUserDocument from JSON.
|
||||
FirebaseUserDocument.fromJson(
|
||||
Map<String, Object?> json,
|
||||
String id,
|
||||
) : this(
|
||||
id: id,
|
||||
firstName:
|
||||
json['first_name'] == null ? '' : json['first_name']! as String,
|
||||
lastName:
|
||||
json['last_name'] == null ? '' : json['last_name']! as String,
|
||||
imageUrl:
|
||||
json['image_url'] == null ? null : json['image_url']! as String,
|
||||
);
|
||||
|
||||
/// The first name of the user.
|
||||
final String? firstName;
|
||||
|
||||
/// The last name of the user.
|
||||
final String? lastName;
|
||||
|
||||
/// The image URL of the user.
|
||||
final String? imageUrl;
|
||||
|
||||
/// The unique identifier of the user document.
|
||||
final String? id;
|
||||
|
||||
/// Converts the FirebaseUserDocument to JSON format.
|
||||
Map<String, Object?> toJson() => {
|
||||
'first_name': firstName,
|
||||
'last_name': lastName,
|
||||
'image_url': imageUrl,
|
||||
};
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
// SPDX-FileCopyrightText: 2022 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
///
|
||||
library flutter_chat_firebase;
|
||||
|
||||
export 'package:flutter_chat_firebase/service/service.dart';
|
|
@ -1,347 +0,0 @@
|
|||
// SPDX-FileCopyrightText: 2022 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_storage/firebase_storage.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_chat_firebase/config/firebase_chat_options.dart';
|
||||
import 'package:flutter_chat_firebase/dto/firebase_message_document.dart';
|
||||
import 'package:flutter_chat_interface/flutter_chat_interface.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// Service class for managing chat details using Firebase.
|
||||
class FirebaseChatDetailService
|
||||
with ChangeNotifier
|
||||
implements ChatDetailService {
|
||||
/// Constructor for FirebaseChatDetailService.
|
||||
///
|
||||
/// [userService]: Instance of ChatUserService.
|
||||
/// [app]: Optional FirebaseApp instance, defaults to Firebase.app().
|
||||
/// [options]: Optional FirebaseChatOptions instance,
|
||||
/// defaults to FirebaseChatOptions().
|
||||
FirebaseChatDetailService({
|
||||
required ChatUserService userService,
|
||||
FirebaseApp? app,
|
||||
FirebaseChatOptions? options,
|
||||
}) {
|
||||
var appInstance = app ?? Firebase.app();
|
||||
|
||||
_db = FirebaseFirestore.instanceFor(app: appInstance);
|
||||
_storage = FirebaseStorage.instanceFor(app: appInstance);
|
||||
_userService = userService;
|
||||
_options = options ?? const FirebaseChatOptions();
|
||||
}
|
||||
late final FirebaseFirestore _db;
|
||||
late final FirebaseStorage _storage;
|
||||
late final ChatUserService _userService;
|
||||
late FirebaseChatOptions _options;
|
||||
|
||||
StreamController<List<ChatMessageModel>>? _controller;
|
||||
StreamSubscription<QuerySnapshot>? _subscription;
|
||||
DocumentSnapshot<Object>? lastMessage;
|
||||
List<ChatMessageModel> _cumulativeMessages = [];
|
||||
String? lastChat;
|
||||
int? chatPageSize;
|
||||
DateTime timestampToFilter = DateTime.now();
|
||||
|
||||
Future<void> _sendMessage(String chatId, Map<String, dynamic> data) async {
|
||||
var currentUser = await _userService.getCurrentUser();
|
||||
|
||||
if (currentUser == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
var message = {
|
||||
'sender': currentUser.id,
|
||||
'timestamp': DateTime.now(),
|
||||
...data,
|
||||
};
|
||||
|
||||
var chatReference = _db
|
||||
.collection(
|
||||
_options.chatsCollectionName,
|
||||
)
|
||||
.doc(chatId);
|
||||
|
||||
var newMessage = await chatReference
|
||||
.collection(
|
||||
_options.messagesCollectionName,
|
||||
)
|
||||
.add(message);
|
||||
|
||||
if (_cumulativeMessages.length == 1) {
|
||||
lastMessage = await chatReference
|
||||
.collection(
|
||||
_options.messagesCollectionName,
|
||||
)
|
||||
.doc(newMessage.id)
|
||||
.get();
|
||||
}
|
||||
|
||||
var metadataReference = _db
|
||||
.collection(
|
||||
_options.chatsMetaDataCollectionName,
|
||||
)
|
||||
.doc(chatId);
|
||||
|
||||
await metadataReference.update({
|
||||
'last_used': DateTime.now(),
|
||||
'last_message': message,
|
||||
});
|
||||
|
||||
// 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 metadataReference.get();
|
||||
var chatUsers = fetchedChat.data()?['users'] as List<dynamic>;
|
||||
// for all users except the message sender update the unread counter
|
||||
for (var userId in chatUsers) {
|
||||
if (userId != currentUser.id) {
|
||||
var userReference = _db
|
||||
.collection(
|
||||
_options.usersCollectionName,
|
||||
)
|
||||
.doc(userId)
|
||||
.collection(_options.userChatsCollectionName)
|
||||
.doc(chatId);
|
||||
// what if the amount_unread_messages field does not exist?
|
||||
// it should be created when the chat is create
|
||||
if ((await userReference.get())
|
||||
.data()
|
||||
?.containsKey('amount_unread_messages') ??
|
||||
false) {
|
||||
await userReference.update({
|
||||
'amount_unread_messages': FieldValue.increment(1),
|
||||
});
|
||||
} else {
|
||||
await userReference.set(
|
||||
{
|
||||
'amount_unread_messages': 1,
|
||||
},
|
||||
SetOptions(merge: true),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a text message to a chat.
|
||||
///
|
||||
/// [text]: The text message to send.
|
||||
/// [chatId]: The ID of the chat where the message will be sent.
|
||||
@override
|
||||
Future<void> sendTextMessage({
|
||||
required String text,
|
||||
required String chatId,
|
||||
}) =>
|
||||
_sendMessage(
|
||||
chatId,
|
||||
{
|
||||
'text': text,
|
||||
},
|
||||
);
|
||||
|
||||
/// Sends an image message to a chat.
|
||||
///
|
||||
/// [chatId]: The ID of the chat where the message will be sent.
|
||||
/// [image]: The image data to send.
|
||||
@override
|
||||
Future<void> sendImageMessage({
|
||||
required String chatId,
|
||||
required Uint8List image,
|
||||
}) async {
|
||||
var ref = _storage
|
||||
.ref('${_options.chatsCollectionName}/$chatId/${const Uuid().v4()}');
|
||||
|
||||
return ref.putData(image).then(
|
||||
(_) => ref.getDownloadURL().then(
|
||||
(url) {
|
||||
_sendMessage(
|
||||
chatId,
|
||||
{
|
||||
'image_url': url,
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Retrieves a stream of messages for a chat.
|
||||
///
|
||||
/// [chatId]: The ID of the chat.
|
||||
@override
|
||||
Stream<List<ChatMessageModel>> getMessagesStream(String chatId) {
|
||||
timestampToFilter = DateTime.now();
|
||||
var messages = <ChatMessageModel>[];
|
||||
_controller = StreamController<List<ChatMessageModel>>(
|
||||
onListen: () {
|
||||
var messagesCollection = _db
|
||||
.collection(_options.chatsCollectionName)
|
||||
.doc(chatId)
|
||||
.collection(_options.messagesCollectionName)
|
||||
.where(
|
||||
'timestamp',
|
||||
isGreaterThan: timestampToFilter,
|
||||
)
|
||||
.withConverter<FirebaseMessageDocument>(
|
||||
fromFirestore: (snapshot, _) => FirebaseMessageDocument.fromJson(
|
||||
snapshot.data()!,
|
||||
snapshot.id,
|
||||
),
|
||||
toFirestore: (user, _) => user.toJson(),
|
||||
)
|
||||
.snapshots();
|
||||
|
||||
_subscription = messagesCollection.listen((event) async {
|
||||
for (var message in event.docChanges) {
|
||||
var data = message.doc.data();
|
||||
var sender = await _userService.getUser(data!.sender);
|
||||
var timestamp = DateTime.fromMillisecondsSinceEpoch(
|
||||
data.timestamp.millisecondsSinceEpoch,
|
||||
);
|
||||
|
||||
if (timestamp.isBefore(timestampToFilter)) {
|
||||
return;
|
||||
}
|
||||
messages.add(
|
||||
data.imageUrl != null
|
||||
? ChatImageMessageModel(
|
||||
sender: sender!,
|
||||
imageUrl: data.imageUrl!,
|
||||
timestamp: timestamp,
|
||||
)
|
||||
: ChatTextMessageModel(
|
||||
sender: sender!,
|
||||
text: data.text!,
|
||||
timestamp: timestamp,
|
||||
),
|
||||
);
|
||||
timestampToFilter = DateTime.now();
|
||||
}
|
||||
_cumulativeMessages = [
|
||||
..._cumulativeMessages,
|
||||
...messages,
|
||||
];
|
||||
var uniqueObjects = _cumulativeMessages.toSet().toList();
|
||||
_cumulativeMessages = uniqueObjects;
|
||||
_cumulativeMessages
|
||||
.sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
||||
notifyListeners();
|
||||
});
|
||||
},
|
||||
onCancel: () async {
|
||||
await _subscription?.cancel();
|
||||
_subscription = null;
|
||||
_cumulativeMessages = [];
|
||||
lastChat = chatId;
|
||||
lastMessage = null;
|
||||
debugPrint('Canceling messages stream');
|
||||
},
|
||||
);
|
||||
|
||||
return _controller!.stream;
|
||||
}
|
||||
|
||||
/// Stops listening for messages.
|
||||
@override
|
||||
Future<void> stopListeningForMessages() async {
|
||||
await _subscription?.cancel();
|
||||
_subscription = null;
|
||||
await _controller?.close();
|
||||
_controller = null;
|
||||
}
|
||||
|
||||
/// Fetches more messages for a chat.
|
||||
///
|
||||
/// [pageSize]: The number of messages to fetch.
|
||||
/// [chatId]: The ID of the chat.
|
||||
@override
|
||||
Future<void> fetchMoreMessage(
|
||||
int pageSize,
|
||||
String chatId,
|
||||
) async {
|
||||
if (lastChat != chatId) {
|
||||
_cumulativeMessages = [];
|
||||
lastChat = chatId;
|
||||
lastMessage = null;
|
||||
}
|
||||
|
||||
// get the x amount of last messages from the oldest message that is in
|
||||
// cumulative messages and add that to the list
|
||||
var messages = <ChatMessageModel>[];
|
||||
QuerySnapshot<FirebaseMessageDocument>? messagesQuerySnapshot;
|
||||
var query = _db
|
||||
.collection(_options.chatsCollectionName)
|
||||
.doc(chatId)
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
var 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();
|
||||
}
|
||||
|
||||
/// Retrieves the list of messages.
|
||||
@override
|
||||
List<ChatMessageModel> getMessages() => _cumulativeMessages;
|
||||
}
|
|
@ -1,477 +0,0 @@
|
|||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||
// SPDX-FileCopyrightText: 2022 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_storage/firebase_storage.dart';
|
||||
import 'package:flutter_chat_firebase/config/firebase_chat_options.dart';
|
||||
import 'package:flutter_chat_firebase/dto/firebase_chat_document.dart';
|
||||
import 'package:flutter_chat_interface/flutter_chat_interface.dart';
|
||||
|
||||
/// Service class for managing chat overviews using Firebase.
|
||||
class FirebaseChatOverviewService implements ChatOverviewService {
|
||||
late FirebaseFirestore _db;
|
||||
late FirebaseStorage _storage;
|
||||
late ChatUserService _userService;
|
||||
late FirebaseChatOptions _options;
|
||||
|
||||
/// Constructor for FirebaseChatOverviewService.
|
||||
///
|
||||
/// [userService]: Instance of ChatUserService.
|
||||
/// [app]: Optional FirebaseApp instance, defaults to Firebase.app().
|
||||
/// [options]: Optional FirebaseChatOptions instance, defaults
|
||||
/// to FirebaseChatOptions().
|
||||
FirebaseChatOverviewService({
|
||||
required ChatUserService userService,
|
||||
FirebaseApp? app,
|
||||
FirebaseChatOptions? options,
|
||||
}) {
|
||||
var appInstance = app ?? Firebase.app();
|
||||
|
||||
_db = FirebaseFirestore.instanceFor(app: appInstance);
|
||||
_storage = FirebaseStorage.instanceFor(app: appInstance);
|
||||
_userService = userService;
|
||||
_options = options ?? const FirebaseChatOptions();
|
||||
}
|
||||
|
||||
Future<int?> _addUnreadChatSubscription(
|
||||
String chatId,
|
||||
String userId,
|
||||
) async {
|
||||
var snapshots = await _db
|
||||
.collection(_options.usersCollectionName)
|
||||
.doc(userId)
|
||||
.collection(_options.userChatsCollectionName)
|
||||
.doc(chatId)
|
||||
.get();
|
||||
|
||||
return snapshots.data()?['amount_unread_messages'];
|
||||
}
|
||||
|
||||
/// Retrieves a stream of chat overviews.
|
||||
@override
|
||||
Stream<List<ChatModel>> getChatsStream() {
|
||||
StreamSubscription? chatSubscription;
|
||||
// ignore: close_sinks
|
||||
late StreamController<List<ChatModel>> controller;
|
||||
controller = StreamController(
|
||||
onListen: () async {
|
||||
var currentUser = await _userService.getCurrentUser();
|
||||
var userSnapshot = _db
|
||||
.collection(_options.usersCollectionName)
|
||||
.doc(currentUser?.id)
|
||||
.collection(_options.userChatsCollectionName)
|
||||
.snapshots();
|
||||
|
||||
userSnapshot.listen((event) {
|
||||
var chatIds = event.docs.map((e) => e.id).toList();
|
||||
var chatSnapshot = _db
|
||||
.collection(_options.chatsMetaDataCollectionName)
|
||||
.where(
|
||||
FieldPath.documentId,
|
||||
whereIn: chatIds,
|
||||
)
|
||||
.withConverter(
|
||||
fromFirestore: (snapshot, _) => FirebaseChatDocument.fromJson(
|
||||
snapshot.data()!,
|
||||
snapshot.id,
|
||||
),
|
||||
toFirestore: (chat, _) => chat.toJson(),
|
||||
)
|
||||
.snapshots();
|
||||
var chats = <ChatModel>[];
|
||||
ChatModel? chatModel;
|
||||
|
||||
chatSubscription = chatSnapshot.listen((event) async {
|
||||
for (var element in event.docChanges) {
|
||||
var chat = element.doc.data();
|
||||
if (chat == null) return;
|
||||
|
||||
var otherUser = await _userService.getUser(
|
||||
chat.users.firstWhere(
|
||||
(element) => element != currentUser?.id,
|
||||
),
|
||||
);
|
||||
|
||||
var unread =
|
||||
await _addUnreadChatSubscription(chat.id!, currentUser!.id!);
|
||||
|
||||
if (chat.personal) {
|
||||
chatModel = PersonalChatModel(
|
||||
id: chat.id,
|
||||
user: otherUser!,
|
||||
unreadMessages: unread,
|
||||
lastUsed: chat.lastUsed == null
|
||||
? null
|
||||
: DateTime.fromMillisecondsSinceEpoch(
|
||||
chat.lastUsed!.millisecondsSinceEpoch,
|
||||
),
|
||||
lastMessage: chat.lastMessage != null &&
|
||||
chat.lastMessage!.imageUrl != null
|
||||
? ChatImageMessageModel(
|
||||
sender: otherUser,
|
||||
imageUrl: chat.lastMessage!.imageUrl!,
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
||||
chat.lastMessage!.timestamp.millisecondsSinceEpoch,
|
||||
),
|
||||
)
|
||||
: chat.lastMessage != null
|
||||
? ChatTextMessageModel(
|
||||
sender: otherUser,
|
||||
text: chat.lastMessage!.text!,
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
||||
chat.lastMessage!.timestamp
|
||||
.millisecondsSinceEpoch,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
} else {
|
||||
var users = <ChatUserModel>[];
|
||||
for (var userId in chat.users) {
|
||||
var user = await _userService.getUser(userId);
|
||||
if (user != null) {
|
||||
users.add(user);
|
||||
}
|
||||
}
|
||||
chatModel = GroupChatModel(
|
||||
id: chat.id,
|
||||
title: chat.title ?? '',
|
||||
imageUrl: chat.imageUrl ?? '',
|
||||
unreadMessages: unread,
|
||||
users: users,
|
||||
lastMessage: chat.lastMessage != null
|
||||
? chat.lastMessage!.imageUrl == null
|
||||
? ChatTextMessageModel(
|
||||
sender: otherUser!,
|
||||
text: chat.lastMessage!.text!,
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
||||
chat.lastMessage!.timestamp
|
||||
.millisecondsSinceEpoch,
|
||||
),
|
||||
)
|
||||
: ChatImageMessageModel(
|
||||
sender: otherUser!,
|
||||
imageUrl: chat.lastMessage!.imageUrl!,
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
||||
chat.lastMessage!.timestamp
|
||||
.millisecondsSinceEpoch,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
canBeDeleted: chat.canBeDeleted,
|
||||
lastUsed: chat.lastUsed == null
|
||||
? null
|
||||
: DateTime.fromMillisecondsSinceEpoch(
|
||||
chat.lastUsed!.millisecondsSinceEpoch,
|
||||
),
|
||||
);
|
||||
}
|
||||
chats.add(chatModel!);
|
||||
}
|
||||
var uniqueIds = <String>{};
|
||||
var uniqueChatModels = <ChatModel>[];
|
||||
|
||||
for (var chatModel in chats) {
|
||||
if (uniqueIds.add(chatModel.id!)) {
|
||||
uniqueChatModels.add(chatModel);
|
||||
} else {
|
||||
var index = uniqueChatModels.indexWhere(
|
||||
(element) => element.id == chatModel.id,
|
||||
);
|
||||
if (index != -1) {
|
||||
if (chatModel.lastUsed != null &&
|
||||
uniqueChatModels[index].lastUsed != null) {
|
||||
if (chatModel.lastUsed!
|
||||
.isAfter(uniqueChatModels[index].lastUsed!)) {
|
||||
uniqueChatModels[index] = chatModel;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uniqueChatModels.sort(
|
||||
(a, b) => (b.lastUsed ?? DateTime.now()).compareTo(
|
||||
a.lastUsed ?? DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
controller.add(uniqueChatModels);
|
||||
});
|
||||
});
|
||||
},
|
||||
onCancel: () async {
|
||||
await chatSubscription?.cancel();
|
||||
},
|
||||
);
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
/// Retrieves a chat by the given user.
|
||||
///
|
||||
/// [user]: The user associated with the chat.
|
||||
@override
|
||||
Future<ChatModel> getChatByUser(ChatUserModel user) async {
|
||||
var currentUser = await _userService.getCurrentUser();
|
||||
var collection = await _db
|
||||
.collection(_options.usersCollectionName)
|
||||
.doc(currentUser?.id)
|
||||
.collection(_options.userChatsCollectionName)
|
||||
.where('users', arrayContains: user.id)
|
||||
.get();
|
||||
|
||||
var doc = collection.docs.isNotEmpty ? collection.docs.first : null;
|
||||
|
||||
return PersonalChatModel(
|
||||
id: doc?.id,
|
||||
user: user,
|
||||
);
|
||||
}
|
||||
|
||||
/// Retrieves a chat by the given ID.
|
||||
///
|
||||
/// [chatId]: The ID of the chat.
|
||||
@override
|
||||
Future<ChatModel> getChatById(String chatId) async {
|
||||
var currentUser = await _userService.getCurrentUser();
|
||||
var chatCollection = await _db
|
||||
.collection(_options.usersCollectionName)
|
||||
.doc(currentUser?.id)
|
||||
.collection(_options.userChatsCollectionName)
|
||||
.doc(chatId)
|
||||
.get();
|
||||
|
||||
if (chatCollection.exists && chatCollection.data()?['users'] != null) {
|
||||
// ignore: avoid_dynamic_calls
|
||||
var otherUser = chatCollection.data()?['users'].firstWhere(
|
||||
(element) => element != currentUser?.id,
|
||||
);
|
||||
var user = await _userService.getUser(otherUser);
|
||||
return PersonalChatModel(
|
||||
id: chatId,
|
||||
user: user!,
|
||||
canBeDeleted: chatCollection.data()?['can_be_deleted'] ?? true,
|
||||
);
|
||||
} else {
|
||||
var groupChatCollection = await _db
|
||||
.collection(_options.chatsMetaDataCollectionName)
|
||||
.doc(chatId)
|
||||
.withConverter(
|
||||
fromFirestore: (snapshot, _) =>
|
||||
FirebaseChatDocument.fromJson(snapshot.data()!, snapshot.id),
|
||||
toFirestore: (chat, _) => chat.toJson(),
|
||||
)
|
||||
.get();
|
||||
var chat = groupChatCollection.data();
|
||||
var users = <ChatUserModel>[];
|
||||
for (var userId in chat?.users ?? []) {
|
||||
var user = await _userService.getUser(userId);
|
||||
if (user != null) {
|
||||
users.add(user);
|
||||
}
|
||||
}
|
||||
return GroupChatModel(
|
||||
id: chat?.id ?? chatId,
|
||||
title: chat?.title ?? '',
|
||||
imageUrl: chat?.imageUrl ?? '',
|
||||
users: users,
|
||||
canBeDeleted: chat?.canBeDeleted ?? true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes the given chat.
|
||||
///
|
||||
/// [chat]: The chat to be deleted.
|
||||
@override
|
||||
Future<void> deleteChat(ChatModel chat) async {
|
||||
var chatCollection = await _db
|
||||
.collection(_options.chatsMetaDataCollectionName)
|
||||
.doc(chat.id)
|
||||
.withConverter(
|
||||
fromFirestore: (snapshot, _) =>
|
||||
FirebaseChatDocument.fromJson(snapshot.data()!, snapshot.id),
|
||||
toFirestore: (chat, _) => chat.toJson(),
|
||||
)
|
||||
.get();
|
||||
|
||||
var chatData = chatCollection.data();
|
||||
|
||||
if (chatData != null) {
|
||||
for (var userId in chatData.users) {
|
||||
await _db
|
||||
.collection(_options.usersCollectionName)
|
||||
.doc(userId)
|
||||
.collection(_options.userChatsCollectionName)
|
||||
.doc(chat.id)
|
||||
.delete();
|
||||
}
|
||||
|
||||
if (chat.id != null) {
|
||||
await _db
|
||||
.collection(_options.chatsCollectionName)
|
||||
.doc(chat.id)
|
||||
.delete();
|
||||
await _storage
|
||||
.ref(_options.chatsCollectionName)
|
||||
.child(chat.id!)
|
||||
.listAll()
|
||||
.then((value) {
|
||||
for (var element in value.items) {
|
||||
element.delete();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores the given chat if it does not exist already.
|
||||
///
|
||||
/// [chat]: The chat to be stored.
|
||||
@override
|
||||
Future<ChatModel> storeChatIfNot(ChatModel chat) async {
|
||||
if (chat.id == null) {
|
||||
var currentUser = await _userService.getCurrentUser();
|
||||
if (chat is PersonalChatModel) {
|
||||
if (currentUser?.id == null || chat.user.id == null) {
|
||||
return chat;
|
||||
}
|
||||
|
||||
var userIds = <String>[
|
||||
currentUser!.id!,
|
||||
chat.user.id!,
|
||||
];
|
||||
|
||||
var reference = await _db
|
||||
.collection(_options.chatsMetaDataCollectionName)
|
||||
.withConverter(
|
||||
fromFirestore: (snapshot, _) =>
|
||||
FirebaseChatDocument.fromJson(snapshot.data()!, snapshot.id),
|
||||
toFirestore: (chat, _) => chat.toJson(),
|
||||
)
|
||||
.add(
|
||||
FirebaseChatDocument(
|
||||
personal: true,
|
||||
canBeDeleted: chat.canBeDeleted,
|
||||
users: userIds,
|
||||
lastUsed: Timestamp.now(),
|
||||
),
|
||||
);
|
||||
|
||||
for (var userId in userIds) {
|
||||
await _db
|
||||
.collection(_options.usersCollectionName)
|
||||
.doc(userId)
|
||||
.collection(_options.userChatsCollectionName)
|
||||
.doc(reference.id)
|
||||
.set({'users': userIds}, SetOptions(merge: true));
|
||||
}
|
||||
|
||||
chat.id = reference.id;
|
||||
} else if (chat is GroupChatModel) {
|
||||
if (currentUser?.id == null) {
|
||||
return chat;
|
||||
}
|
||||
|
||||
var userIds = <String>[
|
||||
currentUser!.id!,
|
||||
...chat.users.map((e) => e.id!),
|
||||
];
|
||||
|
||||
var reference = await _db
|
||||
.collection(_options.chatsMetaDataCollectionName)
|
||||
.withConverter(
|
||||
fromFirestore: (snapshot, _) =>
|
||||
FirebaseChatDocument.fromJson(snapshot.data()!, snapshot.id),
|
||||
toFirestore: (chat, _) => chat.toJson(),
|
||||
)
|
||||
.add(
|
||||
FirebaseChatDocument(
|
||||
personal: false,
|
||||
title: chat.title,
|
||||
imageUrl: chat.imageUrl,
|
||||
canBeDeleted: chat.canBeDeleted,
|
||||
users: userIds,
|
||||
lastUsed: Timestamp.now(),
|
||||
),
|
||||
);
|
||||
|
||||
for (var userId in userIds) {
|
||||
await _db
|
||||
.collection(_options.usersCollectionName)
|
||||
.doc(userId)
|
||||
.collection(_options.groupChatsCollectionName)
|
||||
.doc(reference.id)
|
||||
.set({'users': userIds}, SetOptions(merge: true));
|
||||
}
|
||||
chat.id = reference.id;
|
||||
} else {
|
||||
throw Exception('Chat type not supported for firebase');
|
||||
}
|
||||
}
|
||||
|
||||
return chat;
|
||||
}
|
||||
|
||||
/// Retrieves a stream of the count of unread chats.
|
||||
@override
|
||||
Stream<int> getUnreadChatsCountStream() {
|
||||
// open a stream to the user's chats collection and listen to changes in
|
||||
// this collection we will also add the amount of read chats
|
||||
StreamSubscription? unreadChatSubscription;
|
||||
// ignore: close_sinks
|
||||
late StreamController<int> controller;
|
||||
controller = StreamController(
|
||||
onListen: () async {
|
||||
var currentUser = await _userService.getCurrentUser();
|
||||
var userSnapshot = _db
|
||||
.collection(_options.usersCollectionName)
|
||||
.doc(currentUser?.id)
|
||||
.collection(_options.userChatsCollectionName)
|
||||
.snapshots();
|
||||
|
||||
unreadChatSubscription = userSnapshot.listen((event) {
|
||||
// every chat has a field called amount_unread_messages, combine all
|
||||
// of these fields to get the total amount of unread messages
|
||||
var unreadChats = event.docs
|
||||
.map((chat) => chat.data()['amount_unread_messages'] ?? 0)
|
||||
.toList();
|
||||
var totalUnreadChats = unreadChats.fold<int>(
|
||||
0,
|
||||
(previousValue, element) => previousValue + (element as int),
|
||||
);
|
||||
controller.add(totalUnreadChats);
|
||||
});
|
||||
},
|
||||
onCancel: () async {
|
||||
await unreadChatSubscription?.cancel();
|
||||
},
|
||||
);
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
/// Marks a chat as read.
|
||||
///
|
||||
/// [chat]: The chat to be marked as read.
|
||||
@override
|
||||
Future<void> readChat(ChatModel chat) async {
|
||||
// set the amount of read chats to the amount of messages in the chat
|
||||
var currentUser = await _userService.getCurrentUser();
|
||||
if (currentUser?.id == null || chat.id == null) {
|
||||
return;
|
||||
}
|
||||
// set the amount of unread messages to 0
|
||||
|
||||
await _db
|
||||
.collection(_options.usersCollectionName)
|
||||
.doc(currentUser!.id)
|
||||
.collection(_options.userChatsCollectionName)
|
||||
.doc(chat.id)
|
||||
.set({'amount_unread_messages': 0}, SetOptions(merge: true));
|
||||
}
|
||||
}
|
|
@ -1,85 +0,0 @@
|
|||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:flutter_chat_firebase/config/firebase_chat_options.dart';
|
||||
import 'package:flutter_chat_firebase/flutter_chat_firebase.dart';
|
||||
import 'package:flutter_chat_interface/flutter_chat_interface.dart';
|
||||
|
||||
/// Service class for managing chat services using Firebase.
|
||||
class FirebaseChatService implements ChatService {
|
||||
FirebaseChatService({
|
||||
this.options,
|
||||
this.app,
|
||||
this.firebaseChatDetailService,
|
||||
this.firebaseChatOverviewService,
|
||||
this.firebaseChatUserService,
|
||||
}) {
|
||||
firebaseChatDetailService ??= FirebaseChatDetailService(
|
||||
userService: chatUserService,
|
||||
options: options,
|
||||
app: app,
|
||||
);
|
||||
|
||||
firebaseChatOverviewService ??= FirebaseChatOverviewService(
|
||||
userService: chatUserService,
|
||||
options: options,
|
||||
app: app,
|
||||
);
|
||||
|
||||
firebaseChatUserService ??= FirebaseChatUserService(
|
||||
options: options,
|
||||
app: app,
|
||||
);
|
||||
}
|
||||
|
||||
/// The options for configuring Firebase Chat.
|
||||
final FirebaseChatOptions? options;
|
||||
|
||||
/// The Firebase app instance.
|
||||
final FirebaseApp? app;
|
||||
|
||||
/// The service for managing chat details.
|
||||
ChatDetailService? firebaseChatDetailService;
|
||||
|
||||
/// The service for managing chat overviews.
|
||||
ChatOverviewService? firebaseChatOverviewService;
|
||||
|
||||
/// The service for managing chat users.
|
||||
ChatUserService? firebaseChatUserService;
|
||||
|
||||
@override
|
||||
ChatDetailService get chatDetailService {
|
||||
if (firebaseChatDetailService != null) {
|
||||
return firebaseChatDetailService!;
|
||||
} else {
|
||||
return FirebaseChatDetailService(
|
||||
userService: chatUserService,
|
||||
options: options,
|
||||
app: app,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
ChatOverviewService get chatOverviewService {
|
||||
if (firebaseChatOverviewService != null) {
|
||||
return firebaseChatOverviewService!;
|
||||
} else {
|
||||
return FirebaseChatOverviewService(
|
||||
userService: chatUserService,
|
||||
options: options,
|
||||
app: app,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
ChatUserService get chatUserService {
|
||||
if (firebaseChatUserService != null) {
|
||||
return firebaseChatUserService!;
|
||||
} else {
|
||||
return FirebaseChatUserService(
|
||||
options: options,
|
||||
app: app,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,106 +0,0 @@
|
|||
// SPDX-FileCopyrightText: 2022 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:flutter_chat_firebase/config/firebase_chat_options.dart';
|
||||
import 'package:flutter_chat_firebase/dto/firebase_user_document.dart';
|
||||
import 'package:flutter_chat_interface/flutter_chat_interface.dart';
|
||||
|
||||
/// Service class for managing chat users using Firebase.
|
||||
class FirebaseChatUserService implements ChatUserService {
|
||||
/// Constructor for FirebaseChatUserService.
|
||||
///
|
||||
/// [app]: The Firebase app instance.
|
||||
/// [options]: The options for configuring Firebase Chat.
|
||||
FirebaseChatUserService({
|
||||
FirebaseApp? app,
|
||||
FirebaseChatOptions? options,
|
||||
}) {
|
||||
var appInstance = app ?? Firebase.app();
|
||||
|
||||
_db = FirebaseFirestore.instanceFor(app: appInstance);
|
||||
_auth = FirebaseAuth.instanceFor(app: appInstance);
|
||||
_options = options ?? const FirebaseChatOptions();
|
||||
}
|
||||
|
||||
/// The Firebase Firestore instance.
|
||||
late FirebaseFirestore _db;
|
||||
|
||||
/// The Firebase Authentication instance.
|
||||
late FirebaseAuth _auth;
|
||||
|
||||
/// The options for configuring Firebase Chat.
|
||||
late FirebaseChatOptions _options;
|
||||
|
||||
/// The current user.
|
||||
ChatUserModel? _currentUser;
|
||||
|
||||
/// Map to cache user models.
|
||||
final Map<String, ChatUserModel> _users = {};
|
||||
|
||||
/// Collection reference for users.
|
||||
CollectionReference<FirebaseUserDocument> get _userCollection => _db
|
||||
.collection(_options.usersCollectionName)
|
||||
.withConverter<FirebaseUserDocument>(
|
||||
fromFirestore: (snapshot, _) => FirebaseUserDocument.fromJson(
|
||||
snapshot.data()!,
|
||||
snapshot.id,
|
||||
),
|
||||
toFirestore: (user, _) => user.toJson(),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<ChatUserModel?> getUser(String id) async {
|
||||
if (_users.containsKey(id)) {
|
||||
return _users[id]!;
|
||||
}
|
||||
|
||||
return _userCollection.doc(id).get().then((response) {
|
||||
var data = response.data();
|
||||
|
||||
var user = data == null
|
||||
? ChatUserModel(id: id)
|
||||
: ChatUserModel(
|
||||
id: id,
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
imageUrl: data.imageUrl,
|
||||
);
|
||||
|
||||
_users[id] = user;
|
||||
|
||||
return user;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ChatUserModel?> getCurrentUser() async =>
|
||||
_currentUser == null && _auth.currentUser?.uid != null
|
||||
? _currentUser = await getUser(_auth.currentUser!.uid)
|
||||
: _currentUser;
|
||||
|
||||
@override
|
||||
Future<List<ChatUserModel>> getAllUsers() async {
|
||||
var currentUser = await getCurrentUser();
|
||||
|
||||
var query = _userCollection.where(
|
||||
FieldPath.documentId,
|
||||
isNotEqualTo: currentUser?.id,
|
||||
);
|
||||
|
||||
var data = await query.get();
|
||||
|
||||
return data.docs.map((user) {
|
||||
var userData = user.data();
|
||||
return ChatUserModel(
|
||||
id: user.id,
|
||||
firstName: userData.firstName,
|
||||
lastName: userData.lastName,
|
||||
imageUrl: userData.imageUrl,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
export 'package:flutter_chat_firebase/service/firebase_chat_detail_service.dart';
|
||||
export 'package:flutter_chat_firebase/service/firebase_chat_overview_service.dart';
|
||||
export 'package:flutter_chat_firebase/service/firebase_chat_service.dart';
|
||||
export 'package:flutter_chat_firebase/service/firebase_chat_user_service.dart';
|
|
@ -1,34 +0,0 @@
|
|||
# SPDX-FileCopyrightText: 2022 Iconica
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
name: flutter_chat_firebase
|
||||
description: A new Flutter package project.
|
||||
version: 1.4.3
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
sdk: ">=3.1.0 <4.0.0"
|
||||
flutter: ">=1.17.0"
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
firebase_core: ^2.1.1
|
||||
cloud_firestore: ^4.0.5
|
||||
firebase_storage: ^11.0.5
|
||||
firebase_auth: ^4.1.2
|
||||
uuid: ^4.0.0
|
||||
flutter_chat_interface:
|
||||
git:
|
||||
url: https://github.com/Iconica-Development/flutter_chat
|
||||
path: packages/flutter_chat_interface
|
||||
ref: 1.4.3
|
||||
|
||||
dev_dependencies:
|
||||
flutter_iconica_analysis:
|
||||
git:
|
||||
url: https://github.com/Iconica-Development/flutter_iconica_analysis
|
||||
ref: 6.0.0
|
||||
|
||||
flutter:
|
|
@ -1,9 +0,0 @@
|
|||
include: package:flutter_iconica_analysis/analysis_options.yaml
|
||||
|
||||
# Possible to overwrite the rules from the package
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
|
||||
linter:
|
||||
rules:
|
|
@ -1,9 +0,0 @@
|
|||
// SPDX-FileCopyrightText: 2022 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
///
|
||||
library flutter_chat_interface;
|
||||
|
||||
export 'package:flutter_chat_interface/src/chat_data_provider.dart';
|
||||
export 'package:flutter_chat_interface/src/model/model.dart';
|
||||
export 'package:flutter_chat_interface/src/service/service.dart';
|
|
@ -1,19 +0,0 @@
|
|||
// SPDX-FileCopyrightText: 2022 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'package:flutter_chat_interface/flutter_chat_interface.dart';
|
||||
import 'package:flutter_data_interface/flutter_data_interface.dart';
|
||||
|
||||
class ChatDataProvider extends DataInterface {
|
||||
ChatDataProvider({
|
||||
required this.chatService,
|
||||
required this.userService,
|
||||
required this.messageService,
|
||||
}) : super(token: _token);
|
||||
|
||||
static final Object _token = Object();
|
||||
final ChatUserService userService;
|
||||
final ChatOverviewService chatService;
|
||||
final ChatDetailService messageService;
|
||||
}
|
|
@ -1,77 +0,0 @@
|
|||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||
// SPDX-FileCopyrightText: 2022 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'package:flutter_chat_interface/flutter_chat_interface.dart';
|
||||
|
||||
abstract class ChatModelInterface {
|
||||
ChatModelInterface copyWith();
|
||||
String? get id;
|
||||
List<ChatMessageModel>? get messages;
|
||||
int? get unreadMessages;
|
||||
DateTime? get lastUsed;
|
||||
ChatMessageModel? get lastMessage;
|
||||
bool get canBeDeleted;
|
||||
}
|
||||
|
||||
/// A concrete implementation of [ChatModelInterface] representing a chat.
|
||||
class ChatModel implements ChatModelInterface {
|
||||
/// Constructs a [ChatModel] instance.
|
||||
///
|
||||
/// [id]: The ID of the chat.
|
||||
///
|
||||
/// [messages]: The list of messages in the chat.
|
||||
///
|
||||
/// [unreadMessages]: The number of unread messages in the chat.
|
||||
///
|
||||
/// [lastUsed]: The timestamp when the chat was last used.
|
||||
///
|
||||
/// [lastMessage]: The last message sent in the chat.
|
||||
///
|
||||
/// [canBeDeleted]: Indicates whether the chat can be deleted.
|
||||
ChatModel({
|
||||
this.id,
|
||||
this.messages = const [],
|
||||
this.unreadMessages,
|
||||
this.lastUsed,
|
||||
this.lastMessage,
|
||||
this.canBeDeleted = true,
|
||||
});
|
||||
|
||||
@override
|
||||
String? id;
|
||||
|
||||
@override
|
||||
final List<ChatMessageModel>? messages;
|
||||
|
||||
@override
|
||||
final int? unreadMessages;
|
||||
|
||||
@override
|
||||
final DateTime? lastUsed;
|
||||
|
||||
@override
|
||||
final ChatMessageModel? lastMessage;
|
||||
|
||||
@override
|
||||
final bool canBeDeleted;
|
||||
|
||||
@override
|
||||
ChatModel copyWith({
|
||||
String? id,
|
||||
List<ChatMessageModel>? messages,
|
||||
int? unreadMessages,
|
||||
DateTime? lastUsed,
|
||||
ChatMessageModel? lastMessage,
|
||||
bool? canBeDeleted,
|
||||
}) =>
|
||||
ChatModel(
|
||||
id: id ?? this.id,
|
||||
messages: messages ?? this.messages,
|
||||
unreadMessages: unreadMessages ?? this.unreadMessages,
|
||||
lastUsed: lastUsed ?? this.lastUsed,
|
||||
lastMessage: lastMessage ?? this.lastMessage,
|
||||
canBeDeleted: canBeDeleted ?? this.canBeDeleted,
|
||||
);
|
||||
}
|
|
@ -1,48 +0,0 @@
|
|||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||
// SPDX-FileCopyrightText: 2022 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'package:flutter_chat_interface/flutter_chat_interface.dart';
|
||||
|
||||
/// An abstract class defining the interface for an image message in a chat.
|
||||
abstract class ChatImageMessageModelInterface extends ChatMessageModel {
|
||||
/// Constructs a [ChatImageMessageModelInterface] instance.
|
||||
///
|
||||
/// [sender]: The sender of the message.
|
||||
///
|
||||
/// [timestamp]: The timestamp when the message was sent.
|
||||
ChatImageMessageModelInterface({
|
||||
required super.sender,
|
||||
required super.timestamp,
|
||||
});
|
||||
|
||||
/// Returns the URL of the image associated with the message.
|
||||
String get imageUrl;
|
||||
}
|
||||
|
||||
/// A concrete implementation of [ChatImageMessageModelInterface]
|
||||
/// representing an image message in a chat.
|
||||
class ChatImageMessageModel implements ChatImageMessageModelInterface {
|
||||
/// Constructs a [ChatImageMessageModel] instance.
|
||||
///
|
||||
/// [sender]: The sender of the message.
|
||||
///
|
||||
/// [timestamp]: The timestamp when the message was sent.
|
||||
///
|
||||
/// [imageUrl]: The URL of the image associated with the message.
|
||||
ChatImageMessageModel({
|
||||
required this.sender,
|
||||
required this.timestamp,
|
||||
required this.imageUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
final ChatUserModel sender;
|
||||
|
||||
@override
|
||||
final DateTime timestamp;
|
||||
|
||||
@override
|
||||
final String imageUrl;
|
||||
}
|
|
@ -1,31 +0,0 @@
|
|||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||
// SPDX-FileCopyrightText: 2022 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'package:flutter_chat_interface/src/model/chat_user.dart';
|
||||
|
||||
abstract class ChatMessageModelInterface {
|
||||
ChatUserModel get sender;
|
||||
DateTime get timestamp;
|
||||
}
|
||||
|
||||
/// A concrete implementation of [ChatMessageModelInterface]
|
||||
/// representing a chat message.
|
||||
class ChatMessageModel implements ChatMessageModelInterface {
|
||||
/// Constructs a [ChatMessageModel] instance.
|
||||
///
|
||||
/// [sender]: The sender of the message.
|
||||
///
|
||||
/// [timestamp]: The timestamp when the message was sent.
|
||||
ChatMessageModel({
|
||||
required this.sender,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
@override
|
||||
final ChatUserModel sender;
|
||||
|
||||
@override
|
||||
final DateTime timestamp;
|
||||
}
|
|
@ -1,41 +0,0 @@
|
|||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||
// SPDX-FileCopyrightText: 2022 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'package:flutter_chat_interface/flutter_chat_interface.dart';
|
||||
|
||||
abstract class ChatTextMessageModelInterface extends ChatMessageModel {
|
||||
ChatTextMessageModelInterface({
|
||||
required super.sender,
|
||||
required super.timestamp,
|
||||
});
|
||||
|
||||
String get text;
|
||||
}
|
||||
|
||||
/// A concrete implementation of [ChatTextMessageModelInterface]
|
||||
/// representing a text message in a chat.
|
||||
class ChatTextMessageModel implements ChatTextMessageModelInterface {
|
||||
/// Constructs a [ChatTextMessageModel] instance.
|
||||
///
|
||||
/// [sender]: The sender of the message.
|
||||
///
|
||||
/// [timestamp]: The timestamp when the message was sent.
|
||||
///
|
||||
/// [text]: The text content of the message.
|
||||
ChatTextMessageModel({
|
||||
required this.sender,
|
||||
required this.timestamp,
|
||||
required this.text,
|
||||
});
|
||||
|
||||
@override
|
||||
final ChatUserModel sender;
|
||||
|
||||
@override
|
||||
final DateTime timestamp;
|
||||
|
||||
@override
|
||||
final String text;
|
||||
}
|
|
@ -1,72 +0,0 @@
|
|||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||
// SPDX-FileCopyrightText: 2022 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
abstract class ChatUserModelInterface {
|
||||
String? get id;
|
||||
String? get firstName;
|
||||
String? get lastName;
|
||||
String? get imageUrl;
|
||||
|
||||
String? get fullName;
|
||||
}
|
||||
|
||||
/// A concrete implementation of [ChatUserModelInterface]
|
||||
/// representing a chat user.
|
||||
@immutable
|
||||
class ChatUserModel implements ChatUserModelInterface {
|
||||
/// Constructs a [ChatUserModel] instance.
|
||||
///
|
||||
/// [id]: The ID of the user.
|
||||
///
|
||||
/// [firstName]: The first name of the user.
|
||||
///
|
||||
/// [lastName]: The last name of the user.
|
||||
///
|
||||
/// [imageUrl]: The URL of the user's image.
|
||||
///
|
||||
const ChatUserModel({
|
||||
this.id,
|
||||
this.firstName,
|
||||
this.lastName,
|
||||
this.imageUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
final String? id;
|
||||
|
||||
@override
|
||||
final String? firstName;
|
||||
|
||||
@override
|
||||
final String? lastName;
|
||||
|
||||
@override
|
||||
final String? imageUrl;
|
||||
|
||||
@override
|
||||
String? get fullName {
|
||||
var fullName = '';
|
||||
|
||||
if (firstName != null && lastName != null) {
|
||||
fullName += '$firstName $lastName';
|
||||
} else if (firstName != null) {
|
||||
fullName += firstName!;
|
||||
} else if (lastName != null) {
|
||||
fullName += lastName!;
|
||||
}
|
||||
|
||||
return fullName == '' ? null : fullName;
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) || other is ChatUserModel && id == other.id;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
id.hashCode ^ firstName.hashCode ^ lastName.hashCode ^ imageUrl.hashCode;
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue