Compare commits

...

241 commits

Author SHA1 Message Date
f7f15ef750 docs: add more descriptive documentation to repository methods 2025-03-12 11:39:22 +01:00
c155531b11 chore: update version and changelog for version 6.0.0 2025-03-12 11:39:22 +01:00
Kiril Tijsma
90610caabd fix(local/pending-message-repository): remove chunking 2025-03-12 11:39:22 +01:00
Kiril Tijsma
e1e23e7b35 fix(chat-service): sort combined streams 2025-03-12 11:39:22 +01:00
9b365d573d feat: add tap to close keyboard on chat detail screen 2025-03-12 11:39:22 +01:00
3b4b456db2 chore: use proper indentation on commented code in example 2025-03-12 11:39:22 +01:00
Kiril Tijsma
ad615133e4 feat(chat-options): add pending messages option 2025-03-12 11:39:22 +01:00
f286e7fb79 feat: add loading and refresh handling for images 2025-03-12 11:39:22 +01:00
3f1caa912b feat: remove camera on unsupported platforms for default image picker 2025-03-12 11:39:22 +01:00
7d634e54c1 feat: remove snackbar when uploading an image according to UI 2025-03-12 11:39:22 +01:00
Kiril Tijsma
3fbcf5d076 feat(chat-service/pending-messages): add pending images 2025-03-12 11:39:22 +01:00
84cc630c6e fix: export pending messages repository interface and local implementation 2025-03-12 11:39:22 +01:00
Kiril Tijsma
3cec2ee1c6 fix(message-date-label): take absolute value to prevent -x days ago 2025-03-12 11:39:22 +01:00
Kiril Tijsma
b8e22425a1 feat(chat-message-bubble): add status marker 2025-03-12 11:39:22 +01:00
Kiril Tijsma
61b588cfd5 feat(chat-service): add pending messages repository 2025-03-12 11:39:22 +01:00
Kiril Tijsma
02ae2aa884 fix(message-send-buttons): move slightly to better match design 2025-03-12 11:39:22 +01:00
Kiril Tijsma
d2f000c8a7 fix(message-send-buttons): prevent focus on hidden elements 2025-03-12 11:39:22 +01:00
52562746b6 fix: show names with correct padding if an indicator is used 2025-03-07 10:16:17 +01:00
a48806fe98 fix: expose default chat indicator builder 2025-03-06 17:02:47 +01:00
bcf2c0484b feat(chat-time-indicator): add small time-indicator in chat detail screens 2025-03-06 16:46:03 +01:00
4e0967cc33 docs: change docs in preparation of 5.0.0 release 2025-03-06 13:03:27 +01:00
John Gorter
c63efadd2c bugfix: add imageQuality with default 20 value to options of flutter chat. 2025-03-06 13:03:27 +01:00
John Gorter
ad4cf1e37b bugfix: add compression parameter to image_picker widget for chat to prevent too large requests. 2025-03-06 13:03:27 +01:00
Freek van de Ven
d14ad4716a chore: update flutter_chat to version 5.0.0 2025-03-06 13:03:27 +01:00
Freek van de Ven
c1e20e84ff fix: set padding for message inputfield to the chatSidePadding 2025-03-06 13:03:27 +01:00
5126b8dab7 feat: add flag to disable new/old message loading on scroll 2025-03-06 13:03:27 +01:00
Jacques
371ff6c335 feat: add semantics for buttons 2025-03-06 13:03:27 +01:00
Jacques
b3b8b1828e feat: add semantics for text fields 2025-03-06 13:03:27 +01:00
Jacques
30fc7b4368 feat: add semantics for texts 2025-03-06 13:03:27 +01:00
6ecf073f15 feat: add error builder to chat detail screen for failed to load messages 2025-03-06 13:03:27 +01:00
d3f839dc94 fix: remove debugPrints to reduce polution of debug console 2025-03-06 13:03:27 +01:00
John Gorter
ba818158fb bugfixes: localtime representations 2025-03-06 13:03:27 +01:00
Jacques
c1181d4e84 fix: add ability to set the color of the CircularProgressIndicator of the ImageLoadingSnackbar
This was standard grey and not changeable which was needed
2025-03-06 13:03:27 +01:00
Freek van de Ven
b0b4121a25 feat: stop loading new messages while autoscroll is on and stop loading old messages if there are no new results 2025-03-06 13:03:27 +01:00
Freek van de Ven
313ead2029 feat: improve loading visualisation on the chatdetailscreen 2025-03-06 13:03:27 +01:00
Freek van de Ven
5975e2f1c0 feat: add imageProviderResolver in the ChatOptions to override the default CachedNetworkImageProvider 2025-03-06 13:03:27 +01:00
Freek van de Ven
a1fc65aba2 feat: add previousMessage to the messageThemeResolver 2025-03-06 13:03:27 +01:00
Freek van de Ven
11d8c81161 fix: allow the messageTheme to override default behavior for message time 2025-03-06 13:03:27 +01:00
Freek van de Ven
590a339d0d feat: add senderTitleResolver to override the default sender.firstName for chat messages 2025-03-06 13:03:27 +01:00
Freek van de Ven
2508789a6d fix: let the chats align to the top 2025-03-06 13:03:27 +01:00
Freek van de Ven
b0d379284d fix: check if context.mounted before updating usestates in chat_detail_screen.dart 2025-03-06 13:03:27 +01:00
Freek van de Ven
62f04e2d9b feat: add chatScreenBuilder to use instead of baseScreenBuilder on the ChatDetailScreen 2025-03-06 13:03:27 +01:00
Freek van de Ven
6a63429efd feat: make the default messageInputBuilder use a multiline textfield which increase the ChatBottomInputSection 2025-03-06 13:03:27 +01:00
Freek van de Ven
1a7b4a2cda feat: add imagePickerBuilder to builders to override the default imagepicker for selecting an image 2025-03-06 13:03:27 +01:00
Freek van de Ven
c82df25aed feat: pass showFullDate to dateformatter for the messageTime 2025-03-06 13:03:27 +01:00
Freek van de Ven
23f61dd5ff feat: add senderId and chatId to uploadImage 2025-03-06 13:03:27 +01:00
e7bb4909ba fix(README): improve wording and add syntax highlighting to all code snippets 2025-03-06 13:03:27 +01:00
Freek van de Ven
02ae851d13 feat: submit the chat message inputfield when enter is pressed 2025-03-06 13:03:27 +01:00
Freek van de Ven
7a0fd49070 feat: add borderRadius option for the MessageTheme 2025-03-06 13:03:27 +01:00
Freek van de Ven
70eeb816e2 feat: add minimum delay for fetching new and old messages that can be configured 2025-03-06 13:03:27 +01:00
Freek van de Ven
f57ba9a736 feat: add pagination controls to ChatOptions 2025-03-06 13:03:27 +01:00
Freek van de Ven
ab8a9d9e6f feat: add proper pagination to chat_detail_screen.dart 2025-03-06 13:03:27 +01:00
Freek van de Ven
e1ca5aab71 fix: make the loadingWidgetBuilder not nullable 2025-03-06 13:03:27 +01:00
Freek van de Ven
8112e939e1 feat: allow chatTitleResolver to return null to fallback to default chatTitles 2025-03-06 13:03:27 +01:00
Freek van de Ven
7c80341ff5 refactor: move widgets from ChatDetailScreen to seperate files
This makes the chat_detail_screen.dart easier to read and only containing the logic
2025-03-06 13:03:27 +01:00
Freek van de Ven
5d29e733aa fix: use ChatService instead of UserRepository directly for getAllUsersForChat 2025-03-06 13:03:27 +01:00
Freek van de Ven
d475cf7298 feat: update ChatRepositoryInterface with methods to manage pagination of messages in a chat 2025-03-06 13:03:27 +01:00
Freek van de Ven
77d6f7257e feat: use getAllUsersForChat on ChatDetailScreen to avoid call to getUser for each user 2025-03-06 13:03:27 +01:00
Freek van de Ven
990a89199b feat: add chatTitleResolver to ChatOptions to override chatTitle behavior 2025-03-06 13:03:27 +01:00
Freek van de Ven
281188c2b7 feat: add chatTitle to baseScreenBuilder 2025-03-06 13:03:27 +01:00
Freek van de Ven
8604ccada7 fix: remove unnecessary ChatOptions from routes.dart 2025-03-06 13:03:27 +01:00
Freek van de Ven
a9b52ef5d9 feat: use chatId instead of chat on chat_detail_screen and load chat from stream 2025-03-06 13:03:27 +01:00
Freek van de Ven
a9eb1a8df4 feat: remove chatService and chatOptions from widget parameters to use ChatScope instead 2025-03-06 13:03:27 +01:00
Freek van de Ven
b1909689f2 fix: remove NavigationWrapper to be more consistent with flutter_availability userstory 2025-03-06 13:03:27 +01:00
Freek van de Ven
4ec7da429e feat: add FlutterChatDetailNavigatorUserstory to start a userstory with only the chat detail and subscreens of that screen 2025-03-06 13:03:27 +01:00
Freek van de Ven
ff28f91524 feat: use ChatScope for userstory screens to get service, options and userId 2025-03-06 13:03:27 +01:00
Freek van de Ven
d66942893f feat: add onback behavior for each userstory screen so it pops correctly with the android backarrow 2025-03-06 13:03:27 +01:00
Freek van de Ven
15604bf264 feat: rework the existing navigator userstory to look more like flutter_availability userstory 2025-03-06 13:03:27 +01:00
Freek van de Ven
4c48cf8cd4 feat: add flutter_hooks dependency 2025-03-06 13:03:27 +01:00
Freek van de Ven
55be653975 feat: change ChatService and ChatScope to work the same as in flutter_availability
The userid should be managed inside of the service and it should come from the ChatOptions
2025-03-06 13:03:27 +01:00
Freek van de Ven
ed72545cc4 feat: add getAllUsersForChat to UserRepositoryInterface
This allows to get all the users for a chat in one call instead of doing multiple seperate calls for every individual user of a chat
2025-03-06 13:03:27 +01:00
Freek van de Ven
070a4d5adc feat: add chat detailscreen styling options for beep 2025-03-06 13:03:27 +01:00
Freek van de Ven
22884ea395 feat: add ChatScope inherited widget for accessing the options, service and userId everywhere through the context 2025-03-06 13:03:27 +01:00
Freek van de Ven
3e03dd755e feat: add messageType to MessageModel 2025-03-06 13:03:27 +01:00
Freek van de Ven
7457602afe feat: add chatMessageBuilder to the chatoptions to override default behavior
With the chatMessageBuilder it is possible to run a null whenever you still want to use the default but only want to update the chat in very specific cases.
I also slightly refactored the chat_detail_screen.dart to remove duplicate code and make it more readable
2025-03-06 13:03:27 +01:00
Kiril Tijsma
1ea2887e27 fix(color-picker): get icon color from theme 2025-03-06 13:03:27 +01:00
Freek van de Ven
91420fde78 feat: finishup 4.0.0 2024-10-31 11:41:47 +01:00
d5b7183df5 chore: publish all packages to forgejo 2024-10-31 11:41:47 +01:00
bd14f5cd6d fix: add remaining documentation and fix deep nesting 2024-10-31 11:41:47 +01:00
mike doornenbal
b6fc7b2cb0 fix: getting users 2024-10-31 11:41:47 +01:00
mike doornenbal
4ee5445809 fix: image_picker version 2024-10-31 11:41:47 +01:00
mike doornenbal
ed95dbd15c fix: upgrade image_picker to 4.0.0 2024-10-31 11:41:47 +01:00
HammadAsiif
f8bffceb4b chore(flutter_chat): various fixes and improvements
- style: use consistent font in buttons
- fix: update profile picture icon for adding a group chat to match design
- fix: align "Write your message here" placeholder text and buttons properly in the input box
- fix: ensure 'next' button is not visible when no one is selected for group chat
- fix: allow scroll to go further so the last person in the list is not hidden by the ‘next’ button
- fix: make entire name clickable when adding members to a group chat
- fix: change text from “create a groupchat” to “start a groupchat”
- fix: make 'create group chat' button inactive until a name is entered
- style: adjust image picker icon sizes, and cancel button font
2024-10-31 11:41:47 +01:00
61de7ae44a feat: add firebase repo implementation, remove scaffold builders and add basescreenbuilder 2024-10-31 11:41:47 +01:00
1f3dc09f44 fix: feedback 2024-10-31 11:41:47 +01:00
ec89961e07 feat: refactor 2024-10-31 11:41:47 +01:00
mike doornenbal
44579ca306 fix: update for safino 2024-10-31 11:41:47 +01:00
mike doornenbal
8f13d87a23 feat: ui update 2024-10-31 11:41:47 +01:00
Freek van de Ven
644615f026 fix: center the texts for no users found with search and type first message 2024-10-31 11:41:47 +01:00
Freek van de Ven
9be096154f feat: add component release workflow 2024-10-28 10:11:24 +01:00
Gorter-dev
f5040d5809
Merge pull request #106 from Iconica-Development/chore/deploy
chore: ready the package for deployment to the pub server
2024-07-22 15:01:15 +02:00
c7fe7152b3 chore: ready the package for deployment to the pub server 2024-07-19 11:56:04 +02:00
2564c74066 chore: add fvm configuration to gitignore 2024-07-19 11:32:07 +02:00
Freek van de Ven
15f15748b6
Merge pull request #93 from Iconica-Development/3.0.1
fix: routing issues
2024-06-17 13:36:12 +02:00
Freek van de Ven
2f2e2be6dd fix: change the backgroundcolors 2024-06-14 16:33:02 +02:00
Freek van de Ven
d46c83e847 fix: handle overflows for users with a long name 2024-06-14 15:47:24 +02:00
Freek van de Ven
d13a8013ac feat: add onPopInvoked callback for a popscope inside the userstory 2024-06-13 09:33:11 +02:00
mike doornenbal
8b4ada7edc fix: linter issue 2024-06-06 15:48:47 +02:00
mike doornenbal
61d901c741 fix: routing issues 2024-06-06 14:47:07 +02:00
Freek van de Ven
5e4a9c7ab4
Merge pull request #92 from Iconica-Development/bugfix/feedback
fix: feedback
2024-06-06 13:13:15 +02:00
mike doornenbal
1141aea83c fix: feedback 2024-06-06 13:05:18 +02:00
Gorter-dev
5464766747
Merge pull request #91 from Iconica-Development/fix/issues
fix: solve several issues
2024-06-04 16:52:33 +02:00
7cfd8087a1 fix: solve several issues 2024-06-04 16:18:00 +02:00
Gorter-dev
3d3153d2ce
Merge pull request #68 from Iconica-Development/2.0.0
Improve flutter_chat for usage in safino
2024-05-29 15:37:26 +02:00
Freek van de Ven
c9a11758d8 fix: add check for groupchats with only 1 member 2024-05-24 14:17:58 +02:00
Freek van de Ven
82448ab9e0 feat: lock CI version to flutter 3.19.6 2024-05-24 08:40:38 +02:00
Freek van de Ven
efd6fc138c fix: add option to set a custom padding around the list of chats 2024-05-23 17:17:11 +02:00
Freek van de Ven
1eb5f99b7b fix: remove divider at the new chat screen because it is not working correctly 2024-05-23 16:49:26 +02:00
Freek van de Ven
146ec3a1a9 feat: make all transltions required for ChatTranslations and provide an .empty() alternative 2024-05-22 11:59:21 +02:00
Freek van de Ven
b5656d5f3a feat: add option to disable groupchat creation 2024-05-09 21:25:43 +02:00
Freek van de Ven
86c50f47f6 feat: change onPressUserProfile callback to use the ChatUserModel 2024-05-09 21:05:33 +02:00
Freek van de Ven
06167d202e feat: add service and translationbuilder for userstory configuration to fetch both when needed 2024-05-09 19:31:56 +02:00
Gorter-dev
37e975ceec
Merge pull request #67 from Iconica-Development/bugfix/default_styling
fix: default_styling
2024-04-26 13:52:57 +02:00
mike doornenbal
58451a7e5e fix: default_styling 2024-04-26 13:35:07 +02:00
Gorter-dev
c370df0bdd
Merge pull request #66 from Iconica-Development/bugfix/feedback
fix: apply feedback
2024-04-23 10:06:52 +02:00
mike doornenbal
0d22cea2f7 fix: apply feedback 2024-04-23 09:57:13 +02:00
Gorter-dev
3b443ee1fc
Merge pull request #65 from Iconica-Development/1.4.1
refactor/match with Figma
2024-04-19 15:44:53 +02:00
Vick Top
70c795e89a fix: fix linter issue 2024-04-19 15:40:25 +02:00
Vick Top
3de70d7710 refactor: set correct yaml version 2024-04-19 15:35:45 +02:00
Vick Top
3b7256173f doc: add to changelog 2024-04-19 14:47:16 +02:00
Vick Top
e85b107038 refactor: change delete chat snackbar 2024-04-19 14:21:14 +02:00
Vick Top
6bafc86a2d refactor: remove personal chat avatar from chat detail screen 2024-04-19 13:01:09 +02:00
Vick Top
19529deb4e fix: fix routing with appbar back button 2024-04-19 10:24:42 +02:00
Vick Top
c3b03f6d38 feat: add text in case of zero messages in chat 2024-04-18 13:26:04 +02:00
Vick Top
158973cd7a refactor: polish image picker styling 2024-04-18 10:59:22 +02:00
Vick Top
c4955e6eb7 feat: add translation for find users to chat with 2024-04-17 15:52:15 +02:00
Vick Top
f2e6875630 refactor: add translation option and builder for no chats case 2024-04-17 15:08:08 +02:00
Vick Top
89edfdd18c refactor: polish chat detail screen 2024-04-16 15:00:21 +02:00
Vick Top
6e2d0feac9 refactor: polish new chat screens 2024-04-16 13:31:40 +02:00
Vick Top
a59e149420 refactor: polish new chat screen 2024-04-16 11:26:53 +02:00
Gorter-dev
32189ba397
Merge pull request #58 from Iconica-Development/fix/fix-screen-routes
fix: add missing screen routes
2024-04-16 09:53:35 +02:00
Vick Top
8e51a4d3fd fix: add missing screen route 2024-04-16 09:35:47 +02:00
Gorter-dev
169818e981
Merge pull request #57 from Iconica-Development/1.4.0
1.4.0
2024-03-27 14:37:10 +01:00
Vick Top
6d23ea04fa fix: rebase with master 2024-03-27 14:23:08 +01:00
Vick Top
e9da9f98e3 fix: set refs to 1.4.0 2024-03-27 11:45:07 +01:00
Vick Top
0950eb3a17 refactor: make use of pr feedback 2024-03-27 11:45:07 +01:00
Vick Top
1badf8a851 feat: add create group chat flow 2024-03-27 11:45:07 +01:00
Vick Top
48668ab89d fix: make example work 2024-03-27 11:42:06 +01:00
Vick Top
b0c8f17bcf update: set flutter_image_picker to 1.0.5 2024-03-27 11:42:06 +01:00
Vick Top
6600a84781 update: set flutter_profile to 1.3.0 2024-03-27 11:42:06 +01:00
mike doornenbal
baefa03a94 fix: updated the default styling 2024-03-27 11:42:06 +01:00
mike doornenbal
5052d016da fix: added more style options and recommendations from trello 2024-03-27 11:42:06 +01:00
Freek van de Ven
37bac6c4cb
Merge pull request #53 from Iconica-Development/doc/improve-documentation
doc: create documentation for files
2024-03-14 14:03:56 +01:00
Vick Top
a4d16c59b8 doc: create documentation for files 2024-03-05 13:57:23 +01:00
Gorter-dev
2a0011b9d1
Merge pull request #52 from Iconica-Development/1.2.1
fix: localService
2024-02-14 16:29:46 +01:00
mike doornenbal
c5af30349d fix: localService 2024-02-14 14:38:25 +01:00
mike doornenbal
5ae3295a8d
Merge pull request #51 from Iconica-Development/update-component-documentation-workflow-correct
Add component-documentation.yml correct
2024-02-14 11:18:44 +01:00
mike doornenbal
ce033d7dbd fix: dependency and added linter 2024-02-14 11:10:35 +01:00
Vick Top
fbb8d5235f fix(ci): resolve dependency issue 2024-02-14 10:41:11 +01:00
Vick Top
3511db2f27 feat(documentation): Create component-documentation.yml workflow file 2024-02-13 13:25:32 +01:00
Gorter-dev
797eedc835
Merge pull request #48 from Iconica-Development/bugfix/versions
fix: versions
2024-01-30 16:10:14 +01:00
mike doornenbal
910cfc4fc6 fix: versions 2024-01-30 16:09:02 +01:00
Gorter-dev
a6550fd57a
Merge pull request #47 from Iconica-Development/feature/local_service
feat: local service
2024-01-30 15:55:48 +01:00
mike doornenbal
16a40b7451 feat: local service 2024-01-30 15:52:56 +01:00
Gorter-dev
5f890fe75f
Merge pull request #46 from Iconica-Development/feature/example
feat: example
2024-01-23 16:32:47 +01:00
mike doornenbal
d3655e3395 feat: example 2024-01-23 16:30:19 +01:00
Gorter-dev
da7d639e05
Merge pull request #45 from Iconica-Development/bugfix/feedback
fix: feedback
2024-01-23 14:39:24 +01:00
mike doornenbal
2b5ab5a933 fix: feedback 2024-01-23 14:37:09 +01:00
Gorter-dev
28e307cf90
Merge pull request #39 from Iconica-Development/bugfix/poi
fix: service naming, image loading indicator
2024-01-17 15:45:37 +01:00
mike doornenbal
23b96e5ce3 fix: chat 2024-01-17 15:38:59 +01:00
mike doornenbal
f8ca89a762
Merge pull request #38 from Iconica-Development/1.0.0
feat: pagination
2023-12-29 15:13:43 +01:00
mike doornenbal
37cd36484a fix: removed android folder 2023-12-29 15:00:07 +01:00
mike doornenbal
cb423c582c feat: updated readme, ui colors and null values 2023-12-29 12:02:54 +01:00
mike doornenbal
ac163a28f8 added pagination 2023-12-28 12:52:19 +01:00
mike doornenbal
69bafc33e6 feat: add pagination 2023-12-22 10:45:56 +01:00
mike doornenbal
4fd823511f
Merge pull request #36 from Iconica-Development/feature/improve_user_story
feat: improve user story
2023-12-20 14:05:49 +01:00
mike doornenbal
4760e281ee feat: improve user story 2023-12-20 14:03:39 +01:00
Freek van de Ven
b3b9ceb07d chore: update figma links 2023-12-14 14:28:16 +01:00
Freek van de Ven
7e503bb0ce Merge pull request #29 from Iconica-Development/0.6.0
feat: made message controller nullable
2023-12-14 11:46:09 +01:00
mike doornenbal
07e29ddd99 feat: added chat options 2023-12-13 17:23:43 +01:00
mike doornenbal
7b33ff2bd7 feat: made message controller nullable 2023-12-01 10:56:22 +01:00
Freek van de Ven
2c0bd42636
Merge pull request #28 from Iconica-Development/0.5.0
feature: added custom dialog and the option to make a chat not deletable
2023-11-29 16:55:09 +01:00
mike doornenbal
d90185a480 feature: added custom dialog and the option to make a chat not deletable 2023-11-29 16:33:18 +01:00
Freek van de Ven
d0933ac252 fix: unread chats bug 2023-11-24 13:31:20 +01:00
Freek van de Ven
d870b8424b Merge pull request #25 from Iconica-Development/0.4.1
fix: update GroupAvatarBuilder so the groupName is available
2023-11-23 12:01:41 +01:00
Freek van de Ven
fde5b289a6 fix: update GroupAvatarBuilder so the groupName is available 2023-11-22 18:03:03 +01:00
Freek van de Ven
19e539734e
feat: Update README.md 2023-11-11 16:03:54 +01:00
Gorter-dev
b7e5c51413
Merge pull request #20 from Iconica-Development/0.4.0
New Chat UI
2023-11-06 12:52:32 +01:00
Freek van de Ven
29d993682a feat: add translations for anonymous user 2023-11-06 11:55:47 +01:00
Freek van de Ven
08e695517b feat: remove extra message info when it is from the same user 2023-11-03 16:26:55 +01:00
Freek van de Ven
5d4aadc62e feat: show unread chats in appbar 2023-11-02 22:44:38 +01:00
Freek van de Ven
5e41f3885f feat: show amount of unread messages 2023-11-02 22:13:40 +01:00
Freek van de Ven
f6a2a26def fix: profile avater correct defaults 2023-10-27 15:51:48 +02:00
Freek van de Ven
c3928c19f6
Merge pull request #14 from Iconica-Development/0.3.4
feat: add unread chats system
2023-10-27 13:52:55 +02:00
Freek van de Ven
84934dec0c feat: add unread chats system 2023-10-27 13:33:33 +02:00
Freek van de Ven
0d9e192134 feat: remove .git from urls 2023-10-25 13:36:10 +02:00
Freek van de Ven
96f3138656 feat: add melos CI workflow 2023-10-25 13:17:18 +02:00
Freek van de Ven
2e79910e77 Merge pull request #11 from Iconica-Development/feat/icon-colors
feat: add color property for icon buttons in chat bottom
2023-10-11 10:11:24 +02:00
FahadFahim71
60bcd1c26a feat: add color property for icon buttons in chat bottom 2023-10-10 16:31:51 +02:00
Gorter-dev
9b4ce62392
Merge pull request #10 from Iconica-Development/hotifx/fullname
fix https://github.com/Iconica-Development/flutter_community_chat/iss
2023-09-26 11:49:50 +02:00
Niels Gorter
d4e42ac440 fix 2023-09-26 11:48:17 +02:00
Niels Gorter
5b8c6db64f fix spaces 2023-09-26 11:46:19 +02:00
Niels Gorter
b7bb8c394a fix https://github.com/Iconica-Development/flutter_community_chat/issues/8 2023-09-26 11:42:51 +02:00
Freek van de Ven
268eaadf82
Merge pull request #9 from Iconica-Development/dependabot/pub/packages/flutter_community_chat_firebase/uuid-4.0.0
build(deps): bump uuid from 3.0.7 to 4.0.0 in /packages/flutter_community_chat_firebase
2023-09-12 10:25:39 +02:00
dependabot[bot]
689a295654
build(deps): bump uuid in /packages/flutter_community_chat_firebase
Bumps [uuid](https://github.com/Daegalus/dart-uuid) from 3.0.7 to 4.0.0.
- [Release notes](https://github.com/Daegalus/dart-uuid/releases)
- [Changelog](https://github.com/daegalus/dart-uuid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Daegalus/dart-uuid/compare/3.0.7...4.0.0)

---
updated-dependencies:
- dependency-name: uuid
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-04 09:40:03 +00:00
Gorter-dev
80b25113e1
Merge pull request #7 from Iconica-Development/0.3.1
0.3.1
2023-07-11 14:08:11 +02:00
Freek van de Ven
05ec6448f8 fix: handle chat without lastmessage 2023-07-11 13:05:23 +02:00
Freek van de Ven
67087ab147 feat: add FEATURES.md 2023-07-11 13:05:12 +02:00
Freek van de Ven
6f78ae6518 feat: update chat example 2023-06-29 15:57:42 +02:00
Freek van de Ven
3003ed5389 fix: personal chats 2023-06-02 09:42:18 +02:00
Gorter-dev
e3a63a9f4b
Merge pull request #6 from Iconica-Development/feature/group-chats
Group Chats
2023-06-01 13:33:09 +02:00
Freek van de Ven
be0a99a6df fix: combine group and user chats 2023-06-01 11:34:16 +02:00
Bugfix Jacques
8f2b37c0aa fix: intl version 2023-05-23 16:05:32 +02:00
Freek van de Ven
6b32c3f55b feat: upgrade internal dependencies 2023-04-03 10:11:10 +02:00
Freek van de Ven
2a0d3115e8 feat: update chat service interface 2023-04-03 10:10:25 +02:00
Freek van de Ven
a00e5d81be feat: pub upgrade interfaces 2023-04-03 10:06:56 +02:00
Freek van de Ven
fa6a8d8b23 feat: rework firebase chat groups 2023-04-03 10:03:35 +02:00
Freek van de Ven
35d89faf04 feat: group chat UI changes 2023-03-31 14:20:28 +02:00
Freek van de Ven
b99bdfd081 feat: cleanup package 2023-03-31 13:28:55 +02:00
Freek van de Ven
65d3a0b6b3 feat: fix example and update melos configuration 2023-03-31 13:16:35 +02:00
Freek van de Ven
77da686859 refactor: remove platform configurations 2023-03-31 12:54:49 +02:00
Freek van de Ven
bba48f33c9 feat: update melos 2023-03-31 11:26:42 +02:00
676988bfd1
Merge pull request #5 from Iconica-Development/hotfix/date-format
Remove the '-' between date and time when formatting dates
2022-12-21 17:16:09 +01:00
0898935345 Remove the '-' between date and time when formatting dates 2022-12-21 17:10:47 +01:00
Stein Milder
659e2692f1 hotfix: alignment change avatar 2022-12-19 09:37:34 +01:00
Stein Milder
4df2adb984 feat: refactor 2022-12-16 15:20:01 +01:00
Stein Milder
f55c43653c feat: provide custom firebase services 2022-12-15 10:49:35 +01:00
Stein Milder
e153b926c9 feat: dependabot 2022-12-09 09:52:23 +01:00
Stein Milder
cf3cd503e5 feat: noUsersFound widget 2022-11-28 09:39:06 +01:00
Stein Milder
35e1aab154 feat: delete chat files when deleting chat 2022-11-25 09:34:58 +01:00
Stein Milder
13f57a0817 feat: confirm deleting chat 2022-11-25 08:44:14 +01:00
Stein Milder
3dde81838d feat: split first and lastname 2022-11-24 16:06:57 +01:00
Stein Milder
f110c224a2 feat: custom avatar builder 2022-11-24 15:52:36 +01:00
Stein Milder
0caebe7d02 bugfix imagepickertheme 2022-11-24 10:07:00 +01:00
Stein Milder
7fd38e3770 feat: onPressChatTitle 2022-11-22 16:53:37 +01:00
Stein Milder
5e53049a3e feat: update firebase 2022-11-17 11:34:14 +01:00
Stein Milder
d024a9fad9 naming fix 2022-11-16 16:59:12 +01:00
Stein Milder
942403d1a4 bugfix: fix overflow when user name it too long 2022-11-11 12:02:53 +01:00
Stein Milder
f800216a9b feat: pop before pushing chat screen + fix bug showing wrong chat 2022-11-10 14:21:35 +01:00
Stein Milder
79d3de0b35 feat: delete chat 2022-11-09 14:22:05 +01:00
Stein Milder
2ebb05bb79 feat: indicator for uploading images 2022-11-09 11:41:01 +01:00
Stein Milder
9e0378fe74 feat: fix opening multiple 'New Chat screens' 2022-11-09 10:56:11 +01:00
Stein Milder
bd40856e1a feat: userFilter 2022-11-09 10:00:54 +01:00
Stein Milder
bae97e917e pub update 2022-11-08 17:14:30 +01:00
Stein Milder
01ab5599d4
Merge pull request #1 from Iconica-Development/feature/open-chat-after-creating-new-one
feat: create chat after sending first message
2022-11-08 17:10:16 +01:00
Stein Milder
3480b97ba0 feat: create chat after sending first message 2022-11-08 17:09:27 +01:00
Stein Milder
7b03c934cf feat: translations 2022-11-08 10:01:23 +01:00
Stein Milder
2d36d02533 feat: image caching 2022-11-07 15:48:17 +01:00
Stein Milder
4380560593 feat: update image picker plug-in 2022-11-07 15:22:35 +01:00
dbb2ffb708 Add BSD-3-Clause license 2022-11-01 08:33:32 +01:00
Stein Milder
0b449627b8 fix: change because of subcollection 2022-10-20 15:56:35 +02:00
216 changed files with 8857 additions and 6097 deletions

3
.fvmrc Normal file
View file

@ -0,0 +1,3 @@
{
"flutter": "3.24.3"
}

22
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,22 @@
version: 2
updates:
- package-ecosystem: "pub"
directory: "/packages/flutter_chat"
schedule:
interval: "weekly"
- package-ecosystem: "pub"
directory: "/packages/flutter_chat_firebase"
schedule:
interval: "weekly"
- package-ecosystem: "pub"
directory: "/packages/flutter_chat_interface"
schedule:
interval: "weekly"
- package-ecosystem: "pub"
directory: "/packages/flutter_chat_view"
schedule:
interval: "weekly"

View file

@ -0,0 +1,14 @@
name: Iconica Standard Component Documentation Workflow
# Workflow Caller version: 1.0.0
on:
release:
types: [published]
workflow_dispatch:
jobs:
call-iconica-component-documentation-workflow:
uses: Iconica-Development/.github/.github/workflows/component-documentation.yml@master
secrets: inherit
permissions: write-all

15
.github/workflows/melos-ci.yml vendored Normal file
View file

@ -0,0 +1,15 @@
name: Iconica Standard Melos CI Workflow
# Workflow Caller version: 1.0.0
on:
pull_request:
workflow_dispatch:
jobs:
call-global-iconica-workflow:
uses: Iconica-Development/.github/.github/workflows/melos-ci.yml@master
secrets: inherit
permissions: write-all
with:
subfolder: '.' # add optional subfolder to run workflow in
flutter_version: 3.24.3

15
.github/workflows/release.yml vendored Normal file
View 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

23
.gitignore vendored
View file

@ -19,6 +19,7 @@ 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
@ -27,8 +28,28 @@ migrate_working_dir/
# Flutter/Dart/Pub related
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
/pubspec.lock
# /pubspec.lock
**/doc/api/
.dart_tool/
.packages
build/
.flutter-plugins-dependencies
.flutter-plugins
.metadata
pubspec.lock
packages/flutter_chat/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

View file

@ -1,3 +1,175 @@
## 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.
- Fixed groupchats using navigator
## 1.4.2
- Added doc comments
- Fixed bug when creating a group chat with the `LocalChatService`
- Updated readme
## 1.4.1
- Made UI changes to match the Figma design
## 1.4.0
- Add way to create group chats
- Update flutter_profile to 1.3.0
- Update flutter_image_picker to 1.0.5
## 1.3.1
- Added more options for styling the UI.
- Changed the way profile images are shown.
- Added an ontapUser in the chat.
- Changed the way the time is shown in the chat after a message.
- Added option to customize chat title and username chat message widget.
## 1.2.1
- Fixed bug in the LocalChatService
## 1.2.0
- Added linter and workflow
## 1.1.0
- Added LocalChatService for example app
## 1.0.0
- Added pagination for the ChatDetailScreen
- Added routes with Go_router and Navigator
- Added ChatEntryWidget
## 0.6.0 - December 1 2023
- Made the message controller nullable
- Improved chat UI and added showTime option for chatDetailScreen to always show the time
## 0.5.0 - November 29 2023
- Added the option to add your own dialog on chat delete and addded the option to make the chat not deletable
## 0.4.2 - November 24 2023
- Fix groupchats seen as personal chat when there are unread messages
## 0.4.1 - November 22 2023
- Add groupName for groupchat avatarbuilder
## 0.4.0 - November 6 2023
- Show amount of unread messages per chat
- More intuitive chat UI
- Fix default profile avatars
## 0.3.4 - October 25 2023
- Add interface methods for getting amount of unread messages
## 0.3.3 - October 10 2023
- Add icon color property for icon buttons
## 0.3.2 - September 26 2023
- Fix fullname getter for nullable values
## 0.3.1 - July 11 2023
- Removed image message when there is no last message in a chat
## 0.3.0 - March 31 2023
- Added support for group chats
## 0.0.1 - October 17th 2022
- Initial release

7
FEATURES.md Normal file
View file

@ -0,0 +1,7 @@
List of Features from this component:
* A chat overview screen
* A chat detail screen
* Chats can be between 2 users or group chats
* Chats can contain messages or images
* Interface for interacting with the messages(createChat, sendMessage, etc.)
* Firebase implementation of the interface

9
LICENSE Normal file
View file

@ -0,0 +1,9 @@
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:
Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

272
README.md
View file

@ -1,66 +1,270 @@
# Flutter Community Chat
Flutter Community Chat is a package which gives the possibility to add a (personal or group) chat to your Flutter-application. Default this package adds support for a Firebase back-end. You can add your custom back-end (like a Websocket-API) by extending the `CommunityChatInterface` interface from the `flutter_community_chat_interface` package.
# Flutter Chat
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.
![Flutter Chat GIF](example.gif)
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_community_chat as a dependency in your pubspec.yaml file:
```
flutter_community_chat:
git:
url: https://github.com/Iconica-Development/flutter_community_chat.git
path: packages/flutter_community_chat
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
path: packages/flutter_chat
```
If you are going to use Firebase as the back-end of the Community Chat, you should also add the following package as a dependency to your pubspec.yaml file:
You can use the `LocalChatService` to test the package in your project:
```dart
ChatUserStoryConfiguration(
chatService: LocalChatService(),
),
```
flutter_community_chat_firebase:
git:
url: https://github.com/Iconica-Development/flutter_community_chat.git
path: packages/flutter_community_chat_firebase
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.
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
default paths as you wish:
```dart
const FirebaseChatOptions({
this.groupChatsCollectionName = 'group_chats',
this.chatsCollectionName = 'chats',
this.messagesCollectionName = 'messages',
this.usersCollectionName = 'users',
this.chatsMetaDataCollectionName = 'chat_metadata',
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:
```dart
class ChatMessageModel implements ChatMessageModelInterface {
ChatMessageModel({
required this.sender,
required this.timestamp,
});
@override
final ChatUserModel sender;
@override
final DateTime timestamp;
}
```
The various interfaces you can implement:
- `ChatUserModelInterface`,
- `ChatImageMessageModelInterface`,
- `ChatTextMessageModelInterface`
- `ChatMessageModelInterface`,
- `ChatModelInterface`,
- `GroupChatModelInterface`,
- `PersonalChatModelInterface`,
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:
```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 you should add the following code to the build-method of a chosen widget.
```
CommunityChat(
dataProvider: FirebaseCommunityChatDataProvider(),
)
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, then add the following configuration to your flutter_application:
```dart
List<GoRoute> getChatRoutes() => getChatStoryRoutes(
ChatUserStoryConfiguration(
chatService: chatService,
chatOptionsBuilder: (ctx) => const ChatOptions(),
),
);
```
In this example we provide a `FirebaseCommunityChatDataProvider` as a data provider. You can also specify your own implementation here of the `CommunityChatInterface` interface.
You can override any method in the `ChatUserStoryConfiguration`.
You can also include your custom configuration for both the Community Chat itself as the Image Picker which is included in this package. You can specify those configurations as a parameter:
Add the `getChatRoutes()` to your go_router routes like so:
```dart
final GoRouter _router = GoRouter(
routes: <RouteBase>[
GoRoute(
path: '/',
builder: (BuildContext context, GoRouterState state) {
return const MyHomePage(
title: "home",
);
},
),
...getChatRoutes()
],
);
```
CommunityChat(
dataProvider: FirebaseCommunityChatDataProvider(),
imagePickerTheme: ImagePickerTheme(),
chatOptions: ChatOptions(),
)
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';
```
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';
```
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,
chatOptionsBuilder: (ctx) => const ChatOptions(),
),
context,
);
```
Just like with the `go_router` routes you can override any methods in the `ChatUserStoryConfiguration`.
Or create your own routing using the screens.
Add the following code to add the `ChatScreen`:
```dart
ChatScreen(
options: options,
onPressStartChat: onPressStartChat,
onPressChat: onPressChat,
onDeleteChat: onDeleteChat,
service: service,
pageSize: pageSize,
);
```
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,
onUploadImage: onUploadImage,
onReadChat: onReadChat,
service: service,
);
```
On the `NewChatScreen` you can select a person to chat.
Add the following coe to add the `NewChatScreen`:
```dart
NewChatScreen(
options: options,
onPressCreateChat: onPressCreateChat,
service: service,
);
```
On the `ChatProfileScreen` you can see the information about a person or group you started a chat with.
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,
translations: translations,
onTapUser: onTapUser,
userId: userId,
);
```
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`.
Add the following code to add the `ChatEntryWidget`:
```dart
ChatEntryWidget(
chatService: chatService,
onTap: onTap,
);
```
The `ChatOptions` has its own parameters, as specified below:
| Parameter | Explanation |
|-----------|-------------|
| newChatButtonBuilder | Builds the 'New Chat' button, to initiate a new chat session. This button is displayed on the chat overview. |
| messageInputBuilder | Builds the text input which is displayed within the chat view, responsible for sending text messages. |
| chatRowContainerBuilder | Builds a chat row. A row with the users' avatar, name and eventually the last massage sended in the chat. This builder is used both in the *chat overview screen* as in the *new chat screen*. |
| imagePickerContainerBuilder | Builds the container around the ImagePicker. |
| newChatButtonBuilder | Builds the 'New Chat' button, to initiate a new chat session. This button is displayed on the chat overview. |
| messageInputBuilder | Builds the text input which is displayed within the chat view, responsible for sending text messages. |
| chatRowContainerBuilder | Builds a chat row. A row with the users' avatar, name and eventually the last massage sended in the chat. This builder is used both in the _chat overview screen_ as in the _new chat screen_. |
| imagePickerContainerBuilder | Builds the container around the ImagePicker. |
| closeImagePickerButtonBuilder | Builds the close button for the Image Picker pop-up window. |
| scaffoldBuilder | Builds the default Scaffold-widget around the Community Chat. The chat title is displayed within the Scaffolds' title for example. |
| scaffoldBuilder | Builds the default Scaffold-widget around the Community Chat. The chat title is displayed within the Scaffolds' title for example. |
The `ImagePickerTheme` also has its own parameters, how to use these parameters can be found in [the documentation of the flutter_image_picker package](https://github.com/Iconica-Development/flutter_image_picker).
The `ImagePickerTheme` also has its own parameters, how to use these parameters can be found in [the documentation of the flutter_image_picker package](https://github.com/Iconica-Development/flutter_image_picker).
## Issues
Please file any issues, bugs or feature request as an issue on our [GitHub](https://github.com/Iconica-Development/flutter_community_chat/pulls) page. Commercial support is available if you need help with integration with your app or services. You can contact us at [support@iconica.nl](mailto:support@iconica.nl).
Please file any issues, bugs or feature request as an issue on our [GitHub](https://github.com/Iconica-Development/flutter_chat/pulls) page. Commercial support is available if you need help with integration with your app or services. You can contact us at [support@iconica.nl](mailto:support@iconica.nl).
## Want to contribute
If you would like to contribute to the plugin (e.g. by improving the documentation, solving a bug or adding a cool new feature), please carefully review our [contribution guide](../CONTRIBUTING.md) and send us your [pull request](https://github.com/Iconica-Development/flutter_community_chat/pulls).
If you would like to contribute to the plugin (e.g. by improving the documentation, solving a bug or adding a cool new feature), please carefully review our [contribution guide](./CONTRIBUTING.md) and send us your [pull request](https://github.com/Iconica-Development/flutter_chat/pulls).
## Author
This `flutter_community_chat` for Flutter is developed by [Iconica](https://iconica.nl). You can contact us at <support@iconica.nl>
This `flutter_chat` for Flutter is developed by [Iconica](https://iconica.nl). You can contact us at <support@iconica.nl>

BIN
example.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

View file

@ -1,4 +1,4 @@
name: flutter_community_chat
name: flutter_chat
packages:
- packages/**
@ -9,22 +9,31 @@ command:
scripts:
lint:all:
run: melos run analyze && melos run format
run: dart run melos run analyze && dart run melos run format-check
description: Run all static analysis checks.
get:
run: melos exec -c 1 -- "flutter pub get"
run: |
melos exec -c 1 -- "flutter pub get"
melos exec --scope="*example*" -c 1 -- "flutter pub get"
upgrade:
run: melos exec -c 1 -- "flutter pub upgrade"
create:
# run create in the example folder of flutter_chat_view
run: melos exec --scope="*example*" -c 1 -- "flutter create ."
analyze:
run: |
melos exec -c 1 -- \
dart run melos exec -c 1 -- \
flutter analyze --fatal-infos
description: Run `flutter analyze` for all packages.
format:
run: melos exec flutter format . --fix
description: Run `flutter format` for all packages.
run: dart run melos exec dart format .
description: Run `dart format` for all packages.
format-check:
run: melos exec flutter format . --set-exit-if-changed
description: Run `flutter format` checks for all packages.
run: dart run melos exec dart format . --set-exit-if-changed
description: Run `dart format` checks for all packages.

View 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/

View file

@ -0,0 +1 @@
../../CHANGELOG.md

View file

@ -0,0 +1 @@
../../LICENSE

View file

@ -0,0 +1,9 @@
include: package:flutter_iconica_analysis/components_options.yaml
# Possible to overwrite the rules from the package
analyzer:
exclude:
linter:
rules:

View file

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

View file

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

View file

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

View file

@ -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,
});
}

View file

@ -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,
});
}

View file

@ -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});
}

View file

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

View file

@ -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",
),
];

View file

@ -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();
}
}

View file

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

View file

@ -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);
}

View file

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

View file

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

View file

@ -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);
}

View 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

View 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/

View file

@ -0,0 +1 @@
../../CHANGELOG.md

View file

@ -0,0 +1 @@
../../LICENSE

View 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',
),
),
```

View file

@ -0,0 +1,9 @@
include: package:flutter_iconica_analysis/components_options.yaml
# Possible to overwrite the rules from the package
analyzer:
exclude:
linter:
rules:

View file

@ -0,0 +1,2 @@
export "src/firebase_chat_repository.dart";
export "src/firebase_user_repository.dart";

View file

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

View file

@ -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(),
);
}

