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(),
|
(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.
|
/// Channel read list.
|
||||||
List<Read> get read => _channelState.read;
|
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({
|
const ChannelListPage({
|
||||||
Key? key,
|
Key? key,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
@@ -92,22 +92,26 @@ class ChannelListPage extends StatelessWidget {
|
|||||||
|
|
||||||
final void Function(Channel)? onTap;
|
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
|
@override
|
||||||
Widget build(BuildContext context) => Scaffold(
|
Widget build(BuildContext context) => Scaffold(
|
||||||
body: ChannelsBloc(
|
body: StreamChannelListView(
|
||||||
child: ChannelListView(
|
onChannelTap: widget.onTap,
|
||||||
onChannelTap: onTap != null
|
controller: _listController,
|
||||||
? (channel, _) {
|
|
||||||
onTap!(channel);
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
filter: Filter.in_(
|
|
||||||
'members',
|
|
||||||
[StreamChat.of(context).currentUser!.id],
|
|
||||||
),
|
|
||||||
sort: const [SortOption('last_message_at')],
|
|
||||||
limit: 20,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,18 +90,15 @@ class ChannelPage extends StatelessWidget {
|
|||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
// ignore: prefer_expression_function_bodies
|
Widget build(BuildContext context) => Scaffold(
|
||||||
Widget build(BuildContext context) {
|
appBar: const ChannelHeader(),
|
||||||
return Scaffold(
|
body: Column(
|
||||||
appBar: const ChannelHeader(),
|
children: const <Widget>[
|
||||||
body: Column(
|
Expanded(
|
||||||
children: const <Widget>[
|
child: MessageListView(),
|
||||||
Expanded(
|
),
|
||||||
child: MessageListView(),
|
MessageInput(),
|
||||||
),
|
],
|
||||||
MessageInput(),
|
),
|
||||||
],
|
);
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,33 +62,53 @@ class MyApp extends StatelessWidget {
|
|||||||
client: client,
|
client: client,
|
||||||
child: child,
|
child: child,
|
||||||
),
|
),
|
||||||
home: const ChannelListPage(),
|
home: ChannelListPage(
|
||||||
|
client: client,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ChannelListPage extends StatelessWidget {
|
class ChannelListPage extends StatefulWidget {
|
||||||
const ChannelListPage({
|
const ChannelListPage({
|
||||||
Key? key,
|
Key? key,
|
||||||
|
required this.client,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
|
final StreamChatClient client;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
// ignore: prefer_expression_function_bodies
|
State<ChannelListPage> createState() => _ChannelListPageState();
|
||||||
Widget build(BuildContext context) {
|
}
|
||||||
return Scaffold(
|
|
||||||
body: ChannelsBloc(
|
class _ChannelListPageState extends State<ChannelListPage> {
|
||||||
child: ChannelListView(
|
late final _controller = StreamChannelListController(
|
||||||
filter: Filter.in_(
|
client: widget.client,
|
||||||
'members',
|
filter: Filter.in_(
|
||||||
[StreamChat.of(context).currentUser!.id],
|
'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 {
|
class ChannelPage extends StatelessWidget {
|
||||||
@@ -97,18 +117,15 @@ class ChannelPage extends StatelessWidget {
|
|||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
// ignore: prefer_expression_function_bodies
|
Widget build(BuildContext context) => Scaffold(
|
||||||
Widget build(BuildContext context) {
|
appBar: const ChannelHeader(),
|
||||||
return Scaffold(
|
body: Column(
|
||||||
appBar: const ChannelHeader(),
|
children: const <Widget>[
|
||||||
body: Column(
|
Expanded(
|
||||||
children: const <Widget>[
|
child: MessageListView(),
|
||||||
Expanded(
|
),
|
||||||
child: MessageListView(),
|
MessageInput(),
|
||||||
),
|
],
|
||||||
MessageInput(),
|
),
|
||||||
],
|
);
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,31 +68,49 @@ class MyApp extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ChannelListPage extends StatelessWidget {
|
class ChannelListPage extends StatefulWidget {
|
||||||
const ChannelListPage({
|
const ChannelListPage({
|
||||||
Key? key,
|
Key? key,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
// ignore: prefer_expression_function_bodies
|
State<ChannelListPage> createState() => _ChannelListPageState();
|
||||||
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(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
final lastMessage = channel.state?.messages.reversed.firstWhereOrNull(
|
||||||
(message) => !message.isDeleted,
|
(message) => !message.isDeleted,
|
||||||
);
|
);
|
||||||
@@ -112,16 +130,17 @@ class ChannelListPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
leading: ChannelAvatar(
|
leading: StreamChannelAvatar(
|
||||||
channel: channel,
|
channel: channel,
|
||||||
),
|
),
|
||||||
title: ChannelName(
|
title: StreamChannelName(
|
||||||
textStyle: ChannelPreviewTheme.of(context).titleStyle!.copyWith(
|
textStyle: ChannelPreviewTheme.of(context).titleStyle!.copyWith(
|
||||||
color: StreamChatTheme.of(context)
|
color: StreamChatTheme.of(context)
|
||||||
.colorTheme
|
.colorTheme
|
||||||
.textHighEmphasis
|
.textHighEmphasis
|
||||||
.withOpacity(opacity),
|
.withOpacity(opacity),
|
||||||
),
|
),
|
||||||
|
channel: channel,
|
||||||
),
|
),
|
||||||
subtitle: Text(subtitle),
|
subtitle: Text(subtitle),
|
||||||
trailing: channel.state!.unreadCount > 0
|
trailing: channel.state!.unreadCount > 0
|
||||||
|
|||||||
@@ -53,28 +53,42 @@ class MyApp extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ChannelListPage extends StatelessWidget {
|
class ChannelListPage extends StatefulWidget {
|
||||||
const ChannelListPage({
|
const ChannelListPage({
|
||||||
Key? key,
|
Key? key,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
// ignore: prefer_expression_function_bodies
|
State<ChannelListPage> createState() => _ChannelListPageState();
|
||||||
Widget build(BuildContext context) {
|
}
|
||||||
return Scaffold(
|
|
||||||
body: ChannelsBloc(
|
class _ChannelListPageState extends State<ChannelListPage> {
|
||||||
child: ChannelListView(
|
late final _listController = StreamChannelListController(
|
||||||
filter: Filter.in_(
|
client: StreamChat.of(context).client,
|
||||||
'members',
|
filter: Filter.in_(
|
||||||
[StreamChat.of(context).currentUser!.id],
|
'members',
|
||||||
),
|
[StreamChat.of(context).currentUser!.id],
|
||||||
sort: const [SortOption('last_message_at')],
|
),
|
||||||
limit: 20,
|
sort: const [SortOption('last_message_at')],
|
||||||
channelWidget: const ChannelPage(),
|
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 {
|
class ChannelPage extends StatelessWidget {
|
||||||
@@ -83,24 +97,21 @@ class ChannelPage extends StatelessWidget {
|
|||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
// ignore: prefer_expression_function_bodies
|
Widget build(BuildContext context) => Scaffold(
|
||||||
Widget build(BuildContext context) {
|
appBar: const ChannelHeader(),
|
||||||
return Scaffold(
|
body: Column(
|
||||||
appBar: const ChannelHeader(),
|
children: <Widget>[
|
||||||
body: Column(
|
Expanded(
|
||||||
children: <Widget>[
|
child: MessageListView(
|
||||||
Expanded(
|
threadBuilder: (_, parentMessage) => ThreadPage(
|
||||||
child: MessageListView(
|
parent: parentMessage,
|
||||||
threadBuilder: (_, parentMessage) => ThreadPage(
|
),
|
||||||
parent: parentMessage,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const MessageInput(),
|
||||||
const MessageInput(),
|
],
|
||||||
],
|
),
|
||||||
),
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class ThreadPage extends StatelessWidget {
|
class ThreadPage extends StatelessWidget {
|
||||||
|
|||||||
@@ -59,28 +59,42 @@ class MyApp extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ChannelListPage extends StatelessWidget {
|
class ChannelListPage extends StatefulWidget {
|
||||||
const ChannelListPage({
|
const ChannelListPage({
|
||||||
Key? key,
|
Key? key,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
// ignore: prefer_expression_function_bodies
|
State<ChannelListPage> createState() => _ChannelListPageState();
|
||||||
Widget build(BuildContext context) {
|
}
|
||||||
return Scaffold(
|
|
||||||
body: ChannelsBloc(
|
class _ChannelListPageState extends State<ChannelListPage> {
|
||||||
child: ChannelListView(
|
late final _listController = StreamChannelListController(
|
||||||
filter: Filter.in_(
|
client: StreamChat.of(context).client,
|
||||||
'members',
|
filter: Filter.in_(
|
||||||
[StreamChat.of(context).currentUser!.id],
|
'members',
|
||||||
),
|
[StreamChat.of(context).currentUser!.id],
|
||||||
sort: const [SortOption('last_message_at')],
|
),
|
||||||
limit: 20,
|
sort: const [SortOption('last_message_at')],
|
||||||
channelWidget: const ChannelPage(),
|
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 {
|
class ChannelPage extends StatelessWidget {
|
||||||
|
|||||||
@@ -93,28 +93,41 @@ class MyApp extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ChannelListPage extends StatelessWidget {
|
class ChannelListPage extends StatefulWidget {
|
||||||
const ChannelListPage({
|
const ChannelListPage({
|
||||||
Key? key,
|
Key? key,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
// ignore: prefer_expression_function_bodies
|
State<ChannelListPage> createState() => _ChannelListPageState();
|
||||||
Widget build(BuildContext context) {
|
}
|
||||||
return Scaffold(
|
|
||||||
body: ChannelsBloc(
|
class _ChannelListPageState extends State<ChannelListPage> {
|
||||||
child: ChannelListView(
|
late final _listController = StreamChannelListController(
|
||||||
filter: Filter.in_(
|
client: StreamChat.of(context).client,
|
||||||
'members',
|
filter: Filter.in_(
|
||||||
[StreamChat.of(context).currentUser!.id],
|
'members',
|
||||||
),
|
[StreamChat.of(context).currentUser!.id],
|
||||||
sort: const [SortOption('last_message_at')],
|
),
|
||||||
limit: 20,
|
sort: const [SortOption('last_message_at')],
|
||||||
channelWidget: const ChannelPage(),
|
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 {
|
class ChannelPage extends StatelessWidget {
|
||||||
@@ -123,24 +136,21 @@ class ChannelPage extends StatelessWidget {
|
|||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
// ignore: prefer_expression_function_bodies
|
Widget build(BuildContext context) => Scaffold(
|
||||||
Widget build(BuildContext context) {
|
appBar: const ChannelHeader(),
|
||||||
return Scaffold(
|
body: Column(
|
||||||
appBar: const ChannelHeader(),
|
children: <Widget>[
|
||||||
body: Column(
|
Expanded(
|
||||||
children: <Widget>[
|
child: MessageListView(
|
||||||
Expanded(
|
threadBuilder: (_, parentMessage) => ThreadPage(
|
||||||
child: MessageListView(
|
parent: parentMessage,
|
||||||
threadBuilder: (_, parentMessage) => ThreadPage(
|
),
|
||||||
parent: parentMessage,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const MessageInput(),
|
||||||
const MessageInput(),
|
],
|
||||||
],
|
),
|
||||||
),
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class ThreadPage extends StatelessWidget {
|
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
|
/// The widget renders the ui based on the first ancestor of type
|
||||||
/// [StreamChatTheme].
|
/// [StreamChatTheme].
|
||||||
/// Modify it to change the widget appearance.
|
/// Modify it to change the widget appearance.
|
||||||
|
|
||||||
|
@Deprecated(
|
||||||
|
"'ChannelAvatar' is deprecated and shouldn't be used. "
|
||||||
|
"Please use 'StreamChannelAvatar' instead.",
|
||||||
|
)
|
||||||
class ChannelAvatar extends StatelessWidget {
|
class ChannelAvatar extends StatelessWidget {
|
||||||
/// Instantiate a new ChannelImage
|
/// Instantiate a new ChannelImage
|
||||||
const ChannelAvatar({
|
const ChannelAvatar({
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import 'package:stream_chat_flutter/src/extension.dart';
|
|||||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||||
|
|
||||||
/// Bottom Sheet with options
|
/// Bottom Sheet with options
|
||||||
|
@Deprecated(
|
||||||
|
"'ChannelBottomSheet' is deprecated and shouldn't be used. "
|
||||||
|
"Please use 'StreamChannelBottomSheet' instead.",
|
||||||
|
)
|
||||||
class ChannelBottomSheet extends StatefulWidget {
|
class ChannelBottomSheet extends StatefulWidget {
|
||||||
/// Constructor for creating bottom sheet
|
/// Constructor for creating bottom sheet
|
||||||
const ChannelBottomSheet({Key? key, this.onViewInfoTap}) : super(key: key);
|
const ChannelBottomSheet({Key? key, this.onViewInfoTap}) : super(key: key);
|
||||||
@@ -15,6 +19,7 @@ class ChannelBottomSheet extends StatefulWidget {
|
|||||||
_ChannelBottomSheetState createState() => _ChannelBottomSheetState();
|
_ChannelBottomSheetState createState() => _ChannelBottomSheetState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ignore: deprecated_member_use_from_same_package
|
||||||
class _ChannelBottomSheetState extends State<ChannelBottomSheet> {
|
class _ChannelBottomSheetState extends State<ChannelBottomSheet> {
|
||||||
bool _showActions = true;
|
bool _showActions = true;
|
||||||
|
|
||||||
|
|||||||
@@ -94,11 +94,12 @@ class ChannelInfo extends StatelessWidget {
|
|||||||
return alternativeWidget ?? const Offstage();
|
return alternativeWidget ?? const Offstage();
|
||||||
}
|
}
|
||||||
|
|
||||||
return TypingIndicator(
|
return Align(
|
||||||
parentId: parentId,
|
child: TypingIndicator(
|
||||||
alignment: Alignment.center,
|
parentId: parentId,
|
||||||
alternativeWidget: alternativeWidget,
|
style: textStyle,
|
||||||
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
|
/// The widget components render the ui based on the first ancestor of
|
||||||
/// type [StreamChatTheme].
|
/// type [StreamChatTheme].
|
||||||
/// Modify it to change the widget appearance.
|
/// Modify it to change the widget appearance.
|
||||||
|
@Deprecated(
|
||||||
|
"'ChannelListView' is deprecated and shouldn't be used. "
|
||||||
|
"Please use 'StreamChannelListView' instead.",
|
||||||
|
)
|
||||||
class ChannelListView extends StatefulWidget {
|
class ChannelListView extends StatefulWidget {
|
||||||
/// Instantiate a new ChannelListView
|
/// Instantiate a new ChannelListView
|
||||||
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
|
/// The widget uses a [StreamBuilder] to render the channel information
|
||||||
/// image as soon as it updates.
|
/// image as soon as it updates.
|
||||||
|
@Deprecated(
|
||||||
|
"'ChannelName' is deprecated and shouldn't be used. "
|
||||||
|
"Please use 'StreamChannelName' instead.",
|
||||||
|
)
|
||||||
class ChannelName extends StatelessWidget {
|
class ChannelName extends StatelessWidget {
|
||||||
/// Instantiate a new ChannelName
|
/// Instantiate a new ChannelName
|
||||||
const ChannelName({
|
const ChannelName({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ class GroupAvatar extends StatelessWidget {
|
|||||||
/// Constructor for creating a [GroupAvatar]
|
/// Constructor for creating a [GroupAvatar]
|
||||||
const GroupAvatar({
|
const GroupAvatar({
|
||||||
Key? key,
|
Key? key,
|
||||||
|
this.channel,
|
||||||
required this.members,
|
required this.members,
|
||||||
this.constraints,
|
this.constraints,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
@@ -15,6 +16,9 @@ class GroupAvatar extends StatelessWidget {
|
|||||||
this.selectionThickness = 4,
|
this.selectionThickness = 4,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
|
/// The channel of the avatar
|
||||||
|
final Channel? channel;
|
||||||
|
|
||||||
/// List of images to display
|
/// List of images to display
|
||||||
final List<Member> members;
|
final List<Member> members;
|
||||||
|
|
||||||
@@ -38,7 +42,7 @@ class GroupAvatar extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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');
|
assert(channel.state != null, 'Channel ${channel.id} is not initialized');
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// A [SafeArea] with an enabled toggle
|
/// A [SafeArea] with an enabled toggle
|
||||||
class SimpleSafeArea extends StatefulWidget {
|
class SimpleSafeArea extends StatelessWidget {
|
||||||
/// Constructor for [SimpleSafeArea]
|
/// Constructor for [SimpleSafeArea]
|
||||||
const SimpleSafeArea({
|
const SimpleSafeArea({
|
||||||
Key? key,
|
Key? key,
|
||||||
@@ -15,17 +15,12 @@ class SimpleSafeArea extends StatefulWidget {
|
|||||||
/// Child widget to wrap
|
/// Child widget to wrap
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
||||||
@override
|
|
||||||
_SimpleSafeAreaState createState() => _SimpleSafeAreaState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SimpleSafeAreaState extends State<SimpleSafeArea> {
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => SafeArea(
|
Widget build(BuildContext context) => SafeArea(
|
||||||
left: widget.enabled,
|
left: enabled,
|
||||||
top: widget.enabled,
|
top: enabled,
|
||||||
right: widget.enabled,
|
right: enabled,
|
||||||
bottom: widget.enabled,
|
bottom: enabled,
|
||||||
child: widget.child,
|
child: child,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -526,6 +526,7 @@ class _MessageListViewState extends State<MessageListView> {
|
|||||||
return ((index + 2) * 2) - 1;
|
return ((index + 2) * 2) - 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Item Count -> 8 (1 parent, 2 header+footer, 2 top+bottom, 3 messages)
|
// 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]
|
/// Constructor for creating [OptionListTile]
|
||||||
const OptionListTile({
|
const OptionListTile({
|
||||||
Key? key,
|
Key? key,
|
||||||
this.title,
|
required this.title,
|
||||||
this.leading,
|
this.leading,
|
||||||
this.trailing,
|
this.trailing,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
@@ -17,7 +17,7 @@ class OptionListTile extends StatelessWidget {
|
|||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
/// Title for tile
|
/// Title for tile
|
||||||
final String? title;
|
final String title;
|
||||||
|
|
||||||
/// Leading widget (start)
|
/// Leading widget (start)
|
||||||
final Widget? leading;
|
final Widget? leading;
|
||||||
@@ -46,8 +46,8 @@ class OptionListTile extends StatelessWidget {
|
|||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
color: separatorColor ?? chatThemeData.colorTheme.disabled,
|
|
||||||
height: 1,
|
height: 1,
|
||||||
|
color: separatorColor ?? chatThemeData.colorTheme.disabled,
|
||||||
),
|
),
|
||||||
Material(
|
Material(
|
||||||
color: tileColor ?? chatThemeData.colorTheme.barsBg,
|
color: tileColor ?? chatThemeData.colorTheme.barsBg,
|
||||||
@@ -57,15 +57,14 @@ class OptionListTile extends StatelessWidget {
|
|||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
if (leading != null) Center(child: leading),
|
if (leading != null)
|
||||||
if (leading == null)
|
Center(child: leading)
|
||||||
const SizedBox(
|
else
|
||||||
width: 16,
|
const SizedBox(width: 16),
|
||||||
),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 4,
|
flex: 4,
|
||||||
child: Text(
|
child: Text(
|
||||||
title!,
|
title,
|
||||||
style: titleTextStyle ??
|
style: titleTextStyle ??
|
||||||
(titleColor == null
|
(titleColor == null
|
||||||
? chatThemeData.textTheme.bodyBold
|
? chatThemeData.textTheme.bodyBold
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:stream_chat_flutter/src/extension.dart';
|
import 'package:stream_chat_flutter/src/extension.dart';
|
||||||
@@ -188,7 +190,7 @@ class MessageInputThemeData with Diagnosticable {
|
|||||||
linkHighlightColor:
|
linkHighlightColor:
|
||||||
Color.lerp(a.linkHighlightColor, b.linkHighlightColor, t),
|
Color.lerp(a.linkHighlightColor, b.linkHighlightColor, t),
|
||||||
enableSafeArea: a.enableSafeArea,
|
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),
|
shadow: BoxShadow.lerp(a.shadow, b.shadow, t),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -161,12 +161,13 @@ class ThreadHeader extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
if (showTypingIndicator)
|
if (showTypingIndicator)
|
||||||
TypingIndicator(
|
Align(
|
||||||
alignment: Alignment.center,
|
child: TypingIndicator(
|
||||||
channel: StreamChannel.of(context).channel,
|
channel: StreamChannel.of(context).channel,
|
||||||
style: channelHeaderTheme.subtitleStyle,
|
style: channelHeaderTheme.subtitleStyle,
|
||||||
parentId: parent.id,
|
parentId: parent.id,
|
||||||
alternativeWidget: defaultSubtitle,
|
alternativeWidget: defaultSubtitle,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
defaultSubtitle,
|
defaultSubtitle,
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ class TypingIndicator extends StatelessWidget {
|
|||||||
this.channel,
|
this.channel,
|
||||||
this.alternativeWidget,
|
this.alternativeWidget,
|
||||||
this.style,
|
this.style,
|
||||||
this.alignment = Alignment.centerLeft,
|
|
||||||
this.padding = const EdgeInsets.all(0),
|
this.padding = const EdgeInsets.all(0),
|
||||||
this.parentId,
|
this.parentId,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
@@ -28,9 +27,6 @@ class TypingIndicator extends StatelessWidget {
|
|||||||
/// The padding of this widget
|
/// The padding of this widget
|
||||||
final EdgeInsets padding;
|
final EdgeInsets padding;
|
||||||
|
|
||||||
/// Alignment of the typing indicator
|
|
||||||
final Alignment alignment;
|
|
||||||
|
|
||||||
/// Id of the parent message in case of a thread
|
/// Id of the parent message in case of a thread
|
||||||
final String? parentId;
|
final String? parentId;
|
||||||
|
|
||||||
@@ -46,30 +42,25 @@ class TypingIndicator extends StatelessWidget {
|
|||||||
stream: channelState.typingEventsStream.map((typings) => typings.entries
|
stream: channelState.typingEventsStream.map((typings) => typings.entries
|
||||||
.where((element) => element.value.parentId == parentId)
|
.where((element) => element.value.parentId == parentId)
|
||||||
.map((e) => e.key)),
|
.map((e) => e.key)),
|
||||||
builder: (context, data) => AnimatedSwitcher(
|
builder: (context, users) => AnimatedSwitcher(
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
child: data.isNotEmpty
|
child: users.isNotEmpty
|
||||||
? Padding(
|
? Padding(
|
||||||
key: const Key('main'),
|
|
||||||
padding: padding,
|
padding: padding,
|
||||||
child: Align(
|
child: Row(
|
||||||
key: const Key('typings'),
|
mainAxisSize: MainAxisSize.min,
|
||||||
alignment: alignment,
|
children: [
|
||||||
child: Row(
|
Lottie.asset(
|
||||||
mainAxisSize: MainAxisSize.min,
|
'animations/typing_dots.json',
|
||||||
children: [
|
package: 'stream_chat_flutter',
|
||||||
Lottie.asset(
|
height: 4,
|
||||||
'animations/typing_dots.json',
|
),
|
||||||
package: 'stream_chat_flutter',
|
Text(
|
||||||
height: 4,
|
context.translations.userTypingText(users),
|
||||||
),
|
maxLines: 1,
|
||||||
Text(
|
style: style,
|
||||||
context.translations.userTypingText(data),
|
),
|
||||||
maxLines: 1,
|
],
|
||||||
style: style,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: altWidget,
|
: 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_action.dart';
|
||||||
export 'src/message_input/countdown_button.dart';
|
export 'src/message_input/countdown_button.dart';
|
||||||
export 'src/message_input/message_input.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_attachment_picker.dart';
|
||||||
export 'src/message_input/stream_message_send_button.dart';
|
export 'src/message_input/stream_message_send_button.dart';
|
||||||
export 'src/message_input/stream_message_text_field.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_list_view.dart';
|
||||||
export 'src/user_mention_tile.dart';
|
export 'src/user_mention_tile.dart';
|
||||||
export 'src/utils.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';
|
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),
|
Event(type: EventType.typingStart),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const typingKey = Key('typing');
|
||||||
|
|
||||||
await tester.pumpWidget(MaterialApp(
|
await tester.pumpWidget(MaterialApp(
|
||||||
home: StreamChat(
|
home: StreamChat(
|
||||||
client: client,
|
client: client,
|
||||||
child: StreamChannel(
|
child: StreamChannel(
|
||||||
channel: channel,
|
channel: channel,
|
||||||
child: const Scaffold(
|
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].
|
/// using Flutter's [BuildContext].
|
||||||
///
|
///
|
||||||
/// API docs: https://getstream.io/chat/docs/flutter-dart/query_channels/
|
/// 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 {
|
class ChannelsBloc extends StatefulWidget {
|
||||||
/// Creates a new [ChannelsBloc]. The parameter [child] must be supplied and
|
/// Creates a new [ChannelsBloc]. The parameter [child] must be supplied and
|
||||||
/// not null.
|
/// not null.
|
||||||
|
|||||||
+3
-1
@@ -2,7 +2,9 @@ import 'dart:convert';
|
|||||||
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.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].
|
/// A value listenable builder related to a [Message].
|
||||||
///
|
///
|
||||||
+2
-13
@@ -1,6 +1,4 @@
|
|||||||
import 'package:flutter/material.dart';
|
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].
|
/// A function that takes a [BuildContext] and returns a [TextStyle].
|
||||||
typedef TextStyleBuilder = TextStyle? Function(
|
typedef TextStyleBuilder = TextStyle? Function(
|
||||||
@@ -33,17 +31,8 @@ class MessageTextFieldController extends TextEditingController {
|
|||||||
TextStyle? style,
|
TextStyle? style,
|
||||||
required bool withComposing,
|
required bool withComposing,
|
||||||
}) {
|
}) {
|
||||||
final pattern = textPatternStyle ??
|
final pattern = textPatternStyle;
|
||||||
{
|
if (pattern == null || pattern.isEmpty) {
|
||||||
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) {
|
|
||||||
return super.buildTextSpan(
|
return super.buildTextSpan(
|
||||||
context: context,
|
context: context,
|
||||||
style: style,
|
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/channel_list_core.dart' hide ChannelListCoreState;
|
||||||
export 'src/channels_bloc.dart';
|
export 'src/channels_bloc.dart';
|
||||||
export 'src/lazy_load_scroll_view.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_list_core.dart' hide MessageListCoreState;
|
||||||
export 'src/message_search_bloc.dart';
|
export 'src/message_search_bloc.dart';
|
||||||
export 'src/message_search_list_core.dart' hide MessageSearchListCoreState;
|
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.dart';
|
||||||
|
export 'src/stream_channel_list_controller.dart';
|
||||||
|
export 'src/stream_channel_list_event_handler.dart';
|
||||||
export 'src/stream_chat_core.dart';
|
export 'src/stream_chat_core.dart';
|
||||||
export 'src/typedef.dart';
|
export 'src/typedef.dart';
|
||||||
export 'src/user_list_core.dart' hide UserListCoreState;
|
export 'src/user_list_core.dart' hide UserListCoreState;
|
||||||
|
|||||||
@@ -14,14 +14,17 @@ dependencies:
|
|||||||
connectivity_plus: ^2.1.0
|
connectivity_plus: ^2.1.0
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
freezed_annotation: ^1.0.0
|
||||||
meta: ^1.3.0
|
meta: ^1.3.0
|
||||||
rxdart: ^0.27.0
|
rxdart: ^0.27.0
|
||||||
stream_chat: ^3.5.0
|
stream_chat: ^3.5.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
build_runner: ^2.0.1
|
||||||
dart_code_metrics: ^4.4.0
|
dart_code_metrics: ^4.4.0
|
||||||
fake_async: ^1.2.0
|
fake_async: ^1.2.0
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
freezed: ^1.0.0
|
||||||
mocktail: ^0.2.0
|
mocktail: ^0.2.0
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ GlobalStreamChatLocalizations? getStreamChatTranslation(Locale locale) {
|
|||||||
case 'pt':
|
case 'pt':
|
||||||
return const StreamChatLocalizationsPt();
|
return const StreamChatLocalizationsPt();
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Implementation of localized strings for the stream chat widgets
|
/// 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
|
@override
|
||||||
String get slowModeOnLabel => 'Modo lento ativado';
|
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