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
@@ -62,29 +62,52 @@ class MyApp extends StatelessWidget {
client: client,
child: child,
),
home: const ChannelListPage(),
home: ChannelListPage(
client: client,
),
);
}
}
class ChannelListPage extends StatelessWidget {
const ChannelListPage({
class ChannelListPage extends StatefulWidget {
ChannelListPage({
Key? key,
required this.client,
}) : super(key: key);
final StreamChatClient client;
@override
State<ChannelListPage> createState() => _ChannelListPageState();
}
class _ChannelListPageState extends State<ChannelListPage> {
late final _controller = StreamChannelListController(
client: widget.client,
filter: Filter.in_(
'members',
[StreamChat.of(context).currentUser!.id],
),
sort: const [SortOption('last_message_at')],
);
@override
// ignore: prefer_expression_function_bodies
Widget build(BuildContext context) {
return Scaffold(
body: ChannelsBloc(
child: ChannelListView(
filter: Filter.in_(
'members',
[StreamChat.of(context).currentUser!.id],
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(),
),
),
);
@@ -94,11 +94,12 @@ class ChannelInfo extends StatelessWidget {
return alternativeWidget ?? const Offstage();
}
return TypingIndicator(
parentId: parentId,
alignment: Alignment.center,
alternativeWidget: alternativeWidget,
style: textStyle,
return Align(
child: TypingIndicator(
parentId: parentId,
style: textStyle,
alternativeWidget: alternativeWidget,
),
);
}
@@ -6,6 +6,7 @@ class GroupAvatar extends StatelessWidget {
/// Constructor for creating a [GroupAvatar]
const GroupAvatar({
Key? key,
this.channel,
required this.members,
this.constraints,
this.onTap,
@@ -15,6 +16,8 @@ class GroupAvatar extends StatelessWidget {
this.selectionThickness = 4,
}) : super(key: key);
final Channel? channel;
/// List of images to display
final List<Member> members;
@@ -38,7 +41,7 @@ class GroupAvatar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final channel = StreamChannel.of(context).channel;
final channel = this.channel ?? StreamChannel.of(context).channel;
assert(channel.state != null, 'Channel ${channel.id} is not initialized');
@@ -6,7 +6,7 @@ class OptionListTile extends StatelessWidget {
/// Constructor for creating [OptionListTile]
const OptionListTile({
Key? key,
this.title,
required this.title,
this.leading,
this.trailing,
this.onTap,
@@ -17,7 +17,7 @@ class OptionListTile extends StatelessWidget {
}) : super(key: key);
/// Title for tile
final String? title;
final String title;
/// Leading widget (start)
final Widget? leading;
@@ -46,8 +46,8 @@ class OptionListTile extends StatelessWidget {
return Column(
children: [
Container(
color: separatorColor ?? chatThemeData.colorTheme.disabled,
height: 1,
color: separatorColor ?? chatThemeData.colorTheme.disabled,
),
Material(
color: tileColor ?? chatThemeData.colorTheme.barsBg,
@@ -57,15 +57,14 @@ class OptionListTile extends StatelessWidget {
onTap: onTap,
child: Row(
children: [
if (leading != null) Center(child: leading),
if (leading == null)
const SizedBox(
width: 16,
),
if (leading != null)
Center(child: leading)
else
const SizedBox(width: 16),
Expanded(
flex: 4,
child: Text(
title!,
title,
style: titleTextStyle ??
(titleColor == null
? chatThemeData.textTheme.bodyBold
@@ -0,0 +1,92 @@
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';
const defaultInitialPagedLimitMultiplier = 3;
typedef PagedValueListenableBuilder<Key, Value>
= ValueListenableBuilder<PagedValue<Key, Value>>;
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;
/// 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() {
var lastValue = value;
assert(lastValue.hasError, '');
lastValue = lastValue as Success<Key, Value>;
final nextPageKey = lastValue.nextPageKey;
// resetting the error
value = lastValue.copyWith(error: null);
return loadMore(nextPageKey!);
}
/// Refresh the data presented by this [PagedValueNotifier].
///
/// Note: This API is intended for UI-driven refresh signals,
/// such as swipe-to-refresh.
Future<void> refresh() {
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);
}
@freezed
abstract class PagedValue<Key, Value> with _$PagedValue<Key, Value> {
const PagedValue._();
/// Creates a new instance of [PagedValue] with the given [key] and [value].
// @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>;
bool get hasNextPage {
assert(this is Success<Key, Value>, '');
return (this as Success<Key, Value>).nextPageKey != null;
}
bool get hasError {
assert(this is Success<Key, Value>, '');
return (this as Success<Key, Value>).error != null;
}
int get itemCount {
assert(this is Success<Key, Value>, '');
final count = (this as Success<Key, Value>).items.length;
if (hasNextPage || hasError) return count + 1;
return count;
}
const factory PagedValue.loading() = Loading;
const factory PagedValue.error(StreamChatError error) = Error;
}
@@ -0,0 +1,590 @@
// 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 is Success<Key, Value> &&
(identical(other.items, items) ||
const DeepCollectionEquality().equals(other.items, items)) &&
(identical(other.nextPageKey, nextPageKey) ||
const DeepCollectionEquality()
.equals(other.nextPageKey, nextPageKey)) &&
(identical(other.error, error) ||
const DeepCollectionEquality().equals(other.error, error)));
}
@override
int get hashCode =>
runtimeType.hashCode ^
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 => throw _privateConstructorUsedError;
/// The key for the next page to be fetched.
Key? get nextPageKey => throw _privateConstructorUsedError;
/// The current error, if any.
StreamChatError? get error => throw _privateConstructorUsedError;
@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 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 is Error<Key, Value> &&
(identical(other.error, error) ||
const DeepCollectionEquality().equals(other.error, error)));
}
@override
int get hashCode =>
runtimeType.hashCode ^ 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 => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$ErrorCopyWith<Key, Value, Error<Key, Value>> get copyWith =>
throw _privateConstructorUsedError;
}
@@ -163,12 +163,13 @@ class ThreadHeader extends StatelessWidget implements PreferredSizeWidget {
),
const SizedBox(height: 2),
if (showTypingIndicator)
TypingIndicator(
alignment: Alignment.center,
channel: StreamChannel.of(context).channel,
style: channelHeaderTheme.subtitleStyle,
parentId: parent.id,
alternativeWidget: defaultSubtitle,
Align(
child: TypingIndicator(
channel: StreamChannel.of(context).channel,
style: channelHeaderTheme.subtitleStyle,
parentId: parent.id,
alternativeWidget: defaultSubtitle,
),
)
else
defaultSubtitle,
@@ -11,7 +11,6 @@ class TypingIndicator extends StatelessWidget {
this.channel,
this.alternativeWidget,
this.style,
this.alignment = Alignment.centerLeft,
this.padding = const EdgeInsets.all(0),
this.parentId,
}) : super(key: key);
@@ -28,9 +27,6 @@ class TypingIndicator extends StatelessWidget {
/// The padding of this widget
final EdgeInsets padding;
/// Alignment of the typing indicator
final Alignment alignment;
/// Id of the parent message in case of a thread
final String? parentId;
@@ -46,30 +42,25 @@ class TypingIndicator extends StatelessWidget {
stream: channelState.typingEventsStream.map((typings) => typings.entries
.where((element) => element.value.parentId == parentId)
.map((e) => e.key)),
builder: (context, data) => AnimatedSwitcher(
builder: (context, users) => AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: data.isNotEmpty
child: users.isNotEmpty
? Padding(
key: const Key('main'),
padding: padding,
child: Align(
key: const Key('typings'),
alignment: alignment,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Lottie.asset(
'animations/typing_dots.json',
package: 'stream_chat_flutter',
height: 4,
),
Text(
context.translations.userTypingText(data),
maxLines: 1,
style: style,
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Lottie.asset(
'animations/typing_dots.json',
package: 'stream_chat_flutter',
height: 4,
),
Text(
context.translations.userTypingText(users),
maxLines: 1,
style: style,
),
],
),
)
: altWidget,
@@ -0,0 +1,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,
);
},
);
}
@@ -48,3 +48,5 @@ export 'src/user_list_view.dart';
export 'src/user_mention_tile.dart';
export 'src/utils.dart';
export 'src/visible_footnote.dart';
export 'src/v4/channel_list_view/stream_channel_list_view.dart';
export 'src/v4/channel_list_view/stream_channel_list_controller.dart';