View 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
View 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/

View file

@ -0,0 +1 @@
../../CHANGELOG.md

View file

@ -0,0 +1 @@
../../LICENSE

View file

@ -0,0 +1,9 @@
include: package:flutter_iconica_analysis/components_options.yaml
# Possible to overwrite the rules from the package
analyzer:
exclude:
linter:
rules:

View file

@ -1,7 +1,3 @@
# SPDX-FileCopyrightText: 2022 Iconica
#
# SPDX-License-Identifier: GPL-3.0-or-later
# Miscellaneous
*.class
*.log
@ -9,9 +5,11 @@
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
@ -31,7 +29,6 @@ migrate_working_dir/
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
/build/
@ -46,3 +43,5 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
firebase_options.dart

View file

@ -0,0 +1,16 @@
# example
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

View file

@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

View 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"}}}}}

View file

@ -0,0 +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() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
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 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 Scaffold(
appBar: AppBar(),
body: const Center(),
floatingActionButton: const FlutterChatEntryWidget(
userId: '1',
),
);
}
}

View file

@ -0,0 +1,28 @@
name: example
description: "A new Flutter project."
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: ^3.5.0
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
flutter_chat:
path: ../
# firebase_chat_repository:
# path: ../../firebase_chat_repository
# firebase_core: any
# firebase_auth: any
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^4.0.0
flutter:
uses-material-design: true

