chore(ui): initial channel list view draft with controller.

Signed-off-by: xsahil03x <[email protected]>
This commit is contained in:
Sahil Kumar
2021-12-06 14:04:13 +05:30
committed by xsahil03x
parent 7018beee46
commit fcb9e308c7
15 changed files with 1983 additions and 57 deletions
@@ -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'));
}
}
}
@@ -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,
),
);
}
}
@@ -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,
);
},
);
}
@@ -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';
/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/channel_image.png)
/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/channel_image_paint.png)
///
/// It shows the current [Channel] image.
///
/// ```dart
/// class MyApp extends StatelessWidget {
/// final StreamChatClient client;
/// final Channel channel;
///
/// MyApp(this.client, this.channel);
///
/// @override
/// Widget build(BuildContext context) {
/// return MaterialApp(
/// debugShowCheckedModeBanner: false,
/// home: StreamChat(
/// client: client,
/// child: StreamChannel(
/// channel: channel,
/// child: Center(
/// child: ChannelImage(
/// channel: channel,
/// ),
/// ),
/// ),
/// ),
/// );
/// }
/// }
/// ```
///
/// The widget uses a [StreamBuilder] to render the channel information
/// image as soon as it updates.
///
/// By default the widget radius size is 40x40 pixels.
/// Set the property [constraints] to set a custom dimension.
///
/// The widget renders the ui based on the first ancestor of type
/// [StreamChatTheme].
/// Modify it to change the widget appearance.
class StreamChannelAvatar extends StatelessWidget {
/// Instantiate a new ChannelImage
StreamChannelAvatar({
Key? key,
required this.channel,
this.constraints,
this.onTap,
this.borderRadius,
this.selected = false,
this.selectionColor,
this.selectionThickness = 4,
}) : assert(
channel.state != null,
'Channel ${channel.id} is not initialized',
),
super(key: key);
/// [BorderRadius] to display the widget
final BorderRadius? borderRadius;
/// The channel to show the image of
final Channel channel;
/// The diameter of the image
final BoxConstraints? constraints;
/// The function called when the image is tapped
final VoidCallback? onTap;
/// If image is selected
final bool selected;
/// Selection color for image
final Color? selectionColor;
/// Thickness of selection image
final double selectionThickness;
@override
Widget build(BuildContext context) {
final client = channel.client.state;
final chatThemeData = StreamChatTheme.of(context);
final colorTheme = chatThemeData.colorTheme;
final previewTheme = chatThemeData.channelPreviewTheme.avatarTheme;
return BetterStreamBuilder<String>(
stream: channel.imageStream,
initialData: channel.image,
builder: (context, channelImage) {
Widget child = ClipRRect(
borderRadius: borderRadius ?? previewTheme?.borderRadius,
child: Container(
constraints: constraints ?? previewTheme?.constraints,
decoration: BoxDecoration(color: colorTheme.accentPrimary),
child: InkWell(
onTap: onTap,
child: CachedNetworkImage(
imageUrl: channelImage,
errorWidget: (_, __, ___) => Center(
child: Text(
channel.name?[0] ?? '',
style: TextStyle(
color: colorTheme.barsBg,
fontWeight: FontWeight.bold,
),
),
),
fit: BoxFit.cover,
),
),
),
);
if (selected) {
child = ClipRRect(
key: const Key('selectedImage'),
borderRadius: BorderRadius.circular(selectionThickness) +
(borderRadius ??
previewTheme?.borderRadius ??
BorderRadius.zero),
child: Container(
constraints: constraints ?? previewTheme?.constraints,
color: selectionColor ?? colorTheme.accentPrimary,
child: Padding(
padding: EdgeInsets.all(selectionThickness),
child: child,
),
),
);
}
return child;
},
noDataBuilder: (context) {
final currentUser = client.currentUser!;
final otherMembers = channel.state!.members
.where((it) => it.userId != currentUser.id)
.toList(growable: false);
// our own space, no other members
if (otherMembers.isEmpty) {
return BetterStreamBuilder<User>(
stream: client.currentUserStream.map((it) => it!),
initialData: currentUser,
builder: (context, user) => UserAvatar(
borderRadius: borderRadius ?? previewTheme?.borderRadius,
user: user,
constraints: constraints ?? previewTheme?.constraints,
onTap: onTap != null ? (_) => onTap!() : null,
selected: selected,
selectionColor: selectionColor ?? colorTheme.accentPrimary,
selectionThickness: selectionThickness,
),
);
}
// 1-1 Conversation
if (otherMembers.length == 1) {
final member = otherMembers.first;
return BetterStreamBuilder<Member>(
stream: channel.state!.membersStream.map(
(members) => members.firstWhere(
(it) => it.userId == member.userId,
orElse: () => member,
),
),
initialData: member,
builder: (context, member) => UserAvatar(
borderRadius: borderRadius ?? previewTheme?.borderRadius,
user: member.user!,
constraints: constraints ?? previewTheme?.constraints,
onTap: onTap != null ? (_) => onTap!() : null,
selected: selected,
selectionColor: selectionColor ?? colorTheme.accentPrimary,
selectionThickness: selectionThickness,
),
);
}
// Group conversation
return GroupAvatar(
channel: channel,
members: otherMembers,
borderRadius: borderRadius ?? previewTheme?.borderRadius,
constraints: constraints ?? previewTheme?.constraints,
onTap: onTap,
selected: selected,
selectionColor: selectionColor ?? colorTheme.accentPrimary,
selectionThickness: selectionThickness,
);
},
);
}
}
@@ -0,0 +1,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,
);
},
);
}