Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
e0a0f7c
fix(ui): default StreamMessageItem.maxWidth to 272, like its props
xsahil03x Sep 30, 2026
60e05b3
Merge branch 'sahil/flu-826-sample-app-two-pane-channel-list-and-chan…
xsahil03x Oct 1, 2026
dc8a10a
feat(ui): show more media tiles per row on wider screens
xsahil03x Oct 1, 2026
9e4d310
Merge branch 'sahil/flu-826-sample-app-two-pane-channel-list-and-chan…
xsahil03x Oct 1, 2026
35a615d
refactor(ui): make the adaptive grid the photo gallery's default dele…
xsahil03x Oct 1, 2026
70a4e9c
docs(ui): keep the FLU-825 changelog to what upgraders can see
xsahil03x Oct 1, 2026
9fbfe3a
chore: Update Goldens
xsahil03x Oct 1, 2026
4fb0c8c
Merge #3023 into the FLU-825 branch
xsahil03x Oct 2, 2026
d0ea5a6
Merge #3023 into the FLU-825 branch
xsahil03x Oct 2, 2026
1c29a1c
Merge #3023 into the FLU-825 branch
xsahil03x Oct 12, 2026
3647581
Merge the FLU-826 branch into the FLU-825 branch
xsahil03x Oct 16, 2026
656599e
refactor(ui): name the media grid's width breakpoints
xsahil03x Oct 16, 2026
cdf2fd4
docs(ui): note StreamMessageItem's new maxWidth default in the changelog
xsahil03x Oct 16, 2026
2226eaa
test(ui): cover the media grid's breakpoints and the gallery's delega…
xsahil03x Oct 16, 2026
23324df
fix(ui): declare defaultStreamPhotoGalleryDelegate as a SliverGridDel…
xsahil03x Oct 16, 2026
3f8c879
docs(repo): declare public defaults with the type their consumers accept
xsahil03x Oct 16, 2026
41a97c6
Merge the FLU-826 branch into the FLU-825 branch
xsahil03x Oct 16, 2026
f371b9b
Merge the FLU-826 branch into the FLU-825 branch
xsahil03x Oct 16, 2026
a3c0852
docs(repo): note the lint also misses an inferred top-level final
xsahil03x Oct 16, 2026
db80ea5
Merge the FLU-826 branch into the FLU-825 branch
xsahil03x Oct 16, 2026
e881b2d
Merge the FLU-826 branch into the FLU-825 branch
xsahil03x Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions STYLE_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion migrations/redesign/message_widget.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
5 changes: 5 additions & 0 deletions packages/stream_chat_flutter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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].
Expand Down Expand Up @@ -40,14 +41,16 @@ class StreamMediaGallery extends StatelessWidget {
StreamMediaGallery({
super.key,
required List<StreamMediaGalleryAttachment> attachments,
int crossAxisCount = 3,
@Deprecated('Use gridDelegate instead.') int? crossAxisCount,
SliverGridDelegate? gridDelegate,
EdgeInsetsGeometry? padding,
ScrollController? scrollController,
ValueChanged<int>? onItemTap,
ValueChanged<int>? onItemLongPress,
}) : props = .new(
attachments: attachments,
crossAxisCount: crossAxisCount,
gridDelegate: gridDelegate,
padding: padding,
scrollController: scrollController,
onItemTap: onItemTap,
Expand Down Expand Up @@ -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<StreamMediaGalleryAttachment> 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.
///
Expand Down Expand Up @@ -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(
Expand All @@ -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,
);
}
}
Original file line number Diff line number Diff line change
@@ -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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't exported from stream_chat_flutter.dart. Not a blocker if the default above is typed as SliverGridDelegate, but it means a customer who wants the adaptive grid with different spacing can't get it: setting any gridDelegate loses the adaptive columns. Worth exporting (plus an Added changelog line) now or in a follow-up?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping it internal for now. gridDelegate on both galleries accepts any SliverGridDelegate, so a customer who wants different spacing or counts isn't stuck, and exporting it would make the 600/840 breakpoints public API. Happy to export it with an Added line once someone asks for it.

/// 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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two small things:

  • 600/840 are magic numbers explained in inline comments; named constants would read better, per STYLE_GUIDE.
  • constraints.crossAxisExtent is the grid's own width after padding, not the screen's, so padding: EdgeInsets.all(16) on a 600-wide screen gives 568 → 3 tiles. That's probably fine, but the dartdoc should say the breakpoints refer to the grid's width.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Named them in 656599e (_kMediumGridMinWidth, _kExpandedGridMinWidth), and the comments now say why each count was chosen instead of naming devices, since the width is the grid's own and can be a pane. I kept the public docs at "more tiles per row as the grid gets wider" so the breakpoints stay an implementation detail.

< _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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ class StreamMessageItem extends StatelessWidget {
EdgeInsetsGeometry? padding,
double? spacing,
Color? backgroundColor,
double maxWidth = 264,
double maxWidth = 272,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changing this default from 264 to 272 widens every bubble built with the constructor by 8px, including the long-press preview, custom builders and customer goldens. The migration doc is updated, but there's no CHANGELOG.md entry. Could you add a 🔄 Changed line, something like "StreamMessageItem's default maxWidth is now 272, matching StreamMessageItemProps"?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in cdf2fd4: "StreamMessageItem's maxWidth now defaults to 272, the same as StreamMessageItemProps."

bool swipeToReply = false,
void Function(Message)? onMessageTap,
void Function(Message)? onMessageLongPress,
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down Expand Up @@ -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].
Expand Down
Original file line number Diff line number Diff line change
@@ -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<GridView>(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<void> _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<GridView>(find.byType(GridView));
final sliver = tester.renderObject<RenderSliverGrid>(find.byType(SliverGrid, skipOffstage: false));
final layout = grid.gridDelegate.getLayout(sliver.constraints) as SliverGridRegularTileLayout;
return layout.crossAxisCount;
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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', () {
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
);
}
Loading