Merge pull request #775 from GetStream/feat/stream-channel-listview

feat(ui): stream channel listview
This commit is contained in:
Salvatore Giordano
2022-03-08 11:21:32 +01:00
committed by GitHub
40 changed files with 3254 additions and 250 deletions
@@ -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<Read> get read => _channelState.read;
@@ -84,7 +84,7 @@ class _SplitViewState extends State<SplitView> {
);
}
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<ChannelListPage> createState() => _ChannelListPageState();
}
class _ChannelListPageState extends State<ChannelListPage> {
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,
),
);
}
@@ -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 <Widget>[
Expanded(
child: MessageListView(),
),
MessageInput(),
],
),
);
}
Widget build(BuildContext context) => Scaffold(
appBar: const ChannelHeader(),
body: Column(
children: const <Widget>[
Expanded(
child: MessageListView(),
),
MessageInput(),
],
),
);
}
@@ -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<ChannelListPage> createState() => _ChannelListPageState();
}
class _ChannelListPageState extends State<ChannelListPage> {
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 <Widget>[
Expanded(
child: MessageListView(),
),
MessageInput(),
],
),
);
}
Widget build(BuildContext context) => Scaffold(
appBar: const ChannelHeader(),
body: Column(
children: const <Widget>[
Expanded(
child: MessageListView(),
),
MessageInput(),
],
),
);
}
@@ -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<ChannelListPage> createState() => _ChannelListPageState();
}
Widget _channelPreviewBuilder(BuildContext context, Channel channel) {
class _ChannelListPageState extends State<ChannelListPage> {
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,
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
@@ -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<ChannelListPage> createState() => _ChannelListPageState();
}
class _ChannelListPageState extends State<ChannelListPage> {
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: <Widget>[
Expanded(
child: MessageListView(
threadBuilder: (_, parentMessage) => ThreadPage(
parent: parentMessage,
Widget build(BuildContext context) => Scaffold(
appBar: const ChannelHeader(),
body: Column(
children: <Widget>[
Expanded(
child: MessageListView(
threadBuilder: (_, parentMessage) => ThreadPage(
parent: parentMessage,
),
),
),
),
const MessageInput(),
],
),
);
}
const MessageInput(),
],
),
);
}
class ThreadPage extends StatelessWidget {
@@ -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<ChannelListPage> createState() => _ChannelListPageState();
}
class _ChannelListPageState extends State<ChannelListPage> {
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 {
@@ -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<ChannelListPage> createState() => _ChannelListPageState();
}
class _ChannelListPageState extends State<ChannelListPage> {
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: <Widget>[
Expanded(
child: MessageListView(
threadBuilder: (_, parentMessage) => ThreadPage(
parent: parentMessage,
Widget build(BuildContext context) => Scaffold(
appBar: const ChannelHeader(),
body: Column(
children: <Widget>[
Expanded(
child: MessageListView(
threadBuilder: (_, parentMessage) => ThreadPage(
parent: parentMessage,
),
),
),
),
const MessageInput(),
],
),
);
}
const MessageInput(),
],
),
);
}
class ThreadPage extends StatelessWidget {
@@ -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({
@@ -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<ChannelBottomSheet> {
bool _showActions = true;
@@ -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,
),
);
}
@@ -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({
@@ -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({
@@ -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<Member> 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');
@@ -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<SimpleSafeArea> {
@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,
);
}
@@ -526,6 +526,7 @@ class _MessageListViewState extends State<MessageListView> {
return ((index + 2) * 2) - 1;
}
}
return null;
},
// Item Count -> 8 (1 parent, 2 header+footer, 2 top+bottom, 3 messages)
@@ -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
@@ -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),
);
@@ -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,
@@ -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,
@@ -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,
),
);
}
}
@@ -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<bool>(
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<List<Member>>(
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<List<Message>>(
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<DateTime>(
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: <Widget>[
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<List<Message>>(
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,
);
},
);
}
@@ -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<StreamChannelListView> {
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<int, Channel>(
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: <Widget>[
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,
),
],
);
}
}
@@ -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<String>(
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<User>(
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<Member>(
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,
);
},
);
}
}
@@ -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.
/// * <https://material.io/design/components/sheets-bottom.html#modal-bottom-sheet>
Future<T?> showChannelInfoModalBottomSheet<T>({
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<T>(
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].
/// * <https://material.io/design/components/sheets-bottom.html#standard-bottom-sheet>
PersistentBottomSheetController<T> showChannelInfoBottomSheet<T>({
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<T>(
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,
),
);
@@ -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<String>(
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<Member> 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 = <Member>[];
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,
);
},
);
}
@@ -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';
@@ -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');
},
);
}
@@ -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);
},
);
}
@@ -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.
@@ -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].
///
@@ -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,
@@ -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<Key, Value>
= ValueListenableBuilder<PagedValue<Key, Value>>;
/// 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<Key, Value>
extends ValueNotifier<PagedValue<Key, Value>> {
/// Creates a [PagedValueNotifier]
PagedValueNotifier(this._initialValue) : super(_initialValue);
/// Stores initialValue in case we need to call [refresh].
final PagedValue<Key, Value> _initialValue;
/// Returns the currently loaded items
List<Value> get currentItems => value.asSuccess.items;
/// Appends [newItems] to the previously loaded ones and replaces
/// the next page's key.
void appendPage({
required List<Value> 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<Value> 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<void> 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<void> refresh({bool resetValue = true}) {
if (resetValue) value = _initialValue;
return doInitialLoad();
}
/// Load initial data from the server.
Future<void> doInitialLoad();
/// Load more data from the server using [nextPageKey].
Future<void> loadMore(Key nextPageKey);
}
/// Paged value that can be used with [PagedValueNotifier].
@freezed
abstract class PagedValue<Key, Value> with _$PagedValue<Key, Value> {
/// 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<Value> items,
/// The key for the next page to be fetched.
Key? nextPageKey,
/// The current error, if any.
StreamChatError? error,
}) = Success<Key, Value>;
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<Key, Value>;
/// Returns the [PagedValue] as [Success].
Success<Key, Value> get asSuccess {
assert(
isSuccess,
'Cannot get asSuccess if the PagedValue is not in the Success state',
);
return this as Success<Key, Value>;
}
/// 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;
}
}
@@ -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>(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<Key, Value> call<Key, Value>(
{required List<Value> items, Key? nextPageKey, StreamChatError? error}) {
return Success<Key, Value>(
items: items,
nextPageKey: nextPageKey,
error: error,
);
}
Loading<Key, Value> loading<Key, Value>() {
return Loading<Key, Value>();
}
Error<Key, Value> error<Key, Value>(StreamChatError error) {
return Error<Key, Value>(
error,
);
}
}
/// @nodoc
const $PagedValue = _$PagedValueTearOff();
/// @nodoc
mixin _$PagedValue<Key, Value> {
@optionalTypeArgs
TResult when<TResult extends Object?>(
TResult Function(
List<Value> items, Key? nextPageKey, StreamChatError? error)
$default, {
required TResult Function() loading,
required TResult Function(StreamChatError error) error,
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>(
TResult Function(
List<Value> items, Key? nextPageKey, StreamChatError? error)?
$default, {
TResult Function()? loading,
TResult Function(StreamChatError error)? error,
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>(
TResult Function(
List<Value> items, Key? nextPageKey, StreamChatError? error)?
$default, {
TResult Function()? loading,
TResult Function(StreamChatError error)? error,
required TResult orElse(),
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult map<TResult extends Object?>(
TResult Function(Success<Key, Value> value) $default, {
required TResult Function(Loading<Key, Value> value) loading,
required TResult Function(Error<Key, Value> value) error,
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>(
TResult Function(Success<Key, Value> value)? $default, {
TResult Function(Loading<Key, Value> value)? loading,
TResult Function(Error<Key, Value> value)? error,
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>(
TResult Function(Success<Key, Value> value)? $default, {
TResult Function(Loading<Key, Value> value)? loading,
TResult Function(Error<Key, Value> value)? error,
required TResult orElse(),
}) =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $PagedValueCopyWith<Key, Value, $Res> {
factory $PagedValueCopyWith(PagedValue<Key, Value> value,
$Res Function(PagedValue<Key, Value>) then) =
_$PagedValueCopyWithImpl<Key, Value, $Res>;
}
/// @nodoc
class _$PagedValueCopyWithImpl<Key, Value, $Res>
implements $PagedValueCopyWith<Key, Value, $Res> {
_$PagedValueCopyWithImpl(this._value, this._then);
final PagedValue<Key, Value> _value;
// ignore: unused_field
final $Res Function(PagedValue<Key, Value>) _then;
}
/// @nodoc
abstract class $SuccessCopyWith<Key, Value, $Res> {
factory $SuccessCopyWith(
Success<Key, Value> value, $Res Function(Success<Key, Value>) then) =
_$SuccessCopyWithImpl<Key, Value, $Res>;
$Res call({List<Value> items, Key? nextPageKey, StreamChatError? error});
}
/// @nodoc
class _$SuccessCopyWithImpl<Key, Value, $Res>
extends _$PagedValueCopyWithImpl<Key, Value, $Res>
implements $SuccessCopyWith<Key, Value, $Res> {
_$SuccessCopyWithImpl(
Success<Key, Value> _value, $Res Function(Success<Key, Value>) _then)
: super(_value, (v) => _then(v as Success<Key, Value>));
@override
Success<Key, Value> get _value => super._value as Success<Key, Value>;
@override
$Res call({
Object? items = freezed,
Object? nextPageKey = freezed,
Object? error = freezed,
}) {
return _then(Success<Key, Value>(
items: items == freezed
? _value.items
: items // ignore: cast_nullable_to_non_nullable
as List<Value>,
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<Key, Value> extends Success<Key, Value>
with DiagnosticableTreeMixin {
const _$Success({required this.items, this.nextPageKey, this.error})
: super._();
@override
/// List with all items loaded so far.
final List<Value> 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<Key, Value> &&
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<Key, Value, Success<Key, Value>> get copyWith =>
_$SuccessCopyWithImpl<Key, Value, Success<Key, Value>>(this, _$identity);
@override
@optionalTypeArgs
TResult when<TResult extends Object?>(
TResult Function(
List<Value> 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 extends Object?>(
TResult Function(
List<Value> 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 extends Object?>(
TResult Function(
List<Value> 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 extends Object?>(
TResult Function(Success<Key, Value> value) $default, {
required TResult Function(Loading<Key, Value> value) loading,
required TResult Function(Error<Key, Value> value) error,
}) {
return $default(this);
}
@override
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>(
TResult Function(Success<Key, Value> value)? $default, {
TResult Function(Loading<Key, Value> value)? loading,
TResult Function(Error<Key, Value> value)? error,
}) {
return $default?.call(this);
}
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>(
TResult Function(Success<Key, Value> value)? $default, {
TResult Function(Loading<Key, Value> value)? loading,
TResult Function(Error<Key, Value> value)? error,
required TResult orElse(),
}) {
if ($default != null) {
return $default(this);
}
return orElse();
}
}
abstract class Success<Key, Value> extends PagedValue<Key, Value> {
const factory Success(
{required List<Value> items,
Key? nextPageKey,
StreamChatError? error}) = _$Success<Key, Value>;
const Success._() : super._();
/// List with all items loaded so far.
List<Value> 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<Key, Value, Success<Key, Value>> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $LoadingCopyWith<Key, Value, $Res> {
factory $LoadingCopyWith(
Loading<Key, Value> value, $Res Function(Loading<Key, Value>) then) =
_$LoadingCopyWithImpl<Key, Value, $Res>;
}
/// @nodoc
class _$LoadingCopyWithImpl<Key, Value, $Res>
extends _$PagedValueCopyWithImpl<Key, Value, $Res>
implements $LoadingCopyWith<Key, Value, $Res> {
_$LoadingCopyWithImpl(
Loading<Key, Value> _value, $Res Function(Loading<Key, Value>) _then)
: super(_value, (v) => _then(v as Loading<Key, Value>));
@override
Loading<Key, Value> get _value => super._value as Loading<Key, Value>;
}
/// @nodoc
class _$Loading<Key, Value> extends Loading<Key, Value>
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<Key, Value>);
}
@override
int get hashCode => runtimeType.hashCode;
@override
@optionalTypeArgs
TResult when<TResult extends Object?>(
TResult Function(
List<Value> items, Key? nextPageKey, StreamChatError? error)
$default, {
required TResult Function() loading,
required TResult Function(StreamChatError error) error,
}) {
return loading();
}
@override
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>(
TResult Function(
List<Value> items, Key? nextPageKey, StreamChatError? error)?
$default, {
TResult Function()? loading,
TResult Function(StreamChatError error)? error,
}) {
return loading?.call();
}
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>(
TResult Function(
List<Value> 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 extends Object?>(
TResult Function(Success<Key, Value> value) $default, {
required TResult Function(Loading<Key, Value> value) loading,
required TResult Function(Error<Key, Value> value) error,
}) {
return loading(this);
}
@override
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>(
TResult Function(Success<Key, Value> value)? $default, {
TResult Function(Loading<Key, Value> value)? loading,
TResult Function(Error<Key, Value> value)? error,
}) {
return loading?.call(this);
}
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>(
TResult Function(Success<Key, Value> value)? $default, {
TResult Function(Loading<Key, Value> value)? loading,
TResult Function(Error<Key, Value> value)? error,
required TResult orElse(),
}) {
if (loading != null) {
return loading(this);
}
return orElse();
}
}
abstract class Loading<Key, Value> extends PagedValue<Key, Value> {
const factory Loading() = _$Loading<Key, Value>;
const Loading._() : super._();
}
/// @nodoc
abstract class $ErrorCopyWith<Key, Value, $Res> {
factory $ErrorCopyWith(
Error<Key, Value> value, $Res Function(Error<Key, Value>) then) =
_$ErrorCopyWithImpl<Key, Value, $Res>;
$Res call({StreamChatError error});
}
/// @nodoc
class _$ErrorCopyWithImpl<Key, Value, $Res>
extends _$PagedValueCopyWithImpl<Key, Value, $Res>
implements $ErrorCopyWith<Key, Value, $Res> {
_$ErrorCopyWithImpl(
Error<Key, Value> _value, $Res Function(Error<Key, Value>) _then)
: super(_value, (v) => _then(v as Error<Key, Value>));
@override
Error<Key, Value> get _value => super._value as Error<Key, Value>;
@override
$Res call({
Object? error = freezed,
}) {
return _then(Error<Key, Value>(
error == freezed
? _value.error
: error // ignore: cast_nullable_to_non_nullable
as StreamChatError,
));
}
}
/// @nodoc
class _$Error<Key, Value> extends Error<Key, Value>
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<Key, Value> &&
const DeepCollectionEquality().equals(other.error, error));
}
@override
int get hashCode =>
Object.hash(runtimeType, const DeepCollectionEquality().hash(error));
@JsonKey(ignore: true)
@override
$ErrorCopyWith<Key, Value, Error<Key, Value>> get copyWith =>
_$ErrorCopyWithImpl<Key, Value, Error<Key, Value>>(this, _$identity);
@override
@optionalTypeArgs
TResult when<TResult extends Object?>(
TResult Function(
List<Value> 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 extends Object?>(
TResult Function(
List<Value> items, Key? nextPageKey, StreamChatError? error)?
$default, {
TResult Function()? loading,
TResult Function(StreamChatError error)? error,
}) {
return error?.call(this.error);
}
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>(
TResult Function(
List<Value> 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 extends Object?>(
TResult Function(Success<Key, Value> value) $default, {
required TResult Function(Loading<Key, Value> value) loading,
required TResult Function(Error<Key, Value> value) error,
}) {
return error(this);
}
@override
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>(
TResult Function(Success<Key, Value> value)? $default, {
TResult Function(Loading<Key, Value> value)? loading,
TResult Function(Error<Key, Value> value)? error,
}) {
return error?.call(this);
}
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>(
TResult Function(Success<Key, Value> value)? $default, {
TResult Function(Loading<Key, Value> value)? loading,
TResult Function(Error<Key, Value> value)? error,
required TResult orElse(),
}) {
if (error != null) {
return error(this);
}
return orElse();
}
}
abstract class Error<Key, Value> extends PagedValue<Key, Value> {
const factory Error(StreamChatError error) = _$Error<Key, Value>;
const Error._() : super._();
StreamChatError get error;
@JsonKey(ignore: true)
$ErrorCopyWith<Key, Value, Error<Key, Value>> get copyWith =>
throw _privateConstructorUsedError;
}
@@ -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<int, Channel> {
/// 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<int, Channel> 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<SortOption<ChannelModel>>? sort;
/// If true youll 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<void> 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<void> 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<Channel> channels) {
value = PagedValue(
items: channels,
nextPageKey: channels.length,
);
}
/// Returns/Creates a new Channel and starts watching it.
Future<Channel> 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<void> 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<void> deleteChannel(Channel channel) async {
await channel.delete();
}
/// Mutes the [channel] and updates the list.
Future<void> muteChannel(Channel channel) async {
await channel.mute();
}
/// Un-mutes the [channel] and updates the list.
Future<void> 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<Event>? _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<void>? resumeSignal]) {
_channelEventSubscription?.pause(resumeSignal);
}
/// Resumes all subscriptions added to this composite.
void resumeEventsSubscription() {
_channelEventSubscription?.resume();
}
@override
void dispose() {
_unsubscribeFromChannelListEvents();
super.dispose();
}
}
@@ -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];
}
}
@@ -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;
@@ -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
@@ -75,6 +75,7 @@ GlobalStreamChatLocalizations? getStreamChatTranslation(Locale locale) {
case 'pt':
return const StreamChatLocalizationsPt();
}
return null;
}
/// Implementation of localized strings for the stream chat widgets
@@ -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';
}