diff --git a/melos.yaml b/melos.yaml index 4894e2382f..5eb60b46ea 100644 --- a/melos.yaml +++ b/melos.yaml @@ -93,9 +93,9 @@ command: svg_icon_widget: ^0.0.1 # TODO: Replace with hosted version before merging PR stream_core_flutter: - git: + git: url: https://github.com/GetStream/stream-core-flutter.git - ref: 07f2357a4f1266784c26fde3430f0bdff43bbee8 + ref: 77f66669da436947e7c146f90b64b825351cd6ef path: packages/stream_core_flutter synchronized: ^3.1.0+1 thumblr: ^0.0.4 diff --git a/migrations/redesign/README.md b/migrations/redesign/README.md index 3f84bfa4ef..97ed8364da 100644 --- a/migrations/redesign/README.md +++ b/migrations/redesign/README.md @@ -39,11 +39,89 @@ MaterialApp( You can also use the convenience factories `StreamTheme.light()` or `StreamTheme.dark()` as a starting point. + +## Component factories + +In the redesigned components we don't use builders in the constructors anymore, but have a centralized component factory. +The component factory contains product agnotic component builders, such as the button and the avatar, and also product specific component builders, such as the channel list item. +You can supply your component factory at any point in the widget tree, but you would usually wrap your full app around it. + +An example of a component factory with custom buttons and a custom channel list item: + +```dart +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return StreamComponentFactory( + builders: StreamComponentBuilders( + button: (context, props) => switch (props.type) { + StreamButtonType.solid => ElevatedButton( + onPressed: props.onTap, + child: Text(props.label ?? ''), + ), + StreamButtonType.outline => OutlinedButton(onPressed: props.onTap, child: Text(props.label ?? '')), + StreamButtonType.ghost => TextButton(onPressed: props.onTap, child: Text(props.label ?? '')), + }, + extensions: streamChatComponentBuilders( + channelListItem: (context, props) => StreamChannelListTile( + title: Text(props.channel.name ?? ''), + avatar: StreamChannelAvatar(channel: props.channel), + onTap: props.onTap, + onLongPress: props.onLongPress, + selected: props.selected, + ), + ), + ), + child: ... + ); + } +} +``` + +You should make the builder themselves as simple as possible by extracting this into separate widgets, such as this: + +```dart +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return StreamComponentFactory( + builders: StreamComponentBuilders( + button: (context, props) => MyCustomButton(props: props), + ), + child: ... + ); + } +} + +class MyCustomButton extends StatelessWidget { + const MyCustomButton({super.key, required this.props}); + + final StreamButtonProps props; + + @override + Widget build(BuildContext context) { + return switch (props.type) { + StreamButtonType.solid => ElevatedButton( + onPressed: props.onTap, + child: Text(props.label ?? ''), + ), + StreamButtonType.outline => OutlinedButton(onPressed: props.onTap, child: Text(props.label ?? '')), + StreamButtonType.ghost => TextButton(onPressed: props.onTap, child: Text(props.label ?? '')), + }; + } +} +``` + ## Components | Component | Migration Guide | |-----------|-----------------| | Stream Avatar | [stream_avatar.md](stream_avatar.md) | +| Channel List Item | [channel_list_item.md](channel_list_item.md) | | Message Actions | [message_actions.md](message_actions.md) | | Reaction Picker / Reactions | [reaction_picker.md](reaction_picker.md) | diff --git a/migrations/redesign/channel_list_item.md b/migrations/redesign/channel_list_item.md new file mode 100644 index 0000000000..30d236b624 --- /dev/null +++ b/migrations/redesign/channel_list_item.md @@ -0,0 +1,270 @@ +# Channel List Item Migration Guide + +This guide covers the migration for the redesigned channel list item components in Stream Chat Flutter SDK. + +--- + +## Table of Contents + +- [Quick Reference](#quick-reference) +- [StreamChannelListTile → StreamChannelListItem](#streamchannellisttile--streamchannellistitem) +- [Customizing Slots](#customizing-slots) +- [Low-level Presentational Component](#low-level-presentational-component) +- [Theme Migration](#theme-migration) +- [Migration Checklist](#migration-checklist) + +--- + +## Quick Reference + +| Old | New | +|-----|-----| +| `StreamChannelListTile` | `StreamChannelListItem` | +| Constructor props: `leading`, `title`, `subtitle`, `trailing` | `StreamChannelListItemProps` (via `StreamComponentFactory`) | +| `tileColor`, `visualDensity`, `contentPadding` | Removed — use `StreamChannelListItemThemeData` | +| `selectedTileColor` | Removed — use `StreamChannelListItemThemeData.backgroundColor` | +| `unreadIndicatorBuilder` | Removed | +| `StreamChannelPreviewThemeData` | `StreamChannelListItemThemeData` | +| `StreamChannelPreviewTheme.of(context)` | `StreamChannelListItemTheme.of(context)` | +| `StreamChatThemeData.channelPreviewTheme` | `StreamChatThemeData.channelListItemTheme` | + +--- + +## StreamChannelListTile → StreamChannelListItem + +The old `StreamChannelListTile` accepted all slot widgets directly in its constructor. The new `StreamChannelListItem` takes only the essential interaction properties. Slot customization is now done via `StreamChannelListItemProps` and the `StreamComponentFactory`. + +### Breaking Changes + +- `leading`, `title`, `subtitle`, `trailing` removed from constructor +- `tileColor` removed — use `StreamChannelListItemThemeData.backgroundColor` +- `visualDensity` removed +- `contentPadding` removed +- `selectedTileColor` removed +- `unreadIndicatorBuilder` removed +- `sendingIndicatorBuilder` removed from constructor — pass via `StreamChannelListItemProps` + +### Migration + +**Before:** +```dart +StreamChannelListTile( + channel: channel, + onTap: () => openChannel(channel), + onLongPress: () => showOptions(channel), + tileColor: Colors.white, + selectedTileColor: Colors.blue.shade50, + selected: isSelected, + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + leading: StreamChannelAvatar(channel: channel), + title: StreamChannelName(channel: channel), + subtitle: ChannelListTileSubtitle(channel: channel), + trailing: ChannelLastMessageDate(channel: channel), +) +``` + +**After:** +```dart +StreamChannelListItem( + channel: channel, + onTap: () => openChannel(channel), + onLongPress: () => showOptions(channel), + selected: isSelected, +) +``` + +--- + +## Customizing Slots + +To customize the slot widgets (avatar, title, subtitle, timestamp), provide a custom builder via `StreamComponentFactory`: + +**Before:** +```dart +StreamChannelListTile( + channel: channel, + leading: MyCustomAvatar(channel: channel), + subtitle: MyCustomSubtitle(channel: channel), +) +``` + +**After:** +```dart +StreamComponentFactory( + builders: StreamComponentBuilders( + extensions: streamChatComponentBuilders( + channelListItem: (context, props) => StreamChannelListTile( + avatar: StreamChannelAvatar(channel: props.channel), + title: Text(props.channel.name ?? ''), + subtitle: Text(props.channel.lastMessageAt?.toString() ?? ''), + ), + ), + ), + child: ..., +) +``` + +--- + +## Low-level Presentational Component + +The new `StreamChannelListTile` is a low-level component that renders pre-resolved data without any channel-specific logic. Use this when you want to display a channel-shaped list item with fully controlled content (e.g., in a skeleton loader or a custom list): + +```dart +StreamChannelListTile( + avatar: StreamChannelAvatar(channel: channel), + title: Text('General'), + subtitle: Text('Last message preview'), + timestamp: Text('9:41 AM'), + unreadCount: 3, + isMuted: false, + onTap: () {}, +) +``` + +> **Note:** This widget does not subscribe to any streams — all values must be provided explicitly. + +--- + +## Theme Migration + +`StreamChannelPreviewThemeData` has been replaced by `StreamChannelListItemThemeData`. + +### Property Mapping + +| Old (`StreamChannelPreviewThemeData`) | New (`StreamChannelListItemThemeData`) | +|---------------------------------------|----------------------------------------| +| `titleStyle` | `titleStyle` | +| `subtitleStyle` | `subtitleStyle` | +| `lastMessageAtStyle` | `timestampStyle` | +| `avatarTheme` | Removed — use `StreamAvatarThemeData` directly | +| `unreadCounterColor` | Removed — use `StreamBadgeNotificationThemeData` | +| `indicatorIconSize` | Removed | +| `lastMessageAtFormatter` | Removed from theme — pass to `ChannelLastMessageDate(formatter: ...)` | + +### New Properties + +| Property | Type | Description | +|----------|------|-------------| +| `backgroundColor` | `WidgetStateProperty?` | Background color resolved per state (default, hover, pressed, selected) | +| `borderColor` | `Color?` | Bottom border color | +| `muteIconPosition` | `MuteIconPosition?` | Whether the mute icon appears in `title` or `subtitle` row | + +### Global Theme Migration + +**Before:** +```dart +StreamChatTheme( + data: StreamChatThemeData( + channelPreviewTheme: StreamChannelPreviewThemeData( + titleStyle: TextStyle(fontWeight: FontWeight.bold), + subtitleStyle: TextStyle(color: Colors.grey), + lastMessageAtStyle: TextStyle(fontSize: 12), + unreadCounterColor: Colors.red, + ), + ), + child: ..., +) +``` + +**After:** +```dart +StreamChatTheme( + data: StreamChatThemeData( + channelListItemTheme: StreamChannelListItemThemeData( + titleStyle: TextStyle(fontWeight: FontWeight.bold), + subtitleStyle: TextStyle(color: Colors.grey), + timestampStyle: TextStyle(fontSize: 12), + // unreadCounterColor → customize via StreamBadgeNotificationThemeData + ), + ), + child: ..., +) +``` + +### Subtree Theme Override + +**Before:** +```dart +StreamChannelPreviewTheme( + data: StreamChannelPreviewThemeData( + titleStyle: TextStyle(color: Colors.blue), + ), + child: StreamChannelListView(...), +) +``` + +**After:** +```dart +StreamChannelListItemTheme( + data: StreamChannelListItemThemeData( + titleStyle: TextStyle(color: Colors.blue), + ), + child: StreamChannelListView(...), +) +``` + +### Background and Selected Color + +**Before:** +```dart +StreamChannelListTile( + channel: channel, + tileColor: Colors.white, + selectedTileColor: Colors.blue.shade50, + selected: isSelected, +) +``` + +**After:** +```dart +StreamChannelListItemTheme( + data: StreamChannelListItemThemeData( + backgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) return Colors.blue.shade50; + return Colors.white; + }), + ), + child: StreamChannelListItem( + channel: channel, + selected: isSelected, + ), +) +``` + +### Custom Timestamp Formatter + +**Before:** +```dart +StreamChannelPreviewThemeData( + lastMessageAtFormatter: (context, date) { + return Jiffy.parseFromDateTime(date).format('d MMMM'); + }, +) +``` + +**After:** +```dart +// Pass directly to the widget — no longer a theme property +ChannelLastMessageDate( + channel: channel, + formatter: (context, date) { + return Jiffy.parseFromDateTime(date).format('d MMMM'); + }, +) +``` + +--- + +## Migration Checklist + +- [ ] Replace `StreamChannelListTile` with `StreamChannelListItem` +- [ ] Remove `tileColor`, `visualDensity`, `contentPadding`, `selectedTileColor`, `unreadIndicatorBuilder` parameters +- [ ] Move slot customization (`leading`, `title`, `subtitle`, `trailing`) to `StreamComponentFactory` +- [ ] Replace `StreamChannelPreviewTheme` with `StreamChannelListItemTheme` +- [ ] Replace `StreamChatThemeData.channelPreviewTheme` with `StreamChatThemeData.channelListItemTheme` +- [ ] Rename `lastMessageAtStyle` to `timestampStyle` +- [ ] Move `lastMessageAtFormatter` from theme to `ChannelLastMessageDate(formatter: ...)` +- [ ] Replace `tileColor`/`selectedTileColor` with `StreamChannelListItemThemeData.backgroundColor` using `WidgetStateProperty` +- [ ] Replace `unreadCounterColor` with `StreamBadgeNotificationThemeData` +- [ ] Replace `avatarTheme` in `StreamChannelPreviewThemeData` with `StreamAvatarThemeData` on the avatar widget directly diff --git a/packages/stream_chat_flutter/example/lib/tutorial_part_3.dart b/packages/stream_chat_flutter/example/lib/tutorial_part_3.dart index bc57390a29..d6cbd0d3a2 100644 --- a/packages/stream_chat_flutter/example/lib/tutorial_part_3.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial_part_3.dart @@ -17,7 +17,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// /// We're passing a custom widget /// to [StreamChannelListView.itemBuilder]; -/// this will override the default [StreamChannelListTile] and allows you +/// this will override the default [StreamChannelListItem] and allows you /// to create one yourself. /// /// There are a couple interesting things we do in this widget: @@ -116,7 +116,7 @@ class _ChannelListPageState extends State { BuildContext context, List channels, int index, - StreamChannelListTile defaultTile, + StreamChannelListItem defaultTile, ) { final channel = channels[index]; final lastMessage = channel.state?.messages.reversed.firstWhereOrNull( @@ -142,7 +142,7 @@ class _ChannelListPageState extends State { channel: channel, ), title: StreamChannelName( - textStyle: StreamChannelPreviewTheme.of(context).titleStyle!.copyWith( + textStyle: StreamChannelListItemTheme.of(context).titleStyle!.copyWith( color: StreamChatTheme.of(context).colorTheme.textHighEmphasis // ignore: deprecated_member_use .withOpacity(opacity), 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 19d0e06f7e..87a03735a4 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 @@ -172,10 +172,10 @@ class StreamChannelListHeader extends StatelessWidget implements PreferredSizeWi ConnectionStatus.disconnected => Colors.grey, }; - return StreamSvgIcon( + return Icon( + context.streamIcons.pencil, size: 24, color: color, - icon: StreamSvgIcons.penWrite, ); }, ), diff --git a/packages/stream_chat_flutter/lib/src/channel/stream_channel_name.dart b/packages/stream_chat_flutter/lib/src/channel/stream_channel_name.dart index 7b1d963f9f..4dc226be84 100644 --- a/packages/stream_chat_flutter/lib/src/channel/stream_channel_name.dart +++ b/packages/stream_chat_flutter/lib/src/channel/stream_channel_name.dart @@ -59,21 +59,15 @@ class StreamChannelName extends StatelessWidget { } } else { final maxWidth = constraints.maxWidth; - final maxChars = maxWidth / (textStyle?.fontSize ?? 1); - var currentChars = 0; + channelName = ''; final currentMembers = []; otherMembers.forEach((element) { - final newLength = currentChars + (element.user?.name.length ?? 0); - if (newLength < maxChars) { - currentChars = newLength; + final newTitle = _getChannelName(currentMembers: [...currentMembers, element], members: members); + if (_calculateTextSize(newTitle).width < maxWidth) { currentMembers.add(element); + channelName = newTitle; } }); - - final exceedingMembers = otherMembers.length - currentMembers.length; - channelName = - '${currentMembers.map((e) => e.user?.name).join(', ')} ' - '${exceedingMembers > 0 ? '+ $exceedingMembers' : ''}'; } } @@ -84,4 +78,19 @@ class StreamChannelName extends StatelessWidget { ); }, ); + + String _getChannelName({required List currentMembers, required List members}) { + final exceedingMembers = members.length - currentMembers.length; + return '${currentMembers.map((e) => e.user?.name).join(', ')} ' + '${exceedingMembers > 0 ? '+ $exceedingMembers' : ''}'; + } + + Size _calculateTextSize(String text) { + final textPainter = TextPainter( + text: TextSpan(text: text, style: textStyle), + maxLines: 1, + textDirection: TextDirection.ltr, + )..layout(minWidth: 0, maxWidth: double.infinity); + return textPainter.size; + } } diff --git a/packages/stream_chat_flutter/lib/src/components/avatar/stream_channel_avatar.dart b/packages/stream_chat_flutter/lib/src/components/avatar/stream_channel_avatar.dart index b1c4c592e7..6d47fd2dd1 100644 --- a/packages/stream_chat_flutter/lib/src/components/avatar/stream_channel_avatar.dart +++ b/packages/stream_chat_flutter/lib/src/components/avatar/stream_channel_avatar.dart @@ -82,13 +82,25 @@ class StreamChannelAvatar extends StatelessWidget { stream: channel.state!.membersStream, initialData: channel.state!.members, builder: (context, members) { - final users = members.map((it) => it.user!); + final users = members.map((it) => it.user!).toList(); final currentUserId = channel.client.state.currentUser?.id; + if (channel.isDistinct && users.length == 2) { + final otherUser = users.firstWhere( + (u) => u.id != currentUserId, + orElse: () => users.first, + ); + return StreamUserAvatar( + user: otherUser, + size: _avatarSizeForAvatarGroupSize(effectiveSize), + // TODO: make this configurable when the online state is shown. + showOnlineIndicator: otherUser.online, + ); + } + return StreamUserAvatarGroup( size: effectiveSize, - // Sort users by current user first. - users: users.sortedBy((it) => it.id == currentUserId ? 0 : 1), + users: users.sortedBy((it) => it.id == currentUserId ? 1 : 0), ); }, ), @@ -103,6 +115,7 @@ class StreamChannelAvatar extends StatelessWidget { ) => switch (size) { .lg => StreamAvatarSize.lg, .xl => StreamAvatarSize.xl, + .xxl => StreamAvatarSize.xxl, }; } diff --git a/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar.dart b/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar.dart index 6190e0e815..cccfa2b3d1 100644 --- a/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar.dart +++ b/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar.dart @@ -135,7 +135,7 @@ class StreamUserAvatar extends StatelessWidget { .xs || .sm => StreamOnlineIndicatorSize.sm, .md => StreamOnlineIndicatorSize.md, .lg => StreamOnlineIndicatorSize.lg, - .xl => StreamOnlineIndicatorSize.xl, + .xl || .xxl => StreamOnlineIndicatorSize.xl, }; } @@ -158,7 +158,7 @@ class _StreamUserAvatarPlaceholder extends StatelessWidget { final userInitials = user.name.initials; if (userInitials != null && userInitials.isNotEmpty) { return switch (size) { - .md || .lg || .xl => Text(userInitials), + .md || .lg || .xl || .xxl => Text(userInitials), .xs || .sm => Text(userInitials.characters.first), }; } diff --git a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_extensions.dart b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_extensions.dart deleted file mode 100644 index cb070faf71..0000000000 --- a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_extensions.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// Helper extensions for internal use only. -extension MessageComposerBuilderExtensions on BuildContext { - StreamComponentBuilders get _factory => StreamComponentFactory.of( - this, - ); - - /// The builder for the message composer component. - StreamComponentBuilder? get messageComposerBuilder => - _factory.extension(); - - /// The builder for the message composer leading component. - StreamComponentBuilder? get messageComposerLeadingBuilder => - _factory.extension(); - - /// The builder for the message composer trailing component. - StreamComponentBuilder? get messageComposerTrailingBuilder => - _factory.extension(); - - /// The builder for the message composer input component. - StreamComponentBuilder? get messageComposerInputBuilder => - _factory.extension(); - - /// The builder for the message composer input leading component. - StreamComponentBuilder? get messageComposerInputLeadingBuilder => - _factory.extension(); - - /// The builder for the message composer input header component. - StreamComponentBuilder? get messageComposerInputHeaderBuilder => - _factory.extension(); - - /// The builder for the message composer input trailing component. - StreamComponentBuilder? get messageComposerInputTrailingBuilder => - _factory.extension(); -} diff --git a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_header.dart b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_header.dart index 9000377d6d..1c46f99045 100644 --- a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_header.dart +++ b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_header.dart @@ -1,7 +1,6 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; -import 'package:stream_chat_flutter/src/components/message_composer/message_composer_extensions.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_core_flutter/stream_core_flutter.dart'; @@ -17,7 +16,10 @@ class StreamMessageComposerInputHeader extends StatelessWidget { @override Widget build(BuildContext context) { - return context.messageComposerInputHeaderBuilder?.call(context, MessageComposerInputHeaderProps.from(props)) ?? + return context.chatComponentBuilder()?.call( + context, + MessageComposerInputHeaderProps.from(props), + ) ?? _DefaultStreamMessageComposerInputHeader(props: props); } } diff --git a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_leading.dart b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_leading.dart index c959a44c0e..2399796aa3 100644 --- a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_leading.dart +++ b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_leading.dart @@ -1,5 +1,4 @@ import 'package:flutter/widgets.dart'; -import 'package:stream_chat_flutter/src/components/message_composer/message_composer_extensions.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// A widget that shows the input leading of the message composer. @@ -14,7 +13,10 @@ class StreamMessageComposerInputLeading extends StatelessWidget { @override Widget build(BuildContext context) { - return context.messageComposerInputLeadingBuilder?.call(context, MessageComposerInputLeadingProps.from(props)) ?? + return context.chatComponentBuilder()?.call( + context, + MessageComposerInputLeadingProps.from(props), + ) ?? const SizedBox.shrink(); } } diff --git a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_trailing.dart b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_trailing.dart index 01763810ec..61190893bf 100644 --- a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_trailing.dart +++ b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_trailing.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; -import 'package:stream_chat_flutter/src/components/message_composer/message_composer_extensions.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_core_flutter/stream_core_flutter.dart'; @@ -17,7 +16,10 @@ class StreamMessageComposerInputTrailing extends StatelessWidget { @override Widget build(BuildContext context) { - return context.messageComposerInputTrailingBuilder?.call(context, MessageComposerInputTrailingProps.from(props)) ?? + return context.chatComponentBuilder()?.call( + context, + MessageComposerInputTrailingProps.from(props), + ) ?? DefaultStreamMessageComposerInputTrailing(props: props); } } 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 13b66ecb2f..eb877c7e7d 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,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/components/message_composer/message_composer_extensions.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_core_flutter/stream_core_flutter.dart'; @@ -16,7 +15,10 @@ class StreamMessageComposerLeading extends StatelessWidget { @override Widget build(BuildContext context) { - return context.messageComposerLeadingBuilder?.call(context, MessageComposerLeadingProps.from(props)) ?? + return context.chatComponentBuilder()?.call( + context, + MessageComposerLeadingProps.from(props), + ) ?? _DefaultStreamMessageComposerLeading(props: props); } } diff --git a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_trailing.dart b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_trailing.dart index c60a315a0e..f5399ebc34 100644 --- a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_trailing.dart +++ b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_trailing.dart @@ -1,5 +1,4 @@ import 'package:flutter/widgets.dart'; -import 'package:stream_chat_flutter/src/components/message_composer/message_composer_extensions.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// A widget that shows the trailing of the message composer. @@ -15,7 +14,10 @@ class StreamMessageComposerTrailing extends StatelessWidget { @override Widget build(BuildContext context) { - return context.messageComposerTrailingBuilder?.call(context, MessageComposerTrailingProps.from(props)) ?? + return context.chatComponentBuilder()?.call( + context, + MessageComposerTrailingProps.from(props), + ) ?? const SizedBox.shrink(); } } diff --git a/packages/stream_chat_flutter/lib/src/components/message_composer/stream_chat_message_composer.dart b/packages/stream_chat_flutter/lib/src/components/message_composer/stream_chat_message_composer.dart index 7110cf02bf..e410a6b8ad 100644 --- a/packages/stream_chat_flutter/lib/src/components/message_composer/stream_chat_message_composer.dart +++ b/packages/stream_chat_flutter/lib/src/components/message_composer/stream_chat_message_composer.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_portal/flutter_portal.dart'; -import 'package:stream_chat_flutter/src/components/message_composer/message_composer_extensions.dart'; import 'package:stream_chat_flutter/src/components/message_composer/message_composer_input_header.dart'; import 'package:stream_chat_flutter/src/components/message_composer/message_composer_input_leading.dart'; import 'package:stream_chat_flutter/src/components/message_composer/message_composer_input_trailing.dart'; @@ -93,7 +92,7 @@ class _StreamChatMessageComposerState extends State { @override Widget build(BuildContext context) { - if (context.messageComposerBuilder?.call(context, widget.props) case final messageComposer?) { + if (context.chatComponentBuilder()?.call(context, widget.props) case final messageComposer?) { return messageComposer; } 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 3d925516de..199ceb9fa2 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 @@ -1,7 +1,9 @@ +import 'package:flutter/widgets.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Builds the list of component builders for the stream chat components. Iterable> streamChatComponentBuilders({ + StreamComponentBuilder? channelListItem, StreamComponentBuilder? messageComposer, StreamComponentBuilder? messageComposerLeading, StreamComponentBuilder? messageComposerTrailing, @@ -11,6 +13,7 @@ Iterable> streamChatComponentBuilders({ StreamComponentBuilder? messageComposerInputTrailing, }) { final builders = [ + if (channelListItem != null) StreamComponentBuilderExtension(builder: channelListItem), if (messageComposer != null) StreamComponentBuilderExtension(builder: messageComposer), if (messageComposerLeading != null) StreamComponentBuilderExtension(builder: messageComposerLeading), if (messageComposerTrailing != null) StreamComponentBuilderExtension(builder: messageComposerTrailing), @@ -22,3 +25,9 @@ Iterable> streamChatComponentBuilders({ return builders; } + +/// Helper extensions for the factory builders. +extension StreamChatComponentBuildersExtension on BuildContext { + /// The builder for the given component type. + StreamComponentBuilder? chatComponentBuilder() => StreamComponentFactory.of(this).extension(); +} diff --git a/packages/stream_chat_flutter/lib/src/indicators/typing_indicator.dart b/packages/stream_chat_flutter/lib/src/indicators/typing_indicator.dart index b3cf76d0d4..10a84b2c7f 100644 --- a/packages/stream_chat_flutter/lib/src/indicators/typing_indicator.dart +++ b/packages/stream_chat_flutter/lib/src/indicators/typing_indicator.dart @@ -58,11 +58,6 @@ class StreamTypingIndicator extends StatelessWidget { mainAxisSize: MainAxisSize.min, spacing: 4, children: [ - Lottie.asset( - 'lib/assets/animations/typing_dots.json', - package: 'stream_chat_flutter', - height: 4, - ), Flexible( child: Text( context.translations.userTypingText(users), @@ -70,6 +65,14 @@ class StreamTypingIndicator extends StatelessWidget { style: style, ), ), + Padding( + padding: const EdgeInsets.only(top: 2), + child: Lottie.asset( + 'lib/assets/animations/typing_dots.json', + package: 'stream_chat_flutter', + height: 5, + ), + ), ], ), ) diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart new file mode 100644 index 0000000000..f29bacaf0f --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart @@ -0,0 +1,788 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; +import 'package:stream_chat_flutter/src/misc/timestamp.dart'; +import 'package:stream_chat_flutter/src/utils/date_formatter.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +/// A widget that displays a channel preview. +/// +/// This widget is intended to be used as a Tile in [StreamChannelListView]. +/// +/// It shows the last message of the channel, the last message time, the unread +/// message count, the typing indicator, the sending indicator and the channel +/// avatar. +/// +/// Internally uses [StreamListTileContainer] from the core design system for +/// consistent visual presentation. +/// +/// See also: +/// * [StreamChannelAvatar] +/// * [StreamChannelName] +class StreamChannelListItem extends StatelessWidget { + /// Creates a new instance of [StreamChannelListItem] widget. + StreamChannelListItem({ + super.key, + required Channel channel, + GestureTapCallback? onTap, + GestureLongPressCallback? onLongPress, + bool selected = false, + }) : assert( + channel.state != null, + 'Channel ${channel.id} is not initialized', + ), + props = .new( + channel: channel, + onTap: onTap, + onLongPress: onLongPress, + selected: selected, + ); + + /// The properties for the channel list item. + final StreamChannelListItemProps props; + + /// Creates a copy of this tile but with the given fields replaced with + /// the new values. + StreamChannelListItem copyWith({ + Key? key, + Channel? channel, + VoidCallback? onTap, + VoidCallback? onLongPress, + bool? selected, + }) { + return StreamChannelListItem( + key: key ?? this.key, + channel: channel ?? props.channel, + onTap: onTap ?? props.onTap, + onLongPress: onLongPress ?? props.onLongPress, + selected: selected ?? props.selected, + ); + } + + @override + Widget build(BuildContext context) { + final builder = context.chatComponentBuilder(); + return builder?.call(context, props) ?? _DefaultStreamChannelListItem(props: props); + } +} + +/// Properties for configuring a [StreamChannelListItem]. +/// +/// This class holds all the configuration options for a channel list item, +/// allowing them to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamChannelListItem], which uses these properties. +/// * [DefaultStreamChannelListItem], the default implementation. +class StreamChannelListItemProps { + /// Creates properties for a channel list item. + const StreamChannelListItemProps({ + required this.channel, + this.leading, + this.title, + this.subtitle, + this.trailing, + this.onTap, + this.onLongPress, + this.sendingIndicatorBuilder, + this.selected = false, + }); + + /// The channel to display. + final Channel channel; + + /// A widget to display as the avatar. + /// + /// Defaults to [StreamChannelAvatar]. + final Widget? leading; + + /// The primary content of the list tile. + /// + /// Defaults to [StreamChannelName]. + final Widget? title; + + /// Additional content displayed below the title. + /// + /// Defaults to [ChannelListTileSubtitle] which shows typing indicators, + /// draft messages, or the last message preview. + final Widget? subtitle; + + /// A widget to display as the timestamp. + /// + /// Defaults to [ChannelLastMessageDate]. + final Widget? trailing; + + /// Called when the user taps this list tile. + final GestureTapCallback? onTap; + + /// Called when the user long-presses on this list tile. + final GestureLongPressCallback? onLongPress; + + /// The widget builder for the sending indicator. + /// + /// `Message` is the last message in the channel. Use it to determine the + /// status using [Message.state]. + final Widget Function(BuildContext, Message)? sendingIndicatorBuilder; + + /// True if the tile is in a selected state. + final bool selected; +} + +class _DefaultStreamChannelListItem extends StatelessWidget { + const _DefaultStreamChannelListItem({ + required this.props, + }); + + final StreamChannelListItemProps props; + + @override + Widget build(BuildContext context) { + final channelState = props.channel.state!; + final textTheme = context.streamTextTheme; + + final avatar = props.leading ?? StreamChannelAvatar(channel: props.channel, size: StreamAvatarGroupSize.xl); + final titleWidget = + props.title ?? + StreamChannelName( + channel: props.channel, + textStyle: textTheme.headingSm.copyWith(height: 1), + ); + final subtitleWidget = + props.subtitle ?? + ChannelListTileSubtitle( + channel: props.channel, + sendingIndicatorBuilder: props.sendingIndicatorBuilder, + ); + final timestampWidget = props.trailing ?? ChannelLastMessageDate(channel: props.channel); + + return BetterStreamBuilder( + stream: props.channel.isMutedStream, + initialData: props.channel.isMuted, + builder: (context, isMuted) => BetterStreamBuilder( + stream: channelState.unreadCountStream, + initialData: channelState.unreadCount, + builder: (context, unreadCount) { + return StreamChannelListTile( + avatar: avatar, + title: titleWidget, + subtitle: subtitleWidget, + timestamp: timestampWidget, + unreadCount: unreadCount, + isMuted: isMuted, + onTap: props.onTap, + onLongPress: props.onLongPress, + selected: props.selected, + ); + }, + ), + ); + } +} + +/// A widget that displays a channel list tile. +/// It's the basic component for [StreamChannelListItem] without any of the logic. +/// It can be used to fully customize the list tile data being shown. +class StreamChannelListTile extends StatelessWidget { + /// Creates a new instance of [StreamChannelListTile] widget. + const StreamChannelListTile({ + super.key, + required this.avatar, + required this.title, + this.subtitle, + this.timestamp, + this.unreadCount = 0, + this.isMuted = false, + this.onTap, + this.onLongPress, + this.selected = false, + }); + + /// The avatar widget displayed at the leading edge. + /// + /// Typically a [StreamAvatar], [StreamAvatarGroup], or an avatar wrapped + /// in a [StreamOnlineIndicator]. + final Widget avatar; + + /// The channel title widget. + /// + /// Typically a [Text] widget with the channel name. The default text style + /// is provided by the theme's title style via [DefaultTextStyle]. + final Widget title; + + /// The message preview widget displayed below the title. + /// + /// Typically a [Text] widget with the last message, but can be any widget + /// for richer content (e.g., icons, read receipts, sender prefix). + final Widget? subtitle; + + /// The timestamp widget displayed in the trailing section of the title row. + /// + /// Typically a [Text] widget with a formatted date string. The default text + /// style is provided by the theme's timestamp style via [DefaultTextStyle]. + final Widget? timestamp; + + /// The number of unread messages. + /// + /// When greater than zero, a [StreamBadgeNotification] is displayed. + final int unreadCount; + + /// Whether the channel is muted. + /// + /// When true, a mute icon is displayed in the title or subtitle. + final bool isMuted; + + /// Called when the list item is tapped. + final VoidCallback? onTap; + + /// Called when the list item is long-pressed. + final VoidCallback? onLongPress; + + /// Whether the list item is in a selected state. + final bool selected; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final channelListItemTheme = StreamChannelListItemTheme.of(context); + final defaults = _StreamChannelListItemThemeDefaults(context); + + final effectiveTitleStyle = channelListItemTheme.titleStyle ?? defaults.titleStyle; + final effectiveSubtitleStyle = channelListItemTheme.subtitleStyle ?? defaults.subtitleStyle; + final effectiveTimestampStyle = channelListItemTheme.timestampStyle ?? defaults.timestampStyle; + final effectiveMuteIconPosition = channelListItemTheme.muteIconPosition ?? defaults.muteIconPosition; + + final muteIcon = isMuted + ? Icon( + context.streamIcons.mute, + size: 20, + color: context.streamColorScheme.textTertiary, + ) + : null; + + final hasMuteIconInSubtitle = effectiveMuteIconPosition == MuteIconPosition.subtitle && isMuted; + + return StreamListTileTheme( + data: context.streamListTileTheme.copyWith( + contentPadding: EdgeInsets.all(spacing.md - 4), + backgroundColor: channelListItemTheme.backgroundColor, + ), + child: Padding( + padding: const EdgeInsets.all(4), + child: Material( + type: MaterialType.transparency, + child: StreamListTileContainer( + enabled: true, + selected: selected, + onTap: onTap, + onLongPress: onLongPress, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.md, + children: [ + avatar, + Expanded( + child: Padding( + padding: EdgeInsets.symmetric(vertical: spacing.xxxs), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: spacing.xxs, + children: [ + _TitleRow( + title: title, + titleTrailing: effectiveMuteIconPosition == MuteIconPosition.title ? muteIcon : null, + timestamp: timestamp, + unreadCount: unreadCount, + titleStyle: effectiveTitleStyle, + timestampStyle: effectiveTimestampStyle, + spacing: spacing, + ), + if (subtitle != null || hasMuteIconInSubtitle) + _SubtitleRow( + subtitle: subtitle, + subtitleTrailing: effectiveMuteIconPosition == MuteIconPosition.subtitle ? muteIcon : null, + subtitleStyle: effectiveSubtitleStyle, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _TitleRow extends StatelessWidget { + const _TitleRow({ + required this.title, + this.titleTrailing, + this.timestamp, + required this.unreadCount, + required this.titleStyle, + required this.timestampStyle, + required this.spacing, + }); + + final Widget title; + final Widget? titleTrailing; + final Widget? timestamp; + final int unreadCount; + final TextStyle titleStyle; + final TextStyle timestampStyle; + final StreamSpacing spacing; + + @override + Widget build(BuildContext context) { + return Row( + spacing: spacing.md, + children: [ + Expanded( + child: Row( + spacing: spacing.xxs, + children: [ + Flexible( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: StreamBadgeNotificationSize.sm.value), + child: Align( + alignment: AlignmentDirectional.centerStart, + widthFactor: 1, + child: DefaultTextStyle.merge( + style: titleStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: title, + ), + ), + ), + ), + ?titleTrailing, + ], + ), + ), + if (timestamp != null || unreadCount > 0) + Row( + mainAxisSize: MainAxisSize.min, + spacing: spacing.xs, + children: [ + if (timestamp case final timestamp?) + DefaultTextStyle.merge( + style: timestampStyle, + child: timestamp, + ), + if (unreadCount > 0) StreamBadgeNotification(label: '$unreadCount'), + ], + ), + ], + ); + } +} + +class _SubtitleRow extends StatelessWidget { + const _SubtitleRow({ + required this.subtitle, + this.subtitleTrailing, + required this.subtitleStyle, + }); + + final Widget? subtitle; + final Widget? subtitleTrailing; + final TextStyle subtitleStyle; + + @override + Widget build(BuildContext context) { + return DefaultTextStyle( + style: subtitleStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: Row( + children: [ + Expanded(child: subtitle ?? const SizedBox.shrink()), + ?subtitleTrailing, + ], + ), + ); + } +} + +class _StreamChannelListItemThemeDefaults extends StreamChannelListItemThemeData { + _StreamChannelListItemThemeDefaults(this._context); + + final BuildContext _context; + + late final _colorScheme = _context.streamColorScheme; + late final _textTheme = _context.streamTextTheme; + + @override + TextStyle get titleStyle => _textTheme.headingSm.copyWith(color: _colorScheme.textPrimary); + + @override + TextStyle get subtitleStyle => _textTheme.captionDefault.copyWith(color: _colorScheme.textSecondary); + + @override + TextStyle get timestampStyle => _textTheme.captionDefault.copyWith(color: _colorScheme.textTertiary); + + @override + Color get borderColor => _colorScheme.borderSubtle; + + @override + MuteIconPosition get muteIconPosition => MuteIconPosition.title; +} + +/// Shows the delivery status icon + "You:" prefix for outgoing messages in +/// the channel list. +/// +/// Unlike [StreamSendingIndicator], this widget does not show a read count +/// number. It only shows: +/// - Clock icon + "You:" (sending) +/// - Single check + "You:" (sent) +/// - Double check grey + "You:" (delivered) +/// - Double check blue + "You:" (read) +class _ChannelListDeliveryStatus extends StatelessWidget { + const _ChannelListDeliveryStatus({ + required this.channel, + required this.message, + }); + + final Channel channel; + final Message message; + + @override + Widget build(BuildContext context) { + final colorTheme = StreamChatTheme.of(context).colorTheme; + final colorScheme = context.streamColorScheme; + + return BetterStreamBuilder>( + stream: channel.state?.readStream, + initialData: channel.state?.read, + builder: (context, data) { + final isRead = data.readsOf(message: message).isNotEmpty; + final isDelivered = data.deliveriesOf(message: message).isNotEmpty; + + final Widget icon; + if (isRead) { + icon = StreamSvgIcon( + size: 16, + icon: StreamSvgIcons.checkAll, + color: colorTheme.accentPrimary, + ); + } else if (isDelivered) { + icon = StreamSvgIcon( + size: 16, + icon: StreamSvgIcons.checkAll, + color: colorScheme.textTertiary, + ); + } else if (message.state.isCompleted) { + icon = StreamSvgIcon( + size: 16, + icon: StreamSvgIcons.check, + color: colorScheme.textTertiary, + ); + } else if (message.state.isOutgoing) { + icon = StreamSvgIcon( + size: 16, + icon: StreamSvgIcons.time, + color: colorScheme.textTertiary, + ); + } else { + return const Empty(); + } + + return Padding( + padding: const EdgeInsetsDirectional.only(end: 4), + child: icon, + ); + }, + ); + } +} + +/// A widget that displays the channel last message date. +class ChannelLastMessageDate extends StatelessWidget { + /// Creates a new instance of the [ChannelLastMessageDate] widget. + ChannelLastMessageDate({ + super.key, + required this.channel, + this.textStyle, + this.formatter, + }) : assert( + channel.state != null, + 'Channel ${channel.id} is not initialized', + ); + + /// The channel to display the last message date for. + final Channel channel; + + /// The style of the text displayed + final TextStyle? textStyle; + + /// The formatter to format the date. + final DateFormatter? formatter; + + @override + Widget build(BuildContext context) { + return BetterStreamBuilder( + stream: channel.lastMessageAtStream, + initialData: channel.lastMessageAt, + builder: (context, lastMessageAt) => StreamTimestamp( + date: lastMessageAt.toLocal(), + style: textStyle, + formatter: formatter, + ), + ); + } +} + +/// A widget that displays the subtitle for [StreamChannelListItem]. +/// +/// Shows typing indicators, draft messages, or the last message preview. +/// The delivery status prefix (icon + "You:") is only shown when the subtitle +/// displays an actual sent message from the current user (not for drafts or +/// typing indicators). +class ChannelListTileSubtitle extends StatelessWidget { + /// Creates a new instance of [StreamChannelListTileSubtitle] widget. + ChannelListTileSubtitle({ + super.key, + required this.channel, + this.textStyle, + this.sendingIndicatorBuilder, + }) : assert( + channel.state != null, + 'Channel ${channel.id} is not initialized', + ); + + /// The channel to create the subtitle from. + final Channel channel; + + /// The style of the text displayed + final TextStyle? textStyle; + + /// The widget builder for the sending indicator. + /// + /// `Message` is the last message in the channel. Use it to determine the + /// status using [Message.state]. + final Widget Function(BuildContext, Message)? sendingIndicatorBuilder; + + @override + Widget build(BuildContext context) { + return StreamTypingIndicator( + channel: channel, + style: textStyle, + alternativeWidget: _ChannelLastMessageWithStatus( + channel: channel, + textStyle: textStyle, + sendingIndicatorBuilder: sendingIndicatorBuilder, + ), + ); + } +} + +/// Combines the delivery status prefix with the last message text. +/// +/// Shows the delivery status only when the displayed content is an actual +/// sent message from the current user (not a draft). +class _ChannelLastMessageWithStatus extends StatefulWidget { + const _ChannelLastMessageWithStatus({ + required this.channel, + this.textStyle, + this.sendingIndicatorBuilder, + }); + + final Channel channel; + final TextStyle? textStyle; + final Widget Function(BuildContext, Message)? sendingIndicatorBuilder; + + @override + State<_ChannelLastMessageWithStatus> createState() => _ChannelLastMessageWithStatusState(); +} + +class _ChannelLastMessageWithStatusState extends State<_ChannelLastMessageWithStatus> { + Message? _currentLastMessage; + + static bool _defaultLastMessagePredicate(Message message) { + if (message.isShadowed) return false; + if (message.isDeleted) return false; + if (message.isError) return false; + if (message.isEphemeral) return false; + + return true; + } + + @override + Widget build(BuildContext context) { + final channelState = widget.channel.state; + if (channelState == null) return const Empty(); + + final currentUser = widget.channel.client.state.currentUser; + + return BetterStreamBuilder<(Draft?, List)>( + stream: CombineLatestStream.combine2( + channelState.draftStream, + channelState.messagesStream, + (draft, messages) => (draft, messages), + ), + initialData: (channelState.draft, channelState.messages), + builder: (context, data) { + final (draft, messages) = data; + + // If there's a draft, show only the draft preview (no delivery status). + if (draft?.message case final draftMessage?) { + return StreamDraftMessagePreviewText( + draftMessage: draftMessage, + textStyle: widget.textStyle, + ); + } + + // Find the last valid message. + final message = messages.lastWhereOrNull( + _defaultLastMessagePredicate, + ); + final latestLastMessage = [message, _currentLastMessage].latest; + + if (latestLastMessage == null) { + return Text( + context.translations.emptyMessagesText, + style: widget.textStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + } + + final isOwnMessage = currentUser != null && latestLastMessage.user?.id == currentUser.id; + + // Show delivery status prefix only for own messages. + final Widget deliveryPrefix; + if (isOwnMessage) { + deliveryPrefix = + widget.sendingIndicatorBuilder?.call(context, latestLastMessage) ?? + _ChannelListDeliveryStatus( + channel: widget.channel, + message: latestLastMessage, + ); + } else { + deliveryPrefix = const Empty(); + } + + return Row( + children: [ + deliveryPrefix, + Flexible( + child: StreamMessagePreviewText( + message: latestLastMessage, + textStyle: widget.textStyle, + channel: channelState.channelState.channel, + ), + ), + ], + ); + }, + ); + } +} + +/// A widget that displays the last message of a channel. +class ChannelLastMessageText extends StatefulWidget { + /// Creates a new instance of [ChannelLastMessageText] widget. + ChannelLastMessageText({ + super.key, + required this.channel, + this.textStyle, + this.lastMessagePredicate = _defaultLastMessagePredicate, + }) : assert( + channel.state != null, + 'Channel ${channel.id} is not initialized', + ); + + /// The channel to display the last message of. + final Channel channel; + + /// The style of the text displayed + final TextStyle? textStyle; + + /// The predicate to determine if the message should be considered for the + /// last message. + /// + /// This predicate is used to filter out messages that should not be + /// considered for the last message. + final bool Function(Message) lastMessagePredicate; + + // The default predicate to determine if the message should be + // considered for the last message. + static bool _defaultLastMessagePredicate(Message message) { + if (message.isShadowed) return false; + if (message.isDeleted) return false; + if (message.isError) return false; + if (message.isEphemeral) return false; + + return true; + } + + @override + State createState() => _ChannelLastMessageTextState(); +} + +class _ChannelLastMessageTextState extends State { + Message? _currentLastMessage; + + @override + Widget build(BuildContext context) { + final channelState = widget.channel.state; + if (channelState == null) return const Empty(); + + return BetterStreamBuilder<(Draft?, List)>( + stream: CombineLatestStream.combine2( + channelState.draftStream, + channelState.messagesStream, + (draft, messages) => (draft, messages), + ), + initialData: (channelState.draft, channelState.messages), + builder: (context, data) { + final (draft, messages) = data; + + // Prioritize the draft message if it exists. + if (draft?.message case final draftMessage?) { + return StreamDraftMessagePreviewText( + draftMessage: draftMessage, + textStyle: widget.textStyle, + ); + } + + // Otherwise, show the channel last message if it exists. + final message = messages.lastWhereOrNull(widget.lastMessagePredicate); + final latestLastMessage = [message, _currentLastMessage].latest; + + if (latestLastMessage == null) { + return Text( + maxLines: 1, + context.translations.emptyMessagesText, + style: widget.textStyle, + overflow: TextOverflow.ellipsis, + ); + } + + return StreamMessagePreviewText( + message: latestLastMessage, + textStyle: widget.textStyle, + channel: channelState.channelState.channel, + ); + }, + ); + } +} + +extension on Iterable { + Message? get latest { + return reduce((a, b) { + if (a == null) return b; + if (b == null) return a; + + if (a.createdAt.isAfter(b.createdAt)) return a; + return b; + }); + } +} diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_tile.dart b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_tile.dart deleted file mode 100644 index 2b0a91bcd2..0000000000 --- a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_tile.dart +++ /dev/null @@ -1,441 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; -import 'package:rxdart/rxdart.dart'; -import 'package:stream_chat_flutter/src/message_widget/sending_indicator_builder.dart'; -import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; -import 'package:stream_chat_flutter/src/misc/timestamp.dart'; -import 'package:stream_chat_flutter/src/utils/date_formatter.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// A widget that displays a channel preview. -/// -/// This widget is intended to be used as a Tile in [StreamChannelListView] -/// -/// It shows the last message of the channel, the last message time, the unread -/// message count, the typing indicator, the sending indicator and the channel -/// avatar. -/// -/// See also: -/// * [StreamChannelAvatar] -/// * [StreamChannelName] -class StreamChannelListTile extends StatelessWidget { - /// Creates a new instance of [StreamChannelListTile] widget. - StreamChannelListTile({ - super.key, - required this.channel, - this.leading, - this.title, - this.subtitle, - this.trailing, - this.onTap, - this.onLongPress, - this.tileColor, - this.visualDensity = VisualDensity.compact, - this.contentPadding = const EdgeInsets.symmetric(horizontal: 8), - this.unreadIndicatorBuilder, - this.sendingIndicatorBuilder, - this.selected = false, - this.selectedTileColor, - }) : assert( - channel.state != null, - 'Channel ${channel.id} is not initialized', - ); - - /// The channel to display. - final Channel channel; - - /// A widget to display before the title. - final Widget? leading; - - /// The primary content of the list tile. - final Widget? title; - - /// Additional content displayed below the title. - final Widget? subtitle; - - /// A widget to display at the end of tile. - final Widget? trailing; - - /// Called when the user taps this list tile. - final GestureTapCallback? onTap; - - /// Called when the user long-presses on this list tile. - final GestureLongPressCallback? onLongPress; - - /// {@template flutter.material.ListTile.tileColor} - /// Defines the background color of `ListTile`. - /// - /// When the value is null, - /// the `tileColor` is set to [ListTileTheme.tileColor] - /// if it's not null and to [Colors.transparent] if it's null. - /// {@endtemplate} - final Color? tileColor; - - /// Defines how compact the list tile's layout will be. - /// - /// {@macro flutter.material.themedata.visualDensity} - /// - /// See also: - /// - /// * [ThemeData.visualDensity], which specifies the [visualDensity] for all - /// widgets within a [Theme]. - final VisualDensity visualDensity; - - /// The tile's internal padding. - /// - /// Insets a [ListTile]'s contents: its [leading], [title], [subtitle], - /// and [trailing] widgets. - /// - /// If null, `EdgeInsets.symmetric(horizontal: 16.0)` is used. - final EdgeInsetsGeometry contentPadding; - - /// The widget builder for the unread indicator. - final WidgetBuilder? unreadIndicatorBuilder; - - /// The widget builder for the sending indicator. - /// - /// `Message` is the last message in the channel, Use it to determine the - /// status using [Message.state]. - final Widget Function(BuildContext, Message)? sendingIndicatorBuilder; - - /// True if the tile is in a selected state. - final bool selected; - - /// The color of the tile in selected state. - final Color? selectedTileColor; - - /// Creates a copy of this tile but with the given fields replaced with - /// the new values. - StreamChannelListTile copyWith({ - Key? key, - Channel? channel, - Widget? leading, - Widget? title, - Widget? subtitle, - VoidCallback? onTap, - VoidCallback? onLongPress, - VisualDensity? visualDensity, - EdgeInsetsGeometry? contentPadding, - bool? selected, - Widget Function(BuildContext, Message)? sendingIndicatorBuilder, - Color? tileColor, - Color? selectedTileColor, - WidgetBuilder? unreadIndicatorBuilder, - Widget? trailing, - }) { - return StreamChannelListTile( - key: key ?? this.key, - channel: channel ?? this.channel, - leading: leading ?? this.leading, - title: title ?? this.title, - subtitle: subtitle ?? this.subtitle, - onTap: onTap ?? this.onTap, - onLongPress: onLongPress ?? this.onLongPress, - visualDensity: visualDensity ?? this.visualDensity, - contentPadding: contentPadding ?? this.contentPadding, - sendingIndicatorBuilder: sendingIndicatorBuilder ?? this.sendingIndicatorBuilder, - tileColor: tileColor ?? this.tileColor, - trailing: trailing ?? this.trailing, - unreadIndicatorBuilder: unreadIndicatorBuilder ?? this.unreadIndicatorBuilder, - selected: selected ?? this.selected, - selectedTileColor: selectedTileColor ?? this.selectedTileColor, - ); - } - - @override - Widget build(BuildContext context) { - final channelState = channel.state!; - final currentUser = channel.client.state.currentUser!; - - final channelPreviewTheme = StreamChannelPreviewTheme.of(context); - final streamChatTheme = StreamChatTheme.of(context); - final streamChat = StreamChat.of(context); - - final leading = this.leading ?? StreamChannelAvatar(channel: channel); - - final title = - this.title ?? - StreamChannelName( - channel: channel, - textStyle: channelPreviewTheme.titleStyle, - ); - - final subtitle = - this.subtitle ?? - ChannelListTileSubtitle( - channel: channel, - textStyle: channelPreviewTheme.subtitleStyle, - ); - - final trailing = - this.trailing ?? - ChannelLastMessageDate( - channel: channel, - textStyle: channelPreviewTheme.lastMessageAtStyle, - formatter: channelPreviewTheme.lastMessageAtFormatter, - ); - - return BetterStreamBuilder( - stream: channel.isMutedStream, - initialData: channel.isMuted, - builder: (context, isMuted) => AnimatedOpacity( - opacity: isMuted ? 0.5 : 1, - duration: const Duration(milliseconds: 300), - child: ListTile( - onTap: onTap, - onLongPress: onLongPress, - visualDensity: visualDensity, - contentPadding: contentPadding, - leading: leading, - tileColor: tileColor, - selected: selected, - selectedTileColor: selectedTileColor ?? StreamChatTheme.of(context).colorTheme.borders, - title: Row( - children: [ - Expanded(child: title), - BetterStreamBuilder>( - stream: channelState.membersStream, - initialData: channelState.members, - comparator: const ListEquality().equals, - builder: (context, members) { - if (members.isEmpty) { - return const Empty(); - } - return unreadIndicatorBuilder?.call(context) ?? StreamUnreadIndicator.channels(cid: channel.cid); - }, - ), - ], - ), - subtitle: Row( - children: [ - Expanded( - child: Align( - alignment: Alignment.centerLeft, - child: subtitle, - ), - ), - BetterStreamBuilder>( - stream: channelState.messagesStream, - initialData: channelState.messages, - comparator: const ListEquality().equals, - builder: (context, messages) { - final lastMessage = messages.lastWhereOrNull( - (m) => !m.shadowed && !m.isDeleted, - ); - - if (lastMessage == null || (lastMessage.user?.id != currentUser.id)) { - return const Empty(); - } - - final hasNonUrlAttachments = lastMessage.attachments.any( - (it) => it.type != AttachmentType.urlPreview, - ); - - return Padding( - padding: const EdgeInsets.only(right: 4), - child: - sendingIndicatorBuilder?.call(context, lastMessage) ?? - SendingIndicatorBuilder( - messageTheme: streamChatTheme.ownMessageTheme, - message: lastMessage, - hasNonUrlAttachments: hasNonUrlAttachments, - streamChat: streamChat, - streamChatTheme: streamChatTheme, - channel: channel, - ), - ); - }, - ), - trailing, - ], - ), - ), - ), - ); - } -} - -/// A widget that displays the channel last message date. -class ChannelLastMessageDate extends StatelessWidget { - /// Creates a new instance of the [ChannelLastMessageDate] widget. - ChannelLastMessageDate({ - super.key, - required this.channel, - this.textStyle, - this.formatter, - }) : assert( - channel.state != null, - 'Channel ${channel.id} is not initialized', - ); - - /// The channel to display the last message date for. - final Channel channel; - - /// The style of the text displayed - final TextStyle? textStyle; - - /// The formatter to format the date. - final DateFormatter? formatter; - - @override - Widget build(BuildContext context) { - return BetterStreamBuilder( - stream: channel.lastMessageAtStream, - initialData: channel.lastMessageAt, - builder: (context, lastMessageAt) => StreamTimestamp( - date: lastMessageAt.toLocal(), - style: textStyle, - formatter: formatter, - ), - ); - } -} - -/// A widget that displays the subtitle for [StreamChannelListTile]. -class ChannelListTileSubtitle extends StatelessWidget { - /// Creates a new instance of [StreamChannelListTileSubtitle] widget. - ChannelListTileSubtitle({ - super.key, - required this.channel, - this.textStyle, - }) : assert( - channel.state != null, - 'Channel ${channel.id} is not initialized', - ); - - /// The channel to create the subtitle from. - final Channel channel; - - /// The style of the text displayed - final TextStyle? textStyle; - - @override - Widget build(BuildContext context) { - if (channel.isMuted) { - return Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Icon(context.streamIcons.mute, size: 16), - Expanded( - child: Text( - ' ${context.translations.channelIsMutedText}', - style: textStyle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ); - } - return StreamTypingIndicator( - channel: channel, - style: textStyle, - alternativeWidget: ChannelLastMessageText( - channel: channel, - textStyle: textStyle, - ), - ); - } -} - -/// A widget that displays the last message of a channel. -class ChannelLastMessageText extends StatefulWidget { - /// Creates a new instance of [ChannelLastMessageText] widget. - ChannelLastMessageText({ - super.key, - required this.channel, - this.textStyle, - this.lastMessagePredicate = _defaultLastMessagePredicate, - }) : assert( - channel.state != null, - 'Channel ${channel.id} is not initialized', - ); - - /// The channel to display the last message of. - final Channel channel; - - /// The style of the text displayed - final TextStyle? textStyle; - - /// The predicate to determine if the message should be considered for the - /// last message. - /// - /// This predicate is used to filter out messages that should not be - /// considered for the last message. - final bool Function(Message) lastMessagePredicate; - - // The default predicate to determine if the message should be - // considered for the last message. - static bool _defaultLastMessagePredicate(Message message) { - if (message.isShadowed) return false; - if (message.isDeleted) return false; - if (message.isError) return false; - if (message.isEphemeral) return false; - - return true; - } - - @override - State createState() => _ChannelLastMessageTextState(); -} - -class _ChannelLastMessageTextState extends State { - Message? _currentLastMessage; - - @override - Widget build(BuildContext context) { - final channelState = widget.channel.state; - if (channelState == null) return const Empty(); - - return BetterStreamBuilder<(Draft?, List)>( - stream: CombineLatestStream.combine2( - channelState.draftStream, - channelState.messagesStream, - (draft, messages) => (draft, messages), - ), - initialData: (channelState.draft, channelState.messages), - builder: (context, data) { - final (draft, messages) = data; - - // Prioritize the draft message if it exists. - if (draft?.message case final draftMessage?) { - return StreamDraftMessagePreviewText( - draftMessage: draftMessage, - textStyle: widget.textStyle, - ); - } - - // Otherwise, show the channel last message if it exists. - final message = messages.lastWhereOrNull(widget.lastMessagePredicate); - final latestLastMessage = [message, _currentLastMessage].latest; - - if (latestLastMessage == null) { - return Text( - maxLines: 1, - context.translations.emptyMessagesText, - style: widget.textStyle, - overflow: TextOverflow.ellipsis, - ); - } - - return StreamMessagePreviewText( - message: latestLastMessage, - textStyle: widget.textStyle, - channel: channelState.channelState.channel, - ); - }, - ); - } -} - -extension on Iterable { - Message? get latest { - return reduce((a, b) { - if (a == null) return b; - if (b == null) return a; - - if (a.createdAt.isAfter(b.createdAt)) return a; - return b; - }); - } -} diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_view.dart index a2c3be15a3..1d697dfd6a 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_view.dart @@ -16,10 +16,10 @@ Widget defaultChannelListViewSeparatorBuilder( /// Signature for the item builder that creates the children of the /// [StreamChannelListView]. typedef StreamChannelListViewIndexedWidgetBuilder = - StreamScrollViewIndexedWidgetBuilder; + StreamScrollViewIndexedWidgetBuilder; /// A [ListView] that shows a list of [Channel]s, -/// it uses [StreamChannelListTile] as a default item. +/// it uses [StreamChannelListItem] as a default item. /// /// This is the new version of [StreamChannelListView] that uses /// [StreamChannelListController]. @@ -39,7 +39,7 @@ typedef StreamChannelListViewIndexedWidgetBuilder = /// ``` /// /// See also: -/// * [StreamChannelListTile] +/// * [StreamChannelListItem] /// * [StreamChannelListController] class StreamChannelListView extends StatelessWidget { /// Creates a new instance of [StreamChannelListView]. @@ -303,7 +303,7 @@ class StreamChannelListView extends StatelessWidget { final onTap = onChannelTap; final onLongPress = onChannelLongPress; - final streamChannelListTile = StreamChannelListTile( + final streamChannelListTile = StreamChannelListItem( channel: channel, onTap: onTap == null ? null : () => onTap(channel), onLongPress: onLongPress == null ? null : () => onLongPress(channel), @@ -365,7 +365,7 @@ class StreamChannelListView extends StatelessWidget { } /// A widget that is used to display a separator between -/// [StreamChannelListTile] items. +/// [StreamChannelListItem] items. class StreamChannelListSeparator extends StatelessWidget { /// Creates a new instance of [StreamChannelListSeparator]. const StreamChannelListSeparator({super.key}); diff --git a/packages/stream_chat_flutter/lib/src/theme/channel_preview_theme.dart b/packages/stream_chat_flutter/lib/src/theme/channel_preview_theme.dart index 89019f322a..88ea65595e 100644 --- a/packages/stream_chat_flutter/lib/src/theme/channel_preview_theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/channel_preview_theme.dart @@ -11,6 +11,9 @@ import 'package:stream_chat_flutter/src/utils/date_formatter.dart'; /// /// * [StreamChannelPreviewThemeData], which is used to configure this theme. /// {@endtemplate} +/// +/// This is deprecated, but currently still used by `StreamChannelInfoBottomSheet`. +@Deprecated('Use StreamChannelListItemTheme instead.') class StreamChannelPreviewTheme extends InheritedTheme { /// Creates a [StreamChannelPreviewTheme]. /// diff --git a/packages/stream_chat_flutter/lib/src/theme/stream_channel_list_item_theme.dart b/packages/stream_chat_flutter/lib/src/theme/stream_channel_list_item_theme.dart new file mode 100644 index 0000000000..d56174f744 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/stream_channel_list_item_theme.dart @@ -0,0 +1,144 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +part 'stream_channel_list_item_theme.g.theme.dart'; + +/// Applies a channel list item theme to descendant +/// [StreamChannelListItem] widgets. +/// +/// Wrap a subtree with [StreamChannelListItemTheme] to override styling. +/// Access the merged theme using [BuildContext.streamChannelListItemTheme]. +/// +/// {@tool snippet} +/// +/// Override channel list item colors for a specific section: +/// +/// ```dart +/// StreamChannelListItemTheme( +/// data: StreamChannelListItemThemeData( +/// backgroundColor: Colors.grey.shade50, +/// ), +/// child: StreamChannelListItem( +/// avatar: StreamAvatar(...), +/// title: 'General', +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamChannelListItemThemeData], which describes the theme. +/// * [StreamChannelListItem], the widget affected by this theme. +class StreamChannelListItemTheme extends InheritedTheme { + /// Creates a channel list item theme that controls descendant widgets. + const StreamChannelListItemTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The channel list item theme data for descendant widgets. + final StreamChannelListItemThemeData data; + + /// Returns the [StreamChannelListItemThemeData] merged from local and + /// global themes. + /// + /// Local values from the nearest [StreamChannelListItemTheme] ancestor + /// take precedence over global values from [StreamTheme.of]. + static StreamChannelListItemThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamChatTheme.of(context).channelListItemTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamChannelListItemTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamChannelListItemTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamChannelListItem] widgets. +/// +/// {@tool snippet} +/// +/// Customize channel list item appearance globally: +/// +/// ```dart +/// StreamTheme( +/// channelListItemTheme: StreamChannelListItemThemeData( +/// backgroundColor: Colors.white, +/// borderColor: Colors.grey.shade200, +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamChannelListItem], the widget that uses this theme data. +/// * [StreamChannelListItemTheme], for overriding theme in a widget subtree. +@themeGen +@immutable +class StreamChannelListItemThemeData with _$StreamChannelListItemThemeData { + /// Creates a channel list item theme with optional style overrides. + const StreamChannelListItemThemeData({ + this.titleStyle, + this.subtitleStyle, + this.timestampStyle, + this.backgroundColor, + this.borderColor, + this.muteIconPosition, + }); + + /// The text style for the channel title. + /// + /// Falls back to [StreamTextTheme.headingSm] with [StreamColorScheme.textPrimary]. + final TextStyle? titleStyle; + + /// The text style for the message preview subtitle. + /// + /// Falls back to [StreamTextTheme.captionDefault] with [StreamColorScheme.textSecondary]. + final TextStyle? subtitleStyle; + + /// The text style for the timestamp. + /// + /// Falls back to [StreamTextTheme.captionDefault] with [StreamColorScheme.textTertiary]. + final TextStyle? timestampStyle; + + /// Defines the default background color of the tile. + /// + /// This color is resolved from [WidgetState]s. + final WidgetStateProperty? backgroundColor; + + /// The bottom border color of the list item. + /// + /// Falls back to [StreamColorScheme.borderSubtle]. + final Color? borderColor; + + /// The position of the mute icon. + /// + /// Falls back to [MuteIconPosition.title]. + final MuteIconPosition? muteIconPosition; + + /// Linearly interpolate between two [StreamChannelListItemThemeData] objects. + static StreamChannelListItemThemeData? lerp( + StreamChannelListItemThemeData? a, + StreamChannelListItemThemeData? b, + double t, + ) => _$StreamChannelListItemThemeData.lerp(a, b, t); +} + +/// The position of the mute icon. +/// By default the mute icon will be shown directly next to the title. +/// When choosing for subtitle, the mute icon will be shown at the end of the list item. +enum MuteIconPosition { + /// Top row of the list item, next to the title. + title, + + /// Bottom row, at the end of the list item. + subtitle, +} diff --git a/packages/stream_chat_flutter/lib/src/theme/stream_channel_list_item_theme.g.theme.dart b/packages/stream_chat_flutter/lib/src/theme/stream_channel_list_item_theme.g.theme.dart new file mode 100644 index 0000000000..bb1baafe13 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/stream_channel_list_item_theme.g.theme.dart @@ -0,0 +1,127 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_channel_list_item_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamChannelListItemThemeData { + bool get canMerge => true; + + static StreamChannelListItemThemeData? lerp( + StreamChannelListItemThemeData? a, + StreamChannelListItemThemeData? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamChannelListItemThemeData( + titleStyle: TextStyle.lerp(a.titleStyle, b.titleStyle, t), + subtitleStyle: TextStyle.lerp(a.subtitleStyle, b.subtitleStyle, t), + timestampStyle: TextStyle.lerp(a.timestampStyle, b.timestampStyle, t), + backgroundColor: WidgetStateProperty.lerp( + a.backgroundColor, + b.backgroundColor, + t, + Color.lerp, + ), + borderColor: Color.lerp(a.borderColor, b.borderColor, t), + muteIconPosition: t < 0.5 ? a.muteIconPosition : b.muteIconPosition, + ); + } + + StreamChannelListItemThemeData copyWith({ + TextStyle? titleStyle, + TextStyle? subtitleStyle, + TextStyle? timestampStyle, + WidgetStateProperty? backgroundColor, + Color? borderColor, + MuteIconPosition? muteIconPosition, + }) { + final _this = (this as StreamChannelListItemThemeData); + + return StreamChannelListItemThemeData( + titleStyle: titleStyle ?? _this.titleStyle, + subtitleStyle: subtitleStyle ?? _this.subtitleStyle, + timestampStyle: timestampStyle ?? _this.timestampStyle, + backgroundColor: backgroundColor ?? _this.backgroundColor, + borderColor: borderColor ?? _this.borderColor, + muteIconPosition: muteIconPosition ?? _this.muteIconPosition, + ); + } + + StreamChannelListItemThemeData merge(StreamChannelListItemThemeData? other) { + final _this = (this as StreamChannelListItemThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + titleStyle: _this.titleStyle?.merge(other.titleStyle) ?? other.titleStyle, + subtitleStyle: + _this.subtitleStyle?.merge(other.subtitleStyle) ?? + other.subtitleStyle, + timestampStyle: + _this.timestampStyle?.merge(other.timestampStyle) ?? + other.timestampStyle, + backgroundColor: other.backgroundColor, + borderColor: other.borderColor, + muteIconPosition: other.muteIconPosition, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamChannelListItemThemeData); + final _other = (other as StreamChannelListItemThemeData); + + return _other.titleStyle == _this.titleStyle && + _other.subtitleStyle == _this.subtitleStyle && + _other.timestampStyle == _this.timestampStyle && + _other.backgroundColor == _this.backgroundColor && + _other.borderColor == _this.borderColor && + _other.muteIconPosition == _this.muteIconPosition; + } + + @override + int get hashCode { + final _this = (this as StreamChannelListItemThemeData); + + return Object.hash( + runtimeType, + _this.titleStyle, + _this.subtitleStyle, + _this.timestampStyle, + _this.backgroundColor, + _this.borderColor, + _this.muteIconPosition, + ); + } +} 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 572dd3c3f2..fbef12f1b2 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 @@ -63,6 +63,7 @@ class StreamChatThemeData { StreamThreadListTileThemeData? threadListTileTheme, StreamDraftListTileThemeData? draftListTileTheme, StreamVoiceRecordingAttachmentThemeData? voiceRecordingAttachmentTheme, + StreamChannelListItemThemeData? channelListItemTheme, }) { brightness ??= colorTheme?.brightness ?? Brightness.light; textTheme ??= StreamTextTheme(brightness: brightness); @@ -95,6 +96,7 @@ class StreamChatThemeData { threadListTileTheme: threadListTileTheme, draftListTileTheme: draftListTileTheme, voiceRecordingAttachmentTheme: voiceRecordingAttachmentTheme, + channelListItemTheme: channelListItemTheme, ); return defaultData.merge(customizedData); @@ -129,6 +131,7 @@ class StreamChatThemeData { required this.threadListTileTheme, required this.draftListTileTheme, required this.voiceRecordingAttachmentTheme, + required this.channelListItemTheme, }); /// Creates a theme from a Material [Theme] @@ -525,6 +528,7 @@ class StreamChatThemeData { ), ), voiceRecordingAttachmentTheme: const StreamVoiceRecordingAttachmentThemeData(), + channelListItemTheme: const StreamChannelListItemThemeData(), ); } @@ -590,6 +594,9 @@ class StreamChatThemeData { /// Theme configuration for the [StreamVoiceRecordingAttachment] widget. final StreamVoiceRecordingAttachmentThemeData voiceRecordingAttachmentTheme; + /// Theme configuration for the [StreamChannelListItem] widget. + final StreamChannelListItemThemeData channelListItemTheme; + /// Theme configuration for the [StreamDraftListTile] widget. final StreamDraftListTileThemeData draftListTileTheme; @@ -628,6 +635,7 @@ class StreamChatThemeData { StreamThreadListTileThemeData? threadListTileTheme, StreamDraftListTileThemeData? draftListTileTheme, StreamVoiceRecordingAttachmentThemeData? voiceRecordingAttachmentTheme, + StreamChannelListItemThemeData? channelListItemTheme, }) => StreamChatThemeData.raw( channelListHeaderTheme: this.channelListHeaderTheme.merge(channelListHeaderTheme), textTheme: this.textTheme.merge(textTheme), @@ -650,6 +658,7 @@ class StreamChatThemeData { threadListTileTheme: threadListTileTheme ?? this.threadListTileTheme, draftListTileTheme: draftListTileTheme ?? this.draftListTileTheme, voiceRecordingAttachmentTheme: voiceRecordingAttachmentTheme ?? this.voiceRecordingAttachmentTheme, + channelListItemTheme: channelListItemTheme ?? this.channelListItemTheme, ); /// Merge themes @@ -677,6 +686,7 @@ class StreamChatThemeData { threadListTileTheme: threadListTileTheme.merge(other.threadListTileTheme), draftListTileTheme: draftListTileTheme.merge(other.draftListTileTheme), voiceRecordingAttachmentTheme: voiceRecordingAttachmentTheme.merge(other.voiceRecordingAttachmentTheme), + channelListItemTheme: channelListItemTheme.merge(other.channelListItemTheme), ); } } diff --git a/packages/stream_chat_flutter/lib/src/theme/themes.dart b/packages/stream_chat_flutter/lib/src/theme/themes.dart index 8f1558bdc3..7bcf5a75e6 100644 --- a/packages/stream_chat_flutter/lib/src/theme/themes.dart +++ b/packages/stream_chat_flutter/lib/src/theme/themes.dart @@ -15,6 +15,7 @@ export 'poll_interactor_theme.dart'; export 'poll_option_votes_dialog_theme.dart'; export 'poll_options_dialog_theme.dart'; export 'poll_results_dialog_theme.dart'; +export 'stream_channel_list_item_theme.dart'; export 'text_theme.dart'; export 'thread_list_tile_theme.dart'; export 'voice_recording_attachment_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 724e02ec00..6ff3c40507 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -4,6 +4,7 @@ export 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; export 'package:stream_core_flutter/stream_core_flutter.dart' show + StreamAvatarGroupSize, StreamAvatarSize, StreamAudioWaveformSlider, StreamAudioWaveform, @@ -138,7 +139,7 @@ export 'src/reactions/picker/reaction_picker.dart'; export 'src/reactions/user_reactions.dart'; export 'src/scroll_view/channel_scroll_view/stream_channel_grid_tile.dart'; export 'src/scroll_view/channel_scroll_view/stream_channel_grid_view.dart'; -export 'src/scroll_view/channel_scroll_view/stream_channel_list_tile.dart'; +export 'src/scroll_view/channel_scroll_view/stream_channel_list_item.dart'; export 'src/scroll_view/channel_scroll_view/stream_channel_list_view.dart'; export 'src/scroll_view/draft_scroll_view/stream_draft_list_tile.dart'; export 'src/scroll_view/draft_scroll_view/stream_draft_list_view.dart'; diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index 43741c9ee8..53af9ee6aa 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -62,10 +62,11 @@ dependencies: stream_core_flutter: git: url: https://github.com/GetStream/stream-core-flutter.git - ref: 07f2357a4f1266784c26fde3430f0bdff43bbee8 + ref: 77f66669da436947e7c146f90b64b825351cd6ef path: packages/stream_core_flutter svg_icon_widget: ^0.0.1 synchronized: ^3.1.0+1 + theme_extensions_builder_annotation: ^7.1.0 thumblr: ^0.0.4 url_launcher: ^6.3.0 video_player: ^2.8.7 diff --git a/packages/stream_chat_flutter/test/src/channel/channel_image_test.dart b/packages/stream_chat_flutter/test/src/channel/channel_image_test.dart index 44921dc7c8..a1e4d22ff2 100644 --- a/packages/stream_chat_flutter/test/src/channel/channel_image_test.dart +++ b/packages/stream_chat_flutter/test/src/channel/channel_image_test.dart @@ -60,6 +60,7 @@ void main() { when(() => channel.client).thenReturn(client); when(() => channel.nameStream).thenAnswer((_) => Stream.value('test')); when(() => channel.name).thenReturn('test'); + when(() => channel.isDistinct).thenReturn(true); when(() => channel.imageStream).thenAnswer((i) => Stream.value(null)); when(() => channel.image).thenReturn(null); when(() => channelState.membersStream).thenAnswer( @@ -137,6 +138,7 @@ void main() { when(() => clientState.currentUser).thenReturn(currentUser); when(() => channel.state).thenReturn(channelState); when(() => channel.client).thenReturn(client); + when(() => channel.isDistinct).thenReturn(false); when(() => channel.nameStream).thenAnswer((_) => Stream.value('test')); when(() => channel.name).thenReturn('test'); when(() => channel.imageStream).thenAnswer((i) => Stream.value(null)); diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_dark.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_dark.png index d060b781dd..ed7e11f4e6 100644 Binary files a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_dark.png and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_light.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_light.png index a26852bf7f..4f907ddec5 100644 Binary files a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_light.png and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_reversed_dark.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_reversed_dark.png index 061a202ad7..b1aff3f790 100644 Binary files a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_reversed_dark.png and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_reversed_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_reversed_light.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_reversed_light.png index 1ef1d5649c..ee59757e4e 100644 Binary files a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_reversed_light.png and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_reversed_light.png differ