From 274ae6b92551611e0e90d40dc32edadc7b67c53a Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 13 May 2026 08:26:39 +0200 Subject: [PATCH 1/4] refactor(ui, localization, sample)!: redesign media viewer Replaces StreamFullScreenMedia / StreamGalleryHeader / StreamGalleryFooter (and the desktop / builder / stub variants, VideoPackage helpers, and the gallery overflow modal) with a single cross-platform StreamMediaGalleryPreview built on the design system's StreamMediaViewer, StreamAppBar, and StreamBottomAppBar. Renames StreamAttachmentPackage to StreamMediaGalleryAttachment and adds a Message.toMediaGalleryAttachments extension. Introduces StreamMediaGallery as the redesigned thumbnail grid companion that the footer's grid sheet now opens. Drops onShowMessage and attachmentActionsModalBuilder from StreamMessageItem and StreamMessageListView, and removes galleryHeaderTheme / galleryFooterTheme / StreamAvatarThemeData from StreamChatThemeData. Adds the photosAndVideosLabel translation across all locales for the thumbnail-grid sheet header. See migrations/redesign/media_viewer.md for the full migration guide. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/docs_screenshots/pubspec.yaml | 5 +- melos.yaml | 5 +- migrations/redesign/README.md | 1 + migrations/redesign/media_viewer.md | 314 ++++++++++++ packages/stream_chat_flutter/CHANGELOG.md | 13 + .../attachment/stream_attachment_package.dart | 18 - .../lib/src/channel/channel_header.dart | 2 +- .../lib/src/channel/channel_list_header.dart | 2 +- .../message_composer_leading.dart | 1 - .../message_composer_recording_locked.dart | 1 - .../stream_chat_component_builders.dart | 4 + .../lib/src/fullscreen_media/fsm_stub.dart | 18 - .../fullscreen_media/full_screen_media.dart | 385 -------------- .../full_screen_media_builder.dart | 87 ---- .../full_screen_media_desktop.dart | 476 ------------------ .../full_screen_media_widget.dart | 10 - .../gallery_navigation_item.dart | 71 --- .../lib/src/gallery/gallery_footer.dart | 281 ----------- .../lib/src/gallery/gallery_header.dart | 163 ------ .../lib/src/keyboard_shortcuts/keysets.dart | 6 +- .../lib/src/localization/translations.dart | 6 + .../media_gallery/stream_media_gallery.dart | 151 ++++++ .../stream_media_gallery_attachment.dart | 71 +++ .../stream_media_gallery_item.dart | 133 +++++ .../stream_media_gallery_preview.dart | 353 +++++++++++++ .../stream_media_gallery_preview_footer.dart | 83 +++ .../stream_media_gallery_preview_header.dart | 59 +++ .../stream_media_gallery_preview_item.dart | 81 +++ .../video_player/stream_video_player.dart | 72 +++ .../stream_video_player_activity_mixin.dart | 91 ++++ .../stream_video_player_default.dart | 159 ++++++ .../stream_video_player_desktop.dart | 146 ++++++ .../stream_attachment_picker.dart | 1 - .../message_list_view/message_list_view.dart | 24 - .../components/stream_message_content.dart | 18 - .../stream_message_attachments.dart | 48 +- .../message_widget/stream_message_item.dart | 27 - .../lib/src/misc/thread_header.dart | 2 +- .../lib/src/theme/avatar_theme.dart | 82 --- .../lib/src/theme/gallery_footer_theme.dart | 234 --------- .../lib/src/theme/stream_chat_theme.dart | 38 +- .../lib/src/theme/themes.dart | 2 - .../lib/stream_chat_flutter.dart | 30 +- packages/stream_chat_flutter/pubspec.yaml | 5 +- .../full_screen_media_test.dart | 125 ----- .../test/src/gallery/gallery_footer_test.dart | 115 ----- .../test/src/gallery/gallery_header_test.dart | 110 ---- .../gallery/goldens/ci/gallery_footer_0.png | Bin 1221 -> 0 bytes .../gallery/goldens/ci/gallery_header_0.png | Bin 1189 -> 0 bytes .../test/src/theme/avatar_theme_test.dart | 55 -- .../src/theme/gallery_footer_theme_test.dart | 153 ------ .../stream_chat_localizations/CHANGELOG.md | 3 + .../example/lib/add_new_lang.dart | 3 + .../lib/src/stream_chat_localizations_ca.dart | 3 + .../lib/src/stream_chat_localizations_de.dart | 3 + .../lib/src/stream_chat_localizations_en.dart | 3 + .../lib/src/stream_chat_localizations_es.dart | 3 + .../lib/src/stream_chat_localizations_fr.dart | 3 + .../lib/src/stream_chat_localizations_hi.dart | 3 + .../lib/src/stream_chat_localizations_it.dart | 3 + .../lib/src/stream_chat_localizations_ja.dart | 3 + .../lib/src/stream_chat_localizations_ko.dart | 3 + .../lib/src/stream_chat_localizations_no.dart | 3 + .../lib/src/stream_chat_localizations_pt.dart | 3 + .../pages/channel_media_display_screen.dart | 98 +--- 65 files changed, 1834 insertions(+), 2640 deletions(-) create mode 100644 migrations/redesign/media_viewer.md delete mode 100644 packages/stream_chat_flutter/lib/src/attachment/stream_attachment_package.dart delete mode 100644 packages/stream_chat_flutter/lib/src/fullscreen_media/fsm_stub.dart delete mode 100644 packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media.dart delete mode 100644 packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_builder.dart delete mode 100644 packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_desktop.dart delete mode 100644 packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_widget.dart delete mode 100644 packages/stream_chat_flutter/lib/src/fullscreen_media/gallery_navigation_item.dart delete mode 100644 packages/stream_chat_flutter/lib/src/gallery/gallery_footer.dart delete mode 100644 packages/stream_chat_flutter/lib/src/gallery/gallery_header.dart create mode 100644 packages/stream_chat_flutter/lib/src/media_gallery/stream_media_gallery.dart create mode 100644 packages/stream_chat_flutter/lib/src/media_gallery/stream_media_gallery_attachment.dart create mode 100644 packages/stream_chat_flutter/lib/src/media_gallery/stream_media_gallery_item.dart create mode 100644 packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview.dart create mode 100644 packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview_footer.dart create mode 100644 packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview_header.dart create mode 100644 packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview_item.dart create mode 100644 packages/stream_chat_flutter/lib/src/media_gallery_preview/video_player/stream_video_player.dart create mode 100644 packages/stream_chat_flutter/lib/src/media_gallery_preview/video_player/stream_video_player_activity_mixin.dart create mode 100644 packages/stream_chat_flutter/lib/src/media_gallery_preview/video_player/stream_video_player_default.dart create mode 100644 packages/stream_chat_flutter/lib/src/media_gallery_preview/video_player/stream_video_player_desktop.dart delete mode 100644 packages/stream_chat_flutter/lib/src/theme/avatar_theme.dart delete mode 100644 packages/stream_chat_flutter/lib/src/theme/gallery_footer_theme.dart delete mode 100644 packages/stream_chat_flutter/test/src/full_screen_media/full_screen_media_test.dart delete mode 100644 packages/stream_chat_flutter/test/src/gallery/gallery_footer_test.dart delete mode 100644 packages/stream_chat_flutter/test/src/gallery/gallery_header_test.dart delete mode 100644 packages/stream_chat_flutter/test/src/gallery/goldens/ci/gallery_footer_0.png delete mode 100644 packages/stream_chat_flutter/test/src/gallery/goldens/ci/gallery_header_0.png delete mode 100644 packages/stream_chat_flutter/test/src/theme/avatar_theme_test.dart delete mode 100644 packages/stream_chat_flutter/test/src/theme/gallery_footer_theme_test.dart diff --git a/docs/docs_screenshots/pubspec.yaml b/docs/docs_screenshots/pubspec.yaml index e3fc5f94e8..88af4fe485 100644 --- a/docs/docs_screenshots/pubspec.yaml +++ b/docs/docs_screenshots/pubspec.yaml @@ -20,10 +20,7 @@ dependencies: record: ^6.2.0 stream_chat_flutter: ^10.0.0-beta.13 stream_core_flutter: - git: - url: https://github.com/GetStream/stream-core-flutter.git - ref: da615a2b232948bf89e46ea3d4c2e99084420544 - path: packages/stream_core_flutter + path: /Users/xsahil03x/StudioProjects/stream-core-flutter/packages/stream_core_flutter dev_dependencies: alchemist: ^0.14.0 diff --git a/melos.yaml b/melos.yaml index 953579af6e..fbe22686ef 100644 --- a/melos.yaml +++ b/melos.yaml @@ -99,10 +99,7 @@ command: svg_icon_widget: ^0.0.1 # TODO: Replace with hosted version before merging PR stream_core_flutter: - git: - url: https://github.com/GetStream/stream-core-flutter.git - ref: da615a2b232948bf89e46ea3d4c2e99084420544 - path: packages/stream_core_flutter + path: /Users/xsahil03x/StudioProjects/stream-core-flutter/packages/stream_core_flutter synchronized: ^3.1.0+1 thumblr: ^0.0.4 url_launcher: ^6.3.0 diff --git a/migrations/redesign/README.md b/migrations/redesign/README.md index cea90eb4de..5f1e3a45f8 100644 --- a/migrations/redesign/README.md +++ b/migrations/redesign/README.md @@ -132,6 +132,7 @@ class MyCustomButton extends StatelessWidget { | Reaction List & Detail Sheet | [reaction_list.md](reaction_list.md) | | Audio Waveform Theme | [audio_theme.md](audio_theme.md) | | Attachments & Polls | [attachments_and_polls.md](attachments_and_polls.md) | +| Media Viewer (Full-screen Media) | [media_viewer.md](media_viewer.md) | | Headers, Icons & Configuration | [headers_and_icons.md](headers_and_icons.md) | | Localizations | [localizations.md](localizations.md) | diff --git a/migrations/redesign/media_viewer.md b/migrations/redesign/media_viewer.md new file mode 100644 index 0000000000..c5713a12ab --- /dev/null +++ b/migrations/redesign/media_viewer.md @@ -0,0 +1,314 @@ +# Media Viewer Migration Guide + +The full-screen media viewer and its thumbnail companion have been redesigned and split into two widgets — both built on the design system's `StreamMediaViewer`, `StreamAppBar`, and `StreamBottomAppBar` chrome. The legacy `StreamFullScreenMedia` (and its desktop/builder/stub variants) and the related `StreamMediaListView` / `StreamImageGallery` flow have all been replaced. + +This guide also covers the related theme cleanups (`StreamGalleryFooterThemeData`, `StreamChatThemeData.galleryHeaderTheme`, `StreamChatThemeData.galleryFooterTheme`, `StreamAvatarThemeData`) and the removal of the `onShowMessage` / `attachmentActionsModalBuilder` callbacks from the message widget and message list view. + +--- + +## Table of Contents + +- [Quick Reference](#quick-reference) +- [Architecture: Props + Component Factory](#architecture-props--component-factory) +- [StreamFullScreenMedia → StreamMediaGalleryPreview](#streamfullscreenmedia--streammediagallerypreview) +- [StreamAttachmentPackage → StreamMediaGalleryAttachment](#streamattachmentpackage--streammediagalleryattachment) +- [StreamGalleryHeader → StreamMediaGalleryPreviewHeader](#streamgalleryheader--streammediagallerypreviewheader) +- [StreamGalleryFooter → StreamMediaGalleryPreviewFooter](#streamgalleryfooter--streammediagallerypreviewfooter) +- [New StreamMediaGallery (Thumbnail Grid)](#new-streammediagallery-thumbnail-grid) +- [Removed message-widget callbacks](#removed-message-widget-callbacks) +- [Removed themes](#removed-themes) +- [Migration Checklist](#migration-checklist) + +--- + +## Quick Reference + +| Old | New | +|-----|-----| +| `StreamFullScreenMedia` / `StreamFullScreenMediaBuilder` / `FullScreenMediaWidget` / `FullScreenMediaDesktop` | `StreamMediaGalleryPreview` (single widget, all platforms) | +| `StreamAttachmentPackage` | `StreamMediaGalleryAttachment` | +| `StreamGalleryHeader` | `StreamMediaGalleryPreviewHeader` | +| `StreamGalleryFooter` | `StreamMediaGalleryPreviewFooter` | +| `VideoPackage` / `DesktopVideoPackage` / `GalleryNavigationItem` | **Removed from the public API** — each preview page now owns its own player state internally | +| *(none)* | `StreamMediaGallery` — **new** thumbnail-grid companion | +| `StreamMessageItem.onShowMessage` / `attachmentActionsModalBuilder` | **Removed** | +| `StreamMessageListView.onShowMessage` / `attachmentActionsModalBuilder` | **Removed** | +| `StreamGalleryFooterThemeData`, `StreamChatThemeData.imageFooterTheme` / `galleryFooterTheme` / `galleryHeaderTheme` | **Removed** | +| `StreamAvatarThemeData` | **Removed** — was unused | +| `Translations.photosAndVideosLabel` | **New** — used by the footer's thumbnail-grid sheet header | + +--- + +## Architecture: Props + Component Factory + +`StreamMediaGalleryPreview` and `StreamMediaGallery` follow the same **Props + Component Factory** pattern used by other redesigned components: + +1. **Public widget** (e.g. `StreamMediaGalleryPreview`) — thin wrapper that reads from `StreamComponentFactory` or falls back to a default implementation. +2. **Props class** (e.g. `StreamMediaGalleryPreviewProps`) — holds all configuration. +3. **Default implementation** (e.g. `DefaultStreamMediaGalleryPreview`) — the built-in rendering. + +Replace either widget globally via `StreamComponentFactory`: + +```dart +StreamComponentFactory( + builders: StreamComponentBuilders( + extensions: streamChatComponentBuilders( + mediaGallery: (context, props) => MyCustomMediaGallery(props: props), + mediaGalleryPreview: (context, props) => MyCustomMediaGalleryPreview(props: props), + ), + ), + child: ..., +) +``` + +--- + +## StreamFullScreenMedia → StreamMediaGalleryPreview + +### Breaking Changes + +- **Renamed** to `StreamMediaGalleryPreview`. There is one widget across mobile, desktop and web — the platform-specific subclasses (`FullScreenMediaDesktop`), the abstract `FullScreenMediaWidget`, and the conditional-import builder `StreamFullScreenMediaBuilder` / `fsm_stub.dart` have all been removed. +- The constructor surface shrunk to the minimum the gallery actually owns. The following parameters are **gone**: + - `userName` — sender metadata is now read from `attachment.message.user` inside the header. + - `sentAt` — read from `attachment.message.createdAt`. + - `onReplyMessage` — no longer surfaced as a header action. + - `onShowMessage` — no longer surfaced as a header action. + - `attachmentActionsModalBuilder` — the more-actions overflow has been removed from the header. +- `mediaAttachmentPackages` → `attachments` (renamed and the element type changed — see [StreamAttachmentPackage → StreamMediaGalleryAttachment](#streamattachmentpackage--streammediagalleryattachment)). +- `startIndex` → `initialIndex` (semantics unchanged). +- `autoplayVideos` is retained. + +### Behaviour built in + +- Tapping the media area toggles the chrome (slides off the top/bottom edges with a fade). +- Keyboard shortcuts: `← / →` advance pages, `esc` pops the route. +- The footer's gallery-grid button now opens a `StreamMediaGallery` inside `showStreamSheet`; tapping a thumbnail pops the sheet and animates the page view to that index. +- The footer's share button downloads the active attachment's bytes via `Dio` and hands them to the system share sheet via `share_plus` (no more platform-specific `dart:io` paths — works on web too). +- Video attachments are played by `StreamVideoPlayer`, which pauses itself when its page is no longer active (see `StreamMediaGalleryPreviewScope`). + +### Migration + +**Before:** +```dart +Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => StreamChannel( + channel: channel, + child: StreamFullScreenMedia( + mediaAttachmentPackages: [ + for (final a in message.attachments) + StreamAttachmentPackage(attachment: a, message: message), + ], + startIndex: 3, + userName: message.user!.name, + autoplayVideos: false, + onReplyMessage: handleReply, + onShowMessage: handleShowInChat, + attachmentActionsModalBuilder: buildActions, + ), + ), + ), +); +``` + +**After:** +```dart +Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => StreamChannel( + channel: channel, + child: StreamMediaGalleryPreview( + attachments: message.toMediaGalleryAttachments( + filter: (a) => + a.type == AttachmentType.image || + a.type == AttachmentType.video || + a.type == AttachmentType.giphy, + ), + initialIndex: 3, + autoplayVideos: false, + ), + ), + ), +); +``` + +If you depended on `onReplyMessage`, `onShowMessage`, or `attachmentActionsModalBuilder`, replace the preview via the component factory (`mediaGalleryPreview: ...`) and surface those actions in your own chrome. + +### Active-page scope + +Per-page widgets (video players, custom items) that need to react when their page goes off-screen read from `StreamMediaGalleryPreviewScope`: + +```dart +final scope = StreamMediaGalleryPreviewScope.of(context); +final isActive = scope.activeIndex.value == myPageIndex; +``` + +The bundled `StreamVideoPlayer` already uses this — it pauses when inactive and resumes when the user swipes back, preserving prior playing/paused state. + +--- + +## StreamAttachmentPackage → StreamMediaGalleryAttachment + +### Breaking Changes + +`StreamAttachmentPackage` has been renamed to `StreamMediaGalleryAttachment` and moved into `media_gallery/`. The shape (an `Attachment` + its parent `Message`) is unchanged. + +A new convenience extension `MessageMediaGalleryX.toMediaGalleryAttachments({filter})` produces a `List` from a `Message`: + +```dart +final attachments = message.toMediaGalleryAttachments( + filter: (a) => + a.type == AttachmentType.image || + a.type == AttachmentType.video || + a.type == AttachmentType.giphy, +); +``` + +--- + +## StreamGalleryHeader → StreamMediaGalleryPreviewHeader + +### Breaking Changes + +- **Renamed** to `StreamMediaGalleryPreviewHeader`. +- Rebuilt on top of `StreamAppBar`; the back affordance is auto-implied from the route. +- The constructor surface shrunk to a `title` and `subtitle` widget pair. The following parameters are **gone**: + - `userName`, `sentAt` — render whatever you want in the `title` / `subtitle` slots. + - `message`, `attachment` — the header no longer reads from the active package; the parent passes presentation-ready widgets in. + - `onShowMessage`, `onBackPressed` — the more-actions overflow has been removed; back is handled by `StreamAppBar`. + +### Migration + +**Before:** +```dart +StreamGalleryHeader( + userName: message.user!.name, + sentAt: 'Sent ${formatted}', + message: message, + attachment: attachment, + onShowMessage: handleShowInChat, + onBackPressed: () => Navigator.of(context).pop(), +) +``` + +**After:** +```dart +StreamMediaGalleryPreviewHeader( + title: Text(message.user?.name ?? ''), + subtitle: Text( + context.translations.sentAtText( + date: message.createdAt, + time: message.createdAt, + ), + ), +) +``` + +--- + +## StreamGalleryFooter → StreamMediaGalleryPreviewFooter + +### Breaking Changes + +- **Renamed** to `StreamMediaGalleryPreviewFooter`. +- Rebuilt on top of `StreamBottomAppBar` — the underlying chrome and theming flow through `StreamBottomAppBarThemeData`. +- The constructor surface shrunk to `title` + two callbacks. The following parameters are **gone**: + - `currentPage`, `totalPages` — render the page counter as a `Text` in the `title` slot using `Translations.galleryPaginationText`. + - `mediaSelectedCallBack` — the gallery-grid action no longer hands a tile index back through a callback. The parent owns the bottom-sheet flow and decides what to do when a tile is tapped. + - `userName`, `sentAt`, `message`, `mediaAttachmentPackages` — not needed; the parent passes presentation-ready widgets and intent callbacks. + +### Migration + +**Before:** +```dart +StreamGalleryFooter( + currentPage: currentPage, + totalPages: totalPages, + mediaAttachmentPackages: packages, + mediaSelectedCallBack: (index, _) { + Navigator.pop(context); + _pageController.jumpToPage(index); + }, +) +``` + +**After:** +```dart +StreamMediaGalleryPreviewFooter( + title: Text( + context.translations.galleryPaginationText( + currentPage: currentPage, + totalPages: totalPages, + ), + ), + onSharePressed: shareCurrentAttachment, + onGalleryPressed: openThumbnailSheet, +) +``` + +> **Note:** The footer is wired up automatically when you use `StreamMediaGalleryPreview`. It's only relevant if you compose your own chrome on top of the design-system primitives. + +--- + +## New StreamMediaGallery (Thumbnail Grid) + +`StreamMediaGallery` is a **new** widget — a 3-up grid of `StreamMediaGalleryItem` tiles, designed to be the thumbnail companion to `StreamMediaGalleryPreview`. The footer's gallery-grid button now opens this widget in a `showStreamSheet`. + +Each tile renders the sender's avatar plus a media badge for videos (`StreamMediaBadge`). Inter-cell gutters and the outer padding default to `spacing.xxxs` (2 logical pixels) for a uniform mosaic. + +```dart +StreamMediaGallery( + attachments: message.toMediaGalleryAttachments(filter: isMedia), + onItemTap: (index) => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => StreamMediaGalleryPreview( + attachments: attachments, + initialIndex: index, + ), + ), + ), +) +``` + +Replace globally via `mediaGallery: ...` on `streamChatComponentBuilders`. + +--- + +## Removed message-widget callbacks + +`onShowMessage` and `attachmentActionsModalBuilder` have been removed from both `StreamMessageItem` (and its props) and `StreamMessageListView`. They were forwarded into the now-removed gallery overflow header, so they no longer have a destination. + +If you relied on either callback, replace the gallery preview via the component factory (`mediaGalleryPreview: ...`) and surface those actions in your own chrome. + +`StreamMessageItem.onReplyTap` and `StreamMessageListView.onReplyTap` are unchanged. + +--- + +## Removed themes + +The following theme types and `StreamChatThemeData` fields have been removed: + +| Removed | Notes | +|---------|-------| +| `StreamGalleryFooterThemeData` | The new footer is themed via `StreamBottomAppBarThemeData` from the design system. | +| `StreamChatThemeData.galleryFooterTheme` / `imageFooterTheme` (named param) | Footer themeing now flows through `StreamBottomAppBarThemeData`. | +| `StreamChatThemeData.galleryHeaderTheme` | Header themeing now flows through `StreamAppBarThemeData`. | +| `StreamAvatarThemeData` | Was unused after the avatar redesign. Use `StreamUserAvatarThemeData` from `stream_core_flutter` to theme avatars globally. | + +The full-screen page background and chrome bands themselves are themed via `StreamMediaViewerThemeData` (from `stream_core_flutter`, re-exported here). + +--- + +## Migration Checklist + +- [ ] Replace `StreamFullScreenMedia` / `StreamFullScreenMediaBuilder` with `StreamMediaGalleryPreview`. +- [ ] Replace `StreamAttachmentPackage` with `StreamMediaGalleryAttachment` (or `Message.toMediaGalleryAttachments(...)`). +- [ ] Rename `startIndex` → `initialIndex`, `mediaAttachmentPackages` → `attachments`. +- [ ] Drop `userName`, `sentAt`, `onReplyMessage`, `onShowMessage`, `attachmentActionsModalBuilder` from preview usage. +- [ ] Replace `StreamGalleryHeader` with `StreamMediaGalleryPreviewHeader`; render sender / timestamp in the `title` / `subtitle` slots. +- [ ] Replace `StreamGalleryFooter` with `StreamMediaGalleryPreviewFooter`; render the page counter via `Translations.galleryPaginationText` in the `title` slot. +- [ ] Drop usages of `VideoPackage`, `DesktopVideoPackage`, `GalleryNavigationItem`, `FullScreenMediaWidget`, `FullScreenMediaDesktop`. +- [ ] Drop `StreamMessageItem.onShowMessage` / `attachmentActionsModalBuilder`, `StreamMessageListView.onShowMessage` / `attachmentActionsModalBuilder` from any constructors. +- [ ] Drop `StreamChatThemeData.galleryHeaderTheme`, `galleryFooterTheme` and `imageFooterTheme:` named-parameter usages. +- [ ] Drop `StreamAvatarThemeData` references. +- [ ] Optionally adopt `StreamMediaGallery` as a thumbnail grid for channel-level media listings. diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 3ef5e5b0b7..e256d3b972 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -38,6 +38,15 @@ - `streamChatComponentBuilders(messageWidget: ...)` → `messageItem: ...`. - `StreamMessageContent(annotation: ..., metadata: ...)` → `header: ..., footer: ...`. See [`migrations/redesign/message_widget.md`](../../migrations/redesign/message_widget.md). +- Redesigned the full-screen media viewer: + - Replaced `StreamFullScreenMedia` (and its `StreamFullScreenMediaBuilder` / `FullScreenMediaWidget` / `FullScreenMediaDesktop` / `fsm_stub` variants) with a single cross-platform `StreamMediaGalleryPreview`. Renamed `mediaAttachmentPackages` → `attachments`, `startIndex` → `initialIndex`; dropped `userName`, `sentAt`, `onReplyMessage`, `onShowMessage` and `attachmentActionsModalBuilder` from its surface. + - Renamed `StreamGalleryHeader` → `StreamMediaGalleryPreviewHeader` and `StreamGalleryFooter` → `StreamMediaGalleryPreviewFooter`, rebuilt on `StreamAppBar` / `StreamBottomAppBar`. Both shrank to a `title` (+ optional `subtitle`) and minimal action callbacks; the more-actions overflow header was removed. + - Renamed `StreamAttachmentPackage` → `StreamMediaGalleryAttachment` and added a `Message.toMediaGalleryAttachments({filter})` extension. + - Removed `VideoPackage`, `DesktopVideoPackage` and `GalleryNavigationItem` from the public API — each preview page now owns its own player state internally. + - Removed `StreamMessageItem.onShowMessage` / `attachmentActionsModalBuilder` and the matching `StreamMessageListView` / `StreamMessageContent` props; those callbacks no longer have a destination after the gallery overflow was removed. + - Removed `StreamChatThemeData.galleryHeaderTheme`, `StreamChatThemeData.galleryFooterTheme` (and the `imageFooterTheme:` constructor parameter) and the `StreamGalleryFooterThemeData` class. Header / footer chrome now flows through `StreamAppBarThemeData` / `StreamBottomAppBarThemeData`. + - Removed the unused `StreamAvatarThemeData`. + See [`migrations/redesign/media_viewer.md`](../../migrations/redesign/media_viewer.md). ✅ Added @@ -45,6 +54,10 @@ - Redesigned `StreamSystemMessage` / `StreamModeratedMessage` with a pill-shaped style and visual customisation props. - Added visual customisation props to `ThreadSeparator` and `UnreadMessagesSeparator`. - Added `StreamUnsupportedAttachment` and `UnsupportedAttachmentBuilder` for unrecognised attachment types. +- Added `StreamMediaGallery` — a 3-up thumbnail grid that pairs with `StreamMediaGalleryPreview`. Each tile surfaces the sender's avatar (`StreamUserAvatar`) plus a video duration badge for video attachments. Used by `StreamMediaGalleryPreviewFooter`'s gallery-grid sheet and ready to drop into channel-level media listings. Customisable via `streamChatComponentBuilders(mediaGallery: ...)`. +- Added `StreamMediaGalleryPreview` — the redesigned full-screen swipeable viewer. Built on the design system's `StreamMediaViewer`, `StreamAppBar` and `StreamBottomAppBar`. Customisable via `streamChatComponentBuilders(mediaGalleryPreview: ...)`. Exposes `StreamMediaGalleryPreviewScope` so per-page widgets (e.g. videos) can react to the active page. +- Added `StreamVideoPlayer` — the platform-aware video backend used by `StreamMediaGalleryPreview`. Pauses itself when its page is no longer active and resumes on return. +- Added `Translations.photosAndVideosLabel` — used by the footer's thumbnail-grid sheet header. - Added `StreamQuotedMessage` and `StreamQuotedMessageThemeData` for the quoted message preview. - `MessagePreviewFormatter` now renders `AttachmentType.urlPreview` messages with a link icon and caption / OG title / `linkAttachmentText` fallback. - Added `StreamPollCardStyle`, `StreamPollQuestionStyle` and `StreamPollOptionVotesStyle` shared style classes for the poll sheets. diff --git a/packages/stream_chat_flutter/lib/src/attachment/stream_attachment_package.dart b/packages/stream_chat_flutter/lib/src/attachment/stream_attachment_package.dart deleted file mode 100644 index e1e0df0e83..0000000000 --- a/packages/stream_chat_flutter/lib/src/attachment/stream_attachment_package.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// The [StreamAttachmentPackage] class is basically meant to wrap -/// individual attachments with their corresponding message -class StreamAttachmentPackage { - /// Default constructor to prepare an [StreamAttachmentPackage] object - StreamAttachmentPackage({ - required this.attachment, - required this.message, - }); - - /// This is the individual attachment - final Attachment attachment; - - /// This is the message that the attachment belongs to - /// The message object may have attachment(s) other than the one packaged - final Message message; -} diff --git a/packages/stream_chat_flutter/lib/src/channel/channel_header.dart b/packages/stream_chat_flutter/lib/src/channel/channel_header.dart index 67648e1291..3d1fb1e3e5 100644 --- a/packages/stream_chat_flutter/lib/src/channel/channel_header.dart +++ b/packages/stream_chat_flutter/lib/src/channel/channel_header.dart @@ -130,7 +130,7 @@ class StreamChannelHeader extends StatelessWidget implements PreferredSizeWidget final StreamAppBarStyle? style; @override - Size get preferredSize => const Size.fromHeight(kStreamHeaderHeight); + Size get preferredSize => const Size.fromHeight(kStreamToolbarHeight); @override Widget build(BuildContext context) { diff --git a/packages/stream_chat_flutter/lib/src/channel/channel_list_header.dart b/packages/stream_chat_flutter/lib/src/channel/channel_list_header.dart index cd09b1f835..e4ed899c7b 100644 --- a/packages/stream_chat_flutter/lib/src/channel/channel_list_header.dart +++ b/packages/stream_chat_flutter/lib/src/channel/channel_list_header.dart @@ -118,7 +118,7 @@ class StreamChannelListHeader extends StatelessWidget implements PreferredSizeWi final StreamAppBarStyle? style; @override - Size get preferredSize => const Size.fromHeight(kStreamHeaderHeight); + Size get preferredSize => const Size.fromHeight(kStreamToolbarHeight); @override Widget build(BuildContext context) { diff --git a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_leading.dart b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_leading.dart index df7deccdd2..49d6a0c0b6 100644 --- a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_leading.dart +++ b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_leading.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:stream_core_flutter/stream_core_flutter.dart'; /// A widget that shows the leading of the message composer. /// Uses the factory to show custom components or the default implementation. diff --git a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_recording_locked.dart b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_recording_locked.dart index 59661d994d..e1e34f2798 100644 --- a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_recording_locked.dart +++ b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_recording_locked.dart @@ -3,7 +3,6 @@ import 'package:stream_chat_flutter/src/audio/audio_playlist_controller.dart'; import 'package:stream_chat_flutter/src/audio/audio_playlist_state.dart'; import 'package:stream_chat_flutter/src/audio/audio_sampling.dart' as sampling; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:stream_core_flutter/stream_core_flutter.dart'; const _kDefaultWaveformHeight = 20.0; const _kDefaultWaveformLimit = 35; diff --git a/packages/stream_chat_flutter/lib/src/components/stream_chat_component_builders.dart b/packages/stream_chat_flutter/lib/src/components/stream_chat_component_builders.dart index a25c7a3635..1219439fe1 100644 --- a/packages/stream_chat_flutter/lib/src/components/stream_chat_component_builders.dart +++ b/packages/stream_chat_flutter/lib/src/components/stream_chat_component_builders.dart @@ -25,6 +25,8 @@ Iterable> streamChatComponentBuilders({ StreamComponentBuilder? pollAttachment, StreamComponentBuilder? quotedMessage, StreamComponentBuilder? unsupportedAttachment, + StreamComponentBuilder? mediaGallery, + StreamComponentBuilder? mediaGalleryPreview, }) { final builders = [ if (channelListItem != null) StreamComponentBuilderExtension(builder: channelListItem), @@ -49,6 +51,8 @@ Iterable> streamChatComponentBuilders({ if (pollAttachment != null) StreamComponentBuilderExtension(builder: pollAttachment), if (quotedMessage != null) StreamComponentBuilderExtension(builder: quotedMessage), if (unsupportedAttachment != null) StreamComponentBuilderExtension(builder: unsupportedAttachment), + if (mediaGallery != null) StreamComponentBuilderExtension(builder: mediaGallery), + if (mediaGalleryPreview != null) StreamComponentBuilderExtension(builder: mediaGalleryPreview), ]; return builders; diff --git a/packages/stream_chat_flutter/lib/src/fullscreen_media/fsm_stub.dart b/packages/stream_chat_flutter/lib/src/fullscreen_media/fsm_stub.dart deleted file mode 100644 index 1dd6ac10c3..0000000000 --- a/packages/stream_chat_flutter/lib/src/fullscreen_media/fsm_stub.dart +++ /dev/null @@ -1,18 +0,0 @@ -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 mediaAttachmentPackages, - required int startIndex, - required String userName, - ShowMessageCallback? onShowMessage, - ReplyMessageCallback? onReplyMessage, - AttachmentActionsBuilder? attachmentActionsModalBuilder, - bool? autoplayVideos, -}) => throw UnsupportedError('Cannot create FullScreenMedia'); diff --git a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media.dart b/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media.dart deleted file mode 100644 index 266e9086b5..0000000000 --- a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media.dart +++ /dev/null @@ -1,385 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:chewie/chewie.dart'; -import 'package:flutter/material.dart'; -import 'package:photo_view/photo_view.dart'; -import 'package:stream_chat_flutter/src/fullscreen_media/full_screen_media_widget.dart'; -import 'package:stream_chat_flutter/src/fullscreen_media/gallery_navigation_item.dart'; -import 'package:stream_chat_flutter/src/misc/empty_widget.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 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 { - late final PageController _pageController; - - late final _currentPage = ValueNotifier(widget.startIndex); - late final _isDisplayingDetail = ValueNotifier(true); - - void switchDisplayingDetail() { - _isDisplayingDetail.value = !_isDisplayingDetail.value; - } - - final videoPackages = {}; - - @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 != AttachmentType.video) continue; - final package = VideoPackage(attachment, showControls: true); - videoPackages[attachment.id] = package; - } - initializePlayers(); - } - - Future 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 == AttachmentType.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( - valueListenable: _currentPage, - builder: (context, currentPage, child) { - final _currentAttachmentPackage = widget.mediaAttachmentPackages[currentPage]; - final _currentMessage = _currentAttachmentPackage.message; - final _currentAttachment = _currentAttachmentPackage.attachment; - return Stack( - children: [ - child!, - ValueListenableBuilder( - 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 + kStreamHeaderHeight), - start: 0, - end: 0, - height: topPadding + kStreamHeaderHeight, - child: StreamGalleryHeader( - userName: widget.userName, - sentAt: context.translations.sentAtText( - date: _currentAttachmentPackage.message.createdAt, - time: _currentAttachmentPackage.message.createdAt, - ), - 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( - 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 + kStreamHeaderHeight), - start: 0, - end: 0, - height: bottomPadding + kStreamHeaderHeight, - 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 == AttachmentType.video) { - final controller = videoPackages[currentAttachment.id]!; - controller._chewieController?.play(); - } - }, - itemBuilder: (context, index) { - final currentAttachmentPackage = widget.mediaAttachmentPackages[index]; - final attachment = currentAttachmentPackage.attachment; - return ValueListenableBuilder( - valueListenable: _isDisplayingDetail, - builder: (context, isDisplayingDetail, child) { - final padding = MediaQuery.paddingOf(context); - - return AnimatedContainer( - duration: kThemeChangeDuration, - color: switch (isDisplayingDetail) { - true => context.streamColorScheme.backgroundApp, - false => StreamColors.black, - }, - padding: EdgeInsetsDirectional.only( - top: padding.top + kStreamHeaderHeight, - bottom: padding.bottom + kStreamHeaderHeight, - ), - child: child, - ); - }, - child: Builder( - builder: (context) { - if (attachment.type == AttachmentType.image || attachment.type == AttachmentType.giphy) { - return PhotoView.customChild( - maxScale: PhotoViewComputedScale.covered, - minScale: PhotoViewComputedScale.contained, - backgroundDecoration: const BoxDecoration( - color: Colors.transparent, - ), - child: StreamMediaAttachmentThumbnail( - media: attachment, - width: double.infinity, - height: double.infinity, - ), - ); - } else if (attachment.type == AttachmentType.video) { - final controller = videoPackages[attachment.id]!; - if (!controller.initialized) { - return const Center( - child: CircularProgressIndicator.adaptive(), - ); - } - - return Chewie( - controller: controller.chewieController!, - ); - } - - return const Empty(); - }, - ), - ); - }, - ), - ), - ), - ), - ); - } -} - -/// 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.networkUrl( - Uri.parse(_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 initialize() { - return _videoPlayerController.initialize().then((_) { - _chewieController = ChewieController( - videoPlayerController: _videoPlayerController, - autoInitialize: _autoInitialize, - showControls: _showControls, - showOptions: false, - 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 dispose() { - _chewieController?.dispose(); - return _videoPlayerController.dispose(); - } -} diff --git a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_builder.dart b/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_builder.dart deleted file mode 100644 index 1609445cd8..0000000000 --- a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_builder.dart +++ /dev/null @@ -1,87 +0,0 @@ -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 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, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_desktop.dart b/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_desktop.dart deleted file mode 100644 index 2cf3b811e2..0000000000 --- a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_desktop.dart +++ /dev/null @@ -1,476 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:media_kit/media_kit.dart'; -import 'package:media_kit_video/media_kit_video.dart'; -import 'package:photo_view/photo_view.dart'; -import 'package:stream_chat_flutter/src/context_menu/context_menu.dart'; -import 'package:stream_chat_flutter/src/context_menu/context_menu_region.dart'; -import 'package:stream_chat_flutter/src/fullscreen_media/full_screen_media_widget.dart'; -import 'package:stream_chat_flutter/src/fullscreen_media/gallery_navigation_item.dart'; -import 'package:stream_chat_flutter/src/misc/empty_widget.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 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 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 { - late final PageController _pageController; - - late final _currentPage = ValueNotifier(widget.startIndex); - late final _isDisplayingDetail = ValueNotifier(true); - - void switchDisplayingDetail() { - _isDisplayingDetail.value = !_isDisplayingDetail.value; - } - - final videoPackages = {}; - - @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 != AttachmentType.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: [ - ContextMenuRegion( - menuBuilder: (context, anchor) { - final index = _currentPage.value; - final mediaAttachment = widget.mediaAttachmentPackages[index]; - return ContextMenu( - anchor: anchor, - menuItems: [ - _DownloadMenuItem( - mediaAttachment: mediaAttachment.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: Icon( - context.streamIcons.xmark, - size: 30, - ), - ), - ), - ), - ], - ); - } - - Widget _buildPageView() { - return ValueListenableBuilder( - valueListenable: _currentPage, - builder: (context, currentPage, child) { - final _currentAttachmentPackage = widget.mediaAttachmentPackages[currentPage]; - final _currentMessage = _currentAttachmentPackage.message; - final _currentAttachment = _currentAttachmentPackage.attachment; - return Stack( - children: [ - child!, - ValueListenableBuilder( - 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 + kStreamHeaderHeight), - start: 0, - end: 0, - height: topPadding + kStreamHeaderHeight, - child: StreamGalleryHeader( - userName: widget.userName, - sentAt: context.translations.sentAtText( - date: _currentAttachmentPackage.message.createdAt, - time: _currentAttachmentPackage.message.createdAt, - ), - message: _currentMessage, - attachment: _currentAttachment, - onShowMessage: () { - widget.onShowMessage?.call( - _currentMessage, - StreamChannel.of(context).channel, - ); - }, - attachmentActionsModalBuilder: widget.attachmentActionsModalBuilder, - ), - ); - }, - ), - if (!_currentMessage.isEphemeral) - ValueListenableBuilder( - 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 + kStreamHeaderHeight), - start: 0, - end: 0, - height: bottomPadding + kStreamHeaderHeight, - 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 == AttachmentType.video) { - final package = videoPackages[currentAttachment.id]!; - package.player.play(); - } - }, - itemBuilder: (context, index) { - final currentAttachmentPackage = widget.mediaAttachmentPackages[index]; - final attachment = currentAttachmentPackage.attachment; - - return ValueListenableBuilder( - valueListenable: _isDisplayingDetail, - builder: (context, isDisplayingDetail, child) { - final padding = MediaQuery.paddingOf(context); - - return AnimatedContainer( - duration: kThemeChangeDuration, - color: switch (isDisplayingDetail) { - true => context.streamColorScheme.backgroundApp, - false => StreamColors.black, - }, - padding: EdgeInsetsDirectional.only( - top: padding.top + kStreamHeaderHeight, - bottom: padding.bottom + kStreamHeaderHeight, - ), - child: child, - ); - }, - child: Builder( - builder: (context) { - if (attachment.type == AttachmentType.image || attachment.type == AttachmentType.giphy) { - return PhotoView.customChild( - maxScale: PhotoViewComputedScale.covered, - minScale: PhotoViewComputedScale.contained, - backgroundDecoration: const BoxDecoration( - color: Colors.transparent, - ), - child: StreamMediaAttachmentThumbnail( - media: attachment, - width: double.infinity, - height: double.infinity, - ), - ); - } else if (attachment.type == AttachmentType.video) { - final package = videoPackages[attachment.id]!; - if (package.attachment.assetUrl != null) { - package.player.open( - Playlist( - [ - Media(package.attachment.assetUrl!), - ], - ), - play: widget.autoplayVideos, - ); - } - - return ContextMenuRegion( - menuBuilder: (_, anchor) => ContextMenu( - anchor: anchor, - menuItems: [ - _DownloadMenuItem( - mediaAttachment: currentAttachmentPackage.attachment, - ), - ], - ), - child: Video( - controller: package.controller, - ), - ); - } - - return const Empty(); - }, - ), - ); - }, - ), - ), - ), - ); - } -} - -/// {@template streamDownloadMenuItem} -/// A context menu item for downloading an attachment from a message. -/// -/// This widget displays a download option in a context menu, allowing users to -/// download the attachment associated with a message. -/// -/// It uses [StreamContextMenuAction] to stay consistent with message actions. -/// {@endtemplate} -class _DownloadMenuItem extends StatelessWidget { - /// {@macro streamDownloadMenuItem} - const _DownloadMenuItem({ - required this.mediaAttachment, - }); - - /// The attachment package containing the message and attachment to download. - final Attachment mediaAttachment; - - @override - Widget build(BuildContext context) { - final icons = context.streamIcons; - return StreamContextMenuAction( - leading: Icon(icons.arrowDown), - label: Text(context.translations.downloadLabel), - onTap: () { - final handler = StreamAttachmentHandler.instance; - return handler.downloadAttachment(mediaAttachment).ignore(); - }, - ); - } -} - -/// Class for packaging up things required for videos -class DesktopVideoPackage { - /// Constructor for creating [VideoPackage] - factory DesktopVideoPackage( - Attachment attachment, { - bool showControls = true, - }) { - final player = Player(); - final controller = VideoController(player); - return DesktopVideoPackage._internal( - attachment, - player, - controller, - showControls, - ); - } - - DesktopVideoPackage._internal( - this.attachment, - this.player, - this.controller, - this.showControls, - ); - - /// The video attachment to play. - final Attachment attachment; - - /// The VLC player to use. - final Player player; - - /// The VLC video controller to use. - final VideoController controller; - - /// 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 packages; - final bool autoStart; - - @override - Widget build(BuildContext context) { - final _media = []; - for (final package in packages) { - if (package.attachment.assetUrl != null) { - _media.add(Media(package.attachment.assetUrl!)); - } - } - packages.first.player.open( - Playlist( - _media, - ), - play: autoStart, - ); - return Video( - controller: packages.first.controller, - fit: BoxFit.cover, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_widget.dart b/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_widget.dart deleted file mode 100644 index adcac9d3ff..0000000000 --- a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_widget.dart +++ /dev/null @@ -1,10 +0,0 @@ -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}); -} diff --git a/packages/stream_chat_flutter/lib/src/fullscreen_media/gallery_navigation_item.dart b/packages/stream_chat_flutter/lib/src/fullscreen_media/gallery_navigation_item.dart deleted file mode 100644 index 5d1af701b4..0000000000 --- a/packages/stream_chat_flutter/lib/src/fullscreen_media/gallery_navigation_item.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/platform_widget_builder/src/platform_widget_builder.dart'; - -/// 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 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( - 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, - ), - ), - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/gallery/gallery_footer.dart b/packages/stream_chat_flutter/lib/src/gallery/gallery_footer.dart deleted file mode 100644 index 77dc4212b3..0000000000 --- a/packages/stream_chat_flutter/lib/src/gallery/gallery_footer.dart +++ /dev/null @@ -1,281 +0,0 @@ -import 'dart:io'; - -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/stream_chat_flutter.dart'; - -/// {@template streamGalleryFooter} -/// Footer widget for media display -/// {@endtemplate} -class StreamGalleryFooter extends StatefulWidget implements PreferredSizeWidget { - /// {@macro streamGalleryFooter} - const StreamGalleryFooter({ - super.key, - this.onBackPressed, - this.onTitleTap, - this.onImageTap, - this.currentPage = 0, - this.totalPages = 0, - required this.mediaAttachmentPackages, - this.mediaSelectedCallBack, - this.backgroundColor, - }); - - /// Callback to call when pressing the back button. - /// By default it calls [Navigator.pop] - final VoidCallback? onBackPressed; - - /// Callback to call when the header is tapped. - final VoidCallback? onTitleTap; - - /// Callback to call when the image is tapped. - final VoidCallback? onImageTap; - - /// Stores the current index of media shown - final int currentPage; - - /// Total number of pages of media - final int totalPages; - - /// All attachments to show - final List mediaAttachmentPackages; - - /// Callback when media is selected - final ValueChanged? mediaSelectedCallBack; - - /// The background color of this [StreamGalleryFooter]. - final Color? backgroundColor; - - @override - _StreamGalleryFooterState createState() => _StreamGalleryFooterState(); - - @override - Size get preferredSize => const Size.fromHeight(kStreamHeaderHeight); -} - -class _StreamGalleryFooterState extends State { - final shareButtonKey = GlobalKey(); - - @override - Widget build(BuildContext context) { - const showShareButton = !kIsWeb; - final mediaQueryData = MediaQuery.of(context); - final galleryFooterThemeData = StreamGalleryFooterTheme.of(context); - return SizedBox.fromSize( - size: Size( - mediaQueryData.size.width, - mediaQueryData.padding.bottom + widget.preferredSize.height, - ), - child: MediaQuery.removePadding( - context: context, - removeTop: true, - child: BottomAppBar( - surfaceTintColor: widget.backgroundColor ?? galleryFooterThemeData.backgroundColor, - color: widget.backgroundColor ?? galleryFooterThemeData.backgroundColor, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - if (!showShareButton) - const SizedBox(width: 48) - else - IconButton( - key: shareButtonKey, - icon: Icon( - context.streamIcons.export, - size: 24, - color: galleryFooterThemeData.shareIconColor, - ), - onPressed: () async { - final attachment = widget.mediaAttachmentPackages[widget.currentPage].attachment; - final url = attachment.imageUrl ?? attachment.assetUrl ?? attachment.thumbUrl!; - final type = attachment.type == AttachmentType.image ? 'jpg' : url.split('?').first.split('.').last; - final request = await HttpClient().getUrl(Uri.parse(url)); - final response = await request.close(); - final bytes = await consolidateHttpClientResponseBytes(response); - final tmpPath = await getTemporaryDirectory(); - final filePath = '${tmpPath.path}/${attachment.id}.$type'; - final file = File(filePath); - await file.writeAsBytes(bytes); - final box = shareButtonKey.currentContext?.findRenderObject(); - final size = shareButtonKey.currentContext?.size; - - final position = (box! as RenderBox).localToGlobal(Offset.zero); - - await SharePlus.instance.share( - ShareParams( - files: [XFile(filePath)], - sharePositionOrigin: Rect.fromLTWH( - position.dx, - position.dy, - size?.width ?? 50, - (size?.height ?? 2) / 2, - ), - ), - ); - }, - ), - InkWell( - onTap: widget.onTitleTap, - child: SizedBox( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - context.translations.galleryPaginationText( - currentPage: widget.currentPage, - totalPages: widget.totalPages, - ), - style: galleryFooterThemeData.titleTextStyle, - ), - ], - ), - ), - ), - IconButton( - icon: Icon( - context.streamIcons.gallery, - color: galleryFooterThemeData.gridIconButtonColor, - ), - onPressed: () => _showPhotosModal(context), - ), - ], - ), - ), - ), - ); - } - - void _showPhotosModal(context) { - final chatThemeData = StreamChatTheme.of(context); - final galleryFooterThemeData = StreamGalleryFooterTheme.of(context); - showModalBottomSheet( - context: context, - barrierColor: galleryFooterThemeData.bottomSheetBarrierColor, - backgroundColor: galleryFooterThemeData.bottomSheetBackgroundColor, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16), - ), - ), - builder: (context) { - return DraggableScrollableSheet( - expand: false, - 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: Icon( - context.streamIcons.xmark, - color: galleryFooterThemeData.bottomSheetCloseIconColor, - ), - onPressed: () => Navigator.of(context).maybePop(), - ), - ), - ], - ), - 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 == AttachmentType.video) { - media = MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () => widget.mediaSelectedCallBack!(index), - child: AspectRatio( - aspectRatio: 1, - child: StreamVideoAttachmentThumbnail( - video: attachment, - ), - ), - ), - ); - } else { - media = MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () => widget.mediaSelectedCallBack!(index), - child: AspectRatio( - aspectRatio: 1, - child: StreamImageAttachmentThumbnail( - image: attachment, - 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, - // ignore: deprecated_member_use - color: Colors.white.withOpacity(0.6), - boxShadow: [ - BoxShadow( - blurRadius: 8, - color: chatThemeData.colorTheme.textHighEmphasis - // ignore: deprecated_member_use - .withOpacity(0.3), - ), - ], - ), - child: StreamUserAvatar( - size: .sm, - user: message.user!, - showOnlineIndicator: false, - ), - ), - ), - ], - ); - }, - ), - ), - ], - ), - ); - }, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/gallery/gallery_header.dart b/packages/stream_chat_flutter/lib/src/gallery/gallery_header.dart deleted file mode 100644 index ff2fa5c712..0000000000 --- a/packages/stream_chat_flutter/lib/src/gallery/gallery_header.dart +++ /dev/null @@ -1,163 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template streamGalleryHeader} -/// Header bar for the gallery / media display screen. -/// -/// Renders a [StreamAppBar] whose default title is the sender's [userName] -/// and whose default subtitle is the [sentAt] timestamp. The default -/// trailing action opens an [AttachmentActionsModal] for the focused -/// [attachment]. -/// -/// The bar's chrome (background, padding, typography, divider) is themed via -/// `StreamChatThemeData.galleryHeaderTheme`. Per-instance overrides go on -/// [style]. -/// {@endtemplate} -class StreamGalleryHeader extends StatelessWidget implements PreferredSizeWidget { - /// {@macro streamGalleryHeader} - const StreamGalleryHeader({ - super.key, - required this.message, - required this.attachment, - this.onShowMessage, - this.onReplyMessage, - this.userName = '', - this.sentAt = '', - this.attachmentActionsModalBuilder, - this.leading, - this.automaticallyImplyLeading = true, - this.title, - this.subtitle, - this.trailing, - this.primary = true, - this.style, - }); - - /// Callback to call when pressing the show message button. - final VoidCallback? onShowMessage; - - /// Callback to call when pressing the reply message button. - final VoidCallback? onReplyMessage; - - /// Message which attachments are attached to. - final Message message; - - /// The attachment that's currently in focus. - final Attachment attachment; - - /// Username of sender. - final String userName; - - /// Text which connotes the time the message was sent. - final String sentAt; - - /// {@macro attachmentActionsBuilder} - final AttachmentActionsBuilder? attachmentActionsModalBuilder; - - /// {@macro StreamAppBar.leading} - final Widget? leading; - - /// {@macro StreamAppBar.automaticallyImplyLeading} - final bool automaticallyImplyLeading; - - /// {@macro StreamAppBar.title} - /// - /// Defaults to a [Text] showing [userName]. - final Widget? title; - - /// {@macro StreamAppBar.subtitle} - /// - /// Defaults to a [Text] showing [sentAt]. - final Widget? subtitle; - - /// {@macro StreamAppBar.trailing} - /// - /// Defaults to an icon button that opens the attachment actions modal. - final Widget? trailing; - - /// {@macro StreamAppBar.primary} - final bool primary; - - /// {@macro StreamAppBar.style} - /// - /// Per-instance override; merges over - /// `StreamChatThemeData.galleryHeaderTheme`. - final StreamAppBarStyle? style; - - @override - Size get preferredSize => const Size.fromHeight(kStreamHeaderHeight); - - @override - Widget build(BuildContext context) { - final headerTheme = StreamChatTheme.of(context).galleryHeaderTheme; - - // Ephemeral messages (e.g. previews of unsent attachments) don't have - // sender / timestamp metadata to surface, so the title / subtitle / - // overflow-action defaults are suppressed — caller-provided slots - // still flow through. - var title = this.title; - if (title == null && !message.isEphemeral) { - title = Text(userName); - } - - var subtitle = this.subtitle; - if (subtitle == null && !message.isEphemeral) { - subtitle = Text(sentAt); - } - - var trailing = this.trailing; - if (trailing == null && !message.isEphemeral) { - trailing = IconButton( - icon: Icon(context.streamIcons.more), - onPressed: () => _showMessageActionModalBottomSheet(context), - ); - } - - return StreamAppBarTheme( - data: headerTheme, - child: StreamAppBar( - leading: leading, - automaticallyImplyLeading: automaticallyImplyLeading, - title: title, - subtitle: subtitle, - trailing: trailing, - primary: primary, - style: style, - ), - ); - } - - Future _showMessageActionModalBottomSheet(BuildContext context) async { - final channel = StreamChannel.of(context).channel; - final colorTheme = StreamChatTheme.of(context).colorTheme; - - final defaultModal = AttachmentActionsModal( - attachment: attachment, - message: message, - onShowMessage: onShowMessage, - onReply: onReplyMessage, - ); - - final effectiveModal = - attachmentActionsModalBuilder?.call( - context, - attachment, - defaultModal, - ) ?? - defaultModal; - - final result = await showDialog( - useRootNavigator: false, - context: context, - barrierColor: colorTheme.overlay, - builder: (context) => StreamChannel( - channel: channel, - child: effectiveModal, - ), - ); - - if (result != null) { - Navigator.of(context).pop(result); - } - } -} diff --git a/packages/stream_chat_flutter/lib/src/keyboard_shortcuts/keysets.dart b/packages/stream_chat_flutter/lib/src/keyboard_shortcuts/keysets.dart index 495be7d922..017c609ee4 100644 --- a/packages/stream_chat_flutter/lib/src/keyboard_shortcuts/keysets.dart +++ b/packages/stream_chat_flutter/lib/src/keyboard_shortcuts/keysets.dart @@ -12,21 +12,21 @@ final enterKeySet = LogicalKeySet( /// /// Use for: /// * Removing a reply from [StreamMessageInput]. -/// * Closing [FullScreenMediaDesktop]. +/// * Closing [StreamMediaGalleryPreview]. final escapeKeySet = LogicalKeySet( LogicalKeyboardKey.escape, ); /// The "right arrow" keyset. /// -/// Use for navigating to the next [FullScreenMediaDesktop] item. +/// Use for navigating to the next [StreamMediaGalleryPreview] page. final rightArrowKeySet = LogicalKeySet( LogicalKeyboardKey.arrowRight, ); /// The "left arrow" keyset. /// -/// Use for navigating to the previous [FullScreenMediaDesktop] item. +/// Use for navigating to the previous [StreamMediaGalleryPreview] page. final leftArrowKeySet = LogicalKeySet( LogicalKeyboardKey.arrowLeft, ); diff --git a/packages/stream_chat_flutter/lib/src/localization/translations.dart b/packages/stream_chat_flutter/lib/src/localization/translations.dart index e531537c4f..9e4653e61c 100644 --- a/packages/stream_chat_flutter/lib/src/localization/translations.dart +++ b/packages/stream_chat_flutter/lib/src/localization/translations.dart @@ -247,6 +247,9 @@ abstract class Translations { /// The label for "Photos" String get photosLabel; + /// The label for "Photos & Videos" + String get photosAndVideosLabel; + /// The text for showing on which [date] and [time] the message was sent String sentAtText({required DateTime date, required DateTime time}); @@ -950,6 +953,9 @@ class DefaultTranslations implements Translations { @override String get photosLabel => 'Photos'; + @override + String get photosAndVideosLabel => 'Photos & Videos'; + String _getDay(DateTime dateTime) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); diff --git a/packages/stream_chat_flutter/lib/src/media_gallery/stream_media_gallery.dart b/packages/stream_chat_flutter/lib/src/media_gallery/stream_media_gallery.dart new file mode 100644 index 0000000000..7dac486102 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/media_gallery/stream_media_gallery.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// A scrollable grid of [StreamMediaGalleryAttachment]s — the thumbnail +/// companion to [StreamMediaGalleryPreview]. +/// +/// Each cell is rendered by a [StreamMediaGalleryItem] in a 1:1 grid with +/// the sender's avatar surfaced on every tile. Inter-cell gutters and the +/// outer padding both default to `spacing.xxxs` (2 logical pixels) so +/// every gap in the grid is uniform; pass [StreamMediaGalleryProps.padding] +/// to override. +/// +/// {@tool snippet} +/// +/// Open the full-screen viewer when a tile is tapped: +/// +/// ```dart +/// StreamMediaGallery( +/// attachments: attachments, +/// onItemTap: (index) => Navigator.push( +/// context, +/// MaterialPageRoute( +/// builder: (_) => StreamMediaGalleryPreview( +/// attachments: attachments, +/// initialIndex: index, +/// ), +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMediaGalleryItem], the cell widget. +/// * [StreamMediaGalleryPreview], the full-screen swipeable viewer. +/// * [DefaultStreamMediaGallery], the default implementation. +class StreamMediaGallery extends StatelessWidget { + /// Creates a [StreamMediaGallery]. + StreamMediaGallery({ + super.key, + required List attachments, + int crossAxisCount = 3, + EdgeInsetsGeometry? padding, + ScrollController? scrollController, + ValueChanged? onItemTap, + ValueChanged? onItemLongPress, + }) : props = .new( + attachments: attachments, + crossAxisCount: crossAxisCount, + padding: padding, + scrollController: scrollController, + onItemTap: onItemTap, + onItemLongPress: onItemLongPress, + ); + + /// The properties that configure this gallery. + final StreamMediaGalleryProps props; + + @override + Widget build(BuildContext context) { + final builder = context.chatComponentBuilder(); + if (builder != null) return builder(context, props); + return DefaultStreamMediaGallery(props: props); + } +} + +/// Properties for configuring a [StreamMediaGallery]. +/// +/// This class holds all configuration options for the gallery, allowing +/// them to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamMediaGallery], which uses these properties. +/// * [DefaultStreamMediaGallery], the default implementation. +@immutable +class StreamMediaGalleryProps { + /// Creates properties for a media gallery. + const StreamMediaGalleryProps({ + required this.attachments, + this.crossAxisCount = 3, + this.padding, + this.scrollController, + this.onItemTap, + this.onItemLongPress, + }); + + /// The attachments to display, in render order. + final List attachments; + + /// Number of tiles per row. Defaults to 3. + final int crossAxisCount; + + /// Padding around the grid. + final EdgeInsetsGeometry? padding; + + /// Scroll controller for the underlying [GridView]. + final ScrollController? scrollController; + + /// Called when the user taps the tile at the given index. + final ValueChanged? onItemTap; + + /// Called when the user long-presses the tile at the given index. + final ValueChanged? onItemLongPress; +} + +/// The default implementation of [StreamMediaGallery]. +/// +/// See also: +/// +/// * [StreamMediaGallery], the public API widget. +/// * [StreamMediaGalleryProps], which configures this widget. +class DefaultStreamMediaGallery extends StatelessWidget { + /// Creates a default media gallery with the given [props]. + const DefaultStreamMediaGallery({super.key, required this.props}); + + /// The properties that configure this gallery. + final StreamMediaGalleryProps props; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final effectivePadding = props.padding ?? EdgeInsets.all(spacing.xxxs); + + return MediaQuery.removePadding( + context: context, + removeTop: true, + removeBottom: true, + child: GridView.builder( + padding: effectivePadding, + controller: props.scrollController, + itemCount: props.attachments.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: props.crossAxisCount, + crossAxisSpacing: spacing.xxxs, + mainAxisSpacing: spacing.xxxs, + ), + itemBuilder: (context, index) { + final ga = props.attachments[index]; + return StreamMediaGalleryItem( + attachment: ga.attachment, + author: ga.message.user, + onTap: props.onItemTap == null ? null : () => props.onItemTap!(index), + onLongPress: props.onItemLongPress == null ? null : () => props.onItemLongPress!(index), + ); + }, + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/media_gallery/stream_media_gallery_attachment.dart b/packages/stream_chat_flutter/lib/src/media_gallery/stream_media_gallery_attachment.dart new file mode 100644 index 0000000000..2c1af88c77 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/media_gallery/stream_media_gallery_attachment.dart @@ -0,0 +1,71 @@ +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +/// A media attachment paired with its parent [Message] for use in a media +/// gallery. +/// +/// [StreamMediaGalleryAttachment] is the input format consumed by +/// [StreamMediaGallery] (the thumbnail grid) and [StreamMediaGalleryPreview] +/// (the full-screen swipeable viewer). The bundled [message] lets each +/// surface render the sender / timestamp metadata alongside the media +/// without a separate lookup. +/// +/// {@tool snippet} +/// +/// Build a list from a message's media attachments: +/// +/// ```dart +/// final attachments = [ +/// for (final a in message.attachments.where((it) => +/// it.type == AttachmentType.image || +/// it.type == AttachmentType.video || +/// it.type == AttachmentType.giphy)) +/// StreamMediaGalleryAttachment(attachment: a, message: message), +/// ]; +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMediaGallery], the thumbnail grid that consumes these. +/// * [StreamMediaGalleryPreview], the full-screen swipeable viewer. +class StreamMediaGalleryAttachment { + /// Creates a [StreamMediaGalleryAttachment]. + const StreamMediaGalleryAttachment({ + required this.attachment, + required this.message, + }); + + /// The media attachment being shown. + final Attachment attachment; + + /// The [Message] [attachment] was sent on. + /// + /// Used by gallery surfaces to surface sender / timestamp metadata. + final Message message; +} + +/// Convenience helpers for producing [StreamMediaGalleryAttachment]s from a +/// [Message]. +extension MessageMediaGalleryX on Message { + /// Returns one [StreamMediaGalleryAttachment] per [Message.attachments] + /// entry on this message, each paired with the message itself. + /// + /// Pass [filter] to narrow the result — typically to keep only media + /// attachment types (image / video / giphy). + /// + /// {@tool snippet} + /// + /// ```dart + /// final mediaOnly = message.toMediaGalleryAttachments( + /// filter: (a) => + /// a.type == AttachmentType.image || + /// a.type == AttachmentType.video || + /// a.type == AttachmentType.giphy, + /// ); + /// ``` + /// {@end-tool} + List toMediaGalleryAttachments({bool Function(Attachment)? filter}) { + final source = filter == null ? attachments : attachments.where(filter); + return [for (final a in source) StreamMediaGalleryAttachment(attachment: a, message: this)]; + } +} diff --git a/packages/stream_chat_flutter/lib/src/media_gallery/stream_media_gallery_item.dart b/packages/stream_chat_flutter/lib/src/media_gallery/stream_media_gallery_item.dart new file mode 100644 index 0000000000..97488c7d80 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/media_gallery/stream_media_gallery_item.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +/// A single tile inside a [StreamMediaGallery]. +/// +/// Renders [attachment]'s media filling a 1:1 square cell, clipped to a +/// `radius.xxs` corner, with optional overlays: +/// +/// - An [author] avatar pinned to the top-leading corner, drawn with a +/// white outside-aligned border so it reads against any thumbnail. +/// - A "video" [StreamMediaBadge] pinned to the bottom-leading corner when +/// [attachment] is a video; the badge surfaces the video's duration in +/// `M:SS` format when present on `extraData['duration']`. +/// +/// The tile is tappable when [onTap] / [onLongPress] is set; the ripple is +/// drawn over the media via a transparent [Material]. +/// +/// {@tool snippet} +/// +/// Basic usage inside a [StreamMediaGallery]'s item builder: +/// +/// ```dart +/// StreamMediaGalleryItem( +/// attachment: attachment, +/// author: message.user, +/// onTap: () => openPreview(index), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMediaGallery], the grid that lays out these tiles. +/// * [StreamMediaGalleryPreview], the full-screen viewer opened from a tap. +class StreamMediaGalleryItem extends StatelessWidget { + /// Creates a [StreamMediaGalleryItem]. + const StreamMediaGalleryItem({ + super.key, + required this.attachment, + this.author, + this.onTap, + this.onLongPress, + }); + + /// The media attachment rendered by this tile. + final Attachment attachment; + + /// Optional user to surface as a small avatar in the top-leading corner. + /// + /// When null, no avatar is drawn. + final User? author; + + /// Called when the user taps this tile. + final GestureTapCallback? onTap; + + /// Called when the user long-presses this tile. + final GestureLongPressCallback? onLongPress; + + @override + Widget build(BuildContext context) { + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + final colorScheme = context.streamColorScheme; + + final isVideo = attachment.type == AttachmentType.video; + + Duration? videoDuration; + if (isVideo) { + final secs = attachment.extraData['duration'] as num?; + if (secs != null) videoDuration = Duration(seconds: secs.round()); + } + + return Material( + clipBehavior: .hardEdge, + shape: RoundedSuperellipseBorder( + borderRadius: BorderRadius.all(radius.xxs), + ), + child: AspectRatio( + aspectRatio: 1, + child: Stack( + children: [ + Positioned.fill( + child: StreamMediaAttachmentThumbnail( + media: attachment, + fit: BoxFit.cover, + ), + ), + if (author case final author?) + PositionedDirectional( + top: spacing.xs, + start: spacing.xs, + child: StreamAvatarTheme( + data: StreamAvatarThemeData( + border: BoxBorder.all( + width: 2, + color: colorScheme.borderOnInverse, + strokeAlign: BorderSide.strokeAlignOutside, + ), + ), + child: StreamUserAvatar( + user: author, + size: StreamAvatarSize.sm, + showOnlineIndicator: false, + ), + ), + ), + if (isVideo) + PositionedDirectional( + start: spacing.xs, + bottom: spacing.xs, + child: StreamMediaBadge( + type: .video, + duration: videoDuration, + durationFormat: .exact, + ), + ), + Positioned.fill( + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + onLongPress: onLongPress, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview.dart b/packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview.dart new file mode 100644 index 0000000000..5fe5479bc2 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview.dart @@ -0,0 +1,353 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' as core; + +/// A swipeable, full-screen viewer for a list of chat media attachments. +/// +/// Wraps a [PageView] in a [core.StreamMediaViewer] whose chrome — +/// [StreamMediaGalleryPreviewHeader] on top, [StreamMediaGalleryPreviewFooter] +/// on the bottom — is composed internally from the active package. +/// +/// Behaviour built in: +/// +/// - Tapping the media area toggles the chrome (it slides off the top / +/// bottom edges while the background fades to immersive black). +/// - Keyboard shortcuts: ← / → advance pages, esc pops the enclosing route. +/// - The footer's gallery-grid button opens [StreamMediaGallery] in a +/// [showStreamSheet] bottom sheet; tapping a tile seeks the page view. +/// - The footer's share button downloads the active attachment's bytes and +/// hands them to the system share sheet. +/// - Video attachments are played by [StreamVideoPlayer], which pauses +/// itself when its page is no longer the active one (see +/// [StreamMediaGalleryPreviewScope]). +/// +/// {@tool snippet} +/// +/// Open the viewer at a specific attachment: +/// +/// ```dart +/// Navigator.of(context).push( +/// MaterialPageRoute( +/// builder: (_) => StreamChannel( +/// channel: channel, +/// child: StreamMediaGalleryPreview( +/// attachments: attachments, +/// initialIndex: 3, +/// ), +/// ), +/// ), +/// ); +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Substitute a custom preview implementation via the component factory: +/// +/// ```dart +/// StreamComponentFactory( +/// builders: StreamComponentBuilders( +/// extensions: streamChatComponentBuilders( +/// mediaGalleryPreview: (context, props) => MyCustomPreview(props: props), +/// ), +/// ), +/// child: ..., +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMediaGalleryPreviewProps], which configures this widget. +/// * [DefaultStreamMediaGalleryPreview], the default implementation. +/// * [StreamMediaGalleryPreviewScope], which exposes the active page to +/// descendants (videos pause based on this). +/// * [StreamMediaGallery], the thumbnail companion shown in the footer's +/// bottom sheet. +class StreamMediaGalleryPreview extends StatelessWidget { + /// Creates a [StreamMediaGalleryPreview]. + StreamMediaGalleryPreview({ + super.key, + required List attachments, + int initialIndex = 0, + bool autoplayVideos = false, + }) : assert(initialIndex >= 0, 'initialIndex cannot be negative'), + props = .new( + attachments: attachments, + initialIndex: initialIndex, + autoplayVideos: autoplayVideos, + ); + + /// The properties that configure this preview. + final StreamMediaGalleryPreviewProps props; + + @override + Widget build(BuildContext context) { + final builder = context.chatComponentBuilder(); + if (builder != null) return builder(context, props); + return DefaultStreamMediaGalleryPreview(props: props); + } +} + +/// Properties for configuring a [StreamMediaGalleryPreview]. +/// +/// This class holds all configuration options for the preview, allowing +/// them to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamMediaGalleryPreview], which uses these properties. +/// * [DefaultStreamMediaGalleryPreview], the default implementation. +@immutable +class StreamMediaGalleryPreviewProps { + /// Creates properties for a media gallery preview. + const StreamMediaGalleryPreviewProps({ + required this.attachments, + this.initialIndex = 0, + this.autoplayVideos = false, + }); + + /// The 0-based index of the attachment to show first. + final int initialIndex; + + /// The attachments to browse. + final List attachments; + + /// Whether video attachments auto-play when their page becomes active. + final bool autoplayVideos; +} + +/// The default implementation of [StreamMediaGalleryPreview]. +/// +/// See also: +/// +/// * [StreamMediaGalleryPreview], the public API widget. +/// * [StreamMediaGalleryPreviewProps], which configures this widget. +class DefaultStreamMediaGalleryPreview extends StatefulWidget { + /// Creates a default media gallery preview with the given [props]. + const DefaultStreamMediaGalleryPreview({super.key, required this.props}); + + /// The properties that configure this preview. + final StreamMediaGalleryPreviewProps props; + + @override + State createState() => _DefaultStreamMediaGalleryPreviewState(); +} + +class _DefaultStreamMediaGalleryPreviewState extends State { + late final _showChrome = ValueNotifier(true); + late final _currentPage = ValueNotifier(widget.props.initialIndex); + late final _pageController = PageController(initialPage: _currentPage.value); + + // Animates the page view to the given page index. + Future _animateToPage(int page) { + return _pageController.animateToPage( + page, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + + // Downloads the currently-focused attachment's bytes and hands them to + // the system share sheet. + Future _shareCurrentAttachment(BuildContext context) async { + final attachment = widget.props.attachments[_currentPage.value].attachment; + final url = attachment.imageUrl ?? attachment.assetUrl ?? attachment.thumbUrl; + if (url == null) return; + + final response = await Dio().get>(url, options: Options(responseType: .bytes)); + final data = response.data; + if (data == null || data.isEmpty) return; + + // sharePositionOrigin anchors the iPad / macOS popover; without it the + // share sheet asserts on those platforms. + final box = context.findRenderObject() as RenderBox?; + final origin = box != null ? box.localToGlobal(Offset.zero) & box.size : Rect.zero; + + final params = ShareParams( + sharePositionOrigin: origin, + fileNameOverrides: [attachment.title ?? attachment.id], + files: [XFile.fromData(Uint8List.fromList(data), mimeType: attachment.mimeType)], + ); + + await SharePlus.instance.share(params); + } + + // Opens the thumbnail grid in a bottom sheet; tapping a tile pops the + // sheet and seeks the page view to that index. + Future _openGallery(BuildContext context) async { + final itemIndex = await showStreamSheet( + context: context, + builder: (sheetContext, scrollController) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + StreamSheetHeader(title: Text(sheetContext.translations.photosAndVideosLabel)), + Expanded( + child: StreamMediaGallery( + attachments: widget.props.attachments, + scrollController: scrollController, + onItemTap: Navigator.of(sheetContext).maybePop, + ), + ), + ], + ), + ); + + if (itemIndex == null || !mounted) return; + _animateToPage(itemIndex); // Animate the page to the selected index from the gallery. + } + + @override + void dispose() { + _showChrome.dispose(); + _currentPage.dispose(); + _pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final attachments = widget.props.attachments; + final itemCount = attachments.length; + + final gallery = ValueListenableBuilder( + valueListenable: _showChrome, + builder: (context, showChrome, pageView) { + return ValueListenableBuilder( + valueListenable: _currentPage, + builder: (context, currentPage, _) { + final translations = context.translations; + final message = attachments[currentPage].message; + + final senderName = message.user?.name ?? ''; + final sentAt = translations.sentAtText(date: message.createdAt, time: message.createdAt); + final pageCounter = translations.galleryPaginationText(currentPage: currentPage, totalPages: itemCount); + + return core.StreamMediaViewer( + showChrome: showChrome, + header: StreamMediaGalleryPreviewHeader( + title: Text(senderName), + subtitle: Text(sentAt), + ), + footer: StreamMediaGalleryPreviewFooter( + title: Text(pageCounter), + onSharePressed: () => _shareCurrentAttachment(context), + onGalleryPressed: () => _openGallery(context), + ), + child: pageView!, + ); + }, + ); + }, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _showChrome.value = !_showChrome.value, + child: PageView.builder( + controller: _pageController, + itemCount: itemCount, + onPageChanged: (page) => _currentPage.value = page, + itemBuilder: (_, index) { + final package = attachments[index]; + return StreamMediaGalleryPreviewItem( + attachment: package.attachment, + pageIndex: index, + autoplay: widget.props.autoplayVideos, + ); + }, + ), + ), + ); + + return Material( + type: MaterialType.transparency, + child: KeyboardShortcutRunner( + onEscapeKeypress: Navigator.of(context).maybePop, + onLeftArrowKeypress: () { + final index = _currentPage.value; + if (index > 0) _animateToPage(index - 1); + }, + onRightArrowKeypress: () { + final index = _currentPage.value; + if (index < itemCount - 1) _animateToPage(index + 1); + }, + child: StreamMediaGalleryPreviewScope._( + activeIndex: _currentPage, + child: gallery, + ), + ), + ); + } +} + +/// Exposes the active page index of the enclosing [StreamMediaGalleryPreview] +/// to descendants. +/// +/// Per-page widgets that need to react when their page is no longer +/// visible — e.g. video players that pause themselves while off-screen — +/// read [activeIndex] from this scope and compare it against their own +/// page index. +/// +/// {@tool snippet} +/// +/// ```dart +/// final scope = StreamMediaGalleryPreviewScope.of(context); +/// final isActive = scope.activeIndex.value == myPageIndex; +/// ``` +/// {@end-tool} +class StreamMediaGalleryPreviewScope extends InheritedWidget { + const StreamMediaGalleryPreviewScope._({ + required this.activeIndex, + required super.child, + }); + + /// The active page index of the enclosing preview. + final ValueListenable activeIndex; + + /// Returns the [StreamMediaGalleryPreviewScope] of the nearest enclosing + /// [StreamMediaGalleryPreview], or `null` if there isn't one. + /// + /// Prefer [of] when the absence of the scope is a programmer error. + static StreamMediaGalleryPreviewScope? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } + + /// Returns the [StreamMediaGalleryPreviewScope] of the nearest enclosing + /// [StreamMediaGalleryPreview]. + /// + /// Throws a [FlutterError] when no scope is in scope — typically because + /// the calling widget is rendered outside the preview's page tree. + /// Use [maybeOf] when the absence is a recoverable case. + static StreamMediaGalleryPreviewScope of(BuildContext context) { + final scope = maybeOf(context); + if (scope != null) return scope; + + throw FlutterError.fromParts([ + ErrorSummary( + 'StreamMediaGalleryPreviewScope.of() called with a context that ' + 'does not contain a StreamMediaGalleryPreview.', + ), + ErrorDescription( + 'No StreamMediaGalleryPreview ancestor could be found starting ' + 'from the context that was passed to ' + 'StreamMediaGalleryPreviewScope.of(). This usually means the ' + 'caller is being built outside the page tree owned by the ' + 'preview, or the context predates the StreamMediaGalleryPreview ' + 'itself.', + ), + ErrorHint( + 'The scope is only available inside widgets rendered by the ' + 'preview — typically a [StreamMediaGalleryPreviewItem] or a ' + 'replacement provided via the component factory. If you need to ' + 'react to gallery activity from elsewhere, lift the state out of ' + 'the preview instead.', + ), + context.describeElement('The context used was'), + ]); + } + + @override + bool updateShouldNotify(StreamMediaGalleryPreviewScope old) => activeIndex != old.activeIndex; +} diff --git a/packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview_footer.dart b/packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview_footer.dart new file mode 100644 index 0000000000..b9a07ba71d --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview_footer.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// Bottom chrome bar for a [StreamMediaGalleryPreview]. +/// +/// Wraps [StreamBottomAppBar] with gallery-specific defaults: +/// +/// - The leading slot is fixed to a share icon button that invokes +/// [onSharePressed]. +/// - The [title] slot accepts any [Widget] but typically renders the +/// localised page counter (e.g. "1 of 9"). +/// - The trailing slot is fixed to a gallery-grid icon button that invokes +/// [onGalleryPressed]. +/// +/// {@tool snippet} +/// +/// Build the footer from the active page index inside a preview builder: +/// +/// ```dart +/// StreamMediaGalleryPreviewFooter( +/// title: Text( +/// context.translations.galleryPaginationText( +/// currentPage: currentPage, +/// totalPages: totalPages, +/// ), +/// ), +/// onSharePressed: shareCurrentAttachment, +/// onGalleryPressed: openThumbnailSheet, +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMediaGalleryPreview], which renders this footer in its chrome. +/// * [StreamMediaGalleryPreviewHeader], the matching top chrome. +/// * [StreamBottomAppBar], the underlying bottom app bar this widget wraps. +class StreamMediaGalleryPreviewFooter extends StatelessWidget implements PreferredSizeWidget { + /// Creates a [StreamMediaGalleryPreviewFooter]. + const StreamMediaGalleryPreviewFooter({ + super.key, + this.title, + this.onSharePressed, + this.onGalleryPressed, + }); + + /// {@macro StreamBottomAppBar.title} + final Widget? title; + + /// Called when the share button is pressed. + /// + /// When null, the button is rendered disabled. + final VoidCallback? onSharePressed; + + /// Called when the gallery-grid button is pressed. + /// + /// When null, the button is rendered disabled. + final VoidCallback? onGalleryPressed; + + @override + Size get preferredSize => const Size.fromHeight(kStreamToolbarHeight); + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + + return StreamBottomAppBar( + leading: StreamButton.icon( + type: StreamButtonType.ghost, + style: StreamButtonStyle.secondary, + icon: Icon(icons.export), + onPressed: onSharePressed, + ), + title: title, + trailing: StreamButton.icon( + type: StreamButtonType.ghost, + style: StreamButtonStyle.secondary, + icon: Icon(icons.gallery), + onPressed: onGalleryPressed, + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview_header.dart b/packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview_header.dart new file mode 100644 index 0000000000..dbb61b78b9 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview_header.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// Top chrome bar for a [StreamMediaGalleryPreview]. +/// +/// Wraps [StreamAppBar] with gallery-specific defaults — the optional +/// [title] / [subtitle] slots accept any [Widget] but typically render the +/// sender's name and the localised sent timestamp. +/// +/// A back affordance is auto-implied on the leading slot from the +/// enclosing route — see [StreamAppBar] for the platform-aware resolution. +/// +/// {@tool snippet} +/// +/// Build the header from the active package inside a preview builder: +/// +/// ```dart +/// StreamMediaGalleryPreviewHeader( +/// title: Text(message.user?.name ?? ''), +/// subtitle: Text( +/// context.translations.sentAtText( +/// date: message.createdAt, +/// time: message.createdAt, +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMediaGalleryPreview], which renders this header in its chrome. +/// * [StreamMediaGalleryPreviewFooter], the matching bottom chrome. +/// * [StreamAppBar], the underlying app bar this widget wraps. +class StreamMediaGalleryPreviewHeader extends StatelessWidget implements PreferredSizeWidget { + /// Creates a [StreamMediaGalleryPreviewHeader]. + const StreamMediaGalleryPreviewHeader({ + super.key, + this.title, + this.subtitle, + }); + + /// {@macro StreamAppBar.title} + final Widget? title; + + /// {@macro StreamAppBar.subtitle} + final Widget? subtitle; + + @override + Size get preferredSize => const Size.fromHeight(kStreamToolbarHeight); + + @override + Widget build(BuildContext context) { + return StreamAppBar( + title: title, + subtitle: subtitle, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview_item.dart b/packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview_item.dart new file mode 100644 index 0000000000..b8fc3ed584 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview_item.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:photo_view/photo_view.dart'; +import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// One page in a [StreamMediaGalleryPreview]. +/// +/// Renders [attachment] based on its type: +/// +/// - image / giphy → [PhotoView] over a [StreamMediaAttachmentThumbnail] +/// for pinch-to-zoom and pan. +/// - video → [StreamVideoPlayer] — picks the right backend per platform +/// and pauses itself when this page is no longer active. +/// - anything else → an empty widget. +/// +/// {@tool snippet} +/// +/// Use inside a custom preview page builder: +/// +/// ```dart +/// PageView.builder( +/// itemCount: attachments.length, +/// itemBuilder: (_, i) => StreamMediaGalleryPreviewItem( +/// attachment: attachments[i].attachment, +/// pageIndex: i, +/// autoplay: true, +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMediaGalleryPreview], the host viewer. +/// * [StreamVideoPlayer], the video backend used for video attachments. +/// * [StreamMediaAttachmentThumbnail], the image / video poster widget. +class StreamMediaGalleryPreviewItem extends StatelessWidget { + /// Creates a [StreamMediaGalleryPreviewItem]. + const StreamMediaGalleryPreviewItem({ + super.key, + required this.attachment, + this.pageIndex = 0, + this.autoplay = false, + }); + + /// The attachment to render. + final Attachment attachment; + + /// The 0-based index of this page in the enclosing preview. Forwarded to + /// [StreamVideoPlayer] so it can decide whether to play or pause based on + /// the active page from [StreamMediaGalleryPreviewScope]. + final int pageIndex; + + /// Whether to start video playback automatically when this page becomes + /// active. No effect for non-video attachments. + final bool autoplay; + + @override + Widget build(BuildContext context) { + final type = attachment.type; + + if (type == .image || type == .giphy) { + return PhotoView.customChild( + maxScale: PhotoViewComputedScale.covered, + minScale: PhotoViewComputedScale.contained, + backgroundDecoration: const BoxDecoration(color: Colors.transparent), + child: StreamMediaAttachmentThumbnail(media: attachment), + ); + } + + if (type == .video) { + return StreamVideoPlayer( + autoplay: autoplay, + pageIndex: pageIndex, + attachment: attachment, + ); + } + + return const Empty(); + } +} diff --git a/packages/stream_chat_flutter/lib/src/media_gallery_preview/video_player/stream_video_player.dart b/packages/stream_chat_flutter/lib/src/media_gallery_preview/video_player/stream_video_player.dart new file mode 100644 index 0000000000..c71a4234e5 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/media_gallery_preview/video_player/stream_video_player.dart @@ -0,0 +1,72 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/src/media_gallery_preview/video_player/stream_video_player_default.dart'; +import 'package:stream_chat_flutter/src/media_gallery_preview/video_player/stream_video_player_desktop.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// Plays a chat video attachment inside a [StreamMediaGalleryPreview]. +/// +/// Hosts a platform-appropriate playback backend behind a single widget; +/// the player pauses itself when its page is no longer the active one in +/// the enclosing preview, and resumes if the user had it playing before +/// (or on first activation when [autoplay] is true). +/// +/// Must be hosted inside a [StreamMediaGalleryPreview] — playback relies +/// on the preview to know which page is currently visible. +/// +/// {@tool snippet} +/// +/// Wire from a custom preview item builder: +/// +/// ```dart +/// StreamVideoPlayer( +/// attachment: attachment, +/// pageIndex: index, +/// autoplay: true, +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMediaGalleryPreview], the host viewer this widget plays into. +/// * [StreamMediaGalleryPreviewItem], which routes video attachments here. +class StreamVideoPlayer extends StatelessWidget { + /// Creates a [StreamVideoPlayer]. + const StreamVideoPlayer({ + super.key, + required this.attachment, + required this.pageIndex, + this.autoplay = false, + }); + + /// The video attachment to play. + final Attachment attachment; + + /// The 0-based index of this page in the enclosing + /// [StreamMediaGalleryPreview]. + /// + /// Forwarded to the backend so it can compare against the gallery's + /// active index and pause when off-screen. + final int pageIndex; + + /// Whether playback should auto-start the first time this page becomes + /// active. Already-paused-by-user state is preserved on re-activation. + final bool autoplay; + + @override + Widget build(BuildContext context) { + if (!kIsWeb && isDesktopVideoPlayerSupported) { + return StreamVideoPlayerDesktop( + attachment: attachment, + pageIndex: pageIndex, + autoplay: autoplay, + ); + } + return StreamVideoPlayerDefault( + attachment: attachment, + pageIndex: pageIndex, + autoplay: autoplay, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/media_gallery_preview/video_player/stream_video_player_activity_mixin.dart b/packages/stream_chat_flutter/lib/src/media_gallery_preview/video_player/stream_video_player_activity_mixin.dart new file mode 100644 index 0000000000..6bd6c41c02 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/media_gallery_preview/video_player/stream_video_player_activity_mixin.dart @@ -0,0 +1,91 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// State mixin for playback controllers (video / audio) that want to +/// behave well inside a [StreamMediaGalleryPreview]. +/// +/// When the widget is hosted inside a preview, the mixin subscribes to the +/// enclosing [StreamMediaGalleryPreviewScope]'s active page index and +/// toggles playback so: +/// +/// - When the page becomes active again, playback resumes if the user had +/// it playing before swiping away — or if [autoplay] is true on the +/// first activation. +/// - When the page goes off-screen, playback pauses; the prior state is +/// remembered so swiping back doesn't drop the user's position or +/// force-restart paused videos. +/// +/// When there is no preview ancestor, the mixin treats the widget as +/// always active — [autoplay] kicks in on init and the caller controls +/// play / pause manually after that. This lets the same player class be +/// reused outside the gallery without breaking. +mixin StreamVideoPlayerActivityMixin on State { + ValueListenable? _activeIndex; + bool _wasPlayingBeforeInactive = false; + + /// The 0-based index of this page in the enclosing + /// [StreamMediaGalleryPreview]. Ignored when no preview ancestor is in + /// scope. + int get pageIndex; + + /// Whether playback should auto-start when this page first becomes + /// active. Already-paused-by-user state is preserved on re-activation + /// regardless of this flag. + bool get autoplay; + + /// Whether the underlying controller is ready for [play] / [pause] + /// calls. The mixin gates its sync on this flag so subclasses can + /// safely return false during async initialisation. + bool get isPlayerReady; + + /// Whether the underlying controller is currently playing. + bool get isPlaying; + + /// Starts playback. Called only when [isPlayerReady] is true. + void play(); + + /// Pauses playback. Called only when [isPlayerReady] is true. + void pause(); + + /// True when this page is currently the active gallery page. + /// + /// Treated as always active when no [StreamMediaGalleryPreviewScope] + /// ancestor is in scope, so the player still auto-plays / can be + /// controlled manually outside the gallery. + bool get isActive => _activeIndex == null || _activeIndex!.value == pageIndex; + + /// Subclasses should call this when their async initialisation completes + /// so the mixin can apply the right initial play / pause state. + void syncPlayState() => _syncPlayState(); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final next = StreamMediaGalleryPreviewScope.maybeOf(context)?.activeIndex; + if (_activeIndex != next) { + _activeIndex?.removeListener(_syncPlayState); + _activeIndex = next?..addListener(_syncPlayState); + _syncPlayState(); + } + } + + @override + void dispose() { + _activeIndex?.removeListener(_syncPlayState); + super.dispose(); + } + + void _syncPlayState() { + if (!isPlayerReady) return; + if (isActive) { + if (autoplay || _wasPlayingBeforeInactive) { + play(); + _wasPlayingBeforeInactive = false; + } + } else if (isPlaying) { + _wasPlayingBeforeInactive = true; + pause(); + } + } +} diff --git a/packages/stream_chat_flutter/lib/src/media_gallery_preview/video_player/stream_video_player_default.dart b/packages/stream_chat_flutter/lib/src/media_gallery_preview/video_player/stream_video_player_default.dart new file mode 100644 index 0000000000..e91a5a1b0e --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/media_gallery_preview/video_player/stream_video_player_default.dart @@ -0,0 +1,159 @@ +import 'dart:io'; + +import 'package:chewie/chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/media_gallery_preview/video_player/stream_video_player_activity_mixin.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:video_player/video_player.dart'; + +/// Video player used on Android, iOS, macOS, and web inside a +/// [StreamMediaGalleryPreview]. +/// +/// Pauses itself when the enclosing preview swipes away from this page +/// and resumes on return if the user had it playing before. +/// +/// Routed to internally by [StreamVideoPlayer]; you generally don't need +/// to construct this directly. +class StreamVideoPlayerDefault extends StatefulWidget { + /// Creates a [StreamVideoPlayerDefault]. + const StreamVideoPlayerDefault({ + super.key, + required this.attachment, + required this.pageIndex, + this.autoplay = false, + }); + + /// The video attachment to play. + final Attachment attachment; + + /// The 0-based index of this page in the enclosing + /// [StreamMediaGalleryPreview]. + final int pageIndex; + + /// Whether playback should auto-start when this page first becomes + /// active. + final bool autoplay; + + @override + State createState() => _StreamVideoPlayerDefaultState(); +} + +class _StreamVideoPlayerDefaultState extends State + with + StreamVideoPlayerActivityMixin, + AutomaticKeepAliveClientMixin { + final _player = _ChewieVideoPlayer(); + Object? _error; + + @override + int get pageIndex => widget.pageIndex; + + @override + bool get autoplay => widget.autoplay; + + @override + bool get isPlayerReady => _player.isReady; + + @override + bool get isPlaying => _player.isPlaying; + + @override + void play() => _player.play(); + + @override + void pause() => _player.pause(); + + // Keep the page mounted in the enclosing PageView once the controller is + // ready, so swiping away and back doesn't re-initialise and replay the + // loading spinner. + @override + bool get wantKeepAlive => _player.isReady; + + @override + void initState() { + super.initState(); + _initialize(); + } + + Future _initialize() async { + try { + await _player.open(widget.attachment); + if (!mounted) return; + setState(() {}); + updateKeepAlive(); + syncPlayState(); + } catch (error) { + if (!mounted) return; + setState(() => _error = error); + } + } + + @override + void dispose() { + _player.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + if (_error != null) return const Center(child: StreamImageErrorPlaceholder()); + if (_player.controller case final controller?) return Chewie(controller: controller); + return const Center(child: StreamScrollViewLoadingWidget()); + } +} + +/// Holds the [VideoPlayerController] / [ChewieController] pair so the +/// state class doesn't have to coordinate them inline. +/// +/// [controller] is the public ready signal — non-null once [open] +/// resolves successfully, null while loading or after [dispose]. +class _ChewieVideoPlayer { + VideoPlayerController? _video; + ChewieController? _chewie; + + /// The chewie controller used for playback once initialisation has + /// completed. `null` until [open] resolves. + ChewieController? get controller => _chewie; + + /// Whether the underlying video player is ready for playback. + bool get isReady => _chewie != null; + + /// Whether the underlying video player is currently playing. + bool get isPlaying => _video?.value.isPlaying ?? false; + + /// Initialises the controller pair for [attachment]. Throws when there + /// is no usable source URL. + Future open(Attachment attachment) async { + final localUri = attachment.localUri; + final assetUrl = attachment.assetUrl; + if (localUri == null && assetUrl == null) { + throw StateError('No video source on attachment ${attachment.id}'); + } + + // Local files take precedence over the network asset; on web the + // local branch is unreachable since `localUri` is always null there. + final video = localUri != null + ? VideoPlayerController.file(File.fromUri(localUri)) + : VideoPlayerController.networkUrl(Uri.parse(assetUrl!)); + _video = video; + await video.initialize(); + _chewie = ChewieController( + videoPlayerController: video, + autoInitialize: true, + aspectRatio: video.value.aspectRatio, + ); + } + + /// Starts playback. No-op if not yet ready. + void play() => _chewie?.play(); + + /// Pauses playback. No-op if not yet ready. + void pause() => _chewie?.pause(); + + /// Releases both controllers. + void dispose() { + _chewie?.dispose(); + _video?.dispose(); + } +} diff --git a/packages/stream_chat_flutter/lib/src/media_gallery_preview/video_player/stream_video_player_desktop.dart b/packages/stream_chat_flutter/lib/src/media_gallery_preview/video_player/stream_video_player_desktop.dart new file mode 100644 index 0000000000..3f69f77288 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/media_gallery_preview/video_player/stream_video_player_desktop.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:stream_chat_flutter/src/media_gallery_preview/video_player/stream_video_player_activity_mixin.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// Video player used on Linux and Windows inside a +/// [StreamMediaGalleryPreview]. +/// +/// Pauses itself when the enclosing preview swipes away from this page +/// and resumes on return if the user had it playing before. +/// +/// Routed to internally by [StreamVideoPlayer]; you generally don't need +/// to construct this directly. +class StreamVideoPlayerDesktop extends StatefulWidget { + /// Creates a [StreamVideoPlayerDesktop]. + const StreamVideoPlayerDesktop({ + super.key, + required this.attachment, + required this.pageIndex, + this.autoplay = false, + }); + + /// The video attachment to play. + final Attachment attachment; + + /// The 0-based index of this page in the enclosing + /// [StreamMediaGalleryPreview]. + final int pageIndex; + + /// Whether playback should auto-start when this page first becomes + /// active. + final bool autoplay; + + @override + State createState() => _StreamVideoPlayerDesktopState(); +} + +class _StreamVideoPlayerDesktopState extends State + with + StreamVideoPlayerActivityMixin, + AutomaticKeepAliveClientMixin { + final _player = _MediaKitVideoPlayer(); + Object? _error; + + @override + int get pageIndex => widget.pageIndex; + + @override + bool get autoplay => widget.autoplay; + + @override + bool get isPlayerReady => _player.isReady; + + @override + bool get isPlaying => _player.isPlaying; + + @override + void play() => _player.play(); + + @override + void pause() => _player.pause(); + + // Keep the page mounted in the enclosing PageView once the controller is + // ready, so swiping away and back doesn't re-initialise and replay the + // loading spinner. + @override + bool get wantKeepAlive => _player.isReady; + + @override + void initState() { + super.initState(); + _initialize(); + } + + Future _initialize() async { + try { + await _player.open(widget.attachment); + if (!mounted) return; + setState(() {}); + updateKeepAlive(); + syncPlayState(); + } catch (error) { + if (!mounted) return; + setState(() => _error = error); + } + } + + @override + void dispose() { + _player.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + if (_error != null) return const Center(child: StreamImageErrorPlaceholder()); + if (_player.controller case final controller?) return Video(controller: controller); + return const Center(child: StreamScrollViewLoadingWidget()); + } +} + +/// Holds the [Player] / [VideoController] pair so the state class doesn't +/// have to coordinate them inline. +/// +/// [controller] is the public ready signal — non-null once [open] +/// resolves successfully, null while loading or after [dispose]. +class _MediaKitVideoPlayer { + Player? _player; + VideoController? _controller; + + /// The video controller used for rendering once initialisation has + /// completed. `null` until [open] resolves. + VideoController? get controller => _controller; + + /// Whether the underlying media player is ready for playback. + bool get isReady => _controller != null; + + /// Whether the underlying media player is currently playing. + bool get isPlaying => _player?.state.playing ?? false; + + /// Initialises the player for [attachment]. Throws when there is no + /// usable source URL. + Future open(Attachment attachment) async { + final assetUrl = attachment.assetUrl; + if (assetUrl == null) { + throw StateError('No video source on attachment ${attachment.id}'); + } + + final player = Player(); + _player = player; + final controller = VideoController(player); + await player.open(Media(assetUrl), play: false); + _controller = controller; + } + + /// Starts playback. No-op if not yet ready. + void play() => _player?.play(); + + /// Pauses playback. No-op if not yet ready. + void pause() => _player?.pause(); + + /// Releases the player. + void dispose() => _player?.dispose(); +} diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart index fcb616c9a6..0204df1e01 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/message_input/attachment_picker/options/options.dart'; import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:stream_core_flutter/stream_core_flutter.dart'; /// Inline widget for the system attachment picker interface. /// diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart index be8d1f1302..e99af30a87 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart @@ -112,8 +112,6 @@ class StreamMessageListView extends StatefulWidget { this.onViewInChannelTap, this.onEditMessageTap, this.onReplyTap, - this.onShowMessage, - this.attachmentActionsModalBuilder, this.swipeToReply = false, this.onUserAvatarTap, this.onReactionsTap, @@ -241,18 +239,6 @@ class StreamMessageListView extends StatefulWidget { /// Forwarded to each [StreamMessageItem] in the list. final void Function(Message)? onReplyTap; - /// Called when the "show in chat" action is tapped in the full-screen - /// media gallery. - /// - /// Forwarded to each [StreamMessageItem] in the list. - final ShowMessageCallback? onShowMessage; - - /// Widget builder for the attachment actions modal shown in the full-screen - /// media gallery. - /// - /// Forwarded to each [StreamMessageItem] in the list. - final AttachmentActionsBuilder? attachmentActionsModalBuilder; - /// Whether swiping a message triggers a quoted-reply action. /// /// Forwarded to each [StreamMessageItem] in the list via @@ -1207,8 +1193,6 @@ class _StreamMessageListViewState extends State { onMessageLongPress: widget.onMessageLongPress, onEditMessageTap: widget.onEditMessageTap, onReplyTap: widget.onReplyTap, - onShowMessage: widget.onShowMessage, - attachmentActionsModalBuilder: widget.attachmentActionsModalBuilder, onUserAvatarTap: widget.onUserAvatarTap, onReactionsTap: widget.onReactionsTap, onQuotedMessageTap: widget.onQuotedMessageTap, @@ -1347,14 +1331,6 @@ class _StreamMessageListViewState extends State { onMessageLongPress: widget.onMessageLongPress, onEditMessageTap: widget.onEditMessageTap, onReplyTap: widget.onReplyTap, - onShowMessage: switch (widget.onShowMessage) { - final onTap? => onTap, - _ => (message, _) => _moveToAndHighlight( - messageId: message.id, - messages: messages, - ), - }, - attachmentActionsModalBuilder: widget.attachmentActionsModalBuilder, onUserAvatarTap: widget.onUserAvatarTap, onReactionsTap: widget.onReactionsTap, onMessageLinkTap: widget.onMessageLinkTap, diff --git a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_content.dart b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_content.dart index 60356f3783..8d52bcf7b4 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_content.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_content.dart @@ -6,7 +6,6 @@ import 'package:stream_chat_flutter/src/message_widget/components/stream_message import 'package:stream_chat_flutter/src/message_widget/components/stream_message_text.dart'; import 'package:stream_chat_flutter/src/message_widget/stream_message_attachments.dart'; import 'package:stream_chat_flutter/src/message_widget/stream_quoted_message.dart'; -import 'package:stream_chat_flutter/src/utils/typedefs.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:stream_core_flutter/stream_core_flutter.dart' as core; @@ -43,9 +42,6 @@ class StreamMessageContent extends StatefulWidget { this.onReactionsTap, this.onQuotedMessageTap, this.reactionSorting, - this.onShowMessage, - this.onReplyTap, - this.attachmentActionsModalBuilder, }); /// The message to display. @@ -106,17 +102,6 @@ class StreamMessageContent extends StatefulWidget { /// Passed through to [StreamMessageReactions.sorting]. final Comparator? reactionSorting; - /// Called when the "show in chat" action is tapped in the full-screen - /// media gallery. - final ShowMessageCallback? onShowMessage; - - /// Called when the reply action is tapped in the full-screen media gallery. - final void Function(Message)? onReplyTap; - - /// Widget builder for the attachment actions modal in the full-screen - /// media gallery. - final AttachmentActionsBuilder? attachmentActionsModalBuilder; - @override State createState() => _StreamMessageContentState(); } @@ -181,9 +166,6 @@ class _StreamMessageContentState extends State { key: attachmentsKey, message: widget.message, attachmentBuilders: widget.attachmentBuilders, - onShowMessage: widget.onShowMessage, - onReplyTap: widget.onReplyTap, - attachmentActionsModalBuilder: widget.attachmentActionsModalBuilder, ), if (widget.message.text case final text? when text.isNotEmpty) StreamMessageText( diff --git a/packages/stream_chat_flutter/lib/src/message_widget/stream_message_attachments.dart b/packages/stream_chat_flutter/lib/src/message_widget/stream_message_attachments.dart index f0b270baa7..fcd9c8905c 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/stream_message_attachments.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/stream_message_attachments.dart @@ -44,10 +44,7 @@ class StreamMessageAttachments extends core.NullableStatelessWidget { required this.message, this.attachmentBuilders, this.onAttachmentTap, - this.onShowMessage, this.onLinkTap, - this.onReplyTap, - this.attachmentActionsModalBuilder, }); /// {@macro message} @@ -59,18 +56,9 @@ class StreamMessageAttachments extends core.NullableStatelessWidget { /// {@macro onAttachmentTap} final OnAttachmentWidgetTap? onAttachmentTap; - /// {@macro onShowMessage} - final ShowMessageCallback? onShowMessage; - /// {@macro onLinkTap} final void Function(String)? onLinkTap; - /// {@macro onReplyTap} - final void Function(Message)? onReplyTap; - - /// {@macro attachmentActionsBuilder} - final AttachmentActionsBuilder? attachmentActionsModalBuilder; - @override Widget? nullableBuild(BuildContext context) { Future effectiveOnAttachmentTap( @@ -125,7 +113,7 @@ class StreamMessageAttachments extends core.NullableStatelessWidget { // attachment in full screen. final isMedia = isImage || isVideo || isGiphy; if (isMedia) { - final attachments = message.toAttachmentPackage( + final attachments = message.toMediaGalleryAttachments( filter: (it) { final isImage = it.type == AttachmentType.image; final isVideo = it.type == AttachmentType.video; @@ -136,7 +124,7 @@ class StreamMessageAttachments extends core.NullableStatelessWidget { final navigator = Navigator.of(context); final channel = StreamChannel.of(context).channel; - final startIndex = attachments.indexWhere( + final initialIndex = attachments.indexWhere( (it) => it.attachment.id == attachment.id, ); @@ -144,13 +132,9 @@ class StreamMessageAttachments extends core.NullableStatelessWidget { MaterialPageRoute( builder: (_) => StreamChannel( channel: channel, - child: StreamFullScreenMediaBuilder( - userName: message.user!.name, - mediaAttachmentPackages: attachments, - startIndex: math.max(0, startIndex), - onReplyMessage: onReplyTap, - onShowMessage: onShowMessage, - attachmentActionsModalBuilder: attachmentActionsModalBuilder, + child: StreamMediaGalleryPreview( + attachments: attachments, + initialIndex: math.max(0, initialIndex), ), ), ), @@ -158,25 +142,3 @@ class StreamMessageAttachments extends core.NullableStatelessWidget { } } } - -extension on Message { - List toAttachmentPackage({ - bool Function(Attachment)? filter, - }) { - // Create a copy of the attachments list. - var attachments = [...this.attachments]; - if (filter != null) { - attachments = [...attachments.where(filter)]; - } - - // Create a list of StreamAttachmentPackage from the attachments list. - return [ - ...attachments.map((it) { - return StreamAttachmentPackage( - attachment: it, - message: this, - ); - }), - ]; - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart b/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart index e466a02dc2..c6bab002b3 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart @@ -92,8 +92,6 @@ class StreamMessageItem extends StatelessWidget { void Function(BuildContext, Message)? onBouncedErrorMessageActions, void Function(Message)? onEditMessageTap, List? attachmentBuilders, - ShowMessageCallback? onShowMessage, - AttachmentActionsBuilder? attachmentActionsModalBuilder, }) : props = .new( message: message, padding: padding, @@ -117,8 +115,6 @@ class StreamMessageItem extends StatelessWidget { onBouncedErrorMessageActions: onBouncedErrorMessageActions, onEditMessageTap: onEditMessageTap, attachmentBuilders: attachmentBuilders, - onShowMessage: onShowMessage, - attachmentActionsModalBuilder: attachmentActionsModalBuilder, ); /// Creates a chat message widget from pre-built [props]. @@ -174,8 +170,6 @@ class StreamMessageItemProps { this.onBouncedErrorMessageActions, this.onEditMessageTap, this.attachmentBuilders, - this.onShowMessage, - this.attachmentActionsModalBuilder, }); /// The message to display. @@ -336,20 +330,6 @@ class StreamMessageItemProps { /// priority for attachment types they can handle. final List? attachmentBuilders; - /// Called when the "show in chat" action is tapped in the full-screen - /// media gallery. - /// - /// Receives the [Message] and its [Channel] so the caller can scroll to - /// the message in the channel view. - final ShowMessageCallback? onShowMessage; - - /// Widget builder for the attachment actions modal shown in the full-screen - /// media gallery. - /// - /// When non-null, allows customizing the [AttachmentActionsModal] displayed - /// when the user taps the actions button in the gallery header. - final AttachmentActionsBuilder? attachmentActionsModalBuilder; - /// Returns a copy of this [StreamMessageItemProps] with the given fields /// replaced with new values. StreamMessageItemProps copyWith({ @@ -375,8 +355,6 @@ class StreamMessageItemProps { void Function(BuildContext, Message)? onBouncedErrorMessageActions, void Function(Message)? onEditMessageTap, List? attachmentBuilders, - ShowMessageCallback? onShowMessage, - AttachmentActionsBuilder? attachmentActionsModalBuilder, }) { return StreamMessageItemProps( message: message ?? this.message, @@ -401,8 +379,6 @@ class StreamMessageItemProps { onBouncedErrorMessageActions: onBouncedErrorMessageActions ?? this.onBouncedErrorMessageActions, onEditMessageTap: onEditMessageTap ?? this.onEditMessageTap, attachmentBuilders: attachmentBuilders ?? this.attachmentBuilders, - onShowMessage: onShowMessage ?? this.onShowMessage, - attachmentActionsModalBuilder: attachmentActionsModalBuilder ?? this.attachmentActionsModalBuilder, ); } } @@ -508,9 +484,6 @@ class DefaultStreamMessageItem extends StatelessWidget { attachmentBuilders: props.attachmentBuilders, reactionSorting: props.reactionSorting, onQuotedMessageTap: props.onQuotedMessageTap, - onShowMessage: props.onShowMessage, - onReplyTap: props.onReplyTap, - attachmentActionsModalBuilder: props.attachmentActionsModalBuilder, onLinkTap: (_, href, __) { if (href == null) return; if (props.onMessageLinkTap case final onTap?) return onTap(message, href); diff --git a/packages/stream_chat_flutter/lib/src/misc/thread_header.dart b/packages/stream_chat_flutter/lib/src/misc/thread_header.dart index 085bb664cf..d6c3995871 100644 --- a/packages/stream_chat_flutter/lib/src/misc/thread_header.dart +++ b/packages/stream_chat_flutter/lib/src/misc/thread_header.dart @@ -62,7 +62,7 @@ class StreamThreadHeader extends StatelessWidget implements PreferredSizeWidget final StreamAppBarStyle? style; @override - Size get preferredSize => const Size.fromHeight(kStreamHeaderHeight); + Size get preferredSize => const Size.fromHeight(kStreamToolbarHeight); @override Widget build(BuildContext context) { diff --git a/packages/stream_chat_flutter/lib/src/theme/avatar_theme.dart b/packages/stream_chat_flutter/lib/src/theme/avatar_theme.dart deleted file mode 100644 index e0e6b24e6a..0000000000 --- a/packages/stream_chat_flutter/lib/src/theme/avatar_theme.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -/// {@template avatarThemeData} -/// A style that overrides the default appearance of various avatar widgets. -/// {@endtemplate} -// ignore: prefer-match-file-name -class StreamAvatarThemeData with Diagnosticable { - /// {@macro avatarThemeData} - const StreamAvatarThemeData({ - BoxConstraints? constraints, - BorderRadius? borderRadius, - }) : _constraints = constraints, - _borderRadius = borderRadius; - - final BoxConstraints? _constraints; - final BorderRadius? _borderRadius; - - /// Get constraints for avatar - BoxConstraints get constraints => - _constraints ?? - const BoxConstraints.tightFor( - height: 32, - width: 32, - ); - - /// Get border radius - BorderRadius get borderRadius => _borderRadius ?? BorderRadius.circular(20); - - /// Copy this [StreamAvatarThemeData] to another. - StreamAvatarThemeData copyWith({ - BoxConstraints? constraints, - BorderRadius? borderRadius, - }) { - return StreamAvatarThemeData( - constraints: constraints ?? _constraints, - borderRadius: borderRadius ?? _borderRadius, - ); - } - - /// Linearly interpolate between two [UserAvatar] themes. - /// - /// All the properties must be non-null. - StreamAvatarThemeData lerp( - StreamAvatarThemeData a, - StreamAvatarThemeData b, - double t, - ) { - return StreamAvatarThemeData( - borderRadius: BorderRadius.lerp(a.borderRadius, b.borderRadius, t), - constraints: BoxConstraints.lerp(a.constraints, b.constraints, t), - ); - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is StreamAvatarThemeData && - runtimeType == other.runtimeType && - _constraints == other._constraints && - _borderRadius == other._borderRadius; - - @override - int get hashCode => _constraints.hashCode ^ _borderRadius.hashCode; - - /// Merges one [StreamAvatarThemeData] with the another - StreamAvatarThemeData merge(StreamAvatarThemeData? other) { - if (other == null) return this; - return copyWith( - constraints: other._constraints, - borderRadius: other._borderRadius, - ); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(DiagnosticsProperty('borderRadius', borderRadius)) - ..add(DiagnosticsProperty('constraints', constraints)); - } -} diff --git a/packages/stream_chat_flutter/lib/src/theme/gallery_footer_theme.dart b/packages/stream_chat_flutter/lib/src/theme/gallery_footer_theme.dart deleted file mode 100644 index 48733b8632..0000000000 --- a/packages/stream_chat_flutter/lib/src/theme/gallery_footer_theme.dart +++ /dev/null @@ -1,234 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; - -/// {@template galleryFooterTheme} -/// Overrides the default style of [GalleryFooter] descendants. -/// -/// See also: -/// -/// * [StreamGalleryFooterThemeData], which is used to configure this theme. -/// {@endtemplate} -class StreamGalleryFooterTheme extends InheritedTheme { - /// Creates an [StreamGalleryFooterTheme]. - /// - /// The [data] parameter must not be null. - const StreamGalleryFooterTheme({ - super.key, - required this.data, - required super.child, - }); - - /// The configuration of this theme. - final StreamGalleryFooterThemeData data; - - /// The closest instance of this class that encloses the given context. - /// - /// If there is no enclosing [StreamGalleryFooterTheme] widget, then - /// [StreamChatThemeData.galleryFooterTheme] is used. - /// - /// Typical usage is as follows: - /// - /// ```dart - /// ImageFooterTheme theme = ImageFooterTheme.of(context); - /// ``` - static StreamGalleryFooterThemeData of(BuildContext context) { - final imageFooterTheme = context.dependOnInheritedWidgetOfExactType(); - return imageFooterTheme?.data ?? StreamChatTheme.of(context).galleryFooterTheme; - } - - @override - Widget wrap(BuildContext context, Widget child) => StreamGalleryFooterTheme(data: data, child: child); - - @override - bool updateShouldNotify(StreamGalleryFooterTheme oldWidget) => data != oldWidget.data; -} - -/// {@template galleryFooterThemeData} -/// A style that overrides the default appearance of [GalleryFooter]s when used -/// with [StreamGalleryFooterTheme] or with the overall [StreamChatTheme]'s -/// [StreamChatThemeData.galleryFooterTheme]. -/// -/// See also: -/// -/// * [StreamGalleryFooterTheme], the theme which is configured with this class. -/// * [StreamChatThemeData.galleryFooterTheme], which can be used to override -/// the default style for [GalleryFooter]s below the overall [StreamChatTheme]. -/// {@endtemplate} -class StreamGalleryFooterThemeData with Diagnosticable { - /// Creates an [StreamGalleryFooterThemeData]. - const StreamGalleryFooterThemeData({ - this.backgroundColor, - this.shareIconColor, - this.titleTextStyle, - this.gridIconButtonColor, - this.bottomSheetBarrierColor, - this.bottomSheetBackgroundColor, - this.bottomSheetPhotosTextStyle, - this.bottomSheetCloseIconColor, - }); - - /// The background color for the [GalleryFooter] widget. - /// - /// Defaults to [ColorTheme.barsBg]. - final Color? backgroundColor; - - /// The color for the "share" icon. - /// - /// Defaults to [ColorTheme.textHighEmphasis]. - final Color? shareIconColor; - - /// The [TextStyle] to use for the [GalleryFooter] title text. - /// - /// Defaults to [TextTheme.headlineBold]. - final TextStyle? titleTextStyle; - - /// The color to use for the "grid" icon. - /// - /// Defaults to [ColorTheme.textHighEmphasis]. - final Color? gridIconButtonColor; - - /// The color to use behind the bottom sheet. - /// - /// Defaults to [ColorTheme.overlay]. - final Color? bottomSheetBarrierColor; - - /// The background color to use for the bottom sheet. - /// - /// Defaults to [ColorTheme.barsBg]. - final Color? bottomSheetBackgroundColor; - - /// The [TextStyle] to use for the "photos" text in the bottom sheet. - /// - /// Defaults to [TextTheme.headlineBold]. - final TextStyle? bottomSheetPhotosTextStyle; - - /// The color to use for the "close" icon. - /// - /// Defaults to [ColorTheme.textHighEmphasis]. - final Color? bottomSheetCloseIconColor; - - /// Copies this [StreamGalleryFooterThemeData] to another. - StreamGalleryFooterThemeData copyWith({ - Color? backgroundColor, - Color? shareIconColor, - TextStyle? titleTextStyle, - Color? gridIconButtonColor, - Color? bottomSheetBarrierColor, - Color? bottomSheetBackgroundColor, - TextStyle? bottomSheetPhotosTextStyle, - Color? bottomSheetCloseIconColor, - }) { - return StreamGalleryFooterThemeData( - backgroundColor: backgroundColor ?? this.backgroundColor, - shareIconColor: shareIconColor ?? this.shareIconColor, - titleTextStyle: titleTextStyle ?? this.titleTextStyle, - gridIconButtonColor: gridIconButtonColor ?? this.gridIconButtonColor, - bottomSheetBarrierColor: bottomSheetBarrierColor ?? this.bottomSheetBarrierColor, - bottomSheetBackgroundColor: bottomSheetBackgroundColor ?? this.bottomSheetBackgroundColor, - bottomSheetPhotosTextStyle: bottomSheetPhotosTextStyle ?? this.bottomSheetPhotosTextStyle, - bottomSheetCloseIconColor: bottomSheetCloseIconColor ?? this.bottomSheetCloseIconColor, - ); - } - - /// Linearly interpolate between two [GalleryFooter] themes. - /// - /// All the properties must be non-null. - StreamGalleryFooterThemeData lerp( - StreamGalleryFooterThemeData a, - StreamGalleryFooterThemeData b, - double t, - ) { - return StreamGalleryFooterThemeData( - backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), - shareIconColor: Color.lerp(a.shareIconColor, b.shareIconColor, t), - titleTextStyle: TextStyle.lerp(a.titleTextStyle, b.titleTextStyle, t), - gridIconButtonColor: Color.lerp(a.gridIconButtonColor, b.gridIconButtonColor, t), - bottomSheetBarrierColor: Color.lerp(a.bottomSheetBarrierColor, b.bottomSheetBarrierColor, t), - bottomSheetBackgroundColor: Color.lerp( - a.bottomSheetBackgroundColor, - b.bottomSheetBackgroundColor, - t, - ), - bottomSheetPhotosTextStyle: TextStyle.lerp( - a.bottomSheetPhotosTextStyle, - b.bottomSheetPhotosTextStyle, - t, - ), - bottomSheetCloseIconColor: Color.lerp( - a.bottomSheetCloseIconColor, - b.bottomSheetCloseIconColor, - t, - ), - ); - } - - /// Merges one [StreamGalleryFooterThemeData] with another. - StreamGalleryFooterThemeData merge(StreamGalleryFooterThemeData? other) { - if (other == null) return this; - return copyWith( - backgroundColor: other.backgroundColor, - bottomSheetBarrierColor: other.bottomSheetBarrierColor, - bottomSheetBackgroundColor: other.bottomSheetBackgroundColor, - bottomSheetCloseIconColor: other.bottomSheetCloseIconColor, - bottomSheetPhotosTextStyle: other.bottomSheetPhotosTextStyle, - gridIconButtonColor: other.gridIconButtonColor, - titleTextStyle: other.titleTextStyle, - shareIconColor: other.shareIconColor, - ); - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is StreamGalleryFooterThemeData && - runtimeType == other.runtimeType && - backgroundColor == other.backgroundColor && - shareIconColor == other.shareIconColor && - titleTextStyle == other.titleTextStyle && - gridIconButtonColor == other.gridIconButtonColor && - bottomSheetBarrierColor == other.bottomSheetBarrierColor && - bottomSheetBackgroundColor == other.bottomSheetBackgroundColor && - bottomSheetPhotosTextStyle == other.bottomSheetPhotosTextStyle && - bottomSheetCloseIconColor == other.bottomSheetCloseIconColor; - - @override - int get hashCode => - backgroundColor.hashCode ^ - shareIconColor.hashCode ^ - titleTextStyle.hashCode ^ - gridIconButtonColor.hashCode ^ - bottomSheetBarrierColor.hashCode ^ - bottomSheetBackgroundColor.hashCode ^ - bottomSheetPhotosTextStyle.hashCode ^ - bottomSheetCloseIconColor.hashCode; - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(ColorProperty('backgroundColor', backgroundColor)) - ..add(ColorProperty('shareIconColor', shareIconColor)) - ..add(DiagnosticsProperty('titleTextStyle', titleTextStyle)) - ..add(ColorProperty('gridIconButtonColor', gridIconButtonColor)) - ..add(ColorProperty('bottomSheetBarrierColor', bottomSheetBarrierColor)) - ..add( - ColorProperty( - 'bottomSheetBackgroundColor', - bottomSheetBackgroundColor, - ), - ) - ..add( - DiagnosticsProperty( - 'bottomSheetPhotosTextStyle', - bottomSheetPhotosTextStyle, - ), - ) - ..add( - ColorProperty( - 'bottomSheetCloseIconColor', - bottomSheetCloseIconColor, - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart b/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart index 2dc8f6a779..a3f3ff554d 100644 --- a/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart @@ -45,11 +45,9 @@ class StreamChatThemeData { StreamAppBarThemeData? channelHeaderTheme, StreamAppBarThemeData? channelListHeaderTheme, StreamAppBarThemeData? threadHeaderTheme, - StreamAppBarThemeData? galleryHeaderTheme, Widget Function(BuildContext, User)? defaultUserImage, PlaceholderUserImage? placeholderUserImage, IconThemeData? primaryIconTheme, - StreamGalleryFooterThemeData? imageFooterTheme, StreamMessageListViewThemeData? messageListViewTheme, StreamPollCreatorThemeData? pollCreatorTheme, StreamPollInteractorThemeData? pollInteractorTheme, @@ -75,11 +73,9 @@ class StreamChatThemeData { channelHeaderTheme: channelHeaderTheme, channelListHeaderTheme: channelListHeaderTheme, threadHeaderTheme: threadHeaderTheme, - galleryHeaderTheme: galleryHeaderTheme, defaultUserImage: defaultUserImage, placeholderUserImage: placeholderUserImage, primaryIconTheme: primaryIconTheme, - galleryFooterTheme: imageFooterTheme, messageListViewTheme: messageListViewTheme, pollCreatorTheme: pollCreatorTheme, pollInteractorTheme: pollInteractorTheme, @@ -109,9 +105,7 @@ class StreamChatThemeData { required this.channelHeaderTheme, required this.channelListHeaderTheme, required this.threadHeaderTheme, - required this.galleryHeaderTheme, required this.primaryIconTheme, - required this.galleryFooterTheme, required this.messageListViewTheme, required this.pollCreatorTheme, required this.pollInteractorTheme, @@ -148,27 +142,10 @@ class StreamChatThemeData { textTheme: textTheme, colorTheme: colorTheme, primaryIconTheme: iconTheme, - // Header chrome flows through per-header [StreamAppBarThemeData] - // entries — defaults are resolved by the design system (background, - // divider, padding, typography). Override individual fields per - // header type to customise globally. channelHeaderTheme: const StreamAppBarThemeData(), channelListHeaderTheme: const StreamAppBarThemeData(), threadHeaderTheme: const StreamAppBarThemeData(), - galleryHeaderTheme: const StreamAppBarThemeData(), - galleryFooterTheme: StreamGalleryFooterThemeData( - backgroundColor: colorTheme.barsBg, - shareIconColor: colorTheme.textHighEmphasis, - titleTextStyle: textTheme.headlineBold, - gridIconButtonColor: colorTheme.textHighEmphasis, - bottomSheetBarrierColor: colorTheme.overlay, - bottomSheetBackgroundColor: colorTheme.barsBg, - bottomSheetPhotosTextStyle: textTheme.headlineBold, - bottomSheetCloseIconColor: colorTheme.textHighEmphasis, - ), - messageListViewTheme: StreamMessageListViewThemeData( - backgroundColor: colorTheme.appBg, - ), + messageListViewTheme: StreamMessageListViewThemeData(backgroundColor: colorTheme.appBg), pollCreatorTheme: const StreamPollCreatorThemeData(), pollInteractorTheme: const StreamPollInteractorThemeData(), pollResultsSheetTheme: const StreamPollResultsSheetThemeData(), @@ -197,13 +174,6 @@ class StreamChatThemeData { /// The default [StreamAppBar] style applied to [StreamThreadHeader]. final StreamAppBarThemeData threadHeaderTheme; - /// The default [StreamAppBar] style applied to [StreamGalleryHeader]. - final StreamAppBarThemeData galleryHeaderTheme; - - /// The default style for [StreamGalleryFooter]s below the overall - /// [StreamChatTheme]. - final StreamGalleryFooterThemeData galleryFooterTheme; - /// Primary icon theme final IconThemeData primaryIconTheme; @@ -248,11 +218,9 @@ class StreamChatThemeData { StreamAppBarThemeData? channelHeaderTheme, StreamAppBarThemeData? channelListHeaderTheme, StreamAppBarThemeData? threadHeaderTheme, - StreamAppBarThemeData? galleryHeaderTheme, Widget Function(BuildContext, User)? defaultUserImage, PlaceholderUserImage? placeholderUserImage, IconThemeData? primaryIconTheme, - StreamGalleryFooterThemeData? galleryFooterTheme, StreamMessageListViewThemeData? messageListViewTheme, StreamPollCreatorThemeData? pollCreatorTheme, StreamPollInteractorThemeData? pollInteractorTheme, @@ -270,9 +238,7 @@ class StreamChatThemeData { channelHeaderTheme: this.channelHeaderTheme.merge(channelHeaderTheme), channelListHeaderTheme: this.channelListHeaderTheme.merge(channelListHeaderTheme), threadHeaderTheme: this.threadHeaderTheme.merge(threadHeaderTheme), - galleryHeaderTheme: this.galleryHeaderTheme.merge(galleryHeaderTheme), primaryIconTheme: this.primaryIconTheme.merge(primaryIconTheme), - galleryFooterTheme: galleryFooterTheme ?? this.galleryFooterTheme, messageListViewTheme: messageListViewTheme ?? this.messageListViewTheme, pollCreatorTheme: pollCreatorTheme ?? this.pollCreatorTheme, pollInteractorTheme: pollInteractorTheme ?? this.pollInteractorTheme, @@ -295,9 +261,7 @@ class StreamChatThemeData { channelHeaderTheme: channelHeaderTheme.merge(other.channelHeaderTheme), channelListHeaderTheme: channelListHeaderTheme.merge(other.channelListHeaderTheme), threadHeaderTheme: threadHeaderTheme.merge(other.threadHeaderTheme), - galleryHeaderTheme: galleryHeaderTheme.merge(other.galleryHeaderTheme), primaryIconTheme: other.primaryIconTheme, - galleryFooterTheme: galleryFooterTheme.merge(other.galleryFooterTheme), messageListViewTheme: messageListViewTheme.merge(other.messageListViewTheme), pollCreatorTheme: pollCreatorTheme.merge(other.pollCreatorTheme), pollInteractorTheme: pollInteractorTheme.merge(other.pollInteractorTheme), diff --git a/packages/stream_chat_flutter/lib/src/theme/themes.dart b/packages/stream_chat_flutter/lib/src/theme/themes.dart index 7f24fb2616..4894ad2310 100644 --- a/packages/stream_chat_flutter/lib/src/theme/themes.dart +++ b/packages/stream_chat_flutter/lib/src/theme/themes.dart @@ -1,6 +1,4 @@ -export 'avatar_theme.dart'; export 'color_theme.dart'; -export 'gallery_footer_theme.dart'; export 'message_list_view_theme.dart'; export 'poll_card_style.dart'; export 'poll_comments_sheet_theme.dart'; diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index 989457c121..5529b5c668 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -13,7 +13,14 @@ export 'package:stream_core_flutter/stream_core_flutter.dart' StreamAvatarGroupSize, StreamAvatarSize, StreamAvatarStackSize, + StreamBottomAppBar, + StreamBottomAppBarStyle, + StreamBottomAppBarTheme, + StreamBottomAppBarThemeData, StreamButton, + StreamButtonSize, + StreamButtonStyle, + StreamButtonType, StreamButtonThemeStyle, StreamBadgeCount, StreamBadgeCountTheme, @@ -32,6 +39,8 @@ export 'package:stream_core_flutter/stream_core_flutter.dart' StreamAudioWaveform, StreamTheme, StreamIcons, + StreamImageErrorPlaceholder, + StreamImageLoadingPlaceholder, StreamImageSourceBadge, StreamThemeExtension, StreamComponentFactory, @@ -105,13 +114,19 @@ export 'package:stream_core_flutter/stream_core_flutter.dart' StreamVideoPlayIndicatorSize, StreamMessageAttachment, StreamMessageAttachmentStyle, + StreamMediaBadge, + MediaBadgeType, + MediaBadgeDurationFormat, + StreamMediaViewer, + StreamMediaViewerTheme, + StreamMediaViewerThemeData, StreamMessageItemTheme, StreamMessageItemThemeData, StreamMessageLayoutProperty, StreamMessageLayoutVisibility, StreamVisibility, StreamColors, - kStreamHeaderHeight, + kStreamToolbarHeight, showStreamSheet, streamSupportedEmojis; @@ -124,7 +139,6 @@ export 'src/attachment/gallery_attachment.dart'; export 'src/attachment/handler/stream_attachment_handler.dart'; export 'src/attachment/image_attachment.dart'; export 'src/attachment/link_preview_attachment.dart'; -export 'src/attachment/stream_attachment_package.dart'; export 'src/attachment/thumbnail/giphy_attachment_thumbnail.dart'; export 'src/attachment/thumbnail/image_attachment_thumbnail.dart'; export 'src/attachment/thumbnail/media_attachment_thumbnail.dart'; @@ -150,10 +164,6 @@ export 'src/components/avatar/stream_user_avatar_stack.dart'; export 'src/components/message_composer/message_composer.dart'; export 'src/components/stream_chat_component_builders.dart'; // endregion -export 'src/fullscreen_media/full_screen_media.dart'; -export 'src/fullscreen_media/full_screen_media_builder.dart'; -export 'src/gallery/gallery_footer.dart'; -export 'src/gallery/gallery_header.dart'; export 'src/icons/stream_svg_icon.dart'; export 'src/indicators/sending_indicator.dart'; export 'src/indicators/typing_indicator.dart'; @@ -161,6 +171,14 @@ export 'src/indicators/unread_indicator.dart'; export 'src/keyboard_shortcuts/keyboard_shortcut_runner.dart'; export 'src/localization/stream_chat_localizations.dart'; export 'src/localization/translations.dart' show DefaultTranslations; +export 'src/media_gallery/stream_media_gallery.dart'; +export 'src/media_gallery/stream_media_gallery_attachment.dart'; +export 'src/media_gallery/stream_media_gallery_item.dart'; +export 'src/media_gallery_preview/stream_media_gallery_preview.dart'; +export 'src/media_gallery_preview/stream_media_gallery_preview_footer.dart'; +export 'src/media_gallery_preview/stream_media_gallery_preview_header.dart'; +export 'src/media_gallery_preview/stream_media_gallery_preview_item.dart'; +export 'src/media_gallery_preview/video_player/stream_video_player.dart'; export 'src/message_action/message_action.dart'; export 'src/message_action/message_actions_builder.dart'; export 'src/message_input/attachment_picker/stream_attachment_picker.dart'; diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index e63d1d61eb..f6d20ec802 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -59,10 +59,7 @@ dependencies: shimmer: ^3.0.0 stream_chat_flutter_core: ^10.0.0-beta.13 stream_core_flutter: - git: - url: https://github.com/GetStream/stream-core-flutter.git - ref: da615a2b232948bf89e46ea3d4c2e99084420544 - path: packages/stream_core_flutter + path: /Users/xsahil03x/StudioProjects/stream-core-flutter/packages/stream_core_flutter svg_icon_widget: ^0.0.1 synchronized: ^3.1.0+1 theme_extensions_builder_annotation: ^7.1.0 diff --git a/packages/stream_chat_flutter/test/src/full_screen_media/full_screen_media_test.dart b/packages/stream_chat_flutter/test/src/full_screen_media/full_screen_media_test.dart deleted file mode 100644 index eed83d1393..0000000000 --- a/packages/stream_chat_flutter/test/src/full_screen_media/full_screen_media_test.dart +++ /dev/null @@ -1,125 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:photo_view/photo_view.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../mocks.dart'; - -void main() { - testWidgets( - 'renders the photo with header and footer icons', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - final channelState = MockChannelState(); - final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - when(() => channel.lastMessageAt).thenReturn(lastMessageAt); - when(() => channel.state).thenReturn(channelState); - when(() => channel.client).thenReturn(client); - when(() => channel.isMuted).thenReturn(false); - when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); - when(() => channel.extraDataStream).thenAnswer( - (i) => Stream.value({ - 'name': 'test', - }), - ); - when(() => channel.extraData).thenReturn({ - 'name': 'test', - }); - when(() => channelState.membersStream).thenAnswer( - (i) => Stream.value([ - Member( - userId: 'user-id', - user: User(id: 'user-id'), - ), - ]), - ); - when(() => channelState.members).thenReturn([ - Member( - userId: 'user-id', - user: User(id: 'user-id'), - ), - ]); - when(() => channelState.messages).thenReturn([ - Message( - text: 'hello', - user: User(id: 'other-user'), - ), - ]); - when(() => channelState.messagesStream).thenAnswer( - (i) => Stream.value([ - Message( - text: 'hello', - user: User(id: 'other-user'), - ), - ]), - ); - when(() => channelState.typingEvents).thenAnswer( - (i) => { - User(id: 'other-user', extraData: const {'name': 'demo'}): Event(type: EventType.typingStart), - }, - ); - when(() => channelState.typingEventsStream).thenAnswer( - (i) => Stream.value({ - User(id: 'other-user', extraData: const {'name': 'demo'}): Event(type: EventType.typingStart), - }), - ); - - final attachment = Attachment( - type: 'image', - title: 'demo image', - imageUrl: '', - ); - final message = Message( - createdAt: DateTime.now(), - attachments: [ - attachment, - ], - ); - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: client, - child: StreamChannel( - channel: channel, - child: child!, - ), - ), - home: Builder( - builder: (context) => Scaffold( - body: Center( - child: ElevatedButton( - onPressed: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => StreamFullScreenMedia( - mediaAttachmentPackages: [ - StreamAttachmentPackage( - attachment: attachment, - message: message, - ), - ], - ), - ), - ), - child: const Text('Open media'), - ), - ), - ), - ), - ), - ); - - await tester.pumpAndSettle(); - await tester.tap(find.byType(ElevatedButton)); - await tester.pumpAndSettle(); - - expect(find.byType(PhotoView), findsOneWidget); - expect(find.byType(Icon), findsNWidgets(4)); - }, - ); -} diff --git a/packages/stream_chat_flutter/test/src/gallery/gallery_footer_test.dart b/packages/stream_chat_flutter/test/src/gallery/gallery_footer_test.dart deleted file mode 100644 index cfb4cd9efa..0000000000 --- a/packages/stream_chat_flutter/test/src/gallery/gallery_footer_test.dart +++ /dev/null @@ -1,115 +0,0 @@ -import 'package:alchemist/alchemist.dart'; // Changed from golden_toolkit -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../material_app_wrapper.dart'; -import '../mocks.dart'; - -void main() { - late MockClient client; - late MockClientState clientState; - late MockChannel channel; - late MockChannelState channelState; - const methodChannel = MethodChannel('dev.fluttercommunity.plus/connectivity_status'); - - setUpAll(() { - client = MockClient(); - clientState = MockClientState(); - channel = MockChannel(); - channelState = MockChannelState(); - final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - when(() => channel.lastMessageAt).thenReturn(lastMessageAt); - when(() => channel.state).thenReturn(channelState); - when(() => channel.client).thenReturn(client); - when(() => channel.isMuted).thenReturn(false); - when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); - when(() => channel.extraDataStream).thenAnswer( - (i) => Stream.value({ - 'name': 'test', - }), - ); - when(() => channel.extraData).thenReturn({ - 'name': 'test', - }); - }); - - setUp(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(methodChannel, ( - MethodCall methodCall, - ) async { - if (methodCall.method == 'listen') { - try { - await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage( - methodChannel.name, - methodChannel.codec.encodeSuccessEnvelope(['wifi']), - (_) {}, - ); - } catch (e) { - print(e); - } - } - return null; - }); - }); - - testWidgets( - 'it should show channel typing', - (WidgetTester tester) async { - await tester.pumpWidget( - MaterialApp( - home: StreamChat( - client: client, - child: StreamChannel( - channel: channel, - child: PopScope( - onPopInvokedWithResult: (bool didPop, res) async => false, - child: const Scaffold( - body: StreamGalleryFooter( - mediaAttachmentPackages: [], - ), - ), - ), - ), - ), - ), - ); - - // wait for the initial state to be rendered. - await tester.pumpAndSettle(); - - expect(find.byType(Icon), findsNWidgets(2)); - }, - ); - - goldenTest( - 'golden test for GalleryFooter', - fileName: 'gallery_footer_0', - constraints: const BoxConstraints.tightFor(width: 400, height: 300), - builder: () => MaterialAppWrapper( - home: StreamChat( - client: client, - child: StreamChannel( - channel: channel, - child: PopScope( - onPopInvokedWithResult: (bool didPop, res) async => false, - child: const Scaffold( - bottomNavigationBar: StreamGalleryFooter( - mediaAttachmentPackages: [], - ), - ), - ), - ), - ), - ), - ); - - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(methodChannel, null); - }); -} diff --git a/packages/stream_chat_flutter/test/src/gallery/gallery_header_test.dart b/packages/stream_chat_flutter/test/src/gallery/gallery_header_test.dart deleted file mode 100644 index a5b6460b81..0000000000 --- a/packages/stream_chat_flutter/test/src/gallery/gallery_header_test.dart +++ /dev/null @@ -1,110 +0,0 @@ -import 'package:alchemist/alchemist.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../material_app_wrapper.dart'; -import '../mocks.dart'; - -void main() { - late MockClient client; - late MockClientState clientState; - late MockChannel channel; - late MockChannelState channelState; - - setUpAll(() { - client = MockClient(); - clientState = MockClientState(); - channel = MockChannel(); - channelState = MockChannelState(); - final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - when(() => channel.lastMessageAt).thenReturn(lastMessageAt); - when(() => channel.state).thenReturn(channelState); - when(() => channel.client).thenReturn(client); - when(() => channel.isMuted).thenReturn(false); - when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); - when(() => channel.extraDataStream).thenAnswer( - (i) => Stream.value({ - 'name': 'test', - }), - ); - when(() => channel.extraData).thenReturn({ - 'name': 'test', - }); - }); - - testWidgets( - 'renders auto-implied back button alongside the overflow action', - (WidgetTester tester) async { - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: client, - connectivityStream: .value([.wifi]), - child: StreamChannel( - channel: channel, - child: child!, - ), - ), - home: Builder( - builder: (context) => Scaffold( - body: Center( - child: ElevatedButton( - onPressed: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => Scaffold( - appBar: StreamGalleryHeader( - attachment: MockAttachment(), - message: Message(), - ), - ), - ), - ), - child: const Text('Open gallery'), - ), - ), - ), - ), - ), - ); - - await tester.pumpAndSettle(); - await tester.tap(find.byType(ElevatedButton)); - await tester.pumpAndSettle(); - - expect(find.byType(Icon), findsNWidgets(2)); - }, - ); - - goldenTest( - 'golden test for GalleryHeader', - fileName: 'gallery_header_0', - constraints: const BoxConstraints.tightFor(width: 300, height: 300), - builder: () { - return MaterialAppWrapper( - home: StreamChat( - client: client, - connectivityStream: .value([.wifi]), - child: StreamChannel( - channel: channel, - child: PopScope( - onPopInvokedWithResult: (bool didPop, res) async => false, - child: Scaffold( - appBar: StreamGalleryHeader( - userName: 'User', - sentAt: '12:02 AM', - message: Message(), - attachment: MockAttachment(), - ), - ), - ), - ), - ), - ); - }, - ); -} diff --git a/packages/stream_chat_flutter/test/src/gallery/goldens/ci/gallery_footer_0.png b/packages/stream_chat_flutter/test/src/gallery/goldens/ci/gallery_footer_0.png deleted file mode 100644 index e42f55979c9ad616c9472b875b92be69a0d20582..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1221 zcmeAS@N?(olHy`uVBq!ia0y~yV4MKNIvi|3k+<8Q9s*J<#ZI0f96(URkH}uUUQl%TWF`|9-qM#D1B3hnnikzIFW9joxC@$;r~-;=tg+LM9cH yd3x~`^(;ApC#4nbq#U+1-(m!oLkym-elF{r5}E*HIhZ&A diff --git a/packages/stream_chat_flutter/test/src/gallery/goldens/ci/gallery_header_0.png b/packages/stream_chat_flutter/test/src/gallery/goldens/ci/gallery_header_0.png deleted file mode 100644 index 8be074be20dbc6e02833fb6cb084fd4d5bf95385..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1189 zcmeAS@N?(olHy`uVBq!ia0y~yVAKI&4mO}jWo=(6kYXuz@(kesf*OvL4j`YgILO_J zVcj{Imq0mxPZ!6KiaBrZZuDev6lqBGU&noxPv^)1wl$a8cywHDyg9>i@WZ5yt*nx5 z|KI)QiirF2xlA&RKe?xnU7%op6+^>b7VZ-QIttDk8h8#e8R6#it!7W?Yd^mHsXY#x7Q*)oiC`-fT z_9g0m`txa570bVekN@U=Yv*<|*ImnbS8X;s z3k(U1y8nOH&0|cMe)wroVKnoE2U7)z3M$-zI4XlLiH(NNK!;9`{e)Zcev2GFxdF== N22WQ%mvv4FO#r&1Y=r;- diff --git a/packages/stream_chat_flutter/test/src/theme/avatar_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/avatar_theme_test.dart deleted file mode 100644 index bda9511ed9..0000000000 --- a/packages/stream_chat_flutter/test/src/theme/avatar_theme_test.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -void main() { - test('AvatarThemeData copyWith, ==, hashCode basics', () { - expect(const StreamAvatarThemeData(), const StreamAvatarThemeData().copyWith()); - expect(const StreamAvatarThemeData().hashCode, const StreamAvatarThemeData().copyWith().hashCode); - }); - - group('AvatarThemeData lerps correctly', () { - test('Lerp completely', () { - expect( - const StreamAvatarThemeData().lerp(_avatarThemeDataControl1, _avatarThemeDataControl2, 1), - _avatarThemeDataControl2, - ); - }); - - test('Lerp halfway', () { - expect( - const StreamAvatarThemeData().lerp( - _avatarThemeDataControl1, - _avatarThemeDataControl2, - 0.5, - ), - _avatarThemeDataControlMidLerp, - // TODO: Remove skip, once we drop support for flutter v3.24.0 - skip: true, - reason: 'Currently failing in flutter v3.27.0 due to new color alpha', - ); - }); - }); - - test('Merging two AvatarThemeData results in the latter', () { - expect(_avatarThemeDataControl1.merge(_avatarThemeDataControl2), _avatarThemeDataControl2); - }); -} - -const _avatarThemeDataControl1 = StreamAvatarThemeData(); - -final _avatarThemeDataControlMidLerp = StreamAvatarThemeData( - borderRadius: BorderRadius.circular(16), - constraints: const BoxConstraints.tightFor( - height: 33, - width: 33, - ), -); - -final _avatarThemeDataControl2 = StreamAvatarThemeData( - borderRadius: BorderRadius.circular(12), - constraints: const BoxConstraints.tightFor( - height: 34, - width: 34, - ), -); diff --git a/packages/stream_chat_flutter/test/src/theme/gallery_footer_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/gallery_footer_theme_test.dart deleted file mode 100644 index fb0c5699a0..0000000000 --- a/packages/stream_chat_flutter/test/src/theme/gallery_footer_theme_test.dart +++ /dev/null @@ -1,153 +0,0 @@ -import 'package:flutter/material.dart' hide TextTheme; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -class MockStreamChatClient extends Mock implements StreamChatClient {} - -void main() { - test('GalleryFooterThemeData copyWith, ==, hashCode basics', () { - expect(const StreamGalleryFooterThemeData(), const StreamGalleryFooterThemeData().copyWith()); - expect(const StreamGalleryFooterThemeData().hashCode, const StreamGalleryFooterThemeData().copyWith().hashCode); - }); - - test('''Light GalleryFooterThemeData lerps completely to dark GalleryFooterThemeData''', () { - expect( - const StreamGalleryFooterThemeData().lerp(_galleryFooterThemeDataControl, _galleryFooterThemeDataControlDark, 1), - _galleryFooterThemeDataControlDark, - ); - }); - - test('''Light GalleryFooterThemeData lerps halfway to dark GalleryFooterThemeData''', () { - expect( - const StreamGalleryFooterThemeData().lerp( - _galleryFooterThemeDataControl, - _galleryFooterThemeDataControlDark, - 0.5, - ), - _galleryFooterThemeDataControlMidLerp, - // TODO: Remove skip, once we drop support for flutter v3.24.0 - skip: true, - reason: 'Currently failing in flutter v3.27.0 due to new color alpha', - ); - }); - - test('''Dark GalleryFooterThemeData lerps completely to light GalleryFooterThemeData''', () { - expect( - const StreamGalleryFooterThemeData().lerp(_galleryFooterThemeDataControlDark, _galleryFooterThemeDataControl, 1), - _galleryFooterThemeDataControl, - ); - }); - - test('Merging dark and light themes results in a dark theme', () { - expect( - _galleryFooterThemeDataControl.merge(_galleryFooterThemeDataControlDark), - _galleryFooterThemeDataControlDark, - ); - }); - - test('Merging dark and light themes results in a dark theme', () { - expect(_galleryFooterThemeDataControlDark.merge(_galleryFooterThemeDataControl), _galleryFooterThemeDataControl); - }); - - testWidgets('Passing no GalleryFooterThemeData returns default light theme values', (WidgetTester tester) async { - late BuildContext _context; - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: MockStreamChatClient(), - child: child, - ), - home: Builder( - builder: (context) { - _context = context; - return const SizedBox.shrink(); - }, - ), - ), - ); - - final imageFooterTheme = StreamGalleryFooterTheme.of(_context); - expect(imageFooterTheme.backgroundColor, _galleryFooterThemeDataControl.backgroundColor); - expect(imageFooterTheme.shareIconColor, _galleryFooterThemeDataControl.shareIconColor); - expect(imageFooterTheme.titleTextStyle, _galleryFooterThemeDataControl.titleTextStyle); - expect(imageFooterTheme.gridIconButtonColor, _galleryFooterThemeDataControl.gridIconButtonColor); - expect(imageFooterTheme.bottomSheetBarrierColor, _galleryFooterThemeDataControl.bottomSheetBarrierColor); - expect(imageFooterTheme.bottomSheetBackgroundColor, _galleryFooterThemeDataControl.bottomSheetBackgroundColor); - expect(imageFooterTheme.bottomSheetCloseIconColor, _galleryFooterThemeDataControl.bottomSheetCloseIconColor); - expect(imageFooterTheme.bottomSheetPhotosTextStyle, _galleryFooterThemeDataControl.bottomSheetPhotosTextStyle); - }); - - testWidgets('Passing no GalleryFooterThemeData returns default dark theme values', (WidgetTester tester) async { - late BuildContext _context; - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: MockStreamChatClient(), - streamChatThemeData: StreamChatThemeData.dark(), - child: child, - ), - home: Builder( - builder: (context) { - _context = context; - return const SizedBox.shrink(); - }, - ), - ), - ); - - final imageFooterTheme = StreamGalleryFooterTheme.of(_context); - expect(imageFooterTheme.backgroundColor, _galleryFooterThemeDataControlDark.backgroundColor); - expect(imageFooterTheme.shareIconColor, _galleryFooterThemeDataControlDark.shareIconColor); - expect(imageFooterTheme.titleTextStyle, _galleryFooterThemeDataControlDark.titleTextStyle); - expect(imageFooterTheme.gridIconButtonColor, _galleryFooterThemeDataControlDark.gridIconButtonColor); - expect(imageFooterTheme.bottomSheetBarrierColor, _galleryFooterThemeDataControlDark.bottomSheetBarrierColor); - expect(imageFooterTheme.bottomSheetBackgroundColor, _galleryFooterThemeDataControlDark.bottomSheetBackgroundColor); - expect(imageFooterTheme.bottomSheetCloseIconColor, _galleryFooterThemeDataControlDark.bottomSheetCloseIconColor); - expect(imageFooterTheme.bottomSheetPhotosTextStyle, _galleryFooterThemeDataControlDark.bottomSheetPhotosTextStyle); - }); -} - -// Light theme control -final _galleryFooterThemeDataControl = StreamGalleryFooterThemeData( - backgroundColor: const StreamColorTheme.light().barsBg, - shareIconColor: const StreamColorTheme.light().textHighEmphasis, - titleTextStyle: const StreamTextTheme.light().headlineBold, - gridIconButtonColor: const StreamColorTheme.light().textHighEmphasis, - bottomSheetBackgroundColor: const StreamColorTheme.light().barsBg, - bottomSheetBarrierColor: const StreamColorTheme.light().overlay, - bottomSheetCloseIconColor: const StreamColorTheme.light().textHighEmphasis, - bottomSheetPhotosTextStyle: const StreamTextTheme.light().headlineBold, -); - -// Mid-lerp theme control -const _galleryFooterThemeDataControlMidLerp = StreamGalleryFooterThemeData( - backgroundColor: Color(0xff88898a), - shareIconColor: Color(0xff7f7f7f), - titleTextStyle: TextStyle( - color: Color(0xff7f7f7f), - fontSize: 16, - fontWeight: FontWeight.w500, - ), - gridIconButtonColor: Color(0xff7f7f7f), - bottomSheetBarrierColor: Color(0x4c000000), - bottomSheetBackgroundColor: Color(0xff88898a), - bottomSheetPhotosTextStyle: TextStyle( - color: Color(0xff7f7f7f), - fontSize: 16, - fontWeight: FontWeight.w500, - ), - bottomSheetCloseIconColor: Color(0xff7f7f7f), -); - -// Dark theme control -final _galleryFooterThemeDataControlDark = StreamGalleryFooterThemeData( - backgroundColor: const StreamColorTheme.dark().barsBg, - shareIconColor: const StreamColorTheme.dark().textHighEmphasis, - titleTextStyle: const StreamTextTheme.dark().headlineBold, - gridIconButtonColor: const StreamColorTheme.dark().textHighEmphasis, - bottomSheetBackgroundColor: const StreamColorTheme.dark().barsBg, - bottomSheetBarrierColor: const StreamColorTheme.dark().overlay, - bottomSheetCloseIconColor: const StreamColorTheme.dark().textHighEmphasis, - bottomSheetPhotosTextStyle: const StreamTextTheme.dark().headlineBold, -); diff --git a/packages/stream_chat_localizations/CHANGELOG.md b/packages/stream_chat_localizations/CHANGELOG.md index 50af5ccea0..dc17364220 100644 --- a/packages/stream_chat_localizations/CHANGELOG.md +++ b/packages/stream_chat_localizations/CHANGELOG.md @@ -30,6 +30,9 @@ - Added `pollVotesLabel` translation for all supported locales. - Added `endVoteConfirmationMessage` translation for all supported locales. - Added `reactionsCountText(int count)` translation for all supported locales. +- Added `photosAndVideosLabel` translation (default `Photos & Videos`) for all + supported locales. Used by the new media gallery preview footer's thumbnail- + grid sheet header. 🔄 Changed diff --git a/packages/stream_chat_localizations/example/lib/add_new_lang.dart b/packages/stream_chat_localizations/example/lib/add_new_lang.dart index 03b4421369..173f77aaee 100644 --- a/packages/stream_chat_localizations/example/lib/add_new_lang.dart +++ b/packages/stream_chat_localizations/example/lib/add_new_lang.dart @@ -256,6 +256,9 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { @override String get photosLabel => 'Photos'; + @override + String get photosAndVideosLabel => 'Photos & Videos'; + String _getDay(DateTime dateTime) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart index e06e8051fc..3b8b467d41 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart @@ -235,6 +235,9 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { @override String get photosLabel => 'Fotos'; + @override + String get photosAndVideosLabel => 'Fotos i vídeos'; + String _getDay(DateTime dateTime) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart index f3fa63af7c..0761fb9743 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart @@ -229,6 +229,9 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { @override String get photosLabel => 'Fotos'; + @override + String get photosAndVideosLabel => 'Fotos & Videos'; + String _getDay(DateTime dateTime) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart index 4e0f127f58..bb53b34fab 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart @@ -234,6 +234,9 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { @override String get photosLabel => 'Photos'; + @override + String get photosAndVideosLabel => 'Photos & Videos'; + String _getDay(DateTime dateTime) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart index 5c504bb93f..2d971b1a2e 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart @@ -235,6 +235,9 @@ class StreamChatLocalizationsEs extends GlobalStreamChatLocalizations { @override String get photosLabel => 'Fotos'; + @override + String get photosAndVideosLabel => 'Fotos y vídeos'; + String _getDay(DateTime dateTime) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart index 583bedf922..b6ace6fdac 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart @@ -235,6 +235,9 @@ class StreamChatLocalizationsFr extends GlobalStreamChatLocalizations { @override String get photosLabel => 'Photos'; + @override + String get photosAndVideosLabel => 'Photos et vidéos'; + String _getDay(DateTime dateTime) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart index bb55615abc..199faf2bd1 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart @@ -233,6 +233,9 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { @override String get photosLabel => 'फ़ोटोज'; + @override + String get photosAndVideosLabel => 'फ़ोटोज़ और वीडियो'; + String _getDay(DateTime dateTime) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart index 4c2eb13809..f5f98a969b 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart @@ -238,6 +238,9 @@ Il file è troppo grande per essere caricato. Il limite è di $limitInMB MB.'''; @override String get photosLabel => 'Foto'; + @override + String get photosAndVideosLabel => 'Foto e video'; + String _getDay(DateTime dateTime) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart index 366772a388..817cc2ea0a 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart @@ -225,6 +225,9 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { @override String get photosLabel => '写真'; + @override + String get photosAndVideosLabel => '写真と動画'; + String _getDay(DateTime dateTime) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart index 2cbeec63e7..197ce29a64 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart @@ -226,6 +226,9 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { @override String get photosLabel => '사진'; + @override + String get photosAndVideosLabel => '사진 및 동영상'; + String _getDay(DateTime dateTime) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart index 445af3725e..87ccc9a320 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart @@ -230,6 +230,9 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { @override String get photosLabel => 'Foto'; + @override + String get photosAndVideosLabel => 'Foto og video'; + String _getDay(DateTime dateTime) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart index 76cc5f8500..1f7f223504 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart @@ -231,6 +231,9 @@ class StreamChatLocalizationsPt extends GlobalStreamChatLocalizations { @override String get photosLabel => 'Fotos'; + @override + String get photosAndVideosLabel => 'Fotos e vídeos'; + String _getDay(DateTime dateTime) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); diff --git a/sample_app/lib/pages/channel_media_display_screen.dart b/sample_app/lib/pages/channel_media_display_screen.dart index fb65e46148..aea698f166 100644 --- a/sample_app/lib/pages/channel_media_display_screen.dart +++ b/sample_app/lib/pages/channel_media_display_screen.dart @@ -1,10 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:sample_app/routes/routes.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Lists every photo + video shared in the enclosing channel as a 3-up -/// grid. Tapping a tile opens the [StreamFullScreenMedia] gallery. +/// grid. Tapping a tile opens [StreamMediaGalleryPreview]. /// /// Matches Figma frames `8833:437788` (grid), `13495:418984` (scrolled), /// and `8833:437329` (empty). @@ -42,7 +40,7 @@ class _ChannelMediaDisplayScreenState extends State { final colorScheme = context.streamColorScheme; return Scaffold( backgroundColor: colorScheme.backgroundApp, - appBar: StreamAppBar(title: const Text('Photos & Videos')), + appBar: StreamAppBar(title: Text(context.translations.photosAndVideosLabel)), body: ValueListenableBuilder>( valueListenable: _controller, builder: (context, value, _) => value.when( @@ -50,30 +48,23 @@ class _ChannelMediaDisplayScreenState extends State { // Flatten messages → individual image/video attachments. // Excludes link previews (`ogScrapeUrl != null`) so we don't // render every shared URL's thumbnail in the grid. - final media = <_MediaItem>[ + final attachments = [ for (final response in items) - for (final attachment in response.message.attachments) - if ((attachment.type == 'image' || attachment.type == 'video') && attachment.ogScrapeUrl == null) - _MediaItem(attachment, response.message), + ...response.message.toMediaGalleryAttachments( + filter: (a) => + (a.type == AttachmentType.image || a.type == AttachmentType.video) && a.ogScrapeUrl == null, + ), ]; - if (media.isEmpty) return const Center(child: _EmptyState()); + if (attachments.isEmpty) return const Center(child: _EmptyState()); return LazyLoadScrollView( onEndOfPage: () async { if (nextPageKey != null) await _controller.loadMore(nextPageKey); }, - child: GridView.builder( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - mainAxisSpacing: 1, - crossAxisSpacing: 1, - ), - itemCount: media.length, - itemBuilder: (context, index) => _MediaTile( - index: index, - items: media, - ), + child: StreamMediaGallery( + attachments: attachments, + onItemTap: (index) => _openPreview(context, attachments, index), ), ); }, @@ -88,71 +79,20 @@ class _ChannelMediaDisplayScreenState extends State { ), ); } -} - -/// Single attachment + its enclosing message — paired so the full-screen -/// gallery can show sender / timestamp metadata when opened. -class _MediaItem { - const _MediaItem(this.attachment, this.message); - - final Attachment attachment; - final Message message; -} - -/// One cell in the photo grid. Renders the attachment's thumbnail -/// (image or video) via [StreamNetworkImage]; videos overlay a -/// [StreamVideoPlayIndicator]. Tapping opens the full-screen gallery -/// at this index — every other media item in the channel is wired up -/// as a swipeable sibling. -class _MediaTile extends StatelessWidget { - const _MediaTile({required this.index, required this.items}); - - final int index; - final List<_MediaItem> items; - - @override - Widget build(BuildContext context) { - final item = items[index]; - final attachment = item.attachment; - final isVideo = attachment.type == 'video'; - final thumbUrl = attachment.thumbUrl ?? attachment.imageUrl ?? attachment.assetUrl; - - return GestureDetector( - onTap: () => _open(context), - child: Stack( - fit: StackFit.expand, - children: [ - if (thumbUrl != null) - StreamNetworkImage(thumbUrl, fit: BoxFit.cover) - else - ColoredBox(color: context.streamColorScheme.backgroundSurfaceCard), - if (isVideo) const Center(child: StreamVideoPlayIndicator()), - ], - ), - ); - } - void _open(BuildContext context) { + void _openPreview( + BuildContext context, + List attachments, + int index, + ) { final channel = StreamChannel.of(context).channel; Navigator.of(context).push( MaterialPageRoute( builder: (_) => StreamChannel( channel: channel, - child: StreamFullScreenMedia( - mediaAttachmentPackages: [ - for (final m in items) StreamAttachmentPackage(attachment: m.attachment, message: m.message), - ], - startIndex: index, - userName: items[index].message.user?.name ?? '', - onShowMessage: (message, _) async { - final router = GoRouter.of(context); - if (channel.state == null) await channel.watch(); - router.pushNamed( - Routes.CHANNEL_PAGE.name, - pathParameters: Routes.CHANNEL_PAGE.params(channel), - queryParameters: Routes.CHANNEL_PAGE.queryParams(message), - ); - }, + child: StreamMediaGalleryPreview( + attachments: attachments, + initialIndex: index, ), ), ), From 912341f776c6ab6bd6669144be62006e45122649 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 13 May 2026 08:32:12 +0200 Subject: [PATCH 2/4] test(localization): cover photosAndVideosLabel across all locales Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/stream_chat_localizations/test/translations_test.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/stream_chat_localizations/test/translations_test.dart b/packages/stream_chat_localizations/test/translations_test.dart index 992cbfff17..3efef36345 100644 --- a/packages/stream_chat_localizations/test/translations_test.dart +++ b/packages/stream_chat_localizations/test/translations_test.dart @@ -116,6 +116,7 @@ void main() { isNotNull, ); expect(localizations.photosLabel, isNotNull); + expect(localizations.photosAndVideosLabel, isNotNull); // today expect( localizations.sentAtText( From 8c4a6d01e8ae742bef0bef1e060275cc6e400c0d Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 15 May 2026 10:55:21 +0200 Subject: [PATCH 3/4] refactor(dependencies): switch stream_core_flutter dependency to git source --- docs/docs_screenshots/pubspec.yaml | 5 ++++- melos.yaml | 5 ++++- packages/stream_chat_flutter/pubspec.yaml | 5 ++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/docs_screenshots/pubspec.yaml b/docs/docs_screenshots/pubspec.yaml index 88af4fe485..a23572a9de 100644 --- a/docs/docs_screenshots/pubspec.yaml +++ b/docs/docs_screenshots/pubspec.yaml @@ -20,7 +20,10 @@ dependencies: record: ^6.2.0 stream_chat_flutter: ^10.0.0-beta.13 stream_core_flutter: - path: /Users/xsahil03x/StudioProjects/stream-core-flutter/packages/stream_core_flutter + git: + url: https://github.com/GetStream/stream-core-flutter.git + ref: 333f7b72485f308b282cc85973223a2919fd8153 + path: packages/stream_core_flutter dev_dependencies: alchemist: ^0.14.0 diff --git a/melos.yaml b/melos.yaml index fbe22686ef..2740e47901 100644 --- a/melos.yaml +++ b/melos.yaml @@ -99,7 +99,10 @@ command: svg_icon_widget: ^0.0.1 # TODO: Replace with hosted version before merging PR stream_core_flutter: - path: /Users/xsahil03x/StudioProjects/stream-core-flutter/packages/stream_core_flutter + git: + url: https://github.com/GetStream/stream-core-flutter.git + ref: 333f7b72485f308b282cc85973223a2919fd8153 + path: packages/stream_core_flutter synchronized: ^3.1.0+1 thumblr: ^0.0.4 url_launcher: ^6.3.0 diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index f6d20ec802..d294551821 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -59,7 +59,10 @@ dependencies: shimmer: ^3.0.0 stream_chat_flutter_core: ^10.0.0-beta.13 stream_core_flutter: - path: /Users/xsahil03x/StudioProjects/stream-core-flutter/packages/stream_core_flutter + git: + url: https://github.com/GetStream/stream-core-flutter.git + ref: 333f7b72485f308b282cc85973223a2919fd8153 + path: packages/stream_core_flutter svg_icon_widget: ^0.0.1 synchronized: ^3.1.0+1 theme_extensions_builder_annotation: ^7.1.0 From 0c02576806ab47b9c6ff0c6d9a893dcbbaa0c97a Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 15 May 2026 11:27:38 +0200 Subject: [PATCH 4/4] refactor(dependencies): remove unused stream_core_flutter import from image_attachment_thumbnail.dart --- .../lib/src/attachment/thumbnail/image_attachment_thumbnail.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/stream_chat_flutter/lib/src/attachment/thumbnail/image_attachment_thumbnail.dart b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/image_attachment_thumbnail.dart index 728c762332..e9a3c0c909 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/thumbnail/image_attachment_thumbnail.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/image_attachment_thumbnail.dart @@ -3,7 +3,6 @@ import 'dart:io' show File; import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/attachment/thumbnail/thumbnail_size_calculator.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:stream_core_flutter/stream_core_flutter.dart'; /// {@template imageAttachmentThumbnail} /// Widget for building image attachment thumbnail.