Merge pull request #775 from GetStream/feat/stream-channel-listview
feat(ui): stream channel listview
This commit is contained in:
@@ -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,
|
||||
|
||||
+96
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+424
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
+502
@@ -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';
|
||||
|
||||
/// 
|
||||
/// 
|
||||
///
|
||||
/// 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';
|
||||
|
||||
-14
@@ -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.
|
||||
|
||||
+3
-1
@@ -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].
|
||||
///
|
||||
+2
-13
@@ -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 you’ll receive user presence updates via the websocket events
|
||||
final bool presence;
|
||||
|
||||
/// The limit to apply to the channel list. The default is set to
|
||||
/// [defaultChannelPagedLimit].
|
||||
final int limit;
|
||||
|
||||
/// Number of messages to fetch in each channel.
|
||||
final int? messageLimit;
|
||||
|
||||
/// Number of members to fetch in each channel.
|
||||
final int? memberLimit;
|
||||
|
||||
@override
|
||||
Future<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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user