View file

@ -0,0 +1,30 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// 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 {
// 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);
});
}

View file

@ -0,0 +1,31 @@
// Core
export "package:chat_repository_interface/chat_repository_interface.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";

View 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,
);

View 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;
}

View 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";

View file

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

View 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,
);
}

View 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);
}

View 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,
);
}

View file

@ -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),
],
),
),
);
}
}

View file

@ -0,0 +1,323 @@
import "dart:async";
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";
/// 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),
),
),
);
/// 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),
),
),
);
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),
);
},
),
);
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),
);

View file

@ -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,
],
);
}

View file

@ -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,
),
);
}
}

View file

@ -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);
}
}

View file

@ -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,
),
),
),
);
}
}

View file

@ -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),
),
),
);
}

View file

@ -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,
);
}

View file

@ -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,
);
}
}

View 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,
),
],
),
),
),
),
),
],
],
);
}
}

View 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,
),
),
),
),
),
],
],
),
],
);
}
}

View file

@ -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,
),
),
);
}
},
),
),
],
);
}
}

View file

@ -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,
),
),
],
),
),
);
}
}

View file

@ -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,
),
],
),
),
),
),
),
);
}
}

View file

@ -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,
),
),
),
),
),
),
);
}
}

View file

@ -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,
);
}
}

