diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index d83fed232a..3bdf7b85c0 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -714,8 +714,22 @@ final label = switch (state) { All public API members must have explicit type annotations — parameters, fields, and return types (`type_annotate_public_apis`, `always_declare_return_types`). This -includes top-level declarations and static members. The linter will reject a public -member without a declared type. +includes top-level declarations and static members. The linter rejects a public member +without a declared type, with one gap described below. + +A public constant or field that serves as a default declares the most general type its +consumers accept, not the class that implements it today: `SliverGridDelegate`, +`ShapeBorder`, `Widget`. The declared type is the API; the implementation behind it can +then change without changing it. `type_annotate_public_apis` doesn't flag a top-level +`const` or `final` whose type is inferred from its initializer, so reviewers check this one. + +```dart +// BAD: the public type is whatever implementation the default uses today. +const defaultGalleryDelegate = StreamMediaGridDelegate(mainAxisSpacing: 2); + +// GOOD: +const SliverGridDelegate defaultGalleryDelegate = StreamMediaGridDelegate(mainAxisSpacing: 2); +``` Avoid `dynamic` in public APIs. If the type is unknown, prefer `Object?` and casting; `dynamic` disables all static checking. diff --git a/migrations/redesign/message_widget.md b/migrations/redesign/message_widget.md index 55f5e31133..08eff9c1c6 100644 --- a/migrations/redesign/message_widget.md +++ b/migrations/redesign/message_widget.md @@ -198,7 +198,7 @@ These parameters have been removed entirely. See the **Migration Path** column f | `padding` | Outer padding around the message item (overrides theme) | | `spacing` | Horizontal spacing between avatar and content (overrides theme) | | `backgroundColor` | Background color for the message row (overrides theme) | -| `maxWidth` | Max content width in logical pixels (default: `264` on `StreamMessageItem`, `272` on `StreamMessageItemProps`) | +| `maxWidth` | Max content width in logical pixels (default: `272`) | | `onMessageLinkTap` | `void Function(Message, String)` — receives message and URL | | `onUserMentionTap` | `void Function(User)` — receives the mentioned user | | `onQuotedMessageTap` | `void Function(Message)` — receives the quoted message object | diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index cf0afe566a..6f6cea7889 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -7,12 +7,14 @@ - Added `MessageTranslationLanguage`, the language `StreamMessageContent` shows a message translated into, for custom attachments to show translations too. - Added `automaticallyImplyLeading` to `StreamChannelPage` and `StreamThreadPage`, which hides the header's back button when `false`. - Added a `selected` parameter to `StreamThreadListTile`, which highlights the tile, and `StreamThreadListTile.copyWith`. +- Added `StreamMediaGallery.gridDelegate` to change how the gallery lays out its tiles. - `StreamMessageListView` now shows who is typing at the newest end of the list with `StreamMessageListTypingIndicator`, replacing the indicator above `StreamChannelPage`'s composer. Replace it with `StreamMessageListViewBuilders.typingIndicator`, or hide it with `StreamMessageListViewConfiguration.showTypingIndicator`. - Added `StreamTypingUsersBuilder`, to build a widget from the users typing in a channel or thread. ⚠️ Deprecated - Deprecated `MessageComposerProps.validator` and the `validator` parameter of `StreamMessageComposer`; pass a `StreamMessageComposerController(validator: …)` as `messageComposerController` instead. Passing a custom one together with a `messageComposerController` fails an assert. +- Deprecated `StreamMediaGallery.crossAxisCount` in favour of `gridDelegate`. 🔄 Changed @@ -24,6 +26,9 @@ - `StreamMessageComposer` now refuses dropped files when `disableAttachments` is set or `allowedAttachmentPickerTypes` leaves out `AttachmentPickerType.files`. - `StreamTypingIndicator` now uses the design system's typing dots; the `lottie` dependency is removed. - Suggestions from `customAutocompleteTriggers` now span the composer's full width. `StreamAutocompleteOptions` keeps its options clear of the side safe-area insets; other options views should wrap their content in `StreamSafeArea(top: false, bottom: false)`. +- `StreamMediaGallery` and `StreamPhotoGallery`, including `defaultStreamPhotoGalleryDelegate`, now show more tiles per row in wider grids instead of always 3. Pass a `SliverGridDelegateWithFixedCrossAxisCount` as `gridDelegate` to keep a fixed count. +- `defaultStreamPhotoGalleryDelegate` is now declared as a `SliverGridDelegate`. +- `StreamMessageItem`'s `maxWidth` now defaults to `272`, the same as `StreamMessageItemProps`. 🐞 Fixed 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 index 4b715fb114..5be04377f9 100644 --- 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 @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../../stream_chat_flutter.dart'; +import 'stream_media_grid_delegate.dart'; /// A scrollable grid of [StreamMediaGalleryAttachment]s — the thumbnail /// companion to [StreamMediaGalleryPreview]. @@ -40,7 +41,8 @@ class StreamMediaGallery extends StatelessWidget { StreamMediaGallery({ super.key, required List attachments, - int crossAxisCount = 3, + @Deprecated('Use gridDelegate instead.') int? crossAxisCount, + SliverGridDelegate? gridDelegate, EdgeInsetsGeometry? padding, ScrollController? scrollController, ValueChanged? onItemTap, @@ -48,6 +50,7 @@ class StreamMediaGallery extends StatelessWidget { }) : props = .new( attachments: attachments, crossAxisCount: crossAxisCount, + gridDelegate: gridDelegate, padding: padding, scrollController: scrollController, onItemTap: onItemTap, @@ -79,18 +82,36 @@ class StreamMediaGalleryProps { /// Creates properties for a media gallery. const StreamMediaGalleryProps({ required this.attachments, - this.crossAxisCount = 3, + @Deprecated('Use gridDelegate instead.') int? crossAxisCount, + this.gridDelegate, this.padding, this.scrollController, this.onItemTap, this.onItemLongPress, - }); + }) : assert( + crossAxisCount == null || gridDelegate == null, + 'Only one of crossAxisCount or gridDelegate can be provided. ' + 'Prefer gridDelegate; crossAxisCount is deprecated.', + ), + _crossAxisCount = crossAxisCount; /// The attachments to display, in render order. final List attachments; - /// Number of tiles per row. Defaults to 3. - final int crossAxisCount; + /// The fixed number of tiles per row given to the constructor, or 3 when none + /// was. + /// + /// Without a fixed number, the grid lays out its tiles with [gridDelegate] or, + /// when that is null, picks the number of tiles per row from its width. + @Deprecated('Use gridDelegate instead. The number of tiles per row now depends on the grid width.') + int get crossAxisCount => _crossAxisCount ?? 3; + final int? _crossAxisCount; + + /// The delegate that lays out the grid's tiles. + /// + /// When null, the grid shows square tiles, with more tiles per row as it gets + /// wider. + final SliverGridDelegate? gridDelegate; /// The padding around this grid. /// @@ -123,17 +144,11 @@ class DefaultStreamMediaGallery extends StatelessWidget { @override Widget build(BuildContext context) { - final spacing = context.streamSpacing; - return GridView.builder( padding: props.padding, controller: props.scrollController, itemCount: props.attachments.length, - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: props.crossAxisCount, - crossAxisSpacing: spacing.xxxs, - mainAxisSpacing: spacing.xxxs, - ), + gridDelegate: _gridDelegate(context), itemBuilder: (context, index) { final ga = props.attachments[index]; return StreamMediaGalleryItem( @@ -145,4 +160,23 @@ class DefaultStreamMediaGallery extends StatelessWidget { }, ); } + + SliverGridDelegate _gridDelegate(BuildContext context) { + if (props.gridDelegate case final gridDelegate?) return gridDelegate; + + final spacing = context.streamSpacing; + + if (props._crossAxisCount case final crossAxisCount?) { + return SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + crossAxisSpacing: spacing.xxxs, + mainAxisSpacing: spacing.xxxs, + ); + } + + return StreamMediaGridDelegate( + crossAxisSpacing: spacing.xxxs, + mainAxisSpacing: spacing.xxxs, + ); + } } diff --git a/packages/stream_chat_flutter/lib/src/media_gallery/stream_media_grid_delegate.dart b/packages/stream_chat_flutter/lib/src/media_gallery/stream_media_grid_delegate.dart new file mode 100644 index 0000000000..6679c6c39f --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/media_gallery/stream_media_grid_delegate.dart @@ -0,0 +1,47 @@ +import 'package:flutter/rendering.dart'; + +const _kMediumGridMinWidth = 600.0; +const _kExpandedGridMinWidth = 840.0; + +/// A grid delegate that lays out a media grid's square tiles, with more tiles +/// per row as the grid gets wider. +/// +/// The tiles share the grid's width between them, so a wider grid gets more +/// tiles rather than larger ones. +class StreamMediaGridDelegate extends SliverGridDelegate { + /// Creates a delegate for a media grid with the given spacing between tiles. + const StreamMediaGridDelegate({ + this.mainAxisSpacing = 0.0, + this.crossAxisSpacing = 0.0, + }) : assert(mainAxisSpacing >= 0, 'mainAxisSpacing must not be negative.'), + assert(crossAxisSpacing >= 0, 'crossAxisSpacing must not be negative.'); + + /// The number of logical pixels between each tile along the main axis. + final double mainAxisSpacing; + + /// The number of logical pixels between each tile along the cross axis. + final double crossAxisSpacing; + + @override + SliverGridLayout getLayout(SliverConstraints constraints) { + final crossAxisExtent = constraints.crossAxisExtent; + final crossAxisCount = switch (crossAxisExtent) { + < _kMediumGridMinWidth => 3, // The design's 3 tiles per row. + < _kExpandedGridMinWidth => 4, // Even, so the tiles split evenly at a fold. + _ => 6, // Even, so the tiles split evenly at a fold. + }; + + final delegate = SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisSpacing: mainAxisSpacing, + crossAxisSpacing: crossAxisSpacing, + ); + + return delegate.getLayout(constraints); + } + + @override + bool shouldRelayout(StreamMediaGridDelegate oldDelegate) { + return oldDelegate.mainAxisSpacing != mainAxisSpacing || oldDelegate.crossAxisSpacing != crossAxisSpacing; + } +} 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 5c6a5d18b3..fd977d475f 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 @@ -76,7 +76,7 @@ class StreamMessageItem extends StatelessWidget { EdgeInsetsGeometry? padding, double? spacing, Color? backgroundColor, - double maxWidth = 264, + double maxWidth = 272, bool swipeToReply = false, void Function(Message)? onMessageTap, void Function(Message)? onMessageLongPress, @@ -230,7 +230,7 @@ class StreamMessageItemProps extends Equatable { /// /// The content uses at most this width while still respecting the parent /// [Flex] constraints. Use [double.infinity] to impose no cap from this - /// widget. Defaults to `264` when not specified. + /// widget. Defaults to `272`. final double maxWidth; /// Whether swiping the message triggers a quoted-reply action. diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/photo_gallery/stream_photo_gallery.dart b/packages/stream_chat_flutter/lib/src/scroll_view/photo_gallery/stream_photo_gallery.dart index a5f88a5f2d..8567780d9d 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/photo_gallery/stream_photo_gallery.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/photo_gallery/stream_photo_gallery.dart @@ -3,11 +3,13 @@ import 'package:flutter/material.dart'; import 'package:photo_manager/photo_manager.dart' show AssetEntity, ThumbnailFormat, ThumbnailSize; import '../../../stream_chat_flutter.dart'; +import '../../media_gallery/stream_media_grid_delegate.dart'; import '../../utils/network_error_text.dart'; -/// Default grid delegate for [StreamPhotoGallery]. -const defaultStreamPhotoGalleryDelegate = SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, +/// The grid delegate a [StreamPhotoGallery] uses when none is given. +/// +/// Shows square tiles, with more tiles per row as the gallery gets wider. +const SliverGridDelegate defaultStreamPhotoGalleryDelegate = StreamMediaGridDelegate( mainAxisSpacing: 2, crossAxisSpacing: 2, ); @@ -59,8 +61,9 @@ class StreamPhotoGallery extends StatelessWidget { /// The [StreamPhotoGalleryController] used to control the grid of users. final StreamPhotoGalleryController controller; - /// A delegate that controls the layout of the children within - /// the [PagedValueGridView]. + /// The delegate that lays out the gallery's tiles. + /// + /// Defaults to [defaultStreamPhotoGalleryDelegate]. final SliverGridDelegate gridDelegate; /// A builder that is called to build items in the [PagedValueGridView]. diff --git a/packages/stream_chat_flutter/test/src/media_gallery/stream_media_gallery_test.dart b/packages/stream_chat_flutter/test/src/media_gallery/stream_media_gallery_test.dart new file mode 100644 index 0000000000..0d26f64b0e --- /dev/null +++ b/packages/stream_chat_flutter/test/src/media_gallery/stream_media_gallery_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +void main() { + // Each side of the 600 and 840 breakpoints. + for (final (width, tilesPerRow) in [(599.0, 3), (600.0, 4), (839.0, 4), (840.0, 6)]) { + testWidgets('StreamMediaGallery shows $tilesPerRow tiles per row when $width wide', (tester) async { + await _pumpGallery( + tester, + width: width, + gallery: StreamMediaGallery(attachments: const []), + ); + + expect(_tilesPerRow(tester), tilesPerRow); + }); + } + + testWidgets('StreamMediaGallery keeps the given crossAxisCount at any width', (tester) async { + await _pumpGallery( + tester, + width: 867, + // ignore: deprecated_member_use_from_same_package + gallery: StreamMediaGallery(attachments: const [], crossAxisCount: 2), + ); + + expect(_tilesPerRow(tester), 2); + }); + + testWidgets('StreamMediaGallery lays out its tiles with the given gridDelegate', (tester) async { + const gridDelegate = SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 5); + + await _pumpGallery( + tester, + width: 402, + gallery: StreamMediaGallery(attachments: const [], gridDelegate: gridDelegate), + ); + + expect(tester.widget(find.byType(GridView)).gridDelegate, same(gridDelegate)); + }); + + test('StreamMediaGallery rejects both a crossAxisCount and a gridDelegate', () { + const gridDelegate = SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 5); + + expect( + // ignore: deprecated_member_use_from_same_package + () => StreamMediaGallery(attachments: const [], crossAxisCount: 2, gridDelegate: gridDelegate), + throwsAssertionError, + ); + }); +} + +Future _pumpGallery(WidgetTester tester, {required double width, required StreamMediaGallery gallery}) async { + tester.view + ..devicePixelRatio = 1 + ..physicalSize = Size(width, 800); + addTearDown(tester.view.reset); + + await tester.pumpWidget(MaterialApp(home: gallery)); +} + +int _tilesPerRow(WidgetTester tester) { + final grid = tester.widget(find.byType(GridView)); + final sliver = tester.renderObject(find.byType(SliverGrid, skipOffstage: false)); + final layout = grid.gridDelegate.getLayout(sliver.constraints) as SliverGridRegularTileLayout; + return layout.crossAxisCount; +} diff --git a/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/stream_message_item_in_list_dark.png b/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/stream_message_item_in_list_dark.png index 7a037ab9d5..0fd70a37d9 100644 Binary files a/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/stream_message_item_in_list_dark.png and b/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/stream_message_item_in_list_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/stream_message_item_in_list_light.png b/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/stream_message_item_in_list_light.png index 56a5bcd742..4571b82adc 100644 Binary files a/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/stream_message_item_in_list_light.png and b/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/stream_message_item_in_list_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/stream_message_item_preview_dark.png b/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/stream_message_item_preview_dark.png index 59b5abae6d..16de3cfe74 100644 Binary files a/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/stream_message_item_preview_dark.png and b/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/stream_message_item_preview_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/stream_message_item_preview_light.png b/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/stream_message_item_preview_light.png index bbbeeb7fe2..3949f36ff6 100644 Binary files a/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/stream_message_item_preview_light.png and b/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/stream_message_item_preview_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_widget/stream_message_item_test.dart b/packages/stream_chat_flutter/test/src/message_widget/stream_message_item_test.dart index 3f2f0d92d0..6de5a99e30 100644 --- a/packages/stream_chat_flutter/test/src/message_widget/stream_message_item_test.dart +++ b/packages/stream_chat_flutter/test/src/message_widget/stream_message_item_test.dart @@ -214,6 +214,14 @@ void main() { }); }); + test('StreamMessageItem defaults to the same maxWidth as StreamMessageItemProps', () { + final message = Message(text: 'Hello'); + + final item = StreamMessageItem(message: message); + + expect(item.props.maxWidth, StreamMessageItemProps(message: message).maxWidth); + }); + // The widget tests above deliberately never see the shipped strings, so pin // the default table's pluralization here. test('DefaultTranslations pluralizes the thread reply count', () { diff --git a/packages/stream_chat_flutter/test/src/scroll_view/photo_gallery/stream_photo_gallery_test.dart b/packages/stream_chat_flutter/test/src/scroll_view/photo_gallery/stream_photo_gallery_test.dart new file mode 100644 index 0000000000..c437d28c41 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/scroll_view/photo_gallery/stream_photo_gallery_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +void main() { + test('StreamPhotoGallery defaults to 4 tiles per row on a 669-wide grid', () { + final controller = StreamPhotoGalleryController(); + addTearDown(controller.dispose); + + final gallery = StreamPhotoGallery(controller: controller); + + final layout = gallery.gridDelegate.getLayout(_verticalConstraints(crossAxisExtent: 669)); + expect((layout as SliverGridRegularTileLayout).crossAxisCount, 4); + }); +} + +SliverConstraints _verticalConstraints({required double crossAxisExtent}) { + return SliverConstraints( + axisDirection: AxisDirection.down, + growthDirection: GrowthDirection.forward, + userScrollDirection: ScrollDirection.idle, + scrollOffset: 0, + precedingScrollExtent: 0, + overlap: 0, + remainingPaintExtent: 800, + crossAxisExtent: crossAxisExtent, + crossAxisDirection: AxisDirection.right, + viewportMainAxisExtent: 800, + remainingCacheExtent: 800, + cacheOrigin: 0, + ); +}