* 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:
Salvatore Giordano
2022-09-30 17:55:57 +02:00
committed by GitHub
co-authored by Sahil Kumar GroovinChip Gordon Hayes Sacha Arbonel
parent 3e1d6e91c7
commit 4f2542291c
542 changed files with 28064 additions and 136563 deletions
@@ -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,
@@ -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;
@@ -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');
}
}
@@ -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,
);
}
}
@@ -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,
);
}
}
@@ -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,
),
),
],
),
);
),
],
),
);
}
}
@@ -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);
},
),
),
],
),
);
}
}
@@ -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,
),
);
}
}
}
@@ -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),
),
);
},
),
),
);
},
);
}
@@ -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,
@@ -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
@@ -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,
),
),
),
],
),
],
);
}
}
@@ -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 {
@@ -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}
/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/channel_header.png)
/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/channel_header_paint.png)
///
/// 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,
),
),
),
],
);
}
}
@@ -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}
/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/channel_preview.png)
/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/channel_preview_paint.png)
///
/// 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,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';
/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/channel_image.png)
@@ -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,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';
/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/channel_image.png)
/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/channel_image_paint.png)
///
/// 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);
/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/channel_list_view.png)
/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/channel_list_view_paint.png)
///
/// 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 youll 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}
/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/channel_preview.png)
/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/channel_preview_paint.png)
///
/// 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,
),
);
}
}
}
@@ -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);
},
);
}
}
@@ -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});
}
@@ -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,
),
),
],
);
},
),
),
],
);
},
),
],
),
),
],
),
);
},
@@ -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,
);
}
@@ -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 ||
@@ -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(
@@ -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,
@@ -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';
@@ -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;
}
@@ -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,
);
}
}
@@ -0,0 +1,4 @@
export 'stream_file_picker.dart';
export 'stream_gallery_picker.dart';
export 'stream_image_picker.dart';
export 'stream_video_picker.dart';
@@ -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,
),
),
),
],
);
},
),
);
}
}
@@ -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';
}
}
}
@@ -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,
),
),
),
],
);
},
),
);
}
}
@@ -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,
),
),
),
],
);
},
),
);
}
}
@@ -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);
},
);
}
@@ -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