V5 (#1222)
* chore(flutter): change BoxFit of VLC player * chore(flutter): remove unused imports * chore(flutter sample): tweak breakpoints, ignore some lints * chore(flutter): minor code readability improvements * chore(flutter): extract out platform_widget_builder to its own "package" * fix(flutter): fix a theme bug with PlatformDialog * chore(persistence): test plugin on macOS * Generated the macos runner for the example * Re-generated the android runner because it was using the v1 embedding * chore(persistence): test plugin on web * chore(persistence): add Windows runner to example * chore(persistence): add linux runner to example * feat(flutter): branded context menus * chore(flutter): fix bug where lines would be shown in between menu items * chore(flutter): import/export fixes * chore(flutter): move file * chore(flutter): /src/channel Also refactors several /channel widgets to use class widgets instead of methods. Some methods, however, have been left as those cases do actually produce cleaner code. chore(flutter): fix bad directory placement chore(flutter): fix bad directory placement * chore(flutter): more directory organization * chore(flutter): navigator improvements * chore(flutter): void async improvements * chore(flutter): fix imports/exports * docs(flutter): documentation updates for /attachment * docs(flutter): doc updates For: * /avatars * /bottom_sheets * /channel * feat(flutter): selectable message text on web & desktop * Squashed commit of the following: commit 50b249edb6b2ee2d744e4e00e97f81979026aab4 Author: GroovinChip <[email protected]> Date: Wed Mar 16 14:55:05 2022 -0400 chore(flutter): wrapping up docs commit 7d289ae45e20c0109bcdb4496f7b693e2adc4ef9 Author: GroovinChip <[email protected]> Date: Wed Mar 16 14:42:09 2022 -0400 chore(flutter): more docs & typedefs commit 6b36f6f3864f15b52e441d58ee7e62577ff8f843 Author: GroovinChip <[email protected]> Date: Wed Mar 16 14:22:34 2022 -0400 chore(flutter): more doc fixes commit 1a9a6de3c836e9ee5d6373033f1d9a7da830bddc Merge: 59188f5d 88abf99f Author: GroovinChip <[email protected]> Date: Wed Mar 16 13:17:52 2022 -0400 Merge branch 'desk_web_dev' into utils commit 59188f5d9c1dd68edd531086cdfd9d42faf93fc8 Author: GroovinChip <[email protected]> Date: Wed Mar 16 12:48:16 2022 -0400 chore(flutter): add todo commit 9b4a986ca5757f284677eb5335637d11ccb1d642 Author: GroovinChip <[email protected]> Date: Wed Mar 16 12:43:12 2022 -0400 chore(flutter): more docs and typedefs updates commit 8786d2dd82b94f673b36371fb2b33a1ebcc9840a Author: GroovinChip <[email protected]> Date: Wed Mar 16 12:14:14 2022 -0400 chore(flutter): more typedef and doc template updates commit 9e906beb0e7a054c13a32c8c8c5b0e2a9e27bb21 Author: GroovinChip <[email protected]> Date: Wed Mar 16 10:46:04 2022 -0400 chore(flutter): create /uitils and move files Fixes subsequent imports/exports * chore(flutter): update file_selector plugin * chore(flutter): include utils/typedefs.dart in library export * Squashed commit of the following: commit 501228afa614b0d740858dec1ebedd3f671e2c96 Author: GroovinChip <[email protected]> Date: Thu Mar 17 16:18:12 2022 -0400 feat(flutter): ensure fullscreen media feature works on ALL platforms commit cd0aab92bc7d90af03258829a5aae66125578af5 Author: GroovinChip <[email protected]> Date: Thu Mar 17 14:26:48 2022 -0400 feat(flutter): working conditional import for initializing VLC * fix(flutter): remove stray import causing web to break * chore(flutter): ensure context menus can show on web in sample * feat(flutter) FullScreenMedia context menus for Web * feat(flutter): branded context menus * chore(flutter): no success dialog on file download (web) * chore(flutter): remove platform UI packages * chore(flutter): properly show/hide delete message menu item Also deletes deprecated PlatformDialog * feat(flutter): 'view info' channel preview context menu * feat(flutter): more menu actions for ChannelPreview Also new ConfirmationDialog and add missing docs * feat(flutter/localizations): translations related to channel mutes * chore(flutter): remove prints and fix super.didChangeDependencies order * chore(flutter): rename some things in ChannelBottomSheet * chore(flutter): more sensible name in TypingIndicator * chore(flutter): remove unused code in MessageWidget * feat(flutter): ChannelInfoDialog * chore(flutter): organize files more Also refactors out some internal MessageListView widgets * fix(flutter): fix render overflow issue * chore(flutter): move MediaListView * chore(flutter): remove unused imports * fix(flutter): fix weird share menu placement on macos * chore(flutter): clean up fullscreenmedia conditional imports * chore(flutter): adjust package exports * chore(flutter): update dart_vlc * chore(flutter): clean up platform checks * chore(flutter): run flutter format on lib * chore(flutter): minor sample cleanup * chore(flutter): ensure vlc doesn't get initialized in non-desktop testing environments * test(flutter): reformat existing tests * fix(flutter): pass onThreadReplyTap down to ThreadReplyButton * First round of new tests commit a90bed427521123fdaa4c5e276744a224fac2b68 Author: GroovinChip <[email protected]> Date: Thu Apr 7 12:34:19 2022 -0400 test(flutter): Username test commit d7bc179c20eae2713c9248191518bf1d955104a9 Author: GroovinChip <[email protected]> Date: Thu Apr 7 12:30:47 2022 -0400 chore(flutter): remove unused imports commit 09c9517de2917dc5ccd7de019b50a7875386991c Author: GroovinChip <[email protected]> Date: Thu Apr 7 12:29:58 2022 -0400 test(flutter): ThreadSeparator test commit 9422d1129211aaaac9a51ca4d29fe94bde06593c Author: GroovinChip <[email protected]> Date: Thu Apr 7 12:19:43 2022 -0400 chore(flutter): rename LoadingIndicator parameter commit 63af943a1a16e78a1e0377d76176420bd7e1fbc7 Author: GroovinChip <[email protected]> Date: Thu Apr 7 11:47:46 2022 -0400 test(flutter): FloatingDateDivider test commit d8e67cf6fca2b76ac091a9047104c0910d5fdfef Author: GroovinChip <[email protected]> Date: Tue Mar 29 15:19:39 2022 -0400 test(flutter): InputAttachment tests commit d2b7d5dd3304c8be6f74bf5b0281d7a95dd027ec Author: GroovinChip <[email protected]> Date: Tue Mar 29 14:41:40 2022 -0400 test(flutter): DmCheckbox tests commit 01567f235a2f00880bb530b232489e3790159b25 Author: GroovinChip <[email protected]> Date: Tue Mar 29 14:16:08 2022 -0400 chore(flutter): remove unused imports commit c9625858e840df6082506ae9374f1d2710053cbd Author: GroovinChip <[email protected]> Date: Tue Mar 29 14:15:44 2022 -0400 test(flutter): CountdownButton tests commit d770474bb11ee8b36a3b0e18db31695fd812a6b3 Author: GroovinChip <[email protected]> Date: Tue Mar 29 14:11:37 2022 -0400 test(flutter): CommandButton tests commit 95e63706d1dd971373d39d471894a38bb2a634f2 Author: GroovinChip <[email protected]> Date: Tue Mar 29 14:07:43 2022 -0400 test(flutter): ClearInputItem tests commit 4156586945c91408cb7943b6c57c2ae560a2857c Author: GroovinChip <[email protected]> Date: Tue Mar 29 14:03:59 2022 -0400 test(flutter): AttachmentButton tests commit bba042806326999aabf338636c6e33900de86160 Author: GroovinChip <[email protected]> Date: Tue Mar 29 13:00:42 2022 -0400 test(flutter): golden test for SendButton commit e2cd0f00b9eeb8d81399f6316edb298234de12dc Author: GroovinChip <[email protected]> Date: Tue Mar 29 12:56:52 2022 -0400 chore(flutter): deal with lints commit b4e045f9cbd54c4d42c3f30acfe72d5f8ccf9d82 Author: GroovinChip <[email protected]> Date: Tue Mar 29 12:56:00 2022 -0400 test(flutter): SendButton test commit 4e3d5db0bddb97e508fcdda2e7272fe31e70808f Author: GroovinChip <[email protected]> Date: Tue Mar 29 12:38:21 2022 -0400 test(flutter): KeyboardShortcutRunner tests commit 0dfd7a0bd6fd6bc76902e87f62ac29da0b01bac6 Author: GroovinChip <[email protected]> Date: Tue Mar 29 12:17:30 2022 -0400 test(flutter): UploadProgressIndicator commit ec50c8aea716bdc19e4308e367c8331211c61ea3 Author: GroovinChip <[email protected]> Date: Tue Mar 29 11:47:53 2022 -0400 test(flutter): SendingIndicator tests commit e87bd1450fb5bfb713a867c7e831968f76428848 Author: GroovinChip <[email protected]> Date: Tue Mar 29 11:32:35 2022 -0400 test(flutter): more gallery tests commit 93d46ca649277fe6d289a70f0bdd23584fd0ab94 Author: GroovinChip <[email protected]> Date: Tue Mar 29 11:17:29 2022 -0400 test(flutter): remove bad mock commit c7b03a39f0f3ca7a334dfb908f3473b5e4deab38 Author: GroovinChip <[email protected]> Date: Tue Mar 29 10:45:45 2022 -0400 chore(flutter): minor refactor commit 33aeb7b5ed398287cb930f3b4702f47a82fce2c6 Author: GroovinChip <[email protected]> Date: Tue Mar 29 09:49:07 2022 -0400 test(flutter): MessageDialog tests commit 220b3ae591e135d9606254666e04d4771945d0ae Author: GroovinChip <[email protected]> Date: Tue Mar 29 09:42:51 2022 -0400 test(flutter): DeleteMessageDialog tests commit db508478bb96749971f563de87b6c2a49e1e4e57 Author: GroovinChip <[email protected]> Date: Mon Mar 28 14:46:41 2022 -0400 test(flutter): ConfirmationDialog tests commit e8172dad6bfe18f40b65554ef2c6c16d7437cb56 Author: GroovinChip <[email protected]> Date: Mon Mar 28 14:35:50 2022 -0400 test(flutter): ChannelInfoDialog test commit 04c21458a601af3c3f4a5fc5ce9e8b61493c208c Author: GroovinChip <[email protected]> Date: Mon Mar 28 13:57:02 2022 -0400 test(flutter): DownloadMenuItem tests commit afee1dace543e2fef8543f9b035f684b2ee00f3b Author: GroovinChip <[email protected]> Date: Mon Mar 28 13:53:21 2022 -0400 test(flutter): StreamChatContextMenuItem tests commit 4616b1a973d0cf5224b5a71914c9908339c8462a Author: GroovinChip <[email protected]> Date: Mon Mar 28 13:02:07 2022 -0400 test(flutter): golden test for ErrorAlertSheet commit 22fb8241ae333f57ade8e8550f87695929836b2b Author: GroovinChip <[email protected]> Date: Mon Mar 28 12:59:01 2022 -0400 test(flutter): golden test for EditMessageSheet commit 0be79964827f344ba66e99cb9e5bd3d627aed01d Author: GroovinChip <[email protected]> Date: Mon Mar 28 12:56:08 2022 -0400 test(flutter): golden test for AttachmentModalSheet commit 0ae842a98143478885bc211c788353c8bca57b40 Author: GroovinChip <[email protected]> Date: Mon Mar 28 12:53:26 2022 -0400 test(flutter): GroupAvatar tests commit 697d294c6c7e1bba1d9089a53d5c60d8609ea64e Author: GroovinChip <[email protected]> Date: Mon Mar 28 12:30:25 2022 -0400 test(flutter): UserAvatar tests commit c03ba4124068c83d3a49c4db00ca18a659801c23 Author: GroovinChip <[email protected]> Date: Mon Mar 28 11:04:55 2022 -0400 test(flutter): ErrorAlertSheet test commit 3414f28530b0e22614b83af20361f21dd4967fae Author: GroovinChip <[email protected]> Date: Mon Mar 28 10:45:37 2022 -0400 test(flutter): EditMessageSheet test commit b2962b641dfbf14c11d3da7f80a4be27f9980d90 Author: GroovinChip <[email protected]> Date: Mon Mar 28 10:37:18 2022 -0400 test(flutter): AttachmentModalSheet test test(flutter): AttachmentModalSheet test chore(flutter): update exports commit 7d7e278692c65dea84e7ef3b2d3c1311ebb28408 Author: GroovinChip <[email protected]> Date: Mon Mar 28 09:52:29 2022 -0400 test(flutter): more organization commit 4479b649835acc62d8a0553f9505a0af8077abfc Author: GroovinChip <[email protected]> Date: Mon Mar 28 09:33:06 2022 -0400 test(flutter): tweak AttachmentHandler tests commit b4a41106567e3c08105ce8dc272b1ebf40b3b0bb Author: GroovinChip <[email protected]> Date: Mon Mar 28 09:24:00 2022 -0400 chore(flutter): streamline attachment handling * MobileAttachmentHandler for streamlining related code for mobile * Break out DesktopAttachmentHandler into its own file * Update minSdk version in Android runner commit bfb06d31b79cd941d8b611c7e89e2d7728a394cb Merge: a44b3b39 15c83d03 Author: GroovinChip <[email protected]> Date: Fri Mar 25 13:00:27 2022 -0400 Merge branch 'desk_web_dev' into tests commit a44b3b390adc25a00d1597bb1460973e8555fa9e Author: GroovinChip <[email protected]> Date: Fri Mar 25 12:59:14 2022 -0400 chore(flutter): start cleaning up test dir commit de104a3491d942d12f72dd66152b62c6e8b10a5b Author: GroovinChip <[email protected]> Date: Fri Mar 25 12:56:48 2022 -0400 test(flutter): VideoAttachment test commit 4748a1c081a3272f7fdbdeb69c501344e221e01e Author: GroovinChip <[email protected]> Date: Fri Mar 25 12:52:30 2022 -0400 test(flutter): UrlAttachment test commit 54408ef70f07c60adf8350553fdf3173e5edd7c7 Author: GroovinChip <[email protected]> Date: Fri Mar 25 12:48:24 2022 -0400 test(flutter): ImageGroup test commit 811cff069e490c6bd03be0c0ac2f5ec86fb990e9 Author: GroovinChip <[email protected]> Date: Fri Mar 25 12:46:04 2022 -0400 test(flutter): ImageAttachment test commit a72677976c6524e76793432b0520e99cac4e7a62 Author: GroovinChip <[email protected]> Date: Fri Mar 25 12:34:06 2022 -0400 test(flutter): GiphyAttachment test commit 32ddac022e2c149726b4b6607f41850d556055b8 Author: GroovinChip <[email protected]> Date: Fri Mar 25 12:28:01 2022 -0400 test(flutter): FileAttachment test commit 6534958cab775be1fb9bda56e67caa4746aa1502 Author: GroovinChip <[email protected]> Date: Fri Mar 25 12:14:25 2022 -0400 test(flutter): AttachmentError test commit 718a83c10c4e883ce01c9c002ced24de671d30b9 Author: GroovinChip <[email protected]> Date: Fri Mar 25 12:06:43 2022 -0400 test(flutter): AttachmentUploadStateBuilder test commit a05108c460e36b2e425e4ec06f8bf408b0c20e97 Author: GroovinChip <[email protected]> Date: Fri Mar 25 11:44:34 2022 -0400 chore(flutter): minor cleanup commit 6e73038ac3714a2bd96f8c587af7f743bf2b2023 Author: GroovinChip <[email protected]> Date: Fri Mar 25 11:43:23 2022 -0400 test(flutter): AttachmentTitle test commit c6ee52ec951877feef31dc1427eec3bfc2bd8fcb Author: GroovinChip <[email protected]> Date: Fri Mar 25 11:43:11 2022 -0400 test(flutter): minor cleanup commit 09845cd845aa8c546043f4ae04805a8e6f53f61c Author: GroovinChip <[email protected]> Date: Fri Mar 25 11:42:52 2022 -0400 chore(flutter): improve device segmentation & vlc init check commit 172e6b25a064b5147b7641f29ebff3c920156f70 Author: GroovinChip <[email protected]> Date: Fri Mar 25 10:37:43 2022 -0400 test(flutter): attachment_handler tests commit 5f5a91c70cd7c069f39518f1f50bb74ab484c7e9 Author: GroovinChip <[email protected]> Date: Wed Mar 23 13:55:27 2022 -0400 test(flutter): test PlatformWidgetBuilder & DesktopWidgetBuilder commit 5a457b7557cb4127b74ee022389a24e5e9150601 Author: GroovinChip <[email protected]> Date: Wed Mar 23 13:55:05 2022 -0400 chore(flutter): make base platform widgets testable Also fix environment check in stream_chat.dart commit 88fb16018103c581236aeebd978e705b223b2243 Author: GroovinChip <[email protected]> Date: Wed Mar 23 11:59:19 2022 -0400 chore(flutter): add readme to conditional_parent_widget library commit e9307f8a6a5639d59358c245f033f9d9b209fe20 Author: GroovinChip <[email protected]> Date: Wed Mar 23 11:54:54 2022 -0400 test(flutter): ConditionalParentBuilder tests * chore(flutter): add thumblr dependency * chore(flutter): fix typos * chore(flutter): properly order super calls * feat(flutter) video thumbnails on desktop * chore(flutter): fix incorrect file name * chore(flutter): fix docs for generateVideoThumbnail * chore(flutter): remove incorrect comment * chore(flutter): run flutter format * chore(flutter): ignore lint * chore(flutter): flutter format * chore(flutter): fix tests * fix(flutter): fix MessageListView bug caused by rebase * chore(flutter): migrate desktop stuff to v4/stream_message_input.dart * chore(flutter): migrate over desktop changes for message replies * chore(flutter): fix message replies on desktop * chore(flutter): address lints * chore(flutter): update dart_vlc * chore(flutter): melos clean & update photo_view * fix(ui): port #1118 * fix(ui): use attachmentButtonBuilder * fix(ui): fix file attachment * format * fix(ui): fix message bottom row * chore(flutter sample): use modern window look * chore(flutter): pass userAvatarBuilder through to MessageWidgetContent Also pass onUserAvatarTap & userAvatarBuilder through to UserAvatarTransform * chore: fix MessageWidget menu actions * fix(flutter): fix "enter" sending empty messages in StreamMessageInput * fix(flutter): fix Giphys not loading in quoted messages * chore(flutter): update thumblr * feat(flutter): keyboard navigation for gallery items * fix(ui): fix double date divider * fix(ui): add scroll to bottom back * fix(ui): use mouse region * fix(ui): remove backbutton in desktop example * fix(ui): rename full screen media widget * feat(flutter): elevation prop for gallery header * chore(flutter): update dart_vlc * fix(flutter): fix "show in chat" showing when it shouldn't * fix(flutter): fix "show in chat" for mobile * docs(flutter): minor doc update * chore(flutter): add onMessageSwiped in example app * chore(flutter): pass usernameBuilder through to BottomRow * fix(flutter): hacky fix for chonky link previews * fix(flutter): fix chonky text messages * feat(flutter): press "esc" to close gallery Also refactors KeyboardShortcutRunner API * chore(flutter): fix innaccurate name * fix(ui): fix attachment + long text sizing * add key to channelpage * fix(ui): better example * fix format * fix example * chore(flutter): revert modern window look * improvement(flutter): improve `GalleryNavigationItem` * Make buttons more visible * Spacing from the sides of the screen * fix video thumbnail * fix split view * fix(ui): fix giphy in thread * feat(flutter): desktop message reactions commit 4b880ecb6d4dcbf315a7d41d863174817a9c530a Author: Reuben Turner <[email protected]> Date: Thu Jun 9 12:25:21 2022 -0400 fix(flutter): fixes for desktop reactions * Fix reactions for reverse messages * Ensure reactions near the edges of MessageListView show properly commit f511513f8dbb272397e81d08bad33ee60108d738 Author: Salvatore Giordano <[email protected]> Date: Thu Jun 9 16:43:03 2022 +0200 fix(ui): fix DesktopReactionBuilder and Portal commit c4885648330552f5212c5ba6b771c0f07023f6ae Merge: 2b710a2b 72b34c21 Author: Reuben Turner <[email protected]> Date: Wed Jun 8 14:12:44 2022 -0400 Merge branch 'desk_web_dev' into desktop_reactions commit 2b710a2bb610326123d5b7f75c26caa705114c9b Author: Reuben Turner <[email protected]> Date: Mon Jun 6 12:19:12 2022 -0400 chore(flutter): add debug props commit e24dabc1de12da8dce697b6d4e1a3617e9835549 Author: Reuben Turner <[email protected]> Date: Mon Jun 6 12:00:03 2022 -0400 feat(flutter): first pass at desktop reactions popup on mouse hover * fix(flutter): potential fix for #1199 * fix(flutter): fix non-reversed reactions placement * refactor(flutter): extract bottom reaction, stacked reaction with debug props * fix(flutter): fixes for desktop reactions * Fix bottom row being rendered above reaction buttons * Fix padding issues related to avatars * chore(flutter): add back lost changes from 2b1fb6a Also removed commented code * chore(flutter): stacked reactions update * Show "You" if userId matches the reaction username (per design) * Ensure long names don't throw off alignment * chore(flutter): format * fix(ui): fix mime type generation * chore(flutter): tweak desktop video * chore(flutter): add some missing things from develop * merge develop * fix merge * fix merge * format * fix tests * increate test action timeout * remove test * refactor(ui,core): remove deprecated code and cleanup * run builder * update coverage threasholds * refactor(ui): remove arrow function from countdown button * Update packages/stream_chat_flutter/lib/src/message_input/countdown_button.dart * Update packages/stream_chat_flutter/lib/src/message_input/countdown_button.dart * update comment * fix(ui): fix attachment + link solution * fix(ui): fix attachment + link solution * chore(flutter) update changelog and version * chore(localizations): update changelog and versions * chore(llc,core,ui,persistence,localizations): update changelog and pubspec * chore(llc,core,ui,persistence,localizations): update changelog and pubspec * fix example * fix deps * fix(ui): fix enter sending the message while in slowmode * fix(ui): fix reaction list * fix web * fix(ui): fix image download when using assetUrl * fix(ui): fix typo in changelog * fix(ui): fix gallery navigation * fix(ui): fix giphy sending size * fix(ui): add keyboard shorcuts to gallery view * fix(ui): fix gallery footer * fix(core): allow scroll notification bubbling in lazy load scroll view * fix(ui): fix lazy load scroll view * fix(ui): fix loading to unread position * fix(ui): fix last message preview * chore(ui): update changelog * fix tests * fix(llc): fix connecting while connecting and disconneting (#1237) * fix(llc): fix connecting while connecting and disconneting * chore(llc): update changelog * chore(llc): add test * chore(llc): improve test * fix(llc): _resetRequestFlags regardless of the connection status * feat(llc): add support for AttachmentFileUploaderProvider. (#1246) * feat(llc): add support for `AttachmentFileUploaderProvider`. Signed-off-by: xsahil03x <[email protected]> * chore(llc): update CHANGELOG.md Signed-off-by: xsahil03x <[email protected]> * refactor(llc): Remove deprecated attachmentFileUploader field. Signed-off-by: xsahil03x <[email protected]> * port no localization * fix(ui): fix jiffy locale initialization (#1255) * fix(ui): fix jiffy locale initialization * chore(ui): update changelog * fix(ui): cleanup * chore(llc,core,ui,persistence,localizations): align changelog * chore(llc,core,ui,persistence,localizations): add links to 4.x changelogs * fix(ui): fix date divider not showing (#1259) * fix(ui): fix date divider not showing * chore(ui): update changelog * chore(ui): minor changes Signed-off-by: xsahil03x <[email protected]> * fix(ui): fix separator Signed-off-by: xsahil03x <[email protected]> Co-authored-by: Sahil Kumar <[email protected]> * chore(ui): add contextmenu requirements for web (#1261) * fix unread separator * feat(ui): `StreamChatConfiguration` class (#1128) * feat: StreamChatConfiguration class See #1125 for more details * chore(flutter): update changelog * fix analysis * cleanup * feat(ui): add enforceUniqueReactions to `StreamChatConfiguration` widget * add tests * refactor(ui): rename StreamChatConfigurationProvider and StreamChatConfiguration * refactor(ui): rename StreamChat.config to streamChatConfigData * refactor(ui): make enforceUniqueReactions non nullable * chore(ui): fix analysis errors * refactor(ui): make config fields non nullable * chore(ui): format Co-authored-by: Salvatore Giordano <[email protected]> * feat(ui, core): add StreamAutocomplete (#1263) * feat(ui, core): add stream_autocomplete.dart Signed-off-by: xsahil03x <[email protected]> * refactor(core, ui): refactor message input controller Signed-off-by: xsahil03x <[email protected]> * fix(ui): minor fixes Signed-off-by: xsahil03x <[email protected]> * feat(ui): add StreamAutocompleteOptions widget Signed-off-by: xsahil03x <[email protected]> * Update packages/stream_chat_flutter/lib/src/autocomplete/stream_autocomplete.dart * fix(ui): cleanup * chore(core, ui): cleanup Signed-off-by: xsahil03x <[email protected]> * chore(ui): ignore no empty block * chore(ui): more cleanup Signed-off-by: xsahil03x <[email protected]> * fix(ui): fix command button for bigger screens, other fixes. Signed-off-by: xsahil03x <[email protected]> Co-authored-by: Salvatore Giordano <[email protected]> * fix(ui): increase default file limit to 100MB (#1279) * fix(ui): increase default file limit to 100MB * Update packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart * fix(ui): export kDefaultMaxAttachmentSize and use it in `StreamAttachmentPicker` default * feat(ui): show dialog after clicking on the camera button and permission is denied (#1262) * feat(ui): show dialog after clicking on the camera button and permission is denied * chore(ui): update changelog * Remove permission_handler plugin and refactor * cleanup * cleanup * add tests * fix(ui): reset the permission state on resume * cleanup * fix concurrent calls * fix(ui): cleanup * fix(ui): add try catch * fix(llc, ui): copy file to tempdir before uploading a file and do not serialize bytes (#1285) * fix(llc, ui): copy file to tempdir before uploading a file and do not serialize bytes * fix(llc): do not save failed messages with attachments on web * fix(llc): replace every with any * chore(llc): add test * fix analysis * fix analysis * chore(llc,core,ui,persistence): align changelog with v4 * fix(ui): do not linkify system message mentions * chore(llc,core,ui,persistence,localizations): update changelog and pubspec (#1289) * chore(ui): update deps * chore(llc,core,ui,persistence,localizations): update changelog and pubspec * chore(ui): publish beta.2 * feat(core,ui): add StreamMemberListView, StreamMemberGridView, and StreamMemberListController (#1291) * feat(core,ui): add StreamMemberListView, StreamMemberGridView, and StreamMemberListController * chore(core,ui): update changelog * fix(ui): fix exports * chore(ui): add tests * chore(ui): add StreamMemberGridTile typedef * chore(ui): minor changes Signed-off-by: xsahil03x <[email protected]> Co-authored-by: Sahil Kumar <[email protected]> * fix(ui): do not send empty message * refactor(ui,localization): removed emoji picker and connected (#1292) * refactor(ui,localization): removed emoji picker and connected * chore(ui,localizations): update changelog * chore(repo): update ui coverage threashold * doc(ui): add autocomplete_triggers guide * chore(ui): add tests for isOnlyEmoji * chore(ui): add tests for isOnlyEmoji * feat(ui): add `StreamAutocompleteTrigger.triggerOnlyAfterSpace` * fix(llc): Fix `Channel.removeMessage` not able to remove thread message. (#1294) Signed-off-by: xsahil03x <[email protected]> * chore(ui): flutter format Signed-off-by: xsahil03x <[email protected]> * fix(ui): fix `stopTyping` unhandled exceptions when network is off or spotty. (#1296) * feat(llc, ui): Introduce `keyStrokeHandler` to properly handle keyStrokes. Signed-off-by: xsahil03x <[email protected]> * chore(ui): update CHANGELOG.md Signed-off-by: xsahil03x <[email protected]> * test(llc): add key_stroke_handler_test.dart Signed-off-by: xsahil03x <[email protected]> * chore(ui): update CHANGELOG.md Signed-off-by: xsahil03x <[email protected]> * fix(core): only handle the channel list event if the state is success. Signed-off-by: xsahil03x <[email protected]> * feat(llc): add `hide_history` flag in `channel.addMembers`. Signed-off-by: xsahil03x <[email protected]> * refactor(llc): Convert positional argument to optional arguments Signed-off-by: xsahil03x <[email protected]> * test(llc): update tests Signed-off-by: xsahil03x <[email protected]> * perf: improve image resizing * fix(core): use copyWith while setting controller items. Signed-off-by: xsahil03x <[email protected]> * feat(ui): Added `thumbnailSize`, `thumbnailResizeType`, and `thumbnailCropType` params to `StreamMessageWidget` and `StreamAttachmentPicker` to customize the appearance of image thumbnails. Signed-off-by: xsahil03x <[email protected]> * fix analysis * Release v4.5.0 (#1308) * chore(llc, ui, core): bump version to v4.5.0 Signed-off-by: xsahil03x <[email protected]> * chore(ui, core): update CHANGELOG.md Signed-off-by: xsahil03x <[email protected]> Signed-off-by: xsahil03x <[email protected]> * third party video integration * fix llc * refactor: clean up and add docs * chore(llc): update changelog * chore(llc): add tests * chore(llc): add more tests * fix analysis * chore(llc): add more tests * chore(llc): add more tests * fix(llc): only start listening to event once connected. Signed-off-by: xsahil03x <[email protected]> * chore(llc): update CHANGELOG.md Signed-off-by: xsahil03x <[email protected]> * chore(llc): move files to correct folders. Signed-off-by: xsahil03x <[email protected]> * fix(ui): Fix message text hiding because of a flutter bug (#1327) * fix(ui): remove clip * chore(ui): update changelog * cleanup * fix(ui): hitting "enter" on the android keyboard sends the message instead of going to a new line. Signed-off-by: xsahil03x <[email protected]> * feat(ui): add support for additional text field params in message input. Signed-off-by: xsahil03x <[email protected]> * fix analysis * remove print * chore(llc,core,ui): update pubspec and changelogs (#1333) * fix(ui): show placeholder for web video thumbs * fix file download on web * fix reply and show buttons * remove tests * treat svg as files * fix(ui): change default image resize to clip * solve some todos * polish v5 fixing analysis issues * feat(ui): stream attachment picker bottom sheet (#1336) * refactor(ui): improve stream_attachment_handler.dart, add stream_attachment_picker_bottom_sheet.dart Signed-off-by: xsahil03x <[email protected]> * feat(ui): add copyWith in command_button.dart Signed-off-by: xsahil03x <[email protected]> * chore(ui): remove permission_manager.dart Signed-off-by: xsahil03x <[email protected]> * fix(ui): some fixes Signed-off-by: xsahil03x <[email protected]> * fix(ui): more fixes Signed-off-by: xsahil03x <[email protected]> * chore(ui): fix lints, add doc comments Signed-off-by: xsahil03x <[email protected]> * chore(ci): update flutter version Signed-off-by: xsahil03x <[email protected]> * chore(ui): more fixes Signed-off-by: xsahil03x <[email protected]> * chore(ui): more fixes Signed-off-by: xsahil03x <[email protected]> * chore(ui): more fixes Signed-off-by: xsahil03x <[email protected]> * chore(ci): update melos and flutter version Signed-off-by: xsahil03x <[email protected]> Signed-off-by: xsahil03x <[email protected]> Co-authored-by: Salvatore Giordano <[email protected]> * remove todo * update changelog * update changelogs * revert * update coverage threasholds Signed-off-by: xsahil03x <[email protected]> Co-authored-by: GroovinChip <[email protected]> Co-authored-by: Sahil Kumar <[email protected]> Co-authored-by: Gordon Hayes <[email protected]> Co-authored-by: Sacha Arbonel <[email protected]>
This commit is contained in:
co-authored by
Sahil Kumar
GroovinChip
Gordon Hayes
Sacha Arbonel
parent
3e1d6e91c7
commit
4f2542291c
@@ -1,5 +1,7 @@
|
||||
export 'attachment_error.dart';
|
||||
export 'attachment_error.dart';
|
||||
export 'attachment_upload_state_builder.dart';
|
||||
export 'attachment_widget.dart' show AttachmentError, AttachmentSource;
|
||||
export 'attachment_widget.dart' show AttachmentSource;
|
||||
export 'file_attachment.dart';
|
||||
export 'giphy_attachment.dart';
|
||||
export 'image_attachment.dart';
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@template attachmentError}
|
||||
/// Widget for building in case of error
|
||||
/// {@endtemplate}
|
||||
class AttachmentError extends StatelessWidget {
|
||||
/// {@macro attachmentError}
|
||||
const AttachmentError({
|
||||
super.key,
|
||||
this.constraints,
|
||||
});
|
||||
|
||||
/// constraints of error
|
||||
final BoxConstraints? constraints;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Container(
|
||||
constraints: constraints ?? const BoxConstraints.expand(),
|
||||
color:
|
||||
StreamChatTheme.of(context).colorTheme.accentError.withOpacity(0.1),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.error_outline,
|
||||
color: StreamChatTheme.of(context).colorTheme.textHighEmphasis,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@macro attachment_title}
|
||||
@Deprecated("Use 'StreamAttachmentTitle' instead")
|
||||
typedef AttachmentTitle = StreamAttachmentTitle;
|
||||
|
||||
/// {@template attachment_title}
|
||||
/// {@template attachmentTitle}
|
||||
/// Title for attachments
|
||||
/// {@endtemplate}
|
||||
class StreamAttachmentTitle extends StatelessWidget {
|
||||
/// Supply attachment and theme for constructing title
|
||||
/// {@macro attachmentTitle}
|
||||
const StreamAttachmentTitle({
|
||||
super.key,
|
||||
required this.attachment,
|
||||
|
||||
+58
-61
@@ -1,50 +1,39 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/extension.dart';
|
||||
import 'package:stream_chat_flutter/src/upload_progress_indicator.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/utils.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// Widget to build in progress
|
||||
typedef InProgressBuilder = Widget Function(BuildContext, int, int);
|
||||
|
||||
/// Widget to build on failure
|
||||
typedef FailedBuilder = Widget Function(BuildContext, String);
|
||||
|
||||
/// {@macro attachment_upload_state_builder}
|
||||
@Deprecated("Use 'StreamAttachmentsUploadStateBuilder' instead")
|
||||
typedef AttachmentUploadStateBuilder = StreamAttachmentUploadStateBuilder;
|
||||
|
||||
/// {@template attachment_upload_state_builder}
|
||||
/// {@template streamAttachmentUploadStateBuilder}
|
||||
/// Widget to display attachment upload state
|
||||
/// {@endtemplate}
|
||||
class StreamAttachmentUploadStateBuilder extends StatelessWidget {
|
||||
/// Constructor for creating an [StreamAttachmentUploadStateBuilder] widget
|
||||
/// {@macro streamAttachmentUploadStateBuilder}
|
||||
const StreamAttachmentUploadStateBuilder({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.attachment,
|
||||
this.failedBuilder,
|
||||
this.successBuilder,
|
||||
this.inProgressBuilder,
|
||||
this.preparingBuilder,
|
||||
this.inProgressBuilder,
|
||||
this.successBuilder,
|
||||
this.failedBuilder,
|
||||
});
|
||||
|
||||
/// Message which attachment is added to
|
||||
/// The message that [attachment] is associated with
|
||||
final Message message;
|
||||
|
||||
/// Attachment in concern
|
||||
/// The attachment currently being handled
|
||||
final Attachment attachment;
|
||||
|
||||
/// Widget to display when failed
|
||||
final FailedBuilder? failedBuilder;
|
||||
/// Widget to display when preparing to upload the [attachment]
|
||||
final PreparingBuilder? preparingBuilder;
|
||||
|
||||
/// Widget to display when succeeded
|
||||
final WidgetBuilder? successBuilder;
|
||||
|
||||
/// Widget to display when in progress
|
||||
/// {@macro inProgressBuilder}
|
||||
final InProgressBuilder? inProgressBuilder;
|
||||
|
||||
/// Widget to display when in prep
|
||||
final WidgetBuilder? preparingBuilder;
|
||||
/// {@macro successBuilder}
|
||||
final SuccessBuilder? successBuilder;
|
||||
|
||||
/// {@macro failedBuilder}
|
||||
final FailedBuilder? failedBuilder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -56,18 +45,22 @@ class StreamAttachmentUploadStateBuilder extends StatelessWidget {
|
||||
final attachmentId = attachment.id;
|
||||
|
||||
final inProgress = inProgressBuilder ??
|
||||
(context, int sent, int total) => _InProgressState(
|
||||
sent: sent,
|
||||
total: total,
|
||||
attachmentId: attachmentId,
|
||||
);
|
||||
(context, int sent, int total) {
|
||||
return _InProgressState(
|
||||
sent: sent,
|
||||
total: total,
|
||||
attachmentId: attachmentId,
|
||||
);
|
||||
};
|
||||
|
||||
final failed = failedBuilder ??
|
||||
(context, error) => _FailedState(
|
||||
error: error,
|
||||
messageId: messageId,
|
||||
attachmentId: attachmentId,
|
||||
);
|
||||
(context, error) {
|
||||
return _FailedState(
|
||||
error: error,
|
||||
messageId: messageId,
|
||||
attachmentId: attachmentId,
|
||||
);
|
||||
};
|
||||
|
||||
final success = successBuilder ?? (context) => _SuccessState();
|
||||
|
||||
@@ -93,22 +86,24 @@ class _IconButton extends StatelessWidget {
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: RawMaterialButton(
|
||||
elevation: 0,
|
||||
highlightElevation: 0,
|
||||
focusElevation: 0,
|
||||
hoverElevation: 0,
|
||||
onPressed: onPressed,
|
||||
fillColor: StreamChatTheme.of(context).colorTheme.overlayDark,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: icon,
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: RawMaterialButton(
|
||||
elevation: 0,
|
||||
highlightElevation: 0,
|
||||
focusElevation: 0,
|
||||
hoverElevation: 0,
|
||||
onPressed: onPressed,
|
||||
fillColor: StreamChatTheme.of(context).colorTheme.overlayDark,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
);
|
||||
child: icon,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PreparingState extends StatelessWidget {
|
||||
@@ -237,14 +232,16 @@ class _FailedState extends StatelessWidget {
|
||||
|
||||
class _SuccessState extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: CircleAvatar(
|
||||
backgroundColor: StreamChatTheme.of(context).colorTheme.overlayDark,
|
||||
maxRadius: 12,
|
||||
child: StreamSvgIcon.check(
|
||||
color: StreamChatTheme.of(context).colorTheme.barsBg,
|
||||
),
|
||||
Widget build(BuildContext context) {
|
||||
return Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: CircleAvatar(
|
||||
backgroundColor: StreamChatTheme.of(context).colorTheme.overlayDark,
|
||||
maxRadius: 12,
|
||||
child: StreamSvgIcon.check(
|
||||
color: StreamChatTheme.of(context).colorTheme.barsBg,
|
||||
),
|
||||
);
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,31 +24,28 @@ enum AttachmentSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// {@macro attachment_widget}
|
||||
@Deprecated("Use 'StreamAttachmentWidget' instead")
|
||||
typedef AttachmentWidget = StreamAttachmentWidget;
|
||||
|
||||
/// {@template attachment_widget}
|
||||
/// {@template streamAttachmentWidget}
|
||||
/// Abstract class for deriving attachment types
|
||||
/// {@endtemplate}
|
||||
abstract class StreamAttachmentWidget extends StatelessWidget {
|
||||
/// Constructor for creating attachment widget
|
||||
/// {@macro streamAttachmentWidget}
|
||||
const StreamAttachmentWidget({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.attachment,
|
||||
this.size,
|
||||
this.constraints,
|
||||
AttachmentSource? source,
|
||||
}) : _source = source;
|
||||
|
||||
/// Size of attachments
|
||||
final Size? size;
|
||||
/// Contraints of attachments
|
||||
final BoxConstraints? constraints;
|
||||
|
||||
final AttachmentSource? _source;
|
||||
|
||||
/// Message which attachment is attached to
|
||||
/// The message that [attachment] is associated with
|
||||
final Message message;
|
||||
|
||||
/// Attachment to display
|
||||
/// The [Attachment] to display
|
||||
final Attachment attachment;
|
||||
|
||||
/// Getter for source of attachment
|
||||
@@ -58,33 +55,3 @@ abstract class StreamAttachmentWidget extends StatelessWidget {
|
||||
? AttachmentSource.local
|
||||
: AttachmentSource.network);
|
||||
}
|
||||
|
||||
/// Widget for building in case of error
|
||||
class AttachmentError extends StatelessWidget {
|
||||
/// Constructor for creating AttachmentError
|
||||
const AttachmentError({
|
||||
super.key,
|
||||
this.size,
|
||||
});
|
||||
|
||||
/// Size of error
|
||||
final Size? size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Center(
|
||||
child: Container(
|
||||
width: size?.width,
|
||||
height: size?.height,
|
||||
color: StreamChatTheme.of(context)
|
||||
.colorTheme
|
||||
.accentError
|
||||
.withOpacity(0.1),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.error_outline,
|
||||
color: StreamChatTheme.of(context).colorTheme.textHighEmphasis,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,46 +2,45 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
import 'package:stream_chat_flutter/src/attachment/attachment_widget.dart';
|
||||
import 'package:stream_chat_flutter/src/extension.dart';
|
||||
import 'package:stream_chat_flutter/src/stream_chat_theme.dart';
|
||||
import 'package:stream_chat_flutter/src/stream_svg_icon.dart';
|
||||
import 'package:stream_chat_flutter/src/upload_progress_indicator.dart';
|
||||
import 'package:stream_chat_flutter/src/utils.dart';
|
||||
import 'package:stream_chat_flutter/src/video_thumbnail_image.dart';
|
||||
import 'package:stream_chat_flutter/src/attachment/handler/stream_attachment_handler.dart';
|
||||
import 'package:stream_chat_flutter/src/indicators/upload_progress_indicator.dart';
|
||||
import 'package:stream_chat_flutter/src/misc/stream_svg_icon.dart';
|
||||
import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/utils.dart';
|
||||
import 'package:stream_chat_flutter/src/video/video_thumbnail_image.dart';
|
||||
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
|
||||
|
||||
/// {@macro file_attachment}
|
||||
@Deprecated("Use 'StreamFileAttachment' instead")
|
||||
typedef FileAttachment = StreamFileAttachment;
|
||||
|
||||
/// {@template file_attachment}
|
||||
/// Widget for displaying file attachments
|
||||
/// {@template streamFileAttachment}
|
||||
/// Displays file attachments that have been sent in a chat.
|
||||
///
|
||||
/// Used in [MessageWidget].
|
||||
/// {@endtemplate}
|
||||
class StreamFileAttachment extends StreamAttachmentWidget {
|
||||
/// Constructor for creating a widget when attachment is of type 'file'
|
||||
/// {@macro streamFileAttachment}
|
||||
const StreamFileAttachment({
|
||||
super.key,
|
||||
required super.message,
|
||||
required super.attachment,
|
||||
super.size,
|
||||
super.constraints,
|
||||
this.title,
|
||||
this.trailing,
|
||||
this.onAttachmentTap,
|
||||
});
|
||||
|
||||
/// Title for attachment
|
||||
/// Title for the attachment
|
||||
final Widget? title;
|
||||
|
||||
/// Widget for displaying at the end of attachment (such as a download button)
|
||||
/// Widget for displaying at the end of the attachment
|
||||
/// (such as a download button)
|
||||
final Widget? trailing;
|
||||
|
||||
/// Callback called when attachment widget is tapped
|
||||
final VoidCallback? onAttachmentTap;
|
||||
/// {@macro onAttachmentTap}
|
||||
final OnAttachmentTap? onAttachmentTap;
|
||||
|
||||
/// Check if attachment is a video
|
||||
/// Checks if the attachment is a video
|
||||
bool get isVideoAttachment => attachment.title?.mimeType?.type == 'video';
|
||||
|
||||
/// Check if attachment is an image
|
||||
/// Checks if the attachment is an image
|
||||
bool get isImageAttachment => attachment.title?.mimeType?.type == 'image';
|
||||
|
||||
@override
|
||||
@@ -51,7 +50,7 @@ class StreamFileAttachment extends StreamAttachmentWidget {
|
||||
child: GestureDetector(
|
||||
onTap: onAttachmentTap,
|
||||
child: Container(
|
||||
width: size?.width ?? 100,
|
||||
constraints: constraints ?? const BoxConstraints.tightFor(width: 100),
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: colorTheme.barsBg,
|
||||
@@ -67,7 +66,12 @@ class StreamFileAttachment extends StreamAttachmentWidget {
|
||||
height: 40,
|
||||
width: 33.33,
|
||||
margin: const EdgeInsets.all(8),
|
||||
child: _getFileTypeImage(context),
|
||||
child: _FileTypeImage(
|
||||
isImageAttachment: isImageAttachment,
|
||||
isVideoAttachment: isVideoAttachment,
|
||||
source: source,
|
||||
attachment: attachment,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
@@ -82,27 +86,51 @@ class StreamFileAttachment extends StreamAttachmentWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
_buildSubtitle(context),
|
||||
_FileAttachmentSubtitle(attachment: attachment),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildTrailing(context),
|
||||
Material(
|
||||
type: MaterialType.transparency,
|
||||
child: trailing ??
|
||||
_Trailing(
|
||||
attachment: attachment,
|
||||
message: message,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ShapeBorder _getDefaultShape(BuildContext context) => RoundedRectangleBorder(
|
||||
side: const BorderSide(width: 0, color: Colors.transparent),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
);
|
||||
class _FileTypeImage extends StatelessWidget {
|
||||
const _FileTypeImage({
|
||||
required this.isImageAttachment,
|
||||
required this.isVideoAttachment,
|
||||
required this.source,
|
||||
required this.attachment,
|
||||
});
|
||||
|
||||
// TODO: Improve image memory. This is using the full image instead of a
|
||||
// smaller version (thumbnail)
|
||||
Widget _getFileTypeImage(BuildContext context) {
|
||||
final bool isImageAttachment;
|
||||
final bool isVideoAttachment;
|
||||
final AttachmentSource source;
|
||||
final Attachment attachment;
|
||||
|
||||
ShapeBorder _getDefaultShape(BuildContext context) {
|
||||
return RoundedRectangleBorder(
|
||||
side: const BorderSide(width: 0, color: Colors.transparent),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Improve image memory.
|
||||
// This is using the full image instead of a smaller version (thumbnail)
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isImageAttachment) {
|
||||
return Material(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
@@ -111,13 +139,16 @@ class StreamFileAttachment extends StreamAttachmentWidget {
|
||||
child: source.when(
|
||||
local: () {
|
||||
if (attachment.file?.bytes == null) {
|
||||
return getFileTypeImage(attachment.extraData['other'] as String?);
|
||||
return getFileTypeImage(
|
||||
attachment.extraData['mime_type'] as String?,
|
||||
);
|
||||
}
|
||||
return Image.memory(
|
||||
attachment.file!.bytes!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, obj, trace) =>
|
||||
getFileTypeImage(attachment.extraData['other'] as String?),
|
||||
errorBuilder: (_, obj, trace) => getFileTypeImage(
|
||||
attachment.extraData['mime_type'] as String?,
|
||||
),
|
||||
);
|
||||
},
|
||||
network: () {
|
||||
@@ -125,15 +156,18 @@ class StreamFileAttachment extends StreamAttachmentWidget {
|
||||
attachment.assetUrl ??
|
||||
attachment.thumbUrl) ==
|
||||
null) {
|
||||
return getFileTypeImage(attachment.extraData['other'] as String?);
|
||||
return getFileTypeImage(
|
||||
attachment.extraData['mime_type'] as String?,
|
||||
);
|
||||
}
|
||||
return CachedNetworkImage(
|
||||
imageUrl: attachment.imageUrl ??
|
||||
attachment.assetUrl ??
|
||||
attachment.thumbUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, obj, trace) =>
|
||||
getFileTypeImage(attachment.extraData['other'] as String?),
|
||||
errorWidget: (_, obj, trace) => getFileTypeImage(
|
||||
attachment.extraData['mime_type'] as String?,
|
||||
),
|
||||
placeholder: (_, __) {
|
||||
final image = Image.asset(
|
||||
'images/placeholder.png',
|
||||
@@ -187,38 +221,47 @@ class StreamFileAttachment extends StreamAttachmentWidget {
|
||||
}
|
||||
return getFileTypeImage(attachment.extraData['mime_type'] as String?);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildButton({
|
||||
Widget? icon,
|
||||
double iconSize = 24.0,
|
||||
VoidCallback? onPressed,
|
||||
Color? fillColor,
|
||||
}) =>
|
||||
SizedBox(
|
||||
height: iconSize,
|
||||
width: iconSize,
|
||||
child: RawMaterialButton(
|
||||
elevation: 0,
|
||||
highlightElevation: 0,
|
||||
focusElevation: 0,
|
||||
hoverElevation: 0,
|
||||
onPressed: onPressed,
|
||||
fillColor: fillColor,
|
||||
shape:
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: icon,
|
||||
),
|
||||
);
|
||||
class _Trailing extends StatelessWidget {
|
||||
const _Trailing({
|
||||
required this.attachment,
|
||||
required this.message,
|
||||
});
|
||||
|
||||
Widget _buildTrailing(BuildContext context) {
|
||||
final Attachment attachment;
|
||||
final Message message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = StreamChatTheme.of(context);
|
||||
final channel = StreamChannel.of(context).channel;
|
||||
final attachmentId = attachment.id;
|
||||
var trailingWidget = trailing;
|
||||
trailingWidget ??= attachment.uploadState.when(
|
||||
|
||||
if (message.status == MessageSendingStatus.sent) {
|
||||
return IconButton(
|
||||
icon: StreamSvgIcon.cloudDownload(
|
||||
color: theme.colorTheme.textHighEmphasis,
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
splashRadius: 16,
|
||||
onPressed: () async {
|
||||
final assetUrl = attachment.assetUrl;
|
||||
if (assetUrl != null) {
|
||||
if (isMobileDeviceOrWeb) {
|
||||
launchURL(context, assetUrl);
|
||||
} else {
|
||||
StreamAttachmentHandler.instance.downloadAttachment(attachment);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return attachment.uploadState.when(
|
||||
preparing: () => Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: _buildButton(
|
||||
child: _TrailingButton(
|
||||
icon: StreamSvgIcon.close(color: theme.colorTheme.barsBg),
|
||||
fillColor: theme.colorTheme.overlayDark,
|
||||
onPressed: () => channel.cancelAttachmentUpload(attachmentId),
|
||||
@@ -226,7 +269,7 @@ class StreamFileAttachment extends StreamAttachmentWidget {
|
||||
),
|
||||
inProgress: (_, __) => Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: _buildButton(
|
||||
child: _TrailingButton(
|
||||
icon: StreamSvgIcon.close(color: theme.colorTheme.barsBg),
|
||||
fillColor: theme.colorTheme.overlayDark,
|
||||
onPressed: () => channel.cancelAttachmentUpload(attachmentId),
|
||||
@@ -242,7 +285,7 @@ class StreamFileAttachment extends StreamAttachmentWidget {
|
||||
),
|
||||
failed: (_) => Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: _buildButton(
|
||||
child: _TrailingButton(
|
||||
icon: StreamSvgIcon.retry(color: theme.colorTheme.barsBg),
|
||||
fillColor: theme.colorTheme.overlayDark,
|
||||
onPressed: () => channel.retryAttachmentUpload(
|
||||
@@ -252,28 +295,48 @@ class StreamFileAttachment extends StreamAttachmentWidget {
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (message.status == MessageSendingStatus.sent) {
|
||||
trailingWidget = IconButton(
|
||||
icon: StreamSvgIcon.cloudDownload(
|
||||
color: theme.colorTheme.textHighEmphasis,
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
splashRadius: 16,
|
||||
onPressed: () {
|
||||
final assetUrl = attachment.assetUrl;
|
||||
if (assetUrl != null) launchURL(context, assetUrl);
|
||||
},
|
||||
);
|
||||
}
|
||||
class _TrailingButton extends StatelessWidget {
|
||||
const _TrailingButton({
|
||||
this.onPressed,
|
||||
this.fillColor,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: trailingWidget,
|
||||
final VoidCallback? onPressed;
|
||||
final Color? fillColor;
|
||||
final Widget? icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: RawMaterialButton(
|
||||
elevation: 0,
|
||||
highlightElevation: 0,
|
||||
focusElevation: 0,
|
||||
hoverElevation: 0,
|
||||
onPressed: onPressed,
|
||||
fillColor: fillColor,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: icon,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildSubtitle(BuildContext context) {
|
||||
class _FileAttachmentSubtitle extends StatelessWidget {
|
||||
const _FileAttachmentSubtitle({
|
||||
required this.attachment,
|
||||
});
|
||||
|
||||
final Attachment attachment;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = StreamChatTheme.of(context);
|
||||
final size = attachment.file?.size ?? attachment.extraData['file_size'];
|
||||
final textStyle = theme.textTheme.footnote.copyWith(
|
||||
|
||||
@@ -2,36 +2,32 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
import 'package:stream_chat_flutter/src/attachment/attachment_widget.dart';
|
||||
import 'package:stream_chat_flutter/src/extension.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/utils.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@macro giphy_attachment}
|
||||
@Deprecated("Use 'StreamGiphyAttachment' instead")
|
||||
typedef GiphyAttachment = StreamGiphyAttachment;
|
||||
|
||||
/// {@template giphy_attachment}
|
||||
/// Widget for showing a GIF attachment
|
||||
/// {@template streamGiphyAttachment}
|
||||
/// Shows a GIF attachment in a [StreamMessageWidget].
|
||||
/// {@endtemplate}
|
||||
class StreamGiphyAttachment extends StreamAttachmentWidget {
|
||||
/// Constructor for creating a [StreamGiphyAttachment] widget
|
||||
/// {@macro streamGiphyAttachment}
|
||||
const StreamGiphyAttachment({
|
||||
super.key,
|
||||
required super.message,
|
||||
required super.attachment,
|
||||
super.size,
|
||||
super.constraints,
|
||||
this.onShowMessage,
|
||||
this.onReturnAction,
|
||||
this.onReplyMessage,
|
||||
this.onAttachmentTap,
|
||||
});
|
||||
|
||||
/// Callback when show message is tapped
|
||||
/// {@macro showMessageCallback}
|
||||
final ShowMessageCallback? onShowMessage;
|
||||
|
||||
/// Callback when attachment is returned to from other screens
|
||||
final ValueChanged<ReturnActionType>? onReturnAction;
|
||||
/// {@macro replyMessageCallback}
|
||||
final ReplyMessageCallback? onReplyMessage;
|
||||
|
||||
/// Callback when attachment is tapped
|
||||
final VoidCallback? onAttachmentTap;
|
||||
/// {@macro onAttachmentTap}
|
||||
final OnAttachmentTap? onAttachmentTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -48,287 +44,301 @@ class StreamGiphyAttachment extends StreamAttachmentWidget {
|
||||
|
||||
Widget _buildSendingAttachment(BuildContext context, String imageUrl) {
|
||||
final streamChannel = StreamChannel.of(context);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Card(
|
||||
color: StreamChatTheme.of(context).colorTheme.barsBg,
|
||||
elevation: 2,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topRight: Radius.circular(16),
|
||||
topLeft: Radius.circular(16),
|
||||
bottomLeft: Radius.circular(16),
|
||||
return ConstrainedBox(
|
||||
constraints: constraints?.copyWith(
|
||||
maxHeight: double.infinity,
|
||||
) ??
|
||||
const BoxConstraints.expand(),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Card(
|
||||
color: StreamChatTheme.of(context).colorTheme.barsBg,
|
||||
elevation: 2,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topRight: Radius.circular(16),
|
||||
topLeft: Radius.circular(16),
|
||||
bottomLeft: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
children: [
|
||||
StreamSvgIcon.giphyIcon(),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
context.translations.giphyLabel,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (attachment.title != null)
|
||||
Flexible(
|
||||
child: Text(
|
||||
attachment.title!,
|
||||
style: TextStyle(
|
||||
color: StreamChatTheme.of(context)
|
||||
.colorTheme
|
||||
.textHighEmphasis
|
||||
.withOpacity(0.5),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
children: [
|
||||
StreamSvgIcon.giphyIcon(),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
context.translations.giphyLabel,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (attachment.title != null)
|
||||
Flexible(
|
||||
child: Text(
|
||||
attachment.title!,
|
||||
style: TextStyle(
|
||||
color: StreamChatTheme.of(context)
|
||||
.colorTheme
|
||||
.textHighEmphasis
|
||||
.withOpacity(0.5),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (onAttachmentTap != null) {
|
||||
onAttachmentTap?.call();
|
||||
} else {
|
||||
_onImageTap(context);
|
||||
}
|
||||
},
|
||||
child: CachedNetworkImage(
|
||||
height: constraints?.maxHeight,
|
||||
width: constraints?.maxWidth,
|
||||
placeholder: (_, __) => SizedBox(
|
||||
width: constraints?.maxHeight,
|
||||
height: constraints?.maxWidth,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
imageUrl: imageUrl,
|
||||
errorWidget: (context, url, error) => AttachmentError(
|
||||
constraints: constraints,
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
color: StreamChatTheme.of(context)
|
||||
.colorTheme
|
||||
.textHighEmphasis
|
||||
.withOpacity(0.2),
|
||||
width: double.infinity,
|
||||
height: 0.5,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 50,
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
streamChannel.channel.sendAction(
|
||||
message,
|
||||
{
|
||||
'image_action': 'cancel',
|
||||
},
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
context.translations.cancelLabel
|
||||
.toLowerCase()
|
||||
.capitalize(),
|
||||
style: StreamChatTheme.of(context)
|
||||
.textTheme
|
||||
.bodyBold
|
||||
.copyWith(
|
||||
color: StreamChatTheme.of(context)
|
||||
.colorTheme
|
||||
.textHighEmphasis
|
||||
.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 0.5,
|
||||
color: StreamChatTheme.of(context)
|
||||
.colorTheme
|
||||
.textHighEmphasis
|
||||
.withOpacity(0.2),
|
||||
height: 50,
|
||||
),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 50,
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
streamChannel.channel.sendAction(
|
||||
message,
|
||||
{
|
||||
'image_action': 'shuffle',
|
||||
},
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
context.translations.shuffleLabel,
|
||||
style: StreamChatTheme.of(context)
|
||||
.textTheme
|
||||
.bodyBold
|
||||
.copyWith(
|
||||
color: StreamChatTheme.of(context)
|
||||
.colorTheme
|
||||
.textHighEmphasis
|
||||
.withOpacity(0.5),
|
||||
),
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 0.5,
|
||||
color: StreamChatTheme.of(context)
|
||||
.colorTheme
|
||||
.textHighEmphasis
|
||||
.withOpacity(0.2),
|
||||
height: 50,
|
||||
),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 50,
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
streamChannel.channel.sendAction(
|
||||
message,
|
||||
{
|
||||
'image_action': 'send',
|
||||
},
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
context.translations.sendLabel,
|
||||
style: TextStyle(
|
||||
color: StreamChatTheme.of(context)
|
||||
.colorTheme
|
||||
.accentPrimary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (onAttachmentTap != null) {
|
||||
onAttachmentTap?.call();
|
||||
} else {
|
||||
_onImageTap(context);
|
||||
}
|
||||
},
|
||||
child: CachedNetworkImage(
|
||||
height: size?.height,
|
||||
width: size?.width,
|
||||
placeholder: (_, __) => SizedBox(
|
||||
width: size?.width,
|
||||
height: size?.height,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
imageUrl: imageUrl,
|
||||
errorWidget: (context, url, error) =>
|
||||
AttachmentError(size: size),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
color: StreamChatTheme.of(context)
|
||||
.colorTheme
|
||||
.textHighEmphasis
|
||||
.withOpacity(0.2),
|
||||
width: double.infinity,
|
||||
height: 0.5,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 50,
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
streamChannel.channel.sendAction(message, {
|
||||
'image_action': 'cancel',
|
||||
});
|
||||
},
|
||||
child: Text(
|
||||
context.translations.cancelLabel
|
||||
.toLowerCase()
|
||||
.capitalize(),
|
||||
style: StreamChatTheme.of(context)
|
||||
.textTheme
|
||||
.bodyBold
|
||||
.copyWith(
|
||||
color: StreamChatTheme.of(context)
|
||||
.colorTheme
|
||||
.textHighEmphasis
|
||||
.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 0.5,
|
||||
color: StreamChatTheme.of(context)
|
||||
.colorTheme
|
||||
.textHighEmphasis
|
||||
.withOpacity(0.2),
|
||||
height: 50,
|
||||
),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 50,
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
streamChannel.channel.sendAction(message, {
|
||||
'image_action': 'shuffle',
|
||||
});
|
||||
},
|
||||
child: Text(
|
||||
context.translations.shuffleLabel,
|
||||
style: StreamChatTheme.of(context)
|
||||
.textTheme
|
||||
.bodyBold
|
||||
.copyWith(
|
||||
color: StreamChatTheme.of(context)
|
||||
.colorTheme
|
||||
.textHighEmphasis
|
||||
.withOpacity(0.5),
|
||||
),
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 0.5,
|
||||
color: StreamChatTheme.of(context)
|
||||
.colorTheme
|
||||
.textHighEmphasis
|
||||
.withOpacity(0.2),
|
||||
height: 50,
|
||||
),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 50,
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
streamChannel.channel.sendAction(message, {
|
||||
'image_action': 'send',
|
||||
});
|
||||
},
|
||||
child: Text(
|
||||
context.translations.sendLabel,
|
||||
style: TextStyle(
|
||||
color: StreamChatTheme.of(context)
|
||||
.colorTheme
|
||||
.accentPrimary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: StreamVisibleFootnote(),
|
||||
const SizedBox(height: 4),
|
||||
const Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: StreamVisibleFootnote(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onImageTap(BuildContext context) async {
|
||||
final res = await Navigator.push(
|
||||
context,
|
||||
Future<void> _onImageTap(BuildContext context) async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) {
|
||||
final channel = StreamChannel.of(context).channel;
|
||||
return StreamChannel(
|
||||
channel: channel,
|
||||
child: StreamFullScreenMedia(
|
||||
child: StreamFullScreenMediaBuilder(
|
||||
mediaAttachmentPackages: message.getAttachmentPackageList(),
|
||||
startIndex: message.attachments.indexOf(attachment),
|
||||
userName: message.user?.name,
|
||||
userName: message.user!.name,
|
||||
onShowMessage: onShowMessage,
|
||||
onReplyMessage: onReplyMessage,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
if (res != null) onReturnAction?.call(res);
|
||||
}
|
||||
|
||||
Widget _buildSentAttachment(BuildContext context, String imageUrl) =>
|
||||
SizedBox(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (onAttachmentTap != null) {
|
||||
onAttachmentTap?.call();
|
||||
} else {
|
||||
_onImageTap(context);
|
||||
}
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
height: size?.height,
|
||||
width: size?.width,
|
||||
placeholder: (_, __) {
|
||||
final image = Image.asset(
|
||||
'images/placeholder.png',
|
||||
fit: BoxFit.cover,
|
||||
package: 'stream_chat_flutter',
|
||||
);
|
||||
|
||||
final colorTheme = StreamChatTheme.of(context).colorTheme;
|
||||
return Shimmer.fromColors(
|
||||
baseColor: colorTheme.disabled,
|
||||
highlightColor: colorTheme.inputBg,
|
||||
child: image,
|
||||
);
|
||||
},
|
||||
imageUrl: imageUrl,
|
||||
errorWidget: (context, url, error) =>
|
||||
AttachmentError(size: size),
|
||||
Widget _buildSentAttachment(BuildContext context, String imageUrl) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
if (onAttachmentTap != null) {
|
||||
onAttachmentTap?.call();
|
||||
} else {
|
||||
_onImageTap(context);
|
||||
}
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
height: constraints?.maxHeight,
|
||||
width: constraints?.maxWidth,
|
||||
placeholder: (_, __) {
|
||||
final image = Image.asset(
|
||||
'images/placeholder.png',
|
||||
fit: BoxFit.cover,
|
||||
package: 'stream_chat_flutter',
|
||||
);
|
||||
|
||||
final colorTheme = StreamChatTheme.of(context).colorTheme;
|
||||
return Shimmer.fromColors(
|
||||
baseColor: colorTheme.disabled,
|
||||
highlightColor: colorTheme.inputBg,
|
||||
child: image,
|
||||
);
|
||||
},
|
||||
imageUrl: imageUrl,
|
||||
errorWidget: (context, url, error) => AttachmentError(
|
||||
constraints: constraints,
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
Positioned(
|
||||
bottom: 8,
|
||||
left: 8,
|
||||
child: Material(
|
||||
color: StreamChatTheme.of(context)
|
||||
.colorTheme
|
||||
.textHighEmphasis
|
||||
.withOpacity(0.5),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 8,
|
||||
left: 8,
|
||||
child: Material(
|
||||
color: StreamChatTheme.of(context)
|
||||
.colorTheme
|
||||
.textHighEmphasis
|
||||
.withOpacity(0.5),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
StreamSvgIcon.lightning(
|
||||
color: StreamChatTheme.of(context).colorTheme.barsBg,
|
||||
size: 16,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
StreamSvgIcon.lightning(
|
||||
color: StreamChatTheme.of(context).colorTheme.barsBg,
|
||||
size: 16,
|
||||
),
|
||||
Text(
|
||||
context.translations.giphyLabel.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color:
|
||||
StreamChatTheme.of(context).colorTheme.barsBg,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
Text(
|
||||
context.translations.giphyLabel.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: StreamChatTheme.of(context).colorTheme.barsBg,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:file_selector/file_selector.dart';
|
||||
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
|
||||
|
||||
/// Downloads the [attachment] to the device and returns
|
||||
/// the path to the file.
|
||||
Future<String?> downloadWebOrDesktopAttachment(
|
||||
Attachment attachment, {
|
||||
ProgressCallback? onReceiveProgress,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
CancelToken? cancelToken,
|
||||
Options? options,
|
||||
}) async {
|
||||
final type = attachment.type;
|
||||
|
||||
String? downloadUrl;
|
||||
String? fileName;
|
||||
/* ---IMAGES/GIFS--- */
|
||||
if (type == 'image') {
|
||||
downloadUrl = attachment.imageUrl ?? attachment.assetUrl;
|
||||
fileName = attachment.title;
|
||||
fileName ??= 'attachment.${attachment.mimeType ?? 'png'}';
|
||||
}
|
||||
/* ---GIPHY's--- */
|
||||
else if (type == 'giphy') {
|
||||
downloadUrl = attachment.thumbUrl;
|
||||
fileName = '${attachment.title}.gif';
|
||||
}
|
||||
/* ---FILES AND VIDEOS--- */
|
||||
else if (type == 'file' || type == 'video') {
|
||||
downloadUrl = attachment.assetUrl;
|
||||
fileName = attachment.title;
|
||||
}
|
||||
|
||||
assert(
|
||||
downloadUrl != null,
|
||||
'Attachment must have an assetUrl or imageUrl or thumbUrl',
|
||||
);
|
||||
|
||||
final response = await Dio().get<List<int>>(
|
||||
downloadUrl!,
|
||||
onReceiveProgress: onReceiveProgress,
|
||||
queryParameters: queryParameters,
|
||||
cancelToken: cancelToken,
|
||||
// set responseType to `bytes`
|
||||
options: options?.copyWith(responseType: ResponseType.bytes) ??
|
||||
Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
|
||||
// Open the native file browser so the user can select the download path.
|
||||
final path = await getSavePath(suggestedName: fileName);
|
||||
|
||||
if (path == null) {
|
||||
// Operation was canceled by the user.
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create an XFile for proper file saving
|
||||
final file = XFile.fromData(
|
||||
Uint8List.fromList(response.data!),
|
||||
mimeType: attachment.mimeType,
|
||||
name: fileName,
|
||||
path: path,
|
||||
);
|
||||
|
||||
// Save the file to the user's selected path.
|
||||
await file.saveTo(path);
|
||||
return path;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export 'stream_attachment_handler_base.dart'
|
||||
if (dart.library.html) 'stream_attachment_handler_html.dart'
|
||||
if (dart.library.io) 'stream_attachment_handler_io.dart'
|
||||
show StreamAttachmentHandler;
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
|
||||
|
||||
/// Base class for handling attachment related functionality.
|
||||
abstract class StreamAttachmentHandlerBase {
|
||||
/// Pick an image from the device.
|
||||
Future<Attachment?> pickImage({
|
||||
required ImageSource source,
|
||||
double? maxWidth,
|
||||
double? maxHeight,
|
||||
int? imageQuality,
|
||||
CameraDevice preferredCameraDevice = CameraDevice.rear,
|
||||
}) {
|
||||
throw UnimplementedError('pickImage is not implemented');
|
||||
}
|
||||
|
||||
/// Pick a video from the device.
|
||||
Future<Attachment?> pickVideo({
|
||||
required ImageSource source,
|
||||
CameraDevice preferredCameraDevice = CameraDevice.rear,
|
||||
Duration? maxDuration,
|
||||
}) {
|
||||
throw UnimplementedError('pickVideo is not implemented');
|
||||
}
|
||||
|
||||
/// Pick a file from the device.
|
||||
Future<Attachment?> pickFile({
|
||||
String? dialogTitle,
|
||||
String? initialDirectory,
|
||||
FileType type = FileType.any,
|
||||
List<String>? allowedExtensions,
|
||||
Function(FilePickerStatus)? onFileLoading,
|
||||
bool allowCompression = true,
|
||||
bool withData = true,
|
||||
bool withReadStream = false,
|
||||
bool lockParentWindow = true,
|
||||
}) {
|
||||
throw UnimplementedError('pickFile is not implemented');
|
||||
}
|
||||
|
||||
/// Pick an audio from the device.
|
||||
Future<Attachment?> pickAudio() {
|
||||
throw UnimplementedError('pickAudio is not implemented');
|
||||
}
|
||||
|
||||
/// Saves the [attachmentFile] to the temporary directory.
|
||||
Future<String> saveAttachmentFile({
|
||||
required AttachmentFile attachmentFile,
|
||||
}) {
|
||||
throw UnimplementedError('saveFile is not implemented');
|
||||
}
|
||||
|
||||
/// Deletes the [attachmentFile] from the temporary directory.
|
||||
Future<void> deleteAttachmentFile({
|
||||
required AttachmentFile attachmentFile,
|
||||
}) {
|
||||
throw UnimplementedError('deleteAttachmentFile is not implemented');
|
||||
}
|
||||
|
||||
/// Downloads the [attachment] to the device and returns
|
||||
/// the path to the file.
|
||||
Future<String?> downloadAttachment(
|
||||
Attachment attachment, {
|
||||
ProgressCallback? onReceiveProgress,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
CancelToken? cancelToken,
|
||||
Options? options,
|
||||
}) {
|
||||
throw UnimplementedError('downloadAttachment is not implemented');
|
||||
}
|
||||
}
|
||||
|
||||
/// Stub implementation of [StreamAttachmentHandlerBase].
|
||||
class StreamAttachmentHandler extends StreamAttachmentHandlerBase {
|
||||
/// Returns an instance of [StreamAttachmentHandler].
|
||||
static StreamAttachmentHandler get instance {
|
||||
throw UnimplementedError('instance is not implemented');
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:stream_chat_flutter/src/attachment/handler/common.dart';
|
||||
import 'package:stream_chat_flutter/src/attachment/handler/stream_attachment_handler_base.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/extensions.dart';
|
||||
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
|
||||
|
||||
/// StreamAttachmentHandler implementation for html.
|
||||
class StreamAttachmentHandler extends StreamAttachmentHandlerBase {
|
||||
StreamAttachmentHandler._();
|
||||
|
||||
static StreamAttachmentHandler? _instance;
|
||||
|
||||
/// Returns the singleton instance of [StreamAttachmentHandler].
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static StreamAttachmentHandler get instance =>
|
||||
_instance ??= StreamAttachmentHandler._();
|
||||
|
||||
late final _filePicker = FilePicker.platform;
|
||||
|
||||
@override
|
||||
Future<Attachment?> pickFile({
|
||||
String? dialogTitle,
|
||||
String? initialDirectory,
|
||||
FileType type = FileType.any,
|
||||
List<String>? allowedExtensions,
|
||||
Function(FilePickerStatus)? onFileLoading,
|
||||
bool allowCompression = true,
|
||||
bool withData = true,
|
||||
bool withReadStream = false,
|
||||
bool lockParentWindow = true,
|
||||
}) async {
|
||||
final result = await _filePicker.pickFiles(
|
||||
dialogTitle: dialogTitle,
|
||||
initialDirectory: initialDirectory,
|
||||
type: type,
|
||||
allowedExtensions: allowedExtensions,
|
||||
onFileLoading: onFileLoading,
|
||||
allowCompression: allowCompression,
|
||||
withData: withData,
|
||||
withReadStream: withReadStream,
|
||||
lockParentWindow: lockParentWindow,
|
||||
);
|
||||
|
||||
return result?.files.first.toAttachment(type: type.toAttachmentType());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> downloadAttachment(
|
||||
Attachment attachment, {
|
||||
ProgressCallback? onReceiveProgress,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
CancelToken? cancelToken,
|
||||
Options? options,
|
||||
}) {
|
||||
return downloadWebOrDesktopAttachment(
|
||||
attachment,
|
||||
onReceiveProgress: onReceiveProgress,
|
||||
queryParameters: queryParameters,
|
||||
cancelToken: cancelToken,
|
||||
options: options,
|
||||
);
|
||||
}
|
||||
}
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:stream_chat_flutter/src/attachment/handler/common.dart';
|
||||
import 'package:stream_chat_flutter/src/attachment/handler/stream_attachment_handler_base.dart';
|
||||
|
||||
import 'package:stream_chat_flutter/src/utils/device_segmentation.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/extensions.dart';
|
||||
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
|
||||
|
||||
/// StreamAttachmentHandler implementation for desktop.
|
||||
class StreamAttachmentHandlerDesktop extends StreamAttachmentHandler {
|
||||
/// Returns the singleton instance of [StreamAttachmentHandler].
|
||||
StreamAttachmentHandlerDesktop() : super.__();
|
||||
|
||||
@override
|
||||
Future<String?> downloadAttachment(
|
||||
Attachment attachment, {
|
||||
ProgressCallback? onReceiveProgress,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
CancelToken? cancelToken,
|
||||
Options? options,
|
||||
}) {
|
||||
return downloadWebOrDesktopAttachment(
|
||||
attachment,
|
||||
onReceiveProgress: onReceiveProgress,
|
||||
queryParameters: queryParameters,
|
||||
cancelToken: cancelToken,
|
||||
options: options,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// StreamAttachmentHandler implementation for io.
|
||||
class StreamAttachmentHandler extends StreamAttachmentHandlerBase {
|
||||
StreamAttachmentHandler.__();
|
||||
|
||||
factory StreamAttachmentHandler._() {
|
||||
if (isDesktopDevice) {
|
||||
return StreamAttachmentHandlerDesktop();
|
||||
}
|
||||
return StreamAttachmentHandler.__();
|
||||
}
|
||||
|
||||
static StreamAttachmentHandler? _instance;
|
||||
|
||||
/// Returns the singleton instance of [StreamAttachmentHandler].
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static StreamAttachmentHandler get instance =>
|
||||
_instance ??= StreamAttachmentHandler._();
|
||||
|
||||
late final _imagePicker = ImagePicker();
|
||||
late final _filePicker = FilePicker.platform;
|
||||
|
||||
@override
|
||||
Future<Attachment?> pickImage({
|
||||
required ImageSource source,
|
||||
double? maxWidth,
|
||||
double? maxHeight,
|
||||
int? imageQuality,
|
||||
CameraDevice preferredCameraDevice = CameraDevice.rear,
|
||||
}) async {
|
||||
final image = await _imagePicker.pickImage(
|
||||
source: source,
|
||||
maxWidth: maxWidth,
|
||||
maxHeight: maxHeight,
|
||||
imageQuality: imageQuality,
|
||||
preferredCameraDevice: preferredCameraDevice,
|
||||
);
|
||||
|
||||
return image?.toAttachment(type: 'image');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Attachment?> pickVideo({
|
||||
required ImageSource source,
|
||||
CameraDevice preferredCameraDevice = CameraDevice.rear,
|
||||
Duration? maxDuration,
|
||||
}) async {
|
||||
final video = await _imagePicker.pickVideo(
|
||||
source: source,
|
||||
preferredCameraDevice: preferredCameraDevice,
|
||||
maxDuration: maxDuration,
|
||||
);
|
||||
|
||||
return video?.toAttachment(type: 'video');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Attachment?> pickFile({
|
||||
String? dialogTitle,
|
||||
String? initialDirectory,
|
||||
FileType type = FileType.any,
|
||||
List<String>? allowedExtensions,
|
||||
Function(FilePickerStatus)? onFileLoading,
|
||||
bool allowCompression = true,
|
||||
bool withData = true,
|
||||
bool withReadStream = false,
|
||||
bool lockParentWindow = true,
|
||||
}) async {
|
||||
final result = await _filePicker.pickFiles(
|
||||
dialogTitle: dialogTitle,
|
||||
initialDirectory: initialDirectory,
|
||||
type: type,
|
||||
allowedExtensions: allowedExtensions,
|
||||
onFileLoading: onFileLoading,
|
||||
allowCompression: allowCompression,
|
||||
withData: withData,
|
||||
withReadStream: withReadStream,
|
||||
lockParentWindow: lockParentWindow,
|
||||
);
|
||||
|
||||
return result?.files.first.toAttachment(type: type.toAttachmentType());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> saveAttachmentFile({
|
||||
required AttachmentFile attachmentFile,
|
||||
}) async {
|
||||
final fileName = attachmentFile.name;
|
||||
assert(fileName != null, 'Attachment file name is required');
|
||||
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempPath = Uri.file(tempDir.path, windows: CurrentPlatform.isWindows);
|
||||
final tempFilePath = tempPath.resolve(fileName!).path;
|
||||
print(tempFilePath);
|
||||
|
||||
final attachmentFileBytes = attachmentFile.bytes;
|
||||
if (attachmentFileBytes == null) {
|
||||
final attachmentFilePath = attachmentFile.path!;
|
||||
final file = File(attachmentFilePath);
|
||||
return file.copy(tempFilePath).then((it) => it.path);
|
||||
} else {
|
||||
final file = File(tempFilePath);
|
||||
return file.writeAsBytes(attachmentFileBytes).then((it) => it.path);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteAttachmentFile({
|
||||
required AttachmentFile attachmentFile,
|
||||
}) async {
|
||||
final attachmentFilePath = attachmentFile.path;
|
||||
if (attachmentFilePath != null) {
|
||||
final file = File(attachmentFilePath);
|
||||
if (file.existsSync()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> downloadAttachment(
|
||||
Attachment attachment, {
|
||||
ProgressCallback? onReceiveProgress,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
CancelToken? cancelToken,
|
||||
Options? options,
|
||||
}) async {
|
||||
final type = attachment.type;
|
||||
|
||||
String? downloadUrl;
|
||||
String? fileName;
|
||||
/* ---IMAGES/GIFS--- */
|
||||
if (type == 'image') {
|
||||
downloadUrl = attachment.imageUrl ?? attachment.assetUrl;
|
||||
fileName = attachment.title;
|
||||
fileName ??= 'attachment.${attachment.mimeType ?? 'png'}';
|
||||
}
|
||||
/* ---GIPHY's--- */
|
||||
else if (type == 'giphy') {
|
||||
downloadUrl = attachment.thumbUrl;
|
||||
fileName = '${attachment.title}.gif';
|
||||
}
|
||||
/* ---FILES AND VIDEOS--- */
|
||||
else if (type == 'file' || type == 'video') {
|
||||
downloadUrl = attachment.assetUrl;
|
||||
fileName = attachment.title;
|
||||
}
|
||||
|
||||
assert(
|
||||
downloadUrl != null,
|
||||
'Attachment must have an assetUrl or imageUrl or thumbUrl',
|
||||
);
|
||||
|
||||
final response = await Dio().get<List<int>>(
|
||||
downloadUrl!,
|
||||
onReceiveProgress: onReceiveProgress,
|
||||
queryParameters: queryParameters,
|
||||
cancelToken: cancelToken,
|
||||
// set responseType to `bytes`
|
||||
options: options?.copyWith(responseType: ResponseType.bytes) ??
|
||||
Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
|
||||
final appDir = await getTemporaryDirectory();
|
||||
final ext = Uri.parse(downloadUrl).pathSegments.last;
|
||||
final path = '${appDir.path}/${attachment.id}.$ext';
|
||||
|
||||
// Create an XFile for proper file saving
|
||||
final file = XFile.fromData(
|
||||
Uint8List.fromList(response.data!),
|
||||
mimeType: attachment.mimeType,
|
||||
name: fileName,
|
||||
path: path,
|
||||
);
|
||||
|
||||
// Save the file to the user's selected path.
|
||||
await file.saveTo(path);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -1,49 +1,44 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
import 'package:stream_chat_flutter/src/attachment/attachment_title.dart';
|
||||
import 'package:stream_chat_flutter/src/attachment/attachment_widget.dart';
|
||||
import 'package:stream_chat_flutter/src/extension.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/utils.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@macro image_attachment}
|
||||
@Deprecated("use 'StreamImageAttachment' instead")
|
||||
typedef ImageAttachment = StreamImageAttachment;
|
||||
|
||||
/// {@template image_attachment}
|
||||
/// Widget for showing an image attachment
|
||||
/// {@template streamImageAttachment}
|
||||
/// Shows an image attachment in a [StreamMessageWidget].
|
||||
/// {@endtemplate}
|
||||
class StreamImageAttachment extends StreamAttachmentWidget {
|
||||
/// Constructor for creating a [StreamImageAttachment] widget
|
||||
/// {@macro streamImageAttachment}
|
||||
const StreamImageAttachment({
|
||||
super.key,
|
||||
required super.message,
|
||||
required super.attachment,
|
||||
required this.messageTheme,
|
||||
super.size,
|
||||
super.constraints,
|
||||
this.showTitle = false,
|
||||
this.onShowMessage,
|
||||
this.onReturnAction,
|
||||
this.onReplyMessage,
|
||||
this.onAttachmentTap,
|
||||
this.imageThumbnailSize = const Size(400, 400),
|
||||
this.imageThumbnailResizeType = 'crop',
|
||||
this.imageThumbnailResizeType = 'clip',
|
||||
this.imageThumbnailCropType = 'center',
|
||||
});
|
||||
|
||||
/// [StreamMessageThemeData] for showing image title
|
||||
/// The [StreamMessageThemeData] to use for the image title
|
||||
final StreamMessageThemeData messageTheme;
|
||||
|
||||
/// Flag for showing title
|
||||
/// Flag for whether the title should be shown or not
|
||||
final bool showTitle;
|
||||
|
||||
/// Callback when show message is tapped
|
||||
/// {@macro showMessageCallback}
|
||||
final ShowMessageCallback? onShowMessage;
|
||||
|
||||
/// Callback when attachment is returned to from other screens
|
||||
final ValueChanged<ReturnActionType>? onReturnAction;
|
||||
/// {@macro replyMessageCallback}
|
||||
final ReplyMessageCallback? onReplyMessage;
|
||||
|
||||
/// Callback when attachment is tapped
|
||||
final VoidCallback? onAttachmentTap;
|
||||
/// {@macro onAttachmentTap}
|
||||
final OnAttachmentTap? onAttachmentTap;
|
||||
|
||||
/// Size of the attachment image thumbnail.
|
||||
final Size imageThumbnailSize;
|
||||
@@ -59,118 +54,126 @@ class StreamImageAttachment extends StreamAttachmentWidget {
|
||||
final String /*center|top|bottom|left|right*/ imageThumbnailCropType;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => source.when(
|
||||
local: () {
|
||||
if (attachment.localUri == null || attachment.file?.bytes == null) {
|
||||
return AttachmentError(size: size);
|
||||
}
|
||||
return _buildImageAttachment(
|
||||
context,
|
||||
Image.memory(
|
||||
attachment.file!.bytes!,
|
||||
height: size?.height,
|
||||
width: size?.width,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, _, __) => Image.asset(
|
||||
Widget build(BuildContext context) {
|
||||
return source.when(
|
||||
local: () {
|
||||
if (attachment.localUri == null || attachment.file?.bytes == null) {
|
||||
return AttachmentError(constraints: constraints);
|
||||
}
|
||||
return _buildImageAttachment(
|
||||
context,
|
||||
Image.memory(
|
||||
attachment.file!.bytes!,
|
||||
height: constraints?.maxHeight,
|
||||
width: constraints?.maxWidth,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, _, __) => Image.asset(
|
||||
'images/placeholder.png',
|
||||
package: 'stream_chat_flutter',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
network: () {
|
||||
var imageUrl =
|
||||
attachment.thumbUrl ?? attachment.imageUrl ?? attachment.assetUrl;
|
||||
|
||||
if (imageUrl == null) {
|
||||
return AttachmentError(constraints: constraints);
|
||||
}
|
||||
|
||||
imageUrl = imageUrl.getResizedImageUrl(
|
||||
width: imageThumbnailSize.width,
|
||||
height: imageThumbnailSize.height,
|
||||
resize: imageThumbnailResizeType,
|
||||
crop: imageThumbnailCropType,
|
||||
);
|
||||
|
||||
return _buildImageAttachment(
|
||||
context,
|
||||
CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
height: constraints?.maxHeight,
|
||||
width: constraints?.maxWidth,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, __) {
|
||||
final image = Image.asset(
|
||||
'images/placeholder.png',
|
||||
fit: BoxFit.cover,
|
||||
package: 'stream_chat_flutter',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
network: () {
|
||||
var imageUrl =
|
||||
attachment.thumbUrl ?? attachment.imageUrl ?? attachment.assetUrl;
|
||||
);
|
||||
final colorTheme = StreamChatTheme.of(context).colorTheme;
|
||||
return Shimmer.fromColors(
|
||||
baseColor: colorTheme.disabled,
|
||||
highlightColor: colorTheme.inputBg,
|
||||
child: image,
|
||||
);
|
||||
},
|
||||
errorWidget: (context, url, error) =>
|
||||
AttachmentError(constraints: constraints),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (imageUrl == null) return AttachmentError(size: size);
|
||||
|
||||
imageUrl = imageUrl.getResizedImageUrl(
|
||||
width: imageThumbnailSize.width,
|
||||
height: imageThumbnailSize.height,
|
||||
resize: imageThumbnailResizeType,
|
||||
crop: imageThumbnailCropType,
|
||||
);
|
||||
|
||||
return _buildImageAttachment(
|
||||
context,
|
||||
CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
height: size?.height,
|
||||
width: size?.width,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, __) {
|
||||
final image = Image.asset(
|
||||
'images/placeholder.png',
|
||||
fit: BoxFit.cover,
|
||||
package: 'stream_chat_flutter',
|
||||
);
|
||||
final colorTheme = StreamChatTheme.of(context).colorTheme;
|
||||
return Shimmer.fromColors(
|
||||
baseColor: colorTheme.disabled,
|
||||
highlightColor: colorTheme.inputBg,
|
||||
child: image,
|
||||
);
|
||||
},
|
||||
errorWidget: (context, url, error) => AttachmentError(size: size),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Widget _buildImageAttachment(BuildContext context, Widget imageWidget) =>
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints.loose(size!),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
GestureDetector(
|
||||
Widget _buildImageAttachment(BuildContext context, Widget imageWidget) {
|
||||
return Container(
|
||||
constraints: constraints,
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: onAttachmentTap ??
|
||||
() async {
|
||||
final result = await Navigator.push(
|
||||
context,
|
||||
() {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) {
|
||||
final channel =
|
||||
StreamChannel.of(context).channel;
|
||||
return StreamChannel(
|
||||
channel: channel,
|
||||
child: StreamFullScreenMedia(
|
||||
child: StreamFullScreenMediaBuilder(
|
||||
mediaAttachmentPackages:
|
||||
message.getAttachmentPackageList(),
|
||||
startIndex:
|
||||
message.attachments.indexOf(attachment),
|
||||
userName: message.user?.name,
|
||||
userName: message.user!.name,
|
||||
onShowMessage: onShowMessage,
|
||||
onReplyMessage: onReplyMessage,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
if (result != null) onReturnAction?.call(result);
|
||||
},
|
||||
child: imageWidget,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: StreamAttachmentUploadStateBuilder(
|
||||
message: message,
|
||||
attachment: attachment,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: StreamAttachmentUploadStateBuilder(
|
||||
message: message,
|
||||
attachment: attachment,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (showTitle && attachment.title != null)
|
||||
Material(
|
||||
color: messageTheme.messageBackgroundColor,
|
||||
child: StreamAttachmentTitle(
|
||||
messageTheme: messageTheme,
|
||||
attachment: attachment,
|
||||
),
|
||||
),
|
||||
if (showTitle && attachment.title != null)
|
||||
Material(
|
||||
color: messageTheme.messageBackgroundColor,
|
||||
child: StreamAttachmentTitle(
|
||||
messageTheme: messageTheme,
|
||||
attachment: attachment,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@template streamImageGroup}
|
||||
/// Constructs a group of image attachments in a [StreamMessageWidget].
|
||||
/// {@endtemplate}
|
||||
class StreamImageGroup extends StatelessWidget {
|
||||
/// {@macro streamImageGroup}
|
||||
const StreamImageGroup({
|
||||
super.key,
|
||||
required this.images,
|
||||
required this.message,
|
||||
required this.messageTheme,
|
||||
required this.constraints,
|
||||
this.onShowMessage,
|
||||
this.onReplyMessage,
|
||||
this.onAttachmentTap,
|
||||
this.imageThumbnailSize = const Size(400, 400),
|
||||
this.imageThumbnailResizeType = 'clip',
|
||||
this.imageThumbnailCropType = 'center',
|
||||
});
|
||||
|
||||
/// List of attachments to show
|
||||
final List<Attachment> images;
|
||||
|
||||
/// {@macro onImageGroupAttachmentTap}
|
||||
final OnImageGroupAttachmentTap? onAttachmentTap;
|
||||
|
||||
/// The [Message] that the images are attached to
|
||||
final Message message;
|
||||
|
||||
/// The [StreamMessageThemeData] to apply to this [message]
|
||||
final StreamMessageThemeData messageTheme;
|
||||
|
||||
/// The constraints of the [images]
|
||||
final BoxConstraints constraints;
|
||||
|
||||
/// {@macro showMessageCallback}
|
||||
final ShowMessageCallback? onShowMessage;
|
||||
|
||||
/// {@macro replyMessageCallback}
|
||||
final ReplyMessageCallback? onReplyMessage;
|
||||
|
||||
/// Size of the attachment image thumbnail.
|
||||
final Size imageThumbnailSize;
|
||||
|
||||
/// Resize type of the image attachment thumbnail.
|
||||
///
|
||||
/// Defaults to [crop]
|
||||
final String /*clip|crop|scale|fill*/ imageThumbnailResizeType;
|
||||
|
||||
/// Crop type of the image attachment thumbnail.
|
||||
///
|
||||
/// Defaults to [center]
|
||||
final String /*center|top|bottom|left|right*/ imageThumbnailCropType;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ConstrainedBox(
|
||||
constraints: constraints,
|
||||
child: Flex(
|
||||
direction: Axis.vertical,
|
||||
children: <Widget>[
|
||||
Flexible(
|
||||
fit: FlexFit.tight,
|
||||
child: Flex(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
direction: Axis.horizontal,
|
||||
children: [
|
||||
Flexible(
|
||||
fit: FlexFit.tight,
|
||||
child: _buildImage(context, 0),
|
||||
),
|
||||
Flexible(
|
||||
fit: FlexFit.tight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 2),
|
||||
child: _buildImage(context, 1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (images.length >= 3)
|
||||
Flexible(
|
||||
fit: FlexFit.tight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Flex(
|
||||
direction: Axis.horizontal,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
Flexible(
|
||||
fit: FlexFit.tight,
|
||||
child: _buildImage(context, 2),
|
||||
),
|
||||
if (images.length >= 4)
|
||||
Flexible(
|
||||
fit: FlexFit.tight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 2),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: <Widget>[
|
||||
_buildImage(context, 3),
|
||||
if (images.length > 4)
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: () => _onTap(context, 3),
|
||||
child: Material(
|
||||
color: Colors.black38,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'+ ${images.length - 4}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 26,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onTap(
|
||||
BuildContext context,
|
||||
int index,
|
||||
) async {
|
||||
if (onAttachmentTap != null) {
|
||||
return onAttachmentTap!(message, images[index]);
|
||||
}
|
||||
|
||||
final channel = StreamChannel.of(context).channel;
|
||||
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => StreamChannel(
|
||||
channel: channel,
|
||||
child: StreamFullScreenMediaBuilder(
|
||||
mediaAttachmentPackages: message.getAttachmentPackageList(),
|
||||
startIndex: index,
|
||||
userName: message.user!.name,
|
||||
onShowMessage: onShowMessage,
|
||||
onReplyMessage: onReplyMessage,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImage(BuildContext context, int index) {
|
||||
return StreamImageAttachment(
|
||||
attachment: images[index],
|
||||
constraints: constraints,
|
||||
message: message,
|
||||
messageTheme: messageTheme,
|
||||
onAttachmentTap: () => _onTap(context, index),
|
||||
imageThumbnailSize: imageThumbnailSize,
|
||||
imageThumbnailResizeType: imageThumbnailResizeType,
|
||||
imageThumbnailCropType: imageThumbnailCropType,
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -13,6 +13,6 @@ class StreamAttachmentPackage {
|
||||
final Attachment attachment;
|
||||
|
||||
/// This is the message that the attachment belongs to
|
||||
/// The message object may have attachemnt(s) other than the one packaged
|
||||
/// The message object may have attachment(s) other than the one packaged
|
||||
final Message message;
|
||||
}
|
||||
@@ -2,15 +2,11 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@macro url_attachment}
|
||||
@Deprecated("Use 'StreamUrlAttachment' instead")
|
||||
typedef UrlAttachment = StreamUrlAttachment;
|
||||
|
||||
/// {@template url_attachment}
|
||||
/// Widget to display URL attachment
|
||||
/// {@template streamUrlAttachment}
|
||||
/// Displays a URL attachment in a [StreamMessageWidget].
|
||||
/// {@endtemplate}
|
||||
class StreamUrlAttachment extends StatelessWidget {
|
||||
/// Constructor for creating a [StreamUrlAttachment]
|
||||
/// {@macro streamUrlAttachment}
|
||||
const StreamUrlAttachment({
|
||||
super.key,
|
||||
required this.urlAttachment,
|
||||
@@ -32,7 +28,7 @@ class StreamUrlAttachment extends StatelessWidget {
|
||||
/// Padding for text
|
||||
final EdgeInsets textPadding;
|
||||
|
||||
/// [StreamMessageThemeData] for showing image title
|
||||
/// The [StreamMessageThemeData] to use for the image title
|
||||
final StreamMessageThemeData messageTheme;
|
||||
|
||||
/// The function called when tapping on a link
|
||||
@@ -41,83 +37,93 @@ class StreamUrlAttachment extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final chatThemeData = StreamChatTheme.of(context);
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
final ogScrapeUrl = urlAttachment.ogScrapeUrl;
|
||||
if (ogScrapeUrl != null) {
|
||||
onLinkTap != null
|
||||
? onLinkTap!(ogScrapeUrl)
|
||||
: launchURL(context, ogScrapeUrl);
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (urlAttachment.imageUrl != null)
|
||||
Container(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
width: double.infinity,
|
||||
imageUrl: urlAttachment.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 400,
|
||||
minWidth: 400,
|
||||
),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
final ogScrapeUrl = urlAttachment.ogScrapeUrl;
|
||||
if (ogScrapeUrl != null) {
|
||||
onLinkTap != null
|
||||
? onLinkTap!(ogScrapeUrl)
|
||||
: launchURL(context, ogScrapeUrl);
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (urlAttachment.imageUrl != null)
|
||||
Container(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
bottom: -1,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.only(
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
color: messageTheme.linkBackgroundColor,
|
||||
child: Stack(
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
width: double.infinity,
|
||||
imageUrl: urlAttachment.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 8,
|
||||
left: 8,
|
||||
right: 8,
|
||||
),
|
||||
child: Text(
|
||||
hostDisplayName,
|
||||
style: chatThemeData.textTheme.bodyBold.copyWith(
|
||||
color: chatThemeData.colorTheme.accentPrimary,
|
||||
Positioned(
|
||||
left: 0,
|
||||
bottom: -1,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.only(
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
color: messageTheme.linkBackgroundColor,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 8,
|
||||
left: 8,
|
||||
right: 8,
|
||||
),
|
||||
child: Text(
|
||||
hostDisplayName,
|
||||
style: chatThemeData.textTheme.bodyBold.copyWith(
|
||||
color: chatThemeData.colorTheme.accentPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: textPadding,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (urlAttachment.title != null)
|
||||
Text(
|
||||
urlAttachment.title!.trim(),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: chatThemeData.textTheme.body
|
||||
.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
if (urlAttachment.text != null)
|
||||
Text(
|
||||
urlAttachment.text!,
|
||||
style: chatThemeData.textTheme.body
|
||||
.copyWith(fontWeight: FontWeight.w400),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: textPadding,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (urlAttachment.title != null)
|
||||
Text(
|
||||
urlAttachment.title!.trim(),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: chatThemeData.textTheme.body
|
||||
.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
if (urlAttachment.text != null)
|
||||
Text(
|
||||
urlAttachment.text!,
|
||||
style: chatThemeData.textTheme.body
|
||||
.copyWith(fontWeight: FontWeight.w400),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,135 +1,124 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/attachment/attachment_title.dart';
|
||||
import 'package:stream_chat_flutter/src/attachment/attachment_widget.dart';
|
||||
import 'package:stream_chat_flutter/src/video_thumbnail_image.dart';
|
||||
import 'package:stream_chat_flutter/src/video/video_thumbnail_image.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@macro video_attachment}
|
||||
@Deprecated("Use 'StreamVideoAttachment' instead")
|
||||
typedef VideoAttachment = StreamVideoAttachment;
|
||||
|
||||
/// {@template video_attachment}
|
||||
/// Widget for showing a video attachment
|
||||
/// {@template streamVideoAttachment}
|
||||
/// Shows a video attachment in a [StreamMessageWidget].
|
||||
/// {@endtemplate}
|
||||
class StreamVideoAttachment extends StreamAttachmentWidget {
|
||||
/// Constructor for creating a [StreamVideoAttachment] widget
|
||||
/// {@macro streamVideoAttachment}
|
||||
const StreamVideoAttachment({
|
||||
super.key,
|
||||
required super.message,
|
||||
required super.attachment,
|
||||
required this.messageTheme,
|
||||
super.size,
|
||||
super.constraints,
|
||||
this.onShowMessage,
|
||||
this.onReturnAction,
|
||||
this.onReplyMessage,
|
||||
this.onAttachmentTap,
|
||||
});
|
||||
|
||||
/// [StreamMessageThemeData] for showing title
|
||||
/// The [StreamMessageThemeData] to use for the title
|
||||
final StreamMessageThemeData messageTheme;
|
||||
|
||||
/// Callback when show message is tapped
|
||||
/// {@macro showMessageCallback}
|
||||
final ShowMessageCallback? onShowMessage;
|
||||
|
||||
/// Callback when attachment is returned to from other screens
|
||||
final ValueChanged<ReturnActionType>? onReturnAction;
|
||||
/// {@macro replyMessageCallback}
|
||||
final ReplyMessageCallback? onReplyMessage;
|
||||
|
||||
/// Callback when attachment is tapped
|
||||
final VoidCallback? onAttachmentTap;
|
||||
/// {@macro onAttachmentTap}
|
||||
final OnAttachmentTap? onAttachmentTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => source.when(
|
||||
local: () {
|
||||
if (attachment.file == null) {
|
||||
return AttachmentError(size: size);
|
||||
}
|
||||
return _buildVideoAttachment(
|
||||
context,
|
||||
StreamVideoThumbnailImage(
|
||||
video: attachment.file!.path!,
|
||||
height: size?.height,
|
||||
width: size?.width,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __) => AttachmentError(size: size),
|
||||
),
|
||||
);
|
||||
},
|
||||
network: () {
|
||||
if (attachment.assetUrl == null) {
|
||||
return AttachmentError(size: size);
|
||||
}
|
||||
return _buildVideoAttachment(
|
||||
context,
|
||||
StreamVideoThumbnailImage(
|
||||
video: attachment.assetUrl!,
|
||||
height: size?.height,
|
||||
width: size?.width,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __) => AttachmentError(size: size),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
Widget build(BuildContext context) {
|
||||
return source.when(
|
||||
local: () {
|
||||
if (attachment.file == null) {
|
||||
return AttachmentError(constraints: constraints);
|
||||
}
|
||||
return _buildVideoAttachment(
|
||||
context,
|
||||
StreamVideoThumbnailImage(
|
||||
video: attachment.file!.path!,
|
||||
constraints: constraints,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __) => AttachmentError(constraints: constraints),
|
||||
),
|
||||
);
|
||||
},
|
||||
network: () {
|
||||
if (attachment.assetUrl == null) {
|
||||
return AttachmentError(constraints: constraints);
|
||||
}
|
||||
return _buildVideoAttachment(
|
||||
context,
|
||||
StreamVideoThumbnailImage(
|
||||
video: attachment.assetUrl!,
|
||||
constraints: constraints,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __) => AttachmentError(constraints: constraints),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVideoAttachment(BuildContext context, Widget videoWidget) =>
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints.loose(size ?? Size.infinite),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onAttachmentTap ??
|
||||
() async {
|
||||
Widget _buildVideoAttachment(BuildContext context, Widget videoWidget) {
|
||||
return ConstrainedBox(
|
||||
constraints: constraints ?? const BoxConstraints.expand(),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onAttachmentTap ??
|
||||
() async {
|
||||
if (attachment.uploadState == const UploadState.success()) {
|
||||
final channel = StreamChannel.of(context).channel;
|
||||
final res = await Navigator.push(
|
||||
context,
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => StreamChannel(
|
||||
channel: channel,
|
||||
child: StreamFullScreenMedia(
|
||||
child: StreamFullScreenMediaBuilder(
|
||||
mediaAttachmentPackages:
|
||||
message.getAttachmentPackageList(),
|
||||
startIndex:
|
||||
message.attachments.indexOf(attachment),
|
||||
userName: message.user?.name,
|
||||
userName: message.user!.name,
|
||||
onShowMessage: onShowMessage,
|
||||
onReplyMessage: onReplyMessage,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (res != null) onReturnAction?.call(res);
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
videoWidget,
|
||||
const Center(
|
||||
child: Material(
|
||||
shape: CircleBorder(),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Icon(Icons.play_arrow),
|
||||
),
|
||||
}
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
videoWidget,
|
||||
const Center(
|
||||
child: Material(
|
||||
shape: CircleBorder(),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Icon(Icons.play_arrow),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: StreamAttachmentUploadStateBuilder(
|
||||
message: message,
|
||||
attachment: attachment,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: StreamAttachmentUploadStateBuilder(
|
||||
message: message,
|
||||
attachment: attachment,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (attachment.title != null)
|
||||
Material(
|
||||
color: messageTheme.messageBackgroundColor,
|
||||
child: StreamAttachmentTitle(
|
||||
messageTheme: messageTheme,
|
||||
attachment: attachment,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+79
-115
@@ -1,20 +1,7 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_gallery_saver/image_gallery_saver.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:stream_chat_flutter/src/extension.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/utils.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// Callback to download an attachment asset
|
||||
typedef AttachmentDownloader = Future<String> Function(
|
||||
Attachment attachment, {
|
||||
ProgressCallback? progressCallback,
|
||||
DownloadedPathCallback? downloadedPathCallback,
|
||||
});
|
||||
|
||||
/// Callback to receive the path once the attachment asset is downloaded
|
||||
typedef DownloadedPathCallback = void Function(String? path);
|
||||
|
||||
/// Widget that shows the options in the gallery view
|
||||
class AttachmentActionsModal extends StatelessWidget {
|
||||
/// Returns a new [AttachmentActionsModal]
|
||||
@@ -23,8 +10,8 @@ class AttachmentActionsModal extends StatelessWidget {
|
||||
required this.attachment,
|
||||
required this.message,
|
||||
this.onShowMessage,
|
||||
this.imageDownloader,
|
||||
this.fileDownloader,
|
||||
this.onReply,
|
||||
this.attachmentDownloader,
|
||||
this.showReply = true,
|
||||
this.showShowInChat = true,
|
||||
this.showSave = true,
|
||||
@@ -41,11 +28,11 @@ class AttachmentActionsModal extends StatelessWidget {
|
||||
/// Callback to show the message
|
||||
final VoidCallback? onShowMessage;
|
||||
|
||||
/// Callback to download images
|
||||
final AttachmentDownloader? imageDownloader;
|
||||
/// Callback to reply the message
|
||||
final VoidCallback? onReply;
|
||||
|
||||
/// Callback to provide download files
|
||||
final AttachmentDownloader? fileDownloader;
|
||||
/// Callback to download [attachment].
|
||||
final AttachmentDownloader? attachmentDownloader;
|
||||
|
||||
/// Show reply option
|
||||
final bool showReply;
|
||||
@@ -69,34 +56,35 @@ class AttachmentActionsModal extends StatelessWidget {
|
||||
Attachment? attachment,
|
||||
Message? message,
|
||||
VoidCallback? onShowMessage,
|
||||
AttachmentDownloader? imageDownloader,
|
||||
AttachmentDownloader? fileDownloader,
|
||||
AttachmentDownloader? attachmentDownloader,
|
||||
bool? showReply,
|
||||
bool? showShowInChat,
|
||||
bool? showSave,
|
||||
bool? showDelete,
|
||||
List<AttachmentAction>? customActions,
|
||||
}) =>
|
||||
AttachmentActionsModal(
|
||||
key: key ?? this.key,
|
||||
attachment: attachment ?? this.attachment,
|
||||
message: message ?? this.message,
|
||||
onShowMessage: onShowMessage ?? this.onShowMessage,
|
||||
imageDownloader: imageDownloader ?? this.imageDownloader,
|
||||
fileDownloader: fileDownloader ?? this.fileDownloader,
|
||||
showReply: showReply ?? this.showReply,
|
||||
showShowInChat: showShowInChat ?? this.showShowInChat,
|
||||
showSave: showSave ?? this.showSave,
|
||||
showDelete: showDelete ?? this.showDelete,
|
||||
customActions: customActions ?? this.customActions,
|
||||
);
|
||||
}) {
|
||||
return AttachmentActionsModal(
|
||||
key: key ?? this.key,
|
||||
attachment: attachment ?? this.attachment,
|
||||
message: message ?? this.message,
|
||||
onShowMessage: onShowMessage ?? this.onShowMessage,
|
||||
attachmentDownloader: attachmentDownloader ?? this.attachmentDownloader,
|
||||
showReply: showReply ?? this.showReply,
|
||||
showShowInChat: showShowInChat ?? this.showShowInChat,
|
||||
showSave: showSave ?? this.showSave,
|
||||
showDelete: showDelete ?? this.showDelete,
|
||||
customActions: customActions ?? this.customActions,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () => Navigator.maybePop(context),
|
||||
child: _buildPage(context),
|
||||
);
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () => Navigator.of(context).maybePop(),
|
||||
child: _buildPage(context),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPage(BuildContext context) {
|
||||
final theme = StreamChatTheme.of(context);
|
||||
@@ -125,9 +113,7 @@ class AttachmentActionsModal extends StatelessWidget {
|
||||
size: 24,
|
||||
color: theme.colorTheme.textLowEmphasis,
|
||||
),
|
||||
() {
|
||||
Navigator.pop(context, ReturnActionType.reply);
|
||||
},
|
||||
() => Navigator.of(context).pop(ReturnActionType.reply),
|
||||
),
|
||||
if (showShowInChat)
|
||||
_buildButton(
|
||||
@@ -150,46 +136,44 @@ class AttachmentActionsModal extends StatelessWidget {
|
||||
color: theme.colorTheme.textLowEmphasis,
|
||||
),
|
||||
() {
|
||||
final isImage = attachment.type == 'image';
|
||||
final Future<String?> Function(
|
||||
Attachment, {
|
||||
void Function(int, int) progressCallback,
|
||||
DownloadedPathCallback downloadedPathCallback,
|
||||
}) saveFile = fileDownloader ?? _downloadAttachment;
|
||||
final Future<String?> Function(
|
||||
Attachment, {
|
||||
void Function(int, int) progressCallback,
|
||||
DownloadedPathCallback downloadedPathCallback,
|
||||
}) saveImage = imageDownloader ?? _downloadAttachment;
|
||||
final downloader = isImage ? saveImage : saveFile;
|
||||
// Closing attachment actions modal before opening
|
||||
// attachment download dialog
|
||||
Navigator.of(context).pop();
|
||||
|
||||
final downloader = attachmentDownloader ??
|
||||
StreamAttachmentHandler.instance.downloadAttachment;
|
||||
|
||||
// No need to show progress dialog in case of
|
||||
// web or desktop.
|
||||
if (isDesktopDeviceOrWeb) {
|
||||
downloader(attachment);
|
||||
return;
|
||||
}
|
||||
|
||||
final progressNotifier =
|
||||
ValueNotifier<_DownloadProgress?>(
|
||||
_DownloadProgress.initial(),
|
||||
);
|
||||
final downloadedPathNotifier = ValueNotifier<String?>(
|
||||
null,
|
||||
);
|
||||
|
||||
final downloadedPathNotifier =
|
||||
ValueNotifier<String?>(null);
|
||||
|
||||
downloader(
|
||||
attachment,
|
||||
progressCallback: (received, total) {
|
||||
onReceiveProgress: (received, total) {
|
||||
progressNotifier.value = _DownloadProgress(
|
||||
total,
|
||||
received,
|
||||
);
|
||||
},
|
||||
downloadedPathCallback: (String? path) {
|
||||
downloadedPathNotifier.value = path;
|
||||
},
|
||||
).catchError((e, stk) {
|
||||
).then((path) {
|
||||
downloadedPathNotifier.value = path;
|
||||
}).catchError((e, stk) {
|
||||
print(e);
|
||||
print(stk);
|
||||
progressNotifier.value = null;
|
||||
});
|
||||
|
||||
// Closing attachment actions modal before opening
|
||||
// attachment download dialog
|
||||
Navigator.pop(context);
|
||||
|
||||
showDialog(
|
||||
barrierDismissible: false,
|
||||
context: context,
|
||||
@@ -273,30 +257,31 @@ class AttachmentActionsModal extends StatelessWidget {
|
||||
VoidCallback? onTap, {
|
||||
Color? color,
|
||||
Key? key,
|
||||
}) =>
|
||||
Material(
|
||||
key: key,
|
||||
color: StreamChatTheme.of(context).colorTheme.barsBg,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
icon,
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
title,
|
||||
style: StreamChatTheme.of(context)
|
||||
.textTheme
|
||||
.body
|
||||
.copyWith(color: color),
|
||||
),
|
||||
],
|
||||
),
|
||||
}) {
|
||||
return Material(
|
||||
key: key,
|
||||
color: StreamChatTheme.of(context).colorTheme.barsBg,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
icon,
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
title,
|
||||
style: StreamChatTheme.of(context)
|
||||
.textTheme
|
||||
.body
|
||||
.copyWith(color: color),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDownloadProgressDialog(
|
||||
BuildContext context,
|
||||
@@ -385,29 +370,6 @@ class AttachmentActionsModal extends StatelessWidget {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _downloadAttachment(
|
||||
Attachment attachment, {
|
||||
ProgressCallback? progressCallback,
|
||||
DownloadedPathCallback? downloadedPathCallback,
|
||||
}) async {
|
||||
String? filePath;
|
||||
final appDocDir = await getTemporaryDirectory();
|
||||
final url =
|
||||
attachment.assetUrl ?? attachment.imageUrl ?? attachment.thumbUrl!;
|
||||
await Dio().download(
|
||||
url,
|
||||
(Headers responseHeaders) {
|
||||
final ext = Uri.parse(url).pathSegments.last;
|
||||
filePath ??= '${appDocDir.path}/${attachment.id}.$ext';
|
||||
return filePath!;
|
||||
},
|
||||
onReceiveProgress: progressCallback,
|
||||
);
|
||||
final result = await ImageGallerySaver.saveFile(filePath!);
|
||||
downloadedPathCallback?.call((result as Map)['filePath'] ?? filePath);
|
||||
return (result as Map)['filePath'];
|
||||
}
|
||||
}
|
||||
|
||||
class _DownloadProgress {
|
||||
@@ -426,9 +388,11 @@ class _DownloadProgress {
|
||||
int get toPercentage => (received * 100) ~/ total;
|
||||
}
|
||||
|
||||
/// Class for custom attachment action
|
||||
/// {@template attachmentAction}
|
||||
/// Defines a custom attachment action.
|
||||
/// {@endtemplate}
|
||||
class AttachmentAction {
|
||||
/// Constructor for custom attachment action
|
||||
/// {@macro attachmentAction}
|
||||
AttachmentAction({
|
||||
required this.actionTitle,
|
||||
required this.icon,
|
||||
@@ -0,0 +1,642 @@
|
||||
// ignore_for_file: no-empty-block
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_portal/flutter_portal.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
export 'stream_command_autocomplete_options.dart';
|
||||
export 'stream_mention_autocomplete_options.dart';
|
||||
|
||||
/// {@macro stream_chat_flutter.StreamMessageInputController}
|
||||
typedef StreamMessageEditingController = StreamMessageInputController;
|
||||
|
||||
/// Positions the [AutocompleteTrigger] options around the [TextField] or
|
||||
/// [TextFormField] that triggered the autocomplete.
|
||||
enum OptionsAlignment {
|
||||
/// The options are displayed below the field.
|
||||
below,
|
||||
|
||||
/// The options are displayed above the field.
|
||||
///
|
||||
/// This is the default.
|
||||
above;
|
||||
|
||||
Anchor _toAnchor() {
|
||||
switch (this) {
|
||||
case OptionsAlignment.below:
|
||||
return const Aligned(
|
||||
widthFactor: 1,
|
||||
follower: Alignment.topCenter,
|
||||
target: Alignment.bottomCenter,
|
||||
);
|
||||
case OptionsAlignment.above:
|
||||
return const Aligned(
|
||||
widthFactor: 1,
|
||||
follower: Alignment.bottomCenter,
|
||||
target: Alignment.topCenter,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The type of the Autocomplete callback which returns the widget that
|
||||
/// contains the input [TextField] or [TextFormField].
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [StreamAutocomplete.fieldViewBuilder], which is of this type.
|
||||
typedef StreamAutocompleteFieldViewBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
StreamMessageEditingController messageEditingController,
|
||||
FocusNode focusNode,
|
||||
);
|
||||
|
||||
/// The type of the [StreamAutocompleteTrigger] callback which returns a
|
||||
/// [Widget] that displays the specified [options].
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [StreamAutocompleteTrigger.optionsViewBuilder], which is of this type.
|
||||
typedef StreamAutocompleteOptionsViewBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
StreamAutocompleteQuery autocompleteQuery,
|
||||
StreamMessageEditingController messageEditingController,
|
||||
);
|
||||
|
||||
/// The query to determine the autocomplete options.
|
||||
class StreamAutocompleteQuery {
|
||||
/// Creates a [StreamAutocompleteQuery] with the specified [query] and
|
||||
/// [selection].
|
||||
const StreamAutocompleteQuery({
|
||||
required this.query,
|
||||
required this.selection,
|
||||
});
|
||||
|
||||
/// The query string.
|
||||
final String query;
|
||||
|
||||
/// The selection in the text field.
|
||||
final TextSelection selection;
|
||||
}
|
||||
|
||||
class _StreamAutocompleteInvokedTriggerWithQuery {
|
||||
const _StreamAutocompleteInvokedTriggerWithQuery(this.trigger, this.query);
|
||||
|
||||
final StreamAutocompleteTrigger trigger;
|
||||
final StreamAutocompleteQuery query;
|
||||
}
|
||||
|
||||
/// The class responsible for triggering autocomplete suggestions and
|
||||
/// displaying the options.
|
||||
class StreamAutocompleteTrigger {
|
||||
/// Creates a [StreamAutocompleteTrigger] which can be used to trigger
|
||||
/// autocomplete suggestions.
|
||||
const StreamAutocompleteTrigger({
|
||||
required this.trigger,
|
||||
required this.optionsViewBuilder,
|
||||
this.triggerOnlyAfterSpace = false,
|
||||
this.triggerOnlyAtStart = false,
|
||||
this.minimumRequiredCharacters = 0,
|
||||
});
|
||||
|
||||
/// The trigger character.
|
||||
///
|
||||
/// eg. '@', '#', ':'
|
||||
final String trigger;
|
||||
|
||||
/// Whether the [trigger] should only be recognised at the start of the input.
|
||||
final bool triggerOnlyAtStart;
|
||||
|
||||
/// Whether the [trigger] should only be recognised after a space.
|
||||
final bool triggerOnlyAfterSpace;
|
||||
|
||||
/// The minimum required characters for the [trigger] to start recognising
|
||||
/// a autocomplete options.
|
||||
final int minimumRequiredCharacters;
|
||||
|
||||
/// Builds the widget responsible for querying and displaying the
|
||||
/// autocomplete options.
|
||||
///
|
||||
/// See also:
|
||||
/// * [StreamAutocompleteOptions], which helps in displaying the options.
|
||||
final StreamAutocompleteOptionsViewBuilder optionsViewBuilder;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is StreamAutocompleteTrigger &&
|
||||
runtimeType == other.runtimeType &&
|
||||
trigger == other.trigger &&
|
||||
triggerOnlyAtStart == other.triggerOnlyAtStart &&
|
||||
triggerOnlyAfterSpace == other.triggerOnlyAfterSpace &&
|
||||
minimumRequiredCharacters == other.minimumRequiredCharacters;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
trigger.hashCode ^
|
||||
triggerOnlyAtStart.hashCode ^
|
||||
triggerOnlyAfterSpace.hashCode ^
|
||||
minimumRequiredCharacters.hashCode;
|
||||
|
||||
/// Checks if the user is invoking the recognising [trigger] and returns
|
||||
/// the autocomplete query if so.
|
||||
StreamAutocompleteQuery? invokingTrigger(
|
||||
Message message,
|
||||
TextEditingValue textEditingValue,
|
||||
) {
|
||||
final text = textEditingValue.text;
|
||||
final cursorPosition = textEditingValue.selection.baseOffset;
|
||||
|
||||
// Find the first [trigger] location before the input cursor.
|
||||
final firstTriggerIndexBeforeCursor =
|
||||
text.substring(0, cursorPosition).lastIndexOf(trigger);
|
||||
|
||||
// If the [trigger] is not found before the cursor, then it's not a trigger.
|
||||
if (firstTriggerIndexBeforeCursor == -1) return null;
|
||||
|
||||
// If the [trigger] is found before the cursor, but the [trigger] is only
|
||||
// recognised at the start of the input, then it's not a trigger.
|
||||
if (triggerOnlyAtStart && firstTriggerIndexBeforeCursor != 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only show typing suggestions after a space, or at the start of the input
|
||||
// valid examples: "@user", "Hello @user"
|
||||
// invalid examples: "Hello@user"
|
||||
final textBeforeTrigger = text.substring(0, firstTriggerIndexBeforeCursor);
|
||||
if (triggerOnlyAfterSpace &&
|
||||
textBeforeTrigger.isNotEmpty &&
|
||||
!textBeforeTrigger.endsWith(' ')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The suggestion range. Protect against invalid ranges.
|
||||
final suggestionStart = firstTriggerIndexBeforeCursor + trigger.length;
|
||||
final suggestionEnd = cursorPosition;
|
||||
if (suggestionStart > suggestionEnd) return null;
|
||||
|
||||
// Fetch the suggestion text. The suggestions can't have spaces.
|
||||
// valid example: "@luke_skywa..."
|
||||
// invalid example: "@luke skywa..."
|
||||
final suggestionText = text.substring(suggestionStart, suggestionEnd);
|
||||
if (suggestionText.contains(' ')) return null;
|
||||
|
||||
// A minimum number of characters can be provided to only show
|
||||
// suggestions after the customer has input enough characters.
|
||||
if (suggestionText.length < minimumRequiredCharacters) return null;
|
||||
|
||||
return StreamAutocompleteQuery(
|
||||
query: suggestionText,
|
||||
selection: TextSelection(
|
||||
baseOffset: suggestionStart,
|
||||
extentOffset: suggestionEnd,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A widget that provides a text field with autocomplete functionality.
|
||||
class StreamAutocomplete extends StatefulWidget {
|
||||
/// Create an instance of StreamAutocomplete.
|
||||
///
|
||||
/// [displayStringForOption], [optionsBuilder] and [optionsViewBuilder] must
|
||||
/// not be null.
|
||||
const StreamAutocomplete({
|
||||
super.key,
|
||||
this.focusNode,
|
||||
this.messageEditingController,
|
||||
required this.autocompleteTriggers,
|
||||
this.fieldViewBuilder = _defaultFieldViewBuilder,
|
||||
this.optionsAlignment = OptionsAlignment.above,
|
||||
this.debounceDuration = const Duration(milliseconds: 300),
|
||||
}) : assert((focusNode == null) == (messageEditingController == null), '');
|
||||
|
||||
/// The triggers that trigger autocomplete.
|
||||
final Iterable<StreamAutocompleteTrigger> autocompleteTriggers;
|
||||
|
||||
/// Builds the field whose input is used to get the options.
|
||||
///
|
||||
/// Pass the provided [StreamMessageEditingController] to the field built
|
||||
/// here so that StreamAutocomplete can listen for changes.
|
||||
final StreamAutocompleteFieldViewBuilder fieldViewBuilder;
|
||||
|
||||
/// The [FocusNode] that is used for the text field.
|
||||
///
|
||||
/// The main purpose of this parameter is to allow the use of a separate text
|
||||
/// field located in another part of the widget tree instead of the text
|
||||
/// field built by [fieldViewBuilder]. For example, it may be desirable to
|
||||
/// place the text field in the AppBar and the options below in the main body.
|
||||
///
|
||||
/// When following this pattern, [fieldViewBuilder] can return
|
||||
/// `SizedBox.shrink()` so that nothing is drawn where the text field would
|
||||
/// normally be. A separate text field can be created elsewhere, and a
|
||||
/// FocusNode and StreamMessageEditingController can be passed both to that
|
||||
/// text field and to StreamAutocomplete.
|
||||
///
|
||||
/// If this parameter is not null, then [messageEditingController] must also
|
||||
/// be not null.
|
||||
final FocusNode? focusNode;
|
||||
|
||||
/// The [StreamMessageEditingController] that is used for the text field.
|
||||
///
|
||||
/// If this parameter is not null, then [focusNode] must also be not null.
|
||||
final StreamMessageEditingController? messageEditingController;
|
||||
|
||||
/// The alignment of the options.
|
||||
///
|
||||
/// The default value is [OptionsAlignment.above].
|
||||
final OptionsAlignment optionsAlignment;
|
||||
|
||||
/// The duration of the debounce period for the
|
||||
/// [StreamMessageEditingController].
|
||||
///
|
||||
/// The default value is [300ms].
|
||||
final Duration debounceDuration;
|
||||
|
||||
static Widget _defaultFieldViewBuilder(
|
||||
BuildContext context,
|
||||
StreamMessageEditingController messageEditingController,
|
||||
FocusNode focusNode,
|
||||
) {
|
||||
return _StreamAutocompleteField(
|
||||
focusNode: focusNode,
|
||||
messageEditingController: messageEditingController,
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns the nearest [StreamAutocomplete] ancestor of the given context.
|
||||
static _StreamAutocompleteState of(BuildContext context) {
|
||||
final state = context.findAncestorStateOfType<_StreamAutocompleteState>();
|
||||
assert(state != null, 'StreamAutocomplete not found in the widget tree');
|
||||
return state!;
|
||||
}
|
||||
|
||||
@override
|
||||
_StreamAutocompleteState createState() => _StreamAutocompleteState();
|
||||
}
|
||||
|
||||
class _StreamAutocompleteState extends State<StreamAutocomplete> {
|
||||
late StreamMessageEditingController _messageEditingController;
|
||||
late FocusNode _focusNode;
|
||||
|
||||
StreamAutocompleteQuery? _currentQuery;
|
||||
StreamAutocompleteTrigger? _currentTrigger;
|
||||
|
||||
bool _hideOptions = false;
|
||||
String _lastFieldText = '';
|
||||
|
||||
// True if the state indicates that the options should be visible.
|
||||
bool get _shouldShowOptions {
|
||||
return !_hideOptions &&
|
||||
_focusNode.hasFocus &&
|
||||
_currentQuery != null &&
|
||||
_currentTrigger != null;
|
||||
}
|
||||
|
||||
/// Accepts and replaces the current query with the given [option] and closes
|
||||
/// the suggested options.
|
||||
///
|
||||
/// Optionally, pass [keepTrigger] false to remove the trigger from the text.
|
||||
void acceptAutocompleteOption(
|
||||
String option, {
|
||||
bool keepTrigger = true,
|
||||
}) {
|
||||
if (option.isEmpty) return;
|
||||
|
||||
final query = _currentQuery;
|
||||
final trigger = _currentTrigger;
|
||||
if (query == null || trigger == null) return;
|
||||
|
||||
final querySelection = query.selection;
|
||||
final text = _messageEditingController.text;
|
||||
|
||||
var start = querySelection.baseOffset;
|
||||
if (!keepTrigger) start -= 1;
|
||||
|
||||
final end = querySelection.extentOffset;
|
||||
|
||||
final alreadyContainsSpace = text.substring(end).startsWith(' ');
|
||||
// Having extra space helps dismissing the auto-completion view.
|
||||
// ignore: parameter_assignments
|
||||
if (!alreadyContainsSpace) option += ' ';
|
||||
|
||||
var selectionOffset = start + option.length;
|
||||
// In case the extra space is already there, we need to move the cursor
|
||||
// after the space.
|
||||
if (alreadyContainsSpace) selectionOffset += 1;
|
||||
|
||||
final newText = text.replaceRange(start, end, option);
|
||||
final newSelection = TextSelection.collapsed(offset: selectionOffset);
|
||||
|
||||
_messageEditingController.textEditingValue = TextEditingValue(
|
||||
text: newText,
|
||||
selection: newSelection,
|
||||
);
|
||||
|
||||
return closeSuggestions();
|
||||
}
|
||||
|
||||
/// Closes the suggestions and resets the current query.
|
||||
void closeSuggestions() {
|
||||
final prev = _currentQuery;
|
||||
if (prev == null) return;
|
||||
|
||||
_currentQuery = null;
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
/// Starts showing the suggestions for the given [query].
|
||||
void showSuggestions(
|
||||
StreamAutocompleteQuery query,
|
||||
StreamAutocompleteTrigger trigger,
|
||||
) {
|
||||
final prevQuery = _currentQuery;
|
||||
final prevTrigger = _currentTrigger;
|
||||
if (prevQuery == query && prevTrigger == trigger) return;
|
||||
|
||||
_currentQuery = query;
|
||||
_currentTrigger = trigger;
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
// Checks if there is any invoked autocomplete trigger and returns the first
|
||||
// one along with the query that matches the current input.
|
||||
_StreamAutocompleteInvokedTriggerWithQuery? _getInvokedTriggerWithQuery(
|
||||
Message messageValue,
|
||||
TextEditingValue textEditingValue,
|
||||
) {
|
||||
final autocompleteTriggers = widget.autocompleteTriggers.toSet();
|
||||
for (final trigger in autocompleteTriggers) {
|
||||
final query = trigger.invokingTrigger(messageValue, textEditingValue);
|
||||
if (query != null) {
|
||||
return _StreamAutocompleteInvokedTriggerWithQuery(trigger, query);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Called when _textEditingController changes.
|
||||
late final _onChangedField = debounce(
|
||||
() {
|
||||
final messageValue = _messageEditingController.message;
|
||||
final textEditingValue = _messageEditingController.textEditingValue;
|
||||
|
||||
// If the content has not changed, then there is nothing to do.
|
||||
if (textEditingValue.text == _lastFieldText) return;
|
||||
|
||||
// Make sure the options are no longer hidden if the content of the
|
||||
// field changes.
|
||||
_hideOptions = false;
|
||||
_lastFieldText = textEditingValue.text;
|
||||
|
||||
// If the text field is empty, then there is no need to do anything.
|
||||
if (textEditingValue.text.isEmpty) return closeSuggestions();
|
||||
|
||||
// If the text field is not empty, then we need to check if the
|
||||
// text field contains a trigger.
|
||||
final _triggerWithQuery = _getInvokedTriggerWithQuery(
|
||||
messageValue,
|
||||
textEditingValue,
|
||||
);
|
||||
|
||||
// If the text field does not contain a trigger, then there is no need
|
||||
// to do anything.
|
||||
if (_triggerWithQuery == null) return closeSuggestions();
|
||||
|
||||
// If the text field contains a trigger, then we need to open the
|
||||
// portal.
|
||||
final trigger = _triggerWithQuery.trigger;
|
||||
final query = _triggerWithQuery.query;
|
||||
return showSuggestions(query, trigger);
|
||||
},
|
||||
widget.debounceDuration,
|
||||
);
|
||||
|
||||
// Called when the field's FocusNode changes.
|
||||
void _onChangedFocus() {
|
||||
// Options should no longer be hidden when the field is re-focused.
|
||||
_hideOptions = !_focusNode.hasFocus;
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
// Handle a potential change in textEditingController by properly disposing of
|
||||
// the old one and setting up the new one, if needed.
|
||||
void _updateTextEditingController(
|
||||
StreamMessageEditingController? old,
|
||||
StreamMessageEditingController? current,
|
||||
) {
|
||||
if ((old == null && current == null) || old == current) {
|
||||
return;
|
||||
}
|
||||
if (old == null) {
|
||||
_messageEditingController
|
||||
..removeListener(_onChangedField)
|
||||
..dispose();
|
||||
_messageEditingController = current!;
|
||||
} else if (current == null) {
|
||||
_messageEditingController.removeListener(_onChangedField);
|
||||
_messageEditingController = StreamMessageEditingController();
|
||||
} else {
|
||||
_messageEditingController.removeListener(_onChangedField);
|
||||
_messageEditingController = current;
|
||||
}
|
||||
_messageEditingController.addListener(_onChangedField);
|
||||
}
|
||||
|
||||
// Handle a potential change in focusNode by properly disposing of the old one
|
||||
// and setting up the new one, if needed.
|
||||
void _updateFocusNode(FocusNode? old, FocusNode? current) {
|
||||
if ((old == null && current == null) || old == current) {
|
||||
return;
|
||||
}
|
||||
if (old == null) {
|
||||
_focusNode
|
||||
..removeListener(_onChangedFocus)
|
||||
..dispose();
|
||||
_focusNode = current!;
|
||||
} else if (current == null) {
|
||||
_focusNode.removeListener(_onChangedFocus);
|
||||
_focusNode = FocusNode();
|
||||
} else {
|
||||
_focusNode.removeListener(_onChangedFocus);
|
||||
_focusNode = current;
|
||||
}
|
||||
_focusNode.addListener(_onChangedFocus);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_messageEditingController =
|
||||
widget.messageEditingController ?? StreamMessageEditingController();
|
||||
_messageEditingController.addListener(_onChangedField);
|
||||
_focusNode = widget.focusNode ?? FocusNode();
|
||||
_focusNode.addListener(_onChangedFocus);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(StreamAutocomplete oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_updateTextEditingController(
|
||||
oldWidget.messageEditingController,
|
||||
widget.messageEditingController,
|
||||
);
|
||||
_updateFocusNode(oldWidget.focusNode, widget.focusNode);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_messageEditingController.removeListener(_onChangedField);
|
||||
if (widget.messageEditingController == null) {
|
||||
_messageEditingController.dispose();
|
||||
}
|
||||
_focusNode.removeListener(_onChangedFocus);
|
||||
if (widget.focusNode == null) {
|
||||
_focusNode.dispose();
|
||||
}
|
||||
_onChangedField.cancel();
|
||||
closeSuggestions();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Adding additional builder so that [.of] works.
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
final anchor = widget.optionsAlignment._toAnchor();
|
||||
final shouldShowOptions = _shouldShowOptions;
|
||||
final optionViewBuilder = shouldShowOptions
|
||||
? _currentTrigger!.optionsViewBuilder(
|
||||
context,
|
||||
_currentQuery!,
|
||||
_messageEditingController,
|
||||
)
|
||||
: null;
|
||||
|
||||
return PortalTarget(
|
||||
anchor: anchor,
|
||||
visible: shouldShowOptions,
|
||||
portalFollower: optionViewBuilder,
|
||||
child: widget.fieldViewBuilder(
|
||||
context,
|
||||
_messageEditingController,
|
||||
_focusNode,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The default Material-style Autocomplete text field.
|
||||
class _StreamAutocompleteField extends StatelessWidget {
|
||||
const _StreamAutocompleteField({
|
||||
required this.focusNode,
|
||||
required this.messageEditingController,
|
||||
});
|
||||
|
||||
final FocusNode focusNode;
|
||||
|
||||
final StreamMessageEditingController messageEditingController;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamMessageTextField(
|
||||
controller: messageEditingController,
|
||||
focusNode: focusNode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const _kDefaultStreamAutocompleteOptionsShape = RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
);
|
||||
|
||||
/// A helper widget used to show the options of a [StreamAutocomplete].
|
||||
class StreamAutocompleteOptions<T extends Object> extends StatelessWidget {
|
||||
/// Creates a [StreamAutocompleteOptions] widget.
|
||||
const StreamAutocompleteOptions({
|
||||
super.key,
|
||||
this.color,
|
||||
this.elevation = 2,
|
||||
this.margin = const EdgeInsets.all(8),
|
||||
this.clipBehavior = Clip.hardEdge,
|
||||
required this.options,
|
||||
this.maxHeight,
|
||||
required this.optionBuilder,
|
||||
this.headerBuilder,
|
||||
this.shape = _kDefaultStreamAutocompleteOptionsShape,
|
||||
});
|
||||
|
||||
/// The background color of the options card.
|
||||
///
|
||||
/// Defaults to [StreamColorTheme.barsBg].
|
||||
final Color? color;
|
||||
|
||||
/// The elevation of the options card.
|
||||
///
|
||||
/// The default value is 2.
|
||||
final double elevation;
|
||||
|
||||
/// The margin of the options card.
|
||||
///
|
||||
/// The default value is [EdgeInsets.all(8)].
|
||||
final EdgeInsetsGeometry margin;
|
||||
|
||||
/// The clip behavior of the options card.
|
||||
///
|
||||
/// The default value is [Clip.hardEdge].
|
||||
final Clip clipBehavior;
|
||||
|
||||
/// The shape of the options card.
|
||||
final ShapeBorder shape;
|
||||
|
||||
/// The options to display.
|
||||
final Iterable<T> options;
|
||||
|
||||
/// The maximum height of the options card.
|
||||
///
|
||||
/// Defaults to half the height of the screen.
|
||||
final double? maxHeight;
|
||||
|
||||
/// The builder for the options.
|
||||
final Widget Function(BuildContext context, T option) optionBuilder;
|
||||
|
||||
/// The builder for the header of the options.
|
||||
final WidgetBuilder? headerBuilder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final height = MediaQuery.of(context).size.height;
|
||||
final colorTheme = StreamChatTheme.of(context).colorTheme;
|
||||
return Card(
|
||||
margin: margin,
|
||||
elevation: elevation,
|
||||
color: color ?? colorTheme.barsBg,
|
||||
shape: shape,
|
||||
clipBehavior: clipBehavior,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (headerBuilder != null) ...[
|
||||
headerBuilder!(context),
|
||||
const Divider(height: 0),
|
||||
],
|
||||
LimitedBox(
|
||||
maxHeight: maxHeight ?? height * 0.5,
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: options.length,
|
||||
itemBuilder: (context, index) {
|
||||
final option = options.elementAt(index);
|
||||
return optionBuilder(context, option);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/autocomplete/stream_autocomplete.dart';
|
||||
import 'package:stream_chat_flutter/src/misc/stream_svg_icon.dart';
|
||||
import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/extensions.dart';
|
||||
|
||||
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
|
||||
|
||||
/// {@template commands_overlay}
|
||||
/// Overlay for displaying commands that can be used
|
||||
/// to interact with the channel.
|
||||
/// {@endtemplate}
|
||||
class StreamCommandAutocompleteOptions extends StatelessWidget {
|
||||
/// Constructor for creating a [StreamCommandAutocompleteOptions]
|
||||
const StreamCommandAutocompleteOptions({
|
||||
required this.query,
|
||||
required this.channel,
|
||||
this.onCommandSelected,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Query for searching commands.
|
||||
final String query;
|
||||
|
||||
/// The channel to search for users.
|
||||
final Channel channel;
|
||||
|
||||
/// Callback called when a command is selected.
|
||||
final ValueSetter<Command>? onCommandSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final commands = channel.config?.commands.where((it) {
|
||||
final normalizedQuery = query.toUpperCase();
|
||||
final normalizedName = it.name.toUpperCase();
|
||||
return normalizedName.contains(normalizedQuery);
|
||||
});
|
||||
|
||||
if (commands == null || commands.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
final streamChatTheme = StreamChatTheme.of(context);
|
||||
final colorTheme = streamChatTheme.colorTheme;
|
||||
final textTheme = streamChatTheme.textTheme;
|
||||
|
||||
return StreamAutocompleteOptions<Command>(
|
||||
options: commands,
|
||||
headerBuilder: (context) {
|
||||
return ListTile(
|
||||
dense: true,
|
||||
horizontalTitleGap: 0,
|
||||
leading: StreamSvgIcon.lightning(
|
||||
color: colorTheme.accentPrimary,
|
||||
size: 28,
|
||||
),
|
||||
title: Text(
|
||||
context.translations.instantCommandsLabel,
|
||||
style: TextStyle(
|
||||
color: colorTheme.textHighEmphasis.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
optionBuilder: (context, command) {
|
||||
return ListTile(
|
||||
dense: true,
|
||||
horizontalTitleGap: 0,
|
||||
leading: _CommandIcon(command: command),
|
||||
title: Row(
|
||||
children: [
|
||||
Text(
|
||||
command.name.capitalize(),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'/${command.name} ${command.args}',
|
||||
style: textTheme.body.copyWith(
|
||||
color: colorTheme.textLowEmphasis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: onCommandSelected == null
|
||||
? null
|
||||
: () => onCommandSelected!(command),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CommandIcon extends StatelessWidget {
|
||||
const _CommandIcon({required this.command});
|
||||
|
||||
final Command command;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final _streamChatTheme = StreamChatTheme.of(context);
|
||||
switch (command.name) {
|
||||
case 'giphy':
|
||||
return CircleAvatar(
|
||||
radius: 12,
|
||||
child: StreamSvgIcon.giphyIcon(
|
||||
size: 24,
|
||||
),
|
||||
);
|
||||
case 'ban':
|
||||
return CircleAvatar(
|
||||
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
|
||||
radius: 12,
|
||||
child: StreamSvgIcon.iconUserDelete(
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
case 'flag':
|
||||
return CircleAvatar(
|
||||
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
|
||||
radius: 12,
|
||||
child: StreamSvgIcon.flag(
|
||||
size: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
case 'imgur':
|
||||
return CircleAvatar(
|
||||
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
|
||||
radius: 12,
|
||||
child: ClipOval(
|
||||
child: StreamSvgIcon.imgur(
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
);
|
||||
case 'mute':
|
||||
return CircleAvatar(
|
||||
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
|
||||
radius: 12,
|
||||
child: StreamSvgIcon.mute(
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
case 'unban':
|
||||
return CircleAvatar(
|
||||
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
|
||||
radius: 12,
|
||||
child: StreamSvgIcon.userAdd(
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
case 'unmute':
|
||||
return CircleAvatar(
|
||||
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
|
||||
radius: 12,
|
||||
child: StreamSvgIcon.volumeUp(
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
default:
|
||||
return CircleAvatar(
|
||||
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
|
||||
radius: 12,
|
||||
child: StreamSvgIcon.lightning(
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
-63
@@ -1,31 +1,20 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/extension.dart';
|
||||
import 'package:stream_chat_flutter/src/stream_chat_theme.dart';
|
||||
import 'package:stream_chat_flutter/src/user_mention_tile.dart';
|
||||
import 'package:stream_chat_flutter/src/autocomplete/stream_autocomplete.dart';
|
||||
import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart';
|
||||
import 'package:stream_chat_flutter/src/user/user_mention_tile.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/extensions.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/typedefs.dart';
|
||||
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
|
||||
|
||||
/// Builder function for building a mention tile.
|
||||
///
|
||||
/// Use [StreamUserMentionTile] for the default implementation.
|
||||
typedef MentionTileBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
User user,
|
||||
);
|
||||
|
||||
/// {@macro user_mention_tile}
|
||||
@Deprecated("Use 'StreamUserMentionsOverlay' instead")
|
||||
typedef UserMentionsOverlay = StreamUserMentionsOverlay;
|
||||
|
||||
/// {@template user_mentions_overlay}
|
||||
/// Overlay for displaying users that can be mentioned.
|
||||
/// {@endtemplate}
|
||||
class StreamUserMentionsOverlay extends StatefulWidget {
|
||||
/// Constructor for creating a [StreamUserMentionsOverlay].
|
||||
StreamUserMentionsOverlay({
|
||||
class StreamMentionAutocompleteOptions extends StatefulWidget {
|
||||
/// Constructor for creating a [StreamMentionAutocompleteOptions].
|
||||
StreamMentionAutocompleteOptions({
|
||||
super.key,
|
||||
required this.query,
|
||||
required this.channel,
|
||||
required this.size,
|
||||
this.client,
|
||||
this.limit = 10,
|
||||
this.mentionAllAppUsers = false,
|
||||
@@ -46,9 +35,6 @@ class StreamUserMentionsOverlay extends StatefulWidget {
|
||||
/// Limit applied on user search results.
|
||||
final int limit;
|
||||
|
||||
/// The size of the overlay.
|
||||
final Size size;
|
||||
|
||||
/// The channel to search for users.
|
||||
final Channel channel;
|
||||
|
||||
@@ -61,17 +47,18 @@ class StreamUserMentionsOverlay extends StatefulWidget {
|
||||
final bool mentionAllAppUsers;
|
||||
|
||||
/// Customize the tile for the mentions overlay.
|
||||
final MentionTileBuilder? mentionsTileBuilder;
|
||||
final UserMentionTileBuilder? mentionsTileBuilder;
|
||||
|
||||
/// Callback called when a user is selected.
|
||||
final void Function(User user)? onMentionUserTap;
|
||||
final ValueSetter<User>? onMentionUserTap;
|
||||
|
||||
@override
|
||||
_StreamUserMentionsOverlayState createState() =>
|
||||
_StreamUserMentionsOverlayState();
|
||||
_StreamMentionAutocompleteOptionsState createState() =>
|
||||
_StreamMentionAutocompleteOptionsState();
|
||||
}
|
||||
|
||||
class _StreamUserMentionsOverlayState extends State<StreamUserMentionsOverlay> {
|
||||
class _StreamMentionAutocompleteOptionsState
|
||||
extends State<StreamMentionAutocompleteOptions> {
|
||||
late Future<List<User>> userMentionsFuture;
|
||||
|
||||
@override
|
||||
@@ -81,7 +68,7 @@ class _StreamUserMentionsOverlayState extends State<StreamUserMentionsOverlay> {
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant StreamUserMentionsOverlay oldWidget) {
|
||||
void didUpdateWidget(covariant StreamMentionAutocompleteOptions oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.channel != oldWidget.channel ||
|
||||
widget.query != oldWidget.query ||
|
||||
@@ -93,43 +80,30 @@ class _StreamUserMentionsOverlayState extends State<StreamUserMentionsOverlay> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = StreamChatTheme.of(context);
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(8),
|
||||
elevation: 2,
|
||||
color: theme.colorTheme.barsBg,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: Container(
|
||||
constraints: BoxConstraints.loose(widget.size),
|
||||
decoration: BoxDecoration(color: theme.colorTheme.barsBg),
|
||||
child: FutureBuilder<List<User>>(
|
||||
future: userMentionsFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) return const Offstage();
|
||||
if (!snapshot.hasData) return const Offstage();
|
||||
final users = snapshot.data!;
|
||||
return ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
shrinkWrap: true,
|
||||
itemCount: users.length,
|
||||
itemBuilder: (context, index) {
|
||||
final user = users[index];
|
||||
return Material(
|
||||
color: theme.colorTheme.barsBg,
|
||||
child: InkWell(
|
||||
onTap: () => widget.onMentionUserTap?.call(user),
|
||||
child: widget.mentionsTileBuilder?.call(context, user) ??
|
||||
StreamUserMentionTile(user),
|
||||
),
|
||||
);
|
||||
},
|
||||
return FutureBuilder<List<User>>(
|
||||
future: userMentionsFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) return const SizedBox.shrink();
|
||||
if (!snapshot.hasData) return const SizedBox.shrink();
|
||||
final users = snapshot.data!;
|
||||
|
||||
return StreamAutocompleteOptions<User>(
|
||||
options: users,
|
||||
optionBuilder: (context, user) {
|
||||
final colorTheme = StreamChatTheme.of(context).colorTheme;
|
||||
return Material(
|
||||
color: colorTheme.barsBg,
|
||||
child: InkWell(
|
||||
onTap: widget.onMentionUserTap == null
|
||||
? null
|
||||
: () => widget.onMentionUserTap!(user),
|
||||
child: widget.mentionsTileBuilder?.call(context, user) ??
|
||||
StreamUserMentionTile(user),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+27
-25
@@ -3,15 +3,11 @@ import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// {@macro gradient_avatar}
|
||||
@Deprecated("Use 'StreamGradientAvatar' instead")
|
||||
typedef GradientAvatar = StreamGradientAvatar;
|
||||
|
||||
/// {@template gradient_avatar}
|
||||
/// Fallback user avatar with a polygon gradient overlayed with text
|
||||
/// {@template streamGradientAvatar}
|
||||
/// Fallback user avatar with a polygon gradient overlaid with text
|
||||
/// {@endtemplate}
|
||||
class StreamGradientAvatar extends StatefulWidget {
|
||||
/// Constructor for [StreamGradientAvatar]
|
||||
/// {@macro streamGradientAvatar}
|
||||
const StreamGradientAvatar({
|
||||
super.key,
|
||||
required this.name,
|
||||
@@ -30,18 +26,20 @@ class StreamGradientAvatar extends StatefulWidget {
|
||||
|
||||
class _StreamGradientAvatarState extends State<StreamGradientAvatar> {
|
||||
@override
|
||||
Widget build(BuildContext context) => Center(
|
||||
child: RepaintBoundary(
|
||||
child: CustomPaint(
|
||||
painter: DemoPainter(
|
||||
widget.userId,
|
||||
getShortenedName(widget.name),
|
||||
DefaultTextStyle.of(context).style.fontFamily ?? 'Roboto',
|
||||
),
|
||||
child: const SizedBox.expand(),
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: RepaintBoundary(
|
||||
child: CustomPaint(
|
||||
painter: PolygonGradientPainter(
|
||||
widget.userId,
|
||||
getShortenedName(widget.name),
|
||||
DefaultTextStyle.of(context).style.fontFamily ?? 'Roboto',
|
||||
),
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
);
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String getShortenedName(String name) {
|
||||
var parts = name.split(' ')..removeWhere((e) => e == '');
|
||||
@@ -60,19 +58,21 @@ class _StreamGradientAvatarState extends State<StreamGradientAvatar> {
|
||||
}
|
||||
}
|
||||
|
||||
/// {@template polygonGradientPainter}
|
||||
/// Painter for bg polygon gradient
|
||||
class DemoPainter extends CustomPainter {
|
||||
/// Constructor for [DemoPainter]
|
||||
DemoPainter(
|
||||
/// {@endtemplate}
|
||||
class PolygonGradientPainter extends CustomPainter {
|
||||
/// {@macro polygonGradientPainter}
|
||||
PolygonGradientPainter(
|
||||
this.userId,
|
||||
this.username,
|
||||
this.fontFamily,
|
||||
);
|
||||
|
||||
/// Init grid row count
|
||||
/// Initial grid row count
|
||||
static const int rowCount = 5;
|
||||
|
||||
/// Init grid column count
|
||||
/// Initial grid column count
|
||||
static const int columnCount = 5;
|
||||
|
||||
/// User ID used for key
|
||||
@@ -154,7 +154,7 @@ class DemoPainter extends CustomPainter {
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
|
||||
/// Transforms initial grid into a polygon grid
|
||||
/// Transforms initial grid into a polygon grid.
|
||||
List<Offset> transformPoints(Set<Offset> points, Size size) {
|
||||
final transformedList = <Offset>[];
|
||||
final orgList = points.toList();
|
||||
@@ -185,9 +185,11 @@ class DemoPainter extends CustomPainter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Class for storing and drawing four points of a polygon
|
||||
/// {@template offset4}
|
||||
/// Class for storing and drawing four points of a polygon.
|
||||
/// {@endtemplate}
|
||||
class Offset4 {
|
||||
/// Constructor for [Offset4]
|
||||
/// {@macro offset4}
|
||||
Offset4(
|
||||
this.p1,
|
||||
this.p2,
|
||||
+10
-11
@@ -1,15 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@macro group_avatar}
|
||||
@Deprecated("Use 'StreamGroupAvatar' instead")
|
||||
typedef GroupAvatar = StreamGroupAvatar;
|
||||
|
||||
/// {@template group_avatar}
|
||||
/// {@template streamGroupAvatar}
|
||||
/// Widget for constructing a group of images
|
||||
/// {@endtemplate}
|
||||
class StreamGroupAvatar extends StatelessWidget {
|
||||
/// Constructor for creating a [StreamGroupAvatar]
|
||||
/// {@macro streamGroupAvatar}
|
||||
const StreamGroupAvatar({
|
||||
super.key,
|
||||
this.channel,
|
||||
@@ -25,25 +21,28 @@ class StreamGroupAvatar extends StatelessWidget {
|
||||
/// The channel of the avatar
|
||||
final Channel? channel;
|
||||
|
||||
/// List of images to display
|
||||
/// The list of members in the group whose avatars should be displayed.
|
||||
final List<Member> members;
|
||||
|
||||
/// Constraints on the widget
|
||||
final BoxConstraints? constraints;
|
||||
|
||||
/// Callback when widget is tapped
|
||||
/// The action to perform when the widget is tapped
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// Highlights if selected
|
||||
/// If `true`, this widget should be highlighted.
|
||||
///
|
||||
/// Defaults to `false`.
|
||||
final bool selected;
|
||||
|
||||
/// [BorderRadius] to pass to the widget
|
||||
final BorderRadius? borderRadius;
|
||||
|
||||
/// Color of selection if selected
|
||||
/// The color to highlight the widget with if [selected] is `true`
|
||||
final Color? selectionColor;
|
||||
|
||||
/// Thickness with which color of selection is shown
|
||||
/// The value to use for the border thickness and padding of the
|
||||
/// selected image
|
||||
final double selectionThickness;
|
||||
|
||||
@override
|
||||
+23
-18
@@ -2,15 +2,11 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@macro user_avatar}
|
||||
@Deprecated("Use 'StreamUserAvatar' instead")
|
||||
typedef UserAvatar = StreamUserAvatar;
|
||||
|
||||
/// {@template user_avatar}
|
||||
/// Widget that displays a user avatar
|
||||
/// {@template streamUserAvatar}
|
||||
/// Displays a user's avatar.
|
||||
/// {@endtemplate}
|
||||
class StreamUserAvatar extends StatelessWidget {
|
||||
/// Constructor to create a [StreamUserAvatar]
|
||||
/// {@macro streamUserAvatar}
|
||||
const StreamUserAvatar({
|
||||
super.key,
|
||||
required this.user,
|
||||
@@ -27,54 +23,63 @@ class StreamUserAvatar extends StatelessWidget {
|
||||
this.placeholder,
|
||||
});
|
||||
|
||||
/// User whose avatar is to displayed
|
||||
/// User whose avatar is to be displayed
|
||||
final User user;
|
||||
|
||||
/// Alignment of the online indicator
|
||||
///
|
||||
/// Defaults to `Alignment.topRight`
|
||||
final Alignment onlineIndicatorAlignment;
|
||||
|
||||
/// Size of the avatar
|
||||
/// Sizing constraints of the avatar
|
||||
final BoxConstraints? constraints;
|
||||
|
||||
/// [BorderRadius] of the image
|
||||
final BorderRadius? borderRadius;
|
||||
|
||||
/// Size of the online indicator
|
||||
/// Sizing constraints of the online indicator
|
||||
final BoxConstraints? onlineIndicatorConstraints;
|
||||
|
||||
/// Callback when avatar is tapped
|
||||
final void Function(User)? onTap;
|
||||
/// {@macro onUserAvatarTap}
|
||||
final OnUserAvatarPress? onTap;
|
||||
|
||||
/// Callback when avatar is long pressed
|
||||
final void Function(User)? onLongPress;
|
||||
/// {@macro onUserAvatarTap}
|
||||
final OnUserAvatarPress? onLongPress;
|
||||
|
||||
/// Flag for showing online status
|
||||
///
|
||||
/// Defaults to `true`
|
||||
final bool showOnlineStatus;
|
||||
|
||||
/// Flag for if avatar is selected
|
||||
///
|
||||
/// Defaults to `false`
|
||||
final bool selected;
|
||||
|
||||
/// Color of selection
|
||||
final Color? selectionColor;
|
||||
|
||||
/// Selection thickness around the avatar
|
||||
///
|
||||
/// Defaults to `4`
|
||||
final double selectionThickness;
|
||||
|
||||
/// The widget that will be built when the user image is loading
|
||||
final Widget Function(BuildContext, User)? placeholder;
|
||||
/// {@macro placeholderUserImage}
|
||||
final PlaceholderUserImage? placeholder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasImage = user.image != null && user.image!.isNotEmpty;
|
||||
final streamChatTheme = StreamChatTheme.of(context);
|
||||
final streamChatConfig = StreamChatConfiguration.of(context);
|
||||
|
||||
final placeholder =
|
||||
this.placeholder ?? streamChatTheme.placeholderUserImage;
|
||||
this.placeholder ?? streamChatConfig.placeholderUserImage;
|
||||
|
||||
final backupGradientAvatar = ClipRRect(
|
||||
borderRadius: borderRadius ??
|
||||
streamChatTheme.ownMessageTheme.avatarTheme?.borderRadius,
|
||||
child: streamChatTheme.defaultUserImage(context, user),
|
||||
child: streamChatConfig.defaultUserImage(context, user),
|
||||
);
|
||||
|
||||
Widget avatar = FittedBox(
|
||||
@@ -1,59 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// Back button implementation
|
||||
// ignore: prefer-match-file-name
|
||||
class StreamBackButton extends StatelessWidget {
|
||||
/// Constructor for creating back button
|
||||
const StreamBackButton({
|
||||
super.key,
|
||||
this.onPressed,
|
||||
this.showUnreads = false,
|
||||
this.cid,
|
||||
});
|
||||
|
||||
/// Callback for when button is pressed
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
/// Show unread count
|
||||
final bool showUnreads;
|
||||
|
||||
/// Channel cid used to retrieve unread count
|
||||
final String? cid;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
RawMaterialButton(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
elevation: 0,
|
||||
highlightElevation: 0,
|
||||
focusElevation: 0,
|
||||
hoverElevation: 0,
|
||||
onPressed: () {
|
||||
if (onPressed != null) {
|
||||
onPressed!();
|
||||
} else {
|
||||
Navigator.maybePop(context);
|
||||
}
|
||||
},
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: StreamSvgIcon.left(
|
||||
size: 24,
|
||||
color: StreamChatTheme.of(context).colorTheme.textHighEmphasis,
|
||||
),
|
||||
),
|
||||
if (showUnreads)
|
||||
Positioned(
|
||||
top: 7,
|
||||
right: 7,
|
||||
child: StreamUnreadIndicator(
|
||||
cid: cid,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/extensions.dart';
|
||||
|
||||
/// {@template attachmentModalSheet}
|
||||
/// The modalBottomSheet that appears when a mobile user attempts to add
|
||||
/// attachments to a chat.
|
||||
///
|
||||
/// Should not be used on desktop or web.
|
||||
/// {@endtemplate}
|
||||
class AttachmentModalSheet extends StatelessWidget {
|
||||
/// {@macro attachmentModalSheet}
|
||||
const AttachmentModalSheet({
|
||||
super.key,
|
||||
required this.onFileTap,
|
||||
required this.onPhotoTap,
|
||||
required this.onVideoTap,
|
||||
});
|
||||
|
||||
/// The action to perform when the "file" button is tapped.
|
||||
final VoidCallback onFileTap;
|
||||
|
||||
/// The action to perform when the "photo" button is tapped.
|
||||
final VoidCallback onPhotoTap;
|
||||
|
||||
/// The action to perform when the "video" button is tapped.
|
||||
final VoidCallback onVideoTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text(
|
||||
context.translations.addAFileLabel,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.image),
|
||||
title: Text(context.translations.uploadAPhotoLabel),
|
||||
onTap: () {
|
||||
onPhotoTap.call();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.video_library),
|
||||
title: Text(context.translations.uploadAVideoLabel),
|
||||
onTap: () {
|
||||
onVideoTap.call();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.insert_drive_file),
|
||||
title: Text(context.translations.uploadAFileLabel),
|
||||
onTap: () {
|
||||
onFileTap.call();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/utils.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@template editMessageSheet}
|
||||
/// Allows a user to edit the selected message.
|
||||
/// {@endtemplate}
|
||||
class EditMessageSheet extends StatefulWidget {
|
||||
/// {@macro editMessageSheet}
|
||||
const EditMessageSheet({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.channel,
|
||||
this.editMessageInputBuilder,
|
||||
});
|
||||
|
||||
/// {@macro editMessageInputBuilder}
|
||||
final EditMessageInputBuilder? editMessageInputBuilder;
|
||||
|
||||
/// The message to edit.
|
||||
final Message message;
|
||||
|
||||
/// The [StreamChannel] above this widget.
|
||||
final Channel channel;
|
||||
|
||||
@override
|
||||
State<EditMessageSheet> createState() => _EditMessageSheetState();
|
||||
}
|
||||
|
||||
class _EditMessageSheetState extends State<EditMessageSheet> {
|
||||
late final controller = StreamMessageInputController(
|
||||
message: widget.message,
|
||||
);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final streamChatThemeData = StreamChatTheme.of(context);
|
||||
return KeyboardShortcutRunner(
|
||||
onEscapeKeypress: () => Navigator.of(context).pop(),
|
||||
child: Padding(
|
||||
padding: MediaQuery.of(context).viewInsets,
|
||||
child: StreamChannel(
|
||||
channel: widget.channel,
|
||||
child: Flex(
|
||||
direction: Axis.vertical,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: StreamSvgIcon.edit(
|
||||
color: streamChatThemeData.colorTheme.disabled,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
context.translations.editMessageLabel,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: StreamSvgIcon.closeSmall(),
|
||||
onPressed: Navigator.of(context).pop,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.editMessageInputBuilder != null)
|
||||
widget.editMessageInputBuilder!(context, widget.message)
|
||||
else
|
||||
StreamMessageInput(
|
||||
messageInputController: controller,
|
||||
preMessageSending: (m) {
|
||||
FocusScope.of(context).unfocus();
|
||||
Navigator.of(context).pop();
|
||||
return m;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/extensions.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@template errorAlertSheet}
|
||||
/// A bottom sheet that displays when an error occurs.
|
||||
///
|
||||
/// Should only be used on mobile platforms.
|
||||
/// {@endtemplate}
|
||||
class ErrorAlertSheet extends StatelessWidget {
|
||||
/// {@macro errorAlertSheet}
|
||||
const ErrorAlertSheet({
|
||||
super.key,
|
||||
required this.errorDescription,
|
||||
});
|
||||
|
||||
/// The description of the error.
|
||||
final String errorDescription;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final _streamChatTheme = StreamChatTheme.of(context);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 26,
|
||||
),
|
||||
StreamSvgIcon.error(
|
||||
color: _streamChatTheme.colorTheme.accentError,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 26,
|
||||
),
|
||||
Text(
|
||||
context.translations.somethingWentWrongError,
|
||||
style: _streamChatTheme.textTheme.headlineBold,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 7,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
errorDescription,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 36,
|
||||
),
|
||||
Container(
|
||||
color: _streamChatTheme.colorTheme.textHighEmphasis.withOpacity(0.08),
|
||||
height: 1,
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(
|
||||
context.translations.okLabel,
|
||||
style: _streamChatTheme.textTheme.bodyBold.copyWith(
|
||||
color: _streamChatTheme.colorTheme.accentPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
-9
@@ -1,13 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/channel_info.dart';
|
||||
import 'package:stream_chat_flutter/src/extension.dart';
|
||||
import 'package:stream_chat_flutter/src/option_list_tile.dart';
|
||||
import 'package:stream_chat_flutter/src/stream_chat_theme.dart';
|
||||
import 'package:stream_chat_flutter/src/stream_svg_icon.dart';
|
||||
import 'package:stream_chat_flutter/src/theme/themes.dart';
|
||||
import 'package:stream_chat_flutter/src/user_avatar.dart';
|
||||
import 'package:stream_chat_flutter/src/v4/stream_channel_name.dart';
|
||||
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/extensions.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// A [BottomSheet] that shows information about a [Channel].
|
||||
class StreamChannelInfoBottomSheet extends StatelessWidget {
|
||||
+30
-29
@@ -1,18 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:stream_chat_flutter/src/channel_info.dart';
|
||||
import 'package:stream_chat_flutter/src/extension.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/extensions.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
///{@macro template_name}
|
||||
@Deprecated("Use 'StreamChannelHeader' instead")
|
||||
typedef ChannelHeader = StreamChannelHeader;
|
||||
|
||||
/// {@template channel_header}
|
||||
/// {@template streamChannelHeader}
|
||||
/// 
|
||||
/// 
|
||||
///
|
||||
/// It shows the current [Channel] information.
|
||||
/// Shows information about the current [Channel].
|
||||
///
|
||||
/// ```dart
|
||||
/// class MyApp extends StatelessWidget {
|
||||
@@ -39,26 +34,26 @@ typedef ChannelHeader = StreamChannelHeader;
|
||||
/// ```
|
||||
///
|
||||
/// Usually you would use this widget as an [AppBar] inside a [Scaffold].
|
||||
/// However you can also use it as a normal widget.
|
||||
/// However, you can also use it as a normal widget.
|
||||
///
|
||||
/// Make sure to have a [StreamChannel] ancestor in order to provide the
|
||||
/// information about the channel.
|
||||
///
|
||||
/// Every part of the widget uses a [StreamBuilder] to render the channel
|
||||
/// information as soon as it updates.
|
||||
///
|
||||
/// By default the widget shows a backButton that calls [Navigator.pop].
|
||||
/// You can disable this button using the [showBackButton] property of just
|
||||
/// override the behaviour
|
||||
/// with [onBackPressed].
|
||||
/// You can disable this button using the [showBackButton] property.
|
||||
/// Alternatively, you can override this behaviour via the [onBackPressed]
|
||||
/// callback.
|
||||
///
|
||||
/// The widget components render the ui based on the first ancestor of type
|
||||
/// [StreamChatTheme] and on its [StreamChatThemeData.channelHeaderTheme]
|
||||
/// property.
|
||||
/// Modify it to change the widget appearance.
|
||||
/// The UI is rendered based on the first ancestor of type [StreamChatTheme]
|
||||
/// and the [StreamChatThemeData.channelHeaderTheme] property. Modify it to
|
||||
/// change the widget's appearance.
|
||||
/// {@endtemplate}
|
||||
class StreamChannelHeader extends StatelessWidget
|
||||
implements PreferredSizeWidget {
|
||||
/// Creates a channel header
|
||||
/// {@macro streamChannelHeader}
|
||||
const StreamChannelHeader({
|
||||
super.key,
|
||||
this.showBackButton = true,
|
||||
@@ -76,23 +71,28 @@ class StreamChannelHeader extends StatelessWidget
|
||||
this.elevation = 1,
|
||||
}) : preferredSize = const Size.fromHeight(kToolbarHeight);
|
||||
|
||||
/// True if this header shows the leading back button
|
||||
/// Whether to show the leading back button
|
||||
///
|
||||
/// Defaults to `true`
|
||||
final bool showBackButton;
|
||||
|
||||
/// Callback to call when pressing the back button.
|
||||
/// The action to perform when the back button is pressed.
|
||||
///
|
||||
/// By default it calls [Navigator.pop]
|
||||
final VoidCallback? onBackPressed;
|
||||
|
||||
/// Callback to call when the header is tapped.
|
||||
/// The action to perform when the header is tapped.
|
||||
final VoidCallback? onTitleTap;
|
||||
|
||||
/// Callback to call when the image is tapped.
|
||||
/// The action to perform when the image is tapped.
|
||||
final VoidCallback? onImageTap;
|
||||
|
||||
/// If true the typing indicator will be rendered if a user is typing
|
||||
/// Whether to show the typing indicator
|
||||
///
|
||||
/// Defaults to `true`
|
||||
final bool showTypingIndicator;
|
||||
|
||||
/// Show connection tile on header
|
||||
/// Whether to show the connection state tile
|
||||
final bool showConnectionStateTile;
|
||||
|
||||
/// Title widget
|
||||
@@ -107,8 +107,9 @@ class StreamChannelHeader extends StatelessWidget
|
||||
/// Leading widget
|
||||
final Widget? leading;
|
||||
|
||||
/// AppBar actions
|
||||
/// By default it shows the [StreamChannelAvatar]
|
||||
/// {@macro flutter.material.appbar.actions}
|
||||
///
|
||||
/// The [StreamChannelAvatar] is shown by default
|
||||
final List<Widget>? actions;
|
||||
|
||||
/// The background color for this [StreamChannelHeader].
|
||||
@@ -117,6 +118,9 @@ class StreamChannelHeader extends StatelessWidget
|
||||
/// The elevation for this [StreamChannelHeader].
|
||||
final double elevation;
|
||||
|
||||
@override
|
||||
final Size preferredSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final effectiveCenterTitle = getEffectiveCenterTitle(
|
||||
@@ -131,7 +135,7 @@ class StreamChannelHeader extends StatelessWidget
|
||||
(showBackButton
|
||||
? StreamBackButton(
|
||||
onPressed: onBackPressed,
|
||||
showUnreads: true,
|
||||
showUnreadCount: true,
|
||||
)
|
||||
: const SizedBox());
|
||||
|
||||
@@ -215,7 +219,4 @@ class StreamChannelHeader extends StatelessWidget
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
final Size preferredSize;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import 'package:collection/collection.dart' show IterableExtension;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/extensions.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@template streamChannelInfo}
|
||||
/// Displays information about the current [Channel].
|
||||
/// {@endtemplate}
|
||||
class StreamChannelInfo extends StatelessWidget {
|
||||
/// {@macro streamChannelInfo}
|
||||
const StreamChannelInfo({
|
||||
super.key,
|
||||
required this.channel,
|
||||
this.textStyle,
|
||||
this.showTypingIndicator = true,
|
||||
this.parentId,
|
||||
});
|
||||
|
||||
/// The channel to display information about
|
||||
final Channel channel;
|
||||
|
||||
/// The style of the text displayed
|
||||
final TextStyle? textStyle;
|
||||
|
||||
/// Whether to show the typing indicator
|
||||
///
|
||||
/// Defaults to `true`
|
||||
final bool showTypingIndicator;
|
||||
|
||||
/// The ID of the parent message (in the case of a thread)
|
||||
final String? parentId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final client = StreamChat.of(context).client;
|
||||
return BetterStreamBuilder<List<Member>>(
|
||||
stream: channel.state!.membersStream,
|
||||
initialData: channel.state!.members,
|
||||
builder: (context, data) => StreamConnectionStatusBuilder(
|
||||
statusBuilder: (context, status) {
|
||||
switch (status) {
|
||||
case ConnectionStatus.connected:
|
||||
return _ConnectedTitleState(
|
||||
channel: channel,
|
||||
showTypingIndicator: showTypingIndicator,
|
||||
textStyle: textStyle,
|
||||
members: data,
|
||||
parentId: parentId,
|
||||
);
|
||||
case ConnectionStatus.connecting:
|
||||
return _ConnectingTitleState(textStyle: textStyle);
|
||||
case ConnectionStatus.disconnected:
|
||||
return _DisconnectedTitleState(
|
||||
client: client,
|
||||
textStyle: textStyle,
|
||||
);
|
||||
default:
|
||||
return const Offstage();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConnectedTitleState extends StatelessWidget {
|
||||
const _ConnectedTitleState({
|
||||
required this.channel,
|
||||
required this.showTypingIndicator,
|
||||
this.members,
|
||||
this.textStyle,
|
||||
this.parentId,
|
||||
});
|
||||
|
||||
final Channel channel;
|
||||
final List<Member>? members;
|
||||
final TextStyle? textStyle;
|
||||
final bool showTypingIndicator;
|
||||
final String? parentId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget? alternativeWidget;
|
||||
|
||||
final memberCount = channel.memberCount;
|
||||
if (memberCount != null && memberCount > 2) {
|
||||
var text = context.translations.membersCountText(memberCount);
|
||||
final onlineCount =
|
||||
members?.where((m) => m.user?.online == true).length ?? 0;
|
||||
if (onlineCount > 0) {
|
||||
text += ', ${context.translations.watchersCountText(onlineCount)}';
|
||||
}
|
||||
alternativeWidget = Text(
|
||||
text,
|
||||
style: StreamChannelHeaderTheme.of(context).subtitleStyle,
|
||||
);
|
||||
} else {
|
||||
final userId = StreamChat.of(context).currentUser?.id;
|
||||
final otherMember = members?.firstWhereOrNull(
|
||||
(element) => element.userId != userId,
|
||||
);
|
||||
|
||||
if (otherMember != null) {
|
||||
if (otherMember.user?.online == true) {
|
||||
alternativeWidget = Text(
|
||||
context.translations.userOnlineText,
|
||||
style: textStyle,
|
||||
);
|
||||
} else {
|
||||
alternativeWidget = Text(
|
||||
'${context.translations.userLastOnlineText} '
|
||||
'${Jiffy(otherMember.user?.lastActive).fromNow()}',
|
||||
style: textStyle,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!showTypingIndicator) {
|
||||
return alternativeWidget ?? const Offstage();
|
||||
}
|
||||
|
||||
return StreamTypingIndicator(
|
||||
parentId: parentId,
|
||||
alternativeWidget: alternativeWidget,
|
||||
style: textStyle,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConnectingTitleState extends StatelessWidget {
|
||||
const _ConnectingTitleState({
|
||||
this.textStyle,
|
||||
});
|
||||
|
||||
final TextStyle? textStyle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
context.translations.searchingForNetworkText,
|
||||
style: textStyle,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DisconnectedTitleState extends StatelessWidget {
|
||||
const _DisconnectedTitleState({
|
||||
required this.client,
|
||||
this.textStyle,
|
||||
});
|
||||
|
||||
final StreamChatClient client;
|
||||
final TextStyle? textStyle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
context.translations.offlineLabel,
|
||||
style: textStyle,
|
||||
),
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: VisualDensity.minimumDensity,
|
||||
vertical: VisualDensity.minimumDensity,
|
||||
),
|
||||
),
|
||||
onPressed: () => client
|
||||
..closeConnection()
|
||||
..openConnection(),
|
||||
child: Text(
|
||||
context.translations.tryAgainLabel,
|
||||
style: textStyle?.copyWith(
|
||||
color: StreamChatTheme.of(context).colorTheme.accentPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+69
-65
@@ -1,22 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:stream_chat_flutter/src/extension.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/utils.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// Widget builder for title
|
||||
typedef TitleBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
ConnectionStatus status,
|
||||
StreamChatClient client,
|
||||
);
|
||||
|
||||
/// {@macro channel_list_header}
|
||||
@Deprecated("Use 'StreamChannelListHeader' instead")
|
||||
typedef ChannelListHeader = StreamChannelListHeader;
|
||||
|
||||
/// {@template channel_list_header}
|
||||
/// It shows the current [StreamChatClient] status.
|
||||
/// {@template streamChannelListHeader}
|
||||
/// Shows the current [StreamChatClient] status.
|
||||
///
|
||||
/// ```dart
|
||||
/// class MyApp extends StatelessWidget {
|
||||
@@ -39,20 +28,19 @@ typedef ChannelListHeader = StreamChannelListHeader;
|
||||
/// ```
|
||||
///
|
||||
/// Usually you would use this widget as an [AppBar] inside a [Scaffold].
|
||||
/// However you can also use it as a normal widget.
|
||||
/// However, you can also use it as a normal widget.
|
||||
///
|
||||
/// The widget by default uses the inherited [StreamChatClient]
|
||||
/// to fetch information about the status.
|
||||
/// However you can also pass your own [StreamChatClient]
|
||||
/// if you don't have it in the widget tree.
|
||||
/// Uses the inherited [StreamChatClient], by default, to fetch information
|
||||
/// about the status of the [client]. You can also pass your own
|
||||
/// [StreamChatClient] if you don't have it in the widget tree.
|
||||
///
|
||||
/// The widget components render the ui based on the first ancestor of type
|
||||
/// [StreamChatTheme] and on its [StreamChannelListHeaderThemeData] property.
|
||||
/// Modify it to change the widget appearance.
|
||||
/// Renders the UI based on the first ancestor of type [StreamChatTheme] and
|
||||
/// the [StreamChannelListHeaderThemeData] property. Modify it to change the
|
||||
/// widget's appearance.
|
||||
/// {@endtemplate}
|
||||
class StreamChannelListHeader extends StatelessWidget
|
||||
implements PreferredSizeWidget {
|
||||
/// Instantiates a ChannelListHeader
|
||||
/// {@macro streamChannelListHeader}
|
||||
const StreamChannelListHeader({
|
||||
super.key,
|
||||
this.client,
|
||||
@@ -69,23 +57,24 @@ class StreamChannelListHeader extends StatelessWidget
|
||||
this.elevation = 1,
|
||||
});
|
||||
|
||||
/// Pass this if you don't have a [StreamChatClient] in your widget tree.
|
||||
/// Use this if you don't have a [StreamChatClient] in your widget tree.
|
||||
final StreamChatClient? client;
|
||||
|
||||
/// Use this to build your own title as per different [ConnectionStatus]
|
||||
final TitleBuilder? titleBuilder;
|
||||
/// {@macro channelListHeaderTitleBuilder}
|
||||
final ChannelListHeaderTitleBuilder? titleBuilder;
|
||||
|
||||
/// Callback to call when pressing the user avatar button.
|
||||
/// By default it calls Scaffold.of(context).openDrawer()
|
||||
/// The action to perform when pressing the user avatar button.
|
||||
///
|
||||
/// By default it calls `Scaffold.of(context).openDrawer()`.
|
||||
final Function(User)? onUserAvatarTap;
|
||||
|
||||
/// Callback to call when pressing the new chat button.
|
||||
/// The action to perform when pressing the "new chat" button.
|
||||
final VoidCallback? onNewChatButtonTap;
|
||||
|
||||
/// Show connection state tile
|
||||
/// Whether to show the connection state tile
|
||||
final bool showConnectionStateTile;
|
||||
|
||||
/// Callback before navigation is performed
|
||||
/// The function to execute before navigation is performed
|
||||
final VoidCallback? preNavigationCallback;
|
||||
|
||||
/// Subtitle widget
|
||||
@@ -95,11 +84,13 @@ class StreamChannelListHeader extends StatelessWidget
|
||||
final bool? centerTitle;
|
||||
|
||||
/// Leading widget
|
||||
/// By default it shows the logged in user avatar
|
||||
///
|
||||
/// By default it shows the logged in user's avatar
|
||||
final Widget? leading;
|
||||
|
||||
/// AppBar actions
|
||||
/// By default it shows the new chat button
|
||||
/// {@macro flutter.material.appbar.actions}
|
||||
///
|
||||
/// The "new chat" button is shown by default.
|
||||
final List<Widget>? actions;
|
||||
|
||||
/// The background color for this [StreamChannelListHeader].
|
||||
@@ -108,6 +99,9 @@ class StreamChannelListHeader extends StatelessWidget
|
||||
/// The elevation for this [StreamChannelListHeader].
|
||||
final double elevation;
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final _client = client ?? StreamChat.of(context).client;
|
||||
@@ -205,11 +199,11 @@ class StreamChannelListHeader extends StatelessWidget
|
||||
}
|
||||
switch (status) {
|
||||
case ConnectionStatus.connected:
|
||||
return _buildConnectedTitleState(context);
|
||||
return _ConnectedTitleState();
|
||||
case ConnectionStatus.connecting:
|
||||
return _buildConnectingTitleState(context);
|
||||
return _ConnectingTitleState();
|
||||
case ConnectionStatus.disconnected:
|
||||
return _buildDisconnectedTitleState(context, _client);
|
||||
return _DisconnectedTitleState(client: _client);
|
||||
default:
|
||||
return const Offstage();
|
||||
}
|
||||
@@ -223,8 +217,11 @@ class StreamChannelListHeader extends StatelessWidget
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildConnectedTitleState(BuildContext context) {
|
||||
class _ConnectedTitleState extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final chatThemeData = StreamChatTheme.of(context);
|
||||
return Text(
|
||||
context.translations.streamChatLabel,
|
||||
@@ -233,33 +230,43 @@ class StreamChannelListHeader extends StatelessWidget
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildConnectingTitleState(BuildContext context) => Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
class _ConnectingTitleState extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
context.translations.searchingForNetworkText,
|
||||
style:
|
||||
StreamChannelListHeaderTheme.of(context).titleStyle?.copyWith(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
context.translations.searchingForNetworkText,
|
||||
style: StreamChannelListHeaderTheme.of(context).titleStyle?.copyWith(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildDisconnectedTitleState(
|
||||
BuildContext context,
|
||||
StreamChatClient client,
|
||||
) {
|
||||
class _DisconnectedTitleState extends StatelessWidget {
|
||||
const _DisconnectedTitleState({
|
||||
required this.client,
|
||||
});
|
||||
|
||||
final StreamChatClient client;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final chatThemeData = StreamChatTheme.of(context);
|
||||
final channelListHeaderTheme = StreamChannelListHeaderTheme.of(context);
|
||||
return Row(
|
||||
@@ -288,7 +295,4 @@ class StreamChannelListHeader extends StatelessWidget
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/extensions.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@template channelName}
|
||||
/// Displays the current [Channel] name using a [Text] widget.
|
||||
///
|
||||
/// The widget uses a [StreamBuilder] to render the channel information
|
||||
/// image as soon as it updates.
|
||||
/// {@endtemplate}
|
||||
class ChannelName extends StatelessWidget {
|
||||
/// {@macro channelName}
|
||||
const ChannelName({
|
||||
super.key,
|
||||
this.textStyle,
|
||||
this.textOverflow = TextOverflow.ellipsis,
|
||||
});
|
||||
|
||||
/// The style of the text displayed
|
||||
final TextStyle? textStyle;
|
||||
|
||||
/// How visual overflow should be handled.
|
||||
final TextOverflow textOverflow;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final client = StreamChat.of(context);
|
||||
final channel = StreamChannel.of(context).channel;
|
||||
|
||||
assert(channel.state != null, 'Channel ${channel.id} is not initialized');
|
||||
|
||||
return BetterStreamBuilder<String>(
|
||||
stream: channel.nameStream,
|
||||
initialData: channel.name,
|
||||
builder: (context, channelName) => Text(
|
||||
channelName,
|
||||
style: textStyle,
|
||||
overflow: textOverflow,
|
||||
),
|
||||
noDataBuilder: (context) => _NameGenerator(
|
||||
currentUser: client.currentUser!,
|
||||
members: channel.state!.members,
|
||||
textStyle: textStyle,
|
||||
textOverflow: textOverflow,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NameGenerator extends StatelessWidget {
|
||||
const _NameGenerator({
|
||||
required this.currentUser,
|
||||
required this.members,
|
||||
this.textStyle,
|
||||
this.textOverflow,
|
||||
});
|
||||
|
||||
final User currentUser;
|
||||
final List<Member> members;
|
||||
final TextStyle? textStyle;
|
||||
final TextOverflow? textOverflow;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
var channelName = context.translations.noTitleText;
|
||||
final otherMembers = members.where(
|
||||
(member) => member.userId != currentUser.id,
|
||||
);
|
||||
|
||||
if (otherMembers.isNotEmpty) {
|
||||
if (otherMembers.length == 1) {
|
||||
final user = otherMembers.first.user;
|
||||
if (user != null) {
|
||||
channelName = user.name;
|
||||
}
|
||||
} else {
|
||||
final maxWidth = constraints.maxWidth;
|
||||
final maxChars = maxWidth / (textStyle?.fontSize ?? 1);
|
||||
var currentChars = 0;
|
||||
final currentMembers = <Member>[];
|
||||
otherMembers.forEach((element) {
|
||||
final newLength = currentChars + (element.user?.name.length ?? 0);
|
||||
if (newLength < maxChars) {
|
||||
currentChars = newLength;
|
||||
currentMembers.add(element);
|
||||
}
|
||||
});
|
||||
|
||||
final exceedingMembers =
|
||||
otherMembers.length - currentMembers.length;
|
||||
channelName =
|
||||
'${currentMembers.map((e) => e.user?.name).join(', ')} '
|
||||
'${exceedingMembers > 0 ? '+ $exceedingMembers' : ''}';
|
||||
}
|
||||
}
|
||||
|
||||
return Text(
|
||||
channelName,
|
||||
style: textStyle,
|
||||
overflow: textOverflow,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
// ignore_for_file: deprecated_member_use_from_same_package
|
||||
|
||||
import 'package:collection/collection.dart'
|
||||
show IterableExtension, ListEquality;
|
||||
import 'package:contextmenu/contextmenu.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/context_menu_items/stream_chat_context_menu_item.dart';
|
||||
import 'package:stream_chat_flutter/src/dialogs/dialogs.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/extensions.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@template channelPreview}
|
||||
/// 
|
||||
/// 
|
||||
///
|
||||
/// Shows a preview for the current [Channel].
|
||||
///
|
||||
/// Uses a [StreamBuilder] to render the channel information image as soon as
|
||||
/// it updates.
|
||||
///
|
||||
/// It is not recommended to use this widget directly as it is the
|
||||
/// default channel preview widget used by [ChannelListView].
|
||||
///
|
||||
/// The UI is rendered based on the first ancestor of type [StreamChatTheme].
|
||||
/// Modify it to change the widget's appearance.
|
||||
/// {@endtemplate}
|
||||
class ChannelPreview extends StatelessWidget {
|
||||
/// {@macro channelPreview}
|
||||
const ChannelPreview({
|
||||
required this.channel,
|
||||
super.key,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
this.onViewInfoTap,
|
||||
this.onImageTap,
|
||||
this.title,
|
||||
this.subtitle,
|
||||
this.leading,
|
||||
this.sendingIndicator,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
/// The action to perform when this widget is tapped or clicked.
|
||||
final void Function(Channel)? onTap;
|
||||
|
||||
/// The action to perform when this widget is long pressed.
|
||||
final void Function(Channel)? onLongPress;
|
||||
|
||||
/// The action to perform when 'View Info' is tapped or clicked.
|
||||
final ViewInfoCallback? onViewInfoTap;
|
||||
|
||||
/// The [Channel] being previewed.
|
||||
final Channel channel;
|
||||
|
||||
/// The action to perform when the image is tapped
|
||||
final VoidCallback? onImageTap;
|
||||
|
||||
/// Widget rendering the title
|
||||
final Widget? title;
|
||||
|
||||
/// Widget rendering the subtitle
|
||||
final Widget? subtitle;
|
||||
|
||||
/// Widget rendering the leading element. By default it shows the
|
||||
/// [StreamChannelAvatar].
|
||||
final Widget? leading;
|
||||
|
||||
/// Widget rendering the trailing element. By default it shows the date of
|
||||
/// the last message.
|
||||
final Widget? trailing;
|
||||
|
||||
/// Widget rendering the sending indicator. By default it uses the
|
||||
/// [StreamSendingIndicator] widget.
|
||||
final Widget? sendingIndicator;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final channelPreviewTheme = StreamChannelPreviewTheme.of(context);
|
||||
final streamChatState = StreamChat.of(context);
|
||||
return BetterStreamBuilder<bool>(
|
||||
stream: channel.isMutedStream,
|
||||
initialData: channel.isMuted,
|
||||
builder: (context, data) => AnimatedOpacity(
|
||||
opacity: data ? 0.5 : 1,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: ContextMenuArea(
|
||||
verticalPadding: 0,
|
||||
builder: (context) => [
|
||||
StreamChatContextMenuItem(
|
||||
leading: StreamSvgIcon.user(
|
||||
color: Colors.grey,
|
||||
),
|
||||
title: Text(context.translations.viewInfoLabel),
|
||||
onClick: () {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
if (onViewInfoTap != null) {
|
||||
onViewInfoTap?.call(channel);
|
||||
} else {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => ChannelInfoDialog(
|
||||
channel: channel,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
StreamChatContextMenuItem(
|
||||
leading: StreamSvgIcon.mute(
|
||||
color: Colors.grey,
|
||||
),
|
||||
title: channel.isGroup
|
||||
? Text(
|
||||
context.translations
|
||||
.toggleMuteUnmuteGroupText(isMuted: channel.isMuted),
|
||||
)
|
||||
: Text(
|
||||
context.translations
|
||||
.toggleMuteUnmuteUserText(isMuted: channel.isMuted),
|
||||
),
|
||||
onClick: () async {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => ConfirmationDialog(
|
||||
titleText: channel.isGroup
|
||||
? context.translations
|
||||
.toggleMuteUnmuteGroupText(isMuted: channel.isMuted)
|
||||
: context.translations
|
||||
.toggleMuteUnmuteUserText(isMuted: channel.isMuted),
|
||||
promptText: channel.isGroup
|
||||
? context.translations.toggleMuteUnmuteGroupQuestion(
|
||||
isMuted: channel.isMuted,
|
||||
)
|
||||
: context.translations.toggleMuteUnmuteUserQuestion(
|
||||
isMuted: channel.isMuted,
|
||||
),
|
||||
affirmativeText: context.translations
|
||||
.toggleMuteUnmuteAction(isMuted: channel.isMuted),
|
||||
onConfirmation: () async {
|
||||
try {
|
||||
if (channel.isMuted) {
|
||||
await channel.unmute();
|
||||
} else {
|
||||
await channel.mute();
|
||||
}
|
||||
} catch (e) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => MessageDialog(
|
||||
messageText: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (channel.isGroup)
|
||||
StreamChatContextMenuItem(
|
||||
leading: StreamSvgIcon.userRemove(
|
||||
color: Colors.red,
|
||||
),
|
||||
title: Text(
|
||||
context.translations.leaveGroupLabel,
|
||||
style: const TextStyle(
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
onClick: () {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => ConfirmationDialog(
|
||||
titleText: context.translations.leaveGroupLabel,
|
||||
promptText:
|
||||
context.translations.leaveConversationQuestion,
|
||||
affirmativeText: context.translations.leaveLabel,
|
||||
onConfirmation: () async {
|
||||
final userAsMember = channel.state?.members.firstWhere(
|
||||
(e) =>
|
||||
e.user?.id ==
|
||||
StreamChat.of(context).currentUser?.id,
|
||||
);
|
||||
try {
|
||||
await channel.removeMembers([userAsMember!.user!.id]);
|
||||
} catch (e) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => MessageDialog(
|
||||
messageText: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (!channel.isGroup)
|
||||
StreamChatContextMenuItem(
|
||||
leading: StreamSvgIcon.delete(
|
||||
color: Colors.red,
|
||||
),
|
||||
title: Text(
|
||||
context.translations.deleteConversationLabel,
|
||||
style: const TextStyle(
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
onClick: () {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => ConfirmationDialog(
|
||||
titleText: context.translations.deleteConversationLabel,
|
||||
promptText:
|
||||
context.translations.deleteConversationQuestion,
|
||||
affirmativeText: context.translations.deleteLabel,
|
||||
onConfirmation: () async {
|
||||
try {
|
||||
await channel.delete();
|
||||
} catch (e) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => MessageDialog(
|
||||
messageText: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
child: ListTile(
|
||||
visualDensity: VisualDensity.compact,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
),
|
||||
onTap: () => onTap?.call(channel),
|
||||
onLongPress: () => onLongPress?.call(channel),
|
||||
leading: leading ??
|
||||
StreamChannelAvatar(
|
||||
onTap: onImageTap,
|
||||
channel: channel,
|
||||
),
|
||||
title: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Flexible(
|
||||
child: title ??
|
||||
ChannelName(
|
||||
textStyle: channelPreviewTheme.titleStyle,
|
||||
),
|
||||
),
|
||||
BetterStreamBuilder<List<Member>>(
|
||||
stream: channel.state?.membersStream,
|
||||
initialData: channel.state?.members,
|
||||
comparator: const ListEquality().equals,
|
||||
builder: (context, members) {
|
||||
if (members.isEmpty ||
|
||||
!members.any((Member e) =>
|
||||
e.user!.id ==
|
||||
channel.client.state.currentUser?.id)) {
|
||||
return const SizedBox();
|
||||
}
|
||||
return StreamUnreadIndicator(
|
||||
cid: channel.cid,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Flexible(child: subtitle ?? _Subtitle(channel: channel)),
|
||||
sendingIndicator ??
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final lastMessage =
|
||||
channel.state?.messages.lastWhereOrNull(
|
||||
(m) => !m.isDeleted && !m.shadowed,
|
||||
);
|
||||
if (lastMessage?.user?.id ==
|
||||
streamChatState.currentUser?.id) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: BetterStreamBuilder<List<Read>>(
|
||||
stream: channel.state?.readStream,
|
||||
initialData: channel.state?.read,
|
||||
builder: (context, data) {
|
||||
final readList = data.where((it) =>
|
||||
it.user.id !=
|
||||
channel.client.state.currentUser?.id &&
|
||||
(it.lastRead
|
||||
.isAfter(lastMessage!.createdAt) ||
|
||||
it.lastRead.isAtSameMomentAs(
|
||||
lastMessage.createdAt,
|
||||
)));
|
||||
final isMessageRead = readList.length >=
|
||||
(channel.memberCount ?? 0) - 1;
|
||||
return StreamSendingIndicator(
|
||||
message: lastMessage!,
|
||||
size: channelPreviewTheme.indicatorIconSize,
|
||||
isMessageRead: isMessageRead,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
trailing ?? _Date(channel: channel),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Date extends StatelessWidget {
|
||||
const _Date({
|
||||
required this.channel,
|
||||
});
|
||||
|
||||
final Channel channel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BetterStreamBuilder<DateTime>(
|
||||
stream: channel.lastMessageAtStream,
|
||||
initialData: channel.lastMessageAt,
|
||||
builder: (context, data) {
|
||||
final lastMessageAt = data.toLocal();
|
||||
|
||||
String stringDate;
|
||||
final now = DateTime.now();
|
||||
|
||||
final startOfDay = DateTime(now.year, now.month, now.day);
|
||||
|
||||
if (lastMessageAt.millisecondsSinceEpoch >=
|
||||
startOfDay.millisecondsSinceEpoch) {
|
||||
stringDate = Jiffy(lastMessageAt.toLocal()).jm;
|
||||
} else if (lastMessageAt.millisecondsSinceEpoch >=
|
||||
startOfDay
|
||||
.subtract(const Duration(days: 1))
|
||||
.millisecondsSinceEpoch) {
|
||||
stringDate = context.translations.yesterdayLabel;
|
||||
} else if (startOfDay.difference(lastMessageAt).inDays < 7) {
|
||||
stringDate = Jiffy(lastMessageAt.toLocal()).EEEE;
|
||||
} else {
|
||||
stringDate = Jiffy(lastMessageAt.toLocal()).yMd;
|
||||
}
|
||||
|
||||
return Text(
|
||||
stringDate,
|
||||
style: StreamChannelPreviewTheme.of(context).lastMessageAtStyle,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Subtitle extends StatelessWidget {
|
||||
const _Subtitle({
|
||||
required this.channel,
|
||||
});
|
||||
|
||||
final Channel channel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final channelPreviewTheme = StreamChannelPreviewTheme.of(context);
|
||||
if (channel.isMuted) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
StreamSvgIcon.mute(
|
||||
size: 16,
|
||||
),
|
||||
Text(
|
||||
' ${context.translations.channelIsMutedText}',
|
||||
style: channelPreviewTheme.subtitleStyle,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return StreamTypingIndicator(
|
||||
channel: channel,
|
||||
alternativeWidget: _LastMessage(
|
||||
channel: channel,
|
||||
),
|
||||
style: channelPreviewTheme.subtitleStyle,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LastMessage extends StatelessWidget {
|
||||
const _LastMessage({
|
||||
required this.channel,
|
||||
});
|
||||
|
||||
final Channel channel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: BetterStreamBuilder<List<Message>>(
|
||||
stream: channel.state!.messagesStream,
|
||||
initialData: channel.state!.messages,
|
||||
builder: (context, data) {
|
||||
final lastMessage =
|
||||
data.lastWhereOrNull((m) => !m.shadowed && !m.isDeleted);
|
||||
if (lastMessage == null) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
var text = lastMessage.text;
|
||||
final parts = <String>[
|
||||
...lastMessage.attachments.map((e) {
|
||||
if (e.type == 'image') {
|
||||
return '📷';
|
||||
} else if (e.type == 'video') {
|
||||
return '🎬';
|
||||
} else if (e.type == 'giphy') {
|
||||
return '[GIF]';
|
||||
}
|
||||
return e == lastMessage.attachments.last
|
||||
? (e.title ?? 'File')
|
||||
: '${e.title ?? 'File'} , ';
|
||||
}),
|
||||
lastMessage.text ?? '',
|
||||
];
|
||||
|
||||
text = parts.join(' ');
|
||||
|
||||
final channelPreviewTheme = StreamChannelPreviewTheme.of(context);
|
||||
return Text.rich(
|
||||
_getDisplayText(
|
||||
text,
|
||||
lastMessage.mentionedUsers,
|
||||
lastMessage.attachments,
|
||||
channelPreviewTheme.subtitleStyle?.copyWith(
|
||||
color: channelPreviewTheme.subtitleStyle?.color,
|
||||
fontStyle: (lastMessage.isSystem || lastMessage.isDeleted)
|
||||
? FontStyle.italic
|
||||
: FontStyle.normal,
|
||||
),
|
||||
channelPreviewTheme.subtitleStyle?.copyWith(
|
||||
color: channelPreviewTheme.subtitleStyle?.color,
|
||||
fontStyle: (lastMessage.isSystem || lastMessage.isDeleted)
|
||||
? FontStyle.italic
|
||||
: FontStyle.normal,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.start,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TextSpan _getDisplayText(
|
||||
String text,
|
||||
List<User> mentions,
|
||||
List<Attachment> attachments,
|
||||
TextStyle? normalTextStyle,
|
||||
TextStyle? mentionsTextStyle,
|
||||
) {
|
||||
final textList = text.split(' ');
|
||||
final resList = <TextSpan>[];
|
||||
for (final e in textList) {
|
||||
if (mentions.isNotEmpty &&
|
||||
mentions.any((element) => '@${element.name}' == e)) {
|
||||
resList.add(TextSpan(
|
||||
text: '$e ',
|
||||
style: mentionsTextStyle,
|
||||
));
|
||||
} else if (attachments.isNotEmpty &&
|
||||
attachments
|
||||
.where((e) => e.title != null)
|
||||
.any((element) => element.title == e)) {
|
||||
resList.add(TextSpan(
|
||||
text: '$e ',
|
||||
style: normalTextStyle?.copyWith(fontStyle: FontStyle.italic),
|
||||
));
|
||||
} else {
|
||||
resList.add(TextSpan(
|
||||
text: e == textList.last ? e : '$e ',
|
||||
style: normalTextStyle,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return TextSpan(children: resList);
|
||||
}
|
||||
}
|
||||
-1
@@ -1,6 +1,5 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/group_avatar.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// 
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/extension.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/extensions.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/extension.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/extensions.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// A widget that renders a preview of the message text.
|
||||
@@ -1,203 +0,0 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/group_avatar.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// 
|
||||
/// 
|
||||
///
|
||||
/// It shows the current [Channel] image.
|
||||
///
|
||||
/// ```dart
|
||||
/// class MyApp extends StatelessWidget {
|
||||
/// final StreamChatClient client;
|
||||
/// final Channel channel;
|
||||
///
|
||||
/// MyApp(this.client, this.channel);
|
||||
///
|
||||
/// @override
|
||||
/// Widget build(BuildContext context) {
|
||||
/// return MaterialApp(
|
||||
/// debugShowCheckedModeBanner: false,
|
||||
/// home: StreamChat(
|
||||
/// client: client,
|
||||
/// child: StreamChannel(
|
||||
/// channel: channel,
|
||||
/// child: Center(
|
||||
/// child: ChannelAvatar(
|
||||
/// channel: channel,
|
||||
/// ),
|
||||
/// ),
|
||||
/// ),
|
||||
/// ),
|
||||
/// );
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// The widget uses a [StreamBuilder] to render the channel information
|
||||
/// image as soon as it updates.
|
||||
///
|
||||
/// By default the widget radius size is 40x40 pixels.
|
||||
/// Set the property [constraints] to set a custom dimension.
|
||||
///
|
||||
/// The widget renders the ui based on the first ancestor of type
|
||||
/// [StreamChatTheme].
|
||||
/// Modify it to change the widget appearance.
|
||||
|
||||
@Deprecated(
|
||||
"'ChannelAvatar' is deprecated and shouldn't be used. "
|
||||
"Please use 'StreamChannelAvatar' instead.",
|
||||
)
|
||||
class ChannelAvatar extends StatelessWidget {
|
||||
/// Instantiate a new ChannelImage
|
||||
const ChannelAvatar({
|
||||
super.key,
|
||||
this.channel,
|
||||
this.constraints,
|
||||
this.onTap,
|
||||
this.borderRadius,
|
||||
this.selected = false,
|
||||
this.selectionColor,
|
||||
this.selectionThickness = 4,
|
||||
});
|
||||
|
||||
/// [BorderRadius] to display the widget
|
||||
final BorderRadius? borderRadius;
|
||||
|
||||
/// The channel to show the image of
|
||||
final Channel? channel;
|
||||
|
||||
/// The diameter of the image
|
||||
final BoxConstraints? constraints;
|
||||
|
||||
/// The function called when the image is tapped
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// If image is selected
|
||||
final bool selected;
|
||||
|
||||
/// Selection color for image
|
||||
final Color? selectionColor;
|
||||
|
||||
/// Thickness of selection image
|
||||
final double selectionThickness;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final streamChat = StreamChat.of(context);
|
||||
final channel = this.channel ?? StreamChannel.of(context).channel;
|
||||
|
||||
assert(channel.state != null, 'Channel ${channel.id} is not initialized');
|
||||
|
||||
final chatThemeData = StreamChatTheme.of(context);
|
||||
final colorTheme = chatThemeData.colorTheme;
|
||||
final previewTheme = chatThemeData.channelPreviewTheme.avatarTheme;
|
||||
|
||||
return BetterStreamBuilder<String>(
|
||||
stream: channel.imageStream,
|
||||
initialData: channel.image,
|
||||
builder: (context, channelImage) {
|
||||
Widget child = ClipRRect(
|
||||
borderRadius: borderRadius ?? previewTheme?.borderRadius,
|
||||
child: Container(
|
||||
constraints: constraints ?? previewTheme?.constraints,
|
||||
decoration: BoxDecoration(color: colorTheme.accentPrimary),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: channelImage,
|
||||
errorWidget: (_, __, ___) => Center(
|
||||
child: Text(
|
||||
channel.name?[0] ?? '',
|
||||
style: TextStyle(
|
||||
color: colorTheme.barsBg,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (selected) {
|
||||
child = ClipRRect(
|
||||
key: const Key('selectedImage'),
|
||||
borderRadius: BorderRadius.circular(selectionThickness) +
|
||||
(borderRadius ??
|
||||
previewTheme?.borderRadius ??
|
||||
BorderRadius.zero),
|
||||
child: Container(
|
||||
constraints: constraints ?? previewTheme?.constraints,
|
||||
color: selectionColor ?? colorTheme.accentPrimary,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(selectionThickness),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return child;
|
||||
},
|
||||
noDataBuilder: (context) {
|
||||
final currentUser = streamChat.currentUser!;
|
||||
final otherMembers = channel.state!.members
|
||||
.where((it) => it.userId != currentUser.id)
|
||||
.toList(growable: false);
|
||||
|
||||
// our own space, no other members
|
||||
if (otherMembers.isEmpty) {
|
||||
return BetterStreamBuilder<User>(
|
||||
stream: streamChat.client.state.currentUserStream.map((it) => it!),
|
||||
initialData: currentUser,
|
||||
builder: (context, user) => StreamUserAvatar(
|
||||
borderRadius: borderRadius ?? previewTheme?.borderRadius,
|
||||
user: user,
|
||||
constraints: constraints ?? previewTheme?.constraints,
|
||||
onTap: onTap != null ? (_) => onTap!() : null,
|
||||
selected: selected,
|
||||
selectionColor: selectionColor ?? colorTheme.accentPrimary,
|
||||
selectionThickness: selectionThickness,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 1-1 Conversation
|
||||
if (otherMembers.length == 1) {
|
||||
final member = otherMembers.first;
|
||||
return BetterStreamBuilder<Member>(
|
||||
stream: channel.state!.membersStream.map(
|
||||
(members) => members.firstWhere(
|
||||
(it) => it.userId == member.userId,
|
||||
orElse: () => member,
|
||||
),
|
||||
),
|
||||
initialData: member,
|
||||
builder: (context, member) => StreamUserAvatar(
|
||||
borderRadius: borderRadius ?? previewTheme?.borderRadius,
|
||||
user: member.user!,
|
||||
constraints: constraints ?? previewTheme?.constraints,
|
||||
onTap: onTap != null ? (_) => onTap!() : null,
|
||||
selected: selected,
|
||||
selectionColor: selectionColor ?? colorTheme.accentPrimary,
|
||||
selectionThickness: selectionThickness,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Group conversation
|
||||
return StreamGroupAvatar(
|
||||
members: otherMembers,
|
||||
borderRadius: borderRadius ?? previewTheme?.borderRadius,
|
||||
constraints: constraints ?? previewTheme?.constraints,
|
||||
onTap: onTap,
|
||||
selected: selected,
|
||||
selectionColor: selectionColor ?? colorTheme.accentPrimary,
|
||||
selectionThickness: selectionThickness,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/channel_info.dart';
|
||||
import 'package:stream_chat_flutter/src/extension.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// Bottom Sheet with options
|
||||
@Deprecated("Use 'StreamChannelInfoBottomSheet' instead")
|
||||
class ChannelBottomSheet extends StatefulWidget {
|
||||
/// Constructor for creating bottom sheet
|
||||
const ChannelBottomSheet({super.key, this.onViewInfoTap});
|
||||
|
||||
/// Callback when 'View Info' is tapped
|
||||
final VoidCallback? onViewInfoTap;
|
||||
|
||||
@override
|
||||
_ChannelBottomSheetState createState() => _ChannelBottomSheetState();
|
||||
}
|
||||
|
||||
// ignore: deprecated_member_use_from_same_package
|
||||
class _ChannelBottomSheetState extends State<ChannelBottomSheet> {
|
||||
bool _showActions = true;
|
||||
|
||||
late StreamChannelState _streamChannelState;
|
||||
late StreamChannelPreviewThemeData _channelPreviewThemeData;
|
||||
late StreamChatThemeData _streamChatThemeData;
|
||||
late StreamChatState _streamChatState;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final channel = _streamChannelState.channel;
|
||||
|
||||
final members = channel.state?.members ?? [];
|
||||
|
||||
final userAsMember = members
|
||||
.firstWhere((e) => e.user?.id == _streamChatState.currentUser?.id);
|
||||
|
||||
return Material(
|
||||
color: _streamChatThemeData.colorTheme.barsBg,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: !_showActions
|
||||
? const SizedBox()
|
||||
: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 24,
|
||||
),
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: StreamChannelName(
|
||||
channel: channel,
|
||||
textStyle: _streamChatThemeData.textTheme.headlineBold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 5,
|
||||
),
|
||||
Center(
|
||||
child: StreamChannelInfo(
|
||||
showTypingIndicator: false,
|
||||
channel: _streamChannelState.channel,
|
||||
textStyle: _channelPreviewThemeData.subtitleStyle,
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 17,
|
||||
),
|
||||
if (channel.isDistinct && channel.memberCount == 2)
|
||||
Column(
|
||||
children: [
|
||||
StreamUserAvatar(
|
||||
user: members
|
||||
.firstWhere(
|
||||
(e) => e.user?.id != userAsMember.user?.id,
|
||||
)
|
||||
.user!,
|
||||
constraints: const BoxConstraints(
|
||||
maxHeight: 64,
|
||||
maxWidth: 64,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
onlineIndicatorConstraints:
|
||||
BoxConstraints.tight(const Size(12, 12)),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 6,
|
||||
),
|
||||
Text(
|
||||
members
|
||||
.firstWhere(
|
||||
(e) => e.user?.id != userAsMember.user?.id,
|
||||
)
|
||||
.user
|
||||
?.name ??
|
||||
'',
|
||||
style: _streamChatThemeData.textTheme.footnoteBold,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!(channel.isDistinct && channel.memberCount == 2))
|
||||
Container(
|
||||
height: 94,
|
||||
alignment: Alignment.center,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: members.length,
|
||||
shrinkWrap: true,
|
||||
itemBuilder: (context, index) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Column(
|
||||
children: [
|
||||
StreamUserAvatar(
|
||||
user: members[index].user!,
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
height: 64,
|
||||
width: 64,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
onlineIndicatorConstraints:
|
||||
BoxConstraints.tight(const Size(12, 12)),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 6,
|
||||
),
|
||||
Text(
|
||||
members[index].user?.name ?? '',
|
||||
style:
|
||||
_streamChatThemeData.textTheme.footnoteBold,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 24,
|
||||
),
|
||||
StreamOptionListTile(
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: StreamSvgIcon.user(
|
||||
color: _streamChatThemeData.colorTheme.textLowEmphasis,
|
||||
),
|
||||
),
|
||||
title: context.translations.viewInfoLabel,
|
||||
onTap: widget.onViewInfoTap,
|
||||
),
|
||||
if (!channel.isDistinct &&
|
||||
channel.ownCapabilities
|
||||
.contains(PermissionType.leaveChannel))
|
||||
StreamOptionListTile(
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: StreamSvgIcon.userRemove(
|
||||
color: _streamChatThemeData.colorTheme.textLowEmphasis,
|
||||
),
|
||||
),
|
||||
title: context.translations.leaveGroupLabel,
|
||||
onTap: () async {
|
||||
setState(() {
|
||||
_showActions = false;
|
||||
});
|
||||
await _showLeaveDialog();
|
||||
setState(() {
|
||||
_showActions = true;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (channel.ownCapabilities
|
||||
.contains(PermissionType.deleteChannel))
|
||||
StreamOptionListTile(
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: StreamSvgIcon.delete(
|
||||
color: _streamChatThemeData.colorTheme.accentError,
|
||||
),
|
||||
),
|
||||
title: context.translations.deleteConversationLabel,
|
||||
titleColor: _streamChatThemeData.colorTheme.accentError,
|
||||
onTap: () async {
|
||||
setState(() {
|
||||
_showActions = false;
|
||||
});
|
||||
await _showDeleteDialog();
|
||||
setState(() {
|
||||
_showActions = true;
|
||||
});
|
||||
},
|
||||
),
|
||||
StreamOptionListTile(
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: StreamSvgIcon.closeSmall(
|
||||
color: _streamChatThemeData.colorTheme.textLowEmphasis,
|
||||
),
|
||||
),
|
||||
title: context.translations.cancelLabel,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
_streamChannelState = StreamChannel.of(context);
|
||||
_streamChatThemeData = StreamChatTheme.of(context);
|
||||
_channelPreviewThemeData = StreamChannelPreviewTheme.of(context);
|
||||
_streamChatState = StreamChat.of(context);
|
||||
super.didChangeDependencies();
|
||||
}
|
||||
|
||||
Future<void> _showDeleteDialog() async {
|
||||
final res = await showConfirmationDialog(
|
||||
context,
|
||||
title: context.translations.deleteConversationLabel,
|
||||
okText: context.translations.deleteLabel,
|
||||
question: context.translations.deleteConversationQuestion,
|
||||
cancelText: context.translations.cancelLabel,
|
||||
icon: StreamSvgIcon.delete(
|
||||
color: _streamChatThemeData.colorTheme.accentError,
|
||||
),
|
||||
);
|
||||
final channel = _streamChannelState.channel;
|
||||
if (res == true) {
|
||||
await channel.delete();
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showLeaveDialog() async {
|
||||
final res = await showConfirmationDialog(
|
||||
context,
|
||||
title: context.translations.leaveConversationLabel,
|
||||
okText: context.translations.leaveLabel,
|
||||
question: context.translations.leaveConversationQuestion,
|
||||
cancelText: context.translations.cancelLabel,
|
||||
icon: StreamSvgIcon.userRemove(
|
||||
color: _streamChatThemeData.colorTheme.accentError,
|
||||
),
|
||||
);
|
||||
if (res == true) {
|
||||
final channel = _streamChannelState.channel;
|
||||
final user = _streamChatState.currentUser;
|
||||
if (user != null) {
|
||||
await channel.removeMembers([user.id]);
|
||||
}
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import 'package:collection/collection.dart' show IterableExtension;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/extension.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@macro channel_info}
|
||||
@Deprecated("Use 'StreamChannelInfo' instead")
|
||||
typedef ChannelInfo = StreamChannelInfo;
|
||||
|
||||
/// {@template channel_info}
|
||||
/// Widget which shows channel info
|
||||
/// {@endtemplate}
|
||||
class StreamChannelInfo extends StatelessWidget {
|
||||
/// Constructor which creates a [StreamChannelInfo] widget
|
||||
const StreamChannelInfo({
|
||||
super.key,
|
||||
required this.channel,
|
||||
this.textStyle,
|
||||
this.showTypingIndicator = true,
|
||||
this.parentId,
|
||||
});
|
||||
|
||||
/// The channel about which the info is to be displayed
|
||||
final Channel channel;
|
||||
|
||||
/// The style of the text displayed
|
||||
final TextStyle? textStyle;
|
||||
|
||||
/// If true the typing indicator will be rendered if a user is typing
|
||||
final bool showTypingIndicator;
|
||||
|
||||
/// Id of the parent message in case of a thread
|
||||
final String? parentId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final client = StreamChat.of(context).client;
|
||||
return BetterStreamBuilder<List<Member>>(
|
||||
stream: channel.state!.membersStream,
|
||||
initialData: channel.state!.members,
|
||||
builder: (context, data) => StreamConnectionStatusBuilder(
|
||||
statusBuilder: (context, status) {
|
||||
switch (status) {
|
||||
case ConnectionStatus.connected:
|
||||
return _buildConnectedTitleState(context, data);
|
||||
case ConnectionStatus.connecting:
|
||||
return _buildConnectingTitleState(context);
|
||||
case ConnectionStatus.disconnected:
|
||||
return _buildDisconnectedTitleState(context, client);
|
||||
default:
|
||||
return const Offstage();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConnectedTitleState(
|
||||
BuildContext context,
|
||||
List<Member>? members,
|
||||
) {
|
||||
Widget? alternativeWidget;
|
||||
|
||||
final memberCount = channel.memberCount;
|
||||
if (memberCount != null && memberCount > 2) {
|
||||
var text = context.translations.membersCountText(memberCount);
|
||||
final onlineCount =
|
||||
members?.where((m) => m.user?.online == true).length ?? 0;
|
||||
if (channel.ownCapabilities.contains(PermissionType.connectEvents) &&
|
||||
onlineCount > 0) {
|
||||
text += ', ${context.translations.watchersCountText(onlineCount)}';
|
||||
}
|
||||
alternativeWidget = Text(
|
||||
text,
|
||||
style: StreamChannelHeaderTheme.of(context).subtitleStyle,
|
||||
);
|
||||
} else {
|
||||
final userId = StreamChat.of(context).currentUser?.id;
|
||||
final otherMember = members?.firstWhereOrNull(
|
||||
(element) => element.userId != userId,
|
||||
);
|
||||
|
||||
if (otherMember != null) {
|
||||
if (otherMember.user?.online == true) {
|
||||
alternativeWidget = Text(
|
||||
context.translations.userOnlineText,
|
||||
style: textStyle,
|
||||
);
|
||||
} else {
|
||||
alternativeWidget = Text(
|
||||
'${context.translations.userLastOnlineText} '
|
||||
'${Jiffy(otherMember.user?.lastActive).fromNow()}',
|
||||
style: textStyle,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!showTypingIndicator) {
|
||||
return alternativeWidget ?? const Offstage();
|
||||
}
|
||||
|
||||
return StreamTypingIndicator(
|
||||
parentId: parentId,
|
||||
style: textStyle,
|
||||
alternativeWidget: alternativeWidget,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConnectingTitleState(BuildContext context) => Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
context.translations.searchingForNetworkText,
|
||||
style: textStyle,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _buildDisconnectedTitleState(
|
||||
BuildContext context,
|
||||
StreamChatClient client,
|
||||
) =>
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
context.translations.offlineLabel,
|
||||
style: textStyle,
|
||||
),
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: VisualDensity.minimumDensity,
|
||||
vertical: VisualDensity.minimumDensity,
|
||||
),
|
||||
),
|
||||
onPressed: () => client
|
||||
..closeConnection()
|
||||
..openConnection(),
|
||||
child: Text(
|
||||
context.translations.tryAgainLabel,
|
||||
style: textStyle?.copyWith(
|
||||
color: StreamChatTheme.of(context).colorTheme.accentPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -1,742 +0,0 @@
|
||||
// ignore: lines_longer_than_80_chars
|
||||
// ignore_for_file: deprecated_member_use_from_same_package, deprecated_member_use
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
import 'package:stream_chat_flutter/src/extension.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// Callback called when tapping on a channel
|
||||
typedef ChannelTapCallback = void Function(Channel, Widget?);
|
||||
|
||||
/// Callback called when tapping on a channel
|
||||
typedef ChannelInfoCallback = void Function(Channel);
|
||||
|
||||
/// Builder used to create a custom [StreamChannelPreview] from a [Channel]
|
||||
typedef ChannelPreviewBuilder = Widget Function(BuildContext, Channel);
|
||||
|
||||
/// Callback for when 'View Info' is tapped
|
||||
typedef ViewInfoCallback = void Function(Channel);
|
||||
|
||||
/// 
|
||||
/// 
|
||||
///
|
||||
/// It shows the list of current channels.
|
||||
///
|
||||
/// ```dart
|
||||
/// class ChannelListPage extends StatelessWidget {
|
||||
/// @override
|
||||
/// Widget build(BuildContext context) {
|
||||
/// return Scaffold(
|
||||
/// body: ChannelListView(
|
||||
/// filter: {
|
||||
/// 'members': {
|
||||
/// '\$in': [StreamChat.of(context).user.id],
|
||||
/// }
|
||||
/// },
|
||||
/// sort: [SortOption('last_message_at')],
|
||||
/// pagination: PaginationParams(
|
||||
/// limit: 20,
|
||||
/// ),
|
||||
/// channelWidget: ChannelPage(),
|
||||
/// ),
|
||||
/// );
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
///
|
||||
/// Make sure to have a [StreamChat] ancestor in order to provide the
|
||||
/// information about the channels.
|
||||
/// The widget uses a [ListView.custom] to render the list of channels.
|
||||
///
|
||||
/// The widget components render the ui based on the first ancestor of
|
||||
/// type [StreamChatTheme].
|
||||
/// Modify it to change the widget appearance.
|
||||
@Deprecated("Use 'StreamChannelListView' instead")
|
||||
class ChannelListView extends StatefulWidget {
|
||||
/// Instantiate a new ChannelListView
|
||||
@Deprecated("Use 'StreamChannelListView' instead")
|
||||
ChannelListView({
|
||||
super.key,
|
||||
this.filter,
|
||||
this.sort,
|
||||
this.state = true,
|
||||
this.watch = true,
|
||||
this.presence = false,
|
||||
this.memberLimit,
|
||||
this.messageLimit,
|
||||
@Deprecated(
|
||||
"'pagination' is deprecated and shouldn't be used. "
|
||||
"This property is no longer used, Please use 'limit' instead",
|
||||
)
|
||||
this.pagination,
|
||||
int? limit,
|
||||
this.onChannelTap,
|
||||
this.onChannelLongPress,
|
||||
this.channelWidget,
|
||||
this.channelPreviewBuilder,
|
||||
this.separatorBuilder,
|
||||
this.onImageTap,
|
||||
this.onStartChatPressed,
|
||||
this.swipeToAction = false,
|
||||
this.pullToRefresh = true,
|
||||
this.crossAxisCount = 1,
|
||||
this.padding,
|
||||
this.selectedChannels = const [],
|
||||
this.onViewInfoTap,
|
||||
this.errorBuilder,
|
||||
this.emptyBuilder,
|
||||
this.loadingBuilder,
|
||||
this.listBuilder,
|
||||
this.onMoreDetailsPressed,
|
||||
this.onDeletePressed,
|
||||
this.swipeActions,
|
||||
this.channelListController,
|
||||
}) : limit = limit ?? pagination?.limit ?? 25;
|
||||
|
||||
/// If true a default swipe to action behaviour will be added to this widget
|
||||
final bool swipeToAction;
|
||||
|
||||
/// The query filters to use.
|
||||
/// You can query on any of the custom fields you've defined on the [Channel].
|
||||
/// You can also filter other built-in channel fields.
|
||||
final Filter? filter;
|
||||
|
||||
/// The sorting used for the channels matching the filters.
|
||||
/// Sorting is based on field and direction, multiple sorting options
|
||||
/// can be provided.
|
||||
/// You can sort based on last_updated, last_message_at, updated_at,
|
||||
/// created_at or member_count.
|
||||
/// Direction can be ascending or descending.
|
||||
final List<SortOption<ChannelModel>>? sort;
|
||||
|
||||
/// If true returns the Channel state
|
||||
final bool state;
|
||||
|
||||
/// If true listen to changes to this Channel in real time.
|
||||
final bool watch;
|
||||
|
||||
/// If true you’ll receive user presence updates via the websocket events
|
||||
final bool presence;
|
||||
|
||||
/// Number of members to fetch in each channel
|
||||
final int? memberLimit;
|
||||
|
||||
/// Number of messages to fetch in each channel
|
||||
final int? messageLimit;
|
||||
|
||||
/// Pagination parameters
|
||||
/// limit: the number of channels to return (max is 30)
|
||||
/// offset: the offset (max is 1000)
|
||||
/// message_limit: how many messages should be included to each channel
|
||||
@Deprecated(
|
||||
"'pagination' is deprecated and shouldn't be used. "
|
||||
"This property is no longer used, Please use 'limit' instead",
|
||||
)
|
||||
final PaginationParams? pagination;
|
||||
|
||||
/// The amount of channels requested per API call.
|
||||
final int limit;
|
||||
|
||||
/// Function called when tapping on a channel
|
||||
/// By default it calls [Navigator.push] building a [MaterialPageRoute]
|
||||
/// with the widget [channelWidget] as child.
|
||||
final ChannelTapCallback? onChannelTap;
|
||||
|
||||
/// Function called when long pressing on a channel
|
||||
final Function(Channel)? onChannelLongPress;
|
||||
|
||||
/// Widget used when opening a channel
|
||||
final Widget? channelWidget;
|
||||
|
||||
/// Builder used to create a custom channel preview
|
||||
final ChannelPreviewBuilder? channelPreviewBuilder;
|
||||
|
||||
/// Builder used to create a custom item separator
|
||||
final Function(BuildContext, int)? separatorBuilder;
|
||||
|
||||
/// The function called when the image is tapped
|
||||
final Function(Channel)? onImageTap;
|
||||
|
||||
/// Set it to false to disable the pull-to-refresh widget
|
||||
final bool pullToRefresh;
|
||||
|
||||
/// Callback used in the default empty list widget
|
||||
final VoidCallback? onStartChatPressed;
|
||||
|
||||
/// The number of children in the cross axis.
|
||||
final int crossAxisCount;
|
||||
|
||||
/// The amount of space by which to inset the children.
|
||||
final EdgeInsetsGeometry? padding;
|
||||
|
||||
/// List of selected channels which are displayed differently
|
||||
final List<Channel> selectedChannels;
|
||||
|
||||
/// Callback for when 'View Info' is tapped
|
||||
final ViewInfoCallback? onViewInfoTap;
|
||||
|
||||
/// The builder that will be used in case of error
|
||||
final ErrorBuilder? errorBuilder;
|
||||
|
||||
/// The builder that will be used in case of loading
|
||||
final WidgetBuilder? loadingBuilder;
|
||||
|
||||
/// The builder which is used when list of channels loads
|
||||
final Function(BuildContext, List<Channel>)? listBuilder;
|
||||
|
||||
/// The builder used when the channel list is empty.
|
||||
final WidgetBuilder? emptyBuilder;
|
||||
|
||||
/// Callback used when the more details slidable option is pressed
|
||||
final ChannelInfoCallback? onMoreDetailsPressed;
|
||||
|
||||
/// Callback used when the delete slidable option is pressed
|
||||
final ChannelInfoCallback? onDeletePressed;
|
||||
|
||||
/// List of actions for slidable
|
||||
final List<SwipeAction>? swipeActions;
|
||||
|
||||
/// A [ChannelListController] allows reloading and pagination.
|
||||
/// Use [ChannelListController.loadData] and
|
||||
/// [ChannelListController.paginateData] respectively for reloading and
|
||||
/// pagination.
|
||||
final ChannelListController? channelListController;
|
||||
|
||||
@override
|
||||
_ChannelListViewState createState() => _ChannelListViewState();
|
||||
}
|
||||
|
||||
class _ChannelListViewState extends State<ChannelListView> {
|
||||
late final _defaultController = ChannelListController();
|
||||
|
||||
ChannelListController get _channelListController =>
|
||||
widget.channelListController ?? _defaultController;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget child = ChannelListCore(
|
||||
filter: widget.filter,
|
||||
sort: widget.sort,
|
||||
state: widget.state,
|
||||
watch: widget.watch,
|
||||
presence: widget.presence,
|
||||
memberLimit: widget.memberLimit,
|
||||
messageLimit: widget.messageLimit,
|
||||
limit: widget.limit,
|
||||
channelListController: _channelListController,
|
||||
listBuilder: widget.listBuilder ?? _buildListView,
|
||||
emptyBuilder: widget.emptyBuilder ?? _buildEmptyWidget,
|
||||
errorBuilder: widget.errorBuilder ?? _buildErrorWidget,
|
||||
loadingBuilder: widget.loadingBuilder ?? _buildLoadingWidget,
|
||||
);
|
||||
|
||||
if (widget.pullToRefresh) {
|
||||
child = RefreshIndicator(
|
||||
onRefresh: () => _channelListController.loadData!(),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
child = LazyLoadScrollView(
|
||||
onEndOfPage: () => _channelListController.paginateData!(),
|
||||
child: child,
|
||||
);
|
||||
|
||||
final backgroundColor =
|
||||
StreamChannelListViewTheme.of(context).backgroundColor;
|
||||
|
||||
if (backgroundColor != null) {
|
||||
return ColoredBox(
|
||||
color: backgroundColor,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
Widget _buildListView(BuildContext context, List<Channel> channels) {
|
||||
if (widget.crossAxisCount > 1) {
|
||||
return GridView.builder(
|
||||
padding: widget.padding,
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: widget.crossAxisCount,
|
||||
),
|
||||
itemCount: channels.length,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
itemBuilder: (context, index) =>
|
||||
_gridItemBuilder(context, index, channels),
|
||||
);
|
||||
}
|
||||
return SlidableAutoCloseBehavior(
|
||||
child: ListView.separated(
|
||||
padding: widget.padding,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
// all channels + progress loader
|
||||
itemCount: channels.length + 1,
|
||||
separatorBuilder: (_, index) {
|
||||
if (widget.separatorBuilder != null) {
|
||||
return widget.separatorBuilder!(context, index);
|
||||
}
|
||||
return _separatorBuilder(context, index);
|
||||
},
|
||||
itemBuilder: (context, index) =>
|
||||
_listItemBuilder(context, index, channels),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyWidget(BuildContext context) => LayoutBuilder(
|
||||
builder: (context, viewportConstraints) {
|
||||
final chatThemeData = StreamChatTheme.of(context);
|
||||
return SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: Stack(
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: viewportConstraints.maxHeight,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: StreamSvgIcon.message(
|
||||
size: 136,
|
||||
color: chatThemeData.colorTheme.disabled,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(
|
||||
context.translations.letsStartChattingLabel,
|
||||
style: chatThemeData.textTheme.headline,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 8,
|
||||
horizontal: 52,
|
||||
),
|
||||
child: Text(
|
||||
context.translations.sendingFirstMessageLabel,
|
||||
textAlign: TextAlign.center,
|
||||
style: chatThemeData.textTheme.body.copyWith(
|
||||
color: chatThemeData.colorTheme.textLowEmphasis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.onStartChatPressed != null)
|
||||
Positioned(
|
||||
right: 0,
|
||||
left: 0,
|
||||
bottom: 32,
|
||||
child: Center(
|
||||
child: TextButton(
|
||||
onPressed: widget.onStartChatPressed,
|
||||
child: Text(
|
||||
context.translations.startAChatLabel,
|
||||
style: chatThemeData.textTheme.bodyBold.copyWith(
|
||||
color: chatThemeData.colorTheme.accentPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Widget _buildLoadingWidget(BuildContext context) => ListView(
|
||||
padding: widget.padding,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: List.generate(
|
||||
25,
|
||||
(i) {
|
||||
if (widget.crossAxisCount == 1) {
|
||||
if (i % 2 != 0) {
|
||||
if (widget.separatorBuilder != null) {
|
||||
return widget.separatorBuilder!(context, i);
|
||||
}
|
||||
return _separatorBuilder(context, i);
|
||||
}
|
||||
}
|
||||
return _buildLoadingItem(context);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Shimmer _buildLoadingItem(BuildContext context) {
|
||||
final chatThemeData = StreamChatTheme.of(context);
|
||||
if (widget.crossAxisCount > 1) {
|
||||
return Shimmer.fromColors(
|
||||
baseColor: chatThemeData.colorTheme.disabled,
|
||||
highlightColor: chatThemeData.colorTheme.inputBg,
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
for (int i = 0; i < widget.crossAxisCount; i++)
|
||||
Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
height: 70,
|
||||
width: 70,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Shimmer.fromColors(
|
||||
baseColor: chatThemeData.colorTheme.disabled,
|
||||
highlightColor: chatThemeData.colorTheme.inputBg,
|
||||
child: ListTile(
|
||||
leading: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: chatThemeData.colorTheme.barsBg,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
height: 40,
|
||||
width: 40,
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.only(
|
||||
left: 8,
|
||||
right: 8,
|
||||
),
|
||||
title: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: chatThemeData.colorTheme.barsBg,
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
),
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
height: 16,
|
||||
width: 82,
|
||||
),
|
||||
),
|
||||
),
|
||||
subtitle: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: chatThemeData.colorTheme.barsBg,
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
),
|
||||
constraints: const BoxConstraints.expand(
|
||||
height: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
margin: const EdgeInsets.only(left: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: chatThemeData.colorTheme.barsBg,
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
),
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
height: 16,
|
||||
width: 42,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildErrorWidget(BuildContext context, Object error) => Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
const WidgetSpan(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
right: 2,
|
||||
),
|
||||
child: Icon(Icons.error_outline),
|
||||
),
|
||||
),
|
||||
TextSpan(text: context.translations.loadingChannelsError),
|
||||
],
|
||||
),
|
||||
style: Theme.of(context).textTheme.headline6,
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _channelListController.loadData!(),
|
||||
child: Text(context.translations.retryLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _listItemBuilder(BuildContext context, int i, List<Channel> channels) {
|
||||
final channelsBloc = ChannelsBloc.of(context);
|
||||
|
||||
if (i == channels.length) {
|
||||
return _buildQueryProgressIndicator(context, channelsBloc);
|
||||
}
|
||||
|
||||
final onTap = _getChannelTap(context);
|
||||
final chatThemeData = StreamChatTheme.of(context);
|
||||
final backgroundColor = chatThemeData.colorTheme.inputBg;
|
||||
final channel = channels[i];
|
||||
|
||||
final canDeleteChannel =
|
||||
channel.ownCapabilities.contains(PermissionType.deleteChannel);
|
||||
|
||||
final actionPaneChildren =
|
||||
widget.swipeActions?.length ?? (canDeleteChannel ? 2 : 1);
|
||||
final actionPaneExtentRatio = actionPaneChildren > 5
|
||||
? 1 / actionPaneChildren
|
||||
: actionPaneChildren * 0.2;
|
||||
|
||||
return StreamChannel(
|
||||
key: ValueKey<String>('CHANNEL-${channel.cid}'),
|
||||
channel: channel,
|
||||
child: Slidable(
|
||||
enabled: widget.swipeToAction,
|
||||
endActionPane: ActionPane(
|
||||
extentRatio: actionPaneExtentRatio,
|
||||
motion: const BehindMotion(),
|
||||
children: widget.swipeActions
|
||||
?.map((e) => CustomSlidableAction(
|
||||
backgroundColor: e.color ?? Colors.white,
|
||||
child: e.iconWidget,
|
||||
onPressed: (_) {
|
||||
e.onTap?.call(channel);
|
||||
},
|
||||
))
|
||||
.toList() ??
|
||||
<Widget>[
|
||||
CustomSlidableAction(
|
||||
backgroundColor: backgroundColor,
|
||||
onPressed: widget.onMoreDetailsPressed != null
|
||||
? (_) {
|
||||
widget.onMoreDetailsPressed!(channel);
|
||||
}
|
||||
: (_) {
|
||||
showModalBottomSheet(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(32),
|
||||
topRight: Radius.circular(32),
|
||||
),
|
||||
),
|
||||
context: context,
|
||||
builder: (context) => StreamChannel(
|
||||
channel: channel,
|
||||
child: StreamChannelInfoBottomSheet(
|
||||
channel: channel,
|
||||
onViewInfoTap: () {
|
||||
widget.onViewInfoTap?.call(channel);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Icon(Icons.more_horiz),
|
||||
),
|
||||
if (canDeleteChannel)
|
||||
CustomSlidableAction(
|
||||
backgroundColor: backgroundColor,
|
||||
onPressed: widget.onDeletePressed != null
|
||||
? (_) {
|
||||
widget.onDeletePressed?.call(channel);
|
||||
}
|
||||
: (_) async {
|
||||
final res = await showConfirmationDialog(
|
||||
context,
|
||||
title:
|
||||
context.translations.deleteConversationLabel,
|
||||
question: context
|
||||
.translations.deleteConversationQuestion,
|
||||
okText: context.translations.deleteLabel,
|
||||
cancelText: context.translations.cancelLabel,
|
||||
icon: StreamSvgIcon.delete(
|
||||
color: chatThemeData.colorTheme.accentError,
|
||||
),
|
||||
);
|
||||
if (res == true) {
|
||||
await channel.delete();
|
||||
}
|
||||
},
|
||||
child: StreamSvgIcon.delete(
|
||||
color: chatThemeData.colorTheme.accentError,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: widget.channelPreviewBuilder?.call(context, channel) ??
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: chatThemeData.channelListViewTheme.backgroundColor,
|
||||
),
|
||||
child: ChannelPreview(
|
||||
onLongPress: widget.onChannelLongPress,
|
||||
channel: channel,
|
||||
onImageTap: widget.onImageTap != null
|
||||
? () => widget.onImageTap!(channel)
|
||||
: null,
|
||||
onTap: (channel) => onTap(channel, widget.channelWidget),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ChannelTapCallback _getChannelTap(BuildContext context) {
|
||||
ChannelTapCallback onTap;
|
||||
if (widget.onChannelTap != null) {
|
||||
onTap = widget.onChannelTap!;
|
||||
} else {
|
||||
onTap = (channel, _) {
|
||||
if (widget.channelWidget == null) {
|
||||
return;
|
||||
}
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => StreamChannel(
|
||||
channel: channel,
|
||||
child: widget.channelWidget!,
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
}
|
||||
return onTap;
|
||||
}
|
||||
|
||||
Widget _gridItemBuilder(BuildContext context, int i, List<Channel> channels) {
|
||||
final channel = channels[i];
|
||||
|
||||
final selected = widget.selectedChannels.contains(channel);
|
||||
|
||||
return Container(
|
||||
key: ValueKey<String>('CHANNEL-${channel.id}'),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
StreamChannelAvatar(
|
||||
channel: channel,
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
selected: selected,
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
width: 64,
|
||||
height: 64,
|
||||
),
|
||||
onTap: () => _getChannelTap(context),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: StreamChannel(
|
||||
channel: channel,
|
||||
child: StreamChannelName(
|
||||
channel: channel,
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQueryProgressIndicator(
|
||||
context,
|
||||
ChannelsBlocState channelsProvider,
|
||||
) =>
|
||||
BetterStreamBuilder<bool>(
|
||||
stream: channelsProvider.queryChannelsLoading,
|
||||
initialData: false,
|
||||
errorBuilder: (context, err) {
|
||||
final theme = StreamChatTheme.of(context);
|
||||
return ColoredBox(
|
||||
color: theme.colorTheme.textLowEmphasis.withOpacity(0.9),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
context.translations.loadingChannelsError,
|
||||
style: theme.textTheme.body.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
builder: (context, showLoading) {
|
||||
if (!showLoading) return const Offstage();
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Widget _separatorBuilder(context, i) {
|
||||
final effect = StreamChatTheme.of(context).colorTheme.borderBottom;
|
||||
|
||||
return Container(
|
||||
height: 1,
|
||||
color: effect.color!.withOpacity(effect.alpha ?? 1.0),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Class for slidable action
|
||||
class SwipeAction {
|
||||
/// Constructor for creating [SwipeAction]
|
||||
SwipeAction({
|
||||
this.color,
|
||||
required this.iconWidget,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
/// Background color of action
|
||||
Color? color;
|
||||
|
||||
/// Widget to display as icon
|
||||
Widget iconWidget;
|
||||
|
||||
/// Callback when icon is tapped
|
||||
ChannelInfoCallback? onTap;
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/extension.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// It shows the current [Channel] name using a [Text] widget.
|
||||
///
|
||||
/// The widget uses a [StreamBuilder] to render the channel information
|
||||
/// image as soon as it updates.
|
||||
@Deprecated("Use 'StreamChannelName' instead")
|
||||
class ChannelName extends StatelessWidget {
|
||||
/// Instantiate a new ChannelName
|
||||
const ChannelName({
|
||||
super.key,
|
||||
this.textStyle,
|
||||
this.textOverflow = TextOverflow.ellipsis,
|
||||
});
|
||||
|
||||
/// The style of the text displayed
|
||||
final TextStyle? textStyle;
|
||||
|
||||
/// How visual overflow should be handled.
|
||||
final TextOverflow textOverflow;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final client = StreamChat.of(context);
|
||||
final channel = StreamChannel.of(context).channel;
|
||||
|
||||
assert(channel.state != null, 'Channel ${channel.id} is not initialized');
|
||||
|
||||
return BetterStreamBuilder<String>(
|
||||
stream: channel.nameStream,
|
||||
initialData: channel.name,
|
||||
builder: (context, channelName) => Text(
|
||||
channelName,
|
||||
style: textStyle,
|
||||
overflow: textOverflow,
|
||||
),
|
||||
noDataBuilder: (context) => _generateName(
|
||||
client.currentUser!,
|
||||
channel.state!.members,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _generateName(
|
||||
User currentUser,
|
||||
List<Member> members,
|
||||
) =>
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
var channelName = context.translations.noTitleText;
|
||||
final otherMembers = members.where(
|
||||
(member) => member.userId != currentUser.id,
|
||||
);
|
||||
|
||||
if (otherMembers.isNotEmpty) {
|
||||
if (otherMembers.length == 1) {
|
||||
final user = otherMembers.first.user;
|
||||
if (user != null) {
|
||||
channelName = user.name;
|
||||
}
|
||||
} else {
|
||||
final maxWidth = constraints.maxWidth;
|
||||
final maxChars = maxWidth / (textStyle?.fontSize ?? 1);
|
||||
var currentChars = 0;
|
||||
final currentMembers = <Member>[];
|
||||
otherMembers.forEach((element) {
|
||||
final newLength =
|
||||
currentChars + (element.user?.name.length ?? 0);
|
||||
if (newLength < maxChars) {
|
||||
currentChars = newLength;
|
||||
currentMembers.add(element);
|
||||
}
|
||||
});
|
||||
|
||||
final exceedingMembers =
|
||||
otherMembers.length - currentMembers.length;
|
||||
channelName =
|
||||
'${currentMembers.map((e) => e.user?.name).join(', ')} '
|
||||
'${exceedingMembers > 0 ? '+ $exceedingMembers' : ''}';
|
||||
}
|
||||
}
|
||||
|
||||
return Text(
|
||||
channelName,
|
||||
style: textStyle,
|
||||
overflow: textOverflow,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
import 'package:collection/collection.dart'
|
||||
show IterableExtension, ListEquality;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/extension.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@template channel_preview}
|
||||
/// 
|
||||
/// 
|
||||
///
|
||||
/// It shows the current [Channel] preview.
|
||||
///
|
||||
/// The widget uses a [StreamBuilder] to render the channel information
|
||||
/// image as soon as it updates.
|
||||
///
|
||||
/// Usually you don't use this widget as it's the default channel preview
|
||||
/// used by [StreamChannelListView].
|
||||
///
|
||||
/// The widget renders the ui based on the first ancestor of type
|
||||
/// [StreamChatTheme].
|
||||
/// Modify it to change the widget appearance.
|
||||
/// {@endtemplate}
|
||||
@Deprecated("Use 'StreamChannelListTile' instead")
|
||||
class ChannelPreview extends StatelessWidget {
|
||||
/// Constructor for creating [ChannelPreview]
|
||||
const ChannelPreview({
|
||||
required this.channel,
|
||||
super.key,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
this.onImageTap,
|
||||
this.title,
|
||||
this.subtitle,
|
||||
this.leading,
|
||||
this.sendingIndicator,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
/// Function called when tapping this widget
|
||||
final void Function(Channel)? onTap;
|
||||
|
||||
/// Function called when long pressing this widget
|
||||
final void Function(Channel)? onLongPress;
|
||||
|
||||
/// Channel displayed
|
||||
final Channel channel;
|
||||
|
||||
/// The function called when the image is tapped
|
||||
final VoidCallback? onImageTap;
|
||||
|
||||
/// Widget rendering the title
|
||||
final Widget? title;
|
||||
|
||||
/// Widget rendering the subtitle
|
||||
final Widget? subtitle;
|
||||
|
||||
/// Widget rendering the leading element, by default
|
||||
/// it shows the [StreamChannelAvatar]
|
||||
final Widget? leading;
|
||||
|
||||
/// Widget rendering the trailing element,
|
||||
/// by default it shows the last message date
|
||||
final Widget? trailing;
|
||||
|
||||
/// Widget rendering the sending indicator,
|
||||
/// by default it uses the [StreamSendingIndicator] widget
|
||||
final Widget? sendingIndicator;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final channelPreviewTheme = ChannelPreviewTheme.of(context);
|
||||
final streamChatState = StreamChat.of(context);
|
||||
return BetterStreamBuilder<bool>(
|
||||
stream: channel.isMutedStream,
|
||||
initialData: channel.isMuted,
|
||||
builder: (context, data) => AnimatedOpacity(
|
||||
opacity: data ? 0.5 : 1,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: ListTile(
|
||||
visualDensity: VisualDensity.compact,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
),
|
||||
onTap: () => onTap?.call(channel),
|
||||
onLongPress: () => onLongPress?.call(channel),
|
||||
leading: leading ??
|
||||
StreamChannelAvatar(
|
||||
channel: channel,
|
||||
onTap: onImageTap,
|
||||
),
|
||||
title: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Flexible(
|
||||
child: title ??
|
||||
StreamChannelName(
|
||||
channel: channel,
|
||||
textStyle: channelPreviewTheme.titleStyle,
|
||||
),
|
||||
),
|
||||
BetterStreamBuilder<List<Member>>(
|
||||
stream: channel.state?.membersStream,
|
||||
initialData: channel.state?.members,
|
||||
comparator: const ListEquality().equals,
|
||||
builder: (context, members) {
|
||||
if (members.isEmpty ||
|
||||
!members.any((Member e) =>
|
||||
e.user!.id == channel.client.state.currentUser?.id)) {
|
||||
return const SizedBox();
|
||||
}
|
||||
return StreamUnreadIndicator(
|
||||
cid: channel.cid,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Flexible(child: subtitle ?? _buildSubtitle(context)),
|
||||
sendingIndicator ??
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final lastMessage =
|
||||
channel.state?.messages.lastWhereOrNull(
|
||||
(m) => !m.isDeleted && !m.shadowed,
|
||||
);
|
||||
if (lastMessage?.user?.id ==
|
||||
streamChatState.currentUser?.id) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: BetterStreamBuilder<List<Read>>(
|
||||
stream: channel.state?.readStream,
|
||||
initialData: channel.state?.read,
|
||||
builder: (context, data) {
|
||||
final readList = data.where((it) =>
|
||||
it.user.id !=
|
||||
channel.client.state.currentUser?.id &&
|
||||
(it.lastRead
|
||||
.isAfter(lastMessage!.createdAt) ||
|
||||
it.lastRead.isAtSameMomentAs(
|
||||
lastMessage.createdAt,
|
||||
)));
|
||||
final isMessageRead = readList.length >=
|
||||
(channel.memberCount ?? 0) - 1;
|
||||
return StreamSendingIndicator(
|
||||
message: lastMessage!,
|
||||
size: channelPreviewTheme.indicatorIconSize,
|
||||
isMessageRead: isMessageRead,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
trailing ?? _buildDate(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDate(BuildContext context) => BetterStreamBuilder<DateTime>(
|
||||
stream: channel.lastMessageAtStream,
|
||||
initialData: channel.lastMessageAt,
|
||||
builder: (context, data) {
|
||||
final lastMessageAt = data.toLocal();
|
||||
|
||||
String stringDate;
|
||||
final now = DateTime.now();
|
||||
|
||||
final startOfDay = DateTime(now.year, now.month, now.day);
|
||||
|
||||
if (lastMessageAt.millisecondsSinceEpoch >=
|
||||
startOfDay.millisecondsSinceEpoch) {
|
||||
stringDate = Jiffy(lastMessageAt.toLocal()).jm;
|
||||
} else if (lastMessageAt.millisecondsSinceEpoch >=
|
||||
startOfDay
|
||||
.subtract(const Duration(days: 1))
|
||||
.millisecondsSinceEpoch) {
|
||||
stringDate = context.translations.yesterdayLabel;
|
||||
} else if (startOfDay.difference(lastMessageAt).inDays < 7) {
|
||||
stringDate = Jiffy(lastMessageAt.toLocal()).EEEE;
|
||||
} else {
|
||||
stringDate = Jiffy(lastMessageAt.toLocal()).yMd;
|
||||
}
|
||||
|
||||
return Text(
|
||||
stringDate,
|
||||
style: ChannelPreviewTheme.of(context).lastMessageAtStyle,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Widget _buildSubtitle(BuildContext context) {
|
||||
final channelPreviewTheme = ChannelPreviewTheme.of(context);
|
||||
if (channel.isMuted) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
StreamSvgIcon.mute(
|
||||
size: 16,
|
||||
),
|
||||
Text(
|
||||
' ${context.translations.channelIsMutedText}',
|
||||
style: channelPreviewTheme.subtitleStyle,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return StreamTypingIndicator(
|
||||
channel: channel,
|
||||
alternativeWidget: _buildLastMessage(context),
|
||||
style: channelPreviewTheme.subtitleStyle,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLastMessage(BuildContext context) => Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: BetterStreamBuilder<List<Message>>(
|
||||
stream: channel.state!.messagesStream,
|
||||
initialData: channel.state!.messages,
|
||||
builder: (context, data) {
|
||||
final lastMessage =
|
||||
data.lastWhereOrNull((m) => !m.shadowed && !m.isDeleted);
|
||||
if (lastMessage == null) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
var text = lastMessage.text;
|
||||
final parts = <String>[
|
||||
...lastMessage.attachments.map((e) {
|
||||
if (e.type == 'image') {
|
||||
return '📷';
|
||||
} else if (e.type == 'video') {
|
||||
return '🎬';
|
||||
} else if (e.type == 'giphy') {
|
||||
return '[GIF]';
|
||||
}
|
||||
return e == lastMessage.attachments.last
|
||||
? (e.title ?? 'File')
|
||||
: '${e.title ?? 'File'} , ';
|
||||
}),
|
||||
lastMessage.text ?? '',
|
||||
];
|
||||
|
||||
text = parts.join(' ');
|
||||
|
||||
final channelPreviewTheme = ChannelPreviewTheme.of(context);
|
||||
return Text.rich(
|
||||
_getDisplayText(
|
||||
text,
|
||||
lastMessage.mentionedUsers,
|
||||
lastMessage.attachments,
|
||||
channelPreviewTheme.subtitleStyle?.copyWith(
|
||||
color: channelPreviewTheme.subtitleStyle?.color,
|
||||
fontStyle: (lastMessage.isSystem || lastMessage.isDeleted)
|
||||
? FontStyle.italic
|
||||
: FontStyle.normal,
|
||||
),
|
||||
channelPreviewTheme.subtitleStyle?.copyWith(
|
||||
color: channelPreviewTheme.subtitleStyle?.color,
|
||||
fontStyle: (lastMessage.isSystem || lastMessage.isDeleted)
|
||||
? FontStyle.italic
|
||||
: FontStyle.normal,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.start,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
TextSpan _getDisplayText(
|
||||
String text,
|
||||
List<User> mentions,
|
||||
List<Attachment> attachments,
|
||||
TextStyle? normalTextStyle,
|
||||
TextStyle? mentionsTextStyle,
|
||||
) {
|
||||
final textList = text.split(' ');
|
||||
final resList = <TextSpan>[];
|
||||
for (final e in textList) {
|
||||
if (mentions.isNotEmpty &&
|
||||
mentions.any((element) => '@${element.name}' == e)) {
|
||||
resList.add(TextSpan(
|
||||
text: '$e ',
|
||||
style: mentionsTextStyle,
|
||||
));
|
||||
} else if (attachments.isNotEmpty &&
|
||||
attachments
|
||||
.where((e) => e.title != null)
|
||||
.any((element) => element.title == e)) {
|
||||
resList.add(TextSpan(
|
||||
text: '$e ',
|
||||
style: normalTextStyle?.copyWith(fontStyle: FontStyle.italic),
|
||||
));
|
||||
} else {
|
||||
resList.add(TextSpan(
|
||||
text: e == textList.last ? e : '$e ',
|
||||
style: normalTextStyle,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return TextSpan(children: resList);
|
||||
}
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/extension.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@macro commands_overlay}
|
||||
@Deprecated("Use 'StreamCommandsOverlay' instead")
|
||||
typedef CommandsOverlay = StreamCommandsOverlay;
|
||||
|
||||
/// {@template commands_overlay}
|
||||
/// Overlay for displaying commands that can be used
|
||||
/// to interact with the channel.
|
||||
/// {@endtemplate}
|
||||
class StreamCommandsOverlay extends StatelessWidget {
|
||||
/// Constructor for creating a [StreamCommandsOverlay]
|
||||
const StreamCommandsOverlay({
|
||||
required this.text,
|
||||
required this.onCommandResult,
|
||||
required this.size,
|
||||
required this.channel,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The size of the overlay
|
||||
final Size size;
|
||||
|
||||
/// Query for searching commands
|
||||
final String text;
|
||||
|
||||
/// The channel to search for users
|
||||
final Channel channel;
|
||||
|
||||
/// Callback called when a command is selected
|
||||
final ValueChanged<Command> onCommandResult;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final _streamChatTheme = StreamChatTheme.of(context);
|
||||
final commands = channel.config?.commands
|
||||
.where((c) => c.name.contains(text.replaceFirst('/', '')))
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
if (commands.isEmpty) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
color: _streamChatTheme.colorTheme.barsBg,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: Container(
|
||||
constraints: BoxConstraints.loose(size),
|
||||
decoration: BoxDecoration(
|
||||
color: _streamChatTheme.colorTheme.barsBg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
if (commands.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
),
|
||||
child: StreamSvgIcon.lightning(
|
||||
color: _streamChatTheme.colorTheme.accentPrimary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
context.translations.instantCommandsLabel,
|
||||
style: TextStyle(
|
||||
color: _streamChatTheme.colorTheme.textHighEmphasis
|
||||
.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 10,
|
||||
),
|
||||
...commands
|
||||
.map(
|
||||
(c) => InkWell(
|
||||
onTap: () {
|
||||
onCommandResult(c);
|
||||
},
|
||||
child: SizedBox(
|
||||
height: 40,
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
),
|
||||
_buildCommandIcon(_streamChatTheme, c.name),
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
text: c.name.capitalize(),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: ' /${c.name} ${c.args}',
|
||||
style: _streamChatTheme.textTheme.body
|
||||
.copyWith(
|
||||
// ignore: lines_longer_than_80_chars
|
||||
color: _streamChatTheme
|
||||
// ignore: lines_longer_than_80_chars
|
||||
.colorTheme
|
||||
.textLowEmphasis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCommandIcon(
|
||||
StreamChatThemeData _streamChatTheme,
|
||||
String iconType,
|
||||
) {
|
||||
switch (iconType) {
|
||||
case 'giphy':
|
||||
return CircleAvatar(
|
||||
radius: 12,
|
||||
child: StreamSvgIcon.giphyIcon(
|
||||
size: 24,
|
||||
),
|
||||
);
|
||||
case 'ban':
|
||||
return CircleAvatar(
|
||||
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
|
||||
radius: 12,
|
||||
child: StreamSvgIcon.iconUserDelete(
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
case 'flag':
|
||||
return CircleAvatar(
|
||||
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
|
||||
radius: 12,
|
||||
child: StreamSvgIcon.flag(
|
||||
size: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
case 'imgur':
|
||||
return CircleAvatar(
|
||||
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
|
||||
radius: 12,
|
||||
child: ClipOval(
|
||||
child: StreamSvgIcon.imgur(
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
);
|
||||
case 'mute':
|
||||
return CircleAvatar(
|
||||
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
|
||||
radius: 12,
|
||||
child: StreamSvgIcon.mute(
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
case 'unban':
|
||||
return CircleAvatar(
|
||||
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
|
||||
radius: 12,
|
||||
child: StreamSvgIcon.userAdd(
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
case 'unmute':
|
||||
return CircleAvatar(
|
||||
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
|
||||
radius: 12,
|
||||
child: StreamSvgIcon.volumeUp(
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
default:
|
||||
return CircleAvatar(
|
||||
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
|
||||
radius: 12,
|
||||
child: StreamSvgIcon.lightning(
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
import 'package:ezanimation/ezanimation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/extensions.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@template contextMenuReactionPicker}
|
||||
/// Allows the user to select reactions to a message on desktop & web via
|
||||
/// context menu.
|
||||
///
|
||||
/// This differs slightly from [StreamReactionPicker] in order to match our
|
||||
/// design spec.
|
||||
///
|
||||
/// Used by the `_buildContextMenu()` function found in `message_widget.dart`.
|
||||
/// It is not recommended to use this widget directly.
|
||||
/// {@endtemplate}
|
||||
class ContextMenuReactionPicker extends StatefulWidget {
|
||||
/// {@macro contextMenuReactionPicker}
|
||||
const ContextMenuReactionPicker({
|
||||
super.key,
|
||||
required this.message,
|
||||
});
|
||||
|
||||
/// The message to react to.
|
||||
final Message message;
|
||||
|
||||
@override
|
||||
State<ContextMenuReactionPicker> createState() =>
|
||||
_ContextMenuReactionPickerState();
|
||||
}
|
||||
|
||||
class _ContextMenuReactionPickerState extends State<ContextMenuReactionPicker>
|
||||
with TickerProviderStateMixin {
|
||||
List<EzAnimation> animations = [];
|
||||
|
||||
Future<void> triggerAnimations() async {
|
||||
for (final a in animations) {
|
||||
a.start();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> pop() async {
|
||||
for (final a in animations) {
|
||||
a.stop();
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
/// Add a reaction to the message
|
||||
void sendReaction(BuildContext context, String reactionType) {
|
||||
StreamChannel.of(context).channel.sendReaction(
|
||||
widget.message,
|
||||
reactionType,
|
||||
enforceUnique:
|
||||
StreamChatConfiguration.of(context).enforceUniqueReactions,
|
||||
);
|
||||
pop();
|
||||
}
|
||||
|
||||
/// Remove a reaction from the message
|
||||
void removeReaction(BuildContext context, Reaction reaction) {
|
||||
StreamChannel.of(context).channel.deleteReaction(widget.message, reaction);
|
||||
pop();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final a in animations) {
|
||||
a.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final reactionIcons = StreamChatConfiguration.of(context).reactionIcons;
|
||||
|
||||
if (animations.isEmpty && reactionIcons.isNotEmpty) {
|
||||
reactionIcons.forEach((element) {
|
||||
animations.add(
|
||||
EzAnimation.tween(
|
||||
Tween(begin: 0.0, end: 1.0),
|
||||
const Duration(milliseconds: 250),
|
||||
curve: Curves.easeInOutBack,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
triggerAnimations();
|
||||
}
|
||||
|
||||
final child = Material(
|
||||
color: StreamChatTheme.of(context).messageListViewTheme.backgroundColor ??
|
||||
Theme.of(context).scaffoldBackgroundColor,
|
||||
//clipBehavior: Clip.hardEdge,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: reactionIcons
|
||||
.map<Widget>((reactionIcon) {
|
||||
final ownReactionIndex =
|
||||
widget.message.ownReactions?.indexWhere(
|
||||
(reaction) => reaction.type == reactionIcon.type,
|
||||
) ??
|
||||
-1;
|
||||
final index = reactionIcons.indexOf(reactionIcon);
|
||||
|
||||
final child = reactionIcon.builder(
|
||||
context,
|
||||
ownReactionIndex != -1,
|
||||
24,
|
||||
);
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
height: 24,
|
||||
width: 24,
|
||||
),
|
||||
child: RawMaterialButton(
|
||||
elevation: 0,
|
||||
shape: ContinuousRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
height: 24,
|
||||
width: 24,
|
||||
),
|
||||
onPressed: () {
|
||||
if (ownReactionIndex != -1) {
|
||||
removeReaction(
|
||||
context,
|
||||
widget.message.ownReactions![ownReactionIndex],
|
||||
);
|
||||
} else {
|
||||
sendReaction(
|
||||
context,
|
||||
reactionIcon.type,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: AnimatedBuilder(
|
||||
animation: animations[index],
|
||||
builder: (context, child) => Transform.scale(
|
||||
scale: animations[index].value,
|
||||
child: child,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
})
|
||||
.insertBetween(
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0, end: 1),
|
||||
curve: Curves.easeInOutBack,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
builder: (context, val, widget) => Transform.scale(
|
||||
scale: val,
|
||||
child: widget,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/context_menu_items/stream_chat_context_menu_item.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/extensions.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@template downloadMenuItem}
|
||||
/// Defines a "download" context menu item that allows a user to download
|
||||
/// a given attachment.
|
||||
///
|
||||
/// Used in [DesktopFullscreenMedia].
|
||||
/// {@endtemplate}
|
||||
class DownloadMenuItem extends StatelessWidget {
|
||||
/// {@macro downloadMenuItem}
|
||||
const DownloadMenuItem({
|
||||
super.key,
|
||||
required this.attachment,
|
||||
});
|
||||
|
||||
/// The attachment to download.
|
||||
final Attachment attachment;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamChatContextMenuItem(
|
||||
leading: StreamSvgIcon.download(),
|
||||
title: Text(context.translations.downloadLabel),
|
||||
onClick: () async {
|
||||
Navigator.of(context).pop();
|
||||
StreamAttachmentHandler.instance.downloadAttachment(attachment);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@template streamChatContextMenuItem}
|
||||
/// Builds a context menu item according to Stream design specification.
|
||||
/// {@endtemplate}
|
||||
class StreamChatContextMenuItem extends StatelessWidget {
|
||||
/// {@macro streamChatContextMenuItem}
|
||||
const StreamChatContextMenuItem({
|
||||
super.key,
|
||||
this.child,
|
||||
this.leading,
|
||||
this.title,
|
||||
this.onClick,
|
||||
});
|
||||
|
||||
/// The child widget for this menu item. Usually a [DesktopReactionPicker].
|
||||
///
|
||||
/// Leave null in order to use the default menu item widget.
|
||||
final Widget? child;
|
||||
|
||||
/// The widget to lead the menu item with. Usually an [Icon].
|
||||
///
|
||||
/// If [child] is specified, this will be ignored.
|
||||
final Widget? leading;
|
||||
|
||||
/// The title of the menu item. Usually a [Text].
|
||||
///
|
||||
/// If [child] is specified, this will be ignored.
|
||||
final Widget? title;
|
||||
|
||||
/// The action to perform when the menu item is clicked.
|
||||
///
|
||||
/// If [child] is specified, this will be ignored.
|
||||
final VoidCallback? onClick;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Ink(
|
||||
color: StreamChatTheme.of(context).messageListViewTheme.backgroundColor ??
|
||||
Theme.of(context).scaffoldBackgroundColor,
|
||||
child: child ??
|
||||
ListTile(
|
||||
dense: true,
|
||||
leading: leading,
|
||||
title: title,
|
||||
onTap: onClick,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@template channelInfoDialog}
|
||||
/// A dialog for showing information about a channel on desktop & web platforms.
|
||||
/// {@endtemplate}
|
||||
class ChannelInfoDialog extends StatelessWidget {
|
||||
/// {@macro channelInfoDialog}
|
||||
const ChannelInfoDialog({
|
||||
super.key,
|
||||
required this.channel,
|
||||
});
|
||||
|
||||
/// The channel to display information about.
|
||||
final Channel channel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final streamTheme = StreamChatTheme.of(context);
|
||||
final members = channel.state?.members ?? [];
|
||||
|
||||
final userAsMember = members.firstWhere(
|
||||
(e) => e.user?.id == StreamChat.of(context).currentUser?.id,
|
||||
);
|
||||
return StreamChannel(
|
||||
channel: channel,
|
||||
child: SimpleDialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
backgroundColor: streamTheme.colorTheme.appBg,
|
||||
title: Text(
|
||||
channel.name ?? channel.id!,
|
||||
style: StreamChatTheme.of(context).textTheme.headlineBold,
|
||||
),
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
StreamChannelInfo(
|
||||
channel: channel,
|
||||
textStyle: StreamChatTheme.of(context)
|
||||
.channelPreviewTheme
|
||||
.subtitleStyle,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (channel.isDistinct && channel.memberCount == 2)
|
||||
Column(
|
||||
children: [
|
||||
StreamUserAvatar(
|
||||
user: members
|
||||
.firstWhere(
|
||||
(e) => e.user?.id != userAsMember.user?.id,
|
||||
)
|
||||
.user!,
|
||||
constraints: const BoxConstraints(
|
||||
maxHeight: 64,
|
||||
maxWidth: 64,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
onlineIndicatorConstraints:
|
||||
BoxConstraints.tight(const Size(12, 12)),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
members
|
||||
.firstWhere(
|
||||
(e) => e.user?.id != userAsMember.user?.id,
|
||||
)
|
||||
.user
|
||||
?.name ??
|
||||
'',
|
||||
style: StreamChatTheme.of(context).textTheme.footnoteBold,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/extensions.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// /// {@template confirmationDialog}
|
||||
/// A dialog that prompts the user to take an action or cancel.
|
||||
/// {@endtemplate}
|
||||
class ConfirmationDialog extends StatelessWidget {
|
||||
/// {@macro confirmationDialog}
|
||||
const ConfirmationDialog({
|
||||
super.key,
|
||||
required this.titleText,
|
||||
required this.promptText,
|
||||
required this.affirmativeText,
|
||||
required this.onConfirmation,
|
||||
});
|
||||
|
||||
/// The text to use for the dialog title.
|
||||
final String titleText;
|
||||
|
||||
/// The text to use for the dialog prompt.
|
||||
final String promptText;
|
||||
|
||||
/// The text to use for the confirmation button.
|
||||
final String affirmativeText;
|
||||
|
||||
/// The action to perform when the user confirms their choice.
|
||||
final VoidCallback onConfirmation;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final streamTheme = StreamChatTheme.of(context);
|
||||
return AlertDialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
backgroundColor: streamTheme.colorTheme.appBg,
|
||||
title: Text(titleText),
|
||||
content: Text(promptText),
|
||||
actions: [
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: streamTheme.colorTheme.accentPrimary,
|
||||
),
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(context.translations.cancelLabel),
|
||||
),
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: streamTheme.colorTheme.accentPrimary,
|
||||
),
|
||||
onPressed: () {
|
||||
onConfirmation.call();
|
||||
Navigator.of(context).pop(true);
|
||||
},
|
||||
child: Text(affirmativeText),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/extensions.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@template deleteMessageDialog}
|
||||
/// A dialog that asks the user to confirm that they want to
|
||||
/// delete the selected message.
|
||||
/// {@endtemplate}
|
||||
class DeleteMessageDialog extends StatelessWidget {
|
||||
/// {@macro deleteMessageDialog}
|
||||
const DeleteMessageDialog({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final streamTheme = StreamChatTheme.of(context);
|
||||
return AlertDialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
backgroundColor: streamTheme.colorTheme.appBg,
|
||||
title: Text(context.translations.deleteMessageLabel),
|
||||
content: Text(context.translations.deleteMessageQuestion),
|
||||
actions: [
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: streamTheme.colorTheme.accentPrimary,
|
||||
),
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(context.translations.cancelLabel),
|
||||
),
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: streamTheme.colorTheme.accentPrimary,
|
||||
),
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(context.translations.deleteLabel),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export 'channel_info_dialog.dart';
|
||||
export 'confirmation_dialog.dart';
|
||||
export 'delete_message_dialog.dart';
|
||||
export 'message_dialog.dart';
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/extensions.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@template messageDialog}
|
||||
/// A dialog that displays a message to a user. Falls back to a
|
||||
/// generic error message if no [titleText] and [messageText] are specified.
|
||||
///
|
||||
/// If using this dialog to display the default generic error, be sure NOT to
|
||||
/// specify a [titleText] and [messageText] so the fallback strings can be used.
|
||||
/// {@endtemplate}
|
||||
class MessageDialog extends StatelessWidget {
|
||||
/// {@macro messageDialog}
|
||||
const MessageDialog({
|
||||
super.key,
|
||||
this.titleText,
|
||||
this.messageText,
|
||||
});
|
||||
|
||||
/// The optional error message title to use.
|
||||
final String? titleText;
|
||||
|
||||
/// The optional error message to use.
|
||||
final String? messageText;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final streamTheme = StreamChatTheme.of(context);
|
||||
return AlertDialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
backgroundColor: streamTheme.colorTheme.appBg,
|
||||
title: Text(titleText ?? context.translations.somethingWentWrongError),
|
||||
content: messageText != null
|
||||
? Text(
|
||||
messageText ??
|
||||
context.translations.operationCouldNotBeCompletedText,
|
||||
)
|
||||
: null,
|
||||
actions: [
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: streamTheme.colorTheme.accentPrimary,
|
||||
),
|
||||
child: Text(context.translations.okLabel),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,124 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/emoji/emoji.dart';
|
||||
import 'package:stream_chat_flutter/src/extension.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
import 'package:substring_highlight/substring_highlight.dart';
|
||||
|
||||
/// {@macro emoji_overlay}
|
||||
@Deprecated("Use 'StreamEmojiOverlay' instead")
|
||||
typedef EmojiOverlay = StreamEmojiOverlay;
|
||||
|
||||
/// {@template emoji_overlay}
|
||||
/// Overlay for displaying emoji that can be used
|
||||
/// {@endtemplate}
|
||||
class StreamEmojiOverlay extends StatelessWidget {
|
||||
/// Constructor for creating a [StreamEmojiOverlay]
|
||||
const StreamEmojiOverlay({
|
||||
required this.query,
|
||||
required this.onEmojiResult,
|
||||
required this.size,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The size of the overlay
|
||||
final Size size;
|
||||
|
||||
/// Query for searching emoji
|
||||
final String query;
|
||||
|
||||
/// Callback called when an emoji is selected
|
||||
final ValueChanged<Emoji> onEmojiResult;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final _streamChatTheme = StreamChatTheme.of(context);
|
||||
final _emojiNames =
|
||||
Emoji.all().where((it) => it.name != null).map((e) => e.name!);
|
||||
|
||||
final emojis = _emojiNames
|
||||
.where((e) => e.contains(query))
|
||||
.map(Emoji.byName)
|
||||
.where((e) => e != null);
|
||||
|
||||
if (emojis.isEmpty) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(8),
|
||||
elevation: 2,
|
||||
color: _streamChatTheme.colorTheme.barsBg,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: Container(
|
||||
constraints: BoxConstraints.loose(size),
|
||||
decoration: BoxDecoration(
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
spreadRadius: -8,
|
||||
blurRadius: 5,
|
||||
offset: Offset(0, -4),
|
||||
),
|
||||
],
|
||||
color: _streamChatTheme.colorTheme.barsBg,
|
||||
),
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
shrinkWrap: true,
|
||||
itemCount: emojis.length + 1,
|
||||
itemBuilder: (context, i) {
|
||||
if (i == 0) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 8, top: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: StreamSvgIcon.smile(
|
||||
color: _streamChatTheme.colorTheme.accentPrimary,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
context.translations.emojiMatchingQueryText(
|
||||
query,
|
||||
),
|
||||
style: TextStyle(
|
||||
color: _streamChatTheme.colorTheme.textHighEmphasis
|
||||
.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final emoji = emojis.elementAt(i - 1)!;
|
||||
final themeData = Theme.of(context);
|
||||
return ListTile(
|
||||
title: SubstringHighlight(
|
||||
text:
|
||||
// ignore: lines_longer_than_80_chars
|
||||
"${emoji.char} ${emoji.name!.replaceAll('_', ' ')}",
|
||||
term: query,
|
||||
textStyleHighlight: themeData.textTheme.headline6!.copyWith(
|
||||
fontSize: 14.5,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textStyle: themeData.textTheme.headline6!.copyWith(
|
||||
fontSize: 14.5,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
onEmojiResult(emoji);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,353 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:chewie/chewie.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
import 'package:stream_chat_flutter/src/extension.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
/// Return action for coming back from pages
|
||||
enum ReturnActionType {
|
||||
/// No return action
|
||||
none,
|
||||
|
||||
/// Go to reply message action
|
||||
reply,
|
||||
}
|
||||
|
||||
/// Callback when show message is tapped
|
||||
typedef ShowMessageCallback = void Function(Message message, Channel channel);
|
||||
|
||||
/// {@macro full_screen_media}
|
||||
@Deprecated("Use 'StreamFullScreenMedia' instead")
|
||||
typedef FullScreenMedia = StreamFullScreenMedia;
|
||||
|
||||
/// {@template full_screen_media}
|
||||
/// A full screen image widget
|
||||
/// {@endtemplate}
|
||||
class StreamFullScreenMedia extends StatefulWidget {
|
||||
/// Instantiate a new FullScreenImage
|
||||
const StreamFullScreenMedia({
|
||||
super.key,
|
||||
required this.mediaAttachmentPackages,
|
||||
this.startIndex = 0,
|
||||
String? userName,
|
||||
this.onShowMessage,
|
||||
this.attachmentActionsModalBuilder,
|
||||
this.autoplayVideos = false,
|
||||
}) : userName = userName ?? '';
|
||||
|
||||
/// The url of the image
|
||||
final List<StreamAttachmentPackage> mediaAttachmentPackages;
|
||||
|
||||
/// First index of media shown
|
||||
final int startIndex;
|
||||
|
||||
/// Username of sender
|
||||
final String userName;
|
||||
|
||||
/// Callback for when show message is tapped
|
||||
final ShowMessageCallback? onShowMessage;
|
||||
|
||||
/// Widget builder for attachment actions modal
|
||||
/// [defaultActionsModal] is the default [AttachmentActionsModal] config
|
||||
/// Use [defaultActionsModal.copyWith] to easily customize it
|
||||
final AttachmentActionsBuilder? attachmentActionsModalBuilder;
|
||||
|
||||
/// Auto-play videos when page is opened
|
||||
final bool autoplayVideos;
|
||||
|
||||
@override
|
||||
_StreamFullScreenMediaState createState() => _StreamFullScreenMediaState();
|
||||
}
|
||||
|
||||
class _StreamFullScreenMediaState extends State<StreamFullScreenMedia> {
|
||||
late final PageController _pageController;
|
||||
|
||||
late final _currentPage = ValueNotifier(widget.startIndex);
|
||||
late final _isDisplayingDetail = ValueNotifier<bool>(true);
|
||||
|
||||
void switchDisplayingDetail() {
|
||||
_isDisplayingDetail.value = !_isDisplayingDetail.value;
|
||||
}
|
||||
|
||||
final videoPackages = <String, VideoPackage>{};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pageController = PageController(initialPage: widget.startIndex);
|
||||
for (var i = 0; i < widget.mediaAttachmentPackages.length; i++) {
|
||||
final attachment = widget.mediaAttachmentPackages[i].attachment;
|
||||
if (attachment.type != 'video') continue;
|
||||
final package = VideoPackage(attachment, showControls: true);
|
||||
videoPackages[attachment.id] = package;
|
||||
}
|
||||
initializePlayers();
|
||||
}
|
||||
|
||||
Future<void> initializePlayers() async {
|
||||
if (videoPackages.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final currentAttachment =
|
||||
widget.mediaAttachmentPackages[widget.startIndex].attachment;
|
||||
|
||||
await Future.wait(videoPackages.values.map(
|
||||
(it) => it.initialize(),
|
||||
));
|
||||
|
||||
if (widget.autoplayVideos && currentAttachment.type == 'video') {
|
||||
final package = videoPackages.values
|
||||
.firstWhere((e) => e._attachment == currentAttachment);
|
||||
package._chewieController?.play();
|
||||
}
|
||||
setState(() {}); // ignore: no-empty-block
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: ValueListenableBuilder<int>(
|
||||
valueListenable: _currentPage,
|
||||
builder: (context, currentPage, child) {
|
||||
final _currentAttachmentPackage =
|
||||
widget.mediaAttachmentPackages[currentPage];
|
||||
final _currentMessage = _currentAttachmentPackage.message;
|
||||
final _currentAttachment = _currentAttachmentPackage.attachment;
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(child: child!),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: _isDisplayingDetail,
|
||||
builder: (context, isDisplayingDetail, child) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final topPadding = mediaQuery.padding.top;
|
||||
return AnimatedPositionedDirectional(
|
||||
duration: kThemeAnimationDuration,
|
||||
curve: Curves.easeInOut,
|
||||
top: isDisplayingDetail
|
||||
? 0
|
||||
: -(topPadding + kToolbarHeight),
|
||||
start: 0,
|
||||
end: 0,
|
||||
height: topPadding + kToolbarHeight,
|
||||
child: StreamGalleryHeader(
|
||||
userName: widget.userName,
|
||||
sentAt: context.translations.sentAtText(
|
||||
date: _currentAttachmentPackage.message.createdAt,
|
||||
time: _currentAttachmentPackage.message.createdAt,
|
||||
),
|
||||
onBackPressed: Navigator.of(context).pop,
|
||||
message: _currentMessage,
|
||||
attachment: _currentAttachment,
|
||||
onShowMessage: () {
|
||||
widget.onShowMessage?.call(
|
||||
_currentMessage,
|
||||
StreamChannel.of(context).channel,
|
||||
);
|
||||
},
|
||||
attachmentActionsModalBuilder:
|
||||
widget.attachmentActionsModalBuilder,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (!_currentMessage.isEphemeral)
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: _isDisplayingDetail,
|
||||
builder: (context, isDisplayingDetail, child) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final bottomPadding = mediaQuery.padding.bottom;
|
||||
return AnimatedPositionedDirectional(
|
||||
duration: kThemeAnimationDuration,
|
||||
curve: Curves.easeInOut,
|
||||
bottom: isDisplayingDetail
|
||||
? 0
|
||||
: -(bottomPadding + kToolbarHeight),
|
||||
start: 0,
|
||||
end: 0,
|
||||
height: bottomPadding + kToolbarHeight,
|
||||
child: StreamGalleryFooter(
|
||||
currentPage: currentPage,
|
||||
totalPages: widget.mediaAttachmentPackages.length,
|
||||
mediaAttachmentPackages:
|
||||
widget.mediaAttachmentPackages,
|
||||
mediaSelectedCallBack: (val) {
|
||||
_currentPage.value = val;
|
||||
_pageController.animateToPage(
|
||||
val,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
child: InkWell(
|
||||
onTap: switchDisplayingDetail,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
itemCount: widget.mediaAttachmentPackages.length,
|
||||
onPageChanged: (val) {
|
||||
_currentPage.value = val;
|
||||
if (videoPackages.isEmpty) return;
|
||||
final currentAttachment =
|
||||
widget.mediaAttachmentPackages[val].attachment;
|
||||
for (final e in videoPackages.values) {
|
||||
if (e._attachment != currentAttachment) {
|
||||
e._chewieController?.pause();
|
||||
}
|
||||
}
|
||||
if (widget.autoplayVideos &&
|
||||
currentAttachment.type == 'video') {
|
||||
final controller = videoPackages[currentAttachment.id]!;
|
||||
controller._chewieController?.play();
|
||||
}
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final currentAttachmentPackage =
|
||||
widget.mediaAttachmentPackages[index];
|
||||
final attachment = currentAttachmentPackage.attachment;
|
||||
if (attachment.type == 'image' || attachment.type == 'giphy') {
|
||||
final imageUrl = attachment.imageUrl ??
|
||||
attachment.assetUrl ??
|
||||
attachment.thumbUrl;
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: _isDisplayingDetail,
|
||||
builder: (context, isDisplayingDetail, _) =>
|
||||
AnimatedContainer(
|
||||
color: isDisplayingDetail
|
||||
? StreamChannelHeaderTheme.of(context).color
|
||||
: Colors.black,
|
||||
duration: kThemeAnimationDuration,
|
||||
child: PhotoView(
|
||||
imageProvider: (imageUrl == null &&
|
||||
attachment.localUri != null &&
|
||||
attachment.file?.bytes != null)
|
||||
? Image.memory(attachment.file!.bytes!).image
|
||||
: CachedNetworkImageProvider(imageUrl!),
|
||||
errorBuilder: (_, __, ___) => const AttachmentError(),
|
||||
loadingBuilder: (context, _) {
|
||||
final image = Image.asset(
|
||||
'images/placeholder.png',
|
||||
fit: BoxFit.cover,
|
||||
package: 'stream_chat_flutter',
|
||||
);
|
||||
final colorTheme =
|
||||
StreamChatTheme.of(context).colorTheme;
|
||||
return Shimmer.fromColors(
|
||||
baseColor: colorTheme.disabled,
|
||||
highlightColor: colorTheme.inputBg,
|
||||
child: image,
|
||||
);
|
||||
},
|
||||
maxScale: PhotoViewComputedScale.covered,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
heroAttributes: PhotoViewHeroAttributes(
|
||||
tag: widget.mediaAttachmentPackages,
|
||||
),
|
||||
backgroundDecoration: const BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (attachment.type == 'video') {
|
||||
final controller = videoPackages[attachment.id]!;
|
||||
if (!controller.initialized) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
return InkWell(
|
||||
onTap: switchDisplayingDetail,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 50),
|
||||
child: Chewie(
|
||||
controller: controller.chewieController!,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_currentPage.dispose();
|
||||
_pageController.dispose();
|
||||
_isDisplayingDetail.dispose();
|
||||
for (final package in videoPackages.values) {
|
||||
package.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Class for packaging up things required for videos
|
||||
class VideoPackage {
|
||||
/// Constructor for creating [VideoPackage]
|
||||
VideoPackage(
|
||||
this._attachment, {
|
||||
bool showControls = false,
|
||||
bool autoInitialize = true,
|
||||
}) : _showControls = showControls,
|
||||
_autoInitialize = autoInitialize,
|
||||
_videoPlayerController = _attachment.localUri != null
|
||||
? VideoPlayerController.file(File.fromUri(_attachment.localUri!))
|
||||
: VideoPlayerController.network(_attachment.assetUrl!);
|
||||
|
||||
final Attachment _attachment;
|
||||
final bool _showControls;
|
||||
final bool _autoInitialize;
|
||||
final VideoPlayerController _videoPlayerController;
|
||||
ChewieController? _chewieController;
|
||||
|
||||
/// Get video player for video
|
||||
VideoPlayerController get videoPlayer => _videoPlayerController;
|
||||
|
||||
/// Get [ChewieController] for video
|
||||
ChewieController? get chewieController => _chewieController;
|
||||
|
||||
/// Check if controller is initialised
|
||||
bool get initialized => _videoPlayerController.value.isInitialized;
|
||||
|
||||
/// Initialize all things required for [VideoPackage]
|
||||
Future<void> initialize() => _videoPlayerController.initialize().then((_) {
|
||||
_chewieController = ChewieController(
|
||||
videoPlayerController: _videoPlayerController,
|
||||
autoInitialize: _autoInitialize,
|
||||
showControls: _showControls,
|
||||
aspectRatio: _videoPlayerController.value.aspectRatio,
|
||||
);
|
||||
});
|
||||
|
||||
/// Add a listener to video player controller
|
||||
void addListener(VoidCallback listener) =>
|
||||
_videoPlayerController.addListener(listener);
|
||||
|
||||
/// Remove a listener to video player controller
|
||||
void removeListener(VoidCallback listener) =>
|
||||
_videoPlayerController.removeListener(listener);
|
||||
|
||||
/// Dispose controllers
|
||||
Future<void> dispose() {
|
||||
_chewieController?.dispose();
|
||||
return _videoPlayerController.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/// Return action for coming back from pages
|
||||
enum ReturnActionType {
|
||||
/// No return action
|
||||
none,
|
||||
|
||||
/// Go to reply message action
|
||||
reply,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:stream_chat_flutter/src/fullscreen_media/full_screen_media_widget.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// Stub function for returning an instance of either [FullScreenMedia] or
|
||||
/// [FullScreenMediaDesktop].
|
||||
///
|
||||
/// This should ONLY be used in [FullScreenMediaBuilder].
|
||||
FullScreenMediaWidget getFsm({
|
||||
Key? key,
|
||||
required List<StreamAttachmentPackage> mediaAttachmentPackages,
|
||||
required int startIndex,
|
||||
required String userName,
|
||||
ShowMessageCallback? onShowMessage,
|
||||
ReplyMessageCallback? onReplyMessage,
|
||||
AttachmentActionsBuilder? attachmentActionsModalBuilder,
|
||||
bool? autoplayVideos,
|
||||
}) =>
|
||||
throw UnsupportedError('Cannot create FullScreenMedia');
|
||||
@@ -0,0 +1,493 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:chewie/chewie.dart';
|
||||
import 'package:contextmenu/contextmenu.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
import 'package:stream_chat_flutter/platform_widget_builder/platform_widget_builder.dart';
|
||||
import 'package:stream_chat_flutter/src/context_menu_items/download_menu_item.dart';
|
||||
import 'package:stream_chat_flutter/src/fullscreen_media/full_screen_media_widget.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/utils.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
/// A full screen image widget
|
||||
class StreamFullScreenMedia extends FullScreenMediaWidget {
|
||||
/// Instantiate a new FullScreenImage
|
||||
const StreamFullScreenMedia({
|
||||
super.key,
|
||||
required this.mediaAttachmentPackages,
|
||||
this.startIndex = 0,
|
||||
this.userName = '',
|
||||
this.onShowMessage,
|
||||
this.onReplyMessage,
|
||||
this.attachmentActionsModalBuilder,
|
||||
this.autoplayVideos = false,
|
||||
}) : assert(startIndex >= 0, 'startIndex cannot be negative');
|
||||
|
||||
/// The url of the image
|
||||
final List<StreamAttachmentPackage> mediaAttachmentPackages;
|
||||
|
||||
/// First index of media shown
|
||||
final int startIndex;
|
||||
|
||||
/// Username of sender
|
||||
final String userName;
|
||||
|
||||
/// Callback for when show message is tapped
|
||||
final ShowMessageCallback? onShowMessage;
|
||||
|
||||
/// Callback for when reply message is tapped
|
||||
final ReplyMessageCallback? onReplyMessage;
|
||||
|
||||
/// Widget builder for attachment actions modal
|
||||
/// [defaultActionsModal] is the default [AttachmentActionsModal] config
|
||||
/// Use [defaultActionsModal.copyWith] to easily customize it
|
||||
final AttachmentActionsBuilder? attachmentActionsModalBuilder;
|
||||
|
||||
/// Auto-play videos when page is opened
|
||||
final bool autoplayVideos;
|
||||
|
||||
@override
|
||||
_FullScreenMediaState createState() => _FullScreenMediaState();
|
||||
}
|
||||
|
||||
class _FullScreenMediaState extends State<StreamFullScreenMedia> {
|
||||
late final PageController _pageController;
|
||||
|
||||
late final _currentPage = ValueNotifier(widget.startIndex);
|
||||
late final _isDisplayingDetail = ValueNotifier<bool>(true);
|
||||
|
||||
void switchDisplayingDetail() {
|
||||
_isDisplayingDetail.value = !_isDisplayingDetail.value;
|
||||
}
|
||||
|
||||
final videoPackages = <String, VideoPackage>{};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pageController = PageController(initialPage: widget.startIndex);
|
||||
for (var i = 0; i < widget.mediaAttachmentPackages.length; i++) {
|
||||
final attachment = widget.mediaAttachmentPackages[i].attachment;
|
||||
if (attachment.type != 'video') continue;
|
||||
final package = VideoPackage(attachment, showControls: true);
|
||||
videoPackages[attachment.id] = package;
|
||||
}
|
||||
initializePlayers();
|
||||
}
|
||||
|
||||
Future<void> initializePlayers() async {
|
||||
if (videoPackages.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final currentAttachment =
|
||||
widget.mediaAttachmentPackages[widget.startIndex].attachment;
|
||||
|
||||
await Future.wait(videoPackages.values.map(
|
||||
(it) => it.initialize(),
|
||||
));
|
||||
|
||||
if (widget.autoplayVideos && currentAttachment.type == 'video') {
|
||||
final package = videoPackages.values
|
||||
.firstWhere((e) => e._attachment == currentAttachment);
|
||||
package._chewieController?.play();
|
||||
}
|
||||
setState(() {}); // ignore: no-empty-block
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_currentPage.dispose();
|
||||
_pageController.dispose();
|
||||
_isDisplayingDetail.dispose();
|
||||
for (final package in videoPackages.values) {
|
||||
package.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: ValueListenableBuilder<int>(
|
||||
valueListenable: _currentPage,
|
||||
builder: (context, currentPage, child) {
|
||||
final _currentAttachmentPackage =
|
||||
widget.mediaAttachmentPackages[currentPage];
|
||||
final _currentMessage = _currentAttachmentPackage.message;
|
||||
final _currentAttachment = _currentAttachmentPackage.attachment;
|
||||
return Stack(
|
||||
children: [
|
||||
child!,
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: _isDisplayingDetail,
|
||||
builder: (context, isDisplayingDetail, child) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final topPadding = mediaQuery.padding.top;
|
||||
return AnimatedPositionedDirectional(
|
||||
duration: kThemeAnimationDuration,
|
||||
curve: Curves.easeInOut,
|
||||
top:
|
||||
isDisplayingDetail ? 0 : -(topPadding + kToolbarHeight),
|
||||
start: 0,
|
||||
end: 0,
|
||||
height: topPadding + kToolbarHeight,
|
||||
child: StreamGalleryHeader(
|
||||
userName: widget.userName,
|
||||
sentAt: context.translations.sentAtText(
|
||||
date: _currentAttachmentPackage.message.createdAt,
|
||||
time: _currentAttachmentPackage.message.createdAt,
|
||||
),
|
||||
onBackPressed: Navigator.of(context).pop,
|
||||
message: _currentMessage,
|
||||
attachment: _currentAttachment,
|
||||
onShowMessage: widget.onShowMessage != null
|
||||
? () {
|
||||
Navigator.pop(context);
|
||||
Navigator.pop(context);
|
||||
widget.onShowMessage?.call(
|
||||
_currentMessage,
|
||||
StreamChannel.of(context).channel,
|
||||
);
|
||||
}
|
||||
: null,
|
||||
onReplyMessage: widget.onReplyMessage != null
|
||||
? () {
|
||||
Navigator.pop(context);
|
||||
Navigator.pop(context);
|
||||
widget.onReplyMessage?.call(
|
||||
_currentMessage,
|
||||
);
|
||||
}
|
||||
: null,
|
||||
attachmentActionsModalBuilder:
|
||||
widget.attachmentActionsModalBuilder,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (!_currentMessage.isEphemeral)
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: _isDisplayingDetail,
|
||||
builder: (context, isDisplayingDetail, child) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final bottomPadding = mediaQuery.padding.bottom;
|
||||
return AnimatedPositionedDirectional(
|
||||
duration: kThemeAnimationDuration,
|
||||
curve: Curves.easeInOut,
|
||||
bottom: isDisplayingDetail
|
||||
? 0
|
||||
: -(bottomPadding + kToolbarHeight),
|
||||
start: 0,
|
||||
end: 0,
|
||||
height: bottomPadding + kToolbarHeight,
|
||||
child: StreamGalleryFooter(
|
||||
currentPage: currentPage,
|
||||
totalPages: widget.mediaAttachmentPackages.length,
|
||||
mediaAttachmentPackages: widget.mediaAttachmentPackages,
|
||||
mediaSelectedCallBack: (val) {
|
||||
_currentPage.value = val;
|
||||
_pageController.animateToPage(
|
||||
val,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (widget.mediaAttachmentPackages.length > 1) ...[
|
||||
if (currentPage > 0)
|
||||
GalleryNavigationItem(
|
||||
left: 8,
|
||||
opacityAnimation: _isDisplayingDetail,
|
||||
icon: const Icon(Icons.chevron_left_rounded),
|
||||
onPressed: () {
|
||||
_currentPage.value--;
|
||||
_pageController.previousPage(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
},
|
||||
),
|
||||
if (currentPage < widget.mediaAttachmentPackages.length - 1)
|
||||
GalleryNavigationItem(
|
||||
right: 8,
|
||||
opacityAnimation: _isDisplayingDetail,
|
||||
icon: const Icon(Icons.chevron_right_rounded),
|
||||
onPressed: () {
|
||||
_currentPage.value++;
|
||||
_pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
child: InkWell(
|
||||
onTap: switchDisplayingDetail,
|
||||
child: KeyboardShortcutRunner(
|
||||
onEscapeKeypress: Navigator.of(context).pop,
|
||||
onLeftArrowKeypress: () {
|
||||
if (_currentPage.value > 0) {
|
||||
_currentPage.value--;
|
||||
_pageController.previousPage(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
},
|
||||
onRightArrowKeypress: () {
|
||||
if (_currentPage.value <
|
||||
widget.mediaAttachmentPackages.length - 1) {
|
||||
_currentPage.value++;
|
||||
_pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
itemCount: widget.mediaAttachmentPackages.length,
|
||||
onPageChanged: (val) {
|
||||
_currentPage.value = val;
|
||||
if (videoPackages.isEmpty) return;
|
||||
final currentAttachment =
|
||||
widget.mediaAttachmentPackages[val].attachment;
|
||||
for (final e in videoPackages.values) {
|
||||
if (e._attachment != currentAttachment) {
|
||||
e._chewieController?.pause();
|
||||
}
|
||||
}
|
||||
if (widget.autoplayVideos &&
|
||||
currentAttachment.type == 'video') {
|
||||
final controller = videoPackages[currentAttachment.id]!;
|
||||
controller._chewieController?.play();
|
||||
}
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final currentAttachmentPackage =
|
||||
widget.mediaAttachmentPackages[index];
|
||||
final attachment = currentAttachmentPackage.attachment;
|
||||
if (attachment.type == 'image' || attachment.type == 'giphy') {
|
||||
final imageUrl = attachment.imageUrl ??
|
||||
attachment.assetUrl ??
|
||||
attachment.thumbUrl;
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: _isDisplayingDetail,
|
||||
builder: (context, isDisplayingDetail, _) =>
|
||||
AnimatedContainer(
|
||||
color: isDisplayingDetail
|
||||
? StreamChannelHeaderTheme.of(context).color
|
||||
: Colors.black,
|
||||
duration: kThemeAnimationDuration,
|
||||
child: ContextMenuArea(
|
||||
verticalPadding: 0,
|
||||
builder: (_) => [
|
||||
DownloadMenuItem(
|
||||
attachment: attachment,
|
||||
),
|
||||
],
|
||||
child: PhotoView(
|
||||
imageProvider: (imageUrl == null &&
|
||||
attachment.localUri != null &&
|
||||
attachment.file?.bytes != null)
|
||||
? Image.memory(attachment.file!.bytes!).image
|
||||
: CachedNetworkImageProvider(imageUrl!),
|
||||
errorBuilder: (_, __, ___) => const AttachmentError(),
|
||||
loadingBuilder: (context, _) {
|
||||
final image = Image.asset(
|
||||
'images/placeholder.png',
|
||||
fit: BoxFit.cover,
|
||||
package: 'stream_chat_flutter',
|
||||
);
|
||||
final colorTheme =
|
||||
StreamChatTheme.of(context).colorTheme;
|
||||
return Shimmer.fromColors(
|
||||
baseColor: colorTheme.disabled,
|
||||
highlightColor: colorTheme.inputBg,
|
||||
child: image,
|
||||
);
|
||||
},
|
||||
maxScale: PhotoViewComputedScale.covered,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
heroAttributes: PhotoViewHeroAttributes(
|
||||
tag: widget.mediaAttachmentPackages,
|
||||
),
|
||||
backgroundDecoration: const BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (attachment.type == 'video') {
|
||||
final controller = videoPackages[attachment.id]!;
|
||||
if (!controller.initialized) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
return InkWell(
|
||||
onTap: switchDisplayingDetail,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 50),
|
||||
child: ContextMenuArea(
|
||||
verticalPadding: 0,
|
||||
builder: (_) => [
|
||||
DownloadMenuItem(
|
||||
attachment: attachment,
|
||||
),
|
||||
],
|
||||
child: Chewie(
|
||||
controller: controller.chewieController!,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A widget for desktop and web users to be able to navigate left and right
|
||||
/// through a gallery of images.
|
||||
class GalleryNavigationItem extends StatelessWidget {
|
||||
/// Builds a [GalleryNavigationItem].
|
||||
const GalleryNavigationItem({
|
||||
super.key,
|
||||
required this.icon,
|
||||
this.iconSize = 48,
|
||||
required this.onPressed,
|
||||
required this.opacityAnimation,
|
||||
this.left,
|
||||
this.right,
|
||||
});
|
||||
|
||||
/// The icon to display.
|
||||
final Widget icon;
|
||||
|
||||
/// The size of the icon.
|
||||
///
|
||||
/// Defaults to 48.
|
||||
final double iconSize;
|
||||
|
||||
/// The callback to perform when the button is clicked.
|
||||
final VoidCallback onPressed;
|
||||
|
||||
/// The animation for showing & hiding this widget.
|
||||
final ValueListenable<bool> opacityAnimation;
|
||||
|
||||
/// The left-hand placement of the button.
|
||||
final double? left;
|
||||
|
||||
/// The right-hand placement of the button.
|
||||
final double? right;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PlatformWidgetBuilder(
|
||||
desktop: (_, child) => child,
|
||||
web: (_, child) => child,
|
||||
child: Positioned(
|
||||
left: left,
|
||||
right: right,
|
||||
top: MediaQuery.of(context).size.height / 2,
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: opacityAnimation,
|
||||
builder: (context, shouldShow, child) {
|
||||
return AnimatedOpacity(
|
||||
opacity: shouldShow ? 1 : 0,
|
||||
duration: kThemeAnimationDuration,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
type: MaterialType.circle,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: IconButton(
|
||||
icon: icon,
|
||||
iconSize: iconSize,
|
||||
onPressed: onPressed,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Class for packaging up things required for videos
|
||||
class VideoPackage {
|
||||
/// Constructor for creating [VideoPackage]
|
||||
VideoPackage(
|
||||
this._attachment, {
|
||||
bool showControls = false,
|
||||
bool autoInitialize = true,
|
||||
}) : _showControls = showControls,
|
||||
_autoInitialize = autoInitialize,
|
||||
_videoPlayerController = _attachment.localUri != null
|
||||
? VideoPlayerController.file(File.fromUri(_attachment.localUri!))
|
||||
: VideoPlayerController.network(_attachment.assetUrl!);
|
||||
|
||||
final Attachment _attachment;
|
||||
final bool _showControls;
|
||||
final bool _autoInitialize;
|
||||
final VideoPlayerController _videoPlayerController;
|
||||
ChewieController? _chewieController;
|
||||
|
||||
/// Get video player for video
|
||||
VideoPlayerController get videoPlayer => _videoPlayerController;
|
||||
|
||||
/// Get [ChewieController] for video
|
||||
ChewieController? get chewieController => _chewieController;
|
||||
|
||||
/// Check if controller is initialised
|
||||
bool get initialized => _videoPlayerController.value.isInitialized;
|
||||
|
||||
/// Initialize all things required for [VideoPackage]
|
||||
Future<void> initialize() {
|
||||
return _videoPlayerController.initialize().then((_) {
|
||||
_chewieController = ChewieController(
|
||||
videoPlayerController: _videoPlayerController,
|
||||
autoInitialize: _autoInitialize,
|
||||
showControls: _showControls,
|
||||
aspectRatio: _videoPlayerController.value.aspectRatio,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Add a listener to video player controller
|
||||
void addListener(VoidCallback listener) =>
|
||||
_videoPlayerController.addListener(listener);
|
||||
|
||||
/// Remove a listener to video player controller
|
||||
void removeListener(VoidCallback listener) =>
|
||||
_videoPlayerController.removeListener(listener);
|
||||
|
||||
/// Dispose controllers
|
||||
Future<void> dispose() {
|
||||
_chewieController?.dispose();
|
||||
return _videoPlayerController.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/fullscreen_media/fsm_stub.dart'
|
||||
if (dart.library.io) 'full_screen_media_desktop.dart' as desktop_fsm;
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@template fsmBuilder}
|
||||
/// A wrapper widget for conditionally providing the proper
|
||||
/// StreamFullScreenMedia widget when writing an application that targets
|
||||
/// all available Flutter platforms (Android, iOS, macOS, Windows, Linux,
|
||||
/// & Web).
|
||||
///
|
||||
/// This is required because:
|
||||
/// * `package:video_player` and `package:chewie` do not support macOS, Windows,
|
||||
/// & Linux, but _do_ support Android, iOS, & Web.
|
||||
/// * `package:dart_vlc` _does_ support macOS, Windows, & Linux via FFI. This
|
||||
/// has the unfortunate consequence of not supporting Web.
|
||||
///
|
||||
/// This widget makes use of dart's conditional imports to ensure that Stream's
|
||||
/// desktop implementation of StreamFullScreenMedia is not imported when
|
||||
/// building applications that target web. Additionally, this widget ensures
|
||||
/// that applications targeting mobile platforms do not build the version of
|
||||
/// StreamFullScreenMedia that targets desktop platforms (even though
|
||||
/// `package:dart_vlc` technically supports iOS).
|
||||
/// {@endtemplate}
|
||||
class StreamFullScreenMediaBuilder extends StatelessWidget {
|
||||
/// {@macro fsmBuilder}
|
||||
const StreamFullScreenMediaBuilder({
|
||||
super.key,
|
||||
required this.mediaAttachmentPackages,
|
||||
required this.startIndex,
|
||||
required this.userName,
|
||||
this.onShowMessage,
|
||||
this.onReplyMessage,
|
||||
this.attachmentActionsModalBuilder,
|
||||
this.autoplayVideos = false,
|
||||
});
|
||||
|
||||
/// The url of the image
|
||||
final List<StreamAttachmentPackage> mediaAttachmentPackages;
|
||||
|
||||
/// First index of media shown
|
||||
final int startIndex;
|
||||
|
||||
/// Username of sender
|
||||
final String userName;
|
||||
|
||||
/// Callback for when show message is tapped
|
||||
final ShowMessageCallback? onShowMessage;
|
||||
|
||||
/// Callback for when reply message is tapped
|
||||
final ReplyMessageCallback? onReplyMessage;
|
||||
|
||||
/// Widget builder for attachment actions modal
|
||||
/// [defaultActionsModal] is the default [AttachmentActionsModal] config
|
||||
/// Use [defaultActionsModal.copyWith] to easily customize it
|
||||
final AttachmentActionsBuilder? attachmentActionsModalBuilder;
|
||||
|
||||
/// Auto-play videos when page is opened
|
||||
final bool autoplayVideos;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!kIsWeb && isDesktopVideoPlayerSupported) {
|
||||
return desktop_fsm.getFsm(
|
||||
mediaAttachmentPackages: mediaAttachmentPackages,
|
||||
startIndex: startIndex,
|
||||
userName: userName,
|
||||
autoplayVideos: autoplayVideos,
|
||||
onShowMessage: onShowMessage,
|
||||
onReplyMessage: onReplyMessage,
|
||||
attachmentActionsModalBuilder: attachmentActionsModalBuilder,
|
||||
);
|
||||
}
|
||||
|
||||
return StreamFullScreenMedia(
|
||||
mediaAttachmentPackages: mediaAttachmentPackages,
|
||||
startIndex: startIndex,
|
||||
userName: userName,
|
||||
onShowMessage: onShowMessage,
|
||||
onReplyMessage: onReplyMessage,
|
||||
attachmentActionsModalBuilder: attachmentActionsModalBuilder,
|
||||
autoplayVideos: autoplayVideos,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:contextmenu/contextmenu.dart';
|
||||
import 'package:dart_vlc/dart_vlc.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
import 'package:stream_chat_flutter/platform_widget_builder/platform_widget_builder.dart';
|
||||
import 'package:stream_chat_flutter/src/context_menu_items/download_menu_item.dart';
|
||||
import 'package:stream_chat_flutter/src/fullscreen_media/full_screen_media_widget.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/utils.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// Returns an instance of [FullScreenMediaDesktop].
|
||||
///
|
||||
/// This should ONLY be used in [FullScreenMediaBuilder].
|
||||
FullScreenMediaWidget getFsm({
|
||||
Key? key,
|
||||
required List<StreamAttachmentPackage> mediaAttachmentPackages,
|
||||
required int startIndex,
|
||||
required String userName,
|
||||
ShowMessageCallback? onShowMessage,
|
||||
ReplyMessageCallback? onReplyMessage,
|
||||
AttachmentActionsBuilder? attachmentActionsModalBuilder,
|
||||
bool? autoplayVideos,
|
||||
}) {
|
||||
return FullScreenMediaDesktop(
|
||||
key: key,
|
||||
mediaAttachmentPackages: mediaAttachmentPackages,
|
||||
startIndex: startIndex,
|
||||
userName: userName,
|
||||
onReplyMessage: onReplyMessage,
|
||||
onShowMessage: onShowMessage,
|
||||
attachmentActionsModalBuilder: attachmentActionsModalBuilder,
|
||||
autoplayVideos: autoplayVideos ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
/// A full screen image widget
|
||||
class FullScreenMediaDesktop extends FullScreenMediaWidget {
|
||||
/// Instantiate a new FullScreenImage
|
||||
const FullScreenMediaDesktop({
|
||||
super.key,
|
||||
required this.mediaAttachmentPackages,
|
||||
this.startIndex = 0,
|
||||
String? userName,
|
||||
this.onShowMessage,
|
||||
this.onReplyMessage,
|
||||
this.attachmentActionsModalBuilder,
|
||||
this.autoplayVideos = false,
|
||||
}) : userName = userName ?? '';
|
||||
|
||||
/// The url of the image
|
||||
final List<StreamAttachmentPackage> mediaAttachmentPackages;
|
||||
|
||||
/// First index of media shown
|
||||
final int startIndex;
|
||||
|
||||
/// Username of sender
|
||||
final String userName;
|
||||
|
||||
/// Callback for when show message is tapped
|
||||
final ShowMessageCallback? onShowMessage;
|
||||
|
||||
/// Callback for when reply message is tapped
|
||||
final ReplyMessageCallback? onReplyMessage;
|
||||
|
||||
/// Widget builder for attachment actions modal
|
||||
/// [defaultActionsModal] is the default [AttachmentActionsModal] config
|
||||
/// Use [defaultActionsModal.copyWith] to easily customize it
|
||||
final AttachmentActionsBuilder? attachmentActionsModalBuilder;
|
||||
|
||||
/// Auto-play videos when page is opened
|
||||
final bool autoplayVideos;
|
||||
|
||||
@override
|
||||
_FullScreenMediaDesktopState createState() => _FullScreenMediaDesktopState();
|
||||
}
|
||||
|
||||
class _FullScreenMediaDesktopState extends State<FullScreenMediaDesktop> {
|
||||
late final PageController _pageController;
|
||||
|
||||
late final _currentPage = ValueNotifier(widget.startIndex);
|
||||
late final _isDisplayingDetail = ValueNotifier<bool>(true);
|
||||
|
||||
void switchDisplayingDetail() {
|
||||
_isDisplayingDetail.value = !_isDisplayingDetail.value;
|
||||
}
|
||||
|
||||
final videoPackages = <String, DesktopVideoPackage>{};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pageController = PageController(initialPage: widget.startIndex);
|
||||
for (var i = 0; i < widget.mediaAttachmentPackages.length; i++) {
|
||||
final attachment = widget.mediaAttachmentPackages[i].attachment;
|
||||
if (attachment.type != 'video') continue;
|
||||
final package = DesktopVideoPackage(attachment);
|
||||
videoPackages[attachment.id] = package;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_currentPage.dispose();
|
||||
_pageController.dispose();
|
||||
_isDisplayingDetail.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final containsOnlyVideos =
|
||||
widget.mediaAttachmentPackages.length == videoPackages.length;
|
||||
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: containsOnlyVideos ? _buildVideoPageView() : _buildPageView(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVideoPageView() {
|
||||
return Stack(
|
||||
children: [
|
||||
ContextMenuArea(
|
||||
verticalPadding: 0,
|
||||
builder: (_) => [
|
||||
DownloadMenuItem(
|
||||
attachment:
|
||||
widget.mediaAttachmentPackages[_currentPage.value].attachment,
|
||||
),
|
||||
],
|
||||
child: _PlaylistPlayer(
|
||||
packages: videoPackages.values.toList(),
|
||||
autoStart: widget.autoplayVideos,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 8,
|
||||
top: 8,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
videoPackages.values.first.player.stop();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: StreamSvgIcon.close(
|
||||
size: 30,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPageView() {
|
||||
return ValueListenableBuilder<int>(
|
||||
valueListenable: _currentPage,
|
||||
builder: (context, currentPage, child) {
|
||||
final _currentAttachmentPackage =
|
||||
widget.mediaAttachmentPackages[currentPage];
|
||||
final _currentMessage = _currentAttachmentPackage.message;
|
||||
final _currentAttachment = _currentAttachmentPackage.attachment;
|
||||
return Stack(
|
||||
children: [
|
||||
child!,
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: _isDisplayingDetail,
|
||||
builder: (context, isDisplayingDetail, child) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final topPadding = mediaQuery.padding.top;
|
||||
return AnimatedPositionedDirectional(
|
||||
duration: kThemeAnimationDuration,
|
||||
curve: Curves.easeInOut,
|
||||
top: isDisplayingDetail ? 0 : -(topPadding + kToolbarHeight),
|
||||
start: 0,
|
||||
end: 0,
|
||||
height: topPadding + kToolbarHeight,
|
||||
child: StreamGalleryHeader(
|
||||
userName: widget.userName,
|
||||
sentAt: context.translations.sentAtText(
|
||||
date: _currentAttachmentPackage.message.createdAt,
|
||||
time: _currentAttachmentPackage.message.createdAt,
|
||||
),
|
||||
onBackPressed: Navigator.of(context).pop,
|
||||
message: _currentMessage,
|
||||
attachment: _currentAttachment,
|
||||
onShowMessage: () {
|
||||
widget.onShowMessage?.call(
|
||||
_currentMessage,
|
||||
StreamChannel.of(context).channel,
|
||||
);
|
||||
},
|
||||
attachmentActionsModalBuilder:
|
||||
widget.attachmentActionsModalBuilder,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (!_currentMessage.isEphemeral)
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: _isDisplayingDetail,
|
||||
builder: (context, isDisplayingDetail, child) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final bottomPadding = mediaQuery.padding.bottom;
|
||||
return AnimatedPositionedDirectional(
|
||||
duration: kThemeAnimationDuration,
|
||||
curve: Curves.easeInOut,
|
||||
bottom: isDisplayingDetail
|
||||
? 0
|
||||
: -(bottomPadding + kToolbarHeight),
|
||||
start: 0,
|
||||
end: 0,
|
||||
height: bottomPadding + kToolbarHeight,
|
||||
child: StreamGalleryFooter(
|
||||
currentPage: currentPage,
|
||||
totalPages: widget.mediaAttachmentPackages.length,
|
||||
mediaAttachmentPackages: widget.mediaAttachmentPackages,
|
||||
mediaSelectedCallBack: (val) {
|
||||
_currentPage.value = val;
|
||||
_pageController.animateToPage(
|
||||
val,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (widget.mediaAttachmentPackages.length > 1) ...[
|
||||
if (currentPage > 0)
|
||||
GalleryNavigationItem(
|
||||
left: 8,
|
||||
opacityAnimation: _isDisplayingDetail,
|
||||
icon: const Icon(Icons.chevron_left_rounded),
|
||||
onPressed: () {
|
||||
_currentPage.value--;
|
||||
_pageController.previousPage(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
},
|
||||
),
|
||||
if (currentPage < widget.mediaAttachmentPackages.length - 1)
|
||||
GalleryNavigationItem(
|
||||
right: 8,
|
||||
opacityAnimation: _isDisplayingDetail,
|
||||
icon: const Icon(Icons.chevron_right_rounded),
|
||||
onPressed: () {
|
||||
_currentPage.value++;
|
||||
_pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
child: InkWell(
|
||||
onTap: switchDisplayingDetail,
|
||||
child: KeyboardShortcutRunner(
|
||||
onEscapeKeypress: Navigator.of(context).pop,
|
||||
onLeftArrowKeypress: () {
|
||||
if (_currentPage.value > 0) {
|
||||
_currentPage.value--;
|
||||
_pageController.previousPage(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
},
|
||||
onRightArrowKeypress: () {
|
||||
if (_currentPage.value <
|
||||
widget.mediaAttachmentPackages.length - 1) {
|
||||
_currentPage.value++;
|
||||
_pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
itemCount: widget.mediaAttachmentPackages.length,
|
||||
onPageChanged: (val) {
|
||||
_currentPage.value = val;
|
||||
if (videoPackages.isEmpty) return;
|
||||
final currentAttachment =
|
||||
widget.mediaAttachmentPackages[val].attachment;
|
||||
for (final p in videoPackages.values) {
|
||||
if (p.attachment != currentAttachment) {
|
||||
p.player.pause();
|
||||
}
|
||||
}
|
||||
if (widget.autoplayVideos && currentAttachment.type == 'video') {
|
||||
final package = videoPackages[currentAttachment.id]!;
|
||||
package.player.play();
|
||||
}
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final currentAttachmentPackage =
|
||||
widget.mediaAttachmentPackages[index];
|
||||
final attachment = currentAttachmentPackage.attachment;
|
||||
if (attachment.type == 'image' || attachment.type == 'giphy') {
|
||||
final imageUrl = attachment.imageUrl ??
|
||||
attachment.assetUrl ??
|
||||
attachment.thumbUrl;
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: _isDisplayingDetail,
|
||||
builder: (context, isDisplayingDetail, _) =>
|
||||
AnimatedContainer(
|
||||
color: isDisplayingDetail
|
||||
? StreamChannelHeaderTheme.of(context).color
|
||||
: Colors.black,
|
||||
duration: kThemeAnimationDuration,
|
||||
child: ContextMenuArea(
|
||||
verticalPadding: 0,
|
||||
builder: (_) => [
|
||||
DownloadMenuItem(
|
||||
attachment: attachment,
|
||||
),
|
||||
],
|
||||
child: PhotoView(
|
||||
imageProvider: (imageUrl == null &&
|
||||
attachment.localUri != null &&
|
||||
attachment.file?.bytes != null)
|
||||
? Image.memory(attachment.file!.bytes!).image
|
||||
: CachedNetworkImageProvider(imageUrl!),
|
||||
errorBuilder: (_, __, ___) => const AttachmentError(),
|
||||
loadingBuilder: (context, _) {
|
||||
final image = Image.asset(
|
||||
'images/placeholder.png',
|
||||
fit: BoxFit.cover,
|
||||
package: 'stream_chat_flutter',
|
||||
);
|
||||
final colorTheme =
|
||||
StreamChatTheme.of(context).colorTheme;
|
||||
return Shimmer.fromColors(
|
||||
baseColor: colorTheme.disabled,
|
||||
highlightColor: colorTheme.inputBg,
|
||||
child: image,
|
||||
);
|
||||
},
|
||||
maxScale: PhotoViewComputedScale.covered,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
heroAttributes: PhotoViewHeroAttributes(
|
||||
tag: widget.mediaAttachmentPackages,
|
||||
),
|
||||
backgroundDecoration: const BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (attachment.type == 'video') {
|
||||
final package = videoPackages[attachment.id]!;
|
||||
package.player.open(
|
||||
Playlist(
|
||||
medias: [
|
||||
Media.network(package.attachment.assetUrl),
|
||||
],
|
||||
),
|
||||
autoStart: widget.autoplayVideos,
|
||||
);
|
||||
|
||||
return InkWell(
|
||||
onTap: switchDisplayingDetail,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 50),
|
||||
child: ContextMenuArea(
|
||||
verticalPadding: 0,
|
||||
builder: (_) => [
|
||||
DownloadMenuItem(
|
||||
attachment: attachment,
|
||||
),
|
||||
],
|
||||
child: Video(
|
||||
player: package.player,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A widget for desktop and web users to be able to navigate left and right
|
||||
/// through a gallery of images.
|
||||
class GalleryNavigationItem extends StatelessWidget {
|
||||
/// Builds a [GalleryNavigationItem].
|
||||
const GalleryNavigationItem({
|
||||
super.key,
|
||||
required this.icon,
|
||||
this.iconSize = 48,
|
||||
required this.onPressed,
|
||||
required this.opacityAnimation,
|
||||
this.left,
|
||||
this.right,
|
||||
});
|
||||
|
||||
/// The icon to display.
|
||||
final Widget icon;
|
||||
|
||||
/// The size of the icon.
|
||||
///
|
||||
/// Defaults to 48.
|
||||
final double iconSize;
|
||||
|
||||
/// The callback to perform when the button is clicked.
|
||||
final VoidCallback onPressed;
|
||||
|
||||
/// The animation for showing & hiding this widget.
|
||||
final ValueListenable<bool> opacityAnimation;
|
||||
|
||||
/// The left-hand placement of the button.
|
||||
final double? left;
|
||||
|
||||
/// The right-hand placement of the button.
|
||||
final double? right;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PlatformWidgetBuilder(
|
||||
desktop: (_, child) => child,
|
||||
web: (_, child) => child,
|
||||
child: Positioned(
|
||||
left: left,
|
||||
right: right,
|
||||
top: MediaQuery.of(context).size.height / 2,
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: opacityAnimation,
|
||||
builder: (context, shouldShow, child) {
|
||||
return AnimatedOpacity(
|
||||
opacity: shouldShow ? 1 : 0,
|
||||
duration: kThemeAnimationDuration,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
type: MaterialType.circle,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: IconButton(
|
||||
icon: icon,
|
||||
iconSize: iconSize,
|
||||
onPressed: onPressed,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Class for packaging up things required for videos
|
||||
class DesktopVideoPackage {
|
||||
/// Constructor for creating [VideoPackage]
|
||||
DesktopVideoPackage(
|
||||
this.attachment, {
|
||||
this.showControls = true,
|
||||
}) : player = Player(
|
||||
id: int.parse(
|
||||
attachment.id.characters
|
||||
.getRange(0, 10)
|
||||
.toString()
|
||||
.replaceAll(RegExp('[^0-9]'), ''),
|
||||
),
|
||||
);
|
||||
|
||||
/// The video attachment to play.
|
||||
final Attachment attachment;
|
||||
|
||||
/// The VLC player to use.
|
||||
final Player player;
|
||||
|
||||
/// Whether to show the player controls or not.
|
||||
final bool showControls;
|
||||
}
|
||||
|
||||
class _PlaylistPlayer extends StatelessWidget {
|
||||
const _PlaylistPlayer({
|
||||
required this.packages,
|
||||
required this.autoStart,
|
||||
});
|
||||
|
||||
final List<DesktopVideoPackage> packages;
|
||||
final bool autoStart;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final _media = <Media>[];
|
||||
for (final package in packages) {
|
||||
_media.add(Media.network(package.attachment.assetUrl));
|
||||
}
|
||||
packages.first.player.open(
|
||||
Playlist(
|
||||
medias: _media,
|
||||
),
|
||||
autoStart: autoStart,
|
||||
);
|
||||
return Video(
|
||||
player: packages.first.player,
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// {@template fsmWidget}
|
||||
/// An ultra-simple abstract class that allows [FullScreenMediaBuilder]
|
||||
/// to call `getFsm()` and build the correct version of FullScreenMedia.
|
||||
/// {@endtemplate}
|
||||
abstract class FullScreenMediaWidget extends StatefulWidget {
|
||||
/// {@macro fsmWidget}
|
||||
const FullScreenMediaWidget({super.key});
|
||||
}
|
||||
+100
-103
@@ -5,20 +5,16 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:stream_chat_flutter/src/extension.dart';
|
||||
import 'package:stream_chat_flutter/src/video_thumbnail_image.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/extensions.dart';
|
||||
import 'package:stream_chat_flutter/src/video/video_thumbnail_image.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@macro gallery_footer}
|
||||
@Deprecated("Use 'StreamGalleryFooter' instead")
|
||||
typedef GalleryFooter = StreamGalleryFooter;
|
||||
|
||||
/// {@template gallery_footer}
|
||||
/// {@template streamGalleryFooter}
|
||||
/// Footer widget for media display
|
||||
/// {@endtemplate}
|
||||
class StreamGalleryFooter extends StatefulWidget
|
||||
implements PreferredSizeWidget {
|
||||
/// Creates a StreamGalleryFooter
|
||||
/// {@macro streamGalleryFooter}
|
||||
const StreamGalleryFooter({
|
||||
super.key,
|
||||
this.onBackPressed,
|
||||
@@ -64,6 +60,7 @@ class StreamGalleryFooter extends StatefulWidget
|
||||
}
|
||||
|
||||
class _StreamGalleryFooterState extends State<StreamGalleryFooter> {
|
||||
final shareButtonKey = GlobalKey();
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const showShareButton = !kIsWeb;
|
||||
@@ -84,11 +81,10 @@ class _StreamGalleryFooterState extends State<StreamGalleryFooter> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (!showShareButton)
|
||||
Container(
|
||||
width: 48,
|
||||
)
|
||||
const SizedBox(width: 48)
|
||||
else
|
||||
IconButton(
|
||||
key: shareButtonKey,
|
||||
icon: StreamSvgIcon.iconShare(
|
||||
size: 24,
|
||||
color: galleryFooterThemeData.shareIconColor,
|
||||
@@ -110,8 +106,14 @@ class _StreamGalleryFooterState extends State<StreamGalleryFooter> {
|
||||
final filePath = '${tmpPath.path}/${attachment.id}.$type';
|
||||
final file = File(filePath);
|
||||
await file.writeAsBytes(bytes);
|
||||
final box =
|
||||
shareButtonKey.currentContext?.findRenderObject();
|
||||
final position =
|
||||
(box! as RenderBox).localToGlobal(Offset.zero);
|
||||
await Share.shareFiles(
|
||||
[filePath],
|
||||
sharePositionOrigin:
|
||||
Rect.fromLTWH(position.dx, position.dy, 0, 0),
|
||||
mimeTypes: [
|
||||
'image/$type',
|
||||
],
|
||||
@@ -164,66 +166,58 @@ class _StreamGalleryFooterState extends State<StreamGalleryFooter> {
|
||||
),
|
||||
),
|
||||
builder: (context) {
|
||||
const crossAxisCount = 3;
|
||||
final noOfRowToShowInitially =
|
||||
widget.mediaAttachmentPackages.length > crossAxisCount ? 2 : 1;
|
||||
final size = MediaQuery.of(context).size;
|
||||
final initialChildSize =
|
||||
48 + (size.width * noOfRowToShowInitially) / crossAxisCount;
|
||||
return DraggableScrollableSheet(
|
||||
expand: false,
|
||||
initialChildSize: initialChildSize / size.height,
|
||||
minChildSize: initialChildSize / size.height,
|
||||
builder: (context, scrollController) => SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
context.translations.photosLabel,
|
||||
style:
|
||||
galleryFooterThemeData.bottomSheetPhotosTextStyle,
|
||||
),
|
||||
initialChildSize:
|
||||
(CurrentPlatform.isAndroid || CurrentPlatform.isIos) ? 0.3 : 0.5,
|
||||
minChildSize: 0.3,
|
||||
maxChildSize: 0.7,
|
||||
builder: (context, scrollController) => Column(
|
||||
children: [
|
||||
Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
context.translations.photosLabel,
|
||||
style:
|
||||
galleryFooterThemeData.bottomSheetPhotosTextStyle,
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: IconButton(
|
||||
icon: StreamSvgIcon.close(
|
||||
color:
|
||||
galleryFooterThemeData.bottomSheetCloseIconColor,
|
||||
),
|
||||
onPressed: () => Navigator.maybePop(context),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: IconButton(
|
||||
icon: StreamSvgIcon.close(
|
||||
color: galleryFooterThemeData.bottomSheetCloseIconColor,
|
||||
),
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
Flexible(
|
||||
child: GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: widget.mediaAttachmentPackages.length,
|
||||
padding: const EdgeInsets.all(1),
|
||||
// ignore: lines_longer_than_80_chars
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: crossAxisCount,
|
||||
mainAxisSpacing: 2,
|
||||
crossAxisSpacing: 2,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
Widget media;
|
||||
final attachmentPackage =
|
||||
widget.mediaAttachmentPackages[index];
|
||||
final attachment = attachmentPackage.attachment;
|
||||
final message = attachmentPackage.message;
|
||||
if (attachment.type == 'video') {
|
||||
media = InkWell(
|
||||
),
|
||||
],
|
||||
),
|
||||
Flexible(
|
||||
child: GridView.builder(
|
||||
shrinkWrap: true,
|
||||
controller: scrollController,
|
||||
itemCount: widget.mediaAttachmentPackages.length,
|
||||
padding: const EdgeInsets.all(1),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
mainAxisSpacing: 2,
|
||||
crossAxisSpacing: 2,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
Widget media;
|
||||
final attachmentPackage =
|
||||
widget.mediaAttachmentPackages[index];
|
||||
final attachment = attachmentPackage.attachment;
|
||||
final message = attachmentPackage.message;
|
||||
if (attachment.type == 'video') {
|
||||
media = MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: () => widget.mediaSelectedCallBack!(index),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
@@ -233,9 +227,12 @@ class _StreamGalleryFooterState extends State<StreamGalleryFooter> {
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
media = InkWell(
|
||||
),
|
||||
);
|
||||
} else {
|
||||
media = MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: () => widget.mediaSelectedCallBack!(index),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
@@ -246,45 +243,45 @@ class _StreamGalleryFooterState extends State<StreamGalleryFooter> {
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
media,
|
||||
if (message.user != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
blurRadius: 8,
|
||||
color: chatThemeData
|
||||
.colorTheme.textHighEmphasis
|
||||
.withOpacity(0.3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: StreamUserAvatar(
|
||||
user: message.user!,
|
||||
constraints:
|
||||
BoxConstraints.tight(const Size(24, 24)),
|
||||
showOnlineStatus: false,
|
||||
),
|
||||
return Stack(
|
||||
children: [
|
||||
media,
|
||||
if (message.user != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
blurRadius: 8,
|
||||
color: chatThemeData
|
||||
.colorTheme.textHighEmphasis
|
||||
.withOpacity(0.3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: StreamUserAvatar(
|
||||
user: message.user!,
|
||||
constraints:
|
||||
BoxConstraints.tight(const Size(24, 24)),
|
||||
showOnlineStatus: false,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
+26
-28
@@ -1,30 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:stream_chat_flutter/src/attachment_actions_modal.dart';
|
||||
import 'package:stream_chat_flutter/src/stream_chat_theme.dart';
|
||||
import 'package:stream_chat_flutter/src/stream_svg_icon.dart';
|
||||
import 'package:stream_chat_flutter/src/attachment_actions_modal/attachment_actions_modal.dart';
|
||||
import 'package:stream_chat_flutter/src/misc/stream_svg_icon.dart';
|
||||
import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart';
|
||||
import 'package:stream_chat_flutter/src/theme/themes.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/utils.dart';
|
||||
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
|
||||
|
||||
/// Widget builder for attachment actions modal
|
||||
/// [defaultActionsModal] is the default [AttachmentActionsModal] config
|
||||
/// Use [defaultActionsModal.copyWith] to easily customize it
|
||||
typedef AttachmentActionsBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
Attachment attachment,
|
||||
AttachmentActionsModal defaultActionsModal,
|
||||
);
|
||||
|
||||
/// {@macro gallery_header}
|
||||
@Deprecated("Use 'StreamGalleryHeader' instead")
|
||||
typedef GalleryHeader = StreamGalleryHeader;
|
||||
|
||||
/// {@template gallery_header}
|
||||
/// {@macro streamGalleryHeader}
|
||||
/// Header/AppBar widget for media display screen
|
||||
/// {@endtemplate}
|
||||
class StreamGalleryHeader extends StatelessWidget
|
||||
implements PreferredSizeWidget {
|
||||
/// Creates a channel header
|
||||
/// {@macro streamGalleryHeader}
|
||||
const StreamGalleryHeader({
|
||||
super.key,
|
||||
required this.message,
|
||||
@@ -32,15 +20,19 @@ class StreamGalleryHeader extends StatelessWidget
|
||||
this.showBackButton = true,
|
||||
this.onBackPressed,
|
||||
this.onShowMessage,
|
||||
this.onReplyMessage,
|
||||
this.onTitleTap,
|
||||
this.onImageTap,
|
||||
this.userName = '',
|
||||
this.sentAt = '',
|
||||
this.backgroundColor,
|
||||
this.attachmentActionsModalBuilder,
|
||||
this.elevation = 1.0,
|
||||
}) : preferredSize = const Size.fromHeight(kToolbarHeight);
|
||||
|
||||
/// True if this header shows the leading back button
|
||||
/// Whether to show the leading back button.
|
||||
///
|
||||
/// Defaults to `true`.
|
||||
final bool showBackButton;
|
||||
|
||||
/// Callback to call when pressing the back button.
|
||||
@@ -50,6 +42,9 @@ class StreamGalleryHeader extends StatelessWidget
|
||||
/// Callback to call when pressing the show message button.
|
||||
final VoidCallback? onShowMessage;
|
||||
|
||||
/// Callback to call when pressing the reply message button.
|
||||
final VoidCallback? onReplyMessage;
|
||||
|
||||
/// Callback to call when the header is tapped.
|
||||
final VoidCallback? onTitleTap;
|
||||
|
||||
@@ -71,11 +66,15 @@ class StreamGalleryHeader extends StatelessWidget
|
||||
/// The background color of this [StreamGalleryHeader].
|
||||
final Color? backgroundColor;
|
||||
|
||||
/// Widget builder for attachment actions modal
|
||||
/// [defaultActionsModal] is the default [AttachmentActionsModal] config
|
||||
/// Use [defaultActionsModal.copyWith] to easily customize it
|
||||
/// {@macro attachmentActionsBuilder}
|
||||
final AttachmentActionsBuilder? attachmentActionsModalBuilder;
|
||||
|
||||
/// The elevation of this [StreamGalleryHeader].
|
||||
///
|
||||
/// Defaults to `1.0`. When used for desktop & web platforms, it should
|
||||
/// be set to `0.0`.
|
||||
final double elevation;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final galleryHeaderThemeData = StreamGalleryHeaderTheme.of(context);
|
||||
@@ -86,7 +85,7 @@ class StreamGalleryHeader extends StatelessWidget
|
||||
systemOverlayStyle: theme.brightness == Brightness.dark
|
||||
? SystemUiOverlayStyle.light
|
||||
: SystemUiOverlayStyle.dark,
|
||||
elevation: 1,
|
||||
elevation: elevation,
|
||||
leading: showBackButton
|
||||
? IconButton(
|
||||
icon: StreamSvgIcon.close(
|
||||
@@ -104,9 +103,7 @@ class StreamGalleryHeader extends StatelessWidget
|
||||
icon: StreamSvgIcon.iconMenuPoint(
|
||||
color: galleryHeaderThemeData.iconMenuPointColor,
|
||||
),
|
||||
onPressed: () {
|
||||
_showMessageActionModalBottomSheet(context);
|
||||
},
|
||||
onPressed: () => _showMessageActionModalBottomSheet(context),
|
||||
),
|
||||
],
|
||||
centerTitle: true,
|
||||
@@ -139,7 +136,7 @@ class StreamGalleryHeader extends StatelessWidget
|
||||
@override
|
||||
final Size preferredSize;
|
||||
|
||||
void _showMessageActionModalBottomSheet(BuildContext context) async {
|
||||
Future<void> _showMessageActionModalBottomSheet(BuildContext context) async {
|
||||
final channel = StreamChannel.of(context).channel;
|
||||
final galleryHeaderThemeData =
|
||||
StreamChatTheme.of(context).galleryHeaderTheme;
|
||||
@@ -148,6 +145,7 @@ class StreamGalleryHeader extends StatelessWidget
|
||||
attachment: attachment,
|
||||
message: message,
|
||||
onShowMessage: onShowMessage,
|
||||
onReply: onReplyMessage,
|
||||
);
|
||||
|
||||
final effectiveModal = attachmentActionsModalBuilder?.call(
|
||||
@@ -168,7 +166,7 @@ class StreamGalleryHeader extends StatelessWidget
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
Navigator.pop(context, result);
|
||||
Navigator.of(context).pop(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@macro image_group}
|
||||
@Deprecated("Use 'StreamImageGroup' instead")
|
||||
typedef ImageGroup = StreamImageGroup;
|
||||
|
||||
/// {@template image_group}
|
||||
/// Widget for constructing a group of images in message
|
||||
/// {@endtemplate}
|
||||
class StreamImageGroup extends StatelessWidget {
|
||||
/// Constructor for creating [StreamImageGroup] widget
|
||||
const StreamImageGroup({
|
||||
super.key,
|
||||
required this.images,
|
||||
required this.message,
|
||||
required this.messageTheme,
|
||||
required this.size,
|
||||
this.onReturnAction,
|
||||
this.onShowMessage,
|
||||
this.onAttachmentTap,
|
||||
this.imageThumbnailSize = const Size(400, 400),
|
||||
this.imageThumbnailResizeType = 'crop',
|
||||
this.imageThumbnailCropType = 'center',
|
||||
});
|
||||
|
||||
/// List of attachments to show
|
||||
final List<Attachment> images;
|
||||
|
||||
/// Callback when attachment is returned to from other screens
|
||||
final ValueChanged<ReturnActionType>? onReturnAction;
|
||||
|
||||
/// Callback when attachment is tapped
|
||||
final void Function(Message message, Attachment attachment)? onAttachmentTap;
|
||||
|
||||
/// Message which images are attached to
|
||||
final Message message;
|
||||
|
||||
/// [StreamMessageThemeData] to apply to message
|
||||
final StreamMessageThemeData messageTheme;
|
||||
|
||||
/// Size of images
|
||||
final Size size;
|
||||
|
||||
/// Callback for when show message is tapped
|
||||
final ShowMessageCallback? onShowMessage;
|
||||
|
||||
/// Size of the attachment image thumbnail.
|
||||
final Size imageThumbnailSize;
|
||||
|
||||
/// Resize type of the image attachment thumbnail.
|
||||
///
|
||||
/// Defaults to [crop]
|
||||
final String /*clip|crop|scale|fill*/ imageThumbnailResizeType;
|
||||
|
||||
/// Crop type of the image attachment thumbnail.
|
||||
///
|
||||
/// Defaults to [center]
|
||||
final String /*center|top|bottom|left|right*/ imageThumbnailCropType;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ConstrainedBox(
|
||||
constraints: BoxConstraints.loose(size),
|
||||
child: Flex(
|
||||
direction: Axis.vertical,
|
||||
children: <Widget>[
|
||||
Flexible(
|
||||
fit: FlexFit.tight,
|
||||
child: Flex(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
direction: Axis.horizontal,
|
||||
children: [
|
||||
Flexible(
|
||||
fit: FlexFit.tight,
|
||||
child: _buildImage(context, 0),
|
||||
),
|
||||
Flexible(
|
||||
fit: FlexFit.tight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 2),
|
||||
child: _buildImage(context, 1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (images.length >= 3)
|
||||
Flexible(
|
||||
fit: FlexFit.tight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Flex(
|
||||
direction: Axis.horizontal,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
Flexible(
|
||||
fit: FlexFit.tight,
|
||||
child: _buildImage(context, 2),
|
||||
),
|
||||
if (images.length >= 4)
|
||||
Flexible(
|
||||
fit: FlexFit.tight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 2),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: <Widget>[
|
||||
_buildImage(context, 3),
|
||||
if (images.length > 4)
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: () => _onTap(context, 3),
|
||||
child: Material(
|
||||
color: Colors.black38,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'+ ${images.length - 4}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 26,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
void _onTap(
|
||||
BuildContext context,
|
||||
int index,
|
||||
) async {
|
||||
if (onAttachmentTap != null) {
|
||||
return onAttachmentTap!(message, images[index]);
|
||||
}
|
||||
|
||||
final channel = StreamChannel.of(context).channel;
|
||||
|
||||
final res = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => StreamChannel(
|
||||
channel: channel,
|
||||
child: StreamFullScreenMedia(
|
||||
mediaAttachmentPackages: message.getAttachmentPackageList(),
|
||||
startIndex: index,
|
||||
userName: message.user?.name,
|
||||
onShowMessage: onShowMessage,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (res != null) onReturnAction?.call(res);
|
||||
}
|
||||
|
||||
Widget _buildImage(BuildContext context, int index) => StreamImageAttachment(
|
||||
attachment: images[index],
|
||||
size: size,
|
||||
message: message,
|
||||
messageTheme: messageTheme,
|
||||
onAttachmentTap: () => _onTap(context, index),
|
||||
imageThumbnailSize: imageThumbnailSize,
|
||||
imageThumbnailResizeType: imageThumbnailResizeType,
|
||||
imageThumbnailCropType: imageThumbnailCropType,
|
||||
);
|
||||
}
|
||||
+4
-8
@@ -1,15 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@macro sending_indicator}
|
||||
@Deprecated("Use 'StreamSendingIndicator' instead")
|
||||
typedef SendingIndicator = StreamSendingIndicator;
|
||||
|
||||
/// {@template sending_indicator}
|
||||
/// Used to show the sending status of the message
|
||||
/// {@template streamSendingIndicator}
|
||||
/// Shows the sending status of a message.
|
||||
/// {@endtemplate}
|
||||
class StreamSendingIndicator extends StatelessWidget {
|
||||
/// Constructor for creating a [StreamSendingIndicator] widget
|
||||
/// {@macro streamSendingIndicator}
|
||||
const StreamSendingIndicator({
|
||||
super.key,
|
||||
required this.message,
|
||||
@@ -37,7 +33,7 @@ class StreamSendingIndicator extends StatelessWidget {
|
||||
if (message.status == MessageSendingStatus.sent) {
|
||||
return StreamSvgIcon.check(
|
||||
size: size,
|
||||
color: IconTheme.of(context).color!.withOpacity(0.5),
|
||||
color: StreamChatTheme.of(context).colorTheme.textLowEmphasis,
|
||||
);
|
||||
}
|
||||
if (message.status == MessageSendingStatus.sending ||
|
||||
+7
-10
@@ -1,17 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lottie/lottie.dart';
|
||||
import 'package:stream_chat_flutter/src/extension.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/extensions.dart';
|
||||
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
|
||||
|
||||
/// {@macro typing_indicator}
|
||||
@Deprecated("Use 'StreamTypingIndicator' instead")
|
||||
typedef TypingIndicator = StreamTypingIndicator;
|
||||
|
||||
/// {@template typing_indicator}
|
||||
/// Widget to show the current list of typing users
|
||||
/// {@template streamTypingIndicator}
|
||||
/// Shows the list of user who are actively typing.
|
||||
/// {@endtemplate}
|
||||
class StreamTypingIndicator extends StatelessWidget {
|
||||
/// Instantiate a new TypingIndicator
|
||||
/// {@macro streamTypingIndicator}
|
||||
const StreamTypingIndicator({
|
||||
super.key,
|
||||
this.channel,
|
||||
@@ -27,7 +23,7 @@ class StreamTypingIndicator extends StatelessWidget {
|
||||
/// List of typing users
|
||||
final Channel? channel;
|
||||
|
||||
/// Widget built when no typings is happening
|
||||
/// The widget to build when no typing is happening
|
||||
final Widget? alternativeWidget;
|
||||
|
||||
/// The padding of this widget
|
||||
@@ -45,7 +41,8 @@ class StreamTypingIndicator extends StatelessWidget {
|
||||
|
||||
return BetterStreamBuilder<Iterable<User>>(
|
||||
initialData: channelState.typingEvents.keys,
|
||||
stream: channelState.typingEventsStream.map((typings) => typings.entries
|
||||
stream: channelState.typingEventsStream.map((typingEvents) => typingEvents
|
||||
.entries
|
||||
.where((element) => element.value.parentId == parentId)
|
||||
.map((e) => e.key)),
|
||||
builder: (context, users) => AnimatedSwitcher(
|
||||
+3
-7
@@ -1,15 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@macro unread_indicator}
|
||||
@Deprecated("Use 'StreamUnreadIndicator' instead")
|
||||
typedef UnreadIndicator = StreamUnreadIndicator;
|
||||
|
||||
/// {@template unread_indicator}
|
||||
/// Widget for showing an unread indicator
|
||||
/// {@template streamUnreadIndicator}
|
||||
/// Shows an unread indicator for a message.
|
||||
/// {@endtemplate}
|
||||
class StreamUnreadIndicator extends StatelessWidget {
|
||||
/// Constructor for creating an [StreamUnreadIndicator]
|
||||
/// {@macro streamUnreadIndicator}
|
||||
const StreamUnreadIndicator({
|
||||
super.key,
|
||||
this.cid,
|
||||
+3
-7
@@ -1,15 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@macro upload_progress_indicator}
|
||||
@Deprecated("Use 'StreamUploadProgressIndicator' instead")
|
||||
typedef UploadProgressIndicator = StreamUploadProgressIndicator;
|
||||
|
||||
/// {@template upload_progress_indicator}
|
||||
/// Widget for showing upload progress
|
||||
/// {@template streamUploadProgressIndicator}
|
||||
/// Shows the upload progress of an attachment.
|
||||
/// {@endtemplate}
|
||||
class StreamUploadProgressIndicator extends StatelessWidget {
|
||||
/// Constructor for creating an [StreamUploadProgressIndicator]
|
||||
/// {@macro streamUploadProgressIndicator}
|
||||
const StreamUploadProgressIndicator({
|
||||
super.key,
|
||||
required this.uploaded,
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// The intent for pressing the "enter" key.
|
||||
class EnterKeyIntent extends Intent {}
|
||||
|
||||
/// The intent for pressing the "escape" key.
|
||||
class EscapeKeyIntent extends Intent {}
|
||||
|
||||
/// The intent for pressing the "right" arrow key.
|
||||
class RightArrowKeyIntent extends Intent {}
|
||||
|
||||
/// The intent for pressing the "left" arrow key.
|
||||
class LeftArrowKeyIntent extends Intent {}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/keyboard_shortcuts/intents.dart';
|
||||
import 'package:stream_chat_flutter/src/keyboard_shortcuts/keysets.dart';
|
||||
|
||||
/// A widget that executes functions when specific physical keyboard shortcuts
|
||||
/// are performed.
|
||||
class KeyboardShortcutRunner extends StatelessWidget {
|
||||
/// Builds a [KeyboardShortcutRunner].
|
||||
const KeyboardShortcutRunner({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.onEnterKeypress,
|
||||
this.onEscapeKeypress,
|
||||
this.onRightArrowKeypress,
|
||||
this.onLeftArrowKeypress,
|
||||
});
|
||||
|
||||
/// This child of this widget.
|
||||
final Widget child;
|
||||
|
||||
/// The function to execute when the "enter" key is pressed.
|
||||
final VoidCallback? onEnterKeypress;
|
||||
|
||||
/// The function to execute when the "escape" key is pressed.
|
||||
final VoidCallback? onEscapeKeypress;
|
||||
|
||||
/// The function to execute when the "right arrow" key is pressed.
|
||||
final VoidCallback? onRightArrowKeypress;
|
||||
|
||||
/// The function to execute when the "left arrow" key is pressed.
|
||||
final VoidCallback? onLeftArrowKeypress;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FocusableActionDetector(
|
||||
autofocus: true,
|
||||
shortcuts: {
|
||||
enterKeySet: EnterKeyIntent(),
|
||||
escapeKeySet: EscapeKeyIntent(),
|
||||
rightArrowKeySet: RightArrowKeyIntent(),
|
||||
leftArrowKeySet: LeftArrowKeyIntent(),
|
||||
},
|
||||
actions: {
|
||||
EnterKeyIntent: CallbackAction(
|
||||
onInvoke: (e) => onEnterKeypress?.call(),
|
||||
),
|
||||
EscapeKeyIntent: CallbackAction(
|
||||
onInvoke: (e) => onEscapeKeypress?.call(),
|
||||
),
|
||||
RightArrowKeyIntent: CallbackAction(
|
||||
onInvoke: (e) => onRightArrowKeypress?.call(),
|
||||
),
|
||||
LeftArrowKeyIntent: CallbackAction(
|
||||
onInvoke: (e) => onLeftArrowKeypress?.call(),
|
||||
),
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// The "enter" keyset.
|
||||
///
|
||||
/// Use to quickly send a message in [StreamMessageInput].
|
||||
final enterKeySet = LogicalKeySet(
|
||||
LogicalKeyboardKey.enter,
|
||||
);
|
||||
|
||||
/// The "escape" keyset.
|
||||
///
|
||||
/// Use for:
|
||||
/// * Removing a reply from [StreamMessageInput].
|
||||
/// * Closing [FullScreenMediaDesktop].
|
||||
final escapeKeySet = LogicalKeySet(
|
||||
LogicalKeyboardKey.escape,
|
||||
);
|
||||
|
||||
/// The "right arrow" keyset.
|
||||
///
|
||||
/// Use for navigating to the next [FullScreenMediaDesktop] item.
|
||||
final rightArrowKeySet = LogicalKeySet(
|
||||
LogicalKeyboardKey.arrowRight,
|
||||
);
|
||||
|
||||
/// The "left arrow" keyset.
|
||||
///
|
||||
/// Use for navigating to the previous [FullScreenMediaDesktop] item.
|
||||
final leftArrowKeySet = LogicalKeySet(
|
||||
LogicalKeyboardKey.arrowLeft,
|
||||
);
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'package:stream_chat_flutter/src/localization/translations.dart'
|
||||
show Translations;
|
||||
|
||||
@@ -28,9 +27,10 @@ abstract class StreamChatLocalizations implements Translations {
|
||||
/// ```dart
|
||||
/// tooltip: StreamChatLocalizations.of(context).streamChatLabel,
|
||||
/// ```
|
||||
static StreamChatLocalizations? of(BuildContext context) =>
|
||||
Localizations.of<StreamChatLocalizations>(
|
||||
context,
|
||||
StreamChatLocalizations,
|
||||
);
|
||||
static StreamChatLocalizations? of(BuildContext context) {
|
||||
return Localizations.of<StreamChatLocalizations>(
|
||||
context,
|
||||
StreamChatLocalizations,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:jiffy/jiffy.dart';
|
||||
import 'package:stream_chat_flutter/src/connection_status_builder.dart';
|
||||
import 'package:stream_chat_flutter/src/message_list_view.dart';
|
||||
import 'package:stream_chat_flutter/src/v4/message_input/stream_message_input.dart';
|
||||
import 'package:stream_chat_flutter/src/message_list_view/message_list_view.dart';
|
||||
import 'package:stream_chat_flutter/src/misc/connection_status_builder.dart';
|
||||
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'
|
||||
show User;
|
||||
|
||||
@@ -113,7 +112,7 @@ abstract class Translations {
|
||||
/// The label for instant commands in [StreamMessageInput]
|
||||
String get instantCommandsLabel;
|
||||
|
||||
/// The error shown in case the fi"le is too large even after compression
|
||||
/// The error shown in case the file is too large even after compression
|
||||
/// while uploading via [StreamMessageInput]
|
||||
String fileTooLargeAfterCompressionError(double limitInMB);
|
||||
|
||||
@@ -121,8 +120,8 @@ abstract class Translations {
|
||||
/// while uploading via [StreamMessageInput]
|
||||
String fileTooLargeError(double limitInMB);
|
||||
|
||||
/// The text for showing the query while searching for emojis
|
||||
String emojiMatchingQueryText(String query);
|
||||
/// The error shown when the file being read has no bytes
|
||||
String get couldNotReadBytesFromFileError;
|
||||
|
||||
/// The label for "add a file"
|
||||
String get addAFileLabel;
|
||||
@@ -160,9 +159,15 @@ abstract class Translations {
|
||||
/// The message shown for asking photo and video access permission
|
||||
String get enablePhotoAndVideoAccessMessage;
|
||||
|
||||
/// The message shown for asking photo and video access permission
|
||||
String get enableFileAccessMessage;
|
||||
|
||||
/// The message shown for asking gallery access permission
|
||||
String get allowGalleryAccessMessage;
|
||||
|
||||
/// The message shown for asking file access permission
|
||||
String get allowFileAccessMessage;
|
||||
|
||||
/// The label for "flag message"
|
||||
String get flagMessageLabel;
|
||||
|
||||
@@ -330,6 +335,26 @@ abstract class Translations {
|
||||
/// Label for "Attachment limit exceeded:
|
||||
/// it's not possible to add more than $limit attachments"
|
||||
String attachmentLimitExceedError(int limit);
|
||||
|
||||
/// The label for "Download"
|
||||
String get downloadLabel;
|
||||
|
||||
/// The text for "Mute Group"/"Unmute Group" based on the value of [isMuted].
|
||||
String toggleMuteUnmuteGroupText({required bool isMuted});
|
||||
|
||||
/// The text for "Mute User"/"Unmute User" based on the value of [isMuted].
|
||||
String toggleMuteUnmuteUserText({required bool isMuted});
|
||||
|
||||
/// The text for "Are you sure you want to mute this group?"/"Are you sure you want to unmute this group?"
|
||||
/// based on the value of [isMuted].
|
||||
String toggleMuteUnmuteGroupQuestion({required bool isMuted});
|
||||
|
||||
/// The text for "Are you sure you want to mute this user?"/"Are you sure you want to unmute this user?"
|
||||
/// based on the value of [isMuted].
|
||||
String toggleMuteUnmuteUserQuestion({required bool isMuted});
|
||||
|
||||
/// The text for "MUTE"/"UNMUTE" based on the value of [isMuted].
|
||||
String toggleMuteUnmuteAction({required bool isMuted});
|
||||
}
|
||||
|
||||
/// Default implementation of Translation strings for the stream chat widgets
|
||||
@@ -462,7 +487,8 @@ class DefaultTranslations implements Translations {
|
||||
'The file is too large to upload. The file size limit is $limitInMB MB.';
|
||||
|
||||
@override
|
||||
String emojiMatchingQueryText(String query) => 'Emoji matching "$query"';
|
||||
String get couldNotReadBytesFromFileError =>
|
||||
'Could not read bytes from file.';
|
||||
|
||||
@override
|
||||
String get addAFileLabel => 'Add a file';
|
||||
@@ -709,6 +735,54 @@ class DefaultTranslations implements Translations {
|
||||
String attachmentLimitExceedError(int limit) => """
|
||||
Attachment limit exceeded: it's not possible to add more than $limit attachments""";
|
||||
|
||||
@override
|
||||
String get downloadLabel => 'Download';
|
||||
|
||||
@override
|
||||
String toggleMuteUnmuteUserText({required bool isMuted}) {
|
||||
if (isMuted) {
|
||||
return 'Unmute User';
|
||||
} else {
|
||||
return 'Mute User';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toggleMuteUnmuteGroupQuestion({required bool isMuted}) {
|
||||
if (isMuted) {
|
||||
return 'Are you sure you want to unmute this group?';
|
||||
} else {
|
||||
return 'Are you sure you want to mute this group?';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toggleMuteUnmuteUserQuestion({required bool isMuted}) {
|
||||
if (isMuted) {
|
||||
return 'Are you sure you want to unmute this user?';
|
||||
} else {
|
||||
return 'Are you sure you want to mute this user?';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toggleMuteUnmuteAction({required bool isMuted}) {
|
||||
if (isMuted) {
|
||||
return 'UNMUTE';
|
||||
} else {
|
||||
return 'MUTE';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toggleMuteUnmuteGroupText({required bool isMuted}) {
|
||||
if (isMuted) {
|
||||
return 'Unmute Group';
|
||||
} else {
|
||||
return 'Mute Group';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String get linkDisabledDetails =>
|
||||
'Sending links is not allowed in this conversation.';
|
||||
@@ -723,4 +797,11 @@ Attachment limit exceeded: it's not possible to add more than $limit attachments
|
||||
}
|
||||
return '$unreadCount unread messages';
|
||||
}
|
||||
|
||||
@override
|
||||
String get enableFileAccessMessage => 'Please enable access to files'
|
||||
'\nso you can share them with friends.';
|
||||
|
||||
@override
|
||||
String get allowFileAccessMessage => 'Allow access to files';
|
||||
}
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:stream_chat_flutter/src/media_list_view_controller.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@macro media_list_view}
|
||||
@Deprecated("Use 'StreamMediaListView' instead")
|
||||
typedef MediaListView = StreamMediaListView;
|
||||
|
||||
/// {@template media_list_view}
|
||||
/// Constructs a list of media
|
||||
/// {@endtemplate}
|
||||
class StreamMediaListView extends StatefulWidget {
|
||||
/// Constructor for creating a [StreamMediaListView] widget
|
||||
const StreamMediaListView({
|
||||
super.key,
|
||||
this.selectedIds = const [],
|
||||
this.onSelect,
|
||||
this.controller,
|
||||
this.thumbnailSize = const ThumbnailSize(400, 400),
|
||||
this.thumbnailFormat = ThumbnailFormat.jpeg,
|
||||
this.thumbnailQuality = 100,
|
||||
this.thumbnailScale = 1,
|
||||
});
|
||||
|
||||
/// Stores the media selected
|
||||
final List<String> selectedIds;
|
||||
|
||||
/// Callback for on media selected
|
||||
final void Function(AssetEntity media)? onSelect;
|
||||
|
||||
/// Controller that handles MediaListView
|
||||
final MediaListViewController? controller;
|
||||
|
||||
/// The thumbnail size.
|
||||
final ThumbnailSize thumbnailSize;
|
||||
|
||||
/// {@macro photo_manager.ThumbnailFormat}
|
||||
final ThumbnailFormat thumbnailFormat;
|
||||
|
||||
/// The quality value for the thumbnail.
|
||||
///
|
||||
/// Valid from 1 to 100.
|
||||
/// Defaults to 100.
|
||||
final int thumbnailQuality;
|
||||
|
||||
/// Scale of the image.
|
||||
final double thumbnailScale;
|
||||
|
||||
@override
|
||||
_StreamMediaListViewState createState() => _StreamMediaListViewState();
|
||||
}
|
||||
|
||||
class _StreamMediaListViewState extends State<StreamMediaListView> {
|
||||
var _media = <AssetEntity>[];
|
||||
var _currentPage = 0;
|
||||
final _scrollController = ScrollController();
|
||||
|
||||
/// Controller necessary to verify limited access to photo gallery in iOS and
|
||||
/// update the media list when listerners are emitted
|
||||
late final controller = widget.controller ?? MediaListViewController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => LazyLoadScrollView(
|
||||
onEndOfPage: () async {
|
||||
await _getMedia();
|
||||
_updatePage();
|
||||
},
|
||||
child: GridView.builder(
|
||||
itemCount: _media.length,
|
||||
controller: _scrollController,
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
),
|
||||
itemBuilder: (
|
||||
context,
|
||||
position,
|
||||
) {
|
||||
final media = _media.elementAt(position);
|
||||
final chatThemeData = StreamChatTheme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 1, vertical: 1),
|
||||
child: InkWell(
|
||||
onTap: widget.onSelect == null
|
||||
? null
|
||||
: () => widget.onSelect!(media),
|
||||
child: Stack(
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: FadeInImage(
|
||||
image: MediaThumbnailProvider(
|
||||
media: media,
|
||||
size: widget.thumbnailSize,
|
||||
format: widget.thumbnailFormat,
|
||||
quality: widget.thumbnailQuality,
|
||||
scale: widget.thumbnailScale,
|
||||
),
|
||||
fadeInDuration: const Duration(milliseconds: 300),
|
||||
placeholder: const AssetImage(
|
||||
'images/placeholder.png',
|
||||
package: 'stream_chat_flutter',
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
opacity:
|
||||
widget.selectedIds.any((id) => id == media.id)
|
||||
? 1.0
|
||||
: 0.0,
|
||||
child: Container(
|
||||
color: chatThemeData.colorTheme.textHighEmphasis
|
||||
.withOpacity(0.5),
|
||||
alignment: Alignment.topRight,
|
||||
padding: const EdgeInsets.only(
|
||||
top: 8,
|
||||
right: 8,
|
||||
),
|
||||
child: CircleAvatar(
|
||||
radius: 12,
|
||||
backgroundColor: chatThemeData.colorTheme.barsBg,
|
||||
child: StreamSvgIcon.check(
|
||||
size: 24,
|
||||
color:
|
||||
chatThemeData.colorTheme.textHighEmphasis,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (media.type == AssetType.video) ...[
|
||||
Positioned(
|
||||
left: 8,
|
||||
bottom: 10,
|
||||
child: SvgPicture.asset(
|
||||
'svgs/video_call_icon.svg',
|
||||
package: 'stream_chat_flutter',
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 4,
|
||||
bottom: 10,
|
||||
child: Text(
|
||||
media.videoDuration.format(),
|
||||
style: TextStyle(
|
||||
color: chatThemeData.colorTheme.barsBg,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller.addListener(_updateMediaList);
|
||||
_getMedia();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
controller.removeListener(_updateMediaList);
|
||||
if (widget.controller == null) {
|
||||
controller.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
void _updateMediaList() {
|
||||
if (controller.shouldUpdateMedia) {
|
||||
_getMedia();
|
||||
}
|
||||
}
|
||||
|
||||
void _updatePage() {
|
||||
++_currentPage;
|
||||
}
|
||||
|
||||
Future<void> _getMedia() async {
|
||||
final assetList = (await PhotoManager.getAssetPathList(
|
||||
filterOption: FilterOptionGroup(
|
||||
orders: [
|
||||
const OrderOption(
|
||||
// ignore: avoid_redundant_argument_values
|
||||
type: OrderOptionType.createDate,
|
||||
),
|
||||
],
|
||||
),
|
||||
onlyAll: true,
|
||||
))
|
||||
.firstOrNull;
|
||||
|
||||
final media = await assetList?.getAssetListPaged(
|
||||
page: _currentPage,
|
||||
size: 50,
|
||||
);
|
||||
if (media?.isNotEmpty == true) {
|
||||
setState(() {
|
||||
_media = media!;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ImageProvider implementation for [AssetEntity].
|
||||
class MediaThumbnailProvider extends ImageProvider<MediaThumbnailProvider> {
|
||||
/// Constructor for creating a [MediaThumbnailProvider]
|
||||
const MediaThumbnailProvider({
|
||||
required this.media,
|
||||
// TODO: Are these sizes optimal? Consider web/desktop
|
||||
this.size = const ThumbnailSize(400, 400),
|
||||
this.format = ThumbnailFormat.jpeg,
|
||||
this.quality = 100,
|
||||
this.scale = 2,
|
||||
});
|
||||
|
||||
/// Media to get the thumbnail from.
|
||||
final AssetEntity media;
|
||||
|
||||
/// The thumbnail size.
|
||||
final ThumbnailSize size;
|
||||
|
||||
/// {@macro photo_manager.ThumbnailFormat}
|
||||
final ThumbnailFormat format;
|
||||
|
||||
/// The quality value for the thumbnail.
|
||||
///
|
||||
/// Valid from 1 to 100.
|
||||
/// Defaults to 100.
|
||||
final int quality;
|
||||
|
||||
/// Scale of the image.
|
||||
final double scale;
|
||||
|
||||
@override
|
||||
Future<MediaThumbnailProvider> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture<MediaThumbnailProvider>(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter load(
|
||||
MediaThumbnailProvider key,
|
||||
DecoderCallback decode,
|
||||
) {
|
||||
return MultiFrameImageStreamCompleter(
|
||||
codec: _loadAsync(key, decode),
|
||||
scale: key.scale,
|
||||
informationCollector: () sync* {
|
||||
yield DiagnosticsProperty<ImageProvider>(
|
||||
'Thumbnail provider: $this \n Thumbnail key: $key',
|
||||
this,
|
||||
style: DiagnosticsTreeStyle.errorProperty,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<ui.Codec> _loadAsync(
|
||||
MediaThumbnailProvider key,
|
||||
DecoderCallback decode,
|
||||
) async {
|
||||
assert(key == this, '$key is not $this');
|
||||
final bytes = await media.thumbnailDataWithSize(
|
||||
size,
|
||||
format: format,
|
||||
quality: quality,
|
||||
);
|
||||
return decode(bytes!);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(dynamic other) {
|
||||
if (other is MediaThumbnailProvider) {
|
||||
return media == other.media &&
|
||||
size == other.size &&
|
||||
format == other.format &&
|
||||
quality == other.quality &&
|
||||
scale == other.scale;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(media, size, format, quality, scale);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType('
|
||||
'media: $media, '
|
||||
'size: $size, '
|
||||
'format: $format, '
|
||||
'quality: $quality, '
|
||||
'scale: $scale'
|
||||
')';
|
||||
}
|
||||
|
||||
extension on Duration {
|
||||
String format() {
|
||||
final s = '$this'.split('.')[0].padLeft(8, '0');
|
||||
if (s.startsWith('00:')) {
|
||||
return s.replaceFirst('00:', '');
|
||||
}
|
||||
return s;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Controller for MediaListView Widget
|
||||
class MediaListViewController extends ChangeNotifier {
|
||||
var _shouldUpdateMedia = false;
|
||||
|
||||
/// Getter that knows if the media should be updated.
|
||||
bool get shouldUpdateMedia => _shouldUpdateMedia;
|
||||
|
||||
/// Method that update shouldUpdateMedia and notify all listeners
|
||||
/// about this update.
|
||||
void updateMedia({required bool newValue}) {
|
||||
_shouldUpdateMedia = newValue;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -1,689 +0,0 @@
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/extension.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@macro message_actions_modal}
|
||||
@Deprecated("Use 'StreamMessageActionsModal' instead")
|
||||
typedef MessageActionsModal = StreamMessageActionsModal;
|
||||
|
||||
/// {@template message_actions_modal}
|
||||
/// Constructs a modal with actions for a message
|
||||
/// {@endtemplate}
|
||||
class StreamMessageActionsModal extends StatefulWidget {
|
||||
/// Constructor for creating a [StreamMessageActionsModal] widget
|
||||
const StreamMessageActionsModal({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.messageWidget,
|
||||
required this.messageTheme,
|
||||
this.showReactions,
|
||||
this.showDeleteMessage,
|
||||
this.showEditMessage,
|
||||
this.onReplyTap,
|
||||
this.onThreadReplyTap,
|
||||
this.showCopyMessage = true,
|
||||
this.showReplyMessage = true,
|
||||
this.showResendMessage = true,
|
||||
this.showThreadReplyMessage,
|
||||
this.showFlagButton,
|
||||
this.showPinButton,
|
||||
this.editMessageInputBuilder,
|
||||
this.reverse = false,
|
||||
this.customActions = const [],
|
||||
this.onCopyTap,
|
||||
});
|
||||
|
||||
/// Widget that shows the message
|
||||
final Widget messageWidget;
|
||||
|
||||
/// Builder for edit message
|
||||
final Widget Function(BuildContext, Message)? editMessageInputBuilder;
|
||||
|
||||
/// Callback for when thread reply is tapped
|
||||
final OnMessageTap? onThreadReplyTap;
|
||||
|
||||
/// Callback for when reply is tapped
|
||||
final OnMessageTap? onReplyTap;
|
||||
|
||||
/// Message in focus for actions
|
||||
final Message message;
|
||||
|
||||
/// [StreamMessageThemeData] for message
|
||||
final StreamMessageThemeData messageTheme;
|
||||
|
||||
/// Flag for showing reactions
|
||||
final bool? showReactions;
|
||||
|
||||
/// Callback when copy is tapped
|
||||
final OnMessageTap? onCopyTap;
|
||||
|
||||
/// Callback when delete is tapped
|
||||
final bool? showDeleteMessage;
|
||||
|
||||
/// Flag for showing copy action
|
||||
final bool showCopyMessage;
|
||||
|
||||
/// Flag for showing edit action
|
||||
final bool? showEditMessage;
|
||||
|
||||
/// Flag for showing resend action
|
||||
final bool showResendMessage;
|
||||
|
||||
/// Flag for showing reply action
|
||||
final bool? showReplyMessage;
|
||||
|
||||
/// Flag for showing thread reply action
|
||||
final bool? showThreadReplyMessage;
|
||||
|
||||
/// Flag for showing flag action
|
||||
final bool? showFlagButton;
|
||||
|
||||
/// Flag for showing pin action
|
||||
final bool? showPinButton;
|
||||
|
||||
/// Flag for reversing message
|
||||
final bool reverse;
|
||||
|
||||
/// List of custom actions
|
||||
final List<StreamMessageAction> customActions;
|
||||
|
||||
@override
|
||||
_StreamMessageActionsModalState createState() =>
|
||||
_StreamMessageActionsModalState();
|
||||
}
|
||||
|
||||
class _StreamMessageActionsModalState extends State<StreamMessageActionsModal> {
|
||||
bool _showActions = true;
|
||||
late List<String> _userPermissions;
|
||||
late bool _isMyMessage;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _showMessageOptionsModal();
|
||||
|
||||
Widget _showMessageOptionsModal() {
|
||||
final mediaQueryData = MediaQuery.of(context);
|
||||
final size = mediaQueryData.size;
|
||||
final user = StreamChat.of(context).currentUser;
|
||||
|
||||
final roughMaxSize = size.width * 2 / 3;
|
||||
var messageTextLength = widget.message.text!.length;
|
||||
if (widget.message.quotedMessage != null) {
|
||||
var quotedMessageLength =
|
||||
(widget.message.quotedMessage!.text?.length ?? 0) + 40;
|
||||
if (widget.message.quotedMessage!.attachments.isNotEmpty) {
|
||||
quotedMessageLength += 40;
|
||||
}
|
||||
if (quotedMessageLength > messageTextLength) {
|
||||
messageTextLength = quotedMessageLength;
|
||||
}
|
||||
}
|
||||
final roughSentenceSize = messageTextLength *
|
||||
(widget.messageTheme.messageTextStyle?.fontSize ?? 1) *
|
||||
1.2;
|
||||
final divFactor = widget.message.attachments.isNotEmpty
|
||||
? 1
|
||||
: (roughSentenceSize == 0 ? 1 : (roughSentenceSize / roughMaxSize));
|
||||
|
||||
final streamChatThemeData = StreamChatTheme.of(context);
|
||||
|
||||
final numberOfReactions = streamChatThemeData.reactionIcons.length;
|
||||
final shiftFactor =
|
||||
numberOfReactions < 5 ? (5 - numberOfReactions) * 0.1 : 0.0;
|
||||
|
||||
final hasEditPermission = _userPermissions.contains(
|
||||
PermissionType.updateAnyMessage,
|
||||
) ||
|
||||
_userPermissions.contains(PermissionType.updateOwnMessage);
|
||||
|
||||
final hasDeletePermission = _userPermissions.contains(
|
||||
PermissionType.deleteAnyMessage,
|
||||
) ||
|
||||
_userPermissions.contains(PermissionType.deleteOwnMessage);
|
||||
|
||||
final hasReactionPermission =
|
||||
_userPermissions.contains(PermissionType.sendReaction);
|
||||
|
||||
final child = Center(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: widget.reverse
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
if ((widget.showReactions ?? hasReactionPermission) &&
|
||||
(widget.message.status == MessageSendingStatus.sent))
|
||||
Align(
|
||||
alignment: Alignment(
|
||||
user?.id == widget.message.user?.id
|
||||
? (divFactor >= 1.0
|
||||
? -0.2 - shiftFactor
|
||||
: (1.2 - divFactor))
|
||||
: (divFactor >= 1.0
|
||||
? shiftFactor + 0.2
|
||||
: -(1.2 - divFactor)),
|
||||
0,
|
||||
),
|
||||
child: StreamReactionPicker(
|
||||
message: widget.message,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
IgnorePointer(
|
||||
child: widget.messageWidget,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: widget.reverse ? 0 : 40,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: mediaQueryData.size.width * 0.75,
|
||||
child: Material(
|
||||
color: streamChatThemeData.colorTheme.appBg,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (widget.showReplyMessage ??
|
||||
(_userPermissions
|
||||
.contains(PermissionType.quoteMessage) &&
|
||||
widget.message.status ==
|
||||
MessageSendingStatus.sent))
|
||||
_buildReplyButton(context),
|
||||
if (widget.showThreadReplyMessage ??
|
||||
_userPermissions
|
||||
.contains(PermissionType.sendReply) &&
|
||||
(widget.message.status ==
|
||||
MessageSendingStatus.sent) &&
|
||||
widget.message.parentId == null)
|
||||
_buildThreadReplyButton(context),
|
||||
if (widget.showResendMessage)
|
||||
_buildResendMessage(context),
|
||||
if (widget.showEditMessage ??
|
||||
_isMyMessage && hasEditPermission)
|
||||
_buildEditMessage(context),
|
||||
if (widget.showCopyMessage) _buildCopyButton(context),
|
||||
if (widget.showFlagButton ??
|
||||
_userPermissions
|
||||
.contains(PermissionType.flagMessage))
|
||||
_buildFlagButton(context),
|
||||
if (widget.showPinButton ??
|
||||
_userPermissions
|
||||
.contains(PermissionType.pinMessage))
|
||||
_buildPinButton(context),
|
||||
if (widget.showDeleteMessage ??
|
||||
(_isMyMessage && hasDeletePermission))
|
||||
_buildDeleteButton(context),
|
||||
...widget.customActions
|
||||
.map((action) => _buildCustomAction(
|
||||
context,
|
||||
action,
|
||||
)),
|
||||
].insertBetween(
|
||||
Container(
|
||||
height: 1,
|
||||
color: streamChatThemeData.colorTheme.borders,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () => Navigator.maybePop(context),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: 10,
|
||||
sigmaY: 10,
|
||||
),
|
||||
child: ColoredBox(
|
||||
color: streamChatThemeData.colorTheme.overlay,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_showActions)
|
||||
TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0, end: 1),
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOutBack,
|
||||
builder: (context, val, child) => Transform.scale(
|
||||
scale: val,
|
||||
child: child,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
InkWell _buildCustomAction(
|
||||
BuildContext context,
|
||||
StreamMessageAction messageAction,
|
||||
) =>
|
||||
InkWell(
|
||||
onTap: () {
|
||||
messageAction.onTap?.call(widget.message);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
messageAction.leading ?? const Offstage(),
|
||||
const SizedBox(width: 16),
|
||||
messageAction.title ?? const Offstage(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
void _showFlagDialog() async {
|
||||
final client = StreamChat.of(context).client;
|
||||
|
||||
final streamChatThemeData = StreamChatTheme.of(context);
|
||||
final answer = await showConfirmationDialog(
|
||||
context,
|
||||
title: context.translations.flagMessageLabel,
|
||||
icon: StreamSvgIcon.flag(
|
||||
color: streamChatThemeData.colorTheme.accentError,
|
||||
size: 24,
|
||||
),
|
||||
question: context.translations.flagMessageQuestion,
|
||||
okText: context.translations.flagLabel,
|
||||
cancelText: context.translations.cancelLabel,
|
||||
);
|
||||
|
||||
final theme = streamChatThemeData;
|
||||
if (answer == true) {
|
||||
try {
|
||||
await client.flagMessage(widget.message.id);
|
||||
await showInfoDialog(
|
||||
context,
|
||||
icon: StreamSvgIcon.flag(
|
||||
color: theme.colorTheme.accentError,
|
||||
size: 24,
|
||||
),
|
||||
details: context.translations.flagMessageSuccessfulText,
|
||||
title: context.translations.flagMessageSuccessfulLabel,
|
||||
okText: context.translations.okLabel,
|
||||
);
|
||||
} catch (err) {
|
||||
if (err is StreamChatNetworkError &&
|
||||
err.errorCode == ChatErrorCode.inputError) {
|
||||
await showInfoDialog(
|
||||
context,
|
||||
icon: StreamSvgIcon.flag(
|
||||
color: theme.colorTheme.accentError,
|
||||
size: 24,
|
||||
),
|
||||
details: context.translations.flagMessageSuccessfulText,
|
||||
title: context.translations.flagMessageSuccessfulLabel,
|
||||
okText: context.translations.okLabel,
|
||||
);
|
||||
} else {
|
||||
_showErrorAlert();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _togglePin() async {
|
||||
final channel = StreamChannel.of(context).channel;
|
||||
|
||||
Navigator.pop(context);
|
||||
try {
|
||||
if (!widget.message.pinned) {
|
||||
await channel.pinMessage(widget.message);
|
||||
} else {
|
||||
await channel.unpinMessage(widget.message);
|
||||
}
|
||||
} catch (e) {
|
||||
_showErrorAlert();
|
||||
}
|
||||
}
|
||||
|
||||
void _showDeleteDialog() async {
|
||||
setState(() {
|
||||
_showActions = false;
|
||||
});
|
||||
final answer = await showConfirmationDialog(
|
||||
context,
|
||||
title: context.translations.deleteMessageLabel,
|
||||
icon: StreamSvgIcon.flag(
|
||||
color: StreamChatTheme.of(context).colorTheme.accentError,
|
||||
size: 24,
|
||||
),
|
||||
question: context.translations.deleteMessageQuestion,
|
||||
okText: context.translations.deleteLabel,
|
||||
cancelText: context.translations.cancelLabel,
|
||||
);
|
||||
|
||||
if (answer == true) {
|
||||
try {
|
||||
Navigator.pop(context);
|
||||
await StreamChannel.of(context).channel.deleteMessage(widget.message);
|
||||
} catch (err) {
|
||||
_showErrorAlert();
|
||||
}
|
||||
} else {
|
||||
setState(() {
|
||||
_showActions = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _showErrorAlert() {
|
||||
showInfoDialog(
|
||||
context,
|
||||
icon: StreamSvgIcon.error(
|
||||
color: StreamChatTheme.of(context).colorTheme.accentError,
|
||||
size: 24,
|
||||
),
|
||||
details: context.translations.operationCouldNotBeCompletedText,
|
||||
title: context.translations.somethingWentWrongError,
|
||||
okText: context.translations.okLabel,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReplyButton(BuildContext context) {
|
||||
final streamChatThemeData = StreamChatTheme.of(context);
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
widget.onReplyTap?.call(widget.message);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
StreamSvgIcon.reply(
|
||||
color: streamChatThemeData.primaryIconTheme.color,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
context.translations.replyLabel,
|
||||
style: streamChatThemeData.textTheme.body,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFlagButton(BuildContext context) {
|
||||
final streamChatThemeData = StreamChatTheme.of(context);
|
||||
return InkWell(
|
||||
onTap: _showFlagDialog,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
StreamSvgIcon.iconFlag(
|
||||
color: streamChatThemeData.primaryIconTheme.color,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
context.translations.flagMessageLabel,
|
||||
style: streamChatThemeData.textTheme.body,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPinButton(BuildContext context) {
|
||||
final streamChatThemeData = StreamChatTheme.of(context);
|
||||
return InkWell(
|
||||
onTap: _togglePin,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
StreamSvgIcon.pin(
|
||||
color: streamChatThemeData.primaryIconTheme.color,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
context.translations.togglePinUnpinText(
|
||||
pinned: widget.message.pinned,
|
||||
),
|
||||
style: streamChatThemeData.textTheme.body,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDeleteButton(BuildContext context) {
|
||||
final isDeleteFailed =
|
||||
widget.message.status == MessageSendingStatus.failed_delete;
|
||||
return InkWell(
|
||||
onTap: _showDeleteDialog,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
StreamSvgIcon.delete(
|
||||
color: Colors.red,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
context.translations.toggleDeleteRetryDeleteMessageText(
|
||||
isDeleteFailed: isDeleteFailed,
|
||||
),
|
||||
style: StreamChatTheme.of(context)
|
||||
.textTheme
|
||||
.body
|
||||
.copyWith(color: Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCopyButton(BuildContext context) {
|
||||
final streamChatThemeData = StreamChatTheme.of(context);
|
||||
return InkWell(
|
||||
onTap: () async {
|
||||
widget.onCopyTap?.call(widget.message);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
StreamSvgIcon.copy(
|
||||
size: 24,
|
||||
color: streamChatThemeData.primaryIconTheme.color,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
context.translations.copyMessageLabel,
|
||||
style: streamChatThemeData.textTheme.body,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEditMessage(BuildContext context) {
|
||||
final streamChatThemeData = StreamChatTheme.of(context);
|
||||
return InkWell(
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
_showEditBottomSheet(context);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
StreamSvgIcon.edit(
|
||||
color: streamChatThemeData.primaryIconTheme.color,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
context.translations.editMessageLabel,
|
||||
style: streamChatThemeData.textTheme.body,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResendMessage(BuildContext context) {
|
||||
final isUpdateFailed =
|
||||
widget.message.status == MessageSendingStatus.failed_update;
|
||||
final streamChatThemeData = StreamChatTheme.of(context);
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
final channel = StreamChannel.of(context).channel;
|
||||
if (isUpdateFailed) {
|
||||
channel.updateMessage(widget.message);
|
||||
} else {
|
||||
channel.sendMessage(widget.message);
|
||||
}
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
StreamSvgIcon.circleUp(
|
||||
color: streamChatThemeData.colorTheme.accentPrimary,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
context.translations.toggleResendOrResendEditedMessage(
|
||||
isUpdateFailed: isUpdateFailed,
|
||||
),
|
||||
style: streamChatThemeData.textTheme.body,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showEditBottomSheet(BuildContext context) {
|
||||
final channel = StreamChannel.of(context).channel;
|
||||
final streamChatThemeData = StreamChatTheme.of(context);
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
elevation: 2,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: StreamMessageInputTheme.of(context).inputBackgroundColor,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
builder: (context) => Padding(
|
||||
padding: MediaQuery.of(context).viewInsets,
|
||||
child: StreamChannel(
|
||||
channel: channel,
|
||||
child: Flex(
|
||||
direction: Axis.vertical,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: StreamSvgIcon.edit(
|
||||
color: streamChatThemeData.colorTheme.disabled,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
context.translations.editMessageLabel,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: StreamSvgIcon.closeSmall(),
|
||||
onPressed: Navigator.of(context).pop,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.editMessageInputBuilder != null)
|
||||
widget.editMessageInputBuilder!(context, widget.message)
|
||||
else
|
||||
StreamMessageInput(
|
||||
messageInputController: StreamMessageInputController(
|
||||
message: widget.message,
|
||||
),
|
||||
preMessageSending: (m) {
|
||||
FocusScope.of(context).unfocus();
|
||||
Navigator.pop(context);
|
||||
return m;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThreadReplyButton(BuildContext context) {
|
||||
final streamChatThemeData = StreamChatTheme.of(context);
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
widget.onThreadReplyTap?.call(widget.message);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
StreamSvgIcon.thread(
|
||||
color: streamChatThemeData.primaryIconTheme.color,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
context.translations.threadReplyLabel,
|
||||
style: streamChatThemeData.textTheme.body,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
final newStreamChannel = StreamChannel.of(context);
|
||||
_userPermissions = newStreamChannel.channel.ownCapabilities;
|
||||
_isMyMessage =
|
||||
widget.message.user?.id == StreamChat.of(context).currentUser?.id;
|
||||
super.didChangeDependencies();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/extensions.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@template copyMessageButton}
|
||||
/// Allows a user to copy the text of a message.
|
||||
///
|
||||
/// Used by [MessageActionsModal]. Should not be used by itself.
|
||||
/// {@endtemplate}
|
||||
class CopyMessageButton extends StatelessWidget {
|
||||
/// {@macro copyMessageButton}
|
||||
const CopyMessageButton({
|
||||
super.key,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
/// The callback to perform when the button is tapped.
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final streamChatThemeData = StreamChatTheme.of(context);
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
StreamSvgIcon.copy(
|
||||
size: 24,
|
||||
color: streamChatThemeData.primaryIconTheme.color,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
context.translations.copyMessageLabel,
|
||||
style: streamChatThemeData.textTheme.body,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/extensions.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@template deleteMessageButton}
|
||||
/// A button that allows a user to delete the selected message.
|
||||
///
|
||||
/// Used by [MessageActionsModal]. Should not be used by itself.
|
||||
/// {@endtemplate}
|
||||
class DeleteMessageButton extends StatelessWidget {
|
||||
/// {@macro deleteMessageButton}
|
||||
const DeleteMessageButton({
|
||||
super.key,
|
||||
required this.isDeleteFailed,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
/// Indicates whether the deletion has failed or not.
|
||||
final bool isDeleteFailed;
|
||||
|
||||
/// The action (deleting the message) to be performed on tap.
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
StreamSvgIcon.delete(
|
||||
color: Colors.red,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
context.translations.toggleDeleteRetryDeleteMessageText(
|
||||
isDeleteFailed: isDeleteFailed,
|
||||
),
|
||||
style: StreamChatTheme.of(context)
|
||||
.textTheme
|
||||
.body
|
||||
.copyWith(color: Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/extensions.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@template editMessageButton}
|
||||
/// Allows a user to edit a message.
|
||||
///
|
||||
/// Used by [MessageActionsModal]. Should not be used by itself.
|
||||
/// {@endtemplate}
|
||||
class EditMessageButton extends StatelessWidget {
|
||||
/// {@macro editMessageButton}
|
||||
const EditMessageButton({
|
||||
super.key,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
/// The callback to perform when the button is tapped.
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final streamChatThemeData = StreamChatTheme.of(context);
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
StreamSvgIcon.edit(
|
||||
color: streamChatThemeData.primaryIconTheme.color,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
context.translations.editMessageLabel,
|
||||
style: streamChatThemeData.textTheme.body,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/extensions.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@template flagMessageButton}
|
||||
/// Allows a user to flag a message.
|
||||
///
|
||||
/// Used by [MessageActionsModal]. Should not be used by itself.
|
||||
/// {@endtemplate}
|
||||
class FlagMessageButton extends StatelessWidget {
|
||||
/// {@macro flagMessageButton}
|
||||
const FlagMessageButton({
|
||||
super.key,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
/// The callback to perform when the button is tapped.
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final streamChatThemeData = StreamChatTheme.of(context);
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
StreamSvgIcon.iconFlag(
|
||||
color: streamChatThemeData.primaryIconTheme.color,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
context.translations.flagMessageLabel,
|
||||
style: streamChatThemeData.textTheme.body,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export 'copy_message_button.dart';
|
||||
export 'delete_message_button.dart';
|
||||
export 'edit_message_button.dart';
|
||||
export 'flag_message_button.dart';
|
||||
export 'pin_message_button.dart';
|
||||
export 'reply_button.dart';
|
||||
export 'resend_message_button.dart';
|
||||
export 'thread_reply_button.dart';
|
||||
+4
-8
@@ -1,15 +1,11 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/typedefs.dart';
|
||||
|
||||
/// {@macro message_action}
|
||||
@Deprecated("Use 'StreamMessageActions' instead")
|
||||
typedef MessageAction = StreamMessageAction;
|
||||
|
||||
/// {@template message_action}
|
||||
/// {@template streamMessageAction}
|
||||
/// Class describing a message action
|
||||
/// {@endtemplate}
|
||||
class StreamMessageAction {
|
||||
/// returns a new instance of a [StreamMessageAction]
|
||||
/// {@macro streamMessageAction}
|
||||
StreamMessageAction({
|
||||
this.leading,
|
||||
this.title,
|
||||
@@ -22,6 +18,6 @@ class StreamMessageAction {
|
||||
/// title widget
|
||||
final Widget? title;
|
||||
|
||||
/// callback called on tap
|
||||
/// {@macro onMessageTap}
|
||||
final OnMessageTap? onTap;
|
||||
}
|
||||
+516
@@ -0,0 +1,516 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart' hide ButtonStyle;
|
||||
import 'package:stream_chat_flutter/src/message_actions_modal/mam_widgets.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/utils.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@template messageActionsModal}
|
||||
/// Constructs a modal with actions for a message
|
||||
/// {@endtemplate}
|
||||
class MessageActionsModal extends StatefulWidget {
|
||||
/// {@macro messageActionsModal}
|
||||
const MessageActionsModal({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.messageWidget,
|
||||
required this.messageTheme,
|
||||
this.showReactions = true,
|
||||
this.showDeleteMessage = true,
|
||||
this.showEditMessage = true,
|
||||
this.onReplyTap,
|
||||
this.onThreadReplyTap,
|
||||
this.showCopyMessage = true,
|
||||
this.showReplyMessage = true,
|
||||
this.showResendMessage = true,
|
||||
this.showThreadReplyMessage = true,
|
||||
this.showFlagButton = true,
|
||||
this.showPinButton = true,
|
||||
this.editMessageInputBuilder,
|
||||
this.reverse = false,
|
||||
this.customActions = const [],
|
||||
this.onCopyTap,
|
||||
});
|
||||
|
||||
/// Widget that shows the message
|
||||
final Widget messageWidget;
|
||||
|
||||
/// Builder for edit message
|
||||
final EditMessageInputBuilder? editMessageInputBuilder;
|
||||
|
||||
/// The action to perform when "thread reply" is tapped
|
||||
final OnMessageTap? onThreadReplyTap;
|
||||
|
||||
/// The action to perform when "reply" is tapped
|
||||
final OnMessageTap? onReplyTap;
|
||||
|
||||
/// Message in focus for actions
|
||||
final Message message;
|
||||
|
||||
/// [StreamMessageThemeData] for message
|
||||
final StreamMessageThemeData messageTheme;
|
||||
|
||||
/// Flag for showing reactions
|
||||
final bool showReactions;
|
||||
|
||||
/// Callback when copy is tapped
|
||||
final OnMessageTap? onCopyTap;
|
||||
|
||||
/// Callback when delete is tapped
|
||||
final bool showDeleteMessage;
|
||||
|
||||
/// Flag for showing copy action
|
||||
final bool showCopyMessage;
|
||||
|
||||
/// Flag for showing edit action
|
||||
final bool showEditMessage;
|
||||
|
||||
/// Flag for showing resend action
|
||||
final bool showResendMessage;
|
||||
|
||||
/// Flag for showing reply action
|
||||
final bool showReplyMessage;
|
||||
|
||||
/// Flag for showing thread reply action
|
||||
final bool showThreadReplyMessage;
|
||||
|
||||
/// Flag for showing flag action
|
||||
final bool showFlagButton;
|
||||
|
||||
/// Flag for showing pin action
|
||||
final bool showPinButton;
|
||||
|
||||
/// Flag for reversing message
|
||||
final bool reverse;
|
||||
|
||||
/// List of custom actions
|
||||
final List<StreamMessageAction> customActions;
|
||||
|
||||
@override
|
||||
_MessageActionsModalState createState() => _MessageActionsModalState();
|
||||
}
|
||||
|
||||
class _MessageActionsModalState extends State<MessageActionsModal> {
|
||||
bool _showActions = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _showMessageOptionsModal();
|
||||
|
||||
Widget _showMessageOptionsModal() {
|
||||
final mediaQueryData = MediaQuery.of(context);
|
||||
final size = mediaQueryData.size;
|
||||
final user = StreamChat.of(context).currentUser;
|
||||
|
||||
final roughMaxSize = size.width * 2 / 3;
|
||||
var messageTextLength = widget.message.text!.length;
|
||||
if (widget.message.quotedMessage != null) {
|
||||
var quotedMessageLength =
|
||||
(widget.message.quotedMessage!.text?.length ?? 0) + 40;
|
||||
if (widget.message.quotedMessage!.attachments.isNotEmpty) {
|
||||
quotedMessageLength += 40;
|
||||
}
|
||||
if (quotedMessageLength > messageTextLength) {
|
||||
messageTextLength = quotedMessageLength;
|
||||
}
|
||||
}
|
||||
final roughSentenceSize = messageTextLength *
|
||||
(widget.messageTheme.messageTextStyle?.fontSize ?? 1) *
|
||||
1.2;
|
||||
final divFactor = widget.message.attachments.isNotEmpty
|
||||
? 1
|
||||
: (roughSentenceSize == 0 ? 1 : (roughSentenceSize / roughMaxSize));
|
||||
|
||||
final streamChatThemeData = StreamChatTheme.of(context);
|
||||
|
||||
final numberOfReactions =
|
||||
StreamChatConfiguration.of(context).reactionIcons.length;
|
||||
final shiftFactor =
|
||||
numberOfReactions < 5 ? (5 - numberOfReactions) * 0.1 : 0.0;
|
||||
final channel = StreamChannel.of(context).channel;
|
||||
|
||||
final child = Center(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: widget.reverse
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
if (widget.showReactions &&
|
||||
(widget.message.status == MessageSendingStatus.sent))
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Align(
|
||||
alignment: Alignment(
|
||||
_calculateReactionsHorizontalAlignmentValue(
|
||||
user,
|
||||
divFactor,
|
||||
shiftFactor,
|
||||
constraints,
|
||||
),
|
||||
0,
|
||||
),
|
||||
child: StreamReactionPicker(
|
||||
message: widget.message,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
IgnorePointer(
|
||||
child: widget.messageWidget,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: widget.reverse ? 0 : 40,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: mediaQueryData.size.width * 0.75,
|
||||
child: Material(
|
||||
color: streamChatThemeData.colorTheme.appBg,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (widget.showReplyMessage &&
|
||||
widget.message.status == MessageSendingStatus.sent)
|
||||
ReplyButton(
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
if (widget.onReplyTap != null) {
|
||||
widget.onReplyTap?.call(widget.message);
|
||||
}
|
||||
},
|
||||
),
|
||||
if (widget.showThreadReplyMessage &&
|
||||
(widget.message.status ==
|
||||
MessageSendingStatus.sent) &&
|
||||
widget.message.parentId == null)
|
||||
ThreadReplyButton(
|
||||
message: widget.message,
|
||||
onThreadReplyTap: widget.onThreadReplyTap,
|
||||
),
|
||||
if (widget.showResendMessage)
|
||||
ResendMessageButton(
|
||||
message: widget.message,
|
||||
channel: channel,
|
||||
),
|
||||
if (widget.showEditMessage)
|
||||
EditMessageButton(
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
_showEditBottomSheet(context);
|
||||
},
|
||||
),
|
||||
if (widget.showCopyMessage)
|
||||
CopyMessageButton(
|
||||
onTap: () {
|
||||
widget.onCopyTap?.call(widget.message);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
if (widget.showFlagButton)
|
||||
FlagMessageButton(
|
||||
onTap: _showFlagDialog,
|
||||
),
|
||||
if (widget.showPinButton)
|
||||
PinMessageButton(
|
||||
onTap: _togglePin,
|
||||
pinned: widget.message.pinned,
|
||||
),
|
||||
if (widget.showDeleteMessage)
|
||||
DeleteMessageButton(
|
||||
isDeleteFailed: widget.message.status ==
|
||||
MessageSendingStatus.failed_delete,
|
||||
onTap: _showDeleteBottomSheet,
|
||||
),
|
||||
...widget.customActions
|
||||
.map((action) => _buildCustomAction(
|
||||
context,
|
||||
action,
|
||||
)),
|
||||
].insertBetween(
|
||||
Container(
|
||||
height: 1,
|
||||
color: streamChatThemeData.colorTheme.borders,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () => Navigator.of(context).maybePop(),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: 10,
|
||||
sigmaY: 10,
|
||||
),
|
||||
child: ColoredBox(
|
||||
color: streamChatThemeData.colorTheme.overlay,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_showActions)
|
||||
TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0, end: 1),
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOutBack,
|
||||
builder: (context, val, child) => Transform.scale(
|
||||
scale: val,
|
||||
child: child,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
double _calculateReactionsHorizontalAlignmentValue(
|
||||
User? user,
|
||||
num divFactor,
|
||||
double shiftFactor,
|
||||
BoxConstraints constraints,
|
||||
) {
|
||||
var result = 0.0;
|
||||
var cont = true;
|
||||
if (user?.id == widget.message.user?.id) {
|
||||
if (divFactor >= 1.0) {
|
||||
// This calculation is hacky and does not cover all bases!!!
|
||||
// A better option is needed!
|
||||
|
||||
// Landscape calculations
|
||||
if (constraints.maxWidth == 1350) {
|
||||
// 12.7 iPad Pro
|
||||
result = shiftFactor + 0.5;
|
||||
cont = false;
|
||||
} else if (constraints.maxWidth == 1178) {
|
||||
// 11 inch iPad Pro
|
||||
result = shiftFactor + 0.42;
|
||||
cont = false;
|
||||
} else if (constraints.maxWidth == 1164) {
|
||||
// iPad Air 4
|
||||
result = shiftFactor + 0.4;
|
||||
cont = false;
|
||||
} else if (constraints.maxWidth == 1117) {
|
||||
// iPad Mini 6
|
||||
result = shiftFactor + 0.37;
|
||||
cont = false;
|
||||
} else if (constraints.maxWidth == 1064) {
|
||||
// iPad 9th gen
|
||||
result = shiftFactor + 0.33;
|
||||
cont = false;
|
||||
} else if (constraints.maxWidth == 1008) {
|
||||
// 9.7 inch iPad Pro
|
||||
result = shiftFactor + 0.3;
|
||||
cont = false;
|
||||
} else if (constraints.maxWidth >= 200 && constraints.maxWidth <= 400) {
|
||||
// Phone (?)
|
||||
result = shiftFactor - 0.2;
|
||||
cont = false;
|
||||
}
|
||||
|
||||
if (cont) {
|
||||
// Portrait calculations
|
||||
if (constraints.maxWidth == 1008) {
|
||||
// 12.7 iPad Pro
|
||||
result = shiftFactor + 0.3;
|
||||
} else if (constraints.maxWidth == 818) {
|
||||
// 11 inch iPad Pro
|
||||
result = shiftFactor + 0.07;
|
||||
} else if (constraints.maxWidth == 804) {
|
||||
// iPad Air 4
|
||||
result = shiftFactor + 0.04;
|
||||
} else if (constraints.maxWidth == 794) {
|
||||
// iPad 9th gen
|
||||
result = shiftFactor + 0.02;
|
||||
} else if (constraints.maxWidth >= 752) {
|
||||
// 9.7 inch iPad Pro
|
||||
result = shiftFactor - 0.05;
|
||||
} else if (constraints.maxWidth == 728) {
|
||||
// iPad Mini 6
|
||||
result = shiftFactor - 0.1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result = 1.2 - divFactor;
|
||||
}
|
||||
} else {
|
||||
if (divFactor >= 1.0) {
|
||||
result = shiftFactor + 0.2;
|
||||
} else {
|
||||
result = -(1.2 - divFactor);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure reactions don't get pushed past the edge of the screen.
|
||||
//
|
||||
// Hacky!!! Needs improvement!!!
|
||||
if (result > 1) {
|
||||
return 1;
|
||||
} else {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
InkWell _buildCustomAction(
|
||||
BuildContext context,
|
||||
StreamMessageAction messageAction,
|
||||
) {
|
||||
return InkWell(
|
||||
onTap: () => messageAction.onTap?.call(widget.message),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
messageAction.leading ?? const Offstage(),
|
||||
const SizedBox(width: 16),
|
||||
messageAction.title ?? const Offstage(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showFlagDialog() async {
|
||||
final client = StreamChat.of(context).client;
|
||||
|
||||
final streamChatThemeData = StreamChatTheme.of(context);
|
||||
final answer = await showConfirmationBottomSheet(
|
||||
context,
|
||||
title: context.translations.flagMessageLabel,
|
||||
icon: StreamSvgIcon.flag(
|
||||
color: streamChatThemeData.colorTheme.accentError,
|
||||
size: 24,
|
||||
),
|
||||
question: context.translations.flagMessageQuestion,
|
||||
okText: context.translations.flagLabel,
|
||||
cancelText: context.translations.cancelLabel,
|
||||
);
|
||||
|
||||
final theme = streamChatThemeData;
|
||||
if (answer == true) {
|
||||
try {
|
||||
await client.flagMessage(widget.message.id);
|
||||
await showInfoBottomSheet(
|
||||
context,
|
||||
icon: StreamSvgIcon.flag(
|
||||
color: theme.colorTheme.accentError,
|
||||
size: 24,
|
||||
),
|
||||
details: context.translations.flagMessageSuccessfulText,
|
||||
title: context.translations.flagMessageSuccessfulLabel,
|
||||
okText: context.translations.okLabel,
|
||||
);
|
||||
} catch (err) {
|
||||
if (err is StreamChatNetworkError &&
|
||||
err.errorCode == ChatErrorCode.inputError) {
|
||||
await showInfoBottomSheet(
|
||||
context,
|
||||
icon: StreamSvgIcon.flag(
|
||||
color: theme.colorTheme.accentError,
|
||||
size: 24,
|
||||
),
|
||||
details: context.translations.flagMessageSuccessfulText,
|
||||
title: context.translations.flagMessageSuccessfulLabel,
|
||||
okText: context.translations.okLabel,
|
||||
);
|
||||
} else {
|
||||
_showErrorAlertBottomSheet();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _togglePin() async {
|
||||
final channel = StreamChannel.of(context).channel;
|
||||
|
||||
Navigator.of(context).pop();
|
||||
try {
|
||||
if (!widget.message.pinned) {
|
||||
await channel.pinMessage(widget.message);
|
||||
} else {
|
||||
await channel.unpinMessage(widget.message);
|
||||
}
|
||||
} catch (e) {
|
||||
_showErrorAlertBottomSheet();
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows a "delete message" bottom sheet on mobile platforms.
|
||||
Future<void> _showDeleteBottomSheet() async {
|
||||
setState(() => _showActions = false);
|
||||
final answer = await showConfirmationBottomSheet(
|
||||
context,
|
||||
title: context.translations.deleteMessageLabel,
|
||||
icon: StreamSvgIcon.flag(
|
||||
color: StreamChatTheme.of(context).colorTheme.accentError,
|
||||
size: 24,
|
||||
),
|
||||
question: context.translations.deleteMessageQuestion,
|
||||
okText: context.translations.deleteLabel,
|
||||
cancelText: context.translations.cancelLabel,
|
||||
);
|
||||
|
||||
if (answer == true) {
|
||||
try {
|
||||
Navigator.of(context).pop();
|
||||
await StreamChannel.of(context).channel.deleteMessage(widget.message);
|
||||
} catch (err) {
|
||||
_showErrorAlertBottomSheet();
|
||||
}
|
||||
} else {
|
||||
setState(() => _showActions = true);
|
||||
}
|
||||
}
|
||||
|
||||
void _showErrorAlertBottomSheet() {
|
||||
showInfoBottomSheet(
|
||||
context,
|
||||
icon: StreamSvgIcon.error(
|
||||
color: StreamChatTheme.of(context).colorTheme.accentError,
|
||||
size: 24,
|
||||
),
|
||||
details: context.translations.operationCouldNotBeCompletedText,
|
||||
title: context.translations.somethingWentWrongError,
|
||||
okText: context.translations.okLabel,
|
||||
);
|
||||
}
|
||||
|
||||
void _showEditBottomSheet(BuildContext context) {
|
||||
final channel = StreamChannel.of(context).channel;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
elevation: 2,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: StreamMessageInputTheme.of(context).inputBackgroundColor,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
builder: (context) => EditMessageSheet(
|
||||
message: widget.message,
|
||||
channel: channel,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/extensions.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@template pinMessageButton}
|
||||
/// Allows a user to pin or unpin a message.
|
||||
///
|
||||
/// Used by [MessageActionsModal]. Should not be used by itself.
|
||||
/// {@endtemplate}
|
||||
class PinMessageButton extends StatelessWidget {
|
||||
/// {@macro pinMessageButton}
|
||||
const PinMessageButton({
|
||||
super.key,
|
||||
required this.onTap,
|
||||
required this.pinned,
|
||||
});
|
||||
|
||||
/// The callback to perform when the button is tapped.
|
||||
final VoidCallback onTap;
|
||||
|
||||
/// Whether the selected message is currently pinned or not.
|
||||
final bool pinned;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final streamChatThemeData = StreamChatTheme.of(context);
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
StreamSvgIcon.pin(
|
||||
color: streamChatThemeData.primaryIconTheme.color,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
context.translations.togglePinUnpinText(
|
||||
pinned: pinned,
|
||||
),
|
||||
style: streamChatThemeData.textTheme.body,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/extensions.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@template replyButton}
|
||||
/// Allows a user to reply to a message.
|
||||
///
|
||||
/// Used by [MessageActionsModal]. Should not be used by itself.
|
||||
/// {@endtemplate}
|
||||
class ReplyButton extends StatelessWidget {
|
||||
/// {@macro replyButton}
|
||||
const ReplyButton({
|
||||
super.key,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
/// The callback to perform when the button is tapped.
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final streamChatThemeData = StreamChatTheme.of(context);
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
StreamSvgIcon.reply(
|
||||
color: streamChatThemeData.primaryIconTheme.color,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
context.translations.replyLabel,
|
||||
style: streamChatThemeData.textTheme.body,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/extensions.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@template resendMessageButton}
|
||||
/// Allows a user to resend a message that has failed to be sent.
|
||||
///
|
||||
/// Used by [MessageActionsModal]. Should not be used by itself.
|
||||
/// {@endtemplate}
|
||||
class ResendMessageButton extends StatelessWidget {
|
||||
/// {@macro resendMessageButton}
|
||||
const ResendMessageButton({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.channel,
|
||||
});
|
||||
|
||||
/// The message to resend.
|
||||
final Message message;
|
||||
|
||||
/// The [StreamChannel] above this widget.
|
||||
final Channel channel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isUpdateFailed = message.status == MessageSendingStatus.failed_update;
|
||||
final streamChatThemeData = StreamChatTheme.of(context);
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
if (isUpdateFailed) {
|
||||
channel.updateMessage(message);
|
||||
} else {
|
||||
channel.sendMessage(message);
|
||||
}
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
StreamSvgIcon.circleUp(
|
||||
color: streamChatThemeData.colorTheme.accentPrimary,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
context.translations.toggleResendOrResendEditedMessage(
|
||||
isUpdateFailed: isUpdateFailed,
|
||||
),
|
||||
style: streamChatThemeData.textTheme.body,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/utils.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@template threadReplyButton}
|
||||
/// Allows a user to start a thread reply to a message.
|
||||
///
|
||||
/// Used by [MessageActionsModal]. Should not be used by itself.
|
||||
/// {@endtemplate}
|
||||
class ThreadReplyButton extends StatelessWidget {
|
||||
/// {@macro threadReplyButton}
|
||||
const ThreadReplyButton({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.onThreadReplyTap,
|
||||
});
|
||||
|
||||
/// The message to start a thread reply to.
|
||||
final Message message;
|
||||
|
||||
/// The action to perform when "thread reply" is tapped
|
||||
final OnMessageTap? onThreadReplyTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final streamChatThemeData = StreamChatTheme.of(context);
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
if (onThreadReplyTap != null) {
|
||||
onThreadReplyTap?.call(message);
|
||||
}
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
StreamSvgIcon.thread(
|
||||
color: streamChatThemeData.primaryIconTheme.color,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
context.translations.threadReplyLabel,
|
||||
style: streamChatThemeData.textTheme.body,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@template attachmentButton}
|
||||
/// A button for adding attachments to a chat on mobile.
|
||||
/// {@endtemplate}
|
||||
class AttachmentButton extends StatelessWidget {
|
||||
/// {@macro attachmentButton}
|
||||
const AttachmentButton({
|
||||
super.key,
|
||||
required this.color,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
/// The color of the button.
|
||||
final Color color;
|
||||
|
||||
/// The callback to perform when the button is tapped or clicked.
|
||||
final VoidCallback onPressed;
|
||||
|
||||
/// Returns a copy of this object with the given fields updated.
|
||||
AttachmentButton copyWith({
|
||||
Key? key,
|
||||
Color? color,
|
||||
VoidCallback? onPressed,
|
||||
}) {
|
||||
return AttachmentButton(
|
||||
key: key ?? this.key,
|
||||
color: color ?? this.color,
|
||||
onPressed: onPressed ?? this.onPressed,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: StreamSvgIcon.attach(
|
||||
color: color,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
height: 24,
|
||||
width: 24,
|
||||
),
|
||||
splashRadius: 24,
|
||||
onPressed: onPressed,
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export 'stream_file_picker.dart';
|
||||
export 'stream_gallery_picker.dart';
|
||||
export 'stream_image_picker.dart';
|
||||
export 'stream_video_picker.dart';
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:stream_chat_flutter/src/attachment/handler/stream_attachment_handler.dart';
|
||||
import 'package:stream_chat_flutter/src/message_input/attachment_picker/stream_attachment_picker.dart';
|
||||
import 'package:stream_chat_flutter/src/misc/stream_svg_icon.dart';
|
||||
import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/utils.dart';
|
||||
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
|
||||
|
||||
/// Widget used to pick files from the device
|
||||
class StreamFilePicker extends StatelessWidget {
|
||||
/// Creates a [StreamFilePicker] widget.
|
||||
const StreamFilePicker({
|
||||
super.key,
|
||||
required this.onFilePicked,
|
||||
this.dialogTitle,
|
||||
this.initialDirectory,
|
||||
this.type = FileType.any,
|
||||
this.allowedExtensions,
|
||||
this.onFileLoading,
|
||||
this.allowCompression = true,
|
||||
this.withData = false,
|
||||
this.withReadStream = false,
|
||||
this.lockParentWindow = false,
|
||||
});
|
||||
|
||||
/// Callback called when a file is picked.
|
||||
final ValueSetter<Attachment?> onFilePicked;
|
||||
|
||||
/// Title of the file picker dialog.
|
||||
final String? dialogTitle;
|
||||
|
||||
/// Initial directory of the file picker dialog.
|
||||
final String? initialDirectory;
|
||||
|
||||
/// Type of the file to pick.
|
||||
final FileType type;
|
||||
|
||||
/// Allowed extensions of the file to pick.
|
||||
final List<String>? allowedExtensions;
|
||||
|
||||
/// Callback called when the file picker is loading a file.
|
||||
final Function(FilePickerStatus)? onFileLoading;
|
||||
|
||||
/// Whether to allow compression of the file.
|
||||
final bool allowCompression;
|
||||
|
||||
/// Whether to include the file data in the [Attachment].
|
||||
final bool withData;
|
||||
|
||||
/// Whether to include the file read stream in the [Attachment].
|
||||
final bool withReadStream;
|
||||
|
||||
/// Whether to lock the parent window when the file picker is open.
|
||||
final bool lockParentWindow;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = StreamChatTheme.of(context);
|
||||
return OptionDrawer(
|
||||
child: EndOfFrameCallbackWidget(
|
||||
child: StreamSvgIcon.files(
|
||||
size: 240,
|
||||
color: theme.colorTheme.disabled,
|
||||
),
|
||||
onEndOfFrame: (_) async {
|
||||
final pickedFile = await runInPermissionRequestLock(() {
|
||||
return StreamAttachmentHandler.instance.pickFile(
|
||||
dialogTitle: dialogTitle,
|
||||
initialDirectory: initialDirectory,
|
||||
type: type,
|
||||
allowedExtensions: allowedExtensions,
|
||||
onFileLoading: onFileLoading,
|
||||
allowCompression: allowCompression,
|
||||
withData: withData,
|
||||
withReadStream: withReadStream,
|
||||
lockParentWindow: lockParentWindow,
|
||||
);
|
||||
});
|
||||
|
||||
onFilePicked.call(pickedFile);
|
||||
},
|
||||
errorBuilder: (context, error, stacktrace) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
StreamSvgIcon.files(
|
||||
size: 240,
|
||||
color: theme.colorTheme.disabled,
|
||||
),
|
||||
Text(
|
||||
context.translations.enablePhotoAndVideoAccessMessage,
|
||||
style: theme.textTheme.body.copyWith(
|
||||
color: theme.colorTheme.textLowEmphasis,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: PhotoManager.openSetting,
|
||||
child: Text(
|
||||
context.translations.allowGalleryAccessMessage,
|
||||
style: theme.textTheme.bodyBold.copyWith(
|
||||
color: theme.colorTheme.accentPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:stream_chat_flutter/src/message_input/attachment_picker/stream_attachment_picker.dart';
|
||||
import 'package:stream_chat_flutter/src/misc/stream_svg_icon.dart';
|
||||
import 'package:stream_chat_flutter/src/scroll_view/photo_gallery/stream_photo_gallery.dart';
|
||||
import 'package:stream_chat_flutter/src/scroll_view/photo_gallery/stream_photo_gallery_controller.dart';
|
||||
import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/utils.dart';
|
||||
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
|
||||
|
||||
/// Max image resolution which can be resized by the CDN.
|
||||
// Taken from https://getstream.io/chat/docs/flutter-dart/file_uploads/?language=dart#image-resizing
|
||||
const maxCDNImageResolution = 16800000;
|
||||
|
||||
/// Widget used to pick media from the device gallery.
|
||||
class StreamGalleryPicker extends StatefulWidget {
|
||||
/// Creates a [StreamGalleryPicker] widget.
|
||||
const StreamGalleryPicker({
|
||||
super.key,
|
||||
this.limit = 50,
|
||||
required this.selectedMediaItems,
|
||||
required this.onMediaItemSelected,
|
||||
this.mediaThumbnailSize = const ThumbnailSize(400, 400),
|
||||
this.mediaThumbnailFormat = ThumbnailFormat.jpeg,
|
||||
this.mediaThumbnailQuality = 100,
|
||||
this.mediaThumbnailScale = 1,
|
||||
});
|
||||
|
||||
/// Maximum number of media items that can be selected.
|
||||
final int limit;
|
||||
|
||||
/// List of selected media items.
|
||||
final Iterable<String> selectedMediaItems;
|
||||
|
||||
/// Callback called when an media item is selected.
|
||||
final ValueSetter<AssetEntity> onMediaItemSelected;
|
||||
|
||||
/// Size of the attachment thumbnails.
|
||||
///
|
||||
/// Defaults to (400, 400).
|
||||
final ThumbnailSize mediaThumbnailSize;
|
||||
|
||||
/// Format of the attachment thumbnails.
|
||||
///
|
||||
/// Defaults to [ThumbnailFormat.jpeg].
|
||||
final ThumbnailFormat mediaThumbnailFormat;
|
||||
|
||||
/// The quality value for the attachment thumbnails.
|
||||
///
|
||||
/// Valid from 1 to 100.
|
||||
/// Defaults to 100.
|
||||
final int mediaThumbnailQuality;
|
||||
|
||||
/// The scale to apply on the [attachmentThumbnailSize].
|
||||
final double mediaThumbnailScale;
|
||||
|
||||
@override
|
||||
State<StreamGalleryPicker> createState() => _StreamGalleryPickerState();
|
||||
}
|
||||
|
||||
class _StreamGalleryPickerState extends State<StreamGalleryPicker> {
|
||||
Future<PermissionState>? requestPermission;
|
||||
late StreamPhotoGalleryController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = StreamPhotoGalleryController(limit: widget.limit);
|
||||
requestPermission = runInPermissionRequestLock(
|
||||
PhotoManager.requestPermissionExtend,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(StreamGalleryPicker oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.limit != oldWidget.limit) {
|
||||
_controller.dispose();
|
||||
_controller = StreamPhotoGalleryController(limit: widget.limit);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<PermissionState>(
|
||||
future: requestPermission,
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) return const SizedBox.shrink();
|
||||
|
||||
final theme = StreamChatTheme.of(context);
|
||||
final textTheme = theme.textTheme;
|
||||
final colorTheme = theme.colorTheme;
|
||||
|
||||
// Available on both Android and iOS.
|
||||
final isAuthorized = snapshot.data == PermissionState.authorized;
|
||||
// Only available on iOS.
|
||||
final isLimited = snapshot.data == PermissionState.limited;
|
||||
|
||||
final isPermissionGranted = isAuthorized || isLimited;
|
||||
|
||||
return OptionDrawer(
|
||||
actions: [
|
||||
if (isLimited)
|
||||
IconButton(
|
||||
color: colorTheme.accentPrimary,
|
||||
icon: const Icon(Icons.add_circle_outline_rounded),
|
||||
onPressed: () async {
|
||||
await PhotoManager.presentLimited();
|
||||
_controller.doInitialLoad();
|
||||
},
|
||||
),
|
||||
],
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
if (!isPermissionGranted) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
StreamSvgIcon.pictures(
|
||||
size: 240,
|
||||
color: colorTheme.disabled,
|
||||
),
|
||||
Text(
|
||||
context.translations.enablePhotoAndVideoAccessMessage,
|
||||
style: textTheme.body.copyWith(
|
||||
color: colorTheme.textLowEmphasis,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: PhotoManager.openSetting,
|
||||
child: Text(
|
||||
context.translations.allowGalleryAccessMessage,
|
||||
style: textTheme.bodyBold.copyWith(
|
||||
color: colorTheme.accentPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return StreamPhotoGallery(
|
||||
shrinkWrap: true,
|
||||
controller: _controller,
|
||||
onMediaTap: widget.onMediaItemSelected,
|
||||
loadMoreTriggerIndex: 10,
|
||||
padding: const EdgeInsets.all(2),
|
||||
thumbnailSize: widget.mediaThumbnailSize,
|
||||
thumbnailFormat: widget.mediaThumbnailFormat,
|
||||
thumbnailQuality: widget.mediaThumbnailQuality,
|
||||
thumbnailScale: widget.mediaThumbnailScale,
|
||||
itemBuilder: (context, mediaItems, index, defaultWidget) {
|
||||
final media = mediaItems[index];
|
||||
return defaultWidget.copyWith(
|
||||
selected: widget.selectedMediaItems.contains(media.id),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
///
|
||||
extension StreamImagePickerX on StreamAttachmentPickerController {
|
||||
///
|
||||
Future<void> addAssetAttachment(AssetEntity asset) async {
|
||||
final mediaFile = await asset.originFile;
|
||||
|
||||
if (mediaFile == null) return;
|
||||
|
||||
var cachedFile = mediaFile;
|
||||
|
||||
final type = asset.type;
|
||||
if (type == AssetType.image) {
|
||||
// Resize image if it's resolution is greater than the
|
||||
// [maxCDNImageResolution].
|
||||
final imageResolution = asset.width * asset.height;
|
||||
if (imageResolution > maxCDNImageResolution) {
|
||||
final aspect = imageResolution / maxCDNImageResolution;
|
||||
final updatedSize = asset.size / (math.sqrt(aspect));
|
||||
final resizedImage = await asset.thumbnailDataWithSize(
|
||||
ThumbnailSize(
|
||||
updatedSize.width.floor(),
|
||||
updatedSize.height.floor(),
|
||||
),
|
||||
quality: 70,
|
||||
);
|
||||
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
cachedFile = await File(
|
||||
'${tempDir.path}/${mediaFile.path.split('/').last}',
|
||||
).create().then((it) => it.writeAsBytes(resizedImage!));
|
||||
}
|
||||
}
|
||||
|
||||
final file = AttachmentFile(
|
||||
path: cachedFile.path,
|
||||
size: await cachedFile.length(),
|
||||
bytes: cachedFile.readAsBytesSync(),
|
||||
);
|
||||
|
||||
final extraDataMap = <String, Object>{};
|
||||
|
||||
final mimeType = file.mimeType?.mimeType;
|
||||
|
||||
if (mimeType != null) {
|
||||
extraDataMap['mime_type'] = mimeType;
|
||||
}
|
||||
|
||||
extraDataMap['file_size'] = file.size!;
|
||||
|
||||
final attachment = Attachment(
|
||||
id: asset.id,
|
||||
file: file,
|
||||
type: asset.type.toAttachmentType(),
|
||||
extraData: extraDataMap,
|
||||
);
|
||||
|
||||
return addAttachment(attachment);
|
||||
}
|
||||
|
||||
///
|
||||
Future<void> removeAssetAttachment(AssetEntity asset) async {
|
||||
if (asset.type == AssetType.image) {
|
||||
final image = await asset.originFile;
|
||||
if (image != null) {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final cachedFile =
|
||||
File('${tempDir.path}/${image.path.split('/').last}');
|
||||
if (cachedFile.existsSync()) {
|
||||
cachedFile.deleteSync();
|
||||
}
|
||||
}
|
||||
}
|
||||
return removeAttachmentById(asset.id);
|
||||
}
|
||||
}
|
||||
|
||||
///
|
||||
extension AssetTypeX on AssetType {
|
||||
///
|
||||
String toAttachmentType() {
|
||||
switch (this) {
|
||||
case AssetType.image:
|
||||
return 'image';
|
||||
case AssetType.video:
|
||||
return 'video';
|
||||
case AssetType.audio:
|
||||
return 'audio';
|
||||
case AssetType.other:
|
||||
return 'file';
|
||||
}
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:stream_chat_flutter/src/attachment/handler/stream_attachment_handler.dart';
|
||||
import 'package:stream_chat_flutter/src/message_input/attachment_picker/stream_attachment_picker.dart';
|
||||
import 'package:stream_chat_flutter/src/misc/stream_svg_icon.dart';
|
||||
import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/extensions.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/helpers.dart';
|
||||
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
|
||||
|
||||
/// Widget used to pick images from the device.
|
||||
class StreamImagePicker extends StatelessWidget {
|
||||
/// Creates a [StreamImagePicker] widget.
|
||||
const StreamImagePicker({
|
||||
super.key,
|
||||
required this.onImagePicked,
|
||||
this.source = ImageSource.camera,
|
||||
this.maxWidth,
|
||||
this.maxHeight,
|
||||
this.imageQuality,
|
||||
this.preferredCameraDevice = CameraDevice.rear,
|
||||
});
|
||||
|
||||
/// Callback called when an image is picked.
|
||||
final ValueSetter<Attachment?> onImagePicked;
|
||||
|
||||
/// Source of the image to pick.
|
||||
final ImageSource source;
|
||||
|
||||
/// Maximum width of the image.
|
||||
final double? maxWidth;
|
||||
|
||||
/// Maximum height of the image.
|
||||
final double? maxHeight;
|
||||
|
||||
/// Quality of the image.
|
||||
final int? imageQuality;
|
||||
|
||||
/// Preferred camera device to use.
|
||||
final CameraDevice preferredCameraDevice;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = StreamChatTheme.of(context);
|
||||
return OptionDrawer(
|
||||
child: EndOfFrameCallbackWidget(
|
||||
child: StreamSvgIcon.camera(
|
||||
size: 240,
|
||||
color: theme.colorTheme.disabled,
|
||||
),
|
||||
onEndOfFrame: (_) async {
|
||||
final pickedImage = await runInPermissionRequestLock(() {
|
||||
return StreamAttachmentHandler.instance.pickImage(
|
||||
source: source,
|
||||
maxWidth: maxWidth,
|
||||
maxHeight: maxHeight,
|
||||
imageQuality: imageQuality,
|
||||
preferredCameraDevice: preferredCameraDevice,
|
||||
);
|
||||
});
|
||||
|
||||
onImagePicked.call(pickedImage);
|
||||
},
|
||||
errorBuilder: (context, error, stacktrace) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
StreamSvgIcon.camera(
|
||||
size: 240,
|
||||
color: theme.colorTheme.disabled,
|
||||
),
|
||||
Text(
|
||||
context.translations.enablePhotoAndVideoAccessMessage,
|
||||
style: theme.textTheme.body.copyWith(
|
||||
color: theme.colorTheme.textLowEmphasis,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: PhotoManager.openSetting,
|
||||
child: Text(
|
||||
context.translations.allowGalleryAccessMessage,
|
||||
style: theme.textTheme.bodyBold.copyWith(
|
||||
color: theme.colorTheme.accentPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/extensions.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// Widget used to capture video using the device camera.
|
||||
class StreamVideoPicker extends StatelessWidget {
|
||||
/// Creates a [StreamVideoPicker] widget.
|
||||
const StreamVideoPicker({
|
||||
super.key,
|
||||
required this.onVideoPicked,
|
||||
this.source = ImageSource.camera,
|
||||
this.preferredCameraDevice = CameraDevice.rear,
|
||||
this.maxDuration,
|
||||
});
|
||||
|
||||
/// Callback called when a video is picked.
|
||||
final ValueSetter<Attachment?> onVideoPicked;
|
||||
|
||||
/// Source of the video to pick.
|
||||
final ImageSource source;
|
||||
|
||||
/// Preferred camera device to use.
|
||||
final CameraDevice preferredCameraDevice;
|
||||
|
||||
/// Maximum duration of the video.
|
||||
final Duration? maxDuration;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = StreamChatTheme.of(context);
|
||||
return OptionDrawer(
|
||||
child: EndOfFrameCallbackWidget(
|
||||
child: StreamSvgIcon.record(
|
||||
size: 240,
|
||||
color: theme.colorTheme.disabled,
|
||||
),
|
||||
onEndOfFrame: (_) async {
|
||||
final pickedVideo = await runInPermissionRequestLock(() {
|
||||
return StreamAttachmentHandler.instance.pickVideo(
|
||||
source: source,
|
||||
preferredCameraDevice: preferredCameraDevice,
|
||||
maxDuration: maxDuration,
|
||||
);
|
||||
});
|
||||
|
||||
onVideoPicked.call(pickedVideo);
|
||||
},
|
||||
errorBuilder: (context, error, stacktrace) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
StreamSvgIcon.record(
|
||||
size: 240,
|
||||
color: theme.colorTheme.disabled,
|
||||
),
|
||||
Text(
|
||||
context.translations.enablePhotoAndVideoAccessMessage,
|
||||
style: theme.textTheme.body.copyWith(
|
||||
color: theme.colorTheme.textLowEmphasis,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: PhotoManager.openSetting,
|
||||
child: Text(
|
||||
context.translations.allowGalleryAccessMessage,
|
||||
style: theme.textTheme.bodyBold.copyWith(
|
||||
color: theme.colorTheme.accentPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+821
@@ -0,0 +1,821 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/message_input/attachment_picker/options/options.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/extensions.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// The default maximum size for media attachments.
|
||||
const kDefaultMaxAttachmentSize = 100 * 1024 * 1024; // 100MB in Bytes
|
||||
|
||||
/// The default maximum number of media attachments.
|
||||
const kDefaultMaxAttachmentCount = 10;
|
||||
|
||||
/// Controller class for [StreamAttachmentPicker].
|
||||
class StreamAttachmentPickerController extends ValueNotifier<List<Attachment>> {
|
||||
/// Creates a new instance of [StreamAttachmentPickerController].
|
||||
StreamAttachmentPickerController({
|
||||
this.initialAttachments,
|
||||
this.maxAttachmentSize = kDefaultMaxAttachmentSize,
|
||||
this.maxAttachmentCount = kDefaultMaxAttachmentCount,
|
||||
}) : assert(
|
||||
(initialAttachments?.length ?? 0) <= maxAttachmentCount,
|
||||
'''The initial attachments count must be less than or equal to maxAttachmentCount''',
|
||||
),
|
||||
super(initialAttachments ?? const []);
|
||||
|
||||
/// The max attachment size allowed in bytes.
|
||||
final int maxAttachmentSize;
|
||||
|
||||
/// The max attachment count allowed.
|
||||
final int maxAttachmentCount;
|
||||
|
||||
/// The initial attachments.
|
||||
final List<Attachment>? initialAttachments;
|
||||
|
||||
@override
|
||||
set value(List<Attachment> newValue) {
|
||||
if (newValue.length > maxAttachmentCount) {
|
||||
throw ArgumentError(
|
||||
'The maximum number of attachments is $maxAttachmentCount.',
|
||||
);
|
||||
}
|
||||
super.value = newValue;
|
||||
}
|
||||
|
||||
Future<String> _saveToCache(AttachmentFile file) async {
|
||||
// Cache the attachment in a temporary file.
|
||||
return StreamAttachmentHandler.instance.saveAttachmentFile(
|
||||
attachmentFile: file,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _removeFromCache(AttachmentFile file) {
|
||||
// Remove the cached attachment file.
|
||||
return StreamAttachmentHandler.instance.deleteAttachmentFile(
|
||||
attachmentFile: file,
|
||||
);
|
||||
}
|
||||
|
||||
/// Adds a new attachment to the message.
|
||||
Future<void> addAttachment(Attachment attachment) async {
|
||||
assert(attachment.fileSize != null, '');
|
||||
if (attachment.fileSize! > maxAttachmentSize) {
|
||||
throw ArgumentError(
|
||||
'The size of the attachment is ${attachment.fileSize} bytes, '
|
||||
'but the maximum size allowed is $maxAttachmentSize bytes.',
|
||||
);
|
||||
}
|
||||
|
||||
final file = attachment.file;
|
||||
final uploadState = attachment.uploadState;
|
||||
|
||||
// No need to cache the attachment if it's already uploaded
|
||||
// or we are on web.
|
||||
if (file == null || uploadState.isSuccess || isWeb) {
|
||||
value = [...value, attachment];
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache the attachment in a temporary file.
|
||||
final tempFilePath = await _saveToCache(file);
|
||||
|
||||
value = [
|
||||
...value,
|
||||
attachment.copyWith(
|
||||
file: file.copyWith(
|
||||
path: tempFilePath,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Removes the specified [attachment] from the message.
|
||||
Future<void> removeAttachment(Attachment attachment) async {
|
||||
final file = attachment.file;
|
||||
final uploadState = attachment.uploadState;
|
||||
|
||||
if (file == null || uploadState.isSuccess || isWeb) {
|
||||
value = [...value]..remove(attachment);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the cached attachment file.
|
||||
await _removeFromCache(file);
|
||||
|
||||
value = [...value]..remove(attachment);
|
||||
}
|
||||
|
||||
/// Remove the attachment with the given [attachmentId].
|
||||
void removeAttachmentById(String attachmentId) {
|
||||
final attachment = value.firstWhereOrNull(
|
||||
(attachment) => attachment.id == attachmentId,
|
||||
);
|
||||
|
||||
if (attachment == null) return;
|
||||
|
||||
removeAttachment(attachment);
|
||||
}
|
||||
|
||||
/// Clears all the attachments.
|
||||
Future<void> clear() async {
|
||||
final attachments = [...value];
|
||||
for (final attachment in attachments) {
|
||||
final file = attachment.file;
|
||||
final uploadState = attachment.uploadState;
|
||||
|
||||
if (file == null || uploadState.isSuccess || isWeb) continue;
|
||||
|
||||
await _removeFromCache(file);
|
||||
}
|
||||
value = const [];
|
||||
}
|
||||
|
||||
/// Resets the controller to its initial state.
|
||||
Future<void> reset() async {
|
||||
final attachments = [...value];
|
||||
for (final attachment in attachments) {
|
||||
final file = attachment.file;
|
||||
final uploadState = attachment.uploadState;
|
||||
|
||||
if (file == null || uploadState.isSuccess || isWeb) continue;
|
||||
|
||||
await _removeFromCache(file);
|
||||
}
|
||||
value = initialAttachments ?? const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// The possible picker types of the attachment picker.
|
||||
enum AttachmentPickerType {
|
||||
/// The attachment picker will only allow to pick images.
|
||||
images,
|
||||
|
||||
/// The attachment picker will only allow to pick videos.
|
||||
videos,
|
||||
|
||||
/// The attachment picker will only allow to pick audios.
|
||||
audios,
|
||||
|
||||
/// The attachment picker will only allow to pick files or documents.
|
||||
files,
|
||||
}
|
||||
|
||||
/// Function signature for building the attachment picker option view.
|
||||
typedef AttachmentPickerOptionViewBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
StreamAttachmentPickerController controller,
|
||||
);
|
||||
|
||||
/// Model class for the attachment picker options.
|
||||
class AttachmentPickerOption {
|
||||
/// Creates a new instance of [AttachmentPickerOption].
|
||||
const AttachmentPickerOption({
|
||||
this.key,
|
||||
required this.supportedTypes,
|
||||
required this.icon,
|
||||
this.title,
|
||||
this.optionViewBuilder,
|
||||
});
|
||||
|
||||
/// A key to identify the option.
|
||||
final String? key;
|
||||
|
||||
/// The icon of the option.
|
||||
final Widget icon;
|
||||
|
||||
/// The title of the option.
|
||||
final String? title;
|
||||
|
||||
/// The supported types of the option.
|
||||
final Iterable<AttachmentPickerType> supportedTypes;
|
||||
|
||||
/// The option view builder.
|
||||
final AttachmentPickerOptionViewBuilder? optionViewBuilder;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is AttachmentPickerOption &&
|
||||
runtimeType == other.runtimeType &&
|
||||
key == other.key &&
|
||||
const IterableEquality().equals(supportedTypes, other.supportedTypes);
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
key.hashCode ^ const IterableEquality().hash(supportedTypes);
|
||||
}
|
||||
|
||||
/// The attachment picker option for the web or desktop platforms.
|
||||
class WebOrDesktopAttachmentPickerOption extends AttachmentPickerOption {
|
||||
/// Creates a new instance of [WebOrDesktopAttachmentPickerOption].
|
||||
WebOrDesktopAttachmentPickerOption({
|
||||
super.key,
|
||||
required AttachmentPickerType type,
|
||||
required super.icon,
|
||||
required super.title,
|
||||
}) : super(supportedTypes: [type]);
|
||||
|
||||
/// Creates a new instance of [WebOrDesktopAttachmentPickerOption] from
|
||||
/// [option].
|
||||
factory WebOrDesktopAttachmentPickerOption.fromAttachmentPickerOption(
|
||||
AttachmentPickerOption option,
|
||||
) {
|
||||
return WebOrDesktopAttachmentPickerOption(
|
||||
key: option.key,
|
||||
type: option.supportedTypes.first,
|
||||
icon: option.icon,
|
||||
title: option.title,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String get title => super.title!;
|
||||
|
||||
/// Type of the option.
|
||||
AttachmentPickerType get type => supportedTypes.first;
|
||||
}
|
||||
|
||||
/// Helpful extensions for [StreamAttachmentPickerController].
|
||||
extension AttachmentPickerOptionTypeX on StreamAttachmentPickerController {
|
||||
/// Returns the list of available attachment picker options.
|
||||
Set<AttachmentPickerType> get currentAttachmentPickerTypes {
|
||||
final containsImage = value.any((it) => it.type == 'image');
|
||||
final containsVideo = value.any((it) => it.type == 'video');
|
||||
final containsAudio = value.any((it) => it.type == 'audio');
|
||||
final containsFile = value.any((it) => it.type == 'file');
|
||||
|
||||
return {
|
||||
if (containsImage) AttachmentPickerType.images,
|
||||
if (containsVideo) AttachmentPickerType.videos,
|
||||
if (containsAudio) AttachmentPickerType.audios,
|
||||
if (containsFile) AttachmentPickerType.files,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the list of enabled picker types.
|
||||
Set<AttachmentPickerType> filterEnabledTypes({
|
||||
required Iterable<AttachmentPickerOption> options,
|
||||
}) {
|
||||
final availableTypes = currentAttachmentPickerTypes;
|
||||
final enabledTypes = <AttachmentPickerType>{};
|
||||
for (final option in options) {
|
||||
final supportedTypes = option.supportedTypes;
|
||||
if (availableTypes.any(supportedTypes.contains)) {
|
||||
enabledTypes.addAll(supportedTypes);
|
||||
}
|
||||
}
|
||||
return enabledTypes;
|
||||
}
|
||||
|
||||
/// Returns true if the [initialAttachments] are changed.
|
||||
bool get isValueChanged {
|
||||
final isEqual = UnorderedIterableEquality(
|
||||
EqualityBy((Attachment attachment) => attachment.id),
|
||||
).equals(value, initialAttachments);
|
||||
|
||||
return !isEqual;
|
||||
}
|
||||
}
|
||||
|
||||
/// Function signature for the callback when the web or desktop attachment
|
||||
/// picker option gets tapped.
|
||||
typedef OnWebOrDesktopAttachmentPickerOptionTap = void Function(
|
||||
BuildContext context,
|
||||
StreamAttachmentPickerController controller,
|
||||
WebOrDesktopAttachmentPickerOption option,
|
||||
);
|
||||
|
||||
/// Bottom sheet widget for the web or desktop version of the attachment picker.
|
||||
class StreamWebOrDesktopAttachmentPickerBottomSheet extends StatelessWidget {
|
||||
/// Creates a new instance of [StreamWebOrDesktopAttachmentPickerBottomSheet].
|
||||
const StreamWebOrDesktopAttachmentPickerBottomSheet({
|
||||
super.key,
|
||||
required this.options,
|
||||
required this.controller,
|
||||
this.onOptionTap,
|
||||
});
|
||||
|
||||
/// The list of options.
|
||||
final Set<WebOrDesktopAttachmentPickerOption> options;
|
||||
|
||||
/// The controller of the attachment picker.
|
||||
final StreamAttachmentPickerController controller;
|
||||
|
||||
/// The callback when the option gets tapped.
|
||||
final OnWebOrDesktopAttachmentPickerOptionTap? onOptionTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final enabledTypes = controller.filterEnabledTypes(options: options);
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
...options.map((option) {
|
||||
VoidCallback? onOptionTap;
|
||||
if (this.onOptionTap != null) {
|
||||
onOptionTap = () {
|
||||
this.onOptionTap?.call(context, controller, option);
|
||||
};
|
||||
}
|
||||
|
||||
final enabled = enabledTypes.isEmpty ||
|
||||
enabledTypes.any((it) => it == option.type);
|
||||
|
||||
return ListTile(
|
||||
enabled: enabled,
|
||||
leading: option.icon,
|
||||
title: Text(option.title),
|
||||
onTap: onOptionTap,
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bottom sheet widget for the mobile version of the attachment picker.
|
||||
class StreamMobileAttachmentPickerBottomSheet extends StatefulWidget {
|
||||
/// Creates a new instance of [StreamMobileAttachmentPickerBottomSheet].
|
||||
const StreamMobileAttachmentPickerBottomSheet({
|
||||
super.key,
|
||||
required this.options,
|
||||
required this.controller,
|
||||
this.initialOption,
|
||||
this.onSendAttachments,
|
||||
});
|
||||
|
||||
/// The list of options.
|
||||
final Set<AttachmentPickerOption> options;
|
||||
|
||||
/// The initial option to be selected.
|
||||
final AttachmentPickerOption? initialOption;
|
||||
|
||||
/// The controller of the attachment picker.
|
||||
final StreamAttachmentPickerController controller;
|
||||
|
||||
/// The callback when the send button gets tapped.
|
||||
final ValueSetter<List<Attachment>>? onSendAttachments;
|
||||
|
||||
@override
|
||||
State<StreamMobileAttachmentPickerBottomSheet> createState() =>
|
||||
_StreamMobileAttachmentPickerBottomSheetState();
|
||||
}
|
||||
|
||||
class _StreamMobileAttachmentPickerBottomSheetState
|
||||
extends State<StreamMobileAttachmentPickerBottomSheet> {
|
||||
late AttachmentPickerOption _currentOption;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.initialOption == null) {
|
||||
final enabledTypes = widget.controller.filterEnabledTypes(
|
||||
options: widget.options,
|
||||
);
|
||||
if (enabledTypes.isNotEmpty) {
|
||||
_currentOption = widget.options.firstWhere((it) {
|
||||
return it.supportedTypes.contains(enabledTypes.first);
|
||||
});
|
||||
} else {
|
||||
_currentOption = widget.options.first;
|
||||
}
|
||||
} else {
|
||||
_currentOption = widget.initialOption!;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<List<Attachment>>(
|
||||
valueListenable: widget.controller,
|
||||
builder: (context, attachments, _) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
_AttachmentPickerOptions(
|
||||
controller: widget.controller,
|
||||
options: widget.options,
|
||||
currentOption: _currentOption,
|
||||
onSendAttachment: widget.onSendAttachments,
|
||||
onOptionSelected: (option) async {
|
||||
setState(() => _currentOption = option);
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: _currentOption.optionViewBuilder
|
||||
?.call(context, widget.controller) ??
|
||||
const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AttachmentPickerOptions extends StatelessWidget {
|
||||
const _AttachmentPickerOptions({
|
||||
required this.options,
|
||||
required this.currentOption,
|
||||
required this.controller,
|
||||
this.onOptionSelected,
|
||||
this.onSendAttachment,
|
||||
});
|
||||
|
||||
final Iterable<AttachmentPickerOption> options;
|
||||
final AttachmentPickerOption currentOption;
|
||||
final StreamAttachmentPickerController controller;
|
||||
final ValueSetter<AttachmentPickerOption>? onOptionSelected;
|
||||
final ValueSetter<List<Attachment>>? onSendAttachment;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorTheme = StreamChatTheme.of(context).colorTheme;
|
||||
return ValueListenableBuilder<List<Attachment>>(
|
||||
valueListenable: controller,
|
||||
builder: (context, attachments, __) {
|
||||
final enabledTypes = controller.filterEnabledTypes(options: options);
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
...options.map(
|
||||
(option) {
|
||||
final supportedTypes = option.supportedTypes;
|
||||
|
||||
final isSelected = option == currentOption;
|
||||
final isEnabled = enabledTypes.isEmpty ||
|
||||
enabledTypes.any(supportedTypes.contains);
|
||||
|
||||
final color = isSelected
|
||||
? colorTheme.accentPrimary
|
||||
: colorTheme.textLowEmphasis;
|
||||
|
||||
final onPressed =
|
||||
isEnabled ? () => onOptionSelected!(option) : null;
|
||||
|
||||
return IconButton(
|
||||
color: color,
|
||||
disabledColor: colorTheme.disabled,
|
||||
icon: option.icon,
|
||||
onPressed: onPressed,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final isEnabled =
|
||||
onSendAttachment != null && controller.isValueChanged;
|
||||
|
||||
final onPressed = isEnabled
|
||||
? () {
|
||||
onSendAttachment!(attachments);
|
||||
}
|
||||
: null;
|
||||
|
||||
return IconButton(
|
||||
iconSize: 22,
|
||||
color: colorTheme.accentPrimary,
|
||||
disabledColor: colorTheme.disabled,
|
||||
icon: StreamSvgIcon.emptyCircleLeft().toIconThemeSvgIcon(),
|
||||
onPressed: onPressed,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Signature used by [EndOfFrameCallbackWidget.errorBuilder] to create a
|
||||
/// replacement widget to render.
|
||||
typedef EndOfFrameCallbackErrorWidgetBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
Object error,
|
||||
StackTrace? stackTrace,
|
||||
);
|
||||
|
||||
/// Function signature for a callback that is called when the end of the frame
|
||||
/// is reached.
|
||||
typedef EndOfFrameCallback = FutureOr<void> Function(BuildContext context);
|
||||
|
||||
/// A widget that calls the given [callback] when the end of the frame is
|
||||
/// reached.
|
||||
class EndOfFrameCallbackWidget extends StatefulWidget {
|
||||
/// Creates a new instance of [EndOfFrameCallbackWidget].
|
||||
const EndOfFrameCallbackWidget({
|
||||
super.key,
|
||||
required this.onEndOfFrame,
|
||||
this.child,
|
||||
this.errorBuilder,
|
||||
});
|
||||
|
||||
/// The widget below this widget in the tree.
|
||||
final Widget? child;
|
||||
|
||||
/// The callback that is called when the end of the frame is reached.x
|
||||
final EndOfFrameCallback onEndOfFrame;
|
||||
|
||||
/// The callback that will be called if the [onEndOfFrame] callback throws an
|
||||
/// error.
|
||||
final EndOfFrameCallbackErrorWidgetBuilder? errorBuilder;
|
||||
|
||||
@override
|
||||
State<EndOfFrameCallbackWidget> createState() =>
|
||||
_EndOfFrameCallbackWidgetState();
|
||||
}
|
||||
|
||||
class _EndOfFrameCallbackWidgetState extends State<EndOfFrameCallbackWidget> {
|
||||
Object? _error;
|
||||
StackTrace? _stackTrace;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.endOfFrame.then((_) async {
|
||||
if (mounted) {
|
||||
try {
|
||||
await widget.onEndOfFrame(context);
|
||||
} catch (error, stackTrace) {
|
||||
setState(() {
|
||||
_error = error;
|
||||
_stackTrace = stackTrace;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final error = _error;
|
||||
final stackTrace = _stackTrace;
|
||||
|
||||
if (error != null) {
|
||||
final errorBuilder = widget.errorBuilder;
|
||||
if (errorBuilder != null) {
|
||||
return errorBuilder(context, error, stackTrace);
|
||||
}
|
||||
return const Text('An error occurred');
|
||||
}
|
||||
|
||||
// Reset the error and stack trace so that we don't keep showing the same
|
||||
// error over and over.
|
||||
_error = null;
|
||||
_stackTrace = null;
|
||||
|
||||
return widget.child ?? const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
const _kDefaultOptionDrawerShape = RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
);
|
||||
|
||||
/// A widget that will be shown in the attachment picker.
|
||||
/// It can be used to show a custom view for each attachment picker option.
|
||||
class OptionDrawer extends StatelessWidget {
|
||||
/// Creates a widget that will be shown in the attachment picker.
|
||||
const OptionDrawer({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.color,
|
||||
this.elevation = 2,
|
||||
this.margin = EdgeInsets.zero,
|
||||
this.clipBehavior = Clip.hardEdge,
|
||||
this.shape = _kDefaultOptionDrawerShape,
|
||||
this.title,
|
||||
this.actions = const [],
|
||||
});
|
||||
|
||||
/// The widget below this widget in the tree.
|
||||
final Widget child;
|
||||
|
||||
/// The background color of the options card.
|
||||
///
|
||||
/// Defaults to [StreamColorTheme.barsBg].
|
||||
final Color? color;
|
||||
|
||||
/// The elevation of the options card.
|
||||
///
|
||||
/// The default value is 2.
|
||||
final double elevation;
|
||||
|
||||
/// The margin of the options card.
|
||||
///
|
||||
/// The default value is [EdgeInsets.zero].
|
||||
final EdgeInsetsGeometry margin;
|
||||
|
||||
/// The clip behavior of the options card.
|
||||
///
|
||||
/// The default value is [Clip.hardEdge].
|
||||
final Clip clipBehavior;
|
||||
|
||||
/// The shape of the options card.
|
||||
final ShapeBorder shape;
|
||||
|
||||
/// The title of the options card.
|
||||
final Widget? title;
|
||||
|
||||
/// The actions available for the options card.
|
||||
final List<Widget> actions;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorTheme = StreamChatTheme.of(context).colorTheme;
|
||||
|
||||
var height = 20.0;
|
||||
if (title != null || actions.isNotEmpty) {
|
||||
height = 40.0;
|
||||
}
|
||||
|
||||
final leading = title ?? const SizedBox.shrink();
|
||||
|
||||
Widget trailing;
|
||||
if (actions.isNotEmpty) {
|
||||
trailing = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: actions,
|
||||
);
|
||||
} else {
|
||||
trailing = const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Card(
|
||||
elevation: elevation,
|
||||
color: color ?? colorTheme.barsBg,
|
||||
margin: margin,
|
||||
shape: shape,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: height,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: leading),
|
||||
Container(
|
||||
height: 4,
|
||||
width: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: colorTheme.disabled,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
Expanded(child: trailing),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(child: child),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the mobile version of the attachment picker.
|
||||
Widget mobileAttachmentPickerBuilder({
|
||||
required BuildContext context,
|
||||
required StreamAttachmentPickerController controller,
|
||||
Iterable<AttachmentPickerOption>? customOptions,
|
||||
ThumbnailSize attachmentThumbnailSize = const ThumbnailSize(400, 400),
|
||||
ThumbnailFormat attachmentThumbnailFormat = ThumbnailFormat.jpeg,
|
||||
int attachmentThumbnailQuality = 100,
|
||||
double attachmentThumbnailScale = 1,
|
||||
}) {
|
||||
return StreamMobileAttachmentPickerBottomSheet(
|
||||
controller: controller,
|
||||
onSendAttachments: Navigator.of(context).pop,
|
||||
options: {
|
||||
if (customOptions != null) ...customOptions,
|
||||
AttachmentPickerOption(
|
||||
key: 'gallery-picker',
|
||||
icon: StreamSvgIcon.pictures(size: 36).toIconThemeSvgIcon(),
|
||||
supportedTypes: [
|
||||
AttachmentPickerType.images,
|
||||
AttachmentPickerType.videos,
|
||||
],
|
||||
optionViewBuilder: (context, controller) {
|
||||
final selectedIds = controller.value.map((it) => it.id);
|
||||
return StreamGalleryPicker(
|
||||
selectedMediaItems: selectedIds,
|
||||
mediaThumbnailSize: attachmentThumbnailSize,
|
||||
mediaThumbnailFormat: attachmentThumbnailFormat,
|
||||
mediaThumbnailQuality: attachmentThumbnailQuality,
|
||||
mediaThumbnailScale: attachmentThumbnailScale,
|
||||
onMediaItemSelected: (media) async {
|
||||
if (selectedIds.contains(media.id)) {
|
||||
return controller.removeAssetAttachment(media);
|
||||
}
|
||||
return controller.addAssetAttachment(media);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
AttachmentPickerOption(
|
||||
key: 'file-picker',
|
||||
icon: StreamSvgIcon.files(size: 36).toIconThemeSvgIcon(),
|
||||
supportedTypes: [AttachmentPickerType.files],
|
||||
optionViewBuilder: (context, controller) {
|
||||
return StreamFilePicker(
|
||||
onFilePicked: (file) async {
|
||||
if (file != null) await controller.addAttachment(file);
|
||||
return Navigator.pop(context, controller.value);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
AttachmentPickerOption(
|
||||
key: 'image-picker',
|
||||
icon: StreamSvgIcon.camera(size: 36).toIconThemeSvgIcon(),
|
||||
supportedTypes: [AttachmentPickerType.images],
|
||||
optionViewBuilder: (context, controller) {
|
||||
return StreamImagePicker(
|
||||
onImagePicked: (image) async {
|
||||
if (image != null) {
|
||||
await controller.addAttachment(image);
|
||||
}
|
||||
return Navigator.pop(context, controller.value);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
AttachmentPickerOption(
|
||||
key: 'video-picker',
|
||||
icon: StreamSvgIcon.record(size: 36).toIconThemeSvgIcon(),
|
||||
supportedTypes: [AttachmentPickerType.videos],
|
||||
optionViewBuilder: (context, controller) {
|
||||
return StreamVideoPicker(
|
||||
onVideoPicked: (video) async {
|
||||
if (video != null) {
|
||||
await controller.addAttachment(video);
|
||||
}
|
||||
return Navigator.pop(context, controller.value);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns the web or desktop version of the attachment picker.
|
||||
Widget webOrDesktopAttachmentPickerBuilder({
|
||||
required BuildContext context,
|
||||
required StreamAttachmentPickerController controller,
|
||||
Iterable<WebOrDesktopAttachmentPickerOption>? customOptions,
|
||||
ThumbnailSize attachmentThumbnailSize = const ThumbnailSize(400, 400),
|
||||
ThumbnailFormat attachmentThumbnailFormat = ThumbnailFormat.jpeg,
|
||||
int attachmentThumbnailQuality = 100,
|
||||
double attachmentThumbnailScale = 1,
|
||||
}) {
|
||||
return StreamWebOrDesktopAttachmentPickerBottomSheet(
|
||||
controller: controller,
|
||||
options: {
|
||||
if (customOptions != null) ...customOptions,
|
||||
WebOrDesktopAttachmentPickerOption(
|
||||
key: 'image-picker',
|
||||
type: AttachmentPickerType.images,
|
||||
icon: StreamSvgIcon.pictures(size: 36).toIconThemeSvgIcon(),
|
||||
title: 'Upload a photo',
|
||||
),
|
||||
WebOrDesktopAttachmentPickerOption(
|
||||
key: 'video-picker',
|
||||
type: AttachmentPickerType.videos,
|
||||
icon: StreamSvgIcon.record(size: 36).toIconThemeSvgIcon(),
|
||||
title: 'Upload a video',
|
||||
),
|
||||
WebOrDesktopAttachmentPickerOption(
|
||||
key: 'file-picker',
|
||||
type: AttachmentPickerType.files,
|
||||
icon: StreamSvgIcon.files(size: 36).toIconThemeSvgIcon(),
|
||||
title: 'Upload a file',
|
||||
),
|
||||
},
|
||||
onOptionTap: (context, controller, option) async {
|
||||
final attachment = await StreamAttachmentHandler.instance.pickFile(
|
||||
type: option.type.fileType,
|
||||
);
|
||||
if (attachment != null) {
|
||||
await controller.addAttachment(attachment);
|
||||
}
|
||||
return Navigator.pop(context, controller.value);
|
||||
},
|
||||
);
|
||||
}
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/platform_widget_builder/src/platform_widget.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// Shows a modal material design bottom sheet.
|
||||
///
|
||||
/// A modal bottom sheet is an alternative to a menu or a dialog and prevents
|
||||
/// the user from interacting with the rest of the app.
|
||||
///
|
||||
/// A closely related widget is a persistent bottom sheet, which shows
|
||||
/// information that supplements the primary content of the app without
|
||||
/// preventing the use from interacting with the app. Persistent bottom sheets
|
||||
/// can be created and displayed with the [showBottomSheet] function or the
|
||||
/// [ScaffoldState.showBottomSheet] method.
|
||||
///
|
||||
/// The `context` argument is used to look up the [Navigator] and [Theme] for
|
||||
/// the bottom sheet. It is only used when the method is called. Its
|
||||
/// corresponding widget can be safely removed from the tree before the bottom
|
||||
/// sheet is closed.
|
||||
///
|
||||
/// The `isScrollControlled` parameter specifies whether this is a route for
|
||||
/// a bottom sheet that will utilize [DraggableScrollableSheet]. If you wish
|
||||
/// to have a bottom sheet that has a scrollable child such as a [ListView] or
|
||||
/// a [GridView] and have the bottom sheet be draggable, you should set this
|
||||
/// parameter to true.
|
||||
///
|
||||
/// The `useRootNavigator` parameter ensures that the root navigator is used to
|
||||
/// display the [BottomSheet] when set to `true`. This is useful in the case
|
||||
/// that a modal [BottomSheet] needs to be displayed above all other content
|
||||
/// but the caller is inside another [Navigator].
|
||||
///
|
||||
/// The [isDismissible] parameter specifies whether the bottom sheet will be
|
||||
/// dismissed when user taps on the scrim.
|
||||
///
|
||||
/// The [enableDrag] parameter specifies whether the bottom sheet can be
|
||||
/// dragged up and down and dismissed by swiping downwards.
|
||||
///
|
||||
/// The optional [backgroundColor], [elevation], [shape], [clipBehavior],
|
||||
/// [constraints] and [transitionAnimationController]
|
||||
/// parameters can be passed in to customize the appearance and behavior of
|
||||
/// modal bottom sheets (see the documentation for these on [BottomSheet]
|
||||
/// for more details).
|
||||
///
|
||||
/// The [transitionAnimationController] controls the bottom sheet's entrance and
|
||||
/// exit animations if provided.
|
||||
///
|
||||
/// The optional `routeSettings` parameter sets the [RouteSettings]
|
||||
/// of the modal bottom sheet sheet.
|
||||
/// This is particularly useful in the case that a user wants to observe
|
||||
/// [PopupRoute]s within a [NavigatorObserver].
|
||||
///
|
||||
/// Returns a `Future` that resolves to the value (if any) that was passed to
|
||||
/// [Navigator.pop] when the modal bottom sheet was closed.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [BottomSheet], which becomes the parent of the widget returned by the
|
||||
/// function passed as the `builder` argument to [showModalBottomSheet].
|
||||
/// * [showBottomSheet] and [ScaffoldState.showBottomSheet], for showing
|
||||
/// non-modal bottom sheets.
|
||||
/// * [DraggableScrollableSheet], which allows you to create a bottom sheet
|
||||
/// that grows and then becomes scrollable once it reaches its maximum size.
|
||||
/// * <https://material.io/design/components/sheets-bottom.html#modal-bottom-sheet>
|
||||
Future<T?> showStreamAttachmentPickerModalBottomSheet<T>({
|
||||
required BuildContext context,
|
||||
Iterable<AttachmentPickerOption>? customOptions,
|
||||
List<Attachment>? initialAttachments,
|
||||
StreamAttachmentPickerController? controller,
|
||||
Color? backgroundColor,
|
||||
double? elevation,
|
||||
BoxConstraints? constraints,
|
||||
Color? barrierColor,
|
||||
bool isScrollControlled = false,
|
||||
bool useRootNavigator = false,
|
||||
bool isDismissible = true,
|
||||
bool enableDrag = true,
|
||||
RouteSettings? routeSettings,
|
||||
AnimationController? transitionAnimationController,
|
||||
Clip? clipBehavior = Clip.hardEdge,
|
||||
ShapeBorder? shape,
|
||||
ThumbnailSize attachmentThumbnailSize = const ThumbnailSize(400, 400),
|
||||
ThumbnailFormat attachmentThumbnailFormat = ThumbnailFormat.jpeg,
|
||||
int attachmentThumbnailQuality = 100,
|
||||
double attachmentThumbnailScale = 1,
|
||||
}) {
|
||||
final colorTheme = StreamChatTheme.of(context).colorTheme;
|
||||
final color = backgroundColor ?? colorTheme.inputBg;
|
||||
|
||||
return showModalBottomSheet<T>(
|
||||
context: context,
|
||||
backgroundColor: color,
|
||||
elevation: elevation,
|
||||
shape: shape,
|
||||
clipBehavior: clipBehavior,
|
||||
constraints: constraints,
|
||||
barrierColor: barrierColor,
|
||||
isScrollControlled: isScrollControlled,
|
||||
useRootNavigator: useRootNavigator,
|
||||
isDismissible: isDismissible,
|
||||
enableDrag: enableDrag,
|
||||
routeSettings: routeSettings,
|
||||
transitionAnimationController: transitionAnimationController,
|
||||
builder: (BuildContext context) {
|
||||
return StreamPlatformAttachmentPickerBottomSheetBuilder(
|
||||
controller: controller,
|
||||
initialAttachments: initialAttachments,
|
||||
builder: (context, controller, child) {
|
||||
return PlatformWidget(
|
||||
web: (context) {
|
||||
return webOrDesktopAttachmentPickerBuilder.call(
|
||||
context: context,
|
||||
controller: controller,
|
||||
customOptions: customOptions?.map(
|
||||
WebOrDesktopAttachmentPickerOption.fromAttachmentPickerOption,
|
||||
),
|
||||
attachmentThumbnailSize: attachmentThumbnailSize,
|
||||
attachmentThumbnailFormat: attachmentThumbnailFormat,
|
||||
attachmentThumbnailQuality: attachmentThumbnailQuality,
|
||||
attachmentThumbnailScale: attachmentThumbnailScale,
|
||||
);
|
||||
},
|
||||
mobile: (context) {
|
||||
return mobileAttachmentPickerBuilder.call(
|
||||
context: context,
|
||||
controller: controller,
|
||||
customOptions: customOptions,
|
||||
attachmentThumbnailSize: attachmentThumbnailSize,
|
||||
attachmentThumbnailFormat: attachmentThumbnailFormat,
|
||||
attachmentThumbnailQuality: attachmentThumbnailQuality,
|
||||
attachmentThumbnailScale: attachmentThumbnailScale,
|
||||
);
|
||||
},
|
||||
desktop: (context) {
|
||||
return webOrDesktopAttachmentPickerBuilder.call(
|
||||
context: context,
|
||||
controller: controller,
|
||||
customOptions: customOptions?.map(
|
||||
WebOrDesktopAttachmentPickerOption.fromAttachmentPickerOption,
|
||||
),
|
||||
attachmentThumbnailSize: attachmentThumbnailSize,
|
||||
attachmentThumbnailFormat: attachmentThumbnailFormat,
|
||||
attachmentThumbnailQuality: attachmentThumbnailQuality,
|
||||
attachmentThumbnailScale: attachmentThumbnailScale,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds the attachment picker bottom sheet.
|
||||
class StreamPlatformAttachmentPickerBottomSheetBuilder extends StatefulWidget {
|
||||
/// Creates a new instance of the widget.
|
||||
const StreamPlatformAttachmentPickerBottomSheetBuilder({
|
||||
super.key,
|
||||
this.customOptions,
|
||||
this.initialAttachments,
|
||||
this.child,
|
||||
this.controller,
|
||||
required this.builder,
|
||||
});
|
||||
|
||||
/// The child widget.
|
||||
final Widget? child;
|
||||
|
||||
/// Builder for the attachment picker bottom sheet.
|
||||
final Widget Function(
|
||||
BuildContext context,
|
||||
StreamAttachmentPickerController controller,
|
||||
Widget? child,
|
||||
) builder;
|
||||
|
||||
/// The custom options to be displayed in the attachment picker.
|
||||
final List<AttachmentPickerOption>? customOptions;
|
||||
|
||||
/// The initial attachments.
|
||||
final List<Attachment>? initialAttachments;
|
||||
|
||||
/// The controller.
|
||||
final StreamAttachmentPickerController? controller;
|
||||
|
||||
@override
|
||||
State<StreamPlatformAttachmentPickerBottomSheetBuilder> createState() =>
|
||||
_StreamPlatformAttachmentPickerBottomSheetBuilderState();
|
||||
}
|
||||
|
||||
class _StreamPlatformAttachmentPickerBottomSheetBuilderState
|
||||
extends State<StreamPlatformAttachmentPickerBottomSheetBuilder> {
|
||||
late StreamAttachmentPickerController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = widget.controller ??
|
||||
StreamAttachmentPickerController(
|
||||
initialAttachments: widget.initialAttachments,
|
||||
);
|
||||
}
|
||||
|
||||
// Handle a potential change in StreamAttachmentPickerController by properly
|
||||
// disposing of the old one and setting up the new one, if needed.
|
||||
void _updateTextEditingController(
|
||||
StreamAttachmentPickerController? old,
|
||||
StreamAttachmentPickerController? current,
|
||||
) {
|
||||
if ((old == null && current == null) || old == current) return;
|
||||
if (old == null) {
|
||||
_controller.dispose();
|
||||
_controller = current!;
|
||||
} else if (current == null) {
|
||||
_controller = StreamAttachmentPickerController();
|
||||
} else {
|
||||
_controller = current;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(
|
||||
StreamPlatformAttachmentPickerBottomSheetBuilder oldWidget,
|
||||
) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_updateTextEditingController(
|
||||
oldWidget.controller,
|
||||
widget.controller,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (widget.controller == null) _controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return widget.builder(context, _controller, widget.child);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// ignore_for_file: deprecated_member_use_from_same_package
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@template clearInputItemButton}
|
||||
/// Used to clear items from the [MessageInput] field, such as attachments
|
||||
/// or message quotes.
|
||||
/// {@endtemplate}
|
||||
class ClearInputItemButton extends StatelessWidget {
|
||||
/// {@macro clearInputItemButton}
|
||||
const ClearInputItemButton({
|
||||
super.key,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
/// The callback to be performed when the button is tapped or clicked.
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final _streamChatTheme = StreamChatTheme.of(context);
|
||||
return SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: RawMaterialButton(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
elevation: 0,
|
||||
highlightElevation: 0,
|
||||
focusElevation: 0,
|
||||
hoverElevation: 0,
|
||||
onPressed: onTap,
|
||||
fillColor:
|
||||
_streamChatTheme.colorTheme.textHighEmphasis.withOpacity(0.5),
|
||||
child: Center(
|
||||
child: StreamSvgIcon.close(
|
||||
size: 24,
|
||||
color: _streamChatTheme.colorTheme.barsBg,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/misc/stream_svg_icon.dart';
|
||||
|
||||
/// {@template commandButton}
|
||||
/// The button that allows a user to use commands in a chat.
|
||||
/// {@endtemplate}
|
||||
class CommandButton extends StatelessWidget {
|
||||
/// {@macro commandButton}
|
||||
const CommandButton({
|
||||
super.key,
|
||||
required this.color,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
/// The color of the button.
|
||||
final Color color;
|
||||
|
||||
/// The action to perform when the button is pressed or clicked.
|
||||
final VoidCallback onPressed;
|
||||
|
||||
/// Returns a copy of this object with the given fields updated.
|
||||
CommandButton copyWith({
|
||||
Key? key,
|
||||
Color? color,
|
||||
VoidCallback? onPressed,
|
||||
}) {
|
||||
return CommandButton(
|
||||
key: key ?? this.key,
|
||||
color: color ?? this.color,
|
||||
onPressed: onPressed ?? this.onPressed,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: StreamSvgIcon.lightning(
|
||||
color: color,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
height: 24,
|
||||
width: 24,
|
||||
),
|
||||
splashRadius: 24,
|
||||
onPressed: onPressed,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// Button for showing visual component of slow mode.
|
||||
class StreamCountdownButton extends StatelessWidget {
|
||||
/// Constructor for creating [StreamCountdownButton].
|
||||
const StreamCountdownButton({
|
||||
super.key,
|
||||
required this.count,
|
||||
});
|
||||
|
||||
/// The amount of time remaining until the user can send a message again.
|
||||
/// Measured in seconds.
|
||||
final int count;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: StreamChatTheme.of(context).colorTheme.disabled,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: Center(
|
||||
child: Text('$count'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/utils/extensions.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// {@template dmCheckbox}
|
||||
/// Prompts the user to send a reply to a message thread as a DM.
|
||||
/// {@endtemplate}
|
||||
class DmCheckbox extends StatelessWidget {
|
||||
/// {@macro dmCheckbox}
|
||||
const DmCheckbox({
|
||||
super.key,
|
||||
required this.foregroundDecoration,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
required this.crossFadeState,
|
||||
});
|
||||
|
||||
/// The decoration to use for the button's foreground.
|
||||
final BoxDecoration foregroundDecoration;
|
||||
|
||||
/// The color to use for the button.
|
||||
final Color color;
|
||||
|
||||
/// The action to perform when the button is tapped or clicked.
|
||||
final VoidCallback onTap;
|
||||
|
||||
/// The [CrossFadeState] of the animation.
|
||||
final CrossFadeState crossFadeState;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final _streamChatTheme = StreamChatTheme.of(context);
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
height: 16,
|
||||
width: 16,
|
||||
foregroundDecoration: foregroundDecoration,
|
||||
child: Center(
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
color: color,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: AnimatedCrossFade(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
reverseDuration: const Duration(milliseconds: 300),
|
||||
crossFadeState: crossFadeState,
|
||||
firstChild: StreamSvgIcon.check(
|
||||
size: 16,
|
||||
color: _streamChatTheme.colorTheme.barsBg,
|
||||
),
|
||||
secondChild: const SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
context.translations.alsoSendAsDirectMessageLabel,
|
||||
style: _streamChatTheme.textTheme.footnote.copyWith(
|
||||
color:
|
||||
_streamChatTheme.colorTheme.textHighEmphasis.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user