diff --git a/example/lib/main.dart b/example/lib/main.dart index 2da40b3b..d4855068 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -23,6 +23,11 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( + theme: ThemeData( + appBarTheme: AppBarTheme( + brightness: Brightness.light, + ), + ), home: Container( child: StreamChat( client: client, @@ -75,14 +80,13 @@ class ChannelPage extends StatelessWidget { }, ), Positioned.fill( - child: Align( - alignment: Alignment.bottomRight, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8.0, - vertical: 4, - ), - child: TypingIndicator(), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 4, + ), + child: TypingIndicator( + alignment: Alignment.bottomRight, ), ), ), diff --git a/lib/src/channel_list_view.dart b/lib/src/channel_list_view.dart index fbb30978..329f27a5 100644 --- a/lib/src/channel_list_view.dart +++ b/lib/src/channel_list_view.dart @@ -55,8 +55,11 @@ class ChannelListView extends StatefulWidget { this.onChannelTap, this.channelWidget, this.channelPreviewBuilder, + this.errorBuilder, }) : super(key: key); + final Widget Function(Error error) errorBuilder; + /// The query filters to use. /// You can query on any of the custom fields you've defined on the [Channel]. /// You can also filter other built-in channel fields. @@ -114,16 +117,69 @@ class _ChannelListViewState extends State { child: StreamBuilder>( stream: streamChat.channelsStream, builder: (context, snapshot) { - if (!snapshot.hasData) { + if (snapshot.hasError) { + if (snapshot.error is Error) { + print((snapshot.error as Error).stackTrace); + } + + if (widget.errorBuilder != null) { + return widget.errorBuilder(snapshot.error); + } + + String message = snapshot.error.toString(); + if (snapshot.error is DioError) { + final dioError = snapshot.error as DioError; + if (dioError.type == DioErrorType.RESPONSE) { + message = dioError.message; + } else { + message = 'Check your connection and retry'; + } + } return Center( - child: CircularProgressIndicator(), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text.rich( + TextSpan( + children: [ + WidgetSpan( + child: Padding( + padding: const EdgeInsets.only( + right: 2.0, + ), + child: Icon(Icons.error_outline), + ), + ), + TextSpan(text: 'Error loading channels'), + ], + ), + style: Theme.of(context).textTheme.title, + ), + Padding( + padding: const EdgeInsets.only( + top: 16.0, + ), + child: Text(message), + ), + FlatButton( + onPressed: () { + streamChat.queryChannels( + filter: widget.filter, + sortOptions: widget.sort, + paginationParams: widget.pagination, + options: widget.options, + ); + }, + child: Text('Retry'), + ), + ], + ), ); } - if (snapshot.hasError) { - print((snapshot.error as Error).stackTrace); + if (!snapshot.hasData) { return Center( - child: Text(snapshot.error.toString()), + child: CircularProgressIndicator(), ); } @@ -159,7 +215,7 @@ class _ChannelListViewState extends State { if (i < channels.length) { final channel = channels[i]; - final channelClient = streamChat.client.channels[channel.id]; + final channelClient = streamChat.client.channels[channel.cid]; ChannelTapCallback onTap; if (widget.onChannelTap != null) { diff --git a/lib/src/channel_preview.dart b/lib/src/channel_preview.dart index 5021df5c..2532d0b1 100644 --- a/lib/src/channel_preview.dart +++ b/lib/src/channel_preview.dart @@ -42,11 +42,22 @@ class ChannelPreview extends StatelessWidget { textStyle: StreamChatTheme.of(context).channelPreviewTheme.title, ), subtitle: _buildSubtitle(context), - trailing: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.end, + trailing: Row( + mainAxisSize: MainAxisSize.min, children: [ _buildDate(context), + if (channel.state.unreadCount > 0) + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: CircleAvatar( + backgroundColor: Color(0xffd0021B), + radius: 6, + child: Text( + '${channel.state.unreadCount}', + style: TextStyle(fontSize: 8), + ), + ), + ), ], ), ); @@ -80,20 +91,16 @@ class ChannelPreview extends StatelessWidget { Widget _buildSubtitle(BuildContext context) { final opacity = channel.state.unreadCount > 0 ? 1.0 : 0.5; - return Align( - alignment: Alignment.centerLeft, - child: TypingIndicator( - channel: channel, - alternativeWidget: _buildLastMessage(context, opacity), - style: - StreamChatTheme.of(context).channelPreviewTheme.subtitle.copyWith( - color: StreamChatTheme.of(context) - .channelPreviewTheme - .subtitle - .color - .withOpacity(opacity), - ), - ), + return TypingIndicator( + channel: channel, + alternativeWidget: _buildLastMessage(context, opacity), + style: StreamChatTheme.of(context).channelPreviewTheme.subtitle.copyWith( + color: StreamChatTheme.of(context) + .channelPreviewTheme + .subtitle + .color + .withOpacity(opacity), + ), ); } diff --git a/lib/src/message_input.dart b/lib/src/message_input.dart index fb986abb..2c45d7a2 100644 --- a/lib/src/message_input.dart +++ b/lib/src/message_input.dart @@ -644,7 +644,7 @@ class _MessageInputState extends State { _mentionsOverlay?.remove(); _mentionsOverlay = null; - FocusScope.of(context).unfocus(); + final channel = StreamChannel.of(context).channel; Future sendingFuture; Message message; @@ -659,6 +659,11 @@ class _MessageInputState extends State { mentionedUsers: _mentionedUsers.where((u) => text.contains('@${u.name}')).toList(), ); + + if (widget.editMessage.status == MessageSendingStatus.FAILED) { + sendingFuture = channel.sendMessage(message); + } + sendingFuture = StreamChat.of(context).client.updateMessage(message); } else { message = Message( @@ -668,19 +673,13 @@ class _MessageInputState extends State { mentionedUsers: _mentionedUsers.where((u) => text.contains('@${u.name}')).toList(), ); - sendingFuture = StreamChannel.of(context).channel.sendMessage(message); + sendingFuture = channel.sendMessage(message); } - sendingFuture.then((_) { + sendingFuture.whenComplete(() { if (widget.onMessageSent != null) { widget.onMessageSent(message); } - }).catchError((error) { - Scaffold.of(context).showSnackBar( - SnackBar( - content: Text(error.toString()), - ), - ); }); } diff --git a/lib/src/message_list_view.dart b/lib/src/message_list_view.dart index 8a8668fb..73190f8c 100644 --- a/lib/src/message_list_view.dart +++ b/lib/src/message_list_view.dart @@ -318,7 +318,11 @@ class _MessageListViewState extends State { Stream> stream; if (widget.parentMessage == null) { - stream = streamChannel.channel.state.messagesStream; + stream = streamChannel.channel.state.messagesStream.map((messages) => + messages + .where((m) => !(m.status == MessageSendingStatus.FAILED && + m.type == 'deleted')) + .toList()); } else { streamChannel.getReplies(widget.parentMessage.id); stream = streamChannel.channel.state.threadsStream @@ -333,6 +337,9 @@ class _MessageListViewState extends State { newMessages.first.id != _messages.first.id) { if (!_scrollController.hasClients || _scrollController.offset < _newMessageLoadingOffset) { + if (streamChannel.channel.state.unreadCount > 0) { + streamChannel.channel.markRead(); + } setState(() { _messages = newMessages; }); diff --git a/lib/src/message_widget.dart b/lib/src/message_widget.dart index 0fff2959..3a46772f 100644 --- a/lib/src/message_widget.dart +++ b/lib/src/message_widget.dart @@ -122,7 +122,58 @@ class _MessageWidgetState extends State left: isMyMessage ? 8.0 : 0, right: isMyMessage ? 0 : 8.0, ), - child: UserAvatar(user: widget.message.user), + child: Row( + children: [ + UserAvatar(user: widget.message.user), + if (isMyMessage && + widget.nextMessage == null && + widget.message.status == MessageSendingStatus.SENT) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 1.0, + ), + child: CircleAvatar( + radius: 4, + child: Icon( + Icons.done, + size: 4, + ), + ), + ), + if (isMyMessage && + widget.message.status == MessageSendingStatus.SENDING) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 1.0, + ), + child: CircleAvatar( + radius: 4, + backgroundColor: Colors.grey, + child: Icon( + Icons.access_time, + size: 4, + color: Colors.white, + ), + ), + ), + if (isMyMessage && + widget.message.status == MessageSendingStatus.FAILED) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 1.0, + ), + child: CircleAvatar( + radius: 4, + backgroundColor: Color(0xffd0021B).withAlpha(125), + child: Icon( + Icons.error_outline, + size: 4, + color: Colors.white, + ), + ), + ), + ], + ), ), ]; @@ -131,7 +182,8 @@ class _MessageWidgetState extends State } child = Container( - padding: const EdgeInsets.symmetric(horizontal: 10.0), + padding: EdgeInsets.symmetric( + horizontal: (isMyMessage && widget.nextMessage == null) ? 0.0 : 10), margin: EdgeInsets.only( top: isLastUser ? 5 : 24, bottom: widget.nextMessage == null ? 30 : 0, @@ -250,41 +302,52 @@ class _MessageWidgetState extends State children: [ attachmentWidget, attachment.title != null - ? Container( - constraints: - BoxConstraints.loose(Size(300, 500)), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - attachment.title, - overflow: TextOverflow.ellipsis, - style: messageTheme.messageText - .copyWith( - color: Colors.blue, - fontWeight: FontWeight.bold, + ? GestureDetector( + onTap: () { + if (attachment.titleLink != null) { + _launchURL(attachment.titleLink); + } + }, + child: Container( + constraints: + BoxConstraints.loose(Size(300, 500)), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + attachment.title, + overflow: TextOverflow.ellipsis, + style: messageTheme.messageText + .copyWith( + color: Colors.blue, + fontWeight: FontWeight.bold, + ), ), - ), - Text( - Uri.parse(attachment.thumbUrl) - .authority - .split('.') - .reversed - .take(2) - .toList() - .reversed - .join('.'), - overflow: TextOverflow.ellipsis, - style: messageTheme.createdAt, - ), - ], + if (attachment.titleLink != null || + attachment.ogScrapeUrl != null) + Text( + Uri.parse(attachment + .titleLink ?? + attachment.ogScrapeUrl) + .authority + .split('.') + .reversed + .take(2) + .toList() + .reversed + .join('.'), + overflow: TextOverflow.ellipsis, + style: messageTheme.createdAt, + ), + ], + ), ), + color: Color(0xffebebeb), ), - color: Color(0xffebebeb), ) : SizedBox(), ], @@ -399,42 +462,58 @@ class _MessageWidgetState extends State isLastUser || nOfAttachmentWidgets > 0), padding: EdgeInsets.all(10), constraints: BoxConstraints.loose(Size.fromWidth(300)), - child: MarkdownBody( - data: text, - onTapLink: (link) { - if (link.startsWith('@')) { - final mentionedUser = - widget.message.mentionedUsers.firstWhere( - (u) => '@${u.name.replaceAll(' ', '')}' == link, - orElse: () => null, - ); + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.message.status == + MessageSendingStatus.FAILED) + Text( + 'MESSAGE FAILED ยท CLICK TO TRY AGAIN', + style: TextStyle( + color: Colors.black.withOpacity(.5), + fontSize: 11, + ), + ), + MarkdownBody( + data: text, + onTapLink: (link) { + if (link.startsWith('@')) { + final mentionedUser = + widget.message.mentionedUsers.firstWhere( + (u) => + '@${u.name.replaceAll(' ', '')}' == link, + orElse: () => null, + ); - if (widget.onMentionTap != null) { - widget.onMentionTap(mentionedUser); - } else { - print('tap on ${mentionedUser.name}'); - } - } else { - _launchURL(link); - } - }, - styleSheet: MarkdownStyleSheet.fromTheme( - Theme.of(context).copyWith( - textTheme: Theme.of(context).textTheme.apply( - bodyColor: messageTheme.messageText.color, - decoration: - messageTheme.messageText.decoration, - decorationColor: - messageTheme.messageText.decorationColor, - decorationStyle: - messageTheme.messageText.decorationStyle, - fontFamily: - messageTheme.messageText.fontFamily, - ), + if (widget.onMentionTap != null) { + widget.onMentionTap(mentionedUser); + } else { + print('tap on ${mentionedUser.name}'); + } + } else { + _launchURL(link); + } + }, + styleSheet: MarkdownStyleSheet.fromTheme( + Theme.of(context).copyWith( + textTheme: Theme.of(context).textTheme.apply( + bodyColor: messageTheme.messageText.color, + decoration: + messageTheme.messageText.decoration, + decorationColor: messageTheme + .messageText.decorationColor, + decorationStyle: messageTheme + .messageText.decorationStyle, + fontFamily: + messageTheme.messageText.fontFamily, + ), + ), + ).copyWith( + p: messageTheme.messageText, + ), ), - ).copyWith( - p: messageTheme.messageText, - ), + ], ), ), ), @@ -474,8 +553,15 @@ class _MessageWidgetState extends State return GestureDetector( child: IntrinsicWidth(child: column), + onTap: () { + if (widget.message.status == MessageSendingStatus.FAILED) { + StreamChannel.of(context).channel.sendMessage(widget.message); + return; + } + }, onLongPress: () { - if (widget.message.type == 'ephemeral') { + if (widget.message.type == 'ephemeral' || + widget.message.status == MessageSendingStatus.SENDING) { return; } @@ -506,6 +592,8 @@ class _MessageWidgetState extends State return; } + final theme = Theme.of(context); + showModalBottomSheet( clipBehavior: Clip.hardEdge, shape: RoundedRectangleBorder( @@ -523,7 +611,8 @@ class _MessageWidgetState extends State children: [ Container( color: Colors.black87, - child: streamChannel.channel.config.reactions + child: (streamChannel.channel.config.reactions && + widget.message.status != MessageSendingStatus.FAILED) ? ReactionPicker( channel: StreamChannel.of(context).channel, reactionToEmoji: reactionToEmoji, @@ -537,17 +626,16 @@ class _MessageWidgetState extends State padding: const EdgeInsets.all(28.0), child: Text( 'Delete message', - style: Theme.of(context) - .textTheme - .headline + style: theme.textTheme.headline .copyWith(color: Colors.red), ), ), onPressed: () { - StreamChat.of(context) - .client - .deleteMessage(widget.message.id); Navigator.pop(context); + StreamChat.of(context).client.deleteMessage( + widget.message, + streamChannel.channel.cid, + ); }, ) : SizedBox(), @@ -557,7 +645,7 @@ class _MessageWidgetState extends State padding: const EdgeInsets.all(28.0), child: Text( 'Edit message', - style: Theme.of(context).textTheme.headline, + style: theme.textTheme.headline, ), ), onPressed: () async { @@ -568,6 +656,7 @@ class _MessageWidgetState extends State ) : SizedBox(), (streamChannel.channel.config.replies && + widget.message.status != MessageSendingStatus.FAILED && widget.message.parentId == null && !widget.isParent) ? FlatButton( @@ -575,7 +664,7 @@ class _MessageWidgetState extends State padding: const EdgeInsets.all(28.0), child: Text( 'Start a thread', - style: Theme.of(context).textTheme.headline, + style: theme.textTheme.headline, ), ), onPressed: () { @@ -667,6 +756,7 @@ class _MessageWidgetState extends State message.id == widget.message.parentId) : null, onMessageSent: (_) { + FocusScope.of(context).unfocus(); Navigator.pop(context); }, ), @@ -698,15 +788,14 @@ class _MessageWidgetState extends State decoration: BoxDecoration( color: Colors.black, borderRadius: BorderRadius.all(Radius.circular(14))), - child: (widget.message.reactionCounts != null && - widget.message.reactionCounts.isNotEmpty) - ? AnimatedSize( - duration: Duration(milliseconds: 300), - vsync: this, - curve: Curves.easeInQuad, - child: _buildReactionRow(), - ) - : SizedBox(), + child: AnimatedSwitcher( + duration: Duration(milliseconds: 300), + reverseDuration: Duration(milliseconds: 0), + child: (widget.message.reactionCounts != null && + widget.message.reactionCounts.isNotEmpty) + ? _buildReactionRow() + : SizedBox(), + ), ), ), ); @@ -751,6 +840,19 @@ class _MessageWidgetState extends State return CachedNetworkImage( imageUrl: attachment.thumbUrl ?? attachment.imageUrl ?? attachment.assetUrl, + errorWidget: (context, url, error) { + return Container( + width: 200, + height: 140, + color: Color(0xffd0021B).withAlpha(26), + child: Center( + child: Icon( + Icons.error_outline, + color: Colors.white, + ), + ), + ); + }, fit: BoxFit.cover, ); } @@ -829,10 +931,12 @@ class _MessageWidgetState extends State Widget _buildTimestamp(Alignment alignment) { return Padding( padding: const EdgeInsets.only(top: 5.0), - child: Text( - Jiffy(widget.message.createdAt.toLocal()).format('HH:mm'), - style: messageTheme.createdAt, - ), + child: widget.message.createdAt != null + ? Text( + Jiffy(widget.message.createdAt.toLocal()).format('HH:mm'), + style: messageTheme.createdAt, + ) + : SizedBox(), ); } @@ -845,7 +949,9 @@ class _MessageWidgetState extends State topRight: Radius.circular((isMyMessage && isLastUser) ? 2 : 16), bottomRight: Radius.circular(isMyMessage ? 2 : 16), ), - color: messageTheme.messageBackgroundColor, + color: widget.message.status == MessageSendingStatus.FAILED + ? Color(0xffd0021B).withAlpha(26) + : messageTheme.messageBackgroundColor, ); } diff --git a/lib/src/reaction_picker.dart b/lib/src/reaction_picker.dart index c5186a93..eed494a6 100644 --- a/lib/src/reaction_picker.dart +++ b/lib/src/reaction_picker.dart @@ -30,7 +30,8 @@ class ReactionPicker extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: reactionToEmoji.keys.map((reactionType) { final ownReactionIndex = message.ownReactions - .indexWhere((reaction) => reaction.type == reactionType); + ?.indexWhere((reaction) => reaction.type == reactionType) ?? + -1; return Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.start, diff --git a/lib/src/stream_chat.dart b/lib/src/stream_chat.dart index 58dfab14..ecdcda0e 100644 --- a/lib/src/stream_chat.dart +++ b/lib/src/stream_chat.dart @@ -226,9 +226,10 @@ class StreamChatState extends State { ); channels.addAll(res); _channelsController.sink.add(channels); - _queryChannelsLoadingController.sink.add(false); } catch (e) { _channelsController.sink.addError(e); + } finally { + _queryChannelsLoadingController.sink.add(false); } } diff --git a/lib/src/typing_indicator.dart b/lib/src/typing_indicator.dart index 6df9db98..a759ecde 100644 --- a/lib/src/typing_indicator.dart +++ b/lib/src/typing_indicator.dart @@ -9,6 +9,7 @@ class TypingIndicator extends StatelessWidget { this.channel, this.alternativeWidget = const SizedBox(), this.style, + this.alignment = Alignment.centerLeft, }) : super(key: key); /// Style of the text widget @@ -20,6 +21,8 @@ class TypingIndicator extends StatelessWidget { /// Widget built when no typings is happening final Widget alternativeWidget; + final Alignment alignment; + @override Widget build(BuildContext context) { final channelState = @@ -31,13 +34,21 @@ class TypingIndicator extends StatelessWidget { return AnimatedSwitcher( duration: Duration(milliseconds: 300), child: snapshot.data.isNotEmpty - ? Text( - '${snapshot.data.map((u) => u.name).join(',')} ${snapshot.data.length == 1 ? 'is' : 'are'} typing...', - maxLines: 1, - style: style, + ? Align( + key: Key('typings'), + alignment: alignment, + child: Text( + '${snapshot.data.map((u) => u.name).join(',')} ${snapshot.data.length == 1 ? 'is' : 'are'} typing...', + maxLines: 1, + style: style, + ), ) - : Container( - child: alternativeWidget, + : Align( + key: Key('alternative'), + alignment: alignment, + child: Container( + child: alternativeWidget, + ), ), ); }, diff --git a/pubspec.yaml b/pubspec.yaml index 406be90e..0317f746 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,7 +20,8 @@ dependencies: file_picker: ^1.4.3+2 image_picker: ^0.6.3+4 keyboard_visibility: ^0.5.6 - stream_chat: ^0.1.17 + stream_chat: + path: ../stream_chat_dart dev_dependencies: pedantic: ^1.9.0