chore(ui): initial channel list view draft with controller.
Signed-off-by: xsahil03x <[email protected]>
This commit is contained in:
+91
@@ -0,0 +1,91 @@
|
||||
import 'package:stream_chat/stream_chat.dart' hide Success;
|
||||
import 'package:stream_chat_flutter/src/paged_value_notifier.dart';
|
||||
|
||||
class StreamChannelListController extends PagedValueNotifier<int, Channel> {
|
||||
/// Creates a [StreamChannelListController].
|
||||
StreamChannelListController({
|
||||
required this.client,
|
||||
this.filter,
|
||||
this.sort,
|
||||
this.limit = 2,
|
||||
this.messageLimit,
|
||||
this.memberLimit,
|
||||
}) : super(const PagedValue.loading());
|
||||
|
||||
/// Creates a [StreamChannelListController] from the passed [value].
|
||||
StreamChannelListController.fromValue(
|
||||
PagedValue<int, Channel> value, {
|
||||
required this.client,
|
||||
this.filter,
|
||||
this.sort,
|
||||
this.limit = 2,
|
||||
this.messageLimit,
|
||||
this.memberLimit,
|
||||
}) : super(value);
|
||||
|
||||
/// The client to use for the channel list.
|
||||
final StreamChatClient client;
|
||||
|
||||
/// The filter to apply to the channel list.
|
||||
final Filter? filter;
|
||||
|
||||
/// The sort to apply to the channel list.
|
||||
final List<SortOption<ChannelModel>>? sort;
|
||||
|
||||
/// The limit to apply to the channel list.
|
||||
final int limit;
|
||||
|
||||
/// The limit to apply to the message list.
|
||||
final int? messageLimit;
|
||||
|
||||
/// The limit to apply to the member list.
|
||||
final int? memberLimit;
|
||||
|
||||
@override
|
||||
Future<void> doInitialLoad() async {
|
||||
final limit = this.limit * defaultInitialPagedLimitMultiplier;
|
||||
try {
|
||||
await for (final channels in client.queryChannels(
|
||||
filter: filter,
|
||||
sort: sort,
|
||||
memberLimit: memberLimit,
|
||||
messageLimit: messageLimit,
|
||||
paginationParams: PaginationParams(limit: limit),
|
||||
)) {
|
||||
final nextKey = channels.length < limit ? null : channels.length;
|
||||
value = PagedValue(
|
||||
items: channels,
|
||||
nextPageKey: nextKey,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
value = PagedValue.error(StreamChatError('error'));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> loadMore(int nextPageKey) async {
|
||||
assert(value is Success<int, Channel>, '');
|
||||
final previousValue = value as Success<int, Channel>;
|
||||
|
||||
try {
|
||||
await for (final channels in client.queryChannels(
|
||||
filter: filter,
|
||||
sort: sort,
|
||||
memberLimit: memberLimit,
|
||||
messageLimit: messageLimit,
|
||||
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,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
value = previousValue.copyWith(error: StreamChatError('error'));
|
||||
}
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
import 'package:stream_chat_flutter/src/stream_chat_theme.dart';
|
||||
|
||||
class StreamChannelListLoadingTile extends StatelessWidget {
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:jiffy/jiffy.dart';
|
||||
import 'package:stream_chat/stream_chat.dart' show Channel;
|
||||
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';
|
||||
import 'package:stream_chat_flutter/src/extension.dart';
|
||||
|
||||
class StreamChannelListTile extends StatelessWidget {
|
||||
StreamChannelListTile({
|
||||
Key? key,
|
||||
required this.channel,
|
||||
this.leading,
|
||||
this.title,
|
||||
this.subtitle,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
this.visualDensity = VisualDensity.compact,
|
||||
this.contentPadding = const EdgeInsets.symmetric(horizontal: 8),
|
||||
}) : assert(
|
||||
channel.state != null,
|
||||
'Channel ${channel.id} is not initialized',
|
||||
),
|
||||
super(key: key);
|
||||
|
||||
final Channel channel;
|
||||
|
||||
/// A widget to display before the title.
|
||||
///
|
||||
/// Typically an [Icon] or a [CircleAvatar] widget.
|
||||
final Widget? leading;
|
||||
|
||||
/// The primary content of the list tile.
|
||||
///
|
||||
/// Typically a [Text] widget.
|
||||
///
|
||||
/// This should not wrap. To enforce the single line limit, use
|
||||
/// [Text.maxLines].
|
||||
final Widget? title;
|
||||
|
||||
/// Additional content displayed below the title.
|
||||
///
|
||||
/// Typically a [Text] widget.
|
||||
///
|
||||
/// If [isThreeLine] is false, this should not wrap.
|
||||
///
|
||||
/// If [isThreeLine] is true, this should be configured to take a maximum of
|
||||
/// two lines. For example, you can use [Text.maxLines] to enforce the number
|
||||
/// of lines.
|
||||
///
|
||||
/// The subtitle's default [TextStyle] depends on [TextTheme.bodyText2] except
|
||||
/// [TextStyle.color]. The [TextStyle.color] depends on the value of [enabled]
|
||||
/// and [selected].
|
||||
///
|
||||
/// When [enabled] is false, the text color is set to [ThemeData.disabledColor].
|
||||
///
|
||||
/// When [selected] is false, the text color is set to [ListTileTheme.textColor]
|
||||
/// if it's not null and to [TextTheme.caption]'s color if [ListTileTheme.textColor]
|
||||
/// is null.
|
||||
final Widget? subtitle;
|
||||
|
||||
/// Called when the user taps this list tile.
|
||||
///
|
||||
/// Inoperative if [enabled] is false.
|
||||
final GestureTapCallback? onTap;
|
||||
|
||||
/// Called when the user long-presses on this list tile.
|
||||
///
|
||||
/// Inoperative if [enabled] is false.
|
||||
final GestureLongPressCallback? onLongPress;
|
||||
|
||||
/// 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 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,
|
||||
);
|
||||
|
||||
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,
|
||||
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 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: SendingIndicator(
|
||||
message: lastMessage,
|
||||
size: channelPreviewTheme.indicatorIconSize,
|
||||
isMessageRead: channelState.read
|
||||
.where((it) => it.user.id != currentUser.id)
|
||||
.where(
|
||||
(it) => it.lastRead.isAfter(lastMessage.createdAt),
|
||||
)
|
||||
.isNotEmpty,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
ChannelLastMessageDate(
|
||||
channel: channel,
|
||||
textStyle: channelPreviewTheme.lastMessageAtStyle,
|
||||
),
|
||||
// trailing ?? _buildDate(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChannelLastMessageDate extends StatelessWidget {
|
||||
ChannelLastMessageDate({
|
||||
Key? key,
|
||||
required this.channel,
|
||||
this.textStyle,
|
||||
}) : assert(
|
||||
channel.state != null,
|
||||
'Channel ${channel.id} is not initialized',
|
||||
),
|
||||
super(key: key);
|
||||
|
||||
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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class ChannelListTileSubtitle extends StatelessWidget {
|
||||
ChannelListTileSubtitle({
|
||||
Key? key,
|
||||
required this.channel,
|
||||
this.textStyle,
|
||||
}) : assert(
|
||||
channel.state != null,
|
||||
'Channel ${channel.id} is not initialized',
|
||||
),
|
||||
super(key: key);
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChannelLastMessageText extends StatelessWidget {
|
||||
ChannelLastMessageText({
|
||||
Key? key,
|
||||
required this.channel,
|
||||
this.textStyle,
|
||||
}) : assert(
|
||||
channel.state != null,
|
||||
'Channel ${channel.id} is not initialized',
|
||||
),
|
||||
super(key: key);
|
||||
|
||||
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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
+369
@@ -0,0 +1,369 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat/stream_chat.dart';
|
||||
import 'package:stream_chat_flutter/src/extension.dart';
|
||||
import 'package:stream_chat_flutter/src/paged_value_notifier.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_controller.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';
|
||||
|
||||
/// Signature for a function that creates a widget for a given index, e.g., in a
|
||||
/// list.
|
||||
///
|
||||
/// Used by [GridView.builder] and other APIs that use lazily-generated widgets.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [WidgetBuilder], which is similar but only takes a [BuildContext].
|
||||
/// * [TransitionBuilder], which is similar but also takes a child.
|
||||
/// * [NullableIndexedWidgetBuilder], which is similar but may return null.
|
||||
typedef StreamChannelListViewItemBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
Channel channel,
|
||||
);
|
||||
|
||||
typedef StreamChannelTapCallback = void Function(Channel);
|
||||
|
||||
Widget _defaultSeparatorBuilder(context, index) =>
|
||||
const _ChannelListSeparator();
|
||||
|
||||
class StreamChannelListView extends StatefulWidget {
|
||||
const StreamChannelListView({
|
||||
Key? key,
|
||||
required this.controller,
|
||||
this.itemBuilder,
|
||||
this.separatorBuilder = _defaultSeparatorBuilder,
|
||||
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);
|
||||
|
||||
final StreamChannelListController controller;
|
||||
|
||||
final StreamChannelListViewItemBuilder? itemBuilder;
|
||||
|
||||
final IndexedWidgetBuilder separatorBuilder;
|
||||
|
||||
/// Called when the user taps this list tile.
|
||||
///
|
||||
/// Inoperative if [enabled] is false.
|
||||
final StreamChannelTapCallback? onChannelTap;
|
||||
|
||||
/// Called when the user long-presses on this list tile.
|
||||
///
|
||||
/// Inoperative if [enabled] is false.
|
||||
final StreamChannelTapCallback? 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 const Center(child: Text('No channels'));
|
||||
}
|
||||
|
||||
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 = value.itemCount - 3;
|
||||
final isBuildingTriggerIndexItem =
|
||||
index == newPageRequestTriggerIndex;
|
||||
if (value.hasNextPage && isBuildingTriggerIndexItem) {
|
||||
// Schedules the request for the end of this frame.
|
||||
WidgetsBinding.instance?.addPostFrameCallback((_) async {
|
||||
if (!value.hasError) {
|
||||
await _controller.loadMore(nextPageKey!);
|
||||
}
|
||||
_hasRequestedNextPage = false;
|
||||
});
|
||||
_hasRequestedNextPage = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (index == channels.length) {
|
||||
if (value.hasError) {
|
||||
return _ChannelListLoadMoreError(
|
||||
onTap: _controller.retry,
|
||||
);
|
||||
}
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: _ChannelListLoadMoreIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final channel = channels[index];
|
||||
final itemBuilder = widget.itemBuilder;
|
||||
if (itemBuilder != null) return itemBuilder(context, channel);
|
||||
|
||||
final onTap = widget.onChannelTap;
|
||||
final onLongPress = widget.onChannelLongPress;
|
||||
|
||||
return StreamChannelListTile(
|
||||
channel: channel,
|
||||
onTap: onTap == null ? null : () => onTap(channel),
|
||||
onLongPress:
|
||||
onLongPress == null ? null : () => onLongPress(channel),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => ListView.separated(
|
||||
padding: widget.padding,
|
||||
physics: widget.physics,
|
||||
reverse: widget.reverse,
|
||||
itemCount: 25,
|
||||
separatorBuilder: widget.separatorBuilder,
|
||||
itemBuilder: (_, __) => const StreamChannelListLoadingTile(),
|
||||
),
|
||||
error: (error) => Center(child: Text('Error: $error')),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _ChannelListLoadMoreIndicator extends StatelessWidget {
|
||||
const _ChannelListLoadMoreIndicator({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
);
|
||||
}
|
||||
|
||||
class _ChannelListLoadMoreError extends StatelessWidget {
|
||||
const _ChannelListLoadMoreError({
|
||||
Key? key,
|
||||
required this.onTap,
|
||||
}) : super(key: key);
|
||||
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChannelListSeparator extends StatelessWidget {
|
||||
const _ChannelListSeparator({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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
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';
|
||||
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.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,93 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.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);
|
||||
|
||||
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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user