diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index e1673442b..70bdd3b1e 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -1993,6 +1993,14 @@ class ChannelClientState { (watchers, users) => watchers!.map((e) => users[e.id] ?? e).toList(), ); + /// Channel member for the current user. + Member? get currentUserMember => members.firstWhereOrNull( + (m) => m.user?.id == _channel.client.state.currentUser?.id, + ); + + /// User role for the current user. + String? get currentUserRole => currentUserMember?.role; + /// Channel read list. List get read => _channelState.read; diff --git a/packages/stream_chat_flutter/example/lib/split_view.dart b/packages/stream_chat_flutter/example/lib/split_view.dart index 1fd3ccb6a..75b8f64b9 100644 --- a/packages/stream_chat_flutter/example/lib/split_view.dart +++ b/packages/stream_chat_flutter/example/lib/split_view.dart @@ -84,7 +84,7 @@ class _SplitViewState extends State { ); } -class ChannelListPage extends StatelessWidget { +class ChannelListPage extends StatefulWidget { const ChannelListPage({ Key? key, this.onTap, @@ -92,22 +92,26 @@ class ChannelListPage extends StatelessWidget { final void Function(Channel)? onTap; + @override + State createState() => _ChannelListPageState(); +} + +class _ChannelListPageState extends State { + late final _listController = StreamChannelListController( + client: StreamChat.of(context).client, + filter: Filter.in_( + 'members', + [StreamChat.of(context).currentUser!.id], + ), + sort: const [SortOption('last_message_at')], + limit: 20, + ); + @override Widget build(BuildContext context) => Scaffold( - body: ChannelsBloc( - child: ChannelListView( - onChannelTap: onTap != null - ? (channel, _) { - onTap!(channel); - } - : null, - filter: Filter.in_( - 'members', - [StreamChat.of(context).currentUser!.id], - ), - sort: const [SortOption('last_message_at')], - limit: 20, - ), + body: StreamChannelListView( + onChannelTap: widget.onTap, + controller: _listController, ), ); } diff --git a/packages/stream_chat_flutter/example/lib/tutorial_part_1.dart b/packages/stream_chat_flutter/example/lib/tutorial_part_1.dart index 459d86901..82acabf9a 100644 --- a/packages/stream_chat_flutter/example/lib/tutorial_part_1.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial_part_1.dart @@ -90,18 +90,15 @@ class ChannelPage extends StatelessWidget { }) : super(key: key); @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - return Scaffold( - appBar: const ChannelHeader(), - body: Column( - children: const [ - Expanded( - child: MessageListView(), - ), - MessageInput(), - ], - ), - ); - } + Widget build(BuildContext context) => Scaffold( + appBar: const ChannelHeader(), + body: Column( + children: const [ + Expanded( + child: MessageListView(), + ), + MessageInput(), + ], + ), + ); } diff --git a/packages/stream_chat_flutter/example/lib/tutorial_part_2.dart b/packages/stream_chat_flutter/example/lib/tutorial_part_2.dart index 6abc260e0..9f718b7e5 100644 --- a/packages/stream_chat_flutter/example/lib/tutorial_part_2.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial_part_2.dart @@ -62,33 +62,53 @@ class MyApp extends StatelessWidget { client: client, child: child, ), - home: const ChannelListPage(), + home: ChannelListPage( + client: client, + ), ); } } -class ChannelListPage extends StatelessWidget { +class ChannelListPage extends StatefulWidget { const ChannelListPage({ Key? key, + required this.client, }) : super(key: key); + final StreamChatClient client; + @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - return Scaffold( - body: ChannelsBloc( - child: ChannelListView( - filter: Filter.in_( - 'members', - [StreamChat.of(context).currentUser!.id], + State createState() => _ChannelListPageState(); +} + +class _ChannelListPageState extends State { + late final _controller = StreamChannelListController( + client: widget.client, + filter: Filter.in_( + 'members', + [StreamChat.of(context).currentUser!.id], + ), + sort: const [SortOption('last_message_at')], + ); + + @override + Widget build(BuildContext context) => Scaffold( + body: RefreshIndicator( + onRefresh: _controller.refresh, + child: StreamChannelListView( + controller: _controller, + onChannelTap: (channel) => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => StreamChannel( + channel: channel, + child: const ChannelPage(), + ), + ), + ), ), - sort: const [SortOption('last_message_at')], - limit: 20, - channelWidget: const ChannelPage(), ), - ), - ); - } + ); } class ChannelPage extends StatelessWidget { @@ -97,18 +117,15 @@ class ChannelPage extends StatelessWidget { }) : super(key: key); @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - return Scaffold( - appBar: const ChannelHeader(), - body: Column( - children: const [ - Expanded( - child: MessageListView(), - ), - MessageInput(), - ], - ), - ); - } + Widget build(BuildContext context) => Scaffold( + appBar: const ChannelHeader(), + body: Column( + children: const [ + Expanded( + child: MessageListView(), + ), + MessageInput(), + ], + ), + ); } 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 538dbd685..76447b11b 100644 --- a/packages/stream_chat_flutter/example/lib/tutorial_part_3.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial_part_3.dart @@ -68,31 +68,49 @@ class MyApp extends StatelessWidget { } } -class ChannelListPage extends StatelessWidget { +class ChannelListPage extends StatefulWidget { const ChannelListPage({ Key? key, }) : super(key: key); @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - return Scaffold( - body: ChannelsBloc( - child: ChannelListView( - filter: Filter.in_( - 'members', - [StreamChat.of(context).currentUser!.id], - ), - channelPreviewBuilder: _channelPreviewBuilder, - // sort: [SortOption('last_message_at')], - limit: 20, - channelWidget: const ChannelPage(), + State createState() => _ChannelListPageState(); +} + +class _ChannelListPageState extends State { + late final _listController = StreamChannelListController( + client: StreamChat.of(context).client, + filter: Filter.in_( + 'members', + [StreamChat.of(context).currentUser!.id], + ), + sort: const [SortOption('last_message_at')], + limit: 20, + ); + + @override + Widget build(BuildContext context) => Scaffold( + body: StreamChannelListView( + controller: _listController, + itemBuilder: _channelPreviewBuilder, + onChannelTap: (channel) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => StreamChannel( + channel: channel, + child: const ChannelPage(), + ), + ), + ); + }, ), - ), - ); - } + ); - Widget _channelPreviewBuilder(BuildContext context, Channel channel) { + Widget _channelPreviewBuilder( + BuildContext context, + Channel channel, + StreamChannelListTile defaultTile, + ) { final lastMessage = channel.state?.messages.reversed.firstWhereOrNull( (message) => !message.isDeleted, ); @@ -112,16 +130,17 @@ class ChannelListPage extends StatelessWidget { ), ); }, - leading: ChannelAvatar( + leading: StreamChannelAvatar( channel: channel, ), - title: ChannelName( + title: StreamChannelName( textStyle: ChannelPreviewTheme.of(context).titleStyle!.copyWith( color: StreamChatTheme.of(context) .colorTheme .textHighEmphasis .withOpacity(opacity), ), + channel: channel, ), subtitle: Text(subtitle), trailing: channel.state!.unreadCount > 0 diff --git a/packages/stream_chat_flutter/example/lib/tutorial_part_4.dart b/packages/stream_chat_flutter/example/lib/tutorial_part_4.dart index ea9afa2f5..fdea19ba0 100644 --- a/packages/stream_chat_flutter/example/lib/tutorial_part_4.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial_part_4.dart @@ -53,28 +53,42 @@ class MyApp extends StatelessWidget { } } -class ChannelListPage extends StatelessWidget { +class ChannelListPage extends StatefulWidget { const ChannelListPage({ Key? key, }) : super(key: key); @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - return Scaffold( - body: ChannelsBloc( - child: ChannelListView( - filter: Filter.in_( - 'members', - [StreamChat.of(context).currentUser!.id], - ), - sort: const [SortOption('last_message_at')], - limit: 20, - channelWidget: const ChannelPage(), + State createState() => _ChannelListPageState(); +} + +class _ChannelListPageState extends State { + late final _listController = StreamChannelListController( + client: StreamChat.of(context).client, + filter: Filter.in_( + 'members', + [StreamChat.of(context).currentUser!.id], + ), + sort: const [SortOption('last_message_at')], + limit: 20, + ); + + @override + Widget build(BuildContext context) => Scaffold( + body: StreamChannelListView( + controller: _listController, + onChannelTap: (channel) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => StreamChannel( + channel: channel, + child: const ChannelPage(), + ), + ), + ); + }, ), - ), - ); - } + ); } class ChannelPage extends StatelessWidget { @@ -83,24 +97,21 @@ class ChannelPage extends StatelessWidget { }) : super(key: key); @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - return Scaffold( - appBar: const ChannelHeader(), - body: Column( - children: [ - Expanded( - child: MessageListView( - threadBuilder: (_, parentMessage) => ThreadPage( - parent: parentMessage, + Widget build(BuildContext context) => Scaffold( + appBar: const ChannelHeader(), + body: Column( + children: [ + Expanded( + child: MessageListView( + threadBuilder: (_, parentMessage) => ThreadPage( + parent: parentMessage, + ), ), ), - ), - const MessageInput(), - ], - ), - ); - } + const MessageInput(), + ], + ), + ); } class ThreadPage extends StatelessWidget { diff --git a/packages/stream_chat_flutter/example/lib/tutorial_part_5.dart b/packages/stream_chat_flutter/example/lib/tutorial_part_5.dart index 86ac2e1fd..347ceaf48 100644 --- a/packages/stream_chat_flutter/example/lib/tutorial_part_5.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial_part_5.dart @@ -59,28 +59,42 @@ class MyApp extends StatelessWidget { } } -class ChannelListPage extends StatelessWidget { +class ChannelListPage extends StatefulWidget { const ChannelListPage({ Key? key, }) : super(key: key); @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - return Scaffold( - body: ChannelsBloc( - child: ChannelListView( - filter: Filter.in_( - 'members', - [StreamChat.of(context).currentUser!.id], - ), - sort: const [SortOption('last_message_at')], - limit: 20, - channelWidget: const ChannelPage(), + State createState() => _ChannelListPageState(); +} + +class _ChannelListPageState extends State { + late final _listController = StreamChannelListController( + client: StreamChat.of(context).client, + filter: Filter.in_( + 'members', + [StreamChat.of(context).currentUser!.id], + ), + sort: const [SortOption('last_message_at')], + limit: 20, + ); + + @override + Widget build(BuildContext context) => Scaffold( + body: StreamChannelListView( + controller: _listController, + onChannelTap: (channel) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => StreamChannel( + channel: channel, + child: const ChannelPage(), + ), + ), + ); + }, ), - ), - ); - } + ); } class ChannelPage extends StatelessWidget { diff --git a/packages/stream_chat_flutter/example/lib/tutorial_part_6.dart b/packages/stream_chat_flutter/example/lib/tutorial_part_6.dart index 2555e0079..4bb8e928d 100644 --- a/packages/stream_chat_flutter/example/lib/tutorial_part_6.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial_part_6.dart @@ -93,28 +93,41 @@ class MyApp extends StatelessWidget { } } -class ChannelListPage extends StatelessWidget { +class ChannelListPage extends StatefulWidget { const ChannelListPage({ Key? key, }) : super(key: key); @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - return Scaffold( - body: ChannelsBloc( - child: ChannelListView( - filter: Filter.in_( - 'members', - [StreamChat.of(context).currentUser!.id], - ), - sort: const [SortOption('last_message_at')], - limit: 20, - channelWidget: const ChannelPage(), + State createState() => _ChannelListPageState(); +} + +class _ChannelListPageState extends State { + late final _listController = StreamChannelListController( + client: StreamChat.of(context).client, + filter: Filter.in_( + 'members', + [StreamChat.of(context).currentUser!.id], + ), + sort: const [SortOption('last_message_at')], + limit: 20, + ); + @override + Widget build(BuildContext context) => Scaffold( + body: StreamChannelListView( + controller: _listController, + onChannelTap: (channel) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => StreamChannel( + channel: channel, + child: const ChannelPage(), + ), + ), + ); + }, ), - ), - ); - } + ); } class ChannelPage extends StatelessWidget { @@ -123,24 +136,21 @@ class ChannelPage extends StatelessWidget { }) : super(key: key); @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - return Scaffold( - appBar: const ChannelHeader(), - body: Column( - children: [ - Expanded( - child: MessageListView( - threadBuilder: (_, parentMessage) => ThreadPage( - parent: parentMessage, + Widget build(BuildContext context) => Scaffold( + appBar: const ChannelHeader(), + body: Column( + children: [ + Expanded( + child: MessageListView( + threadBuilder: (_, parentMessage) => ThreadPage( + parent: parentMessage, + ), ), ), - ), - const MessageInput(), - ], - ), - ); - } + const MessageInput(), + ], + ), + ); } class ThreadPage extends StatelessWidget { diff --git a/packages/stream_chat_flutter/lib/src/channel_avatar.dart b/packages/stream_chat_flutter/lib/src/channel_avatar.dart index ba24a6407..f7c949ee4 100644 --- a/packages/stream_chat_flutter/lib/src/channel_avatar.dart +++ b/packages/stream_chat_flutter/lib/src/channel_avatar.dart @@ -44,6 +44,11 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// The widget renders the ui based on the first ancestor of type /// [StreamChatTheme]. /// Modify it to change the widget appearance. + +@Deprecated( + "'ChannelAvatar' is deprecated and shouldn't be used. " + "Please use 'StreamChannelAvatar' instead.", +) class ChannelAvatar extends StatelessWidget { /// Instantiate a new ChannelImage const ChannelAvatar({ diff --git a/packages/stream_chat_flutter/lib/src/channel_bottom_sheet.dart b/packages/stream_chat_flutter/lib/src/channel_bottom_sheet.dart index 92726939a..05d5996e5 100644 --- a/packages/stream_chat_flutter/lib/src/channel_bottom_sheet.dart +++ b/packages/stream_chat_flutter/lib/src/channel_bottom_sheet.dart @@ -4,6 +4,10 @@ import 'package:stream_chat_flutter/src/extension.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Bottom Sheet with options +@Deprecated( + "'ChannelBottomSheet' is deprecated and shouldn't be used. " + "Please use 'StreamChannelBottomSheet' instead.", +) class ChannelBottomSheet extends StatefulWidget { /// Constructor for creating bottom sheet const ChannelBottomSheet({Key? key, this.onViewInfoTap}) : super(key: key); @@ -15,6 +19,7 @@ class ChannelBottomSheet extends StatefulWidget { _ChannelBottomSheetState createState() => _ChannelBottomSheetState(); } +// ignore: deprecated_member_use_from_same_package class _ChannelBottomSheetState extends State { bool _showActions = true; diff --git a/packages/stream_chat_flutter/lib/src/channel_info.dart b/packages/stream_chat_flutter/lib/src/channel_info.dart index 0238e0f5b..6a8e0634d 100644 --- a/packages/stream_chat_flutter/lib/src/channel_info.dart +++ b/packages/stream_chat_flutter/lib/src/channel_info.dart @@ -94,11 +94,12 @@ class ChannelInfo extends StatelessWidget { return alternativeWidget ?? const Offstage(); } - return TypingIndicator( - parentId: parentId, - alignment: Alignment.center, - alternativeWidget: alternativeWidget, - style: textStyle, + return Align( + child: TypingIndicator( + parentId: parentId, + style: textStyle, + alternativeWidget: alternativeWidget, + ), ); } diff --git a/packages/stream_chat_flutter/lib/src/channel_list_view.dart b/packages/stream_chat_flutter/lib/src/channel_list_view.dart index 87752d776..cb5814911 100644 --- a/packages/stream_chat_flutter/lib/src/channel_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/channel_list_view.dart @@ -52,6 +52,10 @@ typedef ViewInfoCallback = void Function(Channel); /// The widget components render the ui based on the first ancestor of /// type [StreamChatTheme]. /// Modify it to change the widget appearance. +@Deprecated( + "'ChannelListView' is deprecated and shouldn't be used. " + "Please use 'StreamChannelListView' instead.", +) class ChannelListView extends StatefulWidget { /// Instantiate a new ChannelListView ChannelListView({ diff --git a/packages/stream_chat_flutter/lib/src/channel_name.dart b/packages/stream_chat_flutter/lib/src/channel_name.dart index a1e81005d..8dd8c0545 100644 --- a/packages/stream_chat_flutter/lib/src/channel_name.dart +++ b/packages/stream_chat_flutter/lib/src/channel_name.dart @@ -6,6 +6,10 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// /// The widget uses a [StreamBuilder] to render the channel information /// image as soon as it updates. +@Deprecated( + "'ChannelName' is deprecated and shouldn't be used. " + "Please use 'StreamChannelName' instead.", +) class ChannelName extends StatelessWidget { /// Instantiate a new ChannelName const ChannelName({ diff --git a/packages/stream_chat_flutter/lib/src/group_avatar.dart b/packages/stream_chat_flutter/lib/src/group_avatar.dart index bf599c638..e0e87e23d 100644 --- a/packages/stream_chat_flutter/lib/src/group_avatar.dart +++ b/packages/stream_chat_flutter/lib/src/group_avatar.dart @@ -6,6 +6,7 @@ class GroupAvatar extends StatelessWidget { /// Constructor for creating a [GroupAvatar] const GroupAvatar({ Key? key, + this.channel, required this.members, this.constraints, this.onTap, @@ -15,6 +16,9 @@ class GroupAvatar extends StatelessWidget { this.selectionThickness = 4, }) : super(key: key); + /// The channel of the avatar + final Channel? channel; + /// List of images to display final List members; @@ -38,7 +42,7 @@ class GroupAvatar extends StatelessWidget { @override Widget build(BuildContext context) { - final channel = StreamChannel.of(context).channel; + final channel = this.channel ?? StreamChannel.of(context).channel; assert(channel.state != null, 'Channel ${channel.id} is not initialized'); diff --git a/packages/stream_chat_flutter/lib/src/message_input/simple_safe_area.dart b/packages/stream_chat_flutter/lib/src/message_input/simple_safe_area.dart index b91684ed7..5f3d7391f 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/simple_safe_area.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/simple_safe_area.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; /// A [SafeArea] with an enabled toggle -class SimpleSafeArea extends StatefulWidget { +class SimpleSafeArea extends StatelessWidget { /// Constructor for [SimpleSafeArea] const SimpleSafeArea({ Key? key, @@ -15,17 +15,12 @@ class SimpleSafeArea extends StatefulWidget { /// Child widget to wrap final Widget child; - @override - _SimpleSafeAreaState createState() => _SimpleSafeAreaState(); -} - -class _SimpleSafeAreaState extends State { @override Widget build(BuildContext context) => SafeArea( - left: widget.enabled, - top: widget.enabled, - right: widget.enabled, - bottom: widget.enabled, - child: widget.child, + left: enabled, + top: enabled, + right: enabled, + bottom: enabled, + child: child, ); } diff --git a/packages/stream_chat_flutter/lib/src/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view.dart index 2773b1921..e4d677408 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -526,6 +526,7 @@ class _MessageListViewState extends State { return ((index + 2) * 2) - 1; } } + return null; }, // Item Count -> 8 (1 parent, 2 header+footer, 2 top+bottom, 3 messages) diff --git a/packages/stream_chat_flutter/lib/src/option_list_tile.dart b/packages/stream_chat_flutter/lib/src/option_list_tile.dart index 16bf9e971..b8daa0e3f 100644 --- a/packages/stream_chat_flutter/lib/src/option_list_tile.dart +++ b/packages/stream_chat_flutter/lib/src/option_list_tile.dart @@ -6,7 +6,7 @@ class OptionListTile extends StatelessWidget { /// Constructor for creating [OptionListTile] const OptionListTile({ Key? key, - this.title, + required this.title, this.leading, this.trailing, this.onTap, @@ -17,7 +17,7 @@ class OptionListTile extends StatelessWidget { }) : super(key: key); /// Title for tile - final String? title; + final String title; /// Leading widget (start) final Widget? leading; @@ -46,8 +46,8 @@ class OptionListTile extends StatelessWidget { return Column( children: [ Container( - color: separatorColor ?? chatThemeData.colorTheme.disabled, height: 1, + color: separatorColor ?? chatThemeData.colorTheme.disabled, ), Material( color: tileColor ?? chatThemeData.colorTheme.barsBg, @@ -57,15 +57,14 @@ class OptionListTile extends StatelessWidget { onTap: onTap, child: Row( children: [ - if (leading != null) Center(child: leading), - if (leading == null) - const SizedBox( - width: 16, - ), + if (leading != null) + Center(child: leading) + else + const SizedBox(width: 16), Expanded( flex: 4, child: Text( - title!, + title, style: titleTextStyle ?? (titleColor == null ? chatThemeData.textTheme.bodyBold diff --git a/packages/stream_chat_flutter/lib/src/theme/message_input_theme.dart b/packages/stream_chat_flutter/lib/src/theme/message_input_theme.dart index 9bc8ffd2b..81047d77e 100644 --- a/packages/stream_chat_flutter/lib/src/theme/message_input_theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/message_input_theme.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/extension.dart'; @@ -188,7 +190,7 @@ class MessageInputThemeData with Diagnosticable { linkHighlightColor: Color.lerp(a.linkHighlightColor, b.linkHighlightColor, t), enableSafeArea: a.enableSafeArea, - elevation: Tween(begin: a.elevation, end: b.elevation).transform(t), + elevation: lerpDouble(a.elevation, b.elevation, t), shadow: BoxShadow.lerp(a.shadow, b.shadow, t), ); diff --git a/packages/stream_chat_flutter/lib/src/thread_header.dart b/packages/stream_chat_flutter/lib/src/thread_header.dart index aae1d8f95..e7a9372f6 100644 --- a/packages/stream_chat_flutter/lib/src/thread_header.dart +++ b/packages/stream_chat_flutter/lib/src/thread_header.dart @@ -161,12 +161,13 @@ class ThreadHeader extends StatelessWidget implements PreferredSizeWidget { ), const SizedBox(height: 2), if (showTypingIndicator) - TypingIndicator( - alignment: Alignment.center, - channel: StreamChannel.of(context).channel, - style: channelHeaderTheme.subtitleStyle, - parentId: parent.id, - alternativeWidget: defaultSubtitle, + Align( + child: TypingIndicator( + channel: StreamChannel.of(context).channel, + style: channelHeaderTheme.subtitleStyle, + parentId: parent.id, + alternativeWidget: defaultSubtitle, + ), ) else defaultSubtitle, diff --git a/packages/stream_chat_flutter/lib/src/typing_indicator.dart b/packages/stream_chat_flutter/lib/src/typing_indicator.dart index d961a8ffb..389d1020d 100644 --- a/packages/stream_chat_flutter/lib/src/typing_indicator.dart +++ b/packages/stream_chat_flutter/lib/src/typing_indicator.dart @@ -11,7 +11,6 @@ class TypingIndicator extends StatelessWidget { this.channel, this.alternativeWidget, this.style, - this.alignment = Alignment.centerLeft, this.padding = const EdgeInsets.all(0), this.parentId, }) : super(key: key); @@ -28,9 +27,6 @@ class TypingIndicator extends StatelessWidget { /// The padding of this widget final EdgeInsets padding; - /// Alignment of the typing indicator - final Alignment alignment; - /// Id of the parent message in case of a thread final String? parentId; @@ -46,30 +42,25 @@ class TypingIndicator extends StatelessWidget { stream: channelState.typingEventsStream.map((typings) => typings.entries .where((element) => element.value.parentId == parentId) .map((e) => e.key)), - builder: (context, data) => AnimatedSwitcher( + builder: (context, users) => AnimatedSwitcher( duration: const Duration(milliseconds: 300), - child: data.isNotEmpty + child: users.isNotEmpty ? Padding( - key: const Key('main'), padding: padding, - child: Align( - key: const Key('typings'), - alignment: alignment, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Lottie.asset( - 'animations/typing_dots.json', - package: 'stream_chat_flutter', - height: 4, - ), - Text( - context.translations.userTypingText(data), - maxLines: 1, - style: style, - ), - ], - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Lottie.asset( + 'animations/typing_dots.json', + package: 'stream_chat_flutter', + height: 4, + ), + Text( + context.translations.userTypingText(users), + maxLines: 1, + style: style, + ), + ], ), ) : altWidget, diff --git a/packages/stream_chat_flutter/lib/src/v4/channel_list_view/stream_channel_list_loading_tile.dart b/packages/stream_chat_flutter/lib/src/v4/channel_list_view/stream_channel_list_loading_tile.dart new file mode 100644 index 000000000..69a6cf6e7 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/v4/channel_list_view/stream_channel_list_loading_tile.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'package:shimmer/shimmer.dart'; +import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; + +/// A shimmering list item which shows a loading effect. +/// +/// This is used by [StreamChannelListView] to show a loading effect while +/// the list is being loaded. +class StreamChannelListLoadingTile extends StatelessWidget { + /// Creates a new instance of [StreamChannelListLoadingTile] widget. + const StreamChannelListLoadingTile({ + Key? key, + this.visualDensity = VisualDensity.standard, + this.contentPadding = const EdgeInsets.symmetric(horizontal: 8), + }) : super(key: key); + + /// 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; + + @override + Widget build(BuildContext context) { + final colorTheme = StreamChatTheme.of(context).colorTheme; + + final leading = Container( + height: 49, + width: 49, + decoration: BoxDecoration( + color: colorTheme.barsBg, + shape: BoxShape.circle, + ), + ); + + final title = Container( + height: 16, + width: 66, + decoration: BoxDecoration( + color: colorTheme.barsBg, + borderRadius: BorderRadius.circular(8), + ), + ); + + final subtitle = Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: Container( + height: 16, + decoration: BoxDecoration( + color: colorTheme.barsBg, + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + const SizedBox(width: 8), + Container( + height: 16, + width: 50, + decoration: BoxDecoration( + color: colorTheme.barsBg, + borderRadius: BorderRadius.circular(8), + ), + ), + ], + ); + + return Shimmer.fromColors( + baseColor: colorTheme.disabled, + highlightColor: colorTheme.inputBg, + child: ListTile( + leading: leading, + title: title, + subtitle: subtitle, + visualDensity: visualDensity, + contentPadding: contentPadding, + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/v4/channel_list_view/stream_channel_list_tile.dart b/packages/stream_chat_flutter/lib/src/v4/channel_list_view/stream_channel_list_tile.dart new file mode 100644 index 000000000..f820e0402 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/v4/channel_list_view/stream_channel_list_tile.dart @@ -0,0 +1,424 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:stream_chat_flutter/src/extension.dart'; +import 'package:stream_chat_flutter/src/sending_indicator.dart'; +import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; +import 'package:stream_chat_flutter/src/theme/channel_preview_theme.dart'; +import 'package:stream_chat_flutter/src/typing_indicator.dart'; +import 'package:stream_chat_flutter/src/unread_indicator.dart'; +import 'package:stream_chat_flutter/src/v4/stream_channel_avatar.dart'; +import 'package:stream_chat_flutter/src/v4/stream_channel_name.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.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({ + Key? 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, + }) : assert( + channel.state != null, + 'Channel ${channel.id} is not initialized', + ), + super(key: key); + + /// 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.status]. + final Widget Function(BuildContext, Message)? sendingIndicatorBuilder; + + /// 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, + }) => + 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, + ); + + @override + Widget build(BuildContext context) { + final channelState = channel.state!; + final currentUser = channel.client.state.currentUser!; + + final channelPreviewTheme = ChannelPreviewTheme.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, + ); + + 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, + title: Row( + children: [ + Expanded(child: title), + BetterStreamBuilder>( + stream: channelState.membersStream, + initialData: channelState.members, + comparator: const ListEquality().equals, + builder: (context, members) { + if (members.isEmpty || + !members.any((it) => it.user!.id == currentUser.id)) { + return const Offstage(); + } + return unreadIndicatorBuilder?.call(context) ?? + UnreadIndicator(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 Offstage(); + } + + return Padding( + padding: const EdgeInsets.only(right: 4), + child: + sendingIndicatorBuilder?.call(context, lastMessage) ?? + SendingIndicator( + message: lastMessage, + size: channelPreviewTheme.indicatorIconSize, + isMessageRead: channelState + .currentUserRead!.lastRead + .isAfter(lastMessage.createdAt), + ), + ); + }, + ), + trailing, + ], + ), + ), + ), + ); + } +} + +/// A widget that displays the channel last message date. +class ChannelLastMessageDate extends StatelessWidget { + /// Creates a new instance of the [ChannelLastMessageDate] widget. + ChannelLastMessageDate({ + Key? key, + required this.channel, + this.textStyle, + }) : assert( + channel.state != null, + 'Channel ${channel.id} is not initialized', + ), + super(key: key); + + /// The channel to display the last message date for. + final Channel channel; + + /// The style of the text displayed + final TextStyle? textStyle; + + @override + Widget build(BuildContext context) => BetterStreamBuilder( + stream: channel.lastMessageAtStream, + initialData: channel.lastMessageAt, + builder: (context, data) { + final lastMessageAt = data.toLocal(); + + String stringDate; + final now = DateTime.now(); + + final startOfDay = DateTime(now.year, now.month, now.day); + + if (lastMessageAt.millisecondsSinceEpoch >= + startOfDay.millisecondsSinceEpoch) { + stringDate = Jiffy(lastMessageAt.toLocal()).jm; + } else if (lastMessageAt.millisecondsSinceEpoch >= + startOfDay + .subtract(const Duration(days: 1)) + .millisecondsSinceEpoch) { + stringDate = context.translations.yesterdayLabel; + } else if (startOfDay.difference(lastMessageAt).inDays < 7) { + stringDate = Jiffy(lastMessageAt.toLocal()).EEEE; + } else { + stringDate = Jiffy(lastMessageAt.toLocal()).yMd; + } + + return Text( + stringDate, + style: textStyle, + ); + }, + ); +} + +/// A widget that displays the subtitle for [StreamChannelListTile]. +class ChannelListTileSubtitle extends StatelessWidget { + /// Creates a new instance of [StreamChannelListTileSubtitle] widget. + ChannelListTileSubtitle({ + Key? key, + required this.channel, + this.textStyle, + }) : assert( + channel.state != null, + 'Channel ${channel.id} is not initialized', + ), + super(key: key); + + /// 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: [ + StreamSvgIcon.mute(size: 16), + Text( + ' ${context.translations.channelIsMutedText}', + style: textStyle, + ), + ], + ); + } + return TypingIndicator( + channel: channel, + style: textStyle, + alternativeWidget: ChannelLastMessageText( + channel: channel, + textStyle: textStyle, + ), + ); + } +} + +/// A widget that displays the last message of a channel. +class ChannelLastMessageText extends StatelessWidget { + /// Creates a new instance of [ChannelLastMessageText] widget. + ChannelLastMessageText({ + Key? key, + required this.channel, + this.textStyle, + }) : assert( + channel.state != null, + 'Channel ${channel.id} is not initialized', + ), + super(key: key); + + /// The channel to display the last message of. + final Channel channel; + + /// The style of the text displayed + final TextStyle? textStyle; + + @override + Widget build(BuildContext context) => BetterStreamBuilder>( + stream: channel.state!.messagesStream, + initialData: channel.state!.messages, + builder: (context, messages) { + final lastMessage = messages.lastWhereOrNull( + (m) => !m.shadowed && !m.isDeleted, + ); + + if (lastMessage == null) return const Offstage(); + + final lastMessageText = lastMessage.text; + final lastMessageAttachments = lastMessage.attachments; + final lastMessageMentionedUsers = lastMessage.mentionedUsers; + + final messageTextParts = [ + ...lastMessageAttachments.map((it) { + if (it.type == 'image') { + return '📷'; + } else if (it.type == 'video') { + return '🎬'; + } else if (it.type == 'giphy') { + return '[GIF]'; + } + return it == lastMessage.attachments.last + ? (it.title ?? 'File') + : '${it.title ?? 'File'} , '; + }), + if (lastMessageText != null) lastMessageText, + ]; + + final fontStyle = (lastMessage.isSystem || lastMessage.isDeleted) + ? FontStyle.italic + : FontStyle.normal; + + final regularTextStyle = textStyle?.copyWith(fontStyle: fontStyle); + + final mentionsTextStyle = textStyle?.copyWith( + fontStyle: fontStyle, + fontWeight: FontWeight.bold, + ); + + final spans = [ + for (final part in messageTextParts) + if (lastMessageMentionedUsers.isNotEmpty && + lastMessageMentionedUsers.any((it) => '@${it.name}' == part)) + TextSpan( + text: '$part ', + style: mentionsTextStyle, + ) + else if (lastMessageAttachments.isNotEmpty && + lastMessageAttachments + .where((it) => it.title != null) + .any((it) => it.title == part)) + TextSpan( + text: '$part ', + style: regularTextStyle, + ) + else + TextSpan( + text: part == messageTextParts.last ? part : '$part ', + style: regularTextStyle, + ), + ]; + + return Text.rich( + TextSpan(children: spans), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.start, + ); + }, + ); +} diff --git a/packages/stream_chat_flutter/lib/src/v4/channel_list_view/stream_channel_list_view.dart b/packages/stream_chat_flutter/lib/src/v4/channel_list_view/stream_channel_list_view.dart new file mode 100644 index 000000000..1d88f3548 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/v4/channel_list_view/stream_channel_list_view.dart @@ -0,0 +1,502 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/extension.dart'; +import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; +import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; +import 'package:stream_chat_flutter/src/v4/channel_list_view/stream_channel_list_loading_tile.dart'; +import 'package:stream_chat_flutter/src/v4/channel_list_view/stream_channel_list_tile.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +/// Default separator builder for [StreamChannelListView]. +Widget defaultSeparatorBuilder(BuildContext context, int index) => + const StreamChannelListSeparator(); + +/// Signature for the item builder that creates the children of the +/// [StreamChannelListView]. +typedef StreamChannelListViewItemBuilder = Widget Function( + BuildContext context, + Channel channel, + StreamChannelListTile defaultWidget, +); + +/// A [ListView] that shows a list of [Channel]s, +/// it uses [StreamChannelListTile] as a default item. +/// +/// This is the new version of [ChannelListView] that uses +/// [StreamChannelListController]. +/// +/// Example: +/// +/// ```dart +/// StreamChannelListView( +/// controller: controller, +/// onChannelTap: (channel) { +/// // Handle channel tap event +/// }, +/// onChannelLongPress: (channel) { +/// // Handle channel long press event +/// }, +/// ) +/// ``` +/// +/// See also: +/// * [StreamChannelListTile] +/// * [StreamChannelListController] +class StreamChannelListView extends StatefulWidget { + /// Creates a new instance of [StreamChannelListView]. + const StreamChannelListView({ + Key? key, + required this.controller, + this.itemBuilder, + this.separatorBuilder = defaultSeparatorBuilder, + this.emptyBuilder, + this.loadingBuilder, + this.errorBuilder, + this.onChannelTap, + this.onChannelLongPress, + this.padding, + this.physics, + this.reverse = false, + this.scrollController, + this.primary, + this.scrollBehavior, + this.shrinkWrap = false, + this.cacheExtent, + this.dragStartBehavior = DragStartBehavior.start, + this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, + this.restorationId, + }) : super(key: key); + + /// The [StreamChannelListController] used to control the list of channels. + final StreamChannelListController controller; + + /// A builder that is called to build items in the [ListView]. + /// + /// The `channel` parameter is the [Channel] at this position in the list + /// and the `defaultWidget` is the default widget used + /// i.e: [StreamChannelListTile]. + final StreamChannelListViewItemBuilder? itemBuilder; + + /// A builder that is called to build the list separator. + final IndexedWidgetBuilder separatorBuilder; + + /// A builder that is called to build the empty state of the list. + /// + /// If not provider, [StreamChannelListEmptyWidget] will be used. + final WidgetBuilder? emptyBuilder; + + /// A builder that is called to build the loading state of the list. + /// + /// If not provided, [StreamChannelListLoadingTile] will be used. + final WidgetBuilder? loadingBuilder; + + /// A builder that is called to build the error state of the list. + /// + /// If not provided, [StreamChannelListErrorWidget] will be used. + final Widget Function(BuildContext, StreamChatError)? errorBuilder; + + /// Called when the user taps this list tile. + final void Function(Channel)? onChannelTap; + + /// Called when the user long-presses on this list tile. + final void Function(Channel)? onChannelLongPress; + + /// The amount of space by which to inset the children. + final EdgeInsetsGeometry? padding; + + /// {@template flutter.widgets.scroll_view.reverse} + /// Whether the scroll view scrolls in the reading direction. + /// + /// For example, if [scrollDirection] is [Axis.vertical], then the scroll view + /// scrolls from top to bottom when [reverse] is false and from bottom to top + /// when [reverse] is true. + /// + /// Defaults to false. + /// {@endtemplate} + final bool reverse; + + /// {@template flutter.widgets.scroll_view.controller} + /// An object that can be used to control the position to which this scroll + /// view is scrolled. + /// + /// Must be null if [primary] is true. + /// + /// A [ScrollController] serves several purposes. It can be used to control + /// the initial scroll position (see [ScrollController.initialScrollOffset]). + /// It can be used to control whether the scroll view should automatically + /// save and restore its scroll position in the [PageStorage] (see + /// [ScrollController.keepScrollOffset]). It can be used to read the current + /// scroll position (see [ScrollController.offset]), or change it (see + /// [ScrollController.animateTo]). + /// {@endtemplate} + final ScrollController? scrollController; + + /// {@template flutter.widgets.scroll_view.primary} + /// Whether this is the primary scroll view associated with the parent + /// [PrimaryScrollController]. + /// + /// When this is true, the scroll view is scrollable even if it does not have + /// sufficient content to actually scroll. Otherwise, by default the user can + /// only scroll the view if it has sufficient content. See [physics]. + /// + /// Also when true, the scroll view is used for default [ScrollAction]s. If a + /// ScrollAction is not handled by an otherwise focused part of the + /// application, the ScrollAction will be evaluated using this scroll view, + /// for example, when executing [Shortcuts] key events like page up and down. + /// + /// On iOS, this also identifies the scroll view that will scroll to top in + /// response to a tap in the status bar. + /// {@endtemplate} + /// + /// Defaults to true when [scrollController] is null. + final bool? primary; + + /// {@macro flutter.widgets.shadow.scrollBehavior} + /// + /// [ScrollBehavior]s also provide [ScrollPhysics]. If an explicit + /// [ScrollPhysics] is provided in [physics], it will take precedence, + /// followed by [scrollBehavior], and then the inherited ancestor + /// [ScrollBehavior]. + final ScrollBehavior? scrollBehavior; + + /// {@template flutter.widgets.scroll_view.shrinkWrap} + /// Whether the extent of the scroll view in the [scrollDirection] should be + /// determined by the contents being viewed. + /// + /// If the scroll view does not shrink wrap, then the scroll view will expand + /// to the maximum allowed size in the [scrollDirection]. If the scroll view + /// has unbounded constraints in the [scrollDirection], then [shrinkWrap] must + /// be true. + /// + /// Shrink wrapping the content of the scroll view is significantly more + /// expensive than expanding to the maximum allowed size because the content + /// can expand and contract during scrolling, which means the size of the + /// scroll view needs to be recomputed whenever the scroll position changes. + /// + /// Defaults to false. + /// {@endtemplate} + final bool shrinkWrap; + + /// {@template flutter.widgets.scroll_view.physics} + /// How the scroll view should respond to user input. + /// + /// For example, determines how the scroll view continues to animate after the + /// user stops dragging the scroll view. + /// + /// Defaults to matching platform conventions. Furthermore, if [primary] is + /// false, then the user cannot scroll if there is insufficient content to + /// scroll, while if [primary] is true, they can always attempt to scroll. + /// + /// To force the scroll view to always be scrollable even if there is + /// insufficient content, as if [primary] was true but without necessarily + /// setting it to true, provide an [AlwaysScrollableScrollPhysics] physics + /// object, as in: + /// + /// ```dart + /// physics: const AlwaysScrollableScrollPhysics(), + /// ``` + /// + /// To force the scroll view to use the default platform conventions and not + /// be scrollable if there is insufficient content, regardless of the value of + /// [primary], provide an explicit [ScrollPhysics] object, as in: + /// + /// ```dart + /// physics: const ScrollPhysics(), + /// ``` + /// + /// The physics can be changed dynamically (by providing a new object in a + /// subsequent build), but new physics will only take effect if the _class_ of + /// the provided object changes. Merely constructing a new instance with a + /// different configuration is insufficient to cause the physics to be + /// reapplied. (This is because the final object used is generated + /// dynamically, which can be relatively expensive, and it would be + /// inefficient to speculatively create this object each frame to see if the + /// physics should be updated.) + /// {@endtemplate} + /// + /// If an explicit [ScrollBehavior] is provided to [scrollBehavior], the + /// [ScrollPhysics] provided by that behavior will take precedence after + /// [physics]. + final ScrollPhysics? physics; + + /// {@macro flutter.rendering.RenderViewportBase.cacheExtent} + final double? cacheExtent; + + /// {@macro flutter.widgets.scrollable.dragStartBehavior} + final DragStartBehavior dragStartBehavior; + + /// {@template flutter.widgets.scroll_view.keyboardDismissBehavior} + /// [ScrollViewKeyboardDismissBehavior] the defines how this [ScrollView] will + /// dismiss the keyboard automatically. + /// {@endtemplate} + final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior; + + /// {@macro flutter.widgets.scrollable.restorationId} + final String? restorationId; + + @override + _StreamChannelListViewState createState() => _StreamChannelListViewState(); +} + +class _StreamChannelListViewState extends State { + StreamChannelListController get _controller => widget.controller; + + // Avoids duplicate requests on rebuilds. + bool _hasRequestedNextPage = false; + + @override + void initState() { + super.initState(); + _controller.doInitialLoad(); + } + + @override + void didUpdateWidget(covariant StreamChannelListView oldWidget) { + super.didUpdateWidget(oldWidget); + if (_controller != oldWidget.controller) { + // reset duplicate requests flag + _hasRequestedNextPage = false; + _controller.doInitialLoad(); + } + } + + @override + Widget build(BuildContext context) => + PagedValueListenableBuilder( + valueListenable: widget.controller, + builder: (context, value, _) => value.when( + (channels, nextPageKey, error) { + if (channels.isEmpty) { + return widget.emptyBuilder?.call(context) ?? + const Center( + child: Padding( + padding: EdgeInsets.all(8), + child: StreamChannelListEmptyWidget(), + ), + ); + } + + return ListView.separated( + padding: widget.padding, + physics: widget.physics, + reverse: widget.reverse, + controller: widget.scrollController, + primary: widget.primary, + shrinkWrap: widget.shrinkWrap, + keyboardDismissBehavior: widget.keyboardDismissBehavior, + restorationId: widget.restorationId, + dragStartBehavior: widget.dragStartBehavior, + cacheExtent: widget.cacheExtent, + itemCount: value.itemCount, + separatorBuilder: widget.separatorBuilder, + itemBuilder: (context, index) { + if (!_hasRequestedNextPage) { + final newPageRequestTriggerIndex = channels.length - 3; + final isBuildingTriggerIndexItem = + index == newPageRequestTriggerIndex; + if (nextPageKey != null && isBuildingTriggerIndexItem) { + // Schedules the request for the end of this frame. + WidgetsBinding.instance?.addPostFrameCallback((_) async { + if (error == null) { + await _controller.loadMore(nextPageKey); + } + _hasRequestedNextPage = false; + }); + _hasRequestedNextPage = true; + } + } + + if (index == channels.length) { + if (error != null) { + return StreamChannelListLoadMoreError( + onTap: _controller.retry, + ); + } + return const Center( + child: Padding( + padding: EdgeInsets.all(16), + child: StreamChannelListLoadMoreIndicator(), + ), + ); + } + + final channel = channels[index]; + + final onTap = widget.onChannelTap; + final onLongPress = widget.onChannelLongPress; + + final streamChannelListTile = StreamChannelListTile( + channel: channel, + onTap: onTap == null ? null : () => onTap(channel), + onLongPress: + onLongPress == null ? null : () => onLongPress(channel), + ); + + final itemBuilder = widget.itemBuilder; + + if (itemBuilder != null) { + return itemBuilder( + context, + channel, + streamChannelListTile, + ); + } + + return streamChannelListTile; + }, + ); + }, + loading: () => + widget.loadingBuilder?.call(context) ?? + ListView.separated( + padding: widget.padding, + physics: widget.physics, + reverse: widget.reverse, + itemCount: 25, + separatorBuilder: widget.separatorBuilder, + itemBuilder: (_, __) => const StreamChannelListLoadingTile(), + ), + error: (error) => + widget.errorBuilder?.call(context, error) ?? + Center( + child: StreamChannelListErrorWidget( + onPressed: _controller.refresh, + ), + ), + ), + ); +} + +/// A [StreamChannelListTile] that can be used in a [ListView] to show a +/// loading tile while waiting for the [StreamChannelListController] to load +/// more channels. +class StreamChannelListLoadMoreIndicator extends StatelessWidget { + /// Creates a new instance of [StreamChannelListLoadMoreIndicator]. + const StreamChannelListLoadMoreIndicator({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) => const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator.adaptive(), + ); +} + +/// A [StreamChannelListTile] that is used to display the error indicator when +/// loading more channels fails. +class StreamChannelListLoadMoreError extends StatelessWidget { + /// Creates a new instance of [StreamChannelListLoadMoreError]. + const StreamChannelListLoadMoreError({ + Key? key, + this.onTap, + }) : super(key: key); + + /// The callback to invoke when the user taps on the error indicator. + final GestureTapCallback? onTap; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + return InkWell( + onTap: onTap, + child: Container( + color: theme.colorTheme.textLowEmphasis.withOpacity(0.9), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + context.translations.loadingChannelsError, + style: theme.textTheme.body.copyWith( + color: Colors.white, + ), + ), + StreamSvgIcon.retry(color: Colors.white), + ], + ), + ), + ), + ); + } +} + +/// A widget that is used to display a separator between +/// [StreamChannelListTile] items. +class StreamChannelListSeparator extends StatelessWidget { + /// Creates a new instance of [StreamChannelListSeparator]. + const StreamChannelListSeparator({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final effect = StreamChatTheme.of(context).colorTheme.borderBottom; + return Container( + height: 1, + color: effect.color!.withOpacity(effect.alpha ?? 1.0), + ); + } +} + +/// A widget that is used to display an error screen +/// when [StreamChannelListController] fails to load initial channels. +class StreamChannelListErrorWidget extends StatelessWidget { + /// Creates a new instance of [StreamChannelListErrorWidget] widget. + const StreamChannelListErrorWidget({ + Key? key, + this.onPressed, + }) : super(key: key); + + /// The callback to invoke when the user taps on the retry button. + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) => Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text.rich( + TextSpan( + children: [ + const WidgetSpan( + child: Padding( + padding: EdgeInsets.only(right: 2), + child: Icon(Icons.error_outline), + ), + ), + TextSpan(text: context.translations.loadingChannelsError), + ], + ), + style: Theme.of(context).textTheme.headline6, + ), + TextButton( + onPressed: onPressed, + child: Text(context.translations.retryLabel), + ), + ], + ); +} + +/// A widget that is used to display an empty state when +/// [StreamChannelListController] loads zero channels. +class StreamChannelListEmptyWidget extends StatelessWidget { + /// Creates a new instance of [StreamChannelListEmptyWidget] widget. + const StreamChannelListEmptyWidget({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final chatThemeData = StreamChatTheme.of(context); + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + StreamSvgIcon.message( + size: 148, + color: chatThemeData.colorTheme.disabled, + ), + const SizedBox(height: 28), + Text( + context.translations.letsStartChattingLabel, + style: chatThemeData.textTheme.headline, + ), + ], + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/v4/stream_channel_avatar.dart b/packages/stream_chat_flutter/lib/src/v4/stream_channel_avatar.dart new file mode 100644 index 000000000..fd07adc8a --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/v4/stream_channel_avatar.dart @@ -0,0 +1,200 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/group_avatar.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/channel_image.png) +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/channel_image_paint.png) +/// +/// It shows the current [Channel] image. +/// +/// ```dart +/// class MyApp extends StatelessWidget { +/// final StreamChatClient client; +/// final Channel channel; +/// +/// MyApp(this.client, this.channel); +/// +/// @override +/// Widget build(BuildContext context) { +/// return MaterialApp( +/// debugShowCheckedModeBanner: false, +/// home: StreamChat( +/// client: client, +/// child: StreamChannel( +/// channel: channel, +/// child: Center( +/// child: ChannelImage( +/// channel: channel, +/// ), +/// ), +/// ), +/// ), +/// ); +/// } +/// } +/// ``` +/// +/// The widget uses a [StreamBuilder] to render the channel information +/// image as soon as it updates. +/// +/// By default the widget radius size is 40x40 pixels. +/// Set the property [constraints] to set a custom dimension. +/// +/// The widget renders the ui based on the first ancestor of type +/// [StreamChatTheme]. +/// Modify it to change the widget appearance. +class StreamChannelAvatar extends StatelessWidget { + /// Instantiate a new ChannelImage + StreamChannelAvatar({ + Key? key, + required this.channel, + this.constraints, + this.onTap, + this.borderRadius, + this.selected = false, + this.selectionColor, + this.selectionThickness = 4, + }) : assert( + channel.state != null, + 'Channel ${channel.id} is not initialized', + ), + super(key: key); + + /// [BorderRadius] to display the widget + final BorderRadius? borderRadius; + + /// The channel to show the image of + final Channel channel; + + /// The diameter of the image + final BoxConstraints? constraints; + + /// The function called when the image is tapped + final VoidCallback? onTap; + + /// If image is selected + final bool selected; + + /// Selection color for image + final Color? selectionColor; + + /// Thickness of selection image + final double selectionThickness; + + @override + Widget build(BuildContext context) { + final client = channel.client.state; + + final chatThemeData = StreamChatTheme.of(context); + final colorTheme = chatThemeData.colorTheme; + final previewTheme = chatThemeData.channelPreviewTheme.avatarTheme; + + return BetterStreamBuilder( + stream: channel.imageStream, + initialData: channel.image, + builder: (context, channelImage) { + Widget child = ClipRRect( + borderRadius: borderRadius ?? previewTheme?.borderRadius, + child: Container( + constraints: constraints ?? previewTheme?.constraints, + decoration: BoxDecoration(color: colorTheme.accentPrimary), + child: InkWell( + onTap: onTap, + child: CachedNetworkImage( + imageUrl: channelImage, + errorWidget: (_, __, ___) => Center( + child: Text( + channel.name?[0] ?? '', + style: TextStyle( + color: colorTheme.barsBg, + fontWeight: FontWeight.bold, + ), + ), + ), + fit: BoxFit.cover, + ), + ), + ), + ); + + if (selected) { + child = ClipRRect( + key: const Key('selectedImage'), + borderRadius: BorderRadius.circular(selectionThickness) + + (borderRadius ?? + previewTheme?.borderRadius ?? + BorderRadius.zero), + child: Container( + constraints: constraints ?? previewTheme?.constraints, + color: selectionColor ?? colorTheme.accentPrimary, + child: Padding( + padding: EdgeInsets.all(selectionThickness), + child: child, + ), + ), + ); + } + return child; + }, + noDataBuilder: (context) { + final currentUser = client.currentUser!; + final otherMembers = channel.state!.members + .where((it) => it.userId != currentUser.id) + .toList(growable: false); + + // our own space, no other members + if (otherMembers.isEmpty) { + return BetterStreamBuilder( + stream: client.currentUserStream.map((it) => it!), + initialData: currentUser, + builder: (context, user) => UserAvatar( + borderRadius: borderRadius ?? previewTheme?.borderRadius, + user: user, + constraints: constraints ?? previewTheme?.constraints, + onTap: onTap != null ? (_) => onTap!() : null, + selected: selected, + selectionColor: selectionColor ?? colorTheme.accentPrimary, + selectionThickness: selectionThickness, + ), + ); + } + + // 1-1 Conversation + if (otherMembers.length == 1) { + final member = otherMembers.first; + return BetterStreamBuilder( + stream: channel.state!.membersStream.map( + (members) => members.firstWhere( + (it) => it.userId == member.userId, + orElse: () => member, + ), + ), + initialData: member, + builder: (context, member) => UserAvatar( + borderRadius: borderRadius ?? previewTheme?.borderRadius, + user: member.user!, + constraints: constraints ?? previewTheme?.constraints, + onTap: onTap != null ? (_) => onTap!() : null, + selected: selected, + selectionColor: selectionColor ?? colorTheme.accentPrimary, + selectionThickness: selectionThickness, + ), + ); + } + + // Group conversation + return GroupAvatar( + channel: channel, + members: otherMembers, + borderRadius: borderRadius ?? previewTheme?.borderRadius, + constraints: constraints ?? previewTheme?.constraints, + onTap: onTap, + selected: selected, + selectionColor: selectionColor ?? colorTheme.accentPrimary, + selectionThickness: selectionThickness, + ); + }, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/v4/stream_channel_info_bottom_sheet.dart b/packages/stream_chat_flutter/lib/src/v4/stream_channel_info_bottom_sheet.dart new file mode 100644 index 000000000..5107131d1 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/v4/stream_channel_info_bottom_sheet.dart @@ -0,0 +1,366 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter/src/channel_info.dart'; +import 'package:stream_chat_flutter/src/extension.dart'; +import 'package:stream_chat_flutter/src/option_list_tile.dart'; +import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; +import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; +import 'package:stream_chat_flutter/src/theme/themes.dart'; +import 'package:stream_chat_flutter/src/user_avatar.dart'; +import 'package:stream_chat_flutter/src/v4/stream_channel_name.dart'; + +/// A [BottomSheet] that shows information about a [Channel]. +class StreamChannelInfoBottomSheet extends StatelessWidget { + /// Creates a new instance [StreamChannelInfoBottomSheet] widget. + StreamChannelInfoBottomSheet({ + Key? key, + required this.channel, + this.onMemberTap, + this.onViewInfoTap, + this.onLeaveChannelTap, + this.onDeleteConversationTap, + this.onCancelTap, + }) : assert( + channel.state != null, + 'Channel ${channel.id} is not initialized', + ), + super(key: key); + + /// The [Channel] to show information about. + final Channel channel; + + /// A callback that is called when a member is tapped. + final void Function(Member)? onMemberTap; + + /// A callback that is called when the "View Info" button is tapped. + final VoidCallback? onViewInfoTap; + + /// A callback that is called when the "Leave Channel" button is tapped. + /// + /// Only shown when the channel is a group channel. + final VoidCallback? onLeaveChannelTap; + + /// A callback that is called when the "Delete Conversation" button is tapped. + /// + /// Only shown when you are the `owner` of the channel. + final VoidCallback? onDeleteConversationTap; + + /// A callback that is called when the "Cancel" button is tapped. + final VoidCallback? onCancelTap; + + @override + Widget build(BuildContext context) { + final themeData = StreamChatTheme.of(context); + final colorTheme = themeData.colorTheme; + final channelPreviewTheme = ChannelPreviewTheme.of(context); + + final currentUser = channel.client.state.currentUser; + final isOneToOneChannel = channel.isDistinct && channel.memberCount == 2; + + final members = channel.state?.members ?? []; + + final isOwner = members.any( + (it) => it.user?.id == currentUser?.id && it.role == 'owner', + ); + + // remove current user in case it's 1-1 conversation + if (isOneToOneChannel) { + members.removeWhere((it) => it.user?.id == currentUser?.id); + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 24), + Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: StreamChannelName( + channel: channel, + textStyle: themeData.textTheme.headlineBold, + ), + ), + ), + const SizedBox(height: 5), + Center( + // TODO: Refactor ChannelInfo + child: ChannelInfo( + showTypingIndicator: false, + channel: channel, + textStyle: channelPreviewTheme.subtitleStyle, + ), + ), + const SizedBox(height: 17), + Container( + height: 94, + alignment: Alignment.center, + child: ListView.separated( + shrinkWrap: true, + itemCount: members.length, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 8), + separatorBuilder: (context, index) => const SizedBox(width: 16), + itemBuilder: (context, index) { + final member = members[index]; + final user = member.user!; + return Column( + children: [ + UserAvatar( + user: user, + constraints: const BoxConstraints( + maxHeight: 64, + maxWidth: 64, + ), + borderRadius: BorderRadius.circular(32), + onlineIndicatorConstraints: BoxConstraints.tight( + const Size(12, 12), + ), + onTap: onMemberTap != null + ? (_) => onMemberTap!(member) + : null, + ), + const SizedBox(height: 6), + Text( + user.name, + style: themeData.textTheme.footnoteBold, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ); + }, + ), + ), + const SizedBox(height: 24), + OptionListTile( + leading: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: StreamSvgIcon.user( + color: colorTheme.textLowEmphasis, + ), + ), + title: context.translations.viewInfoLabel, + onTap: onViewInfoTap, + ), + if (!isOneToOneChannel) + OptionListTile( + title: context.translations.leaveGroupLabel, + leading: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: StreamSvgIcon.userRemove( + color: colorTheme.textLowEmphasis, + ), + ), + onTap: onLeaveChannelTap, + ), + if (isOwner) + OptionListTile( + leading: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: StreamSvgIcon.delete( + color: colorTheme.accentError, + ), + ), + title: context.translations.deleteConversationLabel, + titleColor: colorTheme.accentError, + onTap: onDeleteConversationTap, + ), + OptionListTile( + leading: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: StreamSvgIcon.closeSmall( + color: colorTheme.textLowEmphasis, + ), + ), + title: context.translations.cancelLabel, + onTap: onCancelTap ?? Navigator.of(context).pop, + ), + ], + ); + } +} + +const _kDefaultChannelInfoBottomSheetShape = RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(32), + topRight: Radius.circular(32), + ), +); + +/// Shows a modal material design bottom sheet. +/// +/// A modal bottom sheet is an alternative to a menu or a dialog and prevents +/// the user from interacting with the rest of the app. +/// +/// A closely related widget is a persistent bottom sheet, which shows +/// information that supplements the primary content of the app without +/// preventing the use from interacting with the app. Persistent bottom sheets +/// can be created and displayed with the [showBottomSheet] function or the +/// [ScaffoldState.showBottomSheet] method. +/// +/// The `context` argument is used to look up the [Navigator] and [Theme] for +/// the bottom sheet. It is only used when the method is called. Its +/// corresponding widget can be safely removed from the tree before the bottom +/// sheet is closed. +/// +/// The `isScrollControlled` parameter specifies whether this is a route for +/// a bottom sheet that will utilize [DraggableScrollableSheet]. If you wish +/// to have a bottom sheet that has a scrollable child such as a [ListView] or +/// a [GridView] and have the bottom sheet be draggable, you should set this +/// parameter to true. +/// +/// The `useRootNavigator` parameter ensures that the root navigator is used to +/// display the [BottomSheet] when set to `true`. This is useful in the case +/// that a modal [BottomSheet] needs to be displayed above all other content +/// but the caller is inside another [Navigator]. +/// +/// The [isDismissible] parameter specifies whether the bottom sheet will be +/// dismissed when user taps on the scrim. +/// +/// The [enableDrag] parameter specifies whether the bottom sheet can be +/// dragged up and down and dismissed by swiping downwards. +/// +/// The optional [backgroundColor], [elevation], [shape], [clipBehavior], +/// [constraints] and [transitionAnimationController] +/// parameters can be passed in to customize the appearance and behavior of +/// modal bottom sheets (see the documentation for these on [BottomSheet] +/// for more details). +/// +/// The [transitionAnimationController] controls the bottom sheet's entrance and +/// exit animations if provided. +/// +/// The optional `routeSettings` parameter sets the [RouteSettings] +/// of the modal bottom sheet sheet. +/// This is particularly useful in the case that a user wants to observe +/// [PopupRoute]s within a [NavigatorObserver]. +/// +/// Returns a `Future` that resolves to the value (if any) that was passed to +/// [Navigator.pop] when the modal bottom sheet was closed. +/// +/// See also: +/// +/// * [BottomSheet], which becomes the parent of the widget returned by the +/// function passed as the `builder` argument to [showModalBottomSheet]. +/// * [showBottomSheet] and [ScaffoldState.showBottomSheet], for showing +/// non-modal bottom sheets. +/// * [DraggableScrollableSheet], which allows you to create a bottom sheet +/// that grows and then becomes scrollable once it reaches its maximum size. +/// * +Future showChannelInfoModalBottomSheet({ + required BuildContext context, + required Channel channel, + Color? backgroundColor, + double? elevation, + BoxConstraints? constraints, + Color? barrierColor, + bool isScrollControlled = true, + bool useRootNavigator = false, + bool isDismissible = true, + bool enableDrag = true, + RouteSettings? routeSettings, + AnimationController? transitionAnimationController, + Clip? clipBehavior = Clip.hardEdge, + ShapeBorder? shape = _kDefaultChannelInfoBottomSheetShape, + void Function(Member)? onMemberTap, + VoidCallback? onViewInfoTap, + VoidCallback? onLeaveChannelTap, + VoidCallback? onDeleteConversationTap, + VoidCallback? onCancelTap, +}) => + showModalBottomSheet( + context: context, + backgroundColor: backgroundColor, + elevation: elevation, + shape: shape, + clipBehavior: clipBehavior, + constraints: constraints, + barrierColor: barrierColor, + isScrollControlled: isScrollControlled, + useRootNavigator: useRootNavigator, + isDismissible: isDismissible, + enableDrag: enableDrag, + routeSettings: routeSettings, + transitionAnimationController: transitionAnimationController, + builder: (BuildContext context) => StreamChannelInfoBottomSheet( + channel: channel, + onMemberTap: onMemberTap, + onViewInfoTap: onViewInfoTap, + onLeaveChannelTap: onLeaveChannelTap, + onDeleteConversationTap: onDeleteConversationTap, + onCancelTap: onCancelTap, + ), + ); + +/// Shows a material design bottom sheet in the nearest [Scaffold] ancestor. If +/// you wish to show a persistent bottom sheet, use [Scaffold.bottomSheet]. +/// +/// Returns a controller that can be used to close and otherwise manipulate the +/// bottom sheet. +/// +/// The optional [backgroundColor], [elevation], [shape], [clipBehavior], +/// [constraints] and [transitionAnimationController] +/// parameters can be passed in to customize the appearance and behavior of +/// persistent bottom sheets (see the documentation for these on [BottomSheet] +/// for more details). +/// +/// To rebuild the bottom sheet (e.g. if it is stateful), call +/// [PersistentBottomSheetController.setState] on the controller returned by +/// this method. +/// +/// The new bottom sheet becomes a [LocalHistoryEntry] for the enclosing +/// [ModalRoute] and a back button is added to the app bar of the [Scaffold] +/// that closes the bottom sheet. +/// +/// To create a persistent bottom sheet that is not a [LocalHistoryEntry] and +/// does not add a back button to the enclosing Scaffold's app bar, use the +/// [Scaffold.bottomSheet] constructor parameter. +/// +/// A closely related widget is a modal bottom sheet, which is an alternative +/// to a menu or a dialog and prevents the user from interacting with the rest +/// of the app. Modal bottom sheets can be created and displayed with the +/// [showModalBottomSheet] function. +/// +/// The `context` argument is used to look up the [Scaffold] for the bottom +/// sheet. It is only used when the method is called. Its corresponding widget +/// can be safely removed from the tree before the bottom sheet is closed. +/// +/// See also: +/// +/// * [BottomSheet], which becomes the parent of the widget returned by the +/// `builder`. +/// * [showModalBottomSheet], which can be used to display a modal bottom +/// sheet. +/// * [Scaffold.of], for information about how to obtain the [BuildContext]. +/// * +PersistentBottomSheetController showChannelInfoBottomSheet({ + required BuildContext context, + required Channel channel, + Color? backgroundColor, + double? elevation, + BoxConstraints? constraints, + AnimationController? transitionAnimationController, + Clip? clipBehavior = Clip.hardEdge, + ShapeBorder? shape = _kDefaultChannelInfoBottomSheetShape, + void Function(Member)? onMemberTap, + VoidCallback? onViewInfoTap, + VoidCallback? onLeaveChannelTap, + VoidCallback? onDeleteConversationTap, + VoidCallback? onCancelTap, +}) => + showBottomSheet( + context: context, + backgroundColor: backgroundColor, + elevation: elevation, + shape: shape, + clipBehavior: clipBehavior, + constraints: constraints, + transitionAnimationController: transitionAnimationController, + builder: (BuildContext context) => StreamChannelInfoBottomSheet( + channel: channel, + onMemberTap: onMemberTap, + onViewInfoTap: onViewInfoTap, + onLeaveChannelTap: onLeaveChannelTap, + onDeleteConversationTap: onDeleteConversationTap, + onCancelTap: onCancelTap, + ), + ); diff --git a/packages/stream_chat_flutter/lib/src/v4/stream_channel_name.dart b/packages/stream_chat_flutter/lib/src/v4/stream_channel_name.dart new file mode 100644 index 000000000..c6070e0f1 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/v4/stream_channel_name.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/extension.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +/// It shows the current [Channel] name using a [Text] widget. +/// +/// The widget uses a [StreamBuilder] to render the channel information +/// image as soon as it updates. +class StreamChannelName extends StatelessWidget { + /// Instantiate a new ChannelName + StreamChannelName({ + Key? key, + required this.channel, + this.textStyle, + this.textOverflow = TextOverflow.ellipsis, + }) : assert( + channel.state != null, + 'Channel ${channel.id} is not initialized', + ), + super(key: key); + + /// The [Channel] to show the name for. + final Channel channel; + + /// The style of the text displayed + final TextStyle? textStyle; + + /// How visual overflow should be handled. + final TextOverflow textOverflow; + + @override + Widget build(BuildContext context) => BetterStreamBuilder( + stream: channel.nameStream, + initialData: channel.name, + builder: (context, channelName) => Text( + channelName, + style: textStyle, + overflow: textOverflow, + ), + noDataBuilder: (context) => _generateName( + channel.client.state.currentUser!, + channel.state!.members, + ), + ); + + Widget _generateName( + User currentUser, + List members, + ) => + LayoutBuilder( + builder: (context, constraints) { + var channelName = context.translations.noTitleText; + final otherMembers = members.where( + (member) => member.userId != currentUser.id, + ); + + if (otherMembers.isNotEmpty) { + if (otherMembers.length == 1) { + final user = otherMembers.first.user; + if (user != null) { + channelName = user.name; + } + } else { + final maxWidth = constraints.maxWidth; + final maxChars = maxWidth / (textStyle?.fontSize ?? 1); + var currentChars = 0; + final currentMembers = []; + otherMembers.forEach((element) { + final newLength = + currentChars + (element.user?.name.length ?? 0); + if (newLength < maxChars) { + currentChars = newLength; + currentMembers.add(element); + } + }); + + final exceedingMembers = + otherMembers.length - currentMembers.length; + channelName = + '${currentMembers.map((e) => e.user?.name).join(', ')} ' + '${exceedingMembers > 0 ? '+ $exceedingMembers' : ''}'; + } + } + + return Text( + channelName, + style: textStyle, + overflow: textOverflow, + ); + }, + ); +} diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index ad3f968d8..83a315102 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -25,8 +25,6 @@ export 'src/mention_tile.dart'; export 'src/message_action.dart'; export 'src/message_input/countdown_button.dart'; export 'src/message_input/message_input.dart'; -export 'src/message_input/message_input_controller.dart'; -export 'src/message_input/message_text_field_controller.dart'; export 'src/message_input/stream_attachment_picker.dart'; export 'src/message_input/stream_message_send_button.dart'; export 'src/message_input/stream_message_text_field.dart'; @@ -53,4 +51,12 @@ export 'src/user_item.dart'; export 'src/user_list_view.dart'; export 'src/user_mention_tile.dart'; export 'src/utils.dart'; + +// v4 +export 'src/v4/channel_list_view/stream_channel_list_loading_tile.dart'; +export 'src/v4/channel_list_view/stream_channel_list_tile.dart'; +export 'src/v4/channel_list_view/stream_channel_list_view.dart'; +export 'src/v4/stream_channel_avatar.dart'; +export 'src/v4/stream_channel_info_bottom_sheet.dart'; +export 'src/v4/stream_channel_name.dart'; export 'src/visible_footnote.dart'; diff --git a/packages/stream_chat_flutter/test/src/message_input/message_input_controller_test.dart b/packages/stream_chat_flutter/test/src/message_input/message_input_controller_test.dart deleted file mode 100644 index 1b4967844..000000000 --- a/packages/stream_chat_flutter/test/src/message_input/message_input_controller_test.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -void main() { - testWidgets( - 'should instantiate a new MessageInputController with empty message', - (tester) async { - final controller = MessageInputController()..text = 'test'; - - expect(controller.text, 'test'); - expect(controller.message.text, 'test'); - }, - ); -} diff --git a/packages/stream_chat_flutter/test/src/typing_indicator_test.dart b/packages/stream_chat_flutter/test/src/typing_indicator_test.dart index 8d790266d..0a98aff71 100644 --- a/packages/stream_chat_flutter/test/src/typing_indicator_test.dart +++ b/packages/stream_chat_flutter/test/src/typing_indicator_test.dart @@ -63,19 +63,23 @@ void main() { Event(type: EventType.typingStart), })); + const typingKey = Key('typing'); + await tester.pumpWidget(MaterialApp( home: StreamChat( client: client, child: StreamChannel( channel: channel, child: const Scaffold( - body: TypingIndicator(), + body: TypingIndicator( + key: typingKey, + ), ), ), ), )); - expect(find.byKey(const Key('typings')), findsOneWidget); + expect(find.byKey(typingKey), findsOneWidget); }, ); } diff --git a/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart b/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart index fa19a90f2..9e7b05d14 100644 --- a/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart +++ b/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart @@ -16,6 +16,10 @@ import 'package:stream_chat_flutter_core/src/stream_controller_extension.dart'; /// using Flutter's [BuildContext]. /// /// API docs: https://getstream.io/chat/docs/flutter-dart/query_channels/ +@Deprecated( + "'ChannelsBloc' is deprecated and shouldn't be used. " + "Please use 'StreamChannelListController' instead.", +) class ChannelsBloc extends StatefulWidget { /// Creates a new [ChannelsBloc]. The parameter [child] must be supplied and /// not null. diff --git a/packages/stream_chat_flutter/lib/src/message_input/message_input_controller.dart b/packages/stream_chat_flutter_core/lib/src/message_input_controller.dart similarity index 98% rename from packages/stream_chat_flutter/lib/src/message_input/message_input_controller.dart rename to packages/stream_chat_flutter_core/lib/src/message_input_controller.dart index 185c875c1..65c6d8d8d 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/message_input_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/message_input_controller.dart @@ -2,7 +2,9 @@ import 'dart:convert'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_chat/stream_chat.dart'; + +import 'package:stream_chat_flutter_core/src/message_text_field_controller.dart'; /// A value listenable builder related to a [Message]. /// diff --git a/packages/stream_chat_flutter/lib/src/message_input/message_text_field_controller.dart b/packages/stream_chat_flutter_core/lib/src/message_text_field_controller.dart similarity index 82% rename from packages/stream_chat_flutter/lib/src/message_input/message_text_field_controller.dart rename to packages/stream_chat_flutter_core/lib/src/message_text_field_controller.dart index 4a6c3708f..0f9f75c65 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/message_text_field_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/message_text_field_controller.dart @@ -1,6 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/message_input/tld.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// A function that takes a [BuildContext] and returns a [TextStyle]. typedef TextStyleBuilder = TextStyle? Function( @@ -33,17 +31,8 @@ class MessageTextFieldController extends TextEditingController { TextStyle? style, required bool withComposing, }) { - final pattern = textPatternStyle ?? - { - RegExp(r'(?:(?:https?|ftp):\/\/)?[\w/\-?=%.]+\.[\w/\-?=%.]+'): - (context, text) { - if (!text.split('.').last.isValidTLD()) return null; - return TextStyle( - color: MessageInputTheme.of(context).linkHighlightColor, - ); - }, - }; - if (pattern.isEmpty) { + final pattern = textPatternStyle; + if (pattern == null || pattern.isEmpty) { return super.buildTextSpan( context: context, style: style, diff --git a/packages/stream_chat_flutter_core/lib/src/paged_value_notifier.dart b/packages/stream_chat_flutter_core/lib/src/paged_value_notifier.dart new file mode 100644 index 000000000..9525fea40 --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/paged_value_notifier.dart @@ -0,0 +1,136 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:stream_chat/stream_chat.dart' show StreamChatError; + +part 'paged_value_notifier.freezed.dart'; + +/// Default initial page size multiplier. +const defaultInitialPagedLimitMultiplier = 3; + +/// Value listenable for paged data. +typedef PagedValueListenableBuilder + = ValueListenableBuilder>; + +/// A [PagedValueNotifier] that uses a [PagedListenable] to load data. +/// +/// This class is useful when you need to load data from a server +/// using a [PagedListenable] and want to keep the UI-driven refresh +/// signals in the [PagedListenable]. +/// +/// [PagedValueNotifier] is a [ValueNotifier] that emits a [PagedValue] +/// whenever the data is loaded or an error occurs. +abstract class PagedValueNotifier + extends ValueNotifier> { + /// Creates a [PagedValueNotifier] + PagedValueNotifier(this._initialValue) : super(_initialValue); + + /// Stores initialValue in case we need to call [refresh]. + final PagedValue _initialValue; + + /// Returns the currently loaded items + List get currentItems => value.asSuccess.items; + + /// Appends [newItems] to the previously loaded ones and replaces + /// the next page's key. + void appendPage({ + required List newItems, + required Key nextPageKey, + }) { + final updatedItems = currentItems + newItems; + value = PagedValue(items: updatedItems, nextPageKey: nextPageKey); + } + + /// Appends [newItems] to the previously loaded ones and sets the next page + /// key to `null`. + void appendLastPage(List newItems) { + final updatedItems = currentItems + newItems; + value = PagedValue(items: updatedItems); + } + + /// Retry any failed load requests. + /// + /// Unlike [refresh], this does not resets the whole [value], + /// it only retries the last failed load request. + Future retry() { + final lastValue = value.asSuccess; + assert(lastValue.hasError, ''); + + final nextPageKey = lastValue.nextPageKey; + // resetting the error + value = lastValue.copyWith(error: null); + // ignore: null_check_on_nullable_type_parameter + return loadMore(nextPageKey!); + } + + /// Refresh the data presented by this [PagedValueNotifier]. + /// + /// Resets the [value] to the initial value in case [resetValue] is true. + /// + /// Note: This API is intended for UI-driven refresh signals, + /// such as swipe-to-refresh. + Future refresh({bool resetValue = true}) { + if (resetValue) value = _initialValue; + return doInitialLoad(); + } + + /// Load initial data from the server. + Future doInitialLoad(); + + /// Load more data from the server using [nextPageKey]. + Future loadMore(Key nextPageKey); +} + +/// Paged value that can be used with [PagedValueNotifier]. +@freezed +abstract class PagedValue with _$PagedValue { + /// Represents the success state of the [PagedValue] + // @Assert( + // 'nextPageKey != null', + // 'Cannot set an error if all the pages are already fetched', + // ) + const factory PagedValue({ + /// List with all items loaded so far. + required List items, + + /// The key for the next page to be fetched. + Key? nextPageKey, + + /// The current error, if any. + StreamChatError? error, + }) = Success; + + const PagedValue._(); + + /// Represents the loading state of the [PagedValue]. + const factory PagedValue.loading() = Loading; + + /// Represents the error state of the [PagedValue]. + const factory PagedValue.error(StreamChatError error) = Error; + + /// Returns `true` if the [PagedValue] is [Success]. + bool get isSuccess => this is Success; + + /// Returns the [PagedValue] as [Success]. + Success get asSuccess { + assert( + isSuccess, + 'Cannot get asSuccess if the PagedValue is not in the Success state', + ); + return this as Success; + } + + /// Returns `true` if the [PagedValue] is [Success] + /// and has more items to load. + bool get hasNextPage => asSuccess.nextPageKey != null; + + /// Returns `true` if the [PagedValue] is [Success] and has an error. + bool get hasError => asSuccess.error != null; + + /// + int get itemCount { + final count = asSuccess.items.length; + if (hasNextPage || hasError) return count + 1; + return count; + } +} diff --git a/packages/stream_chat_flutter_core/lib/src/paged_value_notifier.freezed.dart b/packages/stream_chat_flutter_core/lib/src/paged_value_notifier.freezed.dart new file mode 100644 index 000000000..a7ea0ea3c --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/paged_value_notifier.freezed.dart @@ -0,0 +1,589 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target + +part of 'paged_value_notifier.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more informations: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + +/// @nodoc +class _$PagedValueTearOff { + const _$PagedValueTearOff(); + + Success call( + {required List items, Key? nextPageKey, StreamChatError? error}) { + return Success( + items: items, + nextPageKey: nextPageKey, + error: error, + ); + } + + Loading loading() { + return Loading(); + } + + Error error(StreamChatError error) { + return Error( + error, + ); + } +} + +/// @nodoc +const $PagedValue = _$PagedValueTearOff(); + +/// @nodoc +mixin _$PagedValue { + @optionalTypeArgs + TResult when( + TResult Function( + List items, Key? nextPageKey, StreamChatError? error) + $default, { + required TResult Function() loading, + required TResult Function(StreamChatError error) error, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull( + TResult Function( + List items, Key? nextPageKey, StreamChatError? error)? + $default, { + TResult Function()? loading, + TResult Function(StreamChatError error)? error, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen( + TResult Function( + List items, Key? nextPageKey, StreamChatError? error)? + $default, { + TResult Function()? loading, + TResult Function(StreamChatError error)? error, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult map( + TResult Function(Success value) $default, { + required TResult Function(Loading value) loading, + required TResult Function(Error value) error, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull( + TResult Function(Success value)? $default, { + TResult Function(Loading value)? loading, + TResult Function(Error value)? error, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap( + TResult Function(Success value)? $default, { + TResult Function(Loading value)? loading, + TResult Function(Error value)? error, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $PagedValueCopyWith { + factory $PagedValueCopyWith(PagedValue value, + $Res Function(PagedValue) then) = + _$PagedValueCopyWithImpl; +} + +/// @nodoc +class _$PagedValueCopyWithImpl + implements $PagedValueCopyWith { + _$PagedValueCopyWithImpl(this._value, this._then); + + final PagedValue _value; + // ignore: unused_field + final $Res Function(PagedValue) _then; +} + +/// @nodoc +abstract class $SuccessCopyWith { + factory $SuccessCopyWith( + Success value, $Res Function(Success) then) = + _$SuccessCopyWithImpl; + $Res call({List items, Key? nextPageKey, StreamChatError? error}); +} + +/// @nodoc +class _$SuccessCopyWithImpl + extends _$PagedValueCopyWithImpl + implements $SuccessCopyWith { + _$SuccessCopyWithImpl( + Success _value, $Res Function(Success) _then) + : super(_value, (v) => _then(v as Success)); + + @override + Success get _value => super._value as Success; + + @override + $Res call({ + Object? items = freezed, + Object? nextPageKey = freezed, + Object? error = freezed, + }) { + return _then(Success( + items: items == freezed + ? _value.items + : items // ignore: cast_nullable_to_non_nullable + as List, + nextPageKey: nextPageKey == freezed + ? _value.nextPageKey + : nextPageKey // ignore: cast_nullable_to_non_nullable + as Key?, + error: error == freezed + ? _value.error + : error // ignore: cast_nullable_to_non_nullable + as StreamChatError?, + )); + } +} + +/// @nodoc + +class _$Success extends Success + with DiagnosticableTreeMixin { + const _$Success({required this.items, this.nextPageKey, this.error}) + : super._(); + + @override + + /// List with all items loaded so far. + final List items; + @override + + /// The key for the next page to be fetched. + final Key? nextPageKey; + @override + + /// The current error, if any. + final StreamChatError? error; + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return 'PagedValue<$Key, $Value>(items: $items, nextPageKey: $nextPageKey, error: $error)'; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('type', 'PagedValue<$Key, $Value>')) + ..add(DiagnosticsProperty('items', items)) + ..add(DiagnosticsProperty('nextPageKey', nextPageKey)) + ..add(DiagnosticsProperty('error', error)); + } + + @override + bool operator ==(dynamic other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is Success && + const DeepCollectionEquality().equals(other.items, items) && + const DeepCollectionEquality() + .equals(other.nextPageKey, nextPageKey) && + const DeepCollectionEquality().equals(other.error, error)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(items), + const DeepCollectionEquality().hash(nextPageKey), + const DeepCollectionEquality().hash(error)); + + @JsonKey(ignore: true) + @override + $SuccessCopyWith> get copyWith => + _$SuccessCopyWithImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when( + TResult Function( + List items, Key? nextPageKey, StreamChatError? error) + $default, { + required TResult Function() loading, + required TResult Function(StreamChatError error) error, + }) { + return $default(items, nextPageKey, this.error); + } + + @override + @optionalTypeArgs + TResult? whenOrNull( + TResult Function( + List items, Key? nextPageKey, StreamChatError? error)? + $default, { + TResult Function()? loading, + TResult Function(StreamChatError error)? error, + }) { + return $default?.call(items, nextPageKey, this.error); + } + + @override + @optionalTypeArgs + TResult maybeWhen( + TResult Function( + List items, Key? nextPageKey, StreamChatError? error)? + $default, { + TResult Function()? loading, + TResult Function(StreamChatError error)? error, + required TResult orElse(), + }) { + if ($default != null) { + return $default(items, nextPageKey, this.error); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map( + TResult Function(Success value) $default, { + required TResult Function(Loading value) loading, + required TResult Function(Error value) error, + }) { + return $default(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull( + TResult Function(Success value)? $default, { + TResult Function(Loading value)? loading, + TResult Function(Error value)? error, + }) { + return $default?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap( + TResult Function(Success value)? $default, { + TResult Function(Loading value)? loading, + TResult Function(Error value)? error, + required TResult orElse(), + }) { + if ($default != null) { + return $default(this); + } + return orElse(); + } +} + +abstract class Success extends PagedValue { + const factory Success( + {required List items, + Key? nextPageKey, + StreamChatError? error}) = _$Success; + const Success._() : super._(); + + /// List with all items loaded so far. + List get items; + + /// The key for the next page to be fetched. + Key? get nextPageKey; + + /// The current error, if any. + StreamChatError? get error; + @JsonKey(ignore: true) + $SuccessCopyWith> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $LoadingCopyWith { + factory $LoadingCopyWith( + Loading value, $Res Function(Loading) then) = + _$LoadingCopyWithImpl; +} + +/// @nodoc +class _$LoadingCopyWithImpl + extends _$PagedValueCopyWithImpl + implements $LoadingCopyWith { + _$LoadingCopyWithImpl( + Loading _value, $Res Function(Loading) _then) + : super(_value, (v) => _then(v as Loading)); + + @override + Loading get _value => super._value as Loading; +} + +/// @nodoc + +class _$Loading extends Loading + with DiagnosticableTreeMixin { + const _$Loading() : super._(); + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return 'PagedValue<$Key, $Value>.loading()'; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('type', 'PagedValue<$Key, $Value>.loading')); + } + + @override + bool operator ==(dynamic other) { + return identical(this, other) || + (other.runtimeType == runtimeType && other is Loading); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + @optionalTypeArgs + TResult when( + TResult Function( + List items, Key? nextPageKey, StreamChatError? error) + $default, { + required TResult Function() loading, + required TResult Function(StreamChatError error) error, + }) { + return loading(); + } + + @override + @optionalTypeArgs + TResult? whenOrNull( + TResult Function( + List items, Key? nextPageKey, StreamChatError? error)? + $default, { + TResult Function()? loading, + TResult Function(StreamChatError error)? error, + }) { + return loading?.call(); + } + + @override + @optionalTypeArgs + TResult maybeWhen( + TResult Function( + List items, Key? nextPageKey, StreamChatError? error)? + $default, { + TResult Function()? loading, + TResult Function(StreamChatError error)? error, + required TResult orElse(), + }) { + if (loading != null) { + return loading(); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map( + TResult Function(Success value) $default, { + required TResult Function(Loading value) loading, + required TResult Function(Error value) error, + }) { + return loading(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull( + TResult Function(Success value)? $default, { + TResult Function(Loading value)? loading, + TResult Function(Error value)? error, + }) { + return loading?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap( + TResult Function(Success value)? $default, { + TResult Function(Loading value)? loading, + TResult Function(Error value)? error, + required TResult orElse(), + }) { + if (loading != null) { + return loading(this); + } + return orElse(); + } +} + +abstract class Loading extends PagedValue { + const factory Loading() = _$Loading; + const Loading._() : super._(); +} + +/// @nodoc +abstract class $ErrorCopyWith { + factory $ErrorCopyWith( + Error value, $Res Function(Error) then) = + _$ErrorCopyWithImpl; + $Res call({StreamChatError error}); +} + +/// @nodoc +class _$ErrorCopyWithImpl + extends _$PagedValueCopyWithImpl + implements $ErrorCopyWith { + _$ErrorCopyWithImpl( + Error _value, $Res Function(Error) _then) + : super(_value, (v) => _then(v as Error)); + + @override + Error get _value => super._value as Error; + + @override + $Res call({ + Object? error = freezed, + }) { + return _then(Error( + error == freezed + ? _value.error + : error // ignore: cast_nullable_to_non_nullable + as StreamChatError, + )); + } +} + +/// @nodoc + +class _$Error extends Error + with DiagnosticableTreeMixin { + const _$Error(this.error) : super._(); + + @override + final StreamChatError error; + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return 'PagedValue<$Key, $Value>.error(error: $error)'; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('type', 'PagedValue<$Key, $Value>.error')) + ..add(DiagnosticsProperty('error', error)); + } + + @override + bool operator ==(dynamic other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is Error && + const DeepCollectionEquality().equals(other.error, error)); + } + + @override + int get hashCode => + Object.hash(runtimeType, const DeepCollectionEquality().hash(error)); + + @JsonKey(ignore: true) + @override + $ErrorCopyWith> get copyWith => + _$ErrorCopyWithImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when( + TResult Function( + List items, Key? nextPageKey, StreamChatError? error) + $default, { + required TResult Function() loading, + required TResult Function(StreamChatError error) error, + }) { + return error(this.error); + } + + @override + @optionalTypeArgs + TResult? whenOrNull( + TResult Function( + List items, Key? nextPageKey, StreamChatError? error)? + $default, { + TResult Function()? loading, + TResult Function(StreamChatError error)? error, + }) { + return error?.call(this.error); + } + + @override + @optionalTypeArgs + TResult maybeWhen( + TResult Function( + List items, Key? nextPageKey, StreamChatError? error)? + $default, { + TResult Function()? loading, + TResult Function(StreamChatError error)? error, + required TResult orElse(), + }) { + if (error != null) { + return error(this.error); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map( + TResult Function(Success value) $default, { + required TResult Function(Loading value) loading, + required TResult Function(Error value) error, + }) { + return error(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull( + TResult Function(Success value)? $default, { + TResult Function(Loading value)? loading, + TResult Function(Error value)? error, + }) { + return error?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap( + TResult Function(Success value)? $default, { + TResult Function(Loading value)? loading, + TResult Function(Error value)? error, + required TResult orElse(), + }) { + if (error != null) { + return error(this); + } + return orElse(); + } +} + +abstract class Error extends PagedValue { + const factory Error(StreamChatError error) = _$Error; + const Error._() : super._(); + + StreamChatError get error; + @JsonKey(ignore: true) + $ErrorCopyWith> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart new file mode 100644 index 000000000..7d3a281f5 --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart @@ -0,0 +1,282 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:stream_chat/stream_chat.dart' hide Success; +import 'package:stream_chat_flutter_core/src/paged_value_notifier.dart'; + +import 'package:stream_chat_flutter_core/src/stream_channel_list_event_handler.dart'; + +/// The default channel page limit to load. +const defaultChannelPagedLimit = 10; + +const _kDefaultBackendPaginationLimit = 30; + +/// A controller for a Channel list. +/// +/// This class lets you perform tasks such as: +/// * Load initial data. +/// * Use channel events handlers. +/// * Load more data using [loadMore]. +/// * Replace the previously loaded channels. +/// * Return/Create a new channel and start watching it. +/// * Pause and Resume all subscriptions added to this composite. +class StreamChannelListController extends PagedValueNotifier { + /// Creates a Stream channel list controller. + /// + /// * `client` is the Stream chat client to use for the channels list. + /// + /// * `channelEventHandlers` is the channel events to use for the channels + /// list. This class can be mixed in or extended to create custom overrides. + /// See [StreamChannelListEventHandler] for advice. + /// + /// * `filter` is the query filters to use. + /// + /// * `sort` is the sorting used for the channels matching the filters. + /// + /// * `presence` sets whether you'll receive user presence updates via the + /// websocket events. + /// + /// * `limit` is the limit to apply to the channel list. + /// + /// * `messageLimit` is the number of messages to fetch in each channel. + /// + /// * `memberLimit` is the number of members to fetch in each channel. + StreamChannelListController({ + required this.client, + StreamChannelListEventHandler? eventHandler, + this.filter, + this.sort, + this.presence = true, + this.limit = defaultChannelPagedLimit, + this.messageLimit, + this.memberLimit, + }) : _eventHandler = eventHandler ?? StreamChannelListEventHandler(), + super(const PagedValue.loading()); + + /// Creates a [StreamChannelListController] from the passed [value]. + StreamChannelListController.fromValue( + PagedValue value, { + required this.client, + StreamChannelListEventHandler? eventHandler, + this.filter, + this.sort, + this.presence = true, + this.limit = defaultChannelPagedLimit, + this.messageLimit, + this.memberLimit, + }) : _eventHandler = eventHandler ?? StreamChannelListEventHandler(), + super(value); + + /// The client to use for the channels list. + final StreamChatClient client; + + /// The channel event handlers to use for the channels list. + final StreamChannelListEventHandler _eventHandler; + + /// The query filters to use. + /// + /// You can query on any of the custom fields you've defined on the [Channel]. + /// + /// You can also filter other built-in channel fields. + final Filter? filter; + + /// The sorting used for the channels matching the filters. + /// + /// Sorting is based on field and direction, multiple sorting options + /// can be provided. + /// + /// You can sort based on last_updated, last_message_at, updated_at, + /// created_at or member_count. + /// + /// Direction can be ascending or descending. + final List>? sort; + + /// If true you’ll receive user presence updates via the websocket events + final bool presence; + + /// The limit to apply to the channel list. The default is set to + /// [defaultChannelPagedLimit]. + final int limit; + + /// Number of messages to fetch in each channel. + final int? messageLimit; + + /// Number of members to fetch in each channel. + final int? memberLimit; + + @override + Future doInitialLoad() async { + final limit = min( + this.limit * defaultInitialPagedLimitMultiplier, + _kDefaultBackendPaginationLimit, + ); + try { + await for (final channels in client.queryChannels( + filter: filter, + sort: sort, + memberLimit: memberLimit, + messageLimit: messageLimit, + presence: presence, + paginationParams: PaginationParams(limit: limit), + )) { + final nextKey = channels.length < limit ? null : channels.length; + value = PagedValue( + items: channels, + nextPageKey: nextKey, + ); + } + // start listening to events + _subscribeToChannelListEvents(); + } on StreamChatError catch (error) { + value = PagedValue.error(error); + } catch (error) { + final chatError = StreamChatError(error.toString()); + value = PagedValue.error(chatError); + } + } + + @override + Future loadMore(int nextPageKey) async { + final previousValue = value.asSuccess; + + try { + await for (final channels in client.queryChannels( + filter: filter, + sort: sort, + memberLimit: memberLimit, + messageLimit: messageLimit, + presence: presence, + paginationParams: PaginationParams(limit: limit, offset: nextPageKey), + )) { + final previousItems = previousValue.items; + final newItems = previousItems + channels; + final nextKey = channels.length < limit ? null : newItems.length; + value = PagedValue( + items: newItems, + nextPageKey: nextKey, + ); + } + } on StreamChatError catch (error) { + value = previousValue.copyWith(error: error); + } catch (error) { + final chatError = StreamChatError(error.toString()); + value = previousValue.copyWith(error: chatError); + } + } + + /// Replaces the previously loaded channels with [channels] and updates + /// the nextPageKey. + set channels(List channels) { + value = PagedValue( + items: channels, + nextPageKey: channels.length, + ); + } + + /// Returns/Creates a new Channel and starts watching it. + Future getChannel({ + required String id, + required String type, + }) async { + final channel = client.channel(type, id: id); + await channel.watch(); + return channel; + } + + /// Leaves the [channel] and updates the list. + Future leaveChannel(Channel channel) async { + final user = client.state.currentUser; + assert(user != null, 'You must be logged in to leave a channel.'); + await channel.removeMembers([user!.id]); + } + + /// Deletes the [channel] and updates the list. + Future deleteChannel(Channel channel) async { + await channel.delete(); + } + + /// Mutes the [channel] and updates the list. + Future muteChannel(Channel channel) async { + await channel.mute(); + } + + /// Un-mutes the [channel] and updates the list. + Future unmuteChannel(Channel channel) async { + await channel.unmute(); + } + + /// Event listener, which can be set in order to listen + /// [client] web-socket events. + /// + /// Return `true` if the event is handled. Return `false` to + /// allow the event to be handled internally. + bool Function(Event event)? eventListener; + + StreamSubscription? _channelEventSubscription; + + // Subscribes to the channel list events. + void _subscribeToChannelListEvents() { + if (_channelEventSubscription != null) { + _unsubscribeFromChannelListEvents(); + } + + _channelEventSubscription = client.on().listen((event) { + // Returns early if the event is already handled by the listener. + if (eventListener?.call(event) ?? false) return; + + final eventType = event.type; + if (eventType == EventType.channelDeleted) { + _eventHandler.onChannelDeleted(event, this); + } else if (eventType == EventType.channelHidden) { + _eventHandler.onChannelHidden(event, this); + } else if (eventType == EventType.channelTruncated) { + _eventHandler.onChannelTruncated(event, this); + } else if (eventType == EventType.channelUpdated) { + _eventHandler.onChannelUpdated(event, this); + } else if (eventType == EventType.channelVisible) { + _eventHandler.onChannelVisible(event, this); + } else if (eventType == EventType.connectionRecovered) { + _eventHandler.onConnectionRecovered(event, this); + } else if (eventType == EventType.connectionChanged) { + if (event.online != null) { + _eventHandler.onConnectionRecovered(event, this); + } + } else if (eventType == EventType.messageNew) { + _eventHandler.onMessageNew(event, this); + } else if (eventType == EventType.notificationAddedToChannel) { + _eventHandler.onNotificationAddedToChannel(event, this); + } else if (eventType == EventType.notificationMessageNew) { + _eventHandler.onNotificationMessageNew(event, this); + } else if (eventType == EventType.notificationRemovedFromChannel) { + _eventHandler.onNotificationRemovedFromChannel(event, this); + } else if (eventType == 'user.presence.changed' || + eventType == EventType.userUpdated) { + _eventHandler.onUserPresenceChanged(event, this); + } + }); + } + + // Unsubscribes from all channel list events. + void _unsubscribeFromChannelListEvents() { + if (_channelEventSubscription != null) { + _channelEventSubscription!.cancel(); + _channelEventSubscription = null; + } + } + + /// Pauses all subscriptions added to this composite. + void pauseEventsSubscription([Future? resumeSignal]) { + _channelEventSubscription?.pause(resumeSignal); + } + + /// Resumes all subscriptions added to this composite. + void resumeEventsSubscription() { + _channelEventSubscription?.resume(); + } + + @override + void dispose() { + _unsubscribeFromChannelListEvents(); + super.dispose(); + } +} diff --git a/packages/stream_chat_flutter_core/lib/src/stream_channel_list_event_handler.dart b/packages/stream_chat_flutter_core/lib/src/stream_channel_list_event_handler.dart new file mode 100644 index 000000000..8d548c67b --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/stream_channel_list_event_handler.dart @@ -0,0 +1,213 @@ +import 'package:stream_chat/stream_chat.dart' show ChannelState, Event; +import 'package:stream_chat_flutter_core/src/stream_channel_list_controller.dart'; + +/// Contains handlers that are called from [StreamChannelListController] for +/// certain [Event]s. +/// +/// This class can be mixed in or extended to create custom overrides. +class StreamChannelListEventHandler { + /// Function which gets called for the event + /// [EventType.channelDeleted]. + /// + /// This event is fired when a channel is deleted. + /// + /// By default, this removes the channel from the list of channels. + void onChannelDeleted(Event event, StreamChannelListController controller) { + final channels = [...controller.currentItems]; + + final updatedChannels = channels + ..removeWhere( + (it) => it.cid == (event.cid ?? event.channel?.cid), + ); + + controller.channels = updatedChannels; + } + + /// Function which gets called for the event + /// [EventType.channelHidden]. + /// + /// This event is fired when a channel is hidden. + /// + /// By default, this removes the channel from the list of channels. + void onChannelHidden(Event event, StreamChannelListController controller) { + onChannelDeleted(event, controller); + } + + /// Function which gets called for the event + /// [EventType.channelTruncated]. + /// + /// This event is fired when a channel is truncated. + /// + /// By default, this refreshes the whole channel list. + void onChannelTruncated(Event event, StreamChannelListController controller) { + controller.refresh(); + } + + /// Function which gets called for the event + /// [EventType.channelUpdated]. + /// + /// This event is fired when a channel is updated. + /// + /// By default, this updates the channel received in the event. + void onChannelUpdated(Event event, StreamChannelListController controller) { + final eventChannel = event.channel; + if (eventChannel == null) return; + + final channels = [...controller.currentItems]; + final channelIndex = channels.indexWhere( + (it) => it.cid == (event.cid ?? eventChannel.cid), + ); + + if (channelIndex >= 0) { + final channelState = ChannelState(channel: eventChannel); + channels[channelIndex].state?.updateChannelState(channelState); + } + + controller.channels = channels; + } + + /// Function which gets called for the event + /// [EventType.channelVisible]. + /// + /// This event is fired when a channel is made visible. + /// + /// By default, this adds the channel to the list of channels. + void onChannelVisible( + Event event, + StreamChannelListController controller, + ) async { + final channelId = event.channelId; + final channelType = event.channelType; + + if (channelId == null || channelType == null) return; + + final channel = await controller.getChannel( + id: channelId, + type: channelType, + ); + + final currentChannels = [...controller.currentItems]; + + final updatedChannels = [ + channel, + ...currentChannels..removeWhere((it) => it.cid == channel.cid), + ]; + + controller.channels = updatedChannels; + } + + /// Function which gets called for the event + /// [EventType.connectionRecovered]. + /// + /// This event is fired when the client web-socket connection recovers. + /// + /// By default, this refreshes the whole channel list. + void onConnectionRecovered( + Event event, + StreamChannelListController controller, + ) { + controller.refresh(); + } + + /// Function which gets called for the event [EventType.messageNew]. + /// + /// This event is fired when a new message is created in one of the channels + /// we are currently watching. + /// + /// By default, this moves the channel to the top of the list. + void onMessageNew(Event event, StreamChannelListController controller) { + final channelCid = event.cid; + if (channelCid == null) return; + + final channels = [...controller.currentItems]; + + final channelIndex = channels.indexWhere((it) => it.cid == channelCid); + if (channelIndex <= 0) return; + + final channel = channels.removeAt(channelIndex); + channels.insert(0, channel); + + controller.channels = [...channels]; + } + + /// Function which gets called for the event + /// [EventType.notificationAddedToChannel]. + /// + /// This event is fired when a channel is added which we are not watching. + /// + /// By default, this adds the channel and moves it to the top of list. + void onNotificationAddedToChannel( + Event event, + StreamChannelListController controller, + ) { + onChannelVisible(event, controller); + } + + /// Function which gets called for the event + /// [EventType.notificationMessageNew]. + /// + /// This event is fired when a new message is created in a channel + /// which we are not currently watching. + /// + /// By default, this adds the channel and moves it to the top of list. + void onNotificationMessageNew( + Event event, + StreamChannelListController controller, + ) { + onChannelVisible(event, controller); + } + + /// Function which gets called for the event + /// [EventType.notificationRemovedFromChannel]. + /// + /// This event is fired when a user is removed from a channel which we are + /// not currently watching. + /// + /// By default, this removes the event channel from the list. + void onNotificationRemovedFromChannel( + Event event, + StreamChannelListController controller, + ) { + final channels = [...controller.currentItems]; + final updatedChannels = + channels.where((it) => it.cid != event.channel?.cid); + final listChanged = channels.length != updatedChannels.length; + + if (!listChanged) return; + + controller.channels = [...updatedChannels]; + } + + /// Function which gets called for the event + /// 'user.presence.changed' and [EventType.userUpdated]. + /// + /// This event is fired when a user's presence changes or gets updated. + /// + /// By default, this updates the channel member with the event user. + void onUserPresenceChanged( + Event event, + StreamChannelListController controller, + ) { + final user = event.user; + if (user == null) return; + + final channels = [...controller.currentItems]; + + final updatedChannels = channels.map((channel) { + final members = [...channel.state!.members]; + final memberIndex = members.indexWhere( + (it) => user.id == (it.userId ?? it.user?.id), + ); + + if (memberIndex < 0) return channel; + + members[memberIndex] = members[memberIndex].copyWith(user: user); + final updatedState = ChannelState(members: [...members]); + channel.state!.updateChannelState(updatedState); + + return channel; + }); + + controller.channels = [...updatedChannels]; + } +} diff --git a/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart b/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart index 8bd41c768..78d82d8bf 100644 --- a/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart +++ b/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart @@ -7,10 +7,15 @@ export 'src/better_stream_builder.dart'; export 'src/channel_list_core.dart' hide ChannelListCoreState; export 'src/channels_bloc.dart'; export 'src/lazy_load_scroll_view.dart'; +export 'src/message_input_controller.dart'; export 'src/message_list_core.dart' hide MessageListCoreState; export 'src/message_search_bloc.dart'; export 'src/message_search_list_core.dart' hide MessageSearchListCoreState; +export 'src/message_text_field_controller.dart'; +export 'src/paged_value_notifier.dart' show PagedValueListenableBuilder; export 'src/stream_channel.dart'; +export 'src/stream_channel_list_controller.dart'; +export 'src/stream_channel_list_event_handler.dart'; export 'src/stream_chat_core.dart'; export 'src/typedef.dart'; export 'src/user_list_core.dart' hide UserListCoreState; diff --git a/packages/stream_chat_flutter_core/pubspec.yaml b/packages/stream_chat_flutter_core/pubspec.yaml index 5b5987470..2f6f49f21 100644 --- a/packages/stream_chat_flutter_core/pubspec.yaml +++ b/packages/stream_chat_flutter_core/pubspec.yaml @@ -14,14 +14,17 @@ dependencies: connectivity_plus: ^2.1.0 flutter: sdk: flutter + freezed_annotation: ^1.0.0 meta: ^1.3.0 rxdart: ^0.27.0 stream_chat: ^3.5.0 dev_dependencies: + build_runner: ^2.0.1 dart_code_metrics: ^4.4.0 fake_async: ^1.2.0 flutter_test: sdk: flutter + freezed: ^1.0.0 mocktail: ^0.2.0 diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations.dart index 0c7ce68e2..6ea2eda48 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations.dart @@ -75,6 +75,7 @@ GlobalStreamChatLocalizations? getStreamChatTranslation(Locale locale) { case 'pt': return const StreamChatLocalizationsPt(); } + return null; } /// Implementation of localized strings for the stream chat widgets diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart index 44c02081c..4c1d2a58e 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart @@ -371,4 +371,15 @@ Não é possível adicionar mais de $limit arquivos de uma vez @override String get slowModeOnLabel => 'Modo lento ativado'; + + @override + String get linkDisabledDetails => + 'O envio de links não é permitido nesta conversa.'; + + @override + String get linkDisabledError => 'Os links estão desativados'; + + @override + String get sendMessagePermissionError => + 'Você não tem permissão para enviar mensagens'; }