View file

@ -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,
),
),
);
}
}

View file

@ -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);
}
}

View 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);
}
}
}

View 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();
}
}

View 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>()!;
}

View 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;
}

View file

@ -0,0 +1,37 @@
name: flutter_chat
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.4.3 <4.0.0"
flutter: ">=1.17.0"
dependencies:
flutter:
sdk: flutter
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: 7.0.0

View 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));
});
});
}

View file

@ -1,6 +0,0 @@
# This is a generated file; do not edit or check into version control.
flutter_plugin_android_lifecycle=/Users/steinmilder/.pub-cache/hosted/pub.dartlang.org/flutter_plugin_android_lifecycle-2.0.7/
image_picker=/Users/steinmilder/.pub-cache/hosted/pub.dartlang.org/image_picker-0.8.6/
image_picker_android=/Users/steinmilder/.pub-cache/hosted/pub.dartlang.org/image_picker_android-0.8.5+3/
image_picker_for_web=/Users/steinmilder/.pub-cache/hosted/pub.dartlang.org/image_picker_for_web-2.1.10/
image_picker_ios=/Users/steinmilder/.pub-cache/hosted/pub.dartlang.org/image_picker_ios-0.8.6+1/

View file

@ -1 +0,0 @@
{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"image_picker_ios","path":"/Users/steinmilder/.pub-cache/hosted/pub.dartlang.org/image_picker_ios-0.8.6+1/","native_build":true,"dependencies":[]}],"android":[{"name":"flutter_plugin_android_lifecycle","path":"/Users/steinmilder/.pub-cache/hosted/pub.dartlang.org/flutter_plugin_android_lifecycle-2.0.7/","native_build":true,"dependencies":[]},{"name":"image_picker_android","path":"/Users/steinmilder/.pub-cache/hosted/pub.dartlang.org/image_picker_android-0.8.5+3/","native_build":true,"dependencies":["flutter_plugin_android_lifecycle"]}],"macos":[],"linux":[],"windows":[],"web":[{"name":"image_picker_for_web","path":"/Users/steinmilder/.pub-cache/hosted/pub.dartlang.org/image_picker_for_web-2.1.10/","dependencies":[]}]},"dependencyGraph":[{"name":"flutter_plugin_android_lifecycle","dependencies":[]},{"name":"image_picker","dependencies":["image_picker_android","image_picker_for_web","image_picker_ios"]},{"name":"image_picker_android","dependencies":["flutter_plugin_android_lifecycle"]},{"name":"image_picker_for_web","dependencies":[]},{"name":"image_picker_ios","dependencies":[]}],"date_created":"2022-10-18 09:32:26.979069","version":"3.3.4"}

View file

@ -1,10 +0,0 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: 18a827f3933c19f51862dde3fa472197683249d6
channel: stable
project_type: package

View file

@ -1,3 +0,0 @@
## 0.0.1
* TODO: Describe initial release.

View file

@ -1 +0,0 @@
TODO: Add your license here.

View file

@ -1,39 +0,0 @@
<!--
This README describes the package. If you publish this package to pub.dev,
this README's contents appear on the landing page for your package.
For information about how to write a good package README, see the guide for
[writing package pages](https://dart.dev/guides/libraries/writing-package-pages).
For general information about developing packages, see the Dart guide for
[creating packages](https://dart.dev/guides/libraries/create-library-packages)
and the Flutter guide for
[developing packages and plugins](https://flutter.dev/developing-packages).
-->
TODO: Put a short description of the package here that helps potential users
know whether this package might be useful for them.
## Features
TODO: List what your package can do. Maybe include images, gifs, or videos.
## Getting started
TODO: List prerequisites and provide or point to information on how to
start using the package.
## Usage
TODO: Include short and useful examples for package users. Add longer examples
to `/example` folder.
```dart
const like = 'sample';
```
## Additional information
TODO: Tell users more about the package: where to find more information, how to
contribute to the package, how to file issues, what response they can expect
from the package authors, and more.

View file

@ -1,4 +0,0 @@
include: package:flutter_lints/flutter.yaml
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

View file

@ -1,80 +0,0 @@
library flutter_community_chat;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_community_chat_interface/flutter_community_chat_interface.dart';
import 'package:flutter_community_chat_view/flutter_community_chat_view.dart';
import 'package:flutter_image_picker/flutter_image_picker.dart';
export 'package:flutter_community_chat_view/flutter_community_chat_view.dart';
class CommunityChat extends StatelessWidget {
const CommunityChat({
required this.dataProvider,
this.chatOptions = const ChatOptions(),
this.imagePickerTheme = const ImagePickerTheme(),
super.key,
});
final CommunityChatInterface dataProvider;
final ChatOptions chatOptions;
final ImagePickerTheme imagePickerTheme;
Future<void> _push(BuildContext context, Widget widget) =>
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => widget),
);
Future<void> _onPressStartChat(BuildContext context) =>
dataProvider.getChatUsers().then((users) => _push(
context,
NewChatScreen(
chatOptions: chatOptions,
onPressCreateChat: (user) => dataProvider.createChat(
PersonalChatModel(user: user),
),
users: users,
),
));
Future<void> _onPressChat(BuildContext context, ChatModel chat) => _push(
context,
ChatDetailScreen(
chatOptions: chatOptions,
chat: chat,
chatMessages: dataProvider.getMessagesStream(chat),
onPressSelectImage: (ChatModel chat) =>
_onPressSelectImage(context, chat),
onMessageSubmit: (ChatModel chat, String content) =>
dataProvider.sendTextMessage(chat, content),
),
);
Future<void> _onPressSelectImage(BuildContext context, ChatModel chat) =>
showModalBottomSheet<Uint8List?>(
context: context,
builder: (BuildContext context) =>
chatOptions.imagePickerContainerBuilder(
ImagePicker(
customButton: chatOptions.closeImagePickerButtonBuilder(
context,
() => Navigator.of(context).pop(),
),
imagePickerTheme: imagePickerTheme,
),
),
).then(
(image) {
if (image != null) {
return dataProvider.sendImageMessage(chat, image);
}
},
);
@override
Widget build(BuildContext context) => ChatScreen(
chats: dataProvider.getChatsStream(),
onPressStartChat: () => _onPressStartChat(context),
onPressChat: (chat) => _onPressChat(context, chat),
chatOptions: chatOptions,
);
}

View file

@ -1,414 +0,0 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
url: "https://pub.dartlang.org"
source: hosted
version: "49.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
url: "https://pub.dartlang.org"
source: hosted
version: "5.1.0"
args:
dependency: transitive
description:
name: args
url: "https://pub.dartlang.org"
source: hosted
version: "2.3.1"
async:
dependency: transitive
description:
name: async
url: "https://pub.dartlang.org"
source: hosted
version: "2.9.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
build:
dependency: transitive
description:
name: build
url: "https://pub.dartlang.org"
source: hosted
version: "2.3.1"
built_collection:
dependency: transitive
description:
name: built_collection
url: "https://pub.dartlang.org"
source: hosted
version: "5.1.1"
built_value:
dependency: transitive
description:
name: built_value
url: "https://pub.dartlang.org"
source: hosted
version: "8.4.1"
characters:
dependency: transitive
description:
name: characters
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.1"
clock:
dependency: transitive
description:
name: clock
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.1"
code_builder:
dependency: transitive
description:
name: code_builder
url: "https://pub.dartlang.org"
source: hosted
version: "4.3.0"
collection:
dependency: transitive
description:
name: collection
url: "https://pub.dartlang.org"
source: hosted
version: "1.16.0"
convert:
dependency: transitive
description:
name: convert
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.1"
cross_file:
dependency: transitive
description:
name: cross_file
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.3+2"
crypto:
dependency: transitive
description:
name: crypto
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.2"
dart_style:
dependency: transitive
description:
name: dart_style
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.4"
file:
dependency: transitive
description:
name: file
url: "https://pub.dartlang.org"
source: hosted
version: "6.1.4"
fixnum:
dependency: transitive
description:
name: fixnum
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_community_chat_interface:
dependency: "direct main"
description:
path: "packages/flutter_community_chat_interface"
ref: "0.0.3"
resolved-ref: "3c88dcd3ce77f6703e9d2f3bb535f8117eb0b33d"
url: "https://github.com/Iconica-Development/flutter_community_chat.git"
source: git
version: "0.0.1"
flutter_community_chat_view:
dependency: "direct main"
description:
path: "packages/flutter_community_chat_view"
ref: "0.0.3"
resolved-ref: "3c88dcd3ce77f6703e9d2f3bb535f8117eb0b33d"
url: "https://github.com/Iconica-Development/flutter_community_chat.git"
source: git
version: "0.0.1"
flutter_data_interface:
dependency: transitive
description:
path: "."
ref: master
resolved-ref: e348c921d5e621975e4f06d3eeff8e8a89dda109
url: "https://github.com/Iconica-Development/flutter_data_interface.git"
source: git
version: "1.0.0"
flutter_image_picker:
dependency: "direct main"
description:
path: "."
ref: "v1.0.1"
resolved-ref: "8a5ddd6ea1fcf85793777d9a3e6f10da2e2ac5f3"
url: "https://github.com/Iconica-Development/flutter_image_picker"
source: git
version: "1.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.1"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.7"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
glob:
dependency: transitive
description:
name: glob
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
http:
dependency: transitive
description:
name: http
url: "https://pub.dartlang.org"
source: hosted
version: "0.13.5"
http_parser:
dependency: transitive
description:
name: http_parser
url: "https://pub.dartlang.org"
source: hosted
version: "4.0.1"
image_picker:
dependency: transitive
description:
name: image_picker
url: "https://pub.dartlang.org"
source: hosted
version: "0.8.6"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
url: "https://pub.dartlang.org"
source: hosted
version: "0.8.5+3"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.10"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
url: "https://pub.dartlang.org"
source: hosted
version: "0.8.6+1"
image_picker_platform_interface:
dependency: transitive
description:
name: image_picker_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.6.2"
intl:
dependency: transitive
description:
name: intl
url: "https://pub.dartlang.org"
source: hosted
version: "0.17.0"
js:
dependency: transitive
description:
name: js
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.4"
lints:
dependency: transitive
description:
name: lints
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
logging:
dependency: transitive
description:
name: logging
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0"
matcher:
dependency: transitive
description:
name: matcher
url: "https://pub.dartlang.org"
source: hosted
version: "0.12.12"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.5"
meta:
dependency: transitive
description:
name: meta
url: "https://pub.dartlang.org"
source: hosted
version: "1.8.0"
mockito:
dependency: transitive
description:
name: mockito
url: "https://pub.dartlang.org"
source: hosted
version: "5.3.2"
package_config:
dependency: transitive
description:
name: package_config
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
path:
dependency: transitive
description:
name: path
url: "https://pub.dartlang.org"
source: hosted
version: "1.8.2"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.3"
pub_semver:
dependency: transitive
description:
name: pub_semver
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.1"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.99"
source_gen:
dependency: transitive
description:
name: source_gen
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.6"
source_span:
dependency: transitive
description:
name: source_span
url: "https://pub.dartlang.org"
source: hosted
version: "1.9.1"
stack_trace:
dependency: transitive
description:
name: stack_trace
url: "https://pub.dartlang.org"
source: hosted
version: "1.10.0"
stream_channel:
dependency: transitive
description:
name: stream_channel
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.1"
string_scanner:
dependency: transitive
description:
name: string_scanner
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.1"
test_api:
dependency: transitive
description:
name: test_api
url: "https://pub.dartlang.org"
source: hosted
version: "0.4.14"
typed_data:
dependency: transitive
description:
name: typed_data
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.1"
vector_math:
dependency: transitive
description:
name: vector_math
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.2"
watcher:
dependency: transitive
description:
name: watcher
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
yaml:
dependency: transitive
description:
name: yaml
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.1"
sdks:
dart: ">=2.18.2 <3.0.0"
flutter: ">=2.10.0"

View file

@ -1,67 +0,0 @@
name: flutter_community_chat
description: A new Flutter package project.
version: 0.0.1
homepage:
publish_to: none
environment:
sdk: '>=2.18.2 <3.0.0'
flutter: ">=1.17.0"
dependencies:
flutter:
sdk: flutter
flutter_community_chat_view:
git:
url: https://github.com/Iconica-Development/flutter_community_chat.git
path: packages/flutter_community_chat_view
ref: 0.0.3
flutter_community_chat_interface:
git:
url: https://github.com/Iconica-Development/flutter_community_chat.git
path: packages/flutter_community_chat_interface
ref: 0.0.3
flutter_image_picker:
git:
url: https://github.com/Iconica-Development/flutter_image_picker
ref: v1.0.1
dev_dependencies:
flutter_lints: ^2.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# To add assets to your package, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
#
# For details regarding assets in packages, see
# https://flutter.dev/assets-and-images/#from-packages
#
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware
# To add custom fonts to your package, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts in packages, see
# https://flutter.dev/custom-fonts/#from-packages

View file

@ -1,9 +0,0 @@
# This is a generated file; do not edit or check into version control.
cloud_firestore=/Users/steinmilder/.pub-cache/hosted/pub.dartlang.org/cloud_firestore-3.2.0/
cloud_firestore_web=/Users/steinmilder/.pub-cache/hosted/pub.dartlang.org/cloud_firestore_web-2.8.10/
firebase_auth=/Users/steinmilder/.pub-cache/hosted/pub.dartlang.org/firebase_auth-3.11.2/
firebase_auth_web=/Users/steinmilder/.pub-cache/hosted/pub.dartlang.org/firebase_auth_web-4.6.1/
firebase_core=/Users/steinmilder/.pub-cache/hosted/pub.dartlang.org/firebase_core-1.24.0/
firebase_core_web=/Users/steinmilder/.pub-cache/hosted/pub.dartlang.org/firebase_core_web-1.7.3/
firebase_storage=/Users/steinmilder/.pub-cache/hosted/pub.dartlang.org/firebase_storage-10.3.11/
firebase_storage_web=/Users/steinmilder/.pub-cache/hosted/pub.dartlang.org/firebase_storage_web-3.3.9/

View file

@ -1 +0,0 @@
{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"cloud_firestore","path":"/Users/steinmilder/.pub-cache/hosted/pub.dartlang.org/cloud_firestore-3.2.0/","native_build":true,"dependencies":["firebase_core"]},{"name":"firebase_auth","path":"/Users/steinmilder/.pub-cache/hosted/pub.dartlang.org/firebase_auth-3.11.2/","native_build":true,"dependencies":["firebase_core"]},{"name":"firebase_core","path":"/Users/steinmilder/.pub-cache/hosted/pub.dartlang.org/firebase_core-1.24.0/","native_build":true,"dependencies":[]},{"name":"firebase_storage","path":"/Users/steinmilder/.pub-cache/hosted/pub.dartlang.org/firebase_storage-10.3.11/","native_build":true,"dependencies":["firebase_core"]}],"android":[{"name":"cloud_firestore","path":"/Users/steinmilder/.pub-cache/hosted/pub.dartlang.org/cloud_firestore-3.2.0/","native_build":true,"dependencies":["firebase_core"]},{"name":"firebase_auth","path":"/Users/steinmilder/.pub-cache/hosted/pub.dartlang.org/firebase_auth-3.11.2/","native_build":true,"dependencies":["firebase_core"]},{"name":"firebase_core","path":"/Users/steinmilder/.pub-cache/hosted/pub.dartlang.org/firebase_core-1.24.0/","native_build":true,"dependencies":[]},{"name":"firebase_storage","path":"/Users/steinmilder/.pub-cache/hosted/pub.dartlang.org/firebase_storage-10.3.11/","native_build":true,"dependencies":["firebase_core"]}],"macos":[{"name":"cloud_firestore","path":"/Users/steinmilder/.pub-cache/hosted/pub.dartlang.org/cloud_firestore-3.2.0/","native_build":true,"dependencies":["firebase_core"]},{"name":"firebase_auth","path":"/Users/steinmilder/.pub-cache/hosted/pub.dartlang.org/firebase_auth-3.11.2/","native_build":true,"dependencies":["firebase_core"]},{"name":"firebase_core","path":"/Users/steinmilder/.pub-cache/hosted/pub.dartlang.org/firebase_core-1.24.0/","native_build":true,"dependencies":[]},{"name":"firebase_storage","path":"/Users/steinmilder/.pub-cache/hosted/pub.dartlang.org/firebase_storage-10.3.11/","native_build":true,"dependencies":["firebase_core"]}],"linux":[],"windows":[],"web":[{"name":"cloud_firestore_web","path":"/Users/steinmilder/.pub-cache/hosted/pub.dartlang.org/cloud_firestore_web-2.8.10/","dependencies":["firebase_core_web"]},{"name":"firebase_auth_web","path":"/Users/steinmilder/.pub-cache/hosted/pub.dartlang.org/firebase_auth_web-4.6.1/","dependencies":["firebase_core_web"]},{"name":"firebase_core_web","path":"/Users/steinmilder/.pub-cache/hosted/pub.dartlang.org/firebase_core_web-1.7.3/","dependencies":[]},{"name":"firebase_storage_web","path":"/Users/steinmilder/.pub-cache/hosted/pub.dartlang.org/firebase_storage_web-3.3.9/","dependencies":["firebase_core_web"]}]},"dependencyGraph":[{"name":"cloud_firestore","dependencies":["cloud_firestore_web","firebase_core"]},{"name":"cloud_firestore_web","dependencies":["firebase_core","firebase_core_web"]},{"name":"firebase_auth","dependencies":["firebase_auth_web","firebase_core"]},{"name":"firebase_auth_web","dependencies":["firebase_core","firebase_core_web"]},{"name":"firebase_core","dependencies":["firebase_core_web"]},{"name":"firebase_core_web","dependencies":[]},{"name":"firebase_storage","dependencies":["firebase_core","firebase_storage_web"]},{"name":"firebase_storage_web","dependencies":["firebase_core","firebase_core_web"]}],"date_created":"2022-10-18 09:33:20.309805","version":"3.3.4"}

View file

@ -1,10 +0,0 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: 18a827f3933c19f51862dde3fa472197683249d6
channel: stable
project_type: package

View file

@ -1,3 +0,0 @@
## 0.0.1
* TODO: Describe initial release.

View file

@ -1 +0,0 @@
TODO: Add your license here.

View file

@ -1,39 +0,0 @@
<!--
This README describes the package. If you publish this package to pub.dev,
this README's contents appear on the landing page for your package.
For information about how to write a good package README, see the guide for
[writing package pages](https://dart.dev/guides/libraries/writing-package-pages).
For general information about developing packages, see the Dart guide for
[creating packages](https://dart.dev/guides/libraries/create-library-packages)
and the Flutter guide for
[developing packages and plugins](https://flutter.dev/developing-packages).
-->
TODO: Put a short description of the package here that helps potential users
know whether this package might be useful for them.
## Features
TODO: List what your package can do. Maybe include images, gifs, or videos.
## Getting started
TODO: List prerequisites and provide or point to information on how to
start using the package.
## Usage
TODO: Include short and useful examples for package users. Add longer examples
to `/example` folder.
```dart
const like = 'sample';
```
## Additional information
TODO: Tell users more about the package: where to find more information, how to
contribute to the package, how to file issues, what response they can expect
from the package authors, and more.

Some files were not shown because too many files have changed in this diff Show more