diff --git a/example/lib/main.dart b/example/lib/main.dart index 667b1e0f..ca727cf7 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -98,7 +98,9 @@ class MyApp extends StatelessWidget { return MaterialApp( theme: ThemeData.light(), darkTheme: ThemeData.dark(), - themeMode: ThemeMode.system, + themeMode: ThemeMode.light, + + ///TODO change to system once dark theme is implemented builder: (context, widget) { return StreamChat( child: widget, diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 97971445..b7f2709c 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,6 +1,7 @@ name: example description: A new Flutter project. -version: 1.0.40+42 + +version: 1.0.41+43 environment: sdk: ">=2.2.2 <3.0.0" diff --git a/lib/src/giphy_attachment.dart b/lib/src/giphy_attachment.dart index f913b557..bfa21239 100644 --- a/lib/src/giphy_attachment.dart +++ b/lib/src/giphy_attachment.dart @@ -41,195 +41,211 @@ class GiphyAttachment extends StatelessWidget { return Column( mainAxisSize: MainAxisSize.min, children: [ - Card( - clipBehavior: Clip.antiAlias, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topRight: Radius.circular(16.0), - bottomRight: Radius.circular(0.0), - topLeft: Radius.circular(16.0), - bottomLeft: Radius.circular(16.0), + DecoratedBox( + decoration: BoxDecoration(), + child: Card( + elevation: 2, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topRight: Radius.circular(16.0), + bottomRight: Radius.circular(0.0), + topLeft: Radius.circular(16.0), + bottomLeft: Radius.circular(16.0), + ), ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: GestureDetector( - onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (_) { - return FullScreenImage( - url: attachment.imageUrl ?? - attachment.assetUrl ?? - attachment.thumbUrl, - ); - })); - }, - child: CachedNetworkImage( - height: size?.height, - width: size?.width, - placeholder: (_, __) { - return Container( - width: size?.width, - height: size?.height, - child: Center( - child: CircularProgressIndicator(), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Stack( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: GestureDetector( + onTap: () { + Navigator.push(context, + MaterialPageRoute(builder: (_) { + return FullScreenImage( + url: attachment.imageUrl ?? + attachment.assetUrl ?? + attachment.thumbUrl, + ); + })); + }, + child: CachedNetworkImage( + height: size?.height, + width: size?.width, + placeholder: (_, __) { + return Container( + width: size?.width, + height: size?.height, + child: Center( + child: CircularProgressIndicator(), + ), + ); + }, + imageUrl: attachment.thumbUrl ?? + attachment.imageUrl ?? + attachment.assetUrl, + errorWidget: (context, url, error) => AttachmentError( + attachment: attachment, + size: size, + ), + fit: BoxFit.cover, ), - ); - }, - imageUrl: attachment.thumbUrl ?? - attachment.imageUrl ?? - attachment.assetUrl, - errorWidget: (context, url, error) => AttachmentError( - attachment: attachment, - size: size, + ), ), - fit: BoxFit.cover, - ), - ), - ), - Positioned( - left: 0, - top: 0, - child: Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.only( - bottomRight: Radius.circular(16.0), - )), - child: Padding( - padding: const EdgeInsets.only( - left: 8.0, - right: 8.0, - top: 8.0, - bottom: 4.0, - ), - child: Row( - children: [ - Icon( - StreamIcons.lightning, - color: StreamChatTheme.of(context).accentColor, - size: 16.0, - ), - Text( - 'GIPHY', - style: TextStyle( - color: StreamChatTheme.of(context).accentColor, - fontWeight: FontWeight.bold, - fontSize: 11.0, + Positioned( + left: 0, + top: 0, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only( + bottomRight: Radius.circular(16.0), + )), + child: Padding( + padding: const EdgeInsets.only( + left: 8.0, + right: 8.0, + top: 8.0, + bottom: 4.0, + ), + child: Row( + children: [ + Icon( + StreamIcons.lightning, + color: StreamChatTheme.of(context).accentColor, + size: 16.0, + ), + Text( + 'GIPHY', + style: TextStyle( + color: + StreamChatTheme.of(context).accentColor, + fontWeight: FontWeight.bold, + fontSize: 11.0, + ), + ), + ], ), ), - ], + ), ), - ), + ], ), - ), - if (attachment.title != null) - Container( - alignment: Alignment.bottomCenter, - child: Material( - color: Colors.white, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Card( - child: IconButton( - icon: Icon( - StreamIcons.left, - size: 24.0, + if (attachment.title != null) + Container( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Card( + elevation: 2, + child: IconButton( + padding: const EdgeInsets.all(0), + constraints: BoxConstraints.tight(Size(32, 32)), + icon: Icon( + StreamIcons.left, + size: 24.0, + ), + splashRadius: 16, + onPressed: () { + streamChannel.channel.sendAction(message, { + 'image_action': 'shuffle', + }); + }, ), - splashRadius: 24, - onPressed: () { - streamChannel.channel.sendAction(message, { - 'image_action': 'shuffle', - }); - }, + shape: CircleBorder(), ), - shape: CircleBorder(), - ), - Expanded( - child: Center( - child: Text( - '"${attachment.title}"', - style: TextStyle( - fontStyle: FontStyle.italic, + Expanded( + child: Center( + child: Text( + '"${attachment.title}"', + style: TextStyle( + fontStyle: FontStyle.italic, + ), ), ), ), - ), - Card( - child: IconButton( - icon: Icon( - StreamIcons.right, - size: 24.0, + Card( + elevation: 2, + child: IconButton( + padding: const EdgeInsets.all(0), + constraints: BoxConstraints.tight(Size(32, 32)), + icon: Icon( + StreamIcons.right, + size: 24.0, + ), + splashRadius: 16, + onPressed: () { + streamChannel.channel.sendAction(message, { + 'image_action': 'shuffle', + }); + }, ), - splashRadius: 24, - onPressed: () { - streamChannel.channel.sendAction(message, { - 'image_action': 'shuffle', - }); - }, + shape: CircleBorder(), ), - shape: CircleBorder(), - ), - ], + ], + ), ), ), + SizedBox( + height: 4.0, ), - SizedBox( - height: 4.0, - ), - Container( - color: Colors.black.withOpacity(0.2), - width: double.infinity, - height: 0.5, - ), - Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: FlatButton( - height: 50, - onPressed: () { - streamChannel.channel.sendAction(message, { - 'image_action': 'cancel', - }); - }, - child: Text( - 'Cancel', - style: TextStyle( - fontWeight: FontWeight.bold, - color: Colors.black.withOpacity(0.5)), + Container( + color: Colors.black.withOpacity(0.2), + width: double.infinity, + height: 0.5, + ), + Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: FlatButton( + height: 50, + onPressed: () { + streamChannel.channel.sendAction(message, { + 'image_action': 'cancel', + }); + }, + child: Text( + 'Cancel', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.black.withOpacity(0.5)), + ), ), ), - ), - Container( - width: 0.5, - color: Colors.black.withOpacity(0.2), - height: 50.0, - ), - Expanded( - child: FlatButton( - height: 50, - onPressed: () { - streamChannel.channel.sendAction(message, { - 'image_action': 'send', - }); - }, - child: Text( - 'Send', - style: TextStyle( - color: StreamChatTheme.of(context).accentColor, - fontWeight: FontWeight.bold), + Container( + width: 0.5, + color: Colors.black.withOpacity(0.2), + height: 50.0, + ), + Expanded( + child: FlatButton( + height: 50, + onPressed: () { + streamChannel.channel.sendAction(message, { + 'image_action': 'send', + }); + }, + child: Text( + 'Send', + style: TextStyle( + color: StreamChatTheme.of(context).accentColor, + fontWeight: FontWeight.bold), + ), ), ), - ), - ], - ), - ], + ], + ), + ], + ), ), ), SizedBox( @@ -313,7 +329,10 @@ class GiphyAttachment extends StatelessWidget { ), ), Padding( - padding: const EdgeInsets.only(top: 8.0), + padding: const EdgeInsets.only( + top: 8.0, + bottom: 8, + ), child: Row( children: [ Row( diff --git a/lib/src/message_input.dart b/lib/src/message_input.dart index 7ef2ebd6..89891ace 100644 --- a/lib/src/message_input.dart +++ b/lib/src/message_input.dart @@ -7,6 +7,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:http_parser/http_parser.dart'; import 'package:image_picker/image_picker.dart'; import 'package:mime/mime.dart'; @@ -223,22 +224,66 @@ class MessageInputState extends State { } Widget _buildDmCheckbox() { - return Row( - children: [ - Checkbox( - value: _sendAsDm, - onChanged: (val) => setState( - () { - _sendAsDm = val; - }, + return Container( + height: 36, + padding: const EdgeInsets.only( + left: 12, + bottom: 12, + top: 8, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + height: 16, + width: 16, + foregroundDecoration: BoxDecoration( + border: _sendAsDm + ? null + : Border.all( + color: Colors.black.withOpacity(.5), + width: 2, + ), + borderRadius: BorderRadius.circular(3), + ), + child: Center( + child: Material( + borderRadius: BorderRadius.circular(3), + color: _sendAsDm + ? StreamChatTheme.of(context).accentColor + : Colors.white, + child: InkWell( + onTap: () { + setState(() { + _sendAsDm = !_sendAsDm; + }); + }, + child: AnimatedCrossFade( + duration: Duration(milliseconds: 300), + reverseDuration: Duration(milliseconds: 300), + crossFadeState: _sendAsDm + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: Icon( + StreamIcons.check, + size: 16.0, + color: Colors.white, + ), + secondChild: SizedBox( + height: 16, + width: 16, + ), + ), + ), + ), + ), ), - activeColor: StreamChatTheme.of(context).accentColor, - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Text('Send also as direct message'), - ), - ], + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text('Send also as direct message'), + ), + ], + ), ); } @@ -506,25 +551,19 @@ class MessageInputState extends State { child: Icon(StreamIcons.lightning, color: StreamChatTheme.of(context).accentColor), ), - Text('Instant Commands') + Text( + 'Instant Commands', + style: TextStyle( + color: Colors.black.withOpacity(.5), + ), + ) ], ), ), ...commands .map( (c) => ListTile( - leading: c.name == 'giphy' - ? CircleAvatar( - backgroundColor: Colors.black, - child: Image.asset( - 'images/giphy_icon.png', - package: 'stream_chat_flutter', - width: 16.0, - height: 16.0, - ), - maxRadius: 12.0, - ) - : null, + leading: c.name == 'giphy' ? _buildGiphyIcon() : null, title: Text.rich( TextSpan( text: '${c.name.capitalize()}', @@ -565,6 +604,31 @@ class MessageInputState extends State { }); } + CircleAvatar _buildGiphyIcon() { + if (kIsWeb) { + return CircleAvatar( + backgroundColor: Colors.black, + child: Image.asset( + 'images/giphy_icon.png', + package: 'stream_chat_flutter', + width: 24.0, + height: 24.0, + ), + radius: 12, + ); + } else { + return CircleAvatar( + child: SvgPicture.asset( + 'svgs/giphy_icon.svg', + package: 'stream_chat_flutter', + width: 24.0, + height: 24.0, + ), + radius: 12, + ); + } + } + OverlayEntry _buildMentionsOverlayEntry() { final splits = textEditingController.text .substring(0, textEditingController.value.selection.start) @@ -605,13 +669,6 @@ class MessageInputState extends State { child: Container( constraints: BoxConstraints.loose(Size.fromHeight(400)), decoration: BoxDecoration( - boxShadow: [ - BoxShadow( - spreadRadius: -8, - blurRadius: 5.0, - offset: Offset(0, -4), - ), - ], color: StreamChatTheme.of(context).primaryColor, ), child: FutureBuilder>( @@ -624,6 +681,12 @@ class MessageInputState extends State { children: snapshot.data .map((m) => ListTile( leading: UserAvatar( + constraints: BoxConstraints.tight( + Size( + 40, + 40, + ), + ), user: m.user, ), title: Text( diff --git a/lib/src/message_list_view.dart b/lib/src/message_list_view.dart index bb0f98c0..946ad66f 100644 --- a/lib/src/message_list_view.dart +++ b/lib/src/message_list_view.dart @@ -1,12 +1,13 @@ import 'dart:async'; +import 'dart:math'; import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat_flutter/src/message_widget.dart'; import 'package:stream_chat_flutter/src/system_message.dart'; import 'package:visibility_detector/visibility_detector.dart'; -import 'package:widgets_visibility_provider/widgets_visibility_provider.dart'; import '../stream_chat_flutter.dart'; import 'date_divider.dart'; @@ -106,7 +107,10 @@ class MessageListView extends StatefulWidget { this.onThreadTap, this.dateDividerBuilder, this.scrollPhysics = const AlwaysScrollableScrollPhysics(), - this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, + this.initialScrollIndex = 0, + this.initialAlignment = 0, + this.scrollController, + this.itemPositionListener, }) : super(key: key); /// Function used to build a custom message widget @@ -131,95 +135,101 @@ class MessageListView extends StatefulWidget { /// Builder used to render date dividers final Widget Function(DateTime) dateDividerBuilder; + /// Index of an item to initially align within the viewport. + final int initialScrollIndex; + + /// Determines where the leading edge of the item at [initialScrollIndex] + /// should be placed. + final double initialAlignment; + + /// Controller for jumping or scrolling to an item. + final ItemScrollController scrollController; + + /// Provides a listenable iterable of [itemPositions] of items that are on + /// screen and their locations. + final ItemPositionsListener itemPositionListener; + /// The ScrollPhysics used by the ListView final ScrollPhysics scrollPhysics; - /// The [ScrollViewKeyboardDismissBehavior] used by the ListView - final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior; - @override _MessageListViewState createState() => _MessageListViewState(); } class _MessageListViewState extends State { - static const _newMessageLoadingOffset = 100; - final ScrollController _scrollController = ScrollController(); - bool _bottomWasVisible = true; + ItemScrollController _scrollController; + bool _bottomWasVisible = false; bool _topWasVisible = false; - List _messages = []; - List _newMessageList = []; Function _onThreadTap; bool _showScrollToBottom = false; + ItemPositionsListener _itemPositionListener; @override Widget build(BuildContext context) { final streamChannel = StreamChannel.of(context); - /// TODO: find a better solution when (https://github.com/flutter/flutter/issues/21023) is fixed - return WidgetsVisibilityProvider( - condition: (positionData) => - positionData.endPosition >= 20 && - positionData.startPosition <= positionData.viewportSize, - child: Stack( - alignment: Alignment.center, - children: [ - NotificationListener( - onNotification: (_) { - if (_scrollController.offset < 150 && - _newMessageList.isNotEmpty) { - setState(() { - _messages.insertAll(0, _newMessageList); - _newMessageList.clear(); - }); - } - return true; - }, - child: ListView.custom( - key: Key('messageListView'), - physics: widget.scrollPhysics, - keyboardDismissBehavior: widget.keyboardDismissBehavior, - controller: _scrollController, - reverse: true, - childrenDelegate: SliverChildBuilderDelegate( - (context, i) { - if (i == _messages.length + 1) { - if (widget.parentMessage != null) { - if (widget.parentMessageBuilder != null) { - return widget.parentMessageBuilder( - context, - widget.parentMessage, - ); - } else { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - buildParentMessage(widget.parentMessage), - Padding( - padding: - const EdgeInsets.symmetric(horizontal: 32), - child: Container( - padding: const EdgeInsets.all(8), - child: Text( - 'Start of thread', - textAlign: TextAlign.center, - ), - color: - Theme.of(context).accentColor.withAlpha(50), - ), - ), - ], - ); - } + final messagesStream = widget.parentMessage != null + ? streamChannel.channel.state.threadsStream + .where((threads) => threads.containsKey(widget.parentMessage.id)) + .map((threads) => threads[widget.parentMessage.id]) + : streamChannel.channel.state.messagesStream; + + return StreamBuilder>( + stream: messagesStream, + initialData: widget.parentMessage != null + ? streamChannel.channel.state.threads[widget.parentMessage.id] + : streamChannel.channel.state.messages, + builder: (context, snapshot) { + final messages = snapshot.data?.reversed?.toList() ?? []; + return Stack( + alignment: Alignment.center, + children: [ + ScrollablePositionedList.builder( + itemPositionsListener: _itemPositionListener, + addAutomaticKeepAlives: true, + key: Key('messageListView'), + initialScrollIndex: widget.initialScrollIndex, + initialAlignment: widget.initialAlignment, + physics: widget.scrollPhysics, + itemScrollController: _scrollController, + reverse: true, + itemCount: messages.length + + 1 + + (widget.parentMessage != null ? 1 : 0), + itemBuilder: (context, i) { + if (i == messages.length + 1) { + if (widget.parentMessageBuilder != null) { + return widget.parentMessageBuilder( + context, + widget.parentMessage, + ); } else { - return SizedBox(); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + buildParentMessage(widget.parentMessage), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Container( + padding: const EdgeInsets.all(8), + child: Text( + 'Start of thread', + textAlign: TextAlign.center, + ), + color: + Theme.of(context).accentColor.withAlpha(50), + ), + ), + ], + ); } } - if (i == _messages.length) { + if (i == messages.length) { return _buildLoadingIndicator(streamChannel); } - final message = _messages[i]; - final nextMessage = i > 0 ? _messages[i - 1] : null; + final message = messages[i]; + final nextMessage = i > 0 ? messages[i - 1] : null; Widget messageWidget; @@ -227,14 +237,14 @@ class _MessageListViewState extends State { messageWidget = _buildBottomMessage( context, message, - _messages, + messages, streamChannel, ); - } else if (i == _messages.length - 1) { + } else if (i == messages.length - 1) { messageWidget = _buildTopMessage( context, message, - _messages, + messages, streamChannel, ); } else { @@ -246,13 +256,13 @@ class _MessageListViewState extends State { MessageDetails( context, message, - _messages, + messages, i, ), - _messages), + messages), ); } else { - messageWidget = buildMessage(message, _messages, i); + messageWidget = buildMessage(message, messages, i); } } @@ -265,84 +275,74 @@ class _MessageListViewState extends State { messageWidget, Padding( padding: const EdgeInsets.symmetric(vertical: 12.0), - child: VisibleNotifierWidget( - condition: ( - ScrollNotification previousNotification, - PositionData previousPositionData, - ScrollNotification currentNotification, - PositionData currentPositionData, - ) { - print( - 'currentPositionData.endPosition: ${currentPositionData.endPosition}'); - return true; - }, - data: i, - child: widget.dateDividerBuilder != null - ? widget.dateDividerBuilder( - nextMessage.createdAt.toLocal()) - : DateDivider( - dateTime: nextMessage.createdAt.toLocal(), - ), - ), + child: widget.dateDividerBuilder != null + ? widget.dateDividerBuilder( + nextMessage.createdAt.toLocal()) + : DateDivider( + dateTime: nextMessage.createdAt.toLocal(), + ), ), ], ); } - return VisibleNotifierWidget( - child: messageWidget, - data: i, - ); - }, - childCount: _messages.length + 2, - findChildIndexCallback: (key) { - final ValueKey valueKey = key; - final index = _messages - .indexWhere((m) => 'MESSAGE-${m.id}' == valueKey.value); - return index != -1 ? index : null; + return messageWidget; }, ), - ), - ), - if (widget.showScrollToBottom) - StreamBuilder( - stream: streamChannel.channel.on( - EventType.messageNew, - ), - builder: (context, _) { - if (!_showScrollToBottom || - streamChannel.channel.state.unreadCount == 0) { - return SizedBox(); - } - return _buildScrollToBottom(streamChannel); - }), - Positioned( - top: 20.0, - child: WidgetsVisibilityConsumer( - listener: ( - context, - event, - ) {}, - builder: (context, event) { - if (event.positionDataList == null || - event.positionDataList.isEmpty) { - return Container(); - } + if (widget.showScrollToBottom) + StreamBuilder( + stream: streamChannel.channel.state.unreadCountStream, + builder: (context, snapshot) { + if (!_showScrollToBottom || + !snapshot.hasData || + snapshot.data == 0) { + return SizedBox(); + } + return _buildScrollToBottom(snapshot.data); + }), + Positioned( + top: 20.0, + child: ValueListenableBuilder>( + valueListenable: _itemPositionListener.itemPositions, + builder: (context, values, _) { + final items = _itemPositionListener.itemPositions?.value; + if (items.isEmpty || messages.isEmpty) { + return SizedBox(); + } - return DateDivider( - dateTime: _messages[event.positionDataList.last.data] - .createdAt - .toLocal(), - ); - }, - ), - ), - ], - ), - ); + var index = _getTopElement(values).index; + + if (index > messages.length) { + return SizedBox(); + } + + if (index == messages.length) { + index = max(index - 1, 0); + } + + return widget.dateDividerBuilder != null + ? widget.dateDividerBuilder( + messages[index].createdAt.toLocal(), + ) + : DateDivider( + dateTime: messages[index].createdAt.toLocal(), + ); + }, + ), + ), + ], + ); + }); } - Widget _buildScrollToBottom(StreamChannelState streamChannel) { + ItemPosition _getTopElement(Iterable values) { + return values + .where((ItemPosition position) => position.itemLeadingEdge < 0.9) + .reduce((ItemPosition max, ItemPosition position) => + position.itemLeadingEdge > max.itemLeadingEdge ? position : max); + } + + Widget _buildScrollToBottom(int unreadCount) { return Positioned( bottom: 8, right: 8, @@ -358,13 +358,11 @@ class _MessageListViewState extends State { color: Colors.black, ), onPressed: () { - Future.delayed(Duration(milliseconds: 500), () { - setState(() { - _showScrollToBottom = false; - }); + setState(() { + _showScrollToBottom = false; }); - _scrollController.animateTo( - 0, + _scrollController.scrollTo( + index: 0, duration: Duration(seconds: 1), curve: Curves.easeInOut, ); @@ -377,7 +375,7 @@ class _MessageListViewState extends State { top: -10, child: CircleAvatar( radius: 20, - child: Text(streamChannel.channel.state.unreadCount.toString()), + child: Text(unreadCount.toString()), ), ), ], @@ -387,7 +385,9 @@ class _MessageListViewState extends State { Container _buildLoadingIndicator(StreamChannelState streamChannel) { return Container( + key: Key('LOADING-INDICATOR'), height: 50, + width: double.infinity, child: StreamBuilder( stream: streamChannel.queryMessage, initialData: false, @@ -401,7 +401,7 @@ class _MessageListViewState extends State { ); } if (!snapshot.data) { - return Container(); + return SizedBox(); } return Center( child: Padding( @@ -428,14 +428,14 @@ class _MessageListViewState extends State { MessageDetails( context, message, - _messages, - _messages.length - 1, + messages, + messages.length - 1, ), - _messages, + messages, ), ); } else { - messageWidget = buildMessage(message, messages, _messages.length - 1); + messageWidget = buildMessage(message, messages, messages.length - 1); } return VisibilityDetector( @@ -470,10 +470,10 @@ class _MessageListViewState extends State { MessageDetails( context, message, - _messages, + messages, 0, ), - _messages, + messages, ), ); } else { @@ -492,9 +492,11 @@ class _MessageListViewState extends State { } } _bottomWasVisible = isVisible; - setState(() { - _showScrollToBottom = !isVisible; - }); + if (mounted) { + setState(() { + _showScrollToBottom = !isVisible; + }); + } }, child: messageWidget, ); @@ -541,6 +543,7 @@ class _MessageListViewState extends State { ) { if (message.type == 'system' && message.text?.isNotEmpty == true) { return SystemMessage( + key: ValueKey('MESSAGE-${message.id}'), message: message, ); } @@ -563,6 +566,7 @@ class _MessageListViewState extends State { final allRead = readList.length >= (channel.memberCount ?? 0) - 1; return MessageWidget( + key: ValueKey('MESSAGE-${message.id}'), message: message, reverse: isMyMessage, showReactions: !message.isDeleted, @@ -600,64 +604,45 @@ class _MessageListViewState extends State { ); } - StreamSubscription _streamListener; + StreamSubscription _messageNewListener; @override void initState() { - super.initState(); + _scrollController = widget.scrollController ?? ItemScrollController(); + _itemPositionListener = + widget.itemPositionListener ?? ItemPositionsListener.create(); final streamChannel = StreamChannel.of(context); - Stream> stream; - - if (widget.parentMessage == null) { - stream = streamChannel.channel.state.messagesStream; - } else { - streamChannel.getReplies(widget.parentMessage.id); - stream = streamChannel.channel.state.threadsStream - .where((threads) => threads.containsKey(widget.parentMessage.id)) - .map((threads) => threads[widget.parentMessage.id]); - } - - _streamListener = stream - .map((messages) => - messages - ?.where((m) => - !(m.status == MessageSendingStatus.FAILED && m.isDeleted)) - ?.toList() ?? - []) - .listen((newMessages) { - newMessages = newMessages.reversed.toList(); - if (_messages.isEmpty || - newMessages.isEmpty || - newMessages.first.id != _messages.first.id) { - if (!_scrollController.hasClients || - _scrollController.offset < _newMessageLoadingOffset) { - if (streamChannel.channel.state.unreadCount > 0) { - streamChannel.channel.markRead(); - } - setState(() { - _messages = newMessages; - }); - } else if (newMessages.first.user.id == - streamChannel.channel.client.state.user.id) { - _scrollController.jumpTo(0); - WidgetsBinding.instance.addPostFrameCallback((_) { - setState(() { - _messages = newMessages; - }); - }); - } else { - _newMessageList = newMessages; - } - } else { - setState(() { - _messages = newMessages; + _messageNewListener = + streamChannel.channel.on(EventType.messageNew).listen((event) { + final firstElementInViewport = + _itemPositionListener.itemPositions.value.first; + if (event.message.user.id == streamChannel.channel.client.state.user.id) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollController.jumpTo( + index: 0, + ); }); + } else { + if (firstElementInViewport.index != 0) { + _scrollController.jumpTo( + index: firstElementInViewport.index + 1, + alignment: firstElementInViewport.itemLeadingEdge, + ); + } + } + if (firstElementInViewport.index == 0) { + streamChannel.channel.markRead(); } }); + if (widget.parentMessage != null) { + streamChannel.getReplies(widget.parentMessage.id); + } + _getOnThreadTap(); + super.initState(); } void _getOnThreadTap() { @@ -696,7 +681,7 @@ class _MessageListViewState extends State { @override void dispose() { - _streamListener.cancel(); + _messageNewListener?.cancel(); super.dispose(); } } diff --git a/pubspec.yaml b/pubspec.yaml index eb9f0545..0e768587 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,7 +14,9 @@ dependencies: flutter_app_badger: ^1.1.2 photo_view: ^0.10.1 rxdart: ^0.24.1 + scrollable_positioned_list: ^0.1.8 jiffy: ^3.0.1 + flutter_svg: ^0.19.1 flutter_portal: ^0.3.0 cached_network_image: ^2.2.0+1 flutter_markdown: ^0.5.0 @@ -33,11 +35,11 @@ dependencies: flutter_slidable: ^0.5.4 carousel_slider: ^2.2.1 clipboard: ^0.1.2+8 - widgets_visibility_provider: ^2.0.2 flutter: assets: - - images/giphy_icon.png + - images/ + - svgs/ fonts: - family: stream-icons fonts: diff --git a/svgs/giphy_icon.svg b/svgs/giphy_icon.svg new file mode 100644 index 00000000..e1a1d4bb --- /dev/null +++ b/svgs/giphy_icon.svg @@ -0,0 +1,8 @@ + + + + + + + +