From 2badecbde6229950499eed2f0b741a3d8f784c51 Mon Sep 17 00:00:00 2001 From: Gordon Hayes Date: Wed, 8 Sep 2021 11:48:26 +0200 Subject: [PATCH 01/35] wip: testing positioned list --- .../lib/src/message_input.dart | 125 ++++++++------- .../lib/src/message_list_view.dart | 151 +++++++++--------- .../lib/src/message_widget.dart | 8 + 3 files changed, 150 insertions(+), 134 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/message_input.dart b/packages/stream_chat_flutter/lib/src/message_input.dart index 9646608c..f47aa3ed 100644 --- a/packages/stream_chat_flutter/lib/src/message_input.dart +++ b/packages/stream_chat_flutter/lib/src/message_input.dart @@ -2311,75 +2311,78 @@ class _PickerWidgetState extends State<_PickerWidget> { if (widget.filePickerIndex != 0) { return const Offstage(); } - return FutureBuilder( - future: requestPermission, - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const Center(child: CircularProgressIndicator()); - } - - if (snapshot.data!) { - if (widget.containsFile) { - return GestureDetector( - onTap: () { - widget.onAddMoreFilesClick(DefaultAttachmentTypes.file); - }, - child: Container( - constraints: const BoxConstraints.expand(), - color: widget.streamChatTheme.colorTheme.inputBg, - alignment: Alignment.center, - child: Text( - context.translations.addMoreFilesLabel, - style: TextStyle( - color: widget.streamChatTheme.colorTheme.accentPrimary, - fontWeight: FontWeight.bold, - ), - ), - ), - ); + return RepaintBoundary( + child: FutureBuilder( + future: requestPermission, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Center(child: CircularProgressIndicator()); } - return MediaListView( - selectedIds: widget.selectedMedias, - onSelect: widget.onMediaSelected, - ); - } - return InkWell( - onTap: () async { - PhotoManager.openSetting(); - }, - child: Container( - color: widget.streamChatTheme.colorTheme.inputBg, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SvgPicture.asset( - 'svgs/icon_picture_empty_state.svg', - package: 'stream_chat_flutter', - height: 140, - color: widget.streamChatTheme.colorTheme.disabled, - ), - Text( - context.translations.enablePhotoAndVideoAccessMessage, - style: widget.streamChatTheme.textTheme.body.copyWith( - color: widget.streamChatTheme.colorTheme.textLowEmphasis), - textAlign: TextAlign.center, - ), - const SizedBox(height: 6), - Center( + if (snapshot.data!) { + if (widget.containsFile) { + return GestureDetector( + onTap: () { + widget.onAddMoreFilesClick(DefaultAttachmentTypes.file); + }, + child: Container( + constraints: const BoxConstraints.expand(), + color: widget.streamChatTheme.colorTheme.inputBg, + alignment: Alignment.center, child: Text( - context.translations.allowGalleryAccessMessage, - style: widget.streamChatTheme.textTheme.bodyBold.copyWith( + context.translations.addMoreFilesLabel, + style: TextStyle( color: widget.streamChatTheme.colorTheme.accentPrimary, + fontWeight: FontWeight.bold, ), ), ), - ], + ); + } + return MediaListView( + selectedIds: widget.selectedMedias, + onSelect: widget.onMediaSelected, + ); + } + + return InkWell( + onTap: () async { + PhotoManager.openSetting(); + }, + child: Container( + color: widget.streamChatTheme.colorTheme.inputBg, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SvgPicture.asset( + 'svgs/icon_picture_empty_state.svg', + package: 'stream_chat_flutter', + height: 140, + color: widget.streamChatTheme.colorTheme.disabled, + ), + Text( + context.translations.enablePhotoAndVideoAccessMessage, + style: widget.streamChatTheme.textTheme.body.copyWith( + color: + widget.streamChatTheme.colorTheme.textLowEmphasis), + textAlign: TextAlign.center, + ), + const SizedBox(height: 6), + Center( + child: Text( + context.translations.allowGalleryAccessMessage, + style: widget.streamChatTheme.textTheme.bodyBold.copyWith( + color: widget.streamChatTheme.colorTheme.accentPrimary, + ), + ), + ), + ], + ), ), - ), - ); - }, + ); + }, + ), ); } } diff --git a/packages/stream_chat_flutter/lib/src/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view.dart index ab4ae04f..25d10bb1 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -455,7 +455,7 @@ class _MessageListViewState extends State { onInBetweenOfPage: () { _inBetweenList = true; }, - child: ScrollablePositionedList.separated( + child: ScrollablePositionedList.builder( key: _upToDate ? null : ValueKey(initialIndex + initialAlignment), @@ -465,8 +465,13 @@ class _MessageListViewState extends State { physics: widget.scrollPhysics, itemScrollController: _scrollController, reverse: widget.reverse, - addAutomaticKeepAlives: false, - itemCount: itemCount, + itemCount: messages.length, + findChildIndexCallback: (Key key) { + final String data = (key as ValueKey).value; + final index = + messages.indexWhere((element) => element.id == data); + return index + 1; + }, // Item Count -> 8 (1 parent, 2 header+footer, 2 top+bottom, 3 messages) // eg: |Type| rev(|Index(item)|) rev(|Index(separator)|) |Index(item)| |Index(separator)| @@ -486,72 +491,74 @@ class _MessageListViewState extends State { // Separator(Footer -> 8??30) -> 0 (count-8) // Footer -> 0 (count-8) - separatorBuilder: (context, i) { - if (i == itemCount - 2) { - if (widget.parentMessage == null) { - return const Offstage(); - } - return _buildThreadSeparator(); - } - if (i == itemCount - 3) { - if (widget.headerBuilder == null) { - if (_isThreadConversation) return const Offstage(); - return const SizedBox(height: 52); - } - return const SizedBox(height: 8); - } - if (i == 0) { - if (widget.footerBuilder == null) { - return const SizedBox(height: 30); - } - return const SizedBox(height: 8); - } + // separatorBuilder: (context, i) { + // if (i == itemCount - 2) { + // if (widget.parentMessage == null) { + // return const Offstage(); + // } + // return _buildThreadSeparator(); + // } + // if (i == itemCount - 3) { + // if (widget.headerBuilder == null) { + // if (_isThreadConversation) return const Offstage(); + // return const SizedBox(height: 52); + // } + // return const SizedBox(height: 8); + // } + // if (i == 0) { + // if (widget.footerBuilder == null) { + // return const SizedBox(height: 30); + // } + // return const SizedBox(height: 8); + // } - if (i == 1 || i == itemCount - 4) return const Offstage(); + // if (i == 1 || i == itemCount - 4) return const Offstage(); - late final Message message, nextMessage; - if (widget.reverse) { - message = messages[i - 1]; - nextMessage = messages[i - 2]; - } else { - message = messages[i - 2]; - nextMessage = messages[i - 1]; - } - if (!Jiffy(message.createdAt.toLocal()).isSame( - nextMessage.createdAt.toLocal(), - Units.DAY, - )) { - final divider = widget.dateDividerBuilder != null - ? widget.dateDividerBuilder!( - nextMessage.createdAt.toLocal(), - ) - : DateDivider( - dateTime: nextMessage.createdAt.toLocal(), - ); - return Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: divider, - ); - } - final timeDiff = - Jiffy(nextMessage.createdAt.toLocal()).diff( - message.createdAt.toLocal(), - Units.MINUTE, - ); + // late final Message message, nextMessage; + // if (widget.reverse) { + // message = messages[i - 1]; + // nextMessage = messages[i - 2]; + // } else { + // message = messages[i - 2]; + // nextMessage = messages[i - 1]; + // } + // if (!Jiffy(message.createdAt.toLocal()).isSame( + // nextMessage.createdAt.toLocal(), + // Units.DAY, + // )) { + // final divider = widget.dateDividerBuilder != null + // ? widget.dateDividerBuilder!( + // nextMessage.createdAt.toLocal(), + // ) + // : DateDivider( + // dateTime: nextMessage.createdAt.toLocal(), + // ); + // return Padding( + // padding: const EdgeInsets.symmetric(vertical: 12), + // child: divider, + // ); + // } + // final timeDiff = + // Jiffy(nextMessage.createdAt.toLocal()).diff( + // message.createdAt.toLocal(), + // Units.MINUTE, + // ); - final isNextUserSame = - message.user!.id == nextMessage.user?.id; - final isThread = message.replyCount! > 0; - final isDeleted = message.isDeleted; - if (timeDiff >= 1 || - !isNextUserSame || - isThread || - isDeleted) { - return const SizedBox(height: 8); - } - return const SizedBox(height: 2); - }, + // final isNextUserSame = + // message.user!.id == nextMessage.user?.id; + // final isThread = message.replyCount! > 0; + // final isDeleted = message.isDeleted; + // if (timeDiff >= 1 || + // !isNextUserSame || + // isThread || + // isDeleted) { + // return const SizedBox(height: 8); + // } + // return const SizedBox(height: 2); + // }, itemBuilder: (context, i) { + // final myMessage = messages[i]; + // return buildMessage(myMessage, messages, i, i); if (i == itemCount - 1) { if (widget.parentMessage == null) { return const Offstage(); @@ -597,7 +604,7 @@ class _MessageListViewState extends State { i - 2, ); } else { - messageWidget = buildMessage(message, messages, i - 2); + messageWidget = buildMessage(message, messages, i - 2, i); } return messageWidget; }, @@ -915,16 +922,13 @@ class _MessageListViewState extends State { return defaultMessageWidget; } - Widget buildMessage( - Message message, - List messages, - int index, - ) { + Widget buildMessage(Message message, List messages, int index, + [int? actualIndex]) { if ((message.type == 'system' || message.type == 'error') && message.text?.isNotEmpty == true) { return widget.systemMessageBuilder?.call(context, message) ?? SystemMessage( - key: ValueKey('MESSAGE-${message.id}'), + key: ValueKey(message.id), message: message, onMessageTap: (message) { if (widget.onSystemMessageTap != null) { @@ -1004,7 +1008,8 @@ class _MessageListViewState extends State { members.firstWhereOrNull((e) => e.user!.id == currentUser!.id); Widget messageWidget = MessageWidget( - key: ValueKey('MESSAGE-${message.id}'), + key: ValueKey(message.id), + actualIndex: actualIndex, message: message, reverse: isMyMessage, showReactions: !message.isDeleted, diff --git a/packages/stream_chat_flutter/lib/src/message_widget.dart b/packages/stream_chat_flutter/lib/src/message_widget.dart index 7619f21b..87f02b7c 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget.dart @@ -74,6 +74,7 @@ class MessageWidget extends StatefulWidget { this.showInChannelIndicator = false, this.onReplyTap, this.onThreadTap, + this.actualIndex, this.showUsername = true, this.showTimestamp = true, this.showReactions = true, @@ -257,6 +258,8 @@ class MessageWidget extends StatefulWidget { }..addAll(customAttachmentBuilders ?? {}), super(key: key); + final int? actualIndex; + /// Function called on mention tap final void Function(User)? onMentionTap; @@ -590,6 +593,10 @@ class _MessageWidgetState extends State @override Widget build(BuildContext context) { super.build(context); + return Container( + decoration: BoxDecoration(border: Border.all()), + child: Text('${widget.message.text ?? 'valu'} ${widget.actualIndex}'), + ); final avatarWidth = widget.messageTheme.avatarTheme?.constraints.maxWidth ?? 40; final bottomRowPadding = @@ -658,6 +665,7 @@ class _MessageWidgetState extends State SizedBox(width: avatarWidth + 4), Flexible( child: PortalEntry( + visible: false, portal: Container( transform: Matrix4.translationValues( widget.reverse ? 12 : -12, 0, 0), From d48baf72740a252aa188bd923414f08251c91cf5 Mon Sep 17 00:00:00 2001 From: Gordon Hayes Date: Wed, 8 Sep 2021 11:48:58 +0200 Subject: [PATCH 02/35] chore: update pubspec to fork --- packages/stream_chat_flutter/pubspec.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index 907e9a7f..94ab5fbb 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -34,7 +34,11 @@ dependencies: photo_manager: ^1.2.6+1 photo_view: ^0.12.0 rxdart: ^0.27.0 - scrollable_positioned_list: ^0.2.0-nullsafety.0 + scrollable_positioned_list: + git: + url: https://github.com/HayesGordon/flutter.widgets.git + ref: feat/child-index-callback + path: packages/scrollable_positioned_list share_plus: ^2.0.3 shimmer: ^2.0.0 stream_chat_flutter_core: ^2.2.1 From 9b1d94693afe6619be75970fd3694873e73b1520 Mon Sep 17 00:00:00 2001 From: Salvatore Giordano Date: Wed, 8 Sep 2021 13:14:53 +0200 Subject: [PATCH 03/35] fix: make the list work correctly with separators and findChildIndexCallback --- .../lib/src/message_list_view.dart | 138 +++++++++--------- .../lib/src/message_widget.dart | 7 - 2 files changed, 67 insertions(+), 78 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view.dart index 25d10bb1..e6d349fc 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -455,7 +455,7 @@ class _MessageListViewState extends State { onInBetweenOfPage: () { _inBetweenList = true; }, - child: ScrollablePositionedList.builder( + child: ScrollablePositionedList.separated( key: _upToDate ? null : ValueKey(initialIndex + initialAlignment), @@ -470,7 +470,7 @@ class _MessageListViewState extends State { final String data = (key as ValueKey).value; final index = messages.indexWhere((element) => element.id == data); - return index + 1; + return ((index + 2) * 2) - 1; }, // Item Count -> 8 (1 parent, 2 header+footer, 2 top+bottom, 3 messages) @@ -491,74 +491,72 @@ class _MessageListViewState extends State { // Separator(Footer -> 8??30) -> 0 (count-8) // Footer -> 0 (count-8) - // separatorBuilder: (context, i) { - // if (i == itemCount - 2) { - // if (widget.parentMessage == null) { - // return const Offstage(); - // } - // return _buildThreadSeparator(); - // } - // if (i == itemCount - 3) { - // if (widget.headerBuilder == null) { - // if (_isThreadConversation) return const Offstage(); - // return const SizedBox(height: 52); - // } - // return const SizedBox(height: 8); - // } - // if (i == 0) { - // if (widget.footerBuilder == null) { - // return const SizedBox(height: 30); - // } - // return const SizedBox(height: 8); - // } + separatorBuilder: (context, i) { + if (i == itemCount - 2) { + if (widget.parentMessage == null) { + return const Offstage(); + } + return _buildThreadSeparator(); + } + if (i == itemCount - 3) { + if (widget.headerBuilder == null) { + if (_isThreadConversation) return const Offstage(); + return const SizedBox(height: 52); + } + return const SizedBox(height: 8); + } + if (i == 0) { + if (widget.footerBuilder == null) { + return const SizedBox(height: 30); + } + return const SizedBox(height: 8); + } - // if (i == 1 || i == itemCount - 4) return const Offstage(); + if (i == 1 || i == itemCount - 4) return const Offstage(); - // late final Message message, nextMessage; - // if (widget.reverse) { - // message = messages[i - 1]; - // nextMessage = messages[i - 2]; - // } else { - // message = messages[i - 2]; - // nextMessage = messages[i - 1]; - // } - // if (!Jiffy(message.createdAt.toLocal()).isSame( - // nextMessage.createdAt.toLocal(), - // Units.DAY, - // )) { - // final divider = widget.dateDividerBuilder != null - // ? widget.dateDividerBuilder!( - // nextMessage.createdAt.toLocal(), - // ) - // : DateDivider( - // dateTime: nextMessage.createdAt.toLocal(), - // ); - // return Padding( - // padding: const EdgeInsets.symmetric(vertical: 12), - // child: divider, - // ); - // } - // final timeDiff = - // Jiffy(nextMessage.createdAt.toLocal()).diff( - // message.createdAt.toLocal(), - // Units.MINUTE, - // ); + late final Message message, nextMessage; + if (widget.reverse) { + message = messages[i - 1]; + nextMessage = messages[i - 2]; + } else { + message = messages[i - 2]; + nextMessage = messages[i - 1]; + } + if (!Jiffy(message.createdAt.toLocal()).isSame( + nextMessage.createdAt.toLocal(), + Units.DAY, + )) { + final divider = widget.dateDividerBuilder != null + ? widget.dateDividerBuilder!( + nextMessage.createdAt.toLocal(), + ) + : DateDivider( + dateTime: nextMessage.createdAt.toLocal(), + ); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: divider, + ); + } + final timeDiff = + Jiffy(nextMessage.createdAt.toLocal()).diff( + message.createdAt.toLocal(), + Units.MINUTE, + ); - // final isNextUserSame = - // message.user!.id == nextMessage.user?.id; - // final isThread = message.replyCount! > 0; - // final isDeleted = message.isDeleted; - // if (timeDiff >= 1 || - // !isNextUserSame || - // isThread || - // isDeleted) { - // return const SizedBox(height: 8); - // } - // return const SizedBox(height: 2); - // }, + final isNextUserSame = + message.user!.id == nextMessage.user?.id; + final isThread = message.replyCount! > 0; + final isDeleted = message.isDeleted; + if (timeDiff >= 1 || + !isNextUserSame || + isThread || + isDeleted) { + return const SizedBox(height: 8); + } + return const SizedBox(height: 2); + }, itemBuilder: (context, i) { - // final myMessage = messages[i]; - // return buildMessage(myMessage, messages, i, i); if (i == itemCount - 1) { if (widget.parentMessage == null) { return const Offstage(); @@ -604,7 +602,7 @@ class _MessageListViewState extends State { i - 2, ); } else { - messageWidget = buildMessage(message, messages, i - 2, i); + messageWidget = buildMessage(message, messages, i - 2); } return messageWidget; }, @@ -830,7 +828,7 @@ class _MessageListViewState extends State { final messageWidget = buildMessage(message, messages, index); return VisibilityDetector( - key: ValueKey('BOTTOM-MESSAGE-${message.id}'), + key: ValueKey(message.id), onVisibilityChanged: (visibility) { final isVisible = visibility.visibleBounds != Rect.zero; if (isVisible) { @@ -922,8 +920,7 @@ class _MessageListViewState extends State { return defaultMessageWidget; } - Widget buildMessage(Message message, List messages, int index, - [int? actualIndex]) { + Widget buildMessage(Message message, List messages, int index) { if ((message.type == 'system' || message.type == 'error') && message.text?.isNotEmpty == true) { return widget.systemMessageBuilder?.call(context, message) ?? @@ -1009,7 +1006,6 @@ class _MessageListViewState extends State { Widget messageWidget = MessageWidget( key: ValueKey(message.id), - actualIndex: actualIndex, message: message, reverse: isMyMessage, showReactions: !message.isDeleted, diff --git a/packages/stream_chat_flutter/lib/src/message_widget.dart b/packages/stream_chat_flutter/lib/src/message_widget.dart index 87f02b7c..d8c4970e 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget.dart @@ -74,7 +74,6 @@ class MessageWidget extends StatefulWidget { this.showInChannelIndicator = false, this.onReplyTap, this.onThreadTap, - this.actualIndex, this.showUsername = true, this.showTimestamp = true, this.showReactions = true, @@ -258,8 +257,6 @@ class MessageWidget extends StatefulWidget { }..addAll(customAttachmentBuilders ?? {}), super(key: key); - final int? actualIndex; - /// Function called on mention tap final void Function(User)? onMentionTap; @@ -593,10 +590,6 @@ class _MessageWidgetState extends State @override Widget build(BuildContext context) { super.build(context); - return Container( - decoration: BoxDecoration(border: Border.all()), - child: Text('${widget.message.text ?? 'valu'} ${widget.actualIndex}'), - ); final avatarWidth = widget.messageTheme.avatarTheme?.constraints.maxWidth ?? 40; final bottomRowPadding = From 0425d6774729496675f5bfffe277bac9786e4e4f Mon Sep 17 00:00:00 2001 From: Gordon Hayes Date: Wed, 8 Sep 2021 14:22:04 +0200 Subject: [PATCH 04/35] fix: show portal conditionally --- .../stream_chat_flutter/lib/src/message_widget.dart | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/message_widget.dart b/packages/stream_chat_flutter/lib/src/message_widget.dart index d8c4970e..d165e52c 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget.dart @@ -658,7 +658,7 @@ class _MessageWidgetState extends State SizedBox(width: avatarWidth + 4), Flexible( child: PortalEntry( - visible: false, + visible: _shouldShowReactions, portal: Container( transform: Matrix4.translationValues( widget.reverse ? 12 : -12, 0, 0), @@ -1014,9 +1014,7 @@ class _MessageWidgetState extends State return AnimatedSwitcher( duration: const Duration(milliseconds: 300), - child: (widget.showReactions && - (widget.message.reactionCounts?.isNotEmpty == true) && - !widget.message.isDeleted) + child: _shouldShowReactions ? GestureDetector( onTap: () => _showMessageReactionsModalBottomSheet(context), child: ReactionBubble( @@ -1036,6 +1034,11 @@ class _MessageWidgetState extends State ); } + bool get _shouldShowReactions => + widget.showReactions && + (widget.message.reactionCounts?.isNotEmpty == true) && + !widget.message.isDeleted; + void _showMessageActionModalBottomSheet(BuildContext context) { final channel = StreamChannel.of(context).channel; From 311b109994e05ecd550964d3ebd674e6cc5993a6 Mon Sep 17 00:00:00 2001 From: Gordon Hayes Date: Wed, 8 Sep 2021 14:31:36 +0200 Subject: [PATCH 05/35] perf: only build reaction indicator when needed --- .../lib/src/message_widget.dart | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/message_widget.dart b/packages/stream_chat_flutter/lib/src/message_widget.dart index d165e52c..41ebcab6 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget.dart @@ -595,6 +595,8 @@ class _MessageWidgetState extends State final bottomRowPadding = widget.showUserAvatar != DisplayWidget.gone ? avatarWidth + 8.5 : 0.5; + final showReactions = _shouldShowReactions; + return Material( type: widget.message.pinned && widget.showPinHighlight ? MaterialType.card @@ -658,15 +660,21 @@ class _MessageWidgetState extends State SizedBox(width: avatarWidth + 4), Flexible( child: PortalEntry( - visible: _shouldShowReactions, - portal: Container( - transform: Matrix4.translationValues( - widget.reverse ? 12 : -12, 0, 0), - constraints: const BoxConstraints( - maxWidth: 22 * 6.0, - ), - child: _buildReactionIndicator(context), - ), + visible: showReactions, + portal: showReactions + ? Container( + transform: + Matrix4.translationValues( + widget.reverse ? 12 : -12, + 0, + 0), + constraints: const BoxConstraints( + maxWidth: 22 * 6.0, + ), + child: _buildReactionIndicator( + context), + ) + : null, portalAnchor: Alignment(widget.reverse ? 1 : -1, -1), childAnchor: From 265c0d06372a9897d16e87a7d8a2ebf7aa2f02eb Mon Sep 17 00:00:00 2001 From: Gordon Hayes Date: Wed, 8 Sep 2021 17:24:17 +0200 Subject: [PATCH 06/35] feat: use IndexedKey to track list index --- .../lib/src/message_list_view.dart | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view.dart index e6d349fc..4d1c0929 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -467,9 +467,9 @@ class _MessageListViewState extends State { reverse: widget.reverse, itemCount: messages.length, findChildIndexCallback: (Key key) { - final String data = (key as ValueKey).value; - final index = - messages.indexWhere((element) => element.id == data); + final indexedKey = key as IndexedKey; + final index = messages.indexWhere( + (element) => element.id == indexedKey.value); return ((index + 2) * 2) - 1; }, @@ -600,9 +600,10 @@ class _MessageListViewState extends State { messages, streamChannel!, i - 2, + i, ); } else { - messageWidget = buildMessage(message, messages, i - 2); + messageWidget = buildMessage(message, messages, i - 2, i); } return messageWidget; }, @@ -824,11 +825,13 @@ class _MessageListViewState extends State { List messages, StreamChannelState streamChannel, int index, + int buildItemIndex, ) { - final messageWidget = buildMessage(message, messages, index); + final messageWidget = + buildMessage(message, messages, index, buildItemIndex); return VisibilityDetector( - key: ValueKey(message.id), + key: IndexedKey(message.id, buildItemIndex), onVisibilityChanged: (visibility) { final isVisible = visibility.visibleBounds != Rect.zero; if (isVisible) { @@ -920,12 +923,13 @@ class _MessageListViewState extends State { return defaultMessageWidget; } - Widget buildMessage(Message message, List messages, int index) { + Widget buildMessage( + Message message, List messages, int index, int buildItemIndex) { if ((message.type == 'system' || message.type == 'error') && message.text?.isNotEmpty == true) { return widget.systemMessageBuilder?.call(context, message) ?? SystemMessage( - key: ValueKey(message.id), + key: IndexedKey(message.id, buildItemIndex), message: message, onMessageTap: (message) { if (widget.onSystemMessageTap != null) { @@ -1005,7 +1009,7 @@ class _MessageListViewState extends State { members.firstWhereOrNull((e) => e.user!.id == currentUser!.id); Widget messageWidget = MessageWidget( - key: ValueKey(message.id), + key: IndexedKey(message.id, buildItemIndex), message: message, reverse: isMyMessage, showReactions: !message.isDeleted, From b9866a709aa326755909ad0a80729b85f9782118 Mon Sep 17 00:00:00 2001 From: Gordon Hayes Date: Wed, 6 Oct 2021 15:35:22 +0200 Subject: [PATCH 07/35] feat: add messagesIndex to keep track of list indexes --- .../lib/src/message_list_view.dart | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view.dart index 56e3d48d..b6ea7ff1 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -336,6 +336,8 @@ class _MessageListViewState extends State { List messages = []; + Map messagesIndex = {}; + bool initialMessageHighlightComplete = false; bool _inBetweenList = false; @@ -379,6 +381,9 @@ class _MessageListViewState extends State { Widget _buildListView(List data) { messages = data; + for (var index = 0; index < messages.length; index++) { + messagesIndex[messages[index].id] = index; + } final newMessagesListLength = messages.length; if (_messageListLength != null) { @@ -471,9 +476,10 @@ class _MessageListViewState extends State { itemCount: messages.length, findChildIndexCallback: (Key key) { final indexedKey = key as IndexedKey; - final index = messages.indexWhere( - (element) => element.id == indexedKey.value); - return ((index + 2) * 2) - 1; + final index = messagesIndex[indexedKey.value]; + if (index != null) { + return ((index + 2) * 2) - 1; + } }, // Item Count -> 8 (1 parent, 2 header+footer, 2 top+bottom, 3 messages) From 74c8e6740695c4042f0ae683863d9a1eb1264c72 Mon Sep 17 00:00:00 2001 From: Gordon Hayes Date: Fri, 8 Oct 2021 10:33:11 +0200 Subject: [PATCH 08/35] refactor: IndexKey to ValueKey --- .../lib/src/message_list_view.dart | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view.dart index b6ea7ff1..cb0d75dd 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -473,12 +473,15 @@ class _MessageListViewState extends State { physics: widget.scrollPhysics, itemScrollController: _scrollController, reverse: widget.reverse, - itemCount: messages.length, + itemCount: itemCount, findChildIndexCallback: (Key key) { final indexedKey = key as IndexedKey; - final index = messagesIndex[indexedKey.value]; - if (index != null) { - return ((index + 2) * 2) - 1; + final valueKey = indexedKey.key as ValueKey?; + if (valueKey != null) { + final index = messagesIndex[valueKey.value]; + if (index != null) { + return ((index + 2) * 2) - 1; + } } }, @@ -628,12 +631,14 @@ class _MessageListViewState extends State { messages, streamChannel!, i - 2, - i, ); } else { - messageWidget = buildMessage(message, messages, i - 2, i); + messageWidget = buildMessage(message, messages, i - 2); } - return messageWidget; + return KeyedSubtree( + key: ValueKey(message.id), + child: messageWidget, + ); }, ), ), @@ -856,13 +861,10 @@ class _MessageListViewState extends State { List messages, StreamChannelState streamChannel, int index, - int buildItemIndex, ) { - final messageWidget = - buildMessage(message, messages, index, buildItemIndex); - + final messageWidget = buildMessage(message, messages, index); return VisibilityDetector( - key: IndexedKey(message.id, buildItemIndex), + key: ValueKey('visibility: ${message.id}'), onVisibilityChanged: (visibility) { final isVisible = visibility.visibleBounds != Rect.zero; if (isVisible) { @@ -954,13 +956,11 @@ class _MessageListViewState extends State { return defaultMessageWidget; } - Widget buildMessage( - Message message, List messages, int index, int buildItemIndex) { + Widget buildMessage(Message message, List messages, int index) { if ((message.type == 'system' || message.type == 'error') && message.text?.isNotEmpty == true) { return widget.systemMessageBuilder?.call(context, message) ?? SystemMessage( - key: IndexedKey(message.id, buildItemIndex), message: message, onMessageTap: (message) { if (widget.onSystemMessageTap != null) { @@ -1040,7 +1040,6 @@ class _MessageListViewState extends State { members.firstWhereOrNull((e) => e.user!.id == currentUser!.id); Widget messageWidget = MessageWidget( - key: IndexedKey(message.id, buildItemIndex), message: message, reverse: isMyMessage, showReactions: !message.isDeleted, From b4e544930e70892f963ac2a49f3c95202dde9a54 Mon Sep 17 00:00:00 2001 From: Salvatore Giordano Date: Mon, 11 Oct 2021 11:30:43 +0200 Subject: [PATCH 09/35] fix tests --- .../stream_chat_flutter/test/src/message_list_view_test.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/stream_chat_flutter/test/src/message_list_view_test.dart b/packages/stream_chat_flutter/test/src/message_list_view_test.dart index 8217f6de..ab583532 100644 --- a/packages/stream_chat_flutter/test/src/message_list_view_test.dart +++ b/packages/stream_chat_flutter/test/src/message_list_view_test.dart @@ -22,6 +22,8 @@ void main() { .thenAnswer((_) => const Stream.empty()); when(() => channelClientState.messages).thenReturn([]); when(() => channelClientState.isUpToDate).thenReturn(true); + when(() => channelClientState.isUpToDateStream) + .thenAnswer((_) => Stream.value(true)); }); // https://github.com/GetStream/stream-chat-flutter/issues/674 From 3a7343904d2aa29f38ad7dfb1de86086e935362c Mon Sep 17 00:00:00 2001 From: Gordon Hayes Date: Fri, 15 Oct 2021 15:13:59 +0200 Subject: [PATCH 10/35] feat: move scrollable positioned list to internal src --- .../lib/src/message_list_view.dart | 2 +- .../src/scrollable_positioned_list/LICENSE | 26 + .../src/scrollable_positioned_list/README.md | 53 + .../scrollable_positioned_list.dart | 7 + .../src/element_registry.dart | 96 + .../src/indexed_key.dart | 30 + .../src/item_positions_listener.dart | 61 + .../src/item_positions_notifier.dart | 13 + .../src/positioned_list.dart | 359 +++ .../src/post_mount_callback.dart | 35 + .../src/scroll_view.dart | 76 + .../src/scrollable_positioned_list.dart | 591 +++++ .../src/viewport.dart | 315 +++ packages/stream_chat_flutter/pubspec.yaml | 5 - ...ontal_scrollable_positioned_list_test.dart | 298 +++ .../positioned_list_test.dart | 363 +++ .../reversed_positioned_list_test.dart | 270 ++ ...ersed_scrollable_positioned_list_test.dart | 239 ++ .../scrollable_positioned_list_test.dart | 2301 +++++++++++++++++ .../separated_positioned_list_test.dart | 261 ++ ...rated_scrollable_positioned_list_test.dart | 608 +++++ ...ontal_scrollable_positioned_list_test.dart | 220 ++ 22 files changed, 6223 insertions(+), 6 deletions(-) create mode 100644 packages/stream_chat_flutter/lib/src/scrollable_positioned_list/LICENSE create mode 100644 packages/stream_chat_flutter/lib/src/scrollable_positioned_list/README.md create mode 100644 packages/stream_chat_flutter/lib/src/scrollable_positioned_list/scrollable_positioned_list.dart create mode 100644 packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/element_registry.dart create mode 100644 packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/indexed_key.dart create mode 100644 packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/item_positions_listener.dart create mode 100644 packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/item_positions_notifier.dart create mode 100644 packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/positioned_list.dart create mode 100644 packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/post_mount_callback.dart create mode 100644 packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scroll_view.dart create mode 100644 packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scrollable_positioned_list.dart create mode 100644 packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/viewport.dart create mode 100644 packages/stream_chat_flutter/test/src/scrollable_positioned_list/horizontal_scrollable_positioned_list_test.dart create mode 100644 packages/stream_chat_flutter/test/src/scrollable_positioned_list/positioned_list_test.dart create mode 100644 packages/stream_chat_flutter/test/src/scrollable_positioned_list/reversed_positioned_list_test.dart create mode 100644 packages/stream_chat_flutter/test/src/scrollable_positioned_list/reversed_scrollable_positioned_list_test.dart create mode 100644 packages/stream_chat_flutter/test/src/scrollable_positioned_list/scrollable_positioned_list_test.dart create mode 100644 packages/stream_chat_flutter/test/src/scrollable_positioned_list/separated_positioned_list_test.dart create mode 100644 packages/stream_chat_flutter/test/src/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart create mode 100644 packages/stream_chat_flutter/test/src/scrollable_positioned_list/seperated_horizontal_scrollable_positioned_list_test.dart diff --git a/packages/stream_chat_flutter/lib/src/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view.dart index cb0d75dd..3b2d737d 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -7,10 +7,10 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; import 'package:rxdart/rxdart.dart'; -import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:stream_chat_flutter/src/extension.dart'; import 'package:stream_chat_flutter/src/info_tile.dart'; import 'package:stream_chat_flutter/src/message_widget.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; import 'package:stream_chat_flutter/src/swipeable.dart'; import 'package:stream_chat_flutter/src/system_message.dart'; diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/LICENSE b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/LICENSE new file mode 100644 index 00000000..7cc8ade1 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/LICENSE @@ -0,0 +1,26 @@ +Copyright 2018 the Dart project authors, Inc. All rights reserved. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/README.md b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/README.md new file mode 100644 index 00000000..41b1f70b --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/README.md @@ -0,0 +1,53 @@ +# scrollable_positioned_list + +A flutter list that allows scrolling to a specific item in the list. + +Also allows determining what items are currently visible. + +## Usage + +A `ScrollablePositionedList` works much like the builder version of `ListView` +except that the list can be scrolled or jumped to a specific item. + +### Example + +A `ScrollablePositionedList` can be created with: + +```dart +final ItemScrollController itemScrollController = ItemScrollController(); +final ItemPositionsListener itemPositionsListener = ItemPositionsListener.create(); + +ScrollablePositionedList.builder( + itemCount: 500, + itemBuilder: (context, index) => Text('Item $index'), + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, +); +``` + +One then can scroll to a particular item with: + +```dart +itemScrollController.scrollTo( + index: 150, + duration: Duration(seconds: 2), + curve: Curves.easeInOutCubic); +``` + +or jump to a particular item with: + +```dart +itemScrollController.jumpTo(index: 150); +``` + +One can monitor what items are visible on screen with: + +```dart +itemPositionsListener.itemPositions.addListener(() => ...); +``` + +A full example can be found in the example folder. + +-------------------------------------------------------------------------------- + +This is not an officially supported Google product. diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/scrollable_positioned_list.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/scrollable_positioned_list.dart new file mode 100644 index 00000000..c275b611 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/scrollable_positioned_list.dart @@ -0,0 +1,7 @@ +// Copyright 2019 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +export 'src/indexed_key.dart'; +export 'src/item_positions_listener.dart'; +export 'src/scrollable_positioned_list.dart'; diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/element_registry.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/element_registry.dart new file mode 100644 index 00000000..b24a0c17 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/element_registry.dart @@ -0,0 +1,96 @@ +// Copyright 2019 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; + +/// A registry to track some [Element]s in the tree. +class RegistryWidget extends StatefulWidget { + /// Creates a [RegistryWidget]. + const RegistryWidget({Key? key, this.elementNotifier, required this.child}) + : super(key: key); + + /// The widget below this widget in the tree. + final Widget child; + + /// Contains the current set of all [Element]s created by + /// [RegisteredElementWidget]s in the tree below this widget. + /// + /// Note that if there is another [RegistryWidget] in this widget's subtree + /// that registry, and not this one, will collect elements in its subtree. + final ValueNotifier?>? elementNotifier; + + @override + State createState() => _RegistryWidgetState(); +} + +/// A widget whose [Element] will be added its nearest ancestor +/// [RegistryWidget]. +class RegisteredElementWidget extends ProxyWidget { + /// Creates a [RegisteredElementWidget]. + const RegisteredElementWidget({Key? key, required Widget child}) + : super(key: key, child: child); + + @override + Element createElement() => _RegisteredElement(this); +} + +class _RegistryWidgetState extends State { + final Set registeredElements = {}; + + @override + Widget build(BuildContext context) => _InheritedRegistryWidget( + state: this, + child: widget.child, + ); +} + +class _InheritedRegistryWidget extends InheritedWidget { + final _RegistryWidgetState state; + + const _InheritedRegistryWidget( + {Key? key, required this.state, required Widget child}) + : super(key: key, child: child); + + @override + bool updateShouldNotify(InheritedWidget oldWidget) => true; +} + +class _RegisteredElement extends ProxyElement { + _RegisteredElement(ProxyWidget widget) : super(widget); + + @override + void notifyClients(ProxyWidget oldWidget) {} + + late _RegistryWidgetState _registryWidgetState; + + @override + void mount(Element? parent, dynamic newSlot) { + super.mount(parent, newSlot); + final _inheritedRegistryWidget = + dependOnInheritedWidgetOfExactType<_InheritedRegistryWidget>()!; + _registryWidgetState = _inheritedRegistryWidget.state; + _registryWidgetState.registeredElements.add(this); + _registryWidgetState.widget.elementNotifier?.value = + _registryWidgetState.registeredElements; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final _inheritedRegistryWidget = + dependOnInheritedWidgetOfExactType<_InheritedRegistryWidget>()!; + _registryWidgetState = _inheritedRegistryWidget.state; + _registryWidgetState.registeredElements.add(this); + _registryWidgetState.widget.elementNotifier?.value = + _registryWidgetState.registeredElements; + } + + @override + void unmount() { + _registryWidgetState.registeredElements.remove(this); + _registryWidgetState.widget.elementNotifier?.value = + _registryWidgetState.registeredElements; + super.unmount(); + } +} diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/indexed_key.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/indexed_key.dart new file mode 100644 index 00000000..689996d0 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/indexed_key.dart @@ -0,0 +1,30 @@ +import 'dart:ui' show hashValues; + +import 'package:flutter/foundation.dart'; + +class IndexedKey extends LocalKey { + /// Creates an indexed key that delegates its [operator==] to the given key. + /// + /// It contains an index used in [ScrollablePositionedList]. + const IndexedKey(this.key, this.index); + + /// The key to which this this delegates its [operator==]. + final Key? key; + + /// Index used to show position in a list. + final int index; + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) return false; + return other is IndexedKey && other.key == key; + } + + @override + int get hashCode => hashValues(runtimeType, key); + + @override + String toString() { + return '(IndexedKey) index: $index, key: $key'; + } +} diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/item_positions_listener.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/item_positions_listener.dart new file mode 100644 index 00000000..dfb3e7d8 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/item_positions_listener.dart @@ -0,0 +1,61 @@ +// Copyright 2019 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; + +import 'item_positions_notifier.dart'; +import 'scrollable_positioned_list.dart'; + +/// Provides a listenable iterable of [itemPositions] of items that are on +/// screen and their locations. +abstract class ItemPositionsListener { + /// Creates an [ItemPositionsListener] that can be used by a + /// [ScrollablePositionedList] to return the current position of items. + factory ItemPositionsListener.create() => ItemPositionsNotifier(); + + /// The position of items that are at least partially visible in the viewport. + ValueListenable> get itemPositions; +} + +/// Position information for an item in the list. +class ItemPosition { + /// Create an [ItemPosition]. + const ItemPosition( + {required this.index, + required this.itemLeadingEdge, + required this.itemTrailingEdge}); + + /// Index of the item. + final int index; + + /// Distance in proportion of the viewport's main axis length from the leading + /// edge of the viewport to the leading edge of the item. + /// + /// May be negative if the item is partially visible. + final double itemLeadingEdge; + + /// Distance in proportion of the viewport's main axis length from the leading + /// edge of the viewport to the trailing edge of the item. + /// + /// May be greater than one if the item is partially visible. + final double itemTrailingEdge; + + @override + bool operator ==(dynamic other) { + if (other.runtimeType != runtimeType) return false; + final ItemPosition otherPosition = other; + return otherPosition.index == index && + otherPosition.itemLeadingEdge == itemLeadingEdge && + otherPosition.itemTrailingEdge == itemTrailingEdge; + } + + @override + int get hashCode => + 31 * (31 * (7 + index.hashCode) + itemLeadingEdge.hashCode) + + itemTrailingEdge.hashCode; + + @override + String toString() => + 'ItemPosition(index: $index, itemLeadingEdge: $itemLeadingEdge, itemTrailingEdge: $itemTrailingEdge)'; +} diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/item_positions_notifier.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/item_positions_notifier.dart new file mode 100644 index 00000000..a2b993a6 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/item_positions_notifier.dart @@ -0,0 +1,13 @@ +// Copyright 2019 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; + +import 'item_positions_listener.dart'; + +/// Internal implementation of [ItemPositionsListener]. +class ItemPositionsNotifier implements ItemPositionsListener { + @override + final ValueNotifier> itemPositions = ValueNotifier([]); +} diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/positioned_list.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/positioned_list.dart new file mode 100644 index 00000000..c53f017e --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/positioned_list.dart @@ -0,0 +1,359 @@ +// Copyright 2019 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; + +import 'element_registry.dart'; +import 'indexed_key.dart'; +import 'item_positions_listener.dart'; +import 'item_positions_notifier.dart'; +import 'scroll_view.dart'; + +/// A list of widgets similar to [ListView], except scroll control +/// and position reporting is based on index rather than pixel offset. +/// +/// [PositionedList] lays out children in the same way as [ListView]. +/// +/// The list can be displayed with the item at [positionIndex] positioned at a +/// particular [alignment]. See [ItemScrollController.jumpTo] for an +/// explanation of alignment. +/// +/// All other parameters are the same as specified in [ListView]. +class PositionedList extends StatefulWidget { + /// Create a [PositionedList]. + const PositionedList({ + required this.itemCount, + required this.itemBuilder, + this.separatorBuilder, + this.controller, + this.itemPositionsNotifier, + this.positionedIndex = 0, + this.alignment = 0, + this.scrollDirection = Axis.vertical, + this.reverse = false, + this.physics, + this.padding, + this.cacheExtent, + this.semanticChildCount, + this.findChildIndexCallback, + this.addSemanticIndexes = true, + this.addRepaintBoundaries = true, + this.addAutomaticKeepAlives = true, + }) : assert(itemCount != null), + assert(itemBuilder != null), + assert((positionedIndex == 0) || (positionedIndex < itemCount)); + + /// Called to find the new index of a child based on its key in case of reordering. + /// + /// If not provided, a child widget may not map to its existing [RenderObject] + /// when the order in which children are returned from [builder] changes. + /// This may result in state-loss. + /// + /// This callback should take an input [Key], and it should return the + /// index of the child element with that associated key, or null if not found. + final ChildIndexGetter? findChildIndexCallback; + + /// Number of items the [itemBuilder] can produce. + final int itemCount; + + /// Called to build children for the list with + /// 0 <= index < itemCount. + final IndexedWidgetBuilder itemBuilder; + + /// If not null, called to build separators for between each item in the list. + /// Called with 0 <= index < itemCount - 1. + final IndexedWidgetBuilder? separatorBuilder; + + /// An object that can be used to control the position to which this scroll + /// view is scrolled. + final ScrollController? controller; + + /// Notifier that reports the items laid out in the list after each frame. + final ItemPositionsNotifier? itemPositionsNotifier; + + /// Index of an item to initially align to a position within the viewport + /// defined by [alignment]. + final int positionedIndex; + + /// Determines where the leading edge of the item at [positionedIndex] + /// should be placed. + /// + /// See [ItemScrollController.jumpTo] for an explanation of alignment. + final double alignment; + + /// The axis along which the scroll view scrolls. + /// + /// Defaults to [Axis.vertical]. + final Axis scrollDirection; + + /// Whether the view scrolls in the reading direction. + /// + /// Defaults to false. + /// + /// See [ScrollView.reverse]. + final bool reverse; + + /// 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. + /// + /// See [ScrollView.physics]. + final ScrollPhysics? physics; + + /// {@macro flutter.widgets.scrollable.cacheExtent} + final double? cacheExtent; + + /// The number of children that will contribute semantic information. + /// + /// See [ScrollView.semanticChildCount] for more information. + final int? semanticChildCount; + + /// Whether to wrap each child in an [IndexedSemantics]. + /// + /// See [SliverChildBuilderDelegate.addSemanticIndexes]. + final bool addSemanticIndexes; + + /// The amount of space by which to inset the children. + final EdgeInsets? padding; + + /// Whether to wrap each child in a [RepaintBoundary]. + /// + /// See [SliverChildBuilderDelegate.addRepaintBoundaries]. + final bool addRepaintBoundaries; + + /// Whether to wrap each child in an [AutomaticKeepAlive]. + /// + /// See [SliverChildBuilderDelegate.addAutomaticKeepAlives]. + final bool addAutomaticKeepAlives; + + @override + State createState() => _PositionedListState(); +} + +class _PositionedListState extends State { + final Key _centerKey = UniqueKey(); + + final registeredElements = ValueNotifier?>(null); + late final ScrollController scrollController; + + bool updateScheduled = false; + + @override + void initState() { + super.initState(); + scrollController = widget.controller ?? ScrollController(); + scrollController.addListener(_schedulePositionNotificationUpdate); + _schedulePositionNotificationUpdate(); + } + + @override + void dispose() { + scrollController.removeListener(_schedulePositionNotificationUpdate); + super.dispose(); + } + + @override + void didUpdateWidget(PositionedList oldWidget) { + super.didUpdateWidget(oldWidget); + _schedulePositionNotificationUpdate(); + } + + @override + Widget build(BuildContext context) => RegistryWidget( + elementNotifier: registeredElements, + child: UnboundedCustomScrollView( + anchor: widget.alignment, + center: _centerKey, + controller: scrollController, + scrollDirection: widget.scrollDirection, + reverse: widget.reverse, + cacheExtent: widget.cacheExtent, + physics: widget.physics, + semanticChildCount: widget.semanticChildCount ?? widget.itemCount, + slivers: [ + if (widget.positionedIndex > 0) + SliverPadding( + padding: _leadingSliverPadding, + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => widget.separatorBuilder == null + ? _buildItem(widget.positionedIndex - (index + 1)) + : _buildSeparatedListElement( + 2 * widget.positionedIndex - (index + 1)), + childCount: widget.separatorBuilder == null + ? widget.positionedIndex + : 2 * widget.positionedIndex, + addSemanticIndexes: false, + findChildIndexCallback: widget.findChildIndexCallback, + addRepaintBoundaries: widget.addRepaintBoundaries, + addAutomaticKeepAlives: widget.addAutomaticKeepAlives, + ), + ), + ), + SliverPadding( + key: _centerKey, + padding: _centerSliverPadding, + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => widget.separatorBuilder == null + ? _buildItem(index + widget.positionedIndex) + : _buildSeparatedListElement( + index + 2 * widget.positionedIndex), + childCount: widget.itemCount != 0 ? 1 : 0, + findChildIndexCallback: widget.findChildIndexCallback, + addSemanticIndexes: false, + addRepaintBoundaries: widget.addRepaintBoundaries, + addAutomaticKeepAlives: widget.addAutomaticKeepAlives, + ), + ), + ), + if (widget.positionedIndex >= 0 && + widget.positionedIndex < widget.itemCount - 1) + SliverPadding( + padding: _trailingSliverPadding, + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => widget.separatorBuilder == null + ? _buildItem(index + widget.positionedIndex + 1) + : _buildSeparatedListElement( + index + 2 * widget.positionedIndex + 1), + childCount: widget.separatorBuilder == null + ? widget.itemCount - widget.positionedIndex - 1 + : 2 * (widget.itemCount - widget.positionedIndex - 1), + findChildIndexCallback: widget.findChildIndexCallback, + addSemanticIndexes: false, + addRepaintBoundaries: widget.addRepaintBoundaries, + addAutomaticKeepAlives: widget.addAutomaticKeepAlives, + ), + ), + ), + ], + ), + ); + + Widget _buildSeparatedListElement(int index) { + if (index.isEven) { + return _buildItem(index ~/ 2); + } else { + return widget.separatorBuilder!(context, index ~/ 2); + } + } + + Widget _buildItem(int index) { + final child = widget.itemBuilder(context, index); + return RegisteredElementWidget( + key: IndexedKey(child.key, index), + child: widget.addSemanticIndexes + ? IndexedSemantics(index: index, child: child) + : child, + ); + } + + EdgeInsets get _leadingSliverPadding => + (widget.scrollDirection == Axis.vertical + ? widget.reverse + ? widget.padding?.copyWith(top: 0) + : widget.padding?.copyWith(bottom: 0) + : widget.reverse + ? widget.padding?.copyWith(left: 0) + : widget.padding?.copyWith(right: 0)) ?? + EdgeInsets.all(0); + + EdgeInsets get _centerSliverPadding => widget.scrollDirection == Axis.vertical + ? widget.reverse + ? widget.padding?.copyWith( + top: widget.positionedIndex == widget.itemCount - 1 + ? widget.padding!.top + : 0, + bottom: widget.positionedIndex == 0 + ? widget.padding!.bottom + : 0) ?? + EdgeInsets.all(0) + : widget.padding?.copyWith( + top: widget.positionedIndex == 0 ? widget.padding!.top : 0, + bottom: widget.positionedIndex == widget.itemCount - 1 + ? widget.padding!.bottom + : 0) ?? + EdgeInsets.all(0) + : widget.reverse + ? widget.padding?.copyWith( + left: widget.positionedIndex == widget.itemCount - 1 + ? widget.padding!.left + : 0, + right: widget.positionedIndex == 0 + ? widget.padding!.right + : 0) ?? + EdgeInsets.all(0) + : widget.padding?.copyWith( + left: widget.positionedIndex == 0 ? widget.padding!.left : 0, + right: widget.positionedIndex == widget.itemCount - 1 + ? widget.padding!.right + : 0, + ) ?? + EdgeInsets.all(0); + + EdgeInsets get _trailingSliverPadding => + widget.scrollDirection == Axis.vertical + ? widget.reverse + ? widget.padding?.copyWith(bottom: 0) ?? EdgeInsets.all(0) + : widget.padding?.copyWith(top: 0) ?? EdgeInsets.all(0) + : widget.reverse + ? widget.padding?.copyWith(right: 0) ?? EdgeInsets.all(0) + : widget.padding?.copyWith(left: 0) ?? EdgeInsets.all(0); + + void _schedulePositionNotificationUpdate() { + if (!updateScheduled) { + updateScheduled = true; + SchedulerBinding.instance!.addPostFrameCallback((_) { + if (registeredElements.value == null) { + updateScheduled = false; + return; + } + final positions = []; + RenderViewport? viewport; + for (var element in registeredElements.value!) { + final RenderBox box = element.renderObject as RenderBox; + viewport ??= RenderAbstractViewport.of(box) as RenderViewport?; + final IndexedKey key = element.widget.key as IndexedKey; + if (widget.scrollDirection == Axis.vertical) { + final reveal = viewport!.getOffsetToReveal(box, 0).offset; + final itemOffset = reveal - + viewport.offset.pixels + + viewport.anchor * viewport.size.height; + positions.add(ItemPosition( + index: key.index, + itemLeadingEdge: itemOffset.round() / + scrollController.position.viewportDimension, + itemTrailingEdge: (itemOffset + box.size.height).round() / + scrollController.position.viewportDimension)); + } else { + final itemOffset = + box.localToGlobal(Offset.zero, ancestor: viewport).dx; + positions.add(ItemPosition( + index: key.index, + itemLeadingEdge: (widget.reverse + ? scrollController.position.viewportDimension - + (itemOffset + box.size.width) + : itemOffset) + .round() / + scrollController.position.viewportDimension, + itemTrailingEdge: (widget.reverse + ? scrollController.position.viewportDimension - + itemOffset + : (itemOffset + box.size.width)) + .round() / + scrollController.position.viewportDimension)); + } + } + widget.itemPositionsNotifier?.itemPositions.value = positions; + updateScheduled = false; + }); + } + } +} diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/post_mount_callback.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/post_mount_callback.dart new file mode 100644 index 00000000..9ec553fa --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/post_mount_callback.dart @@ -0,0 +1,35 @@ +// Copyright 2019 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; + +/// Widget whose [Element] calls a callback when the element is mounted. +class PostMountCallback extends StatelessWidget { + /// Creates a [PostMountCallback] widget. + const PostMountCallback({required this.child, this.callback, Key? key}) + : super(key: key); + + /// The widget below this widget in the tree. + final Widget child; + + /// Callback to call when the element for this widget is mounted. + final void Function()? callback; + + @override + StatelessElement createElement() => _PostMountCallbackElement(this); + + @override + Widget build(BuildContext context) => child; +} + +class _PostMountCallbackElement extends StatelessElement { + _PostMountCallbackElement(PostMountCallback widget) : super(widget); + + @override + void mount(Element? parent, dynamic newSlot) { + super.mount(parent, newSlot); + final PostMountCallback postMountCallback = widget as PostMountCallback; + postMountCallback.callback?.call(); + } +} diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scroll_view.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scroll_view.dart new file mode 100644 index 00000000..e00462e2 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scroll_view.dart @@ -0,0 +1,76 @@ +// Copyright 2019 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'viewport.dart'; + +/// A version of [CustomScrollView] that allows does not constrict the extents +/// to be within 0 and 1. See [CustomScrollView] for more information. +class UnboundedCustomScrollView extends CustomScrollView { + const UnboundedCustomScrollView({ + Key? key, + Axis scrollDirection = Axis.vertical, + bool reverse = false, + ScrollController? controller, + bool? primary, + ScrollPhysics? physics, + bool shrinkWrap = false, + Key? center, + double anchor = 0.0, + double? cacheExtent, + List slivers = const [], + int? semanticChildCount, + DragStartBehavior dragStartBehavior = DragStartBehavior.start, + }) : _anchor = anchor, + super( + key: key, + scrollDirection: scrollDirection, + reverse: reverse, + controller: controller, + primary: primary, + physics: physics, + shrinkWrap: shrinkWrap, + center: center, + cacheExtent: cacheExtent, + semanticChildCount: semanticChildCount, + dragStartBehavior: dragStartBehavior, + slivers: slivers, + ); + + // [CustomScrollView] enforces constraints on [CustomScrollView.anchor], so + // we need our own version. + final double _anchor; + + @override + double get anchor => _anchor; + + /// Build the viewport. + @override + @protected + Widget buildViewport( + BuildContext context, + ViewportOffset offset, + AxisDirection axisDirection, + List slivers, + ) { + if (shrinkWrap) { + return ShrinkWrappingViewport( + axisDirection: axisDirection, + offset: offset, + slivers: slivers, + ); + } + return UnboundedViewport( + axisDirection: axisDirection, + offset: offset, + slivers: slivers, + cacheExtent: cacheExtent, + center: center, + anchor: anchor, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scrollable_positioned_list.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scrollable_positioned_list.dart new file mode 100644 index 00000000..d262578b --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scrollable_positioned_list.dart @@ -0,0 +1,591 @@ +// Copyright 2019 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:math'; + +import 'package:collection/collection.dart' show IterableExtension; +import 'package:flutter/foundation.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; + +import 'item_positions_listener.dart'; +import 'item_positions_notifier.dart'; +import 'positioned_list.dart'; +import 'post_mount_callback.dart'; + +/// Number of screens to scroll when scrolling a long distance. +const int _screenScrollCount = 2; + +/// A scrollable list of widgets similar to [ListView], except scroll control +/// and position reporting is based on index rather than pixel offset. +/// +/// [ScrollablePositionedList] lays out children in the same way as [ListView]. +/// +/// The list can be displayed with the item at [initialScrollIndex] positioned +/// at a particular [initialAlignment]. +/// +/// The [itemScrollController] can be used to scroll or jump to particular items +/// in the list. The [itemPositionsNotifier] can be used to get a list of items +/// currently laid out by the list. +/// +/// All other parameters are the same as specified in [ListView]. +class ScrollablePositionedList extends StatefulWidget { + /// Create a [ScrollablePositionedList] whose items are provided by + /// [itemBuilder]. + const ScrollablePositionedList.builder({ + required this.itemCount, + required this.itemBuilder, + Key? key, + this.itemScrollController, + ItemPositionsListener? itemPositionsListener, + this.initialScrollIndex = 0, + this.initialAlignment = 0, + this.scrollDirection = Axis.vertical, + this.reverse = false, + this.physics, + this.semanticChildCount, + this.padding, + this.addSemanticIndexes = true, + this.addAutomaticKeepAlives = true, + this.addRepaintBoundaries = true, + this.minCacheExtent, + this.findChildIndexCallback, + }) : assert(itemCount != null), + assert(itemBuilder != null), + itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?, + separatorBuilder = null, + super(key: key); + + /// Create a [ScrollablePositionedList] whose items are provided by + /// [itemBuilder] and separators provided by [separatorBuilder]. + const ScrollablePositionedList.separated({ + required this.itemCount, + required this.itemBuilder, + required this.separatorBuilder, + Key? key, + this.itemScrollController, + ItemPositionsListener? itemPositionsListener, + this.initialScrollIndex = 0, + this.initialAlignment = 0, + this.scrollDirection = Axis.vertical, + this.reverse = false, + this.physics, + this.semanticChildCount, + this.padding, + this.addSemanticIndexes = true, + this.addAutomaticKeepAlives = true, + this.addRepaintBoundaries = true, + this.minCacheExtent, + this.findChildIndexCallback, + }) : assert(itemCount != null), + assert(itemBuilder != null), + assert(separatorBuilder != null), + itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?, + super(key: key); + + /// Called to find the new index of a child based on its key in case of reordering. + /// + /// If not provided, a child widget may not map to its existing [RenderObject] + /// when the order in which children are returned from [builder] changes. + /// This may result in state-loss. + /// + /// This callback should take an input [Key], and it should return the + /// index of the child element with that associated key, or null if not found. + final ChildIndexGetter? findChildIndexCallback; + + /// Number of items the [itemBuilder] can produce. + final int itemCount; + + /// Called to build children for the list with + /// 0 <= index < itemCount. + final IndexedWidgetBuilder itemBuilder; + + /// Called to build separators for between each item in the list. + /// Called with 0 <= index < itemCount - 1. + final IndexedWidgetBuilder? separatorBuilder; + + /// Controller for jumping or scrolling to an item. + final ItemScrollController? itemScrollController; + + /// Notifier that reports the items laid out in the list after each frame. + final ItemPositionsNotifier? itemPositionsNotifier; + + /// 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. + /// + /// See [ItemScrollController.jumpTo] for an explanation of alignment. + final double initialAlignment; + + /// The axis along which the scroll view scrolls. + /// + /// Defaults to [Axis.vertical]. + final Axis scrollDirection; + + /// Whether the view scrolls in the reading direction. + /// + /// Defaults to false. + /// + /// See [ScrollView.reverse]. + final bool reverse; + + /// 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. + /// + /// See [ScrollView.physics]. + final ScrollPhysics? physics; + + /// The number of children that will contribute semantic information. + /// + /// See [ScrollView.semanticChildCount] for more information. + final int? semanticChildCount; + + /// The amount of space by which to inset the children. + final EdgeInsets? padding; + + /// Whether to wrap each child in an [IndexedSemantics]. + /// + /// See [SliverChildBuilderDelegate.addSemanticIndexes]. + final bool addSemanticIndexes; + + /// Whether to wrap each child in an [AutomaticKeepAlive]. + /// + /// See [SliverChildBuilderDelegate.addAutomaticKeepAlives]. + final bool addAutomaticKeepAlives; + + /// Whether to wrap each child in a [RepaintBoundary]. + /// + /// See [SliverChildBuilderDelegate.addRepaintBoundaries]. + final bool addRepaintBoundaries; + + /// The minimum cache extent used by the underlying scroll lists. + /// See [ScrollView.cacheExtent]. + /// + /// Note that the [ScrollablePositionedList] uses two lists to simulate long + /// scrolls, so using the [ScrollController.scrollTo] method may result + /// in builds of widgets that would otherwise already be built in the + /// cache extent. + final double? minCacheExtent; + + @override + State createState() => _ScrollablePositionedListState(); +} + +/// Controller to jump or scroll to a particular position in a +/// [ScrollablePositionedList]. +class ItemScrollController { + /// Whether any ScrollablePositionedList objects are attached this object. + /// + /// If `false`, then [jumpTo] and [scrollTo] must not be called. + bool get isAttached => _scrollableListState != null; + + _ScrollablePositionedListState? _scrollableListState; + + /// Immediately, without animation, reconfigure the list so that the item at + /// [index]'s leading edge is at the given [alignment]. + /// + /// The [alignment] specifies the desired position for the leading edge of the + /// item. The [alignment] is expected to be a value in the range \[0.0, 1.0\] + /// and represents a proportion along the main axis of the viewport. + /// + /// For a vertically scrolling view that is not reversed: + /// * 0 aligns the top edge of the item with the top edge of the view. + /// * 1 aligns the top edge of the item with the bottom of the view. + /// * 0.5 aligns the top edge of the item with the center of the view. + /// + /// For a horizontally scrolling view that is not reversed: + /// * 0 aligns the left edge of the item with the left edge of the view + /// * 1 aligns the left edge of the item with the right edge of the view. + /// * 0.5 aligns the left edge of the item with the center of the view. + void jumpTo({required int index, double alignment = 0}) { + _scrollableListState!._jumpTo(index: index, alignment: alignment); + } + + /// Animate the list over [duration] using the given [curve] such that the + /// item at [index] ends up with its leading edge at the given [alignment]. + /// See [jumpTo] for an explanation of alignment. + /// + /// The [duration] must be greater than 0; otherwise, use [jumpTo]. + /// + /// When item position is not available, because it's too far, the scroll + /// is composed into three phases: + /// + /// 1. The currently displayed list view starts scrolling. + /// 2. Another list view, which scrolls with the same speed, fades over the + /// first one and shows items that are close to the scroll target. + /// 3. The second list view scrolls and stops on the target. + /// + /// The [opacityAnimationWeights] can be used to apply custom weights to these + /// three stages of this animation. The default weights, `[40, 20, 40]`, are + /// good with default [Curves.linear]. Different weights might be better for + /// other cases. For example, if you use [Curves.easeOut], consider setting + /// [opacityAnimationWeights] to `[20, 20, 60]`. + /// + /// See [TweenSequenceItem.weight] for more info. + Future scrollTo({ + required int index, + double alignment = 0, + required Duration duration, + Curve curve = Curves.linear, + List opacityAnimationWeights = const [40, 20, 40], + }) { + assert(_scrollableListState != null); + assert(opacityAnimationWeights.length == 3); + assert(duration > Duration.zero); + return _scrollableListState!._scrollTo( + index: index, + alignment: alignment, + duration: duration, + curve: curve, + opacityAnimationWeights: opacityAnimationWeights, + ); + } + + void _attach(_ScrollablePositionedListState scrollableListState) { + assert(_scrollableListState == null); + _scrollableListState = scrollableListState; + } + + void _detach() { + _scrollableListState = null; + } +} + +class _ScrollablePositionedListState extends State + with TickerProviderStateMixin { + /// Details for the primary (active) [ListView]. + var primary = _ListDisplayDetails(const ValueKey('Ping')); + + /// Details for the secondary (transitional) [ListView] that is temporarily + /// shown when scrolling a long distance. + var secondary = _ListDisplayDetails(const ValueKey('Pong')); + + final opacity = ProxyAnimation(const AlwaysStoppedAnimation(0)); + + void Function() startAnimationCallback = () {}; + + bool _isTransitioning = false; + + @override + void initState() { + super.initState(); + ItemPosition? initialPosition = PageStorage.of(context)!.readState(context); + primary.target = initialPosition?.index ?? widget.initialScrollIndex; + primary.alignment = + initialPosition?.itemLeadingEdge ?? widget.initialAlignment; + if (widget.itemCount > 0 && primary.target > widget.itemCount - 1) { + primary.target = widget.itemCount - 1; + } + widget.itemScrollController?._attach(this); + primary.itemPositionsNotifier.itemPositions.addListener(_updatePositions); + secondary.itemPositionsNotifier.itemPositions.addListener(_updatePositions); + } + + @override + void deactivate() { + widget.itemScrollController?._detach(); + super.deactivate(); + } + + @override + void dispose() { + primary.itemPositionsNotifier.itemPositions + .removeListener(_updatePositions); + secondary.itemPositionsNotifier.itemPositions + .removeListener(_updatePositions); + super.dispose(); + } + + @override + void didUpdateWidget(ScrollablePositionedList oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.itemScrollController?._scrollableListState == this) { + oldWidget.itemScrollController?._detach(); + } + if (widget.itemScrollController?._scrollableListState != this) { + widget.itemScrollController?._detach(); + widget.itemScrollController?._attach(this); + } + + if (widget.itemCount == 0) { + setState(() { + primary.target = 0; + secondary.target = 0; + }); + } else { + if (primary.target > widget.itemCount - 1) { + setState(() { + primary.target = widget.itemCount - 1; + }); + } + if (secondary.target > widget.itemCount - 1) { + setState(() { + secondary.target = widget.itemCount - 1; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final cacheExtent = _cacheExtent(constraints); + return GestureDetector( + onPanDown: (_) => _stopScroll(canceled: true), + excludeFromSemantics: true, + child: Stack( + children: [ + PostMountCallback( + key: primary.key, + callback: startAnimationCallback, + child: FadeTransition( + opacity: ReverseAnimation(opacity), + child: NotificationListener( + onNotification: (_) => _isTransitioning, + child: PositionedList( + itemBuilder: widget.itemBuilder, + separatorBuilder: widget.separatorBuilder, + itemCount: widget.itemCount, + positionedIndex: primary.target, + controller: primary.scrollController, + itemPositionsNotifier: primary.itemPositionsNotifier, + scrollDirection: widget.scrollDirection, + reverse: widget.reverse, + cacheExtent: cacheExtent, + alignment: primary.alignment, + physics: widget.physics, + addSemanticIndexes: widget.addSemanticIndexes, + semanticChildCount: widget.semanticChildCount, + padding: widget.padding, + addAutomaticKeepAlives: widget.addAutomaticKeepAlives, + addRepaintBoundaries: widget.addRepaintBoundaries, + findChildIndexCallback: widget.findChildIndexCallback, + ), + ), + ), + ), + if (_isTransitioning) + PostMountCallback( + key: secondary.key, + callback: startAnimationCallback, + child: FadeTransition( + opacity: opacity, + child: NotificationListener( + onNotification: (_) => false, + child: PositionedList( + itemBuilder: widget.itemBuilder, + separatorBuilder: widget.separatorBuilder, + itemCount: widget.itemCount, + itemPositionsNotifier: secondary.itemPositionsNotifier, + positionedIndex: secondary.target, + controller: secondary.scrollController, + scrollDirection: widget.scrollDirection, + reverse: widget.reverse, + cacheExtent: cacheExtent, + alignment: secondary.alignment, + physics: widget.physics, + addSemanticIndexes: widget.addSemanticIndexes, + semanticChildCount: widget.semanticChildCount, + padding: widget.padding, + addAutomaticKeepAlives: widget.addAutomaticKeepAlives, + addRepaintBoundaries: widget.addRepaintBoundaries, + ), + ), + ), + ), + ], + ), + ); + }, + ); + } + + double _cacheExtent(BoxConstraints constraints) => max( + constraints.maxHeight * _screenScrollCount, + widget.minCacheExtent ?? 0, + ); + + void _jumpTo({required int index, required double alignment}) { + _stopScroll(canceled: true); + if (index > widget.itemCount - 1) { + index = widget.itemCount - 1; + } + setState(() { + primary.scrollController.jumpTo(0); + primary.target = index; + primary.alignment = alignment; + }); + } + + Future _scrollTo({ + required int index, + required double alignment, + required Duration duration, + Curve curve = Curves.linear, + required List opacityAnimationWeights, + }) async { + if (index > widget.itemCount - 1) { + index = widget.itemCount - 1; + } + if (_isTransitioning) { + _stopScroll(canceled: true); + SchedulerBinding.instance!.addPostFrameCallback((_) { + _startScroll( + index: index, + alignment: alignment, + duration: duration, + curve: curve, + opacityAnimationWeights: opacityAnimationWeights, + ); + }); + } else { + await _startScroll( + index: index, + alignment: alignment, + duration: duration, + curve: curve, + opacityAnimationWeights: opacityAnimationWeights, + ); + } + } + + Future _startScroll({ + required int index, + required double alignment, + required Duration duration, + Curve curve = Curves.linear, + required List opacityAnimationWeights, + }) async { + final direction = index > primary.target ? 1 : -1; + final itemPosition = primary.itemPositionsNotifier.itemPositions.value + .firstWhereOrNull( + (ItemPosition itemPosition) => itemPosition.index == index); + if (itemPosition != null) { + // Scroll directly. + final localScrollAmount = itemPosition.itemLeadingEdge * + primary.scrollController.position.viewportDimension; + await primary.scrollController.animateTo( + primary.scrollController.offset + + localScrollAmount - + alignment * primary.scrollController.position.viewportDimension, + duration: duration, + curve: curve); + } else { + final scrollAmount = _screenScrollCount * + primary.scrollController.position.viewportDimension; + final startCompleter = Completer(); + final endCompleter = Completer(); + startAnimationCallback = () { + SchedulerBinding.instance!.addPostFrameCallback((_) { + startAnimationCallback = () {}; + + opacity.parent = _opacityAnimation(opacityAnimationWeights).animate( + AnimationController(vsync: this, duration: duration)..forward()); + secondary.scrollController.jumpTo(-direction * + (_screenScrollCount * + primary.scrollController.position.viewportDimension - + alignment * + secondary.scrollController.position.viewportDimension)); + + startCompleter.complete(primary.scrollController.animateTo( + primary.scrollController.offset + direction * scrollAmount, + duration: duration, + curve: curve)); + endCompleter.complete(secondary.scrollController + .animateTo(0, duration: duration, curve: curve)); + }); + }; + setState(() { + // TODO: _startScroll can be re-entrant, which invalidates this assert. + // assert(!_isTransitioning); + secondary.target = index; + secondary.alignment = alignment; + _isTransitioning = true; + }); + await Future.wait([startCompleter.future, endCompleter.future]); + _stopScroll(); + } + } + + void _stopScroll({bool canceled = false}) { + if (!_isTransitioning) { + return; + } + + if (canceled) { + if (primary.scrollController.hasClients) { + primary.scrollController.jumpTo(primary.scrollController.offset); + } + if (secondary.scrollController.hasClients) { + secondary.scrollController.jumpTo(secondary.scrollController.offset); + } + } + + setState(() { + if (opacity.value >= 0.5) { + // Secondary [ListView] is more visible than the primary; make it the + // new primary. + var temp = primary; + primary = secondary; + secondary = temp; + } + _isTransitioning = false; + opacity.parent = const AlwaysStoppedAnimation(0); + }); + } + + Animatable _opacityAnimation(List opacityAnimationWeights) { + final startOpacity = 0.0; + final endOpacity = 1.0; + return TweenSequence(>[ + TweenSequenceItem( + tween: ConstantTween(startOpacity), + weight: opacityAnimationWeights[0]), + TweenSequenceItem( + tween: Tween(begin: startOpacity, end: endOpacity), + weight: opacityAnimationWeights[1]), + TweenSequenceItem( + tween: ConstantTween(endOpacity), + weight: opacityAnimationWeights[2]), + ]); + } + + void _updatePositions() { + final itemPositions = primary.itemPositionsNotifier.itemPositions.value + .where((ItemPosition position) => + position.itemLeadingEdge < 1 && position.itemTrailingEdge > 0); + if (itemPositions.isNotEmpty) { + PageStorage.of(context)!.writeState( + context, + itemPositions.reduce((value, element) => + value.itemLeadingEdge < element.itemLeadingEdge + ? value + : element)); + } + widget.itemPositionsNotifier?.itemPositions.value = itemPositions; + } +} + +class _ListDisplayDetails { + _ListDisplayDetails(this.key); + + final itemPositionsNotifier = ItemPositionsNotifier(); + final scrollController = ScrollController(keepScrollOffset: false); + + /// The index of the item to scroll to. + int target = 0; + + /// The desired alignment for [target]. + /// + /// See [ItemScrollController.jumpTo] for an explanation of alignment. + double alignment = 0; + + final Key key; +} diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/viewport.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/viewport.dart new file mode 100644 index 00000000..1de5ff05 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/viewport.dart @@ -0,0 +1,315 @@ +// Copyright 2019 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math' as math; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// A render object that is bigger on the inside. +/// +/// Version of [Viewport] with some modifications to how extents are +/// computed to allow scroll extents outside 0 to 1. See [Viewport] +/// for more information. +class UnboundedViewport extends Viewport { + UnboundedViewport({ + Key? key, + AxisDirection axisDirection = AxisDirection.down, + AxisDirection? crossAxisDirection, + double anchor = 0.0, + required ViewportOffset offset, + Key? center, + double? cacheExtent, + List slivers = const [], + }) : _anchor = anchor, + super( + key: key, + axisDirection: axisDirection, + crossAxisDirection: crossAxisDirection, + offset: offset, + center: center, + cacheExtent: cacheExtent, + slivers: slivers); + + // [Viewport] enforces constraints on [Viewport.anchor], so we need our own + // version. + final double _anchor; + + @override + double get anchor => _anchor; + + @override + RenderViewport createRenderObject(BuildContext context) { + return UnboundedRenderViewport( + axisDirection: axisDirection, + crossAxisDirection: crossAxisDirection ?? + Viewport.getDefaultCrossAxisDirection(context, axisDirection), + anchor: anchor, + offset: offset, + cacheExtent: cacheExtent, + ); + } +} + +/// A render object that is bigger on the inside. +/// +/// Version of [RenderViewport] with some modifications to how extents are +/// computed to allow scroll extents outside 0 to 1. See [RenderViewport] +/// for more information. +/// +// Differences from [RenderViewport] are marked with a //***** Differences +// comment. +class UnboundedRenderViewport extends RenderViewport { + /// Creates a viewport for [RenderSliver] objects. + UnboundedRenderViewport({ + AxisDirection axisDirection = AxisDirection.down, + required AxisDirection crossAxisDirection, + required ViewportOffset offset, + double anchor = 0.0, + List? children, + RenderSliver? center, + double? cacheExtent, + }) : _anchor = anchor, + super( + axisDirection: axisDirection, + crossAxisDirection: crossAxisDirection, + offset: offset, + center: center, + cacheExtent: cacheExtent, + children: children); + + static const int _maxLayoutCycles = 10; + + double _anchor; + + // Out-of-band data computed during layout. + late double _minScrollExtent; + late double _maxScrollExtent; + bool _hasVisualOverflow = false; + + /// This value is set during layout based on the [CacheExtentStyle]. + /// + /// When the style is [CacheExtentStyle.viewport], it is the main axis extent + /// of the viewport multiplied by the requested cache extent, which is still + /// expressed in pixels. + double? _calculatedCacheExtent; + + @override + double get anchor => _anchor; + + @override + set anchor(double value) { + assert(value != null); + if (value == _anchor) return; + _anchor = value; + markNeedsLayout(); + } + + @override + void performResize() { + super.performResize(); + // TODO: Figure out why this override is needed as a result of + // https://github.com/flutter/flutter/pull/61973 and see if it can be + // removed somehow. + switch (axis) { + case Axis.vertical: + offset.applyViewportDimension(size.height); + break; + case Axis.horizontal: + offset.applyViewportDimension(size.width); + break; + } + } + + @override + Rect describeSemanticsClip(RenderSliver? child) { + assert(axis != null); + + if (_calculatedCacheExtent == null) { + return semanticBounds; + } + + switch (axis) { + case Axis.vertical: + return Rect.fromLTRB( + semanticBounds.left, + semanticBounds.top - _calculatedCacheExtent!, + semanticBounds.right, + semanticBounds.bottom + _calculatedCacheExtent!, + ); + default: + return Rect.fromLTRB( + semanticBounds.left - _calculatedCacheExtent!, + semanticBounds.top, + semanticBounds.right + _calculatedCacheExtent!, + semanticBounds.bottom, + ); + } + } + + @override + void performLayout() { + if (center == null) { + assert(firstChild == null); + _minScrollExtent = 0.0; + _maxScrollExtent = 0.0; + _hasVisualOverflow = false; + offset.applyContentDimensions(0.0, 0.0); + return; + } + assert(center!.parent == this); + + late double mainAxisExtent; + late double crossAxisExtent; + switch (axis) { + case Axis.vertical: + mainAxisExtent = size.height; + crossAxisExtent = size.width; + break; + case Axis.horizontal: + mainAxisExtent = size.width; + crossAxisExtent = size.height; + break; + } + + final centerOffsetAdjustment = center!.centerOffsetAdjustment; + + double correction; + var count = 0; + do { + assert(offset.pixels != null); + correction = _attemptLayout(mainAxisExtent, crossAxisExtent, + offset.pixels + centerOffsetAdjustment); + if (correction != 0.0) { + offset.correctBy(correction); + } else { + // *** Difference from [RenderViewport]. + final top = _minScrollExtent + mainAxisExtent * anchor; + final bottom = _maxScrollExtent - mainAxisExtent * (1.0 - anchor); + final maxScrollOffset = math.max(math.min(0.0, top), bottom); + final minScrollOffset = math.min(top, maxScrollOffset); + if (offset.applyContentDimensions(minScrollOffset, maxScrollOffset)) + break; + // *** End of difference from [RenderViewport]. + } + count += 1; + } while (count < _maxLayoutCycles); + assert(() { + if (count >= _maxLayoutCycles) { + assert(count != 1); + throw FlutterError( + 'A RenderViewport exceeded its maximum number of layout cycles.\n' + 'RenderViewport render objects, during layout, can retry if either their ' + 'slivers or their ViewportOffset decide that the offset should be corrected ' + 'to take into account information collected during that layout.\n' + 'In the case of this RenderViewport object, however, this happened $count ' + 'times and still there was no consensus on the scroll offset. This usually ' + 'indicates a bug. Specifically, it means that one of the following three ' + 'problems is being experienced by the RenderViewport object:\n' + ' * One of the RenderSliver children or the ViewportOffset have a bug such' + ' that they always think that they need to correct the offset regardless.\n' + ' * Some combination of the RenderSliver children and the ViewportOffset' + ' have a bad interaction such that one applies a correction then another' + ' applies a reverse correction, leading to an infinite loop of corrections.\n' + ' * There is a pathological case that would eventually resolve, but it is' + ' so complicated that it cannot be resolved in any reasonable number of' + ' layout passes.'); + } + return true; + }()); + } + + double _attemptLayout( + double mainAxisExtent, double crossAxisExtent, double correctedOffset) { + assert(!mainAxisExtent.isNaN); + assert(mainAxisExtent >= 0.0); + assert(crossAxisExtent.isFinite); + assert(crossAxisExtent >= 0.0); + assert(correctedOffset.isFinite); + _minScrollExtent = 0.0; + _maxScrollExtent = 0.0; + _hasVisualOverflow = false; + + // centerOffset is the offset from the leading edge of the RenderViewport + // to the zero scroll offset (the line between the forward slivers and the + // reverse slivers). + final double centerOffset = mainAxisExtent * anchor - correctedOffset; + final double reverseDirectionRemainingPaintExtent = + centerOffset.clamp(0.0, mainAxisExtent); + final double forwardDirectionRemainingPaintExtent = + (mainAxisExtent - centerOffset).clamp(0.0, mainAxisExtent); + + switch (cacheExtentStyle) { + case CacheExtentStyle.pixel: + _calculatedCacheExtent = cacheExtent; + break; + case CacheExtentStyle.viewport: + _calculatedCacheExtent = mainAxisExtent * cacheExtent!; + break; + } + + final double fullCacheExtent = mainAxisExtent + 2 * _calculatedCacheExtent!; + final double centerCacheOffset = centerOffset + _calculatedCacheExtent!; + final double reverseDirectionRemainingCacheExtent = + centerCacheOffset.clamp(0.0, fullCacheExtent); + final double forwardDirectionRemainingCacheExtent = + (fullCacheExtent - centerCacheOffset).clamp(0.0, fullCacheExtent); + + final RenderSliver? leadingNegativeChild = childBefore(center!); + + if (leadingNegativeChild != null) { + // negative scroll offsets + final double result = layoutChildSequence( + child: leadingNegativeChild, + scrollOffset: math.max(mainAxisExtent, centerOffset) - mainAxisExtent, + overlap: 0.0, + layoutOffset: forwardDirectionRemainingPaintExtent, + remainingPaintExtent: reverseDirectionRemainingPaintExtent, + mainAxisExtent: mainAxisExtent, + crossAxisExtent: crossAxisExtent, + growthDirection: GrowthDirection.reverse, + advance: childBefore, + remainingCacheExtent: reverseDirectionRemainingCacheExtent, + cacheOrigin: (mainAxisExtent - centerOffset) + .clamp(-_calculatedCacheExtent!, 0.0), + ); + if (result != 0.0) return -result; + } + + // positive scroll offsets + return layoutChildSequence( + child: center, + scrollOffset: math.max(0.0, -centerOffset), + overlap: + leadingNegativeChild == null ? math.min(0.0, -centerOffset) : 0.0, + layoutOffset: centerOffset >= mainAxisExtent + ? centerOffset + : reverseDirectionRemainingPaintExtent, + remainingPaintExtent: forwardDirectionRemainingPaintExtent, + mainAxisExtent: mainAxisExtent, + crossAxisExtent: crossAxisExtent, + growthDirection: GrowthDirection.forward, + advance: childAfter, + remainingCacheExtent: forwardDirectionRemainingCacheExtent, + cacheOrigin: centerOffset.clamp(-_calculatedCacheExtent!, 0.0), + ); + } + + @override + bool get hasVisualOverflow => _hasVisualOverflow; + + @override + void updateOutOfBandData( + GrowthDirection growthDirection, SliverGeometry childLayoutGeometry) { + switch (growthDirection) { + case GrowthDirection.forward: + _maxScrollExtent += childLayoutGeometry.scrollExtent; + break; + case GrowthDirection.reverse: + _minScrollExtent -= childLayoutGeometry.scrollExtent; + break; + } + if (childLayoutGeometry.hasVisualOverflow) _hasVisualOverflow = true; + } +} diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index 031a8f2e..ef34d854 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -34,11 +34,6 @@ dependencies: photo_manager: ^1.2.6+1 photo_view: ^0.12.0 rxdart: ^0.27.0 - scrollable_positioned_list: - git: - url: https://github.com/HayesGordon/flutter.widgets.git - ref: feat/child-index-callback - path: packages/scrollable_positioned_list share_plus: ^2.0.3 shimmer: ^2.0.0 stream_chat_flutter_core: ^3.1.1 diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/horizontal_scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/horizontal_scrollable_positioned_list_test.dart new file mode 100644 index 00000000..61b22aa2 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/horizontal_scrollable_positioned_list_test.dart @@ -0,0 +1,298 @@ +// Copyright 2019 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pedantic/pedantic.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/scrollable_positioned_list.dart'; + +const screenHeight = 400.0; +const screenWidth = 400.0; +const itemWidth = screenWidth / 10.0; +const itemCount = 500; +const scrollDuration = Duration(seconds: 1); + +void main() { + Future setUpWidgetTest( + WidgetTester tester, { + ItemScrollController? itemScrollController, + ItemPositionsListener? itemPositionsListener, + bool reverse = false, + EdgeInsets? padding, + int initialScrollIndex = 0, + }) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + await tester.pumpWidget( + MaterialApp( + home: ScrollablePositionedList.builder( + itemCount: itemCount, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + width: itemWidth, + child: Text('Item $index'), + ), + itemPositionsListener: itemPositionsListener, + scrollDirection: Axis.horizontal, + reverse: reverse, + padding: padding, + initialScrollIndex: initialScrollIndex, + ), + ), + ); + } + + testWidgets('List positioned with 0 at left', (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener); + + expect(tester.getTopLeft(find.text('Item 0')).dx, 0); + expect(tester.getBottomRight(find.text('Item 9')).dx, screenWidth); + expect(find.text('Item 10'), findsNothing); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemTrailingEdge, + 1 / 10); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemTrailingEdge, + 1); + }); + + testWidgets('List positioned with 0 at right', (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemPositionsListener: itemPositionsListener, reverse: true); + + expect(tester.getBottomRight(find.text('Item 0')).dx, screenWidth); + expect(tester.getTopLeft(find.text('Item 9')).dx, 0); + expect(find.text('Item 10'), findsNothing); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemTrailingEdge, + 1 / 10); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemTrailingEdge, + 1); + }); + + testWidgets('Scroll to 2 (already on screen)', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + unawaited( + itemScrollController.scrollTo(index: 2, duration: scrollDuration)); + await tester.pump(); + await tester.pump(scrollDuration); + + expect(find.text('Item 1'), findsNothing); + expect(tester.getTopLeft(find.text('Item 2')).dx, 0); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 2) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 2) + .itemTrailingEdge, + 1 / 10); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 11) + .itemTrailingEdge, + 1); + }); + + testWidgets('Scroll to 100 (not already on screen)', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pumpAndSettle(); + + expect(find.text('Item 99'), findsNothing); + expect(find.text('Item 100'), findsOneWidget); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 100) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 100) + .itemTrailingEdge, + 1 / 10); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 109) + .itemTrailingEdge, + 1); + }); + + testWidgets('Jump to 100', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + itemScrollController.jumpTo(index: 100); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Item 100')).dx, 0); + expect(tester.getBottomRight(find.text('Item 109')).dy, screenWidth); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 100) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 100) + .itemTrailingEdge, + 1 / 10); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 109) + .itemLeadingEdge, + 9 / 10); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 109) + .itemTrailingEdge, + 1); + }); + + testWidgets('padding test - centered sliver at left', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + padding: const EdgeInsets.all(10), + ); + + expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10)); + expect(tester.getTopLeft(find.text('Item 1')), + const Offset(10 + itemWidth, 10)); + expect(tester.getBottomRight(find.text('Item 1')), + const Offset(10 + 2 * itemWidth, screenHeight - 10)); + + unawaited( + itemScrollController.scrollTo(index: 490, duration: scrollDuration)); + await tester.pumpAndSettle(); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(-100, 0)); + await tester.pumpAndSettle(); + + expect(tester.getBottomRight(find.text('Item 499')), + const Offset(screenWidth - 10, screenHeight - 10)); + }); + + testWidgets('padding test - centered sliver not at left', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + initialScrollIndex: 2, + padding: const EdgeInsets.all(10), + ); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(200, 0)); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10)); + expect(tester.getTopLeft(find.text('Item 2')), + const Offset(10 + 2 * itemWidth, 10)); + expect(tester.getTopLeft(find.text('Item 3')), + const Offset(10 + 3 * itemWidth, 10)); + }); + + testWidgets('padding test - reversed - centered sliver at right', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + padding: const EdgeInsets.all(10), + reverse: true, + ); + + expect(tester.getTopRight(find.text('Item 0')), + const Offset(screenWidth - 10, 10)); + expect(tester.getTopRight(find.text('Item 1')), + const Offset(screenWidth - (10 + itemWidth), 10)); + expect(tester.getBottomLeft(find.text('Item 1')), + const Offset(screenWidth - (10 + 2 * itemWidth), screenHeight - 10)); + + unawaited( + itemScrollController.scrollTo(index: 490, duration: scrollDuration)); + await tester.pumpAndSettle(); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(100, 0)); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Item 499')), const Offset(10, 10)); + }); + + testWidgets('padding test - reversed - centered sliver not at right', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + initialScrollIndex: 2, + padding: const EdgeInsets.all(10), + reverse: true, + ); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(-200, 0)); + await tester.pumpAndSettle(); + + expect(tester.getTopRight(find.text('Item 0')), + const Offset(screenWidth - 10, 10)); + expect(tester.getTopRight(find.text('Item 2')), + const Offset(screenWidth - (10 + 2 * itemWidth), 10)); + expect(tester.getTopRight(find.text('Item 3')), + const Offset(screenWidth - (10 + 3 * itemWidth), 10)); + }); +} diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/positioned_list_test.dart b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/positioned_list_test.dart new file mode 100644 index 00000000..983f249f --- /dev/null +++ b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/positioned_list_test.dart @@ -0,0 +1,363 @@ +// Copyright 2019 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/item_positions_notifier.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/positioned_list.dart'; + +const screenHeight = 400.0; +const screenWidth = 400.0; +const itemHeight = screenHeight / 10.0; +const defaultItemCount = 500; +const cacheExtent = itemHeight * 2; + +void main() { + final itemPositionsNotifier = ItemPositionsListener.create(); + + Future setUpWidgetTest( + WidgetTester tester, { + int topItem = 0, + ScrollController? scrollController, + double anchor = 0, + int itemCount = defaultItemCount, + }) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + await tester.pumpWidget( + MaterialApp( + home: PositionedList( + itemCount: itemCount, + positionedIndex: topItem, + alignment: anchor, + controller: scrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + itemPositionsNotifier: itemPositionsNotifier as ItemPositionsNotifier, + cacheExtent: cacheExtent, + ), + ), + ); + } + + testWidgets('short list', (WidgetTester tester) async { + await setUpWidgetTest(tester, itemCount: 5); + await tester.pump(); + + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Item 4'), findsOneWidget); + expect(find.text('Item 5'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 4) + .itemTrailingEdge, + 1 / 2); + }); + + testWidgets('List positioned with 0 at top', (WidgetTester tester) async { + await setUpWidgetTest(tester); + await tester.pump(); + + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Item 9'), findsOneWidget); + expect(find.text('Item 10'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemTrailingEdge, + 1); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 10) + .itemLeadingEdge, + 1); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 10) + .itemTrailingEdge, + 11 / 10); + }); + + testWidgets('List positioned with 5 at top', (WidgetTester tester) async { + await setUpWidgetTest(tester, topItem: 5); + await tester.pump(); + + expect(find.text('Item 4'), findsNothing); + expect(find.text('Item 5'), findsOneWidget); + expect(find.text('Item 14'), findsOneWidget); + expect(find.text('Item 15'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 4) + .itemLeadingEdge, + -1 / 10); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 4) + .itemTrailingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 5) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 14) + .itemTrailingEdge, + 1); + }); + + testWidgets('List positioned with 20 at bottom', (WidgetTester tester) async { + await setUpWidgetTest(tester, topItem: 20, anchor: 1); + await tester.pump(); + + expect(find.text('Item 20'), findsNothing); + expect(find.text('Item 19'), findsOneWidget); + expect(find.text('Item 10'), findsOneWidget); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 10) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 19) + .itemLeadingEdge, + 9 / 10); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 19) + .itemTrailingEdge, + 1); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 20) + .itemLeadingEdge, + 1); + }); + + testWidgets('List positioned with 20 at halfway', + (WidgetTester tester) async { + await setUpWidgetTest(tester, topItem: 20, anchor: 0.5); + await tester.pump(); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 20) + .itemLeadingEdge, + 0.5); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 20) + .itemTrailingEdge, + 0.5 + itemHeight / screenHeight); + }); + + testWidgets('List positioned with 20 half off top of screen', + (WidgetTester tester) async { + await setUpWidgetTest(tester, + topItem: 20, anchor: -(itemHeight / screenHeight) / 2); + await tester.pump(); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 20) + .itemLeadingEdge, + -(itemHeight / screenHeight) / 2); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 20) + .itemTrailingEdge, + (itemHeight / screenHeight) / 2); + }); + + testWidgets('List positioned with 5 at top then scroll up 2', + (WidgetTester tester) async { + await setUpWidgetTest(tester, topItem: 5); + + await tester.drag( + find.byType(PositionedList), const Offset(0, 2 * itemHeight)); + await tester.pump(); + + expect(find.text('Item 2'), findsNothing); + expect(find.text('Item 3'), findsOneWidget); + expect(find.text('Item 12'), findsOneWidget); + expect(find.text('Item 13'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 2) + .itemLeadingEdge, + -1 / 10); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 3) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 12) + .itemTrailingEdge, + 1); + }); + + testWidgets('List positioned with 5 at top then scroll down 1/2', + (WidgetTester tester) async { + await setUpWidgetTest(tester, topItem: 5); + + await tester.drag( + find.byType(PositionedList), const Offset(0, -1 / 2 * itemHeight)); + await tester.pump(); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 5) + .itemTrailingEdge, + 1 / 20); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 14) + .itemLeadingEdge, + 17 / 20); + }); + + testWidgets('List positioned with 0 at top scroll up 5', + (WidgetTester tester) async { + final scrollController = ScrollController(); + await setUpWidgetTest(tester, scrollController: scrollController); + await tester.pump(); + + scrollController.jumpTo(5 * itemHeight); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.text('Item 4'), findsNothing); + expect(find.text('Item 5'), findsOneWidget); + expect(find.text('Item 14'), findsOneWidget); + expect(find.text('Item 15'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 5) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 4) + .itemLeadingEdge, + -1 / 10); + }); + + testWidgets('List positioned with 5 at top then scroll up 2 programatically', + (WidgetTester tester) async { + final scrollController = ScrollController(); + await setUpWidgetTest(tester, + topItem: 5, scrollController: scrollController); + + scrollController.jumpTo(-2 * itemHeight); + await tester.pump(); + + expect(find.text('Item 2'), findsNothing); + expect(find.text('Item 3'), findsOneWidget); + expect(find.text('Item 12'), findsOneWidget); + expect(find.text('Item 13'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 2) + .itemLeadingEdge, + -1 / 10); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 3) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 12) + .itemTrailingEdge, + 1); + }); + + testWidgets( + 'List positioned with 5 at top then scroll down 20 programatically', + (WidgetTester tester) async { + final scrollController = ScrollController(); + await setUpWidgetTest(tester, + topItem: 5, scrollController: scrollController); + + scrollController.jumpTo(20 * itemHeight); + await tester.pump(); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 23) + .itemLeadingEdge, + -2 / 10); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 24) + .itemLeadingEdge, + -1 / 10); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 25) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 4) + .itemLeadingEdge, + -21 / 10); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 5) + .itemLeadingEdge, + -20 / 10); + }); + + testWidgets('List positioned with 5 at top and initial scroll offset', + (WidgetTester tester) async { + final scrollController = + ScrollController(initialScrollOffset: -2 * itemHeight); + await setUpWidgetTest(tester, + topItem: 5, scrollController: scrollController); + + expect(find.text('Item 2'), findsNothing); + expect(find.text('Item 3'), findsOneWidget); + expect(find.text('Item 12'), findsOneWidget); + expect(find.text('Item 13'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 3) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 12) + .itemTrailingEdge, + 1); + }); +} diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/reversed_positioned_list_test.dart b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/reversed_positioned_list_test.dart new file mode 100644 index 00000000..98979df1 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/reversed_positioned_list_test.dart @@ -0,0 +1,270 @@ +// Copyright 2019 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/item_positions_notifier.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/positioned_list.dart'; + +const screenHeight = 400.0; +const screenWidth = 400.0; +const itemHeight = screenHeight / 10.0; +const defaultItemCount = 500; + +void main() { + final itemPositionsNotifier = ItemPositionsListener.create(); + + Future setUpWidgetTest( + WidgetTester tester, { + int topItem = 0, + ScrollController? scrollController, + double anchor = 0, + int itemCount = defaultItemCount, + }) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + await tester.pumpWidget( + MaterialApp( + home: PositionedList( + itemCount: itemCount, + positionedIndex: topItem, + alignment: anchor, + controller: scrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + itemPositionsNotifier: itemPositionsNotifier as ItemPositionsNotifier, + reverse: true, + ), + ), + ); + } + + testWidgets('short list', (WidgetTester tester) async { + await setUpWidgetTest(tester, itemCount: 5); + await tester.pump(); + + expect(tester.getBottomRight(find.text('Item 0')).dy, screenHeight); + expect(find.text('Item 4'), findsOneWidget); + expect(find.text('Item 5'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 4) + .itemTrailingEdge, + 1 / 2); + }); + + testWidgets('List positioned with 0 at bottom', (WidgetTester tester) async { + await setUpWidgetTest(tester); + await tester.pump(); + + expect(tester.getBottomRight(find.text('Item 0')).dy, screenHeight); + expect(tester.getTopLeft(find.text('Item 9')).dy, 0); + expect(find.text('Item 10'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemTrailingEdge, + 1); + }); + + testWidgets('List positioned with 5 at bottom', (WidgetTester tester) async { + await setUpWidgetTest(tester, topItem: 5); + await tester.pump(); + + expect(find.text('Item 4'), findsNothing); + expect(find.text('Item 5'), findsOneWidget); + expect(find.text('Item 14'), findsOneWidget); + expect(find.text('Item 15'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 4) + .itemLeadingEdge, + -1 / 10); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 4) + .itemTrailingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 5) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 14) + .itemTrailingEdge, + 1); + }); + + testWidgets('List positioned with 15 at bottom', (WidgetTester tester) async { + await setUpWidgetTest(tester, topItem: 15, anchor: 0); + await tester.pump(); + + expect(find.text('Item 14'), findsNothing); + expect(find.text('Item 15'), findsOneWidget); + expect(find.text('Item 24'), findsOneWidget); + expect(find.text('Item 25'), findsNothing); + }); + + testWidgets('List positioned with 15 at top', (WidgetTester tester) async { + await setUpWidgetTest(tester, topItem: 15, anchor: 1); + await tester.pump(); + + expect(find.text('Item 15'), findsNothing); + expect(find.text('Item 14'), findsOneWidget); + expect(find.text('Item 5'), findsOneWidget); + expect(find.text('Item 4'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 15) + .itemLeadingEdge, + 1); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 14) + .itemTrailingEdge, + 1); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 14) + .itemLeadingEdge, + 9 / 10); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 5) + .itemLeadingEdge, + 0); + }); + + testWidgets('List positioned with 5 at bottom then scroll up 2', + (WidgetTester tester) async { + await setUpWidgetTest(tester, topItem: 5); + + await tester.drag( + find.byType(PositionedList), const Offset(0, 2 * itemHeight)); + await tester.pump(); + + expect(find.text('Item 6'), findsNothing); + expect(find.text('Item 7'), findsOneWidget); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 7) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 7) + .itemTrailingEdge, + 1 / 10); + }); + + testWidgets('List positioned with 0 at bottom scroll to item 5', + (WidgetTester tester) async { + final scrollController = ScrollController(); + await setUpWidgetTest(tester, scrollController: scrollController); + await tester.pump(); + + scrollController.jumpTo(5 * itemHeight); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.text('Item 4'), findsNothing); + expect(find.text('Item 5'), findsOneWidget); + expect(find.text('Item 14'), findsOneWidget); + expect(find.text('Item 15'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 5) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 4) + .itemLeadingEdge, + -1 / 10); + }); + + testWidgets( + 'List positioned with 5 at bottom then scroll up 2 programatically', + (WidgetTester tester) async { + final scrollController = ScrollController(); + await setUpWidgetTest(tester, + topItem: 5, scrollController: scrollController); + + scrollController.jumpTo(2 * itemHeight); + await tester.pump(); + + expect(find.text('Item 6'), findsNothing); + expect(find.text('Item 7'), findsOneWidget); + expect(find.text('Item 16'), findsOneWidget); + expect(find.text('Item 17'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 6) + .itemLeadingEdge, + -1 / 10); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 7) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 16) + .itemTrailingEdge, + 1); + }); + + testWidgets('List positioned with 5 at bottom and initial scroll offset', + (WidgetTester tester) async { + final scrollController = + ScrollController(initialScrollOffset: 2 * itemHeight); + await setUpWidgetTest(tester, + topItem: 5, scrollController: scrollController); + + expect(find.text('Item 6'), findsNothing); + expect(find.text('Item 7'), findsOneWidget); + expect(find.text('Item 16'), findsOneWidget); + expect(find.text('Item 17'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 6) + .itemLeadingEdge, + -1 / 10); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 7) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 16) + .itemTrailingEdge, + 1); + }); +} diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/reversed_scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/reversed_scrollable_positioned_list_test.dart new file mode 100644 index 00000000..f93bcf71 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/reversed_scrollable_positioned_list_test.dart @@ -0,0 +1,239 @@ +// Copyright 2019 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pedantic/pedantic.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/scrollable_positioned_list.dart'; + +const screenHeight = 400.0; +const screenWidth = 400.0; +const itemHeight = screenHeight / 10.0; +const itemCount = 500; +const scrollDuration = Duration(seconds: 1); + +void main() { + Future setUpWidgetTest( + WidgetTester tester, { + ItemScrollController? itemScrollController, + ItemPositionsListener? itemPositionsListener, + EdgeInsets? padding, + int initialIndex = 0, + }) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + await tester.pumpWidget( + MaterialApp( + home: ScrollablePositionedList.builder( + itemCount: itemCount, + initialScrollIndex: initialIndex, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + itemPositionsListener: itemPositionsListener, + reverse: true, + padding: padding, + ), + ), + ); + } + + testWidgets('List positioned with 0 at bottom', (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener); + + expect(tester.getBottomRight(find.text('Item 0')).dy, screenHeight); + expect(tester.getTopLeft(find.text('Item 9')).dy, 0); + expect(find.text('Item 10'), findsNothing); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemTrailingEdge, + 1); + }); + + testWidgets('Scroll to 1 then 2 (both already on screen)', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + unawaited( + itemScrollController.scrollTo(index: 1, duration: scrollDuration)); + await tester.pump(); + await tester.pump(scrollDuration); + expect(find.text('Item 0'), findsNothing); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 1) + .itemLeadingEdge, + 0); + expect(tester.getBottomRight(find.text('Item 1')).dy, screenHeight); + + unawaited( + itemScrollController.scrollTo(index: 2, duration: scrollDuration)); + await tester.pump(); + await tester.pump(scrollDuration); + + expect(find.text('Item 1'), findsNothing); + expect(tester.getBottomRight(find.text('Item 2')).dy, screenHeight); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 2) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 11) + .itemTrailingEdge, + 1); + }); + + testWidgets('Scroll to 5 (already on screen) and then back to 0', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + unawaited( + itemScrollController.scrollTo(index: 5, duration: scrollDuration)); + await tester.pumpAndSettle(); + unawaited( + itemScrollController.scrollTo(index: 0, duration: scrollDuration)); + await tester.pumpAndSettle(); + + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Item 9'), findsOneWidget); + expect(find.text('Item 10'), findsNothing); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemTrailingEdge, + 1); + }); + + testWidgets('Scroll to 100 (not already on screen)', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pumpAndSettle(); + + expect(find.text('Item 99'), findsNothing); + expect(find.text('Item 100'), findsOneWidget); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 100) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 109) + .itemTrailingEdge, + 1); + }); + + testWidgets('Jump to 100', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + itemScrollController.jumpTo(index: 100); + await tester.pumpAndSettle(); + + expect(tester.getBottomRight(find.text('Item 100')).dy, screenHeight); + expect(tester.getTopLeft(find.text('Item 109')).dy, 0); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 100) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 109) + .itemTrailingEdge, + 1); + }); + + testWidgets('padding test - centered sliver at bottom', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + padding: const EdgeInsets.all(10), + ); + + expect(tester.getBottomLeft(find.text('Item 0')), + const Offset(10, screenHeight - 10)); + expect(tester.getBottomLeft(find.text('Item 1')), + const Offset(10, screenHeight - (10 + itemHeight))); + expect(tester.getTopRight(find.text('Item 1')), + const Offset(screenWidth - 10, screenHeight - (10 + 2 * itemHeight))); + + unawaited( + itemScrollController.scrollTo(index: 490, duration: scrollDuration)); + await tester.pumpAndSettle(); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, 100)); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Item 499')), const Offset(10, 10)); + }); + + testWidgets('padding test - centered sliver not at bottom', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + initialIndex: 2, + padding: const EdgeInsets.all(10), + ); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, -200)); + await tester.pumpAndSettle(); + + expect(tester.getBottomLeft(find.text('Item 0')), + const Offset(10, screenHeight - 10)); + expect(tester.getBottomLeft(find.text('Item 2')), + const Offset(10, screenHeight - (10 + 2 * itemHeight))); + expect(tester.getBottomLeft(find.text('Item 3')), + const Offset(10, screenHeight - (10 + 3 * itemHeight))); + }); +} diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/scrollable_positioned_list_test.dart new file mode 100644 index 00000000..bfdfa762 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/scrollable_positioned_list_test.dart @@ -0,0 +1,2301 @@ +// Copyright 2019 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pedantic/pedantic.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/scroll_view.dart'; + +const screenHeight = 400.0; +const screenWidth = 400.0; +const itemHeight = screenHeight / 10.0; +const defaultItemCount = 500; +const scrollDuration = Duration(seconds: 1); +const scrollDurationTolerance = Duration(milliseconds: 1); +const tolerance = 1e-3; + +void main() { + Future setUpWidgetTest( + WidgetTester tester, { + Key? key, + ItemScrollController? itemScrollController, + ItemPositionsListener? itemPositionsListener, + int initialIndex = 0, + double initialAlignment = 0.0, + int itemCount = defaultItemCount, + ScrollPhysics? physics, + bool addSemanticIndexes = true, + int? semanticChildCount, + EdgeInsets? padding, + bool addRepaintBoundaries = true, + bool addAutomaticKeepAlives = true, + double? minCacheExtent, + bool variableHeight = false, + }) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + await tester.pumpWidget( + MaterialApp( + home: ScrollablePositionedList.builder( + key: key, + itemCount: itemCount, + itemScrollController: itemScrollController, + itemBuilder: (context, index) { + assert(index >= 0 && index <= itemCount - 1); + return SizedBox( + height: + variableHeight ? (itemHeight + (index % 13) * 5) : itemHeight, + child: Text('Item $index'), + ); + }, + itemPositionsListener: itemPositionsListener, + initialScrollIndex: initialIndex, + initialAlignment: initialAlignment, + physics: physics, + addSemanticIndexes: addSemanticIndexes, + semanticChildCount: semanticChildCount, + padding: padding, + addAutomaticKeepAlives: addAutomaticKeepAlives, + addRepaintBoundaries: addRepaintBoundaries, + minCacheExtent: minCacheExtent, + ), + ), + ); + } + + testWidgets('List positioned with 0 at top', (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener); + + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Item 9'), findsOneWidget); + expect(find.text('Item 10'), findsNothing); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemTrailingEdge, + 1); + expect( + itemPositionsListener.itemPositions.value + .where((position) => position.index == 10), + isEmpty); + }); + + testWidgets('List positioned with 0 at top - use default values', + (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + await tester.pumpWidget( + MaterialApp( + home: ScrollablePositionedList.builder( + itemCount: defaultItemCount, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + itemPositionsListener: itemPositionsListener, + ), + ), + ); + + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Item 9'), findsOneWidget); + expect(find.text('Item 10'), findsNothing); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemTrailingEdge, + 1); + }); + + testWidgets('List positioned with 5 at top', (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemPositionsListener: itemPositionsListener, initialIndex: 5); + + expect(find.text('Item 4'), findsNothing); + expect(find.text('Item 5'), findsOneWidget); + + expect( + itemPositionsListener.itemPositions.value + .where((position) => position.index == 4), + isEmpty); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 5) + .itemLeadingEdge, + 0); + }); + + testWidgets('List positioned with 9 at middle', (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemPositionsListener: itemPositionsListener, + initialIndex: 9, + initialAlignment: 0.5); + + expect(tester.getTopLeft(find.text('Item 9')).dy, screenHeight / 2); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemLeadingEdge, + 0.5); + }); + + testWidgets('List positioned with 9 half way off top', + (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemPositionsListener: itemPositionsListener, + initialIndex: 9, + initialAlignment: -(itemHeight / screenHeight) / 2); + + expect(tester.getTopLeft(find.text('Item 9')).dy, -itemHeight / 2); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemLeadingEdge, + -(itemHeight / screenHeight) / 2); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemTrailingEdge, + (itemHeight / screenHeight) / 2); + }); + + testWidgets('Scroll to 9 half way off top', (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + final itemScrollController = ItemScrollController(); + expect(itemScrollController.isAttached, false); + await setUpWidgetTest(tester, + itemPositionsListener: itemPositionsListener, + itemScrollController: itemScrollController); + expect(itemScrollController.isAttached, true); + + unawaited(itemScrollController.scrollTo( + index: 9, + duration: scrollDuration, + alignment: -(itemHeight / screenHeight) / 2)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + expect(tester.getTopLeft(find.text('Item 9')).dy, -itemHeight / 2); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemLeadingEdge, + -(itemHeight / screenHeight) / 2); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemTrailingEdge, + (itemHeight / screenHeight) / 2); + }); + + testWidgets('Jump to 9 half way off top', (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, + itemPositionsListener: itemPositionsListener, + itemScrollController: itemScrollController); + + itemScrollController.jumpTo( + index: 9, alignment: -(itemHeight / screenHeight) / 2); + await tester.pump(); + + expect(tester.getTopLeft(find.text('Item 9')).dy, -itemHeight / 2); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemLeadingEdge, + -(itemHeight / screenHeight) / 2); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemTrailingEdge, + (itemHeight / screenHeight) / 2); + }); + + testWidgets('List positioned with 9 at middle scroll to 15 at bottom', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + initialIndex: 9, + initialAlignment: 0.5); + + unawaited(itemScrollController.scrollTo( + index: 16, duration: scrollDuration, alignment: 1.0)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + expect(tester.getBottomRight(find.text('Item 15')).dy, screenHeight); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 15) + .itemTrailingEdge, + 1.0); + }); + + testWidgets('Scroll to 1 (already on screen)', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + unawaited( + itemScrollController.scrollTo(index: 1, duration: scrollDuration)); + await tester.pump(); + + await tester.pump(scrollDuration ~/ 2); + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Item 1'), findsOneWidget); + expect(find.text('Item 10'), findsOneWidget); + + await tester.pump(scrollDuration ~/ 2); + expect(find.text('Item 0'), findsNothing); + expect(find.text('Item 1'), findsOneWidget); + expect(find.text('Item 10'), findsOneWidget); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 1) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 10) + .itemTrailingEdge, + 1); + expect( + itemPositionsListener.itemPositions.value + .where((position) => position.index == 11), + isEmpty); + }); + + testWidgets('Scroll to 1 then 2 (both already on screen)', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + unawaited( + itemScrollController.scrollTo(index: 1, duration: scrollDuration)); + await tester.pump(); + await tester.pump(scrollDuration); + expect(find.text('Item 0'), findsNothing); + expect(find.text('Item 1'), findsOneWidget); + + unawaited( + itemScrollController.scrollTo(index: 2, duration: scrollDuration)); + await tester.pump(); + await tester.pump(scrollDuration); + + expect(find.text('Item 1'), findsNothing); + expect(find.text('Item 2'), findsOneWidget); + + expect( + itemPositionsListener.itemPositions.value + .where((position) => position.index == 1), + isEmpty); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 2) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 11) + .itemTrailingEdge, + 1); + }); + + testWidgets('Scroll to 5 (already on screen) and then back to 0', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + unawaited( + itemScrollController.scrollTo(index: 5, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 5) + .itemLeadingEdge, + 0); + + unawaited( + itemScrollController.scrollTo(index: 0, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Item 9'), findsOneWidget); + expect(find.text('Item 10'), findsNothing); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemTrailingEdge, + 1); + }); + + testWidgets('Scroll to 20 without fading', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + var fadeTransition = tester.widget(find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(FadeTransition)) + .last); + final initialOpacity = fadeTransition.opacity; + + unawaited( + itemScrollController.scrollTo(index: 20, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration ~/ 2); + + fadeTransition = tester.widget(find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(FadeTransition)) + .last); + expect(fadeTransition.opacity, initialOpacity); + + await tester.pumpAndSettle(); + + expect(find.text('Item 14'), findsNothing); + expect(find.text('Item 20'), findsOneWidget); + }); + + testWidgets('Scroll to 100 (not already on screen)', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + expect(find.text('Item 99'), findsNothing); + expect(find.text('Item 100'), findsOneWidget); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 100) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 109) + .itemTrailingEdge, + 1); + + await tester.pumpAndSettle(); + }); + + testWidgets('Scroll to 100 (not already on screen) front scroll view', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + var fadeTransitionFinder = find.descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(FadeTransition)); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + expect(fadeTransitionFinder.evaluate().length, 2); + expect( + tester.widget(fadeTransitionFinder.last).opacity.value, + closeTo(0, 0.01), + ); + + await tester.pump(scrollDuration ~/ 2); + + expect(tester.getTopLeft(find.text('Item 10')).dy, 0); + expect(tester.getBottomLeft(find.text('Item 19')).dy, screenHeight); + expect(fadeTransitionFinder.evaluate().length, 2); + expect( + tester.widget(fadeTransitionFinder.last).opacity.value, + closeTo(0.5, 0.01), + ); + + await tester.pump(scrollDuration ~/ 2 - scrollDurationTolerance); + expect(fadeTransitionFinder.evaluate().length, 2); + expect( + tester.widget(fadeTransitionFinder.last).opacity.value, + closeTo(1, 0.01), + ); + + await tester.pumpAndSettle(); + + expect(fadeTransitionFinder.evaluate().length, 1); + expect( + tester.widget(fadeTransitionFinder.last).opacity.value, + closeTo(1, 0.01), + ); + }); + + testWidgets('Scroll to 100 (not already on screen) back scroll view', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration ~/ 2); + + expect(tester.getBottomLeft(find.text('Item 99')).dy, screenHeight); + + await tester.pumpAndSettle(); + expect(find.text('Item 25', skipOffstage: false), findsNothing); + }); + + testWidgets('Scroll to 100 (not already on screen) then back to 0', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + expect(find.text('Item 0'), findsNothing); + + unawaited( + itemScrollController.scrollTo(index: 0, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + expect( + tester + .widget(find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(FadeTransition)) + .last) + .opacity + .value, + closeTo(0, 0.01)); + await tester.pump(scrollDuration + scrollDurationTolerance); + expect( + tester + .widget(find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(FadeTransition)) + .last) + .opacity + .value, + closeTo(1, 0.01)); + + expect(find.text('Item 0'), findsOneWidget); + expect(tester.getTopLeft(find.text('Item 0')).dy, 0); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemTrailingEdge, + 1); + }); + + testWidgets('Scroll to 100 then back to 0 back scroll view', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pumpAndSettle(); + + unawaited( + itemScrollController.scrollTo(index: 0, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration ~/ 2); + + expect(tester.getTopLeft(find.text('Item 90')).dy, 0); + expect(tester.getBottomLeft(find.text('Item 99')).dy, screenHeight); + + await tester.pumpAndSettle(); + }); + + testWidgets('Scroll to 100 then back to 0 front scroll view', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + unawaited( + itemScrollController.scrollTo(index: 0, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration ~/ 2); + + expect(tester.getTopLeft(find.text('Item 10')).dy, 0); + expect(tester.getBottomLeft(find.text('Item 19')).dy, screenHeight); + expect( + tester + .widget(find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(FadeTransition)) + .last) + .opacity + .value, + closeTo(0.5, 0.01)); + + await tester.pumpAndSettle(); + }); + + testWidgets('Scroll 100-0-100', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + unawaited( + itemScrollController.scrollTo(index: 0, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration ~/ 2); + + expect(tester.getTopLeft(find.text('Item 10')).dy, 0); + expect(tester.getBottomLeft(find.text('Item 19')).dy, screenHeight); + + await tester.pumpAndSettle(); + }); + + testWidgets('Jump to 100', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + itemScrollController.jumpTo(index: 100); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + expect(tester.getTopLeft(find.text('Item 100')).dy, 0); + expect(tester.getBottomLeft(find.text('Item 109')).dy, screenHeight); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 100) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 109) + .itemTrailingEdge, + 1); + }); + + testWidgets('Jump to 100 and position at bottom', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + itemScrollController.jumpTo(index: 100, alignment: 1.0); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + expect(tester.getBottomLeft(find.text('Item 99')).dy, screenHeight); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 99) + .itemTrailingEdge, + 1.0); + expect( + itemPositionsListener.itemPositions.value + .where((position) => position.index == 100), + isEmpty); + }); + + testWidgets('Jump to 100 and position at middle', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + itemScrollController.jumpTo(index: 100, alignment: 0.5); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + expect(tester.getTopLeft(find.text('Item 100')).dy, screenHeight / 2); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 100) + .itemLeadingEdge, + 0.5); + }); + + testWidgets('Manually scroll a significant distance, jump to 100', + (WidgetTester tester) async { + // Test for https://github.com/google/flutter.widgets/issues/144. + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + variableHeight: true); + + final listFinder = find.byType(ScrollablePositionedList); + for (var i = 0; i < 5; i += 1) { + await tester.drag(listFinder, const Offset(0, -screenHeight)); + await tester.pumpAndSettle(); + } + + itemScrollController.jumpTo(index: 100); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Item 100')).dy, 0); + }, skip: true); + + testWidgets('Scroll to 100 and position at bottom', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + unawaited(itemScrollController.scrollTo( + index: 100, alignment: 1.0, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + expect(tester.getBottomLeft(find.text('Item 99')).dy, screenHeight); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 99) + .itemTrailingEdge, + 1.0); + expect( + itemPositionsListener.itemPositions.value + .where((position) => position.index == 100), + isEmpty); + }); + + testWidgets('Scroll to 100 and position at middle', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + unawaited(itemScrollController.scrollTo( + index: 100, alignment: 0.5, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + expect(tester.getTopLeft(find.text('Item 100')).dy, screenHeight / 2); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 100) + .itemLeadingEdge, + 0.5); + }); + + testWidgets('Scroll to 9 and position at middle', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + unawaited(itemScrollController.scrollTo( + index: 9, alignment: 0.5, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + expect(tester.getTopLeft(find.text('Item 9')).dy, screenHeight / 2); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemLeadingEdge, + 0.5); + }); + + testWidgets('Scroll up a little then jump to 100', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, -10)); + await tester.pumpAndSettle(); + + itemScrollController.jumpTo(index: 100); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + expect(tester.getTopLeft(find.text('Item 100')).dy, 0); + expect(tester.getBottomLeft(find.text('Item 109')).dy, screenHeight); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 100) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 109) + .itemTrailingEdge, + 1); + }); + + testWidgets('Scroll to 100 Jump to 0 Scroll to 100', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + itemScrollController.jumpTo(index: 0); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration ~/ 2); + + expect(tester.getTopLeft(find.text('Item 10')).dy, 0); + expect(tester.getBottomLeft(find.text('Item 19')).dy, screenHeight); + + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Item 100')).dy, 0); + expect(tester.getBottomLeft(find.text('Item 109')).dy, screenHeight); + }); + + testWidgets('Scroll to 100 stop before half way', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration ~/ 2 - scrollDuration ~/ 20); + + await tester.tap(find.byType(ScrollablePositionedList)); + await tester.pump(); + + expect(tester.getTopLeft(find.text('Item 9')).dy, 0); + final fadeTransition = tester.widget(find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(FadeTransition)) + .last); + expect(fadeTransition.opacity.value, 1.0); + + await tester.pumpAndSettle(); + }); + + testWidgets('Scroll to 100 stop half way', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration ~/ 2); + + await tester.tap(find.byType(ScrollablePositionedList)); + await tester.pump(); + + expect(tester.getTopLeft(find.text('Item 10')).dy, 0); + final fadeTransition = tester.widget(find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(FadeTransition)) + .last); + expect(fadeTransition.opacity.value, 1.0); + + await tester.pumpAndSettle(); + }); + + testWidgets('Scroll to 0 stop before half way', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pumpAndSettle(); + + unawaited( + itemScrollController.scrollTo(index: 0, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration ~/ 2 - scrollDuration ~/ 20); + + await tester.tap(find.byType(ScrollablePositionedList)); + await tester.pump(); + + expect(tester.getTopLeft(find.text('Item 91')).dy, 0); + expect(find.byType(FadeTransition), findsNWidgets(2)); + + await tester.pumpAndSettle(); + }); + + testWidgets('Scroll to 100 stop after half way', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration ~/ 2 + scrollDuration ~/ 20); + + expect(find.text('Item 9', skipOffstage: false), findsOneWidget); + expect(tester.getBottomLeft(find.text('Item 100')).dy, + closeTo(screenHeight, tolerance)); + + await tester.tap(find.byType(ScrollablePositionedList)); + await tester.pump(); + + expect(tester.getBottomLeft(find.text('Item 100')).dy, + closeTo(screenHeight, tolerance)); + expect(find.text('Item 9', skipOffstage: false), findsNothing); + expect(find.byType(FadeTransition), findsNWidgets(2)); + + await tester.pumpAndSettle(); + }); + + testWidgets('Scroll to 0 stop after half way', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pumpAndSettle(); + + unawaited( + itemScrollController.scrollTo(index: 0, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration ~/ 2 + scrollDuration ~/ 20); + + await tester.tap(find.byType(ScrollablePositionedList)); + await tester.pump(); + + expect(tester.getTopLeft(find.text('Item 9')).dy, closeTo(0, tolerance)); + final fadeTransition = tester.widget(find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(FadeTransition)) + .last); + expect(fadeTransition.opacity.value, 1.0); + + await tester.pumpAndSettle(); + }); + + testWidgets('Scroll to 0 stop half way', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pumpAndSettle(); + + unawaited( + itemScrollController.scrollTo(index: 0, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration ~/ 2); + + await tester.tap(find.byType(ScrollablePositionedList)); + await tester.pump(); + + expect(tester.getTopLeft(find.text('Item 90')).dy, 0); + expect(find.byType(FadeTransition), findsNWidgets(2)); + + await tester.pumpAndSettle(); + }); + + testWidgets('Scroll to 100 jump to 250 half way', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration ~/ 2); + + itemScrollController.jumpTo(index: 250); + await tester.pump(); + + expect(tester.getTopLeft(find.text('Item 250')).dy, 0); + + expect(find.text('Item 100'), findsNothing); + + await tester.pumpAndSettle(); + }); + + testWidgets('Scroll to 250, scroll to 100, jump to 0 half way', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + unawaited( + itemScrollController.scrollTo(index: 250, duration: scrollDuration)); + await tester.pumpAndSettle(); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration ~/ 2); + + itemScrollController.jumpTo(index: 0); + await tester.pump(); + + expect(tester.getTopLeft(find.text('Item 0')).dy, 0); + expect(find.text('Item 100'), findsNothing); + + await tester.pumpAndSettle(); + }); + + testWidgets('Scroll to 100 scroll to 250 half way', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration ~/ 2); + + unawaited( + itemScrollController.scrollTo(index: 250, duration: scrollDuration)); + + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('Item 250')).dy, 0); + expect(find.text('Item 100'), findsNothing); + }); + + testWidgets('Scroll to 250, scroll to 100, scroll to 0 half way', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + unawaited( + itemScrollController.scrollTo(index: 250, duration: scrollDuration)); + await tester.pumpAndSettle(); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration ~/ 2); + + unawaited( + itemScrollController.scrollTo(index: 0, duration: scrollDuration)); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Item 0')).dy, 0); + expect(find.text('Item 100'), findsNothing); + }); + + testWidgets( + 'Scroll to 100, scroll to 200, then scroll to 300 without waiting', + (WidgetTester tester) async { + // Possibly https://github.com/google/flutter.widgets/issues/171. + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited( + itemScrollController.scrollTo(index: 200, duration: scrollDuration)); + unawaited( + itemScrollController.scrollTo(index: 300, duration: scrollDuration)); + await tester.pumpAndSettle(); + + expect(find.text('Item 100'), findsNothing); + expect(find.text('Item 200'), findsNothing); + + var itemFinder = find.text('Item 300'); + expect(itemFinder, findsOneWidget); + expect(tester.getTopLeft(itemFinder).dy, 0); + }, skip: true); + + testWidgets( + 'Jump to 400 at bottom, manually scroll, scroll to 100 at bottom and back', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + itemScrollController.jumpTo(index: 400, alignment: 1); + await tester.pumpAndSettle(); + + final listFinder = find.byType(ScrollablePositionedList); + + await tester.drag(listFinder, const Offset(0, -screenHeight)); + await tester.pumpAndSettle(); + + unawaited(itemScrollController.scrollTo( + index: 100, alignment: 1, duration: scrollDuration)); + await tester.pumpAndSettle(); + + unawaited(itemScrollController.scrollTo( + index: 400, alignment: 1, duration: scrollDuration)); + await tester.pumpAndSettle(); + + var itemFinder = find.text('Item 399'); + expect(itemFinder, findsOneWidget); + expect(tester.getBottomLeft(itemFinder).dy, screenHeight); + }); + + testWidgets('physics', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + physics: const BouncingScrollPhysics()); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, 50)); + await tester.pump(const Duration(milliseconds: 200)); + + expect(tester.getTopLeft(find.text('Item 0')).dy, greaterThan(0)); + + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('Item 0')).dy, 0); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pumpAndSettle(); + itemScrollController.jumpTo(index: 0); + await tester.pumpAndSettle(); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, 50)); + await tester.pump(const Duration(milliseconds: 200)); + + expect(tester.getTopLeft(find.text('Item 0')).dy, greaterThan(0)); + + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('Item 0')).dy, 0); + }); + + testWidgets('correct index sematics', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, initialIndex: 5); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, 2 * itemHeight)); + await tester.pumpAndSettle(); + + final indexSemantics3 = tester.widget(find.ancestor( + of: find.text('Item 3'), matching: find.byType(IndexedSemantics))); + expect(indexSemantics3.index, 3); + final indexSemantics4 = tester.widget(find.ancestor( + of: find.text('Item 4'), matching: find.byType(IndexedSemantics))); + expect(indexSemantics4.index, 4); + final indexSemantics5 = tester.widget(find.ancestor( + of: find.text('Item 5'), matching: find.byType(IndexedSemantics))); + expect(indexSemantics5.index, 5); + final indexSemantics6 = tester.widget(find.ancestor( + of: find.text('Item 6'), matching: find.byType(IndexedSemantics))); + expect(indexSemantics6.index, 6); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pumpAndSettle(); + itemScrollController.jumpTo(index: 0); + await tester.pumpAndSettle(); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, 2 * itemHeight)); + await tester.pumpAndSettle(); + + final indexSemantics3b = tester.widget(find.ancestor( + of: find.text('Item 3'), matching: find.byType(IndexedSemantics))); + expect(indexSemantics3b.index, 3); + final indexSemantics4b = tester.widget(find.ancestor( + of: find.text('Item 4'), matching: find.byType(IndexedSemantics))); + expect(indexSemantics4b.index, 4); + final indexSemantics5b = tester.widget(find.ancestor( + of: find.text('Item 5'), matching: find.byType(IndexedSemantics))); + expect(indexSemantics5b.index, 5); + final indexSemantics6b = tester.widget(find.ancestor( + of: find.text('Item 6'), matching: find.byType(IndexedSemantics))); + expect(indexSemantics6b.index, 6); + }); + + testWidgets('addIndexSemantics = false', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + initialIndex: 5, + addSemanticIndexes: false, + ); + + expect(find.byType(IndexedSemantics), findsNothing); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pumpAndSettle(); + itemScrollController.jumpTo(index: 0); + await tester.pumpAndSettle(); + + expect(find.byType(IndexedSemantics), findsNothing); + }); + + testWidgets('semanticChildCount specified', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + + await setUpWidgetTest( + tester, + semanticChildCount: 30, + itemScrollController: itemScrollController, + ); + + final customScrollView = + tester.widget(find.byType(UnboundedCustomScrollView)); + expect(customScrollView.semanticChildCount, 30); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pumpAndSettle(); + + final customScrollView2 = + tester.widget(find.byType(UnboundedCustomScrollView)); + expect(customScrollView2.semanticChildCount, 30); + }); + + testWidgets('semanticChildCount not specified', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + ); + + final customScrollView = + tester.widget(find.byType(UnboundedCustomScrollView)); + expect(customScrollView.semanticChildCount, defaultItemCount); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pumpAndSettle(); + + final customScrollView2 = + tester.widget(find.byType(UnboundedCustomScrollView)); + expect(customScrollView2.semanticChildCount, defaultItemCount); + }); + + testWidgets('Semantic tree contains items in cache', + (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener); + + final root = WidgetsBinding + .instance!.pipelineOwner.semanticsOwner!.rootSemanticsNode!; + + final semanticNodes = [root]; + + collectSemanticNodes(root, semanticNodes); + + expect(semanticNodes.where((element) => element.label == 'Item 10'), + isNotEmpty); + }); + + testWidgets('padding test - centered at top', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + padding: const EdgeInsets.all(10), + ); + + expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10)); + expect(tester.getTopLeft(find.text('Item 1')), + const Offset(10, 10 + itemHeight)); + expect(tester.getTopRight(find.text('Item 1')), + const Offset(screenWidth - 10, 10 + itemHeight)); + + unawaited( + itemScrollController.scrollTo(index: 490, duration: scrollDuration)); + await tester.pumpAndSettle(); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, -100)); + await tester.pumpAndSettle(); + + expect(tester.getBottomRight(find.text('Item 499')), + const Offset(screenWidth - 10, screenHeight - 10)); + }); + + testWidgets('padding test - centered not at top', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + initialIndex: 2, + padding: const EdgeInsets.all(10), + ); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, 200)); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10)); + expect(tester.getTopLeft(find.text('Item 2')), + const Offset(10, 10 + 2 * itemHeight)); + expect(tester.getTopLeft(find.text('Item 3')), + const Offset(10, 10 + 3 * itemHeight)); + }); + + testWidgets('padding - first element centered - scroll up', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + padding: const EdgeInsets.all(10), + ); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, 100)); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10)); + }); + + testWidgets('padding - last element centered - scroll down', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + padding: const EdgeInsets.all(10), + ); + + unawaited(itemScrollController.scrollTo( + index: defaultItemCount - 1, duration: scrollDuration)); + await tester.pumpAndSettle(); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, -100)); + await tester.pumpAndSettle(); + + expect( + tester.getBottomLeft(find.text('Item ${defaultItemCount - 1}')), + const Offset(10, screenHeight - 10), + ); + }); + + testWidgets('no repaint boundaries', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + initialIndex: 2, + padding: const EdgeInsets.all(10), + addRepaintBoundaries: false, + ); + + expect( + tester + .widgetList(find.descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(RepaintBoundary))) + .length, + lessThan(5)); + }); + + testWidgets('no automatic keep alives', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + initialIndex: 2, + padding: const EdgeInsets.all(10), + addAutomaticKeepAlives: false, + ); + + expect( + find.descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(AutomaticKeepAlive)), + findsNothing); + }); + + testWidgets('Jump to end of list', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + itemScrollController.jumpTo(index: defaultItemCount - 1); + await tester.pumpAndSettle(); + + expect(tester.getBottomLeft(find.text('Item ${defaultItemCount - 1}')).dy, + screenHeight); + }); + + testWidgets('Scroll to end of list', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + unawaited(itemScrollController.scrollTo( + index: defaultItemCount - 1, duration: scrollDuration)); + await tester.pumpAndSettle(); + + expect(tester.getBottomLeft(find.text('Item ${defaultItemCount - 1}')).dy, + screenHeight); + }); + + testWidgets('Scroll to end of list, jump to beginning, jump to end', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + unawaited(itemScrollController.scrollTo( + index: defaultItemCount - 1, duration: scrollDuration)); + await tester.pumpAndSettle(); + itemScrollController.jumpTo(index: 0); + await tester.pumpAndSettle(); + itemScrollController.jumpTo(index: defaultItemCount - 1); + await tester.pumpAndSettle(); + + expect(tester.getBottomLeft(find.text('Item ${defaultItemCount - 1}')).dy, + screenHeight); + }); + + testWidgets('Jump to end of list, scroll to beginning, scroll to end', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + itemScrollController.jumpTo(index: defaultItemCount - 1); + await tester.pumpAndSettle(); + + unawaited( + itemScrollController.scrollTo(index: 0, duration: scrollDuration)); + await tester.pumpAndSettle(); + unawaited(itemScrollController.scrollTo( + index: defaultItemCount - 1, duration: scrollDuration)); + await tester.pumpAndSettle(); + + expect(tester.getBottomLeft(find.text('Item ${defaultItemCount - 1}')).dy, + screenHeight); + }); + + testWidgets( + 'Jump to end of list, jump to beginning with alignment not at top', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + itemScrollController.jumpTo(index: defaultItemCount - 1); + await tester.pumpAndSettle(); + + itemScrollController.jumpTo(index: 0, alignment: 0.3); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Item 0')).dy, 0); + }); + + testWidgets("Short list, can't scroll past end", (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, itemCount: 3); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, -10)); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Item 0')).dy, 0); + }); + + testWidgets('List can be keyed', (WidgetTester tester) async { + final key = ValueKey('key'); + + await setUpWidgetTest(tester, key: key); + + expect(find.byKey(key), findsOneWidget); + }); + + testWidgets( + 'Maintain programmatic position (9 half way off top) in page view', + (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + final itemScrollController = ItemScrollController(); + + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + await tester.pumpWidget( + MaterialApp( + home: PageView( + children: [ + KeyedSubtree( + key: PageStorageKey('key'), + child: ScrollablePositionedList.builder( + itemCount: defaultItemCount, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + itemPositionsListener: itemPositionsListener, + ), + ), + Center( + child: Text('Test'), + ) + ], + ), + ), + ); + + itemScrollController.jumpTo( + index: 9, alignment: -(itemHeight / screenHeight) / 2); + await tester.pump(); + + await tester.drag(find.byType(PageView), const Offset(-500, 0)); + await tester.pumpAndSettle(); + + await tester.drag(find.byType(PageView), const Offset(500, 0)); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Item 9')).dy, -itemHeight / 2); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemLeadingEdge, + -(itemHeight / screenHeight) / 2); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemTrailingEdge, + (itemHeight / screenHeight) / 2); + }); + + testWidgets('Maintain user scroll position (1 half way off top) in page view', + (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + final itemScrollController = ItemScrollController(); + + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + await tester.pumpWidget( + MaterialApp( + home: PageView( + children: [ + KeyedSubtree( + key: PageStorageKey('key'), + child: ScrollablePositionedList.builder( + itemCount: defaultItemCount, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + itemPositionsListener: itemPositionsListener, + ), + ), + Center( + child: Text('Test'), + ) + ], + ), + ), + ); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, -itemHeight)); + await tester.pumpAndSettle(); + + final item0Bottom = tester.getBottomRight(find.text('Item 0')).dy; + expect(item0Bottom, lessThan(itemHeight)); + + await tester.drag(find.byType(PageView), const Offset(-500, 0)); + await tester.pumpAndSettle(); + + await tester.drag(find.byType(PageView), const Offset(500, 0)); + await tester.pumpAndSettle(); + + expect(tester.getBottomRight(find.text('Item 0')).dy, item0Bottom); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + -(itemHeight / screenHeight) / 2); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemTrailingEdge, + (itemHeight / screenHeight) / 2); + }); + + testWidgets( + 'Maintain programmatic and user position (9 half way off top) in page view', + (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + final itemScrollController = ItemScrollController(); + + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + await tester.pumpWidget( + MaterialApp( + home: PageView( + children: [ + KeyedSubtree( + key: PageStorageKey('key'), + child: ScrollablePositionedList.builder( + itemCount: defaultItemCount, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + itemPositionsListener: itemPositionsListener, + ), + ), + Center( + child: Text('Test'), + ) + ], + ), + ), + ); + + itemScrollController.jumpTo(index: 9); + await tester.pump(); + + expect(tester.getBottomRight(find.text('Item 9')).dy, itemHeight); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, -itemHeight)); + await tester.pumpAndSettle(); + + final item9Bottom = tester.getBottomRight(find.text('Item 9')).dy; + expect(item9Bottom, lessThan(itemHeight)); + + await tester.drag(find.byType(PageView), const Offset(-500, 0)); + await tester.pumpAndSettle(); + + await tester.drag(find.byType(PageView), const Offset(500, 0)); + await tester.pumpAndSettle(); + + expect(tester.getBottomRight(find.text('Item 9')).dy, item9Bottom); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemLeadingEdge, + -(itemHeight / screenHeight) / 2); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemTrailingEdge, + (itemHeight / screenHeight) / 2); + }); + + testWidgets("List with no items", (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, itemCount: 0); + + expect(find.text('Item 0'), findsNothing); + }); + + testWidgets('Jump to 100 then set itemCount to 0', + (WidgetTester tester) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + final itemCount = ValueNotifier(defaultItemCount); + + await tester.pumpWidget( + MaterialApp( + home: ValueListenableBuilder( + valueListenable: itemCount, + builder: (context, itemCount, child) { + return ScrollablePositionedList.builder( + initialScrollIndex: min(100, itemCount), + initialAlignment: 0, + itemCount: itemCount, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + itemBuilder: (context, index) { + assert(index >= 0 && index <= itemCount - 1); + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + itemScrollController.jumpTo(index: 100); + await tester.pumpAndSettle(); + expect(find.text('Item 100'), findsOneWidget); + + itemCount.value = 0; + await tester.pumpAndSettle(); + + expect(find.text('Item 0'), findsNothing); + expect(find.text('Item 100'), findsNothing); + expect(itemPositionsListener.itemPositions.value.isEmpty, isTrue); + }); + + testWidgets('List positioned with 100 at top then set itemCount to 100', + (WidgetTester tester) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + final itemCount = ValueNotifier(defaultItemCount); + + await tester.pumpWidget( + MaterialApp( + home: ValueListenableBuilder( + valueListenable: itemCount, + builder: (context, itemCount, child) { + return ScrollablePositionedList.builder( + initialScrollIndex: min(100, itemCount - 1), + initialAlignment: 0, + itemCount: itemCount, + itemBuilder: (context, index) { + assert(index >= 0 && index <= itemCount - 1); + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('Item 100')).dy, 0); + + itemCount.value = 100; + await tester.pumpAndSettle(); + + expect(find.text('Item 100'), findsNothing); + expect(tester.getBottomLeft(find.text('Item 99')).dy, screenHeight); + }); + + testWidgets('List positioned with 499 at bottom then set itemCount to 100', + (WidgetTester tester) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + final itemCount = ValueNotifier(defaultItemCount); + + await tester.pumpWidget( + MaterialApp( + home: ValueListenableBuilder( + valueListenable: itemCount, + builder: (context, itemCount, child) { + return ScrollablePositionedList.builder( + initialScrollIndex: itemCount - 1, + itemCount: itemCount, + itemBuilder: (context, index) { + assert(index >= 0 && index <= itemCount - 1); + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + expect(tester.getBottomRight(find.text('Item 499')).dy, screenHeight); + + itemCount.value = 100; + await tester.pumpAndSettle(); + + expect(find.text('Item 100'), findsNothing); + expect(tester.getBottomLeft(find.text('Item 99')).dy, screenHeight); + }); + + testWidgets('Large minCacheExtent', (WidgetTester tester) async { + await setUpWidgetTest(tester, minCacheExtent: 200 * itemHeight); + + expect(find.text('Item 100', skipOffstage: false), findsOneWidget); + }); + + testWidgets('Scroll to 20 without fading small minCacheExtent', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + minCacheExtent: 10, + ); + + var fadeTransition = tester.widget(find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(FadeTransition)) + .last); + final initialOpacity = fadeTransition.opacity; + + unawaited( + itemScrollController.scrollTo(index: 20, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration ~/ 2); + + fadeTransition = tester.widget(find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(FadeTransition)) + .last); + expect(fadeTransition.opacity, initialOpacity); + + await tester.pumpAndSettle(); + + expect(find.text('Item 14'), findsNothing); + expect(find.text('Item 20'), findsOneWidget); + }); + + testWidgets('Scroll to 100 without fading for large minCacheExtent', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + minCacheExtent: 200 * itemHeight, + ); + + var fadeTransition = tester.widget(find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(FadeTransition)) + .last); + final initialOpacity = fadeTransition.opacity; + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration ~/ 2); + + fadeTransition = tester.widget(find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(FadeTransition)) + .last); + expect(fadeTransition.opacity, initialOpacity); + + await tester.pumpAndSettle(); + + expect(find.text('Item 100'), findsOneWidget); + }); + + testWidgets('Position list when not enough above top item to fill viewport', + (WidgetTester tester) async { + const alignment = 0.8; + + await setUpWidgetTest( + tester, + itemCount: 2, + initialAlignment: alignment, + initialIndex: 0, + ); + + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Item 0')).dy, screenHeight * alignment); + }); + + testWidgets('Rebuild with scroll controller', (WidgetTester tester) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + final key = ValueNotifier(ValueKey('key')); + final itemScrollController = ItemScrollController(); + + await tester.pumpWidget( + MaterialApp( + home: ValueListenableBuilder( + valueListenable: key, + builder: (context, key, child) { + return Container( + key: key, + child: ScrollablePositionedList.builder( + itemCount: 200, + itemScrollController: itemScrollController, + itemBuilder: (context, index) { + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ), + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + + key.value = ValueKey('newKey'); + await tester.pumpAndSettle(); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pumpAndSettle(); + + expect(find.text('Item 100'), findsOneWidget); + }); + + testWidgets('Double rebuild with scroll controller', + (WidgetTester tester) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + final outerKey = ValueNotifier(ValueKey('outerKey')); + final innerKey = GlobalKey(); + final listKey = ValueNotifier(ValueKey(null)); + final itemScrollController = ItemScrollController(); + + await tester.pumpWidget( + MaterialApp( + home: ValueListenableBuilder( + valueListenable: outerKey, + builder: (context, outerKey, child) => ValueListenableBuilder( + valueListenable: listKey, + builder: (context, listKey, child) => Container( + key: outerKey, + child: Builder( + builder: (context) => Container( + key: innerKey, + child: ScrollablePositionedList.builder( + key: listKey, + itemCount: 200, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + ), + ), + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + outerKey.value = ValueKey('newOuterKey'); + listKey.value = ValueKey('newListKey'); + await tester.pumpAndSettle(); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pumpAndSettle(); + + expect(find.text('Item 100'), findsOneWidget); + }); + + testWidgets('Key change with scroll controller', (WidgetTester tester) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + final key = ValueNotifier(ValueKey('key')); + final itemScrollController = ItemScrollController(); + + await tester.pumpWidget( + MaterialApp( + home: ValueListenableBuilder( + valueListenable: key, + builder: (context, key, child) { + return ScrollablePositionedList.builder( + key: key, + itemCount: 10, + itemScrollController: itemScrollController, + itemBuilder: (context, index) { + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + + key.value = ValueKey('newKey'); + await tester.pumpAndSettle(); + }); + + testWidgets('Scroll after rebuild', (WidgetTester tester) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + final key = ValueNotifier(ValueKey('key')); + final itemScrollController = ItemScrollController(); + + await tester.pumpWidget( + MaterialApp( + home: ValueListenableBuilder( + valueListenable: key, + builder: (context, key, child) { + return Container( + key: key, + child: ScrollablePositionedList.builder( + itemCount: 100, + itemScrollController: itemScrollController, + itemBuilder: (context, index) { + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ), + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + + key.value = ValueKey('newKey'); + await tester.pumpAndSettle(); + + unawaited( + itemScrollController.scrollTo(index: 70, duration: scrollDuration)); + await tester.pumpAndSettle(); + + expect(find.text('Item 70'), findsOneWidget); + }); + + testWidgets('Scroll after rebuild when resusing state', + (WidgetTester tester) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + final containerKey = ValueNotifier(ValueKey('key')); + final scrollKey = GlobalKey(); + final itemScrollController = ItemScrollController(); + + await tester.pumpWidget( + MaterialApp( + home: ValueListenableBuilder( + valueListenable: containerKey, + builder: (context, key, child) { + return Container( + key: key, + child: ScrollablePositionedList.builder( + key: scrollKey, + itemCount: 100, + itemScrollController: itemScrollController, + itemBuilder: (context, index) { + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ), + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + + containerKey.value = ValueKey('newKey'); + await tester.pumpAndSettle(); + + unawaited( + itemScrollController.scrollTo(index: 70, duration: scrollDuration)); + await tester.pumpAndSettle(); + + expect(find.text('Item 70'), findsOneWidget); + }); + + testWidgets('Scroll after changing scroll controller', + (WidgetTester tester) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + final itemScrollController0 = ItemScrollController(); + final itemScrollController1 = ItemScrollController(); + final itemScrollControllerListenable = ValueNotifier(itemScrollController0); + + await tester.pumpWidget( + MaterialApp( + home: ValueListenableBuilder( + valueListenable: itemScrollControllerListenable, + builder: (context, itemScrollController, child) { + return ScrollablePositionedList.builder( + itemCount: 100, + itemScrollController: itemScrollController, + itemBuilder: (context, index) { + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + expect(itemScrollController0.isAttached, true); + expect(itemScrollController1.isAttached, false); + + itemScrollControllerListenable.value = itemScrollController1; + await tester.pumpAndSettle(); + + expect(itemScrollController0.isAttached, false); + expect(itemScrollController1.isAttached, true); + + unawaited( + itemScrollController1.scrollTo(index: 70, duration: scrollDuration)); + await tester.pumpAndSettle(); + + expect(find.text('Item 70'), findsOneWidget); + }); + + testWidgets('Scroll after swapping scroll controllers', + (WidgetTester tester) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + final itemScrollController0 = ItemScrollController(); + final itemScrollController1 = ItemScrollController(); + final topItemScrollControllerListenable = + ValueNotifier(itemScrollController0); + final bottomItemScrollControllerListenable = + ValueNotifier(itemScrollController1); + + await tester.pumpWidget(MaterialApp( + home: Column( + children: [ + Expanded( + child: ValueListenableBuilder( + valueListenable: topItemScrollControllerListenable, + builder: (context, itemScrollController, child) { + return ScrollablePositionedList.builder( + itemCount: 100, + itemScrollController: itemScrollController, + itemBuilder: (context, index) { + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ); + }, + ), + ), + Expanded( + child: ValueListenableBuilder( + valueListenable: bottomItemScrollControllerListenable, + builder: (context, itemScrollController, child) { + return ScrollablePositionedList.builder( + itemCount: 100, + itemScrollController: itemScrollController, + itemBuilder: (context, index) { + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ); + }, + ), + ), + ], + ), + )); + + await tester.pumpAndSettle(); + expect(itemScrollController0.isAttached, true); + expect(itemScrollController1.isAttached, true); + + topItemScrollControllerListenable.value = itemScrollController1; + bottomItemScrollControllerListenable.value = itemScrollController0; + await tester.pumpAndSettle(); + + expect(itemScrollController0.isAttached, true); + expect(itemScrollController1.isAttached, true); + + unawaited( + itemScrollController1.scrollTo(index: 70, duration: scrollDuration)); + unawaited( + itemScrollController0.scrollTo(index: 50, duration: scrollDuration)); + await tester.pumpAndSettle(); + + expect(find.text('Item 70'), findsOneWidget); + expect(find.text('Item 50'), findsOneWidget); + }); +} + +bool collectSemanticNodes(SemanticsNode root, List nodes) { + nodes.add(root); + if (!root.hasChildren) return true; + root.visitChildren((child) => collectSemanticNodes(child, nodes)); + return true; +} diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/separated_positioned_list_test.dart b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/separated_positioned_list_test.dart new file mode 100644 index 00000000..5afdb438 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/separated_positioned_list_test.dart @@ -0,0 +1,261 @@ +// Copyright 2019 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/item_positions_notifier.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/positioned_list.dart'; + +const screenHeight = 400.0; +const screenWidth = 400.0; +const itemHeight = screenHeight / 10.0; +const separatorHeight = screenHeight / 20.0; +const defaultItemCount = 500; +const cacheExtent = itemHeight * 2; + +void main() { + final itemPositionsNotifier = ItemPositionsListener.create(); + + Future setUpWidgetTest( + WidgetTester tester, { + int topItem = 0, + ScrollController? scrollController, + double anchor = 0, + int itemCount = defaultItemCount, + }) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + await tester.pumpWidget( + MaterialApp( + home: PositionedList( + itemCount: itemCount, + positionedIndex: topItem, + alignment: anchor, + controller: scrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + separatorBuilder: (context, index) => SizedBox( + height: separatorHeight, + child: Text('Separator $index'), + ), + itemPositionsNotifier: itemPositionsNotifier as ItemPositionsNotifier, + cacheExtent: cacheExtent, + ), + ), + ); + } + + testWidgets('Empty list', (WidgetTester tester) async { + await setUpWidgetTest(tester, itemCount: 0); + + expect(find.text('Item 0'), findsNothing); + expect(find.text('Separator 0'), findsNothing); + }); + + testWidgets('Short list', (WidgetTester tester) async { + await setUpWidgetTest(tester, itemCount: 3); + + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Separator 0'), findsOneWidget); + expect(find.text('Item 1'), findsOneWidget); + expect(find.text('Separator 1'), findsOneWidget); + expect(find.text('Item 2'), findsOneWidget); + expect(find.text('Separator 2'), findsNothing); + expect(find.text('Item 3'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 2) + .itemTrailingEdge, + _screenProportion(numberOfItems: 3, numberOfSeparators: 2)); + }); + + testWidgets('Short list centered at 1 scrolled up', + (WidgetTester tester) async { + await setUpWidgetTest(tester, itemCount: 3, topItem: 1); + + await tester.drag( + find.byType(PositionedList), const Offset(0, 2 * itemHeight)); + await tester.pumpAndSettle(); + + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Separator 0'), findsOneWidget); + expect(find.text('Item 1'), findsOneWidget); + expect(find.text('Separator 1'), findsOneWidget); + expect(find.text('Item 2'), findsOneWidget); + expect(find.text('Separator 2'), findsNothing); + expect(find.text('Item 3'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 2) + .itemTrailingEdge, + _screenProportion(numberOfItems: 3, numberOfSeparators: 2)); + }); + + testWidgets('List positioned with 0 at top', (WidgetTester tester) async { + await setUpWidgetTest(tester); + await tester.pump(); + + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Separator 5'), findsOneWidget); + expect(find.text('Item 6'), findsOneWidget); + expect(find.text('Separator 6'), findsNothing); + expect(find.text('Item 7'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 5) + .itemTrailingEdge, + 1 - _screenProportion(numberOfItems: 1, numberOfSeparators: 1)); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 6) + .itemTrailingEdge, + 1); + }); + + testWidgets('List positioned with 5 at top', (WidgetTester tester) async { + await setUpWidgetTest(tester, topItem: 5); + await tester.pump(); + + expect(find.text('Item 4'), findsNothing); + expect(find.text('Separator 4'), findsNothing); + expect(find.text('Item 5'), findsOneWidget); + expect(find.text('Separator 5'), findsOneWidget); + + expect(find.text('Separator 10'), findsOneWidget); + expect(find.text('Item 11'), findsOneWidget); + expect(find.text('Separator 11'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 5) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 6) + .itemLeadingEdge, + _screenProportion(numberOfItems: 1, numberOfSeparators: 1)); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 11) + .itemTrailingEdge, + 1); + }); + + testWidgets('List positioned with 20 at bottom', (WidgetTester tester) async { + await setUpWidgetTest(tester, topItem: 20, anchor: 1); + await tester.pump(); + + expect(find.text('Item 20'), findsNothing); + expect(find.text('Item 19'), findsOneWidget); + expect(find.text('Separator 19'), findsOneWidget); + expect(find.text('Item 14'), findsOneWidget); + expect(find.text('Separator 13'), findsOneWidget); + expect(find.text('Item 13'), findsOneWidget); + expect(find.text('Separator 12'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 19) + .itemTrailingEdge, + 1 - _screenProportion(numberOfItems: 0, numberOfSeparators: 1)); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 20) + .itemLeadingEdge, + 1); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 13) + .itemLeadingEdge, + _screenProportion(numberOfItems: -0.5, numberOfSeparators: 0)); + }); + + testWidgets('List positioned with item 20 at halfway', + (WidgetTester tester) async { + await setUpWidgetTest(tester, topItem: 20, anchor: 0.5); + await tester.pump(); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 20) + .itemLeadingEdge, + 0.5); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 20) + .itemTrailingEdge, + 0.5 + itemHeight / screenHeight); + }); + + testWidgets('List positioned with item 20 half off top of screen', + (WidgetTester tester) async { + await setUpWidgetTest(tester, + topItem: 20, anchor: -(itemHeight / screenHeight) / 2); + await tester.pump(); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 20) + .itemLeadingEdge, + _screenProportion(numberOfItems: -0.5, numberOfSeparators: 0)); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 20) + .itemTrailingEdge, + _screenProportion(numberOfItems: 0.5, numberOfSeparators: 0)); + }); + + testWidgets('List positioned with 5 at top then scroll up 2 items', + (WidgetTester tester) async { + await setUpWidgetTest(tester, topItem: 5); + + await tester.drag(find.byType(PositionedList), + const Offset(0, 2 * (itemHeight + separatorHeight))); + await tester.pump(); + + expect(find.text('Separator 2'), findsNothing); + expect(find.text('Item 3'), findsOneWidget); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 2) + .itemLeadingEdge, + _screenProportion(numberOfItems: -1, numberOfSeparators: -1)); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 3) + .itemLeadingEdge, + 0); + }); +} + +double _screenProportion( + {required double numberOfItems, required double numberOfSeparators}) => + (numberOfItems * itemHeight + numberOfSeparators * separatorHeight) / + screenHeight; diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart new file mode 100644 index 00000000..3a052d0b --- /dev/null +++ b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart @@ -0,0 +1,608 @@ +// Copyright 2019 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pedantic/pedantic.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/scroll_view.dart'; + +const screenHeight = 400.0; +const screenWidth = 400.0; +const itemHeight = screenHeight / 10.0; +const separatorHeight = screenHeight / 20.0; +const defaultItemCount = 500; +const scrollDuration = Duration(seconds: 1); +const scrollDurationTolerance = Duration(milliseconds: 1); +const tolerance = 1e-3; + +void main() { + Future setUpWidgetTest( + WidgetTester tester, { + Key? key, + ItemScrollController? itemScrollController, + ItemPositionsListener? itemPositionsListener, + int initialIndex = 0, + double initialAlignment = 0.0, + int? itemCount, + ScrollPhysics? physics, + bool addSemanticIndexes = true, + int? semanticChildCount, + EdgeInsets? padding, + bool addRepaintBoundaries = true, + bool addAutomaticKeepAlives = true, + }) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + await tester.pumpWidget( + MaterialApp( + home: ScrollablePositionedList.separated( + itemCount: itemCount ?? defaultItemCount, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + separatorBuilder: (context, index) => SizedBox( + height: separatorHeight, + child: Text('Separator $index'), + ), + key: key, + itemPositionsListener: itemPositionsListener, + initialScrollIndex: initialIndex, + initialAlignment: initialAlignment, + physics: physics, + addSemanticIndexes: addSemanticIndexes, + semanticChildCount: semanticChildCount, + padding: padding, + addAutomaticKeepAlives: addAutomaticKeepAlives, + addRepaintBoundaries: addRepaintBoundaries, + ), + ), + ); + } + + testWidgets('List positioned with 0 at top', (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener); + + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Separator 5'), findsOneWidget); + expect(find.text('Item 6'), findsOneWidget); + expect(find.text('Separator 6'), findsNothing); + expect(find.text('Item 7'), findsNothing); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 5) + .itemTrailingEdge, + 1 - _screenProportion(numberOfItems: 1, numberOfSeparators: 1)); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 6) + .itemTrailingEdge, + 1); + expect( + itemPositionsListener.itemPositions.value + .where((position) => position.index == 7), + isEmpty); + }); + + testWidgets('List positioned with 0 at top - use default values', + (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + await tester.pumpWidget( + MaterialApp( + home: ScrollablePositionedList.separated( + itemCount: defaultItemCount, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + separatorBuilder: (context, index) => SizedBox( + height: separatorHeight, + child: Text('Separator $index'), + ), + itemPositionsListener: itemPositionsListener, + ), + ), + ); + + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Separator 5'), findsOneWidget); + expect(find.text('Item 6'), findsOneWidget); + expect(find.text('Separator 6'), findsNothing); + expect(find.text('Item 7'), findsNothing); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 5) + .itemTrailingEdge, + 1 - _screenProportion(numberOfItems: 1, numberOfSeparators: 1)); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 6) + .itemTrailingEdge, + 1); + expect( + itemPositionsListener.itemPositions.value + .where((position) => position.index == 7), + isEmpty); + }); + + testWidgets('List positioned with 5 at top', (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemPositionsListener: itemPositionsListener, initialIndex: 5); + + expect(find.text('Item 4'), findsNothing); + expect(find.text('Separator 4'), findsNothing); + expect(find.text('Item 5'), findsOneWidget); + expect(find.text('Separator 5'), findsOneWidget); + expect(find.text('Separator 10'), findsOneWidget); + expect(find.text('Item 11'), findsOneWidget); + expect(find.text('Separator 11'), findsNothing); + + expect( + itemPositionsListener.itemPositions.value + .where((position) => position.index == 4), + isEmpty); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 5) + .itemLeadingEdge, + 0); + }); + + testWidgets('List positioned with 9 at middle', (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemPositionsListener: itemPositionsListener, + initialIndex: 9, + initialAlignment: 0.5); + + expect(tester.getTopLeft(find.text('Item 9')).dy, screenHeight / 2); + expect(tester.getTopLeft(find.text('Item 8')).dy, + screenHeight / 2 - itemHeight - separatorHeight); + expect(tester.getTopLeft(find.text('Item 10')).dy, + screenHeight / 2 + itemHeight + separatorHeight); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemLeadingEdge, + 0.5); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 8) + .itemLeadingEdge, + 0.5 - _screenProportion(numberOfItems: 1, numberOfSeparators: 1)); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 10) + .itemLeadingEdge, + 0.5 + _screenProportion(numberOfItems: 1, numberOfSeparators: 1)); + }); + + testWidgets('Scroll to 9 half way off top', (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, + itemPositionsListener: itemPositionsListener, + itemScrollController: itemScrollController); + + unawaited(itemScrollController.scrollTo( + index: 9, + duration: scrollDuration, + alignment: -(itemHeight / screenHeight) / 2)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + expect(tester.getTopLeft(find.text('Item 9')).dy, -itemHeight / 2); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemLeadingEdge, + _screenProportion(numberOfItems: -0.5, numberOfSeparators: 0)); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemTrailingEdge, + _screenProportion(numberOfItems: 0.5, numberOfSeparators: 0)); + }); + + testWidgets('Jump to 9 half way off top', (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, + itemPositionsListener: itemPositionsListener, + itemScrollController: itemScrollController); + + itemScrollController.jumpTo( + index: 9, alignment: -(itemHeight / screenHeight) / 2); + await tester.pump(); + + expect(tester.getTopLeft(find.text('Item 9')).dy, -itemHeight / 2); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemLeadingEdge, + _screenProportion(numberOfItems: -0.5, numberOfSeparators: 0)); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemTrailingEdge, + _screenProportion(numberOfItems: 0.5, numberOfSeparators: 0)); + }); + + testWidgets('List positioned with 9 at middle scroll to 16 at bottom', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + initialIndex: 9, + initialAlignment: 0.5); + + unawaited(itemScrollController.scrollTo( + index: 16, duration: scrollDuration, alignment: 1.0)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + expect(tester.getBottomRight(find.text('Item 15')).dy, + screenHeight - separatorHeight); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 15) + .itemTrailingEdge, + 1 - _screenProportion(numberOfItems: 0, numberOfSeparators: 1)); + }); + + testWidgets('physics', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + physics: const BouncingScrollPhysics()); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, 50)); + await tester.pump(const Duration(milliseconds: 200)); + + expect(tester.getTopLeft(find.text('Item 0')).dy, greaterThan(0)); + + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('Item 0')).dy, 0); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pumpAndSettle(); + itemScrollController.jumpTo(index: 0); + await tester.pumpAndSettle(); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, 50)); + await tester.pump(const Duration(milliseconds: 200)); + + expect(tester.getTopLeft(find.text('Item 0')).dy, greaterThan(0)); + + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('Item 0')).dy, 0); + }); + + testWidgets('correct index semantics', (WidgetTester tester) async { + await setUpWidgetTest(tester, initialIndex: 5); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, 4 * itemHeight)); + await tester.pumpAndSettle(); + + final indexSemantics3 = tester.widget(find.ancestor( + of: find.text('Item 3'), matching: find.byType(IndexedSemantics))); + expect(indexSemantics3.index, 3); + final indexSemantics4 = tester.widget(find.ancestor( + of: find.text('Item 4'), matching: find.byType(IndexedSemantics))); + expect(indexSemantics4.index, 4); + }); + + testWidgets('addIndexSemantics = false', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + initialIndex: 5, + addSemanticIndexes: false, + ); + + expect(find.byType(IndexedSemantics), findsNothing); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pumpAndSettle(); + + expect(find.byType(IndexedSemantics), findsNothing); + }); + + testWidgets('semanticChildCount specified', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + + await setUpWidgetTest( + tester, + semanticChildCount: 30, + itemScrollController: itemScrollController, + ); + + final customScrollView = + tester.widget(find.byType(UnboundedCustomScrollView)); + expect(customScrollView.semanticChildCount, 30); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pumpAndSettle(); + + final customScrollView2 = + tester.widget(find.byType(UnboundedCustomScrollView)); + expect(customScrollView2.semanticChildCount, 30); + }); + + testWidgets('semanticChildCount not specified', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + ); + + final customScrollView = + tester.widget(find.byType(UnboundedCustomScrollView)); + expect(customScrollView.semanticChildCount, defaultItemCount); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pumpAndSettle(); + + final customScrollView2 = + tester.widget(find.byType(UnboundedCustomScrollView)); + expect(customScrollView2.semanticChildCount, defaultItemCount); + }); + + testWidgets('padding test - centered at top', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + padding: const EdgeInsets.all(10), + ); + + expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10)); + expect(tester.getTopLeft(find.text('Item 1')), + const Offset(10, 10 + itemHeight + separatorHeight)); + expect(tester.getTopRight(find.text('Item 1')), + const Offset(screenWidth - 10, 10 + itemHeight + separatorHeight)); + + unawaited( + itemScrollController.scrollTo(index: 494, duration: scrollDuration)); + await tester.pumpAndSettle(); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, -500)); + await tester.pumpAndSettle(); + + expect(tester.getBottomRight(find.text('Item 499')), + const Offset(screenWidth - 10, screenHeight - 10)); + }); + + testWidgets('padding test - centered sliver not at top', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + initialIndex: 2, + padding: const EdgeInsets.all(10), + ); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, 200)); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10)); + expect(tester.getTopLeft(find.text('Item 2')), + const Offset(10, 10 + 2 * (separatorHeight + itemHeight))); + expect( + tester.getTopRight(find.text('Item 3')), + const Offset( + screenWidth - 10, 10 + 3 * (itemHeight + separatorHeight))); + }); + + testWidgets('no repaint bounderies', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + initialIndex: 2, + padding: const EdgeInsets.all(10), + addRepaintBoundaries: false, + ); + + expect( + tester + .widgetList(find.descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(RepaintBoundary))) + .length, + lessThan(5)); + }); + + testWidgets('no automatic keep alives', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + initialIndex: 2, + padding: const EdgeInsets.all(10), + addAutomaticKeepAlives: false, + ); + + expect( + find.descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(AutomaticKeepAlive)), + findsNothing); + }); + + testWidgets('List can be keyed', (WidgetTester tester) async { + final key = ValueKey('key'); + + await setUpWidgetTest(tester, key: key); + + expect(find.byKey(key), findsOneWidget); + }); + + testWidgets('Empty list then update to single item list', + (WidgetTester tester) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + final itemCount = ValueNotifier(0); + + await tester.pumpWidget( + MaterialApp( + home: ValueListenableBuilder( + valueListenable: itemCount, + builder: (context, itemCount, child) { + return ScrollablePositionedList.separated( + initialScrollIndex: 0, + initialAlignment: 0, + itemCount: itemCount, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + separatorBuilder: (context, index) => SizedBox( + height: separatorHeight, + child: Text('Separator $index'), + ), + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + + itemCount.value = 1; + await tester.pumpAndSettle(); + + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Separator 0'), findsNothing); + }); + + testWidgets('ItemPositions: Empty list then update to 10 items list', + (WidgetTester tester) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + final itemCount = ValueNotifier(0); + + await tester.pumpWidget( + MaterialApp( + home: ValueListenableBuilder( + valueListenable: itemCount, + builder: (context, itemCount, child) { + return ScrollablePositionedList.separated( + initialScrollIndex: 0, + initialAlignment: 0, + itemCount: itemCount, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + separatorBuilder: (context, index) => SizedBox( + height: separatorHeight, + child: Text('Separator $index'), + ), + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Item 0'), findsNothing); + expect(find.text('Separator 0'), findsNothing); + expect(itemPositionsListener.itemPositions.value, []); + + itemCount.value = 10; + await tester.pumpAndSettle(); + + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Separator 5'), findsOneWidget); + expect(find.text('Item 6'), findsOneWidget); + expect(find.text('Separator 6'), findsNothing); + expect(find.text('Item 7'), findsNothing); + + expect(itemPositionsListener.itemPositions.value, isNotEmpty); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 5) + .itemTrailingEdge, + 1 - _screenProportion(numberOfItems: 1, numberOfSeparators: 1)); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 6) + .itemTrailingEdge, + 1); + expect( + itemPositionsListener.itemPositions.value + .where((position) => position.index == 7), + isEmpty); + }); +} + +double _screenProportion( + {required double numberOfItems, required double numberOfSeparators}) => + (numberOfItems * itemHeight + numberOfSeparators * separatorHeight) / + screenHeight; diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/seperated_horizontal_scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/seperated_horizontal_scrollable_positioned_list_test.dart new file mode 100644 index 00000000..2c8dbacb --- /dev/null +++ b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/seperated_horizontal_scrollable_positioned_list_test.dart @@ -0,0 +1,220 @@ +// Copyright 2019 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pedantic/pedantic.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/scrollable_positioned_list.dart'; + +const screenHeight = 400.0; +const screenWidth = 400.0; +const itemWidth = screenWidth / 10.0; +const separatorWidth = screenWidth / 20.0; +const itemCount = 500; +const scrollDuration = Duration(seconds: 1); +const tolerance = 10e-5; + +void main() { + Future setUpWidgetTest( + WidgetTester tester, { + ItemScrollController? itemScrollController, + ItemPositionsListener? itemPositionsListener, + bool reverse = false, + EdgeInsets? padding, + int initialScrollIndex = 0, + }) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + await tester.pumpWidget( + MaterialApp( + home: ScrollablePositionedList.separated( + itemCount: itemCount, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + width: itemWidth, + child: Text('Item $index'), + ), + separatorBuilder: (context, index) => SizedBox( + width: separatorWidth, + child: Text('Separator $index'), + ), + itemPositionsListener: itemPositionsListener, + scrollDirection: Axis.horizontal, + reverse: reverse, + padding: padding, + initialScrollIndex: initialScrollIndex, + ), + ), + ); + } + + testWidgets('List positioned with 0 at left', (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener); + + expect(tester.getTopLeft(find.text('Item 0')).dx, 0); + expect(tester.getBottomLeft(find.text('Item 1')).dx, + itemWidth + separatorWidth); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 1) + .itemLeadingEdge, + _screenProportion(numberOfItems: 1, numberOfSeparators: 1)); + }); + + testWidgets('Scroll to 2 (already on screen)', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + unawaited( + itemScrollController.scrollTo(index: 2, duration: scrollDuration)); + await tester.pump(); + await tester.pump(scrollDuration); + + expect(find.text('Item 1'), findsNothing); + expect(tester.getTopLeft(find.text('Item 2')).dx, 0); + expect( + tester.getTopLeft(find.text('Item 3')).dx, itemWidth + separatorWidth); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 2) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 3) + .itemLeadingEdge, + _screenProportion(numberOfItems: 1, numberOfSeparators: 1)); + }); + + testWidgets('Scroll to 100 (not already on screen)', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pumpAndSettle(); + + expect(find.text('Item 99'), findsNothing); + expect(find.text('Item 100'), findsOneWidget); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 100) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 101) + .itemLeadingEdge, + _screenProportion(numberOfItems: 1, numberOfSeparators: 1)); + }); + + testWidgets('Jump to 100', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + itemScrollController.jumpTo(index: 100); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Item 100')).dx, 0); + expect(tester.getTopLeft(find.text('Item 101')).dx, + itemWidth + separatorWidth); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 100) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 101) + .itemLeadingEdge, + _screenProportion(numberOfItems: 1, numberOfSeparators: 1)); + }); + + testWidgets('padding test - centered sliver at left', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + padding: const EdgeInsets.all(10), + ); + + expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10)); + expect(tester.getTopLeft(find.text('Item 1')), + const Offset(10 + itemWidth + separatorWidth, 10)); + expect(tester.getBottomRight(find.text('Item 1')), + const Offset(10 + 2 * itemWidth + separatorWidth, screenHeight - 10)); + + unawaited( + itemScrollController.scrollTo(index: 494, duration: scrollDuration)); + await tester.pumpAndSettle(); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(-500, 0)); + await tester.pumpAndSettle(); + + expect(tester.getBottomRight(find.text('Item 499')), + const Offset(screenWidth - 10, screenHeight - 10)); + }); + + testWidgets('padding test - centered sliver not at left', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + initialScrollIndex: 2, + padding: const EdgeInsets.all(10), + ); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(300, 0)); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10)); + expect(tester.getTopLeft(find.text('Item 2')), + const Offset(10 + 2 * (itemWidth + separatorWidth), 10)); + expect(tester.getTopLeft(find.text('Item 3')), + const Offset(10 + 3 * (itemWidth + separatorWidth), 10)); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 2) + .itemLeadingEdge, + closeTo( + 10 / screenWidth + 2 * ((itemWidth + separatorWidth) / screenWidth), + tolerance)); + }); +} + +double _screenProportion( + {required double numberOfItems, required double numberOfSeparators}) => + (numberOfItems * itemWidth + numberOfSeparators * separatorWidth) / + screenHeight; From 51e0b90f941ab84fc58690504252915d718b3d96 Mon Sep 17 00:00:00 2001 From: Gordon Hayes Date: Mon, 18 Oct 2021 10:51:06 +0200 Subject: [PATCH 11/35] chore: linting --- .../src/element_registry.dart | 4 +- .../src/indexed_key.dart | 13 +- .../src/item_positions_listener.dart | 6 +- .../src/item_positions_notifier.dart | 2 +- .../src/positioned_list.dart | 44 +-- .../src/post_mount_callback.dart | 2 +- .../src/scroll_view.dart | 7 +- .../src/scrollable_positioned_list.dart | 162 +++++----- .../src/viewport.dart | 80 ++--- .../reversed_positioned_list_test.dart | 2 +- .../scrollable_positioned_list_test.dart | 303 ++++++++---------- ...rated_scrollable_positioned_list_test.dart | 66 ++-- 12 files changed, 333 insertions(+), 358 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/element_registry.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/element_registry.dart index b24a0c17..f4327246 100644 --- a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/element_registry.dart +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/element_registry.dart @@ -46,12 +46,12 @@ class _RegistryWidgetState extends State { } class _InheritedRegistryWidget extends InheritedWidget { - final _RegistryWidgetState state; - const _InheritedRegistryWidget( {Key? key, required this.state, required Widget child}) : super(key: key, child: child); + final _RegistryWidgetState state; + @override bool updateShouldNotify(InheritedWidget oldWidget) => true; } diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/indexed_key.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/indexed_key.dart index 689996d0..bbff45c2 100644 --- a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/indexed_key.dart +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/indexed_key.dart @@ -2,10 +2,13 @@ import 'dart:ui' show hashValues; import 'package:flutter/foundation.dart'; +/// {@template indexed_key} +/// Creates an indexed key that delegates its [operator==] to the given key. +/// +/// It contains an index used in [ScrollablePositionedList]. +/// {@endtemplate} class IndexedKey extends LocalKey { - /// Creates an indexed key that delegates its [operator==] to the given key. - /// - /// It contains an index used in [ScrollablePositionedList]. + /// {@macro indexed_key} const IndexedKey(this.key, this.index); /// The key to which this this delegates its [operator==]. @@ -24,7 +27,5 @@ class IndexedKey extends LocalKey { int get hashCode => hashValues(runtimeType, key); @override - String toString() { - return '(IndexedKey) index: $index, key: $key'; - } + String toString() => '(IndexedKey) index: $index, key: $key'; } diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/item_positions_listener.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/item_positions_listener.dart index dfb3e7d8..886cabe7 100644 --- a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/item_positions_listener.dart +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/item_positions_listener.dart @@ -4,8 +4,8 @@ import 'package:flutter/foundation.dart'; -import 'item_positions_notifier.dart'; -import 'scrollable_positioned_list.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/item_positions_notifier.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/scrollable_positioned_list.dart'; /// Provides a listenable iterable of [itemPositions] of items that are on /// screen and their locations. @@ -57,5 +57,5 @@ class ItemPosition { @override String toString() => - 'ItemPosition(index: $index, itemLeadingEdge: $itemLeadingEdge, itemTrailingEdge: $itemTrailingEdge)'; + '''ItemPosition(index: $index, itemLeadingEdge: $itemLeadingEdge, itemTrailingEdge: $itemTrailingEdge)'''; } diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/item_positions_notifier.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/item_positions_notifier.dart index a2b993a6..172426a5 100644 --- a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/item_positions_notifier.dart +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/item_positions_notifier.dart @@ -4,7 +4,7 @@ import 'package:flutter/foundation.dart'; -import 'item_positions_listener.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/item_positions_listener.dart'; /// Internal implementation of [ItemPositionsListener]. class ItemPositionsNotifier implements ItemPositionsListener { diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/positioned_list.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/positioned_list.dart index c53f017e..43f3d9ab 100644 --- a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/positioned_list.dart +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/positioned_list.dart @@ -8,11 +8,11 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; -import 'element_registry.dart'; -import 'indexed_key.dart'; -import 'item_positions_listener.dart'; -import 'item_positions_notifier.dart'; -import 'scroll_view.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/element_registry.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/indexed_key.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/item_positions_listener.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/item_positions_notifier.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/scroll_view.dart'; /// A list of widgets similar to [ListView], except scroll control /// and position reporting is based on index rather than pixel offset. @@ -27,6 +27,7 @@ import 'scroll_view.dart'; class PositionedList extends StatefulWidget { /// Create a [PositionedList]. const PositionedList({ + Key? key, required this.itemCount, required this.itemBuilder, this.separatorBuilder, @@ -44,11 +45,12 @@ class PositionedList extends StatefulWidget { this.addSemanticIndexes = true, this.addRepaintBoundaries = true, this.addAutomaticKeepAlives = true, - }) : assert(itemCount != null), - assert(itemBuilder != null), - assert((positionedIndex == 0) || (positionedIndex < itemCount)); + }) : assert((positionedIndex == 0) || (positionedIndex < itemCount), + 'positionedIndex cannot be 0 and must be smaller than itemCount'), + super(key: key); - /// Called to find the new index of a child based on its key in case of reordering. + /// Called to find the new index of a child based on its key in case of + /// reordering. /// /// If not provided, a child widget may not map to its existing [RenderObject] /// when the order in which children are returned from [builder] changes. @@ -263,7 +265,7 @@ class _PositionedListState extends State { : widget.reverse ? widget.padding?.copyWith(left: 0) : widget.padding?.copyWith(right: 0)) ?? - EdgeInsets.all(0); + const EdgeInsets.all(0); EdgeInsets get _centerSliverPadding => widget.scrollDirection == Axis.vertical ? widget.reverse @@ -274,13 +276,13 @@ class _PositionedListState extends State { bottom: widget.positionedIndex == 0 ? widget.padding!.bottom : 0) ?? - EdgeInsets.all(0) + const EdgeInsets.all(0) : widget.padding?.copyWith( top: widget.positionedIndex == 0 ? widget.padding!.top : 0, bottom: widget.positionedIndex == widget.itemCount - 1 ? widget.padding!.bottom : 0) ?? - EdgeInsets.all(0) + const EdgeInsets.all(0) : widget.reverse ? widget.padding?.copyWith( left: widget.positionedIndex == widget.itemCount - 1 @@ -289,23 +291,23 @@ class _PositionedListState extends State { right: widget.positionedIndex == 0 ? widget.padding!.right : 0) ?? - EdgeInsets.all(0) + const EdgeInsets.all(0) : widget.padding?.copyWith( left: widget.positionedIndex == 0 ? widget.padding!.left : 0, right: widget.positionedIndex == widget.itemCount - 1 ? widget.padding!.right : 0, ) ?? - EdgeInsets.all(0); + const EdgeInsets.all(0); EdgeInsets get _trailingSliverPadding => widget.scrollDirection == Axis.vertical ? widget.reverse - ? widget.padding?.copyWith(bottom: 0) ?? EdgeInsets.all(0) - : widget.padding?.copyWith(top: 0) ?? EdgeInsets.all(0) + ? widget.padding?.copyWith(bottom: 0) ?? const EdgeInsets.all(0) + : widget.padding?.copyWith(top: 0) ?? const EdgeInsets.all(0) : widget.reverse - ? widget.padding?.copyWith(right: 0) ?? EdgeInsets.all(0) - : widget.padding?.copyWith(left: 0) ?? EdgeInsets.all(0); + ? widget.padding?.copyWith(right: 0) ?? const EdgeInsets.all(0) + : widget.padding?.copyWith(left: 0) ?? const EdgeInsets.all(0); void _schedulePositionNotificationUpdate() { if (!updateScheduled) { @@ -317,10 +319,10 @@ class _PositionedListState extends State { } final positions = []; RenderViewport? viewport; - for (var element in registeredElements.value!) { - final RenderBox box = element.renderObject as RenderBox; + for (final element in registeredElements.value!) { + final box = element.renderObject as RenderBox; viewport ??= RenderAbstractViewport.of(box) as RenderViewport?; - final IndexedKey key = element.widget.key as IndexedKey; + final key = element.widget.key as IndexedKey; if (widget.scrollDirection == Axis.vertical) { final reveal = viewport!.getOffsetToReveal(box, 0).offset; final itemOffset = reveal - diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/post_mount_callback.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/post_mount_callback.dart index 9ec553fa..ddb3e3d4 100644 --- a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/post_mount_callback.dart +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/post_mount_callback.dart @@ -29,7 +29,7 @@ class _PostMountCallbackElement extends StatelessElement { @override void mount(Element? parent, dynamic newSlot) { super.mount(parent, newSlot); - final PostMountCallback postMountCallback = widget as PostMountCallback; + final postMountCallback = widget as PostMountCallback; postMountCallback.callback?.call(); } } diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scroll_view.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scroll_view.dart index e00462e2..9adacfeb 100644 --- a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scroll_view.dart +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scroll_view.dart @@ -6,11 +6,14 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; -import 'viewport.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/viewport.dart'; -/// A version of [CustomScrollView] that allows does not constrict the extents +/// {@template custom_scroll_view} +/// A version of [CustomScrollView] that does not constrict the extents /// to be within 0 and 1. See [CustomScrollView] for more information. +/// {@endtemplate} class UnboundedCustomScrollView extends CustomScrollView { + /// {@macro custom_scroll_view} const UnboundedCustomScrollView({ Key? key, Axis scrollDirection = Axis.vertical, diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scrollable_positioned_list.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scrollable_positioned_list.dart index d262578b..704ed221 100644 --- a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scrollable_positioned_list.dart +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scrollable_positioned_list.dart @@ -10,10 +10,10 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; -import 'item_positions_listener.dart'; -import 'item_positions_notifier.dart'; -import 'positioned_list.dart'; -import 'post_mount_callback.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/item_positions_listener.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/item_positions_notifier.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/positioned_list.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/post_mount_callback.dart'; /// Number of screens to scroll when scrolling a long distance. const int _screenScrollCount = 2; @@ -52,9 +52,7 @@ class ScrollablePositionedList extends StatefulWidget { this.addRepaintBoundaries = true, this.minCacheExtent, this.findChildIndexCallback, - }) : assert(itemCount != null), - assert(itemBuilder != null), - itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?, + }) : itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?, separatorBuilder = null, super(key: key); @@ -79,13 +77,12 @@ class ScrollablePositionedList extends StatefulWidget { this.addRepaintBoundaries = true, this.minCacheExtent, this.findChildIndexCallback, - }) : assert(itemCount != null), - assert(itemBuilder != null), - assert(separatorBuilder != null), + }) : assert(separatorBuilder != null, 'seperatorBuilder cannot be null'), itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?, super(key: key); - /// Called to find the new index of a child based on its key in case of reordering. + /// Called to find the new index of a child based on its key in case of + /// reordering. /// /// If not provided, a child widget may not map to its existing [RenderObject] /// when the order in which children are returned from [builder] changes. @@ -235,9 +232,11 @@ class ItemScrollController { Curve curve = Curves.linear, List opacityAnimationWeights = const [40, 20, 40], }) { - assert(_scrollableListState != null); - assert(opacityAnimationWeights.length == 3); - assert(duration > Duration.zero); + assert(_scrollableListState != null, '_scrollableListState cannot be null'); + assert(opacityAnimationWeights.length == 3, + 'opacityAnimationWeights.length is not equal to 3'); + assert(duration > Duration.zero, + 'duration needs to be bigger than Duration.zero'); return _scrollableListState!._scrollTo( index: index, alignment: alignment, @@ -248,7 +247,8 @@ class ItemScrollController { } void _attach(_ScrollablePositionedListState scrollableListState) { - assert(_scrollableListState == null); + assert( + _scrollableListState == null, '_scrollableListState needs to be null'); _scrollableListState = scrollableListState; } @@ -260,11 +260,11 @@ class ItemScrollController { class _ScrollablePositionedListState extends State with TickerProviderStateMixin { /// Details for the primary (active) [ListView]. - var primary = _ListDisplayDetails(const ValueKey('Ping')); + _ListDisplayDetails primary = _ListDisplayDetails(const ValueKey('Ping')); /// Details for the secondary (transitional) [ListView] that is temporarily /// shown when scrolling a long distance. - var secondary = _ListDisplayDetails(const ValueKey('Pong')); + _ListDisplayDetails secondary = _ListDisplayDetails(const ValueKey('Pong')); final opacity = ProxyAnimation(const AlwaysStoppedAnimation(0)); @@ -275,10 +275,11 @@ class _ScrollablePositionedListState extends State @override void initState() { super.initState(); - ItemPosition? initialPosition = PageStorage.of(context)!.readState(context); - primary.target = initialPosition?.index ?? widget.initialScrollIndex; - primary.alignment = - initialPosition?.itemLeadingEdge ?? widget.initialAlignment; + final ItemPosition? initialPosition = + PageStorage.of(context)!.readState(context); + primary + ..target = initialPosition?.index ?? widget.initialScrollIndex + ..alignment = initialPosition?.itemLeadingEdge ?? widget.initialAlignment; if (widget.itemCount > 0 && primary.target > widget.itemCount - 1) { primary.target = widget.itemCount - 1; } @@ -333,79 +334,78 @@ class _ScrollablePositionedListState extends State } @override - Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - final cacheExtent = _cacheExtent(constraints); - return GestureDetector( - onPanDown: (_) => _stopScroll(canceled: true), - excludeFromSemantics: true, - child: Stack( - children: [ - PostMountCallback( - key: primary.key, - callback: startAnimationCallback, - child: FadeTransition( - opacity: ReverseAnimation(opacity), - child: NotificationListener( - onNotification: (_) => _isTransitioning, - child: PositionedList( - itemBuilder: widget.itemBuilder, - separatorBuilder: widget.separatorBuilder, - itemCount: widget.itemCount, - positionedIndex: primary.target, - controller: primary.scrollController, - itemPositionsNotifier: primary.itemPositionsNotifier, - scrollDirection: widget.scrollDirection, - reverse: widget.reverse, - cacheExtent: cacheExtent, - alignment: primary.alignment, - physics: widget.physics, - addSemanticIndexes: widget.addSemanticIndexes, - semanticChildCount: widget.semanticChildCount, - padding: widget.padding, - addAutomaticKeepAlives: widget.addAutomaticKeepAlives, - addRepaintBoundaries: widget.addRepaintBoundaries, - findChildIndexCallback: widget.findChildIndexCallback, - ), - ), - ), - ), - if (_isTransitioning) + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) { + final cacheExtent = _cacheExtent(constraints); + return GestureDetector( + onPanDown: (_) => _stopScroll(canceled: true), + excludeFromSemantics: true, + child: Stack( + children: [ PostMountCallback( - key: secondary.key, + key: primary.key, callback: startAnimationCallback, child: FadeTransition( - opacity: opacity, + opacity: ReverseAnimation(opacity), child: NotificationListener( - onNotification: (_) => false, + onNotification: (_) => _isTransitioning, child: PositionedList( itemBuilder: widget.itemBuilder, separatorBuilder: widget.separatorBuilder, itemCount: widget.itemCount, - itemPositionsNotifier: secondary.itemPositionsNotifier, - positionedIndex: secondary.target, - controller: secondary.scrollController, + positionedIndex: primary.target, + controller: primary.scrollController, + itemPositionsNotifier: primary.itemPositionsNotifier, scrollDirection: widget.scrollDirection, reverse: widget.reverse, cacheExtent: cacheExtent, - alignment: secondary.alignment, + alignment: primary.alignment, physics: widget.physics, addSemanticIndexes: widget.addSemanticIndexes, semanticChildCount: widget.semanticChildCount, padding: widget.padding, addAutomaticKeepAlives: widget.addAutomaticKeepAlives, addRepaintBoundaries: widget.addRepaintBoundaries, + findChildIndexCallback: widget.findChildIndexCallback, ), ), ), ), - ], - ), - ); - }, - ); - } + if (_isTransitioning) + PostMountCallback( + key: secondary.key, + callback: startAnimationCallback, + child: FadeTransition( + opacity: opacity, + child: NotificationListener( + onNotification: (_) => false, + child: PositionedList( + itemBuilder: widget.itemBuilder, + separatorBuilder: widget.separatorBuilder, + itemCount: widget.itemCount, + itemPositionsNotifier: + secondary.itemPositionsNotifier, + positionedIndex: secondary.target, + controller: secondary.scrollController, + scrollDirection: widget.scrollDirection, + reverse: widget.reverse, + cacheExtent: cacheExtent, + alignment: secondary.alignment, + physics: widget.physics, + addSemanticIndexes: widget.addSemanticIndexes, + semanticChildCount: widget.semanticChildCount, + padding: widget.padding, + addAutomaticKeepAlives: widget.addAutomaticKeepAlives, + addRepaintBoundaries: widget.addRepaintBoundaries, + ), + ), + ), + ), + ], + ), + ); + }, + ); double _cacheExtent(BoxConstraints constraints) => max( constraints.maxHeight * _screenScrollCount, @@ -419,8 +419,9 @@ class _ScrollablePositionedListState extends State } setState(() { primary.scrollController.jumpTo(0); - primary.target = index; - primary.alignment = alignment; + primary + ..target = index + ..alignment = alignment; }); } @@ -505,8 +506,9 @@ class _ScrollablePositionedListState extends State setState(() { // TODO: _startScroll can be re-entrant, which invalidates this assert. // assert(!_isTransitioning); - secondary.target = index; - secondary.alignment = alignment; + secondary + ..target = index + ..alignment = alignment; _isTransitioning = true; }); await Future.wait([startCompleter.future, endCompleter.future]); @@ -532,7 +534,7 @@ class _ScrollablePositionedListState extends State if (opacity.value >= 0.5) { // Secondary [ListView] is more visible than the primary; make it the // new primary. - var temp = primary; + final temp = primary; primary = secondary; secondary = temp; } @@ -542,8 +544,8 @@ class _ScrollablePositionedListState extends State } Animatable _opacityAnimation(List opacityAnimationWeights) { - final startOpacity = 0.0; - final endOpacity = 1.0; + const startOpacity = 0.0; + const endOpacity = 1.0; return TweenSequence(>[ TweenSequenceItem( tween: ConstantTween(startOpacity), diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/viewport.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/viewport.dart index 1de5ff05..7e307040 100644 --- a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/viewport.dart +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/viewport.dart @@ -2,17 +2,22 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +// ignore_for_file: lines_longer_than_80_chars + import 'dart:math' as math; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; +/// {@template unbounded_viewport} /// A render object that is bigger on the inside. /// /// Version of [Viewport] with some modifications to how extents are /// computed to allow scroll extents outside 0 to 1. See [Viewport] /// for more information. +/// description class UnboundedViewport extends Viewport { + /// {@macro unbounded_viewport} UnboundedViewport({ Key? key, AxisDirection axisDirection = AxisDirection.down, @@ -40,16 +45,15 @@ class UnboundedViewport extends Viewport { double get anchor => _anchor; @override - RenderViewport createRenderObject(BuildContext context) { - return UnboundedRenderViewport( - axisDirection: axisDirection, - crossAxisDirection: crossAxisDirection ?? - Viewport.getDefaultCrossAxisDirection(context, axisDirection), - anchor: anchor, - offset: offset, - cacheExtent: cacheExtent, - ); - } + RenderViewport createRenderObject(BuildContext context) => + UnboundedRenderViewport( + axisDirection: axisDirection, + crossAxisDirection: crossAxisDirection ?? + Viewport.getDefaultCrossAxisDirection(context, axisDirection), + anchor: anchor, + offset: offset, + cacheExtent: cacheExtent, + ); } /// A render object that is bigger on the inside. @@ -100,7 +104,6 @@ class UnboundedRenderViewport extends RenderViewport { @override set anchor(double value) { - assert(value != null); if (value == _anchor) return; _anchor = value; markNeedsLayout(); @@ -124,8 +127,6 @@ class UnboundedRenderViewport extends RenderViewport { @override Rect describeSemanticsClip(RenderSliver? child) { - assert(axis != null); - if (_calculatedCacheExtent == null) { return semanticBounds; } @@ -151,14 +152,14 @@ class UnboundedRenderViewport extends RenderViewport { @override void performLayout() { if (center == null) { - assert(firstChild == null); + assert(firstChild == null, 'firstChild cannot be null'); _minScrollExtent = 0.0; _maxScrollExtent = 0.0; _hasVisualOverflow = false; - offset.applyContentDimensions(0.0, 0.0); + offset.applyContentDimensions(0, 0); return; } - assert(center!.parent == this); + assert(center!.parent == this, 'center.parent cannot be equal to this'); late double mainAxisExtent; late double crossAxisExtent; @@ -178,7 +179,6 @@ class UnboundedRenderViewport extends RenderViewport { double correction; var count = 0; do { - assert(offset.pixels != null); correction = _attemptLayout(mainAxisExtent, crossAxisExtent, offset.pixels + centerOffsetAdjustment); if (correction != 0.0) { @@ -187,17 +187,18 @@ class UnboundedRenderViewport extends RenderViewport { // *** Difference from [RenderViewport]. final top = _minScrollExtent + mainAxisExtent * anchor; final bottom = _maxScrollExtent - mainAxisExtent * (1.0 - anchor); - final maxScrollOffset = math.max(math.min(0.0, top), bottom); - final minScrollOffset = math.min(top, maxScrollOffset); - if (offset.applyContentDimensions(minScrollOffset, maxScrollOffset)) + final maxScrollOffset = math.max(math.min(0, top), bottom); + final minScrollOffset = math.min(top, maxScrollOffset); + if (offset.applyContentDimensions(minScrollOffset, maxScrollOffset)) { break; + } // *** End of difference from [RenderViewport]. } count += 1; } while (count < _maxLayoutCycles); assert(() { if (count >= _maxLayoutCycles) { - assert(count != 1); + assert(count != 1, 'count not equal to 1'); throw FlutterError( 'A RenderViewport exceeded its maximum number of layout cycles.\n' 'RenderViewport render objects, during layout, can retry if either their ' @@ -217,16 +218,16 @@ class UnboundedRenderViewport extends RenderViewport { ' layout passes.'); } return true; - }()); + }(), 'count needs to be bigger than _maxLayoutCycles'); } double _attemptLayout( double mainAxisExtent, double crossAxisExtent, double correctedOffset) { - assert(!mainAxisExtent.isNaN); - assert(mainAxisExtent >= 0.0); - assert(crossAxisExtent.isFinite); - assert(crossAxisExtent >= 0.0); - assert(correctedOffset.isFinite); + assert(!mainAxisExtent.isNaN, 'assert mainAxisExtent.isNaN'); + assert(mainAxisExtent >= 0.0, 'assert mainAxisExtent >= 0.0'); + assert(crossAxisExtent.isFinite, 'assert crossAxisExtent.isFinite'); + assert(crossAxisExtent >= 0.0, 'assert crossAxisExtent >= 0.0'); + assert(correctedOffset.isFinite, 'assert correctedOffset.isFinite'); _minScrollExtent = 0.0; _maxScrollExtent = 0.0; _hasVisualOverflow = false; @@ -234,10 +235,10 @@ class UnboundedRenderViewport extends RenderViewport { // centerOffset is the offset from the leading edge of the RenderViewport // to the zero scroll offset (the line between the forward slivers and the // reverse slivers). - final double centerOffset = mainAxisExtent * anchor - correctedOffset; - final double reverseDirectionRemainingPaintExtent = + final centerOffset = mainAxisExtent * anchor - correctedOffset; + final reverseDirectionRemainingPaintExtent = centerOffset.clamp(0.0, mainAxisExtent); - final double forwardDirectionRemainingPaintExtent = + final forwardDirectionRemainingPaintExtent = (mainAxisExtent - centerOffset).clamp(0.0, mainAxisExtent); switch (cacheExtentStyle) { @@ -249,21 +250,21 @@ class UnboundedRenderViewport extends RenderViewport { break; } - final double fullCacheExtent = mainAxisExtent + 2 * _calculatedCacheExtent!; - final double centerCacheOffset = centerOffset + _calculatedCacheExtent!; - final double reverseDirectionRemainingCacheExtent = + final fullCacheExtent = mainAxisExtent + 2 * _calculatedCacheExtent!; + final centerCacheOffset = centerOffset + _calculatedCacheExtent!; + final reverseDirectionRemainingCacheExtent = centerCacheOffset.clamp(0.0, fullCacheExtent); - final double forwardDirectionRemainingCacheExtent = + final forwardDirectionRemainingCacheExtent = (fullCacheExtent - centerCacheOffset).clamp(0.0, fullCacheExtent); - final RenderSliver? leadingNegativeChild = childBefore(center!); + final leadingNegativeChild = childBefore(center!); if (leadingNegativeChild != null) { // negative scroll offsets - final double result = layoutChildSequence( + final result = layoutChildSequence( child: leadingNegativeChild, scrollOffset: math.max(mainAxisExtent, centerOffset) - mainAxisExtent, - overlap: 0.0, + overlap: 0, layoutOffset: forwardDirectionRemainingPaintExtent, remainingPaintExtent: reverseDirectionRemainingPaintExtent, mainAxisExtent: mainAxisExtent, @@ -280,9 +281,8 @@ class UnboundedRenderViewport extends RenderViewport { // positive scroll offsets return layoutChildSequence( child: center, - scrollOffset: math.max(0.0, -centerOffset), - overlap: - leadingNegativeChild == null ? math.min(0.0, -centerOffset) : 0.0, + scrollOffset: math.max(0, -centerOffset), + overlap: leadingNegativeChild == null ? math.min(0, -centerOffset) : 0.0, layoutOffset: centerOffset >= mainAxisExtent ? centerOffset : reverseDirectionRemainingPaintExtent, diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/reversed_positioned_list_test.dart b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/reversed_positioned_list_test.dart index 98979df1..8b3a1ab5 100644 --- a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/reversed_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/reversed_positioned_list_test.dart @@ -117,7 +117,7 @@ void main() { }); testWidgets('List positioned with 15 at bottom', (WidgetTester tester) async { - await setUpWidgetTest(tester, topItem: 15, anchor: 0); + await setUpWidgetTest(tester, topItem: 15); await tester.pump(); expect(find.text('Item 14'), findsNothing); diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/scrollable_positioned_list_test.dart index bfdfa762..243aeeb1 100644 --- a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/scrollable_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/scrollable_positioned_list_test.dart @@ -50,7 +50,8 @@ void main() { itemCount: itemCount, itemScrollController: itemScrollController, itemBuilder: (context, index) { - assert(index >= 0 && index <= itemCount - 1); + assert(index >= 0 && index <= itemCount - 1, + '''index needs to be bigger or equal to 0 and smallert than itemCount -1'''); return SizedBox( height: variableHeight ? (itemHeight + (index % 13) * 5) : itemHeight, @@ -256,7 +257,7 @@ void main() { initialAlignment: 0.5); unawaited(itemScrollController.scrollTo( - index: 16, duration: scrollDuration, alignment: 1.0)); + index: 16, duration: scrollDuration, alignment: 1)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration + scrollDurationTolerance); @@ -457,7 +458,7 @@ void main() { final itemScrollController = ItemScrollController(); await setUpWidgetTest(tester, itemScrollController: itemScrollController); - var fadeTransitionFinder = find.descendant( + final fadeTransitionFinder = find.descendant( of: find.byType(ScrollablePositionedList), matching: find.byType(FadeTransition)); @@ -687,7 +688,7 @@ void main() { itemScrollController: itemScrollController, itemPositionsListener: itemPositionsListener); - itemScrollController.jumpTo(index: 100, alignment: 1.0); + itemScrollController.jumpTo(index: 100, alignment: 1); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration + scrollDurationTolerance); @@ -758,7 +759,7 @@ void main() { itemPositionsListener: itemPositionsListener); unawaited(itemScrollController.scrollTo( - index: 100, alignment: 1.0, duration: scrollDuration)); + index: 100, alignment: 1, duration: scrollDuration)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration + scrollDurationTolerance); @@ -1138,13 +1139,13 @@ void main() { expect(find.text('Item 100'), findsNothing); expect(find.text('Item 200'), findsNothing); - var itemFinder = find.text('Item 300'); + final itemFinder = find.text('Item 300'); expect(itemFinder, findsOneWidget); expect(tester.getTopLeft(itemFinder).dy, 0); }, skip: true); testWidgets( - 'Jump to 400 at bottom, manually scroll, scroll to 100 at bottom and back', + '''Jump to 400 at bottom, manually scroll, scroll to 100 at bottom and back''', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); @@ -1168,7 +1169,7 @@ void main() { index: 400, alignment: 1, duration: scrollDuration)); await tester.pumpAndSettle(); - var itemFinder = find.text('Item 399'); + final itemFinder = find.text('Item 399'); expect(itemFinder, findsOneWidget); expect(tester.getBottomLeft(itemFinder).dy, screenHeight); }); @@ -1537,7 +1538,7 @@ void main() { }); testWidgets('List can be keyed', (WidgetTester tester) async { - final key = ValueKey('key'); + const key = ValueKey('key'); await setUpWidgetTest(tester, key: key); @@ -1559,7 +1560,7 @@ void main() { home: PageView( children: [ KeyedSubtree( - key: PageStorageKey('key'), + key: const PageStorageKey('key'), child: ScrollablePositionedList.builder( itemCount: defaultItemCount, itemScrollController: itemScrollController, @@ -1570,7 +1571,7 @@ void main() { itemPositionsListener: itemPositionsListener, ), ), - Center( + const Center( child: Text('Test'), ) ], @@ -1616,7 +1617,7 @@ void main() { home: PageView( children: [ KeyedSubtree( - key: PageStorageKey('key'), + key: const PageStorageKey('key'), child: ScrollablePositionedList.builder( itemCount: defaultItemCount, itemScrollController: itemScrollController, @@ -1627,7 +1628,7 @@ void main() { itemPositionsListener: itemPositionsListener, ), ), - Center( + const Center( child: Text('Test'), ) ], @@ -1663,7 +1664,7 @@ void main() { }); testWidgets( - 'Maintain programmatic and user position (9 half way off top) in page view', + '''Maintain programmatic and user position (9 half way off top) in page view''', (WidgetTester tester) async { final itemPositionsListener = ItemPositionsListener.create(); final itemScrollController = ItemScrollController(); @@ -1677,7 +1678,7 @@ void main() { home: PageView( children: [ KeyedSubtree( - key: PageStorageKey('key'), + key: const PageStorageKey('key'), child: ScrollablePositionedList.builder( itemCount: defaultItemCount, itemScrollController: itemScrollController, @@ -1688,7 +1689,7 @@ void main() { itemPositionsListener: itemPositionsListener, ), ), - Center( + const Center( child: Text('Test'), ) ], @@ -1728,7 +1729,7 @@ void main() { (itemHeight / screenHeight) / 2); }); - testWidgets("List with no items", (WidgetTester tester) async { + testWidgets('List with no items', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); await setUpWidgetTest(tester, itemScrollController: itemScrollController, itemCount: 0); @@ -1750,22 +1751,21 @@ void main() { MaterialApp( home: ValueListenableBuilder( valueListenable: itemCount, - builder: (context, itemCount, child) { - return ScrollablePositionedList.builder( - initialScrollIndex: min(100, itemCount), - initialAlignment: 0, - itemCount: itemCount, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener, - itemBuilder: (context, index) { - assert(index >= 0 && index <= itemCount - 1); - return SizedBox( - height: itemHeight, - child: Text('Item $index'), - ); - }, - ); - }, + builder: (context, itemCount, child) => + ScrollablePositionedList.builder( + initialScrollIndex: min(100, itemCount), + itemCount: itemCount, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + itemBuilder: (context, index) { + assert(index >= 0 && index <= itemCount - 1, + 'index not bigger than 0 and smaller than itemCount - 1'); + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ), ), ), ); @@ -1795,20 +1795,19 @@ void main() { MaterialApp( home: ValueListenableBuilder( valueListenable: itemCount, - builder: (context, itemCount, child) { - return ScrollablePositionedList.builder( - initialScrollIndex: min(100, itemCount - 1), - initialAlignment: 0, - itemCount: itemCount, - itemBuilder: (context, index) { - assert(index >= 0 && index <= itemCount - 1); - return SizedBox( - height: itemHeight, - child: Text('Item $index'), - ); - }, - ); - }, + builder: (context, itemCount, child) => + ScrollablePositionedList.builder( + initialScrollIndex: min(100, itemCount - 1), + itemCount: itemCount, + itemBuilder: (context, index) { + assert(index >= 0 && index <= itemCount - 1, + 'index not bigger than 0 and smaller than itemCount -1'); + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ), ), ), ); @@ -1835,19 +1834,19 @@ void main() { MaterialApp( home: ValueListenableBuilder( valueListenable: itemCount, - builder: (context, itemCount, child) { - return ScrollablePositionedList.builder( - initialScrollIndex: itemCount - 1, - itemCount: itemCount, - itemBuilder: (context, index) { - assert(index >= 0 && index <= itemCount - 1); - return SizedBox( - height: itemHeight, - child: Text('Item $index'), - ); - }, - ); - }, + builder: (context, itemCount, child) => + ScrollablePositionedList.builder( + initialScrollIndex: itemCount - 1, + itemCount: itemCount, + itemBuilder: (context, index) { + assert(index >= 0 && index <= itemCount - 1, + 'index not bigger than 0 and smaller than itemCount -1'); + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ), ), ), ); @@ -1948,7 +1947,6 @@ void main() { tester, itemCount: 2, initialAlignment: alignment, - initialIndex: 0, ); await tester.pumpAndSettle(); @@ -1960,35 +1958,31 @@ void main() { tester.binding.window.devicePixelRatioTestValue = 1.0; tester.binding.window.physicalSizeTestValue = const Size(screenWidth, screenHeight); - final key = ValueNotifier(ValueKey('key')); + final key = ValueNotifier(const ValueKey('key')); final itemScrollController = ItemScrollController(); await tester.pumpWidget( MaterialApp( home: ValueListenableBuilder( valueListenable: key, - builder: (context, key, child) { - return Container( - key: key, - child: ScrollablePositionedList.builder( - itemCount: 200, - itemScrollController: itemScrollController, - itemBuilder: (context, index) { - return SizedBox( - height: itemHeight, - child: Text('Item $index'), - ); - }, + builder: (context, key, child) => Container( + key: key, + child: ScrollablePositionedList.builder( + itemCount: 200, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), ), - ); - }, + ), + ), ), ), ); await tester.pumpAndSettle(); - key.value = ValueKey('newKey'); + key.value = const ValueKey('newKey'); await tester.pumpAndSettle(); unawaited( @@ -2003,9 +1997,9 @@ void main() { tester.binding.window.devicePixelRatioTestValue = 1.0; tester.binding.window.physicalSizeTestValue = const Size(screenWidth, screenHeight); - final outerKey = ValueNotifier(ValueKey('outerKey')); + final outerKey = ValueNotifier(const ValueKey('outerKey')); final innerKey = GlobalKey(); - final listKey = ValueNotifier(ValueKey(null)); + final listKey = ValueNotifier(const ValueKey(null)); final itemScrollController = ItemScrollController(); await tester.pumpWidget( @@ -2038,8 +2032,8 @@ void main() { await tester.pumpAndSettle(); - outerKey.value = ValueKey('newOuterKey'); - listKey.value = ValueKey('newListKey'); + outerKey.value = const ValueKey('newOuterKey'); + listKey.value = const ValueKey('newListKey'); await tester.pumpAndSettle(); unawaited( @@ -2053,33 +2047,29 @@ void main() { tester.binding.window.devicePixelRatioTestValue = 1.0; tester.binding.window.physicalSizeTestValue = const Size(screenWidth, screenHeight); - final key = ValueNotifier(ValueKey('key')); + final key = ValueNotifier(const ValueKey('key')); final itemScrollController = ItemScrollController(); await tester.pumpWidget( MaterialApp( home: ValueListenableBuilder( valueListenable: key, - builder: (context, key, child) { - return ScrollablePositionedList.builder( - key: key, - itemCount: 10, - itemScrollController: itemScrollController, - itemBuilder: (context, index) { - return SizedBox( - height: itemHeight, - child: Text('Item $index'), - ); - }, - ); - }, + builder: (context, key, child) => ScrollablePositionedList.builder( + key: key, + itemCount: 10, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + ), ), ), ); await tester.pumpAndSettle(); - key.value = ValueKey('newKey'); + key.value = const ValueKey('newKey'); await tester.pumpAndSettle(); }); @@ -2087,35 +2077,31 @@ void main() { tester.binding.window.devicePixelRatioTestValue = 1.0; tester.binding.window.physicalSizeTestValue = const Size(screenWidth, screenHeight); - final key = ValueNotifier(ValueKey('key')); + final key = ValueNotifier(const ValueKey('key')); final itemScrollController = ItemScrollController(); await tester.pumpWidget( MaterialApp( home: ValueListenableBuilder( valueListenable: key, - builder: (context, key, child) { - return Container( - key: key, - child: ScrollablePositionedList.builder( - itemCount: 100, - itemScrollController: itemScrollController, - itemBuilder: (context, index) { - return SizedBox( - height: itemHeight, - child: Text('Item $index'), - ); - }, + builder: (context, key, child) => Container( + key: key, + child: ScrollablePositionedList.builder( + itemCount: 100, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), ), - ); - }, + ), + ), ), ), ); await tester.pumpAndSettle(); - key.value = ValueKey('newKey'); + key.value = const ValueKey('newKey'); await tester.pumpAndSettle(); unawaited( @@ -2130,7 +2116,7 @@ void main() { tester.binding.window.devicePixelRatioTestValue = 1.0; tester.binding.window.physicalSizeTestValue = const Size(screenWidth, screenHeight); - final containerKey = ValueNotifier(ValueKey('key')); + final containerKey = ValueNotifier(const ValueKey('key')); final scrollKey = GlobalKey(); final itemScrollController = ItemScrollController(); @@ -2138,29 +2124,25 @@ void main() { MaterialApp( home: ValueListenableBuilder( valueListenable: containerKey, - builder: (context, key, child) { - return Container( - key: key, - child: ScrollablePositionedList.builder( - key: scrollKey, - itemCount: 100, - itemScrollController: itemScrollController, - itemBuilder: (context, index) { - return SizedBox( - height: itemHeight, - child: Text('Item $index'), - ); - }, + builder: (context, key, child) => Container( + key: key, + child: ScrollablePositionedList.builder( + key: scrollKey, + itemCount: 100, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), ), - ); - }, + ), + ), ), ), ); await tester.pumpAndSettle(); - containerKey.value = ValueKey('newKey'); + containerKey.value = const ValueKey('newKey'); await tester.pumpAndSettle(); unawaited( @@ -2184,18 +2166,15 @@ void main() { MaterialApp( home: ValueListenableBuilder( valueListenable: itemScrollControllerListenable, - builder: (context, itemScrollController, child) { - return ScrollablePositionedList.builder( - itemCount: 100, - itemScrollController: itemScrollController, - itemBuilder: (context, index) { - return SizedBox( - height: itemHeight, - child: Text('Item $index'), - ); - }, - ); - }, + builder: (context, itemScrollController, child) => + ScrollablePositionedList.builder( + itemCount: 100, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + ), ), ), ); @@ -2236,35 +2215,29 @@ void main() { Expanded( child: ValueListenableBuilder( valueListenable: topItemScrollControllerListenable, - builder: (context, itemScrollController, child) { - return ScrollablePositionedList.builder( - itemCount: 100, - itemScrollController: itemScrollController, - itemBuilder: (context, index) { - return SizedBox( - height: itemHeight, - child: Text('Item $index'), - ); - }, - ); - }, + builder: (context, itemScrollController, child) => + ScrollablePositionedList.builder( + itemCount: 100, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + ), ), ), Expanded( child: ValueListenableBuilder( valueListenable: bottomItemScrollControllerListenable, - builder: (context, itemScrollController, child) { - return ScrollablePositionedList.builder( - itemCount: 100, - itemScrollController: itemScrollController, - itemBuilder: (context, index) { - return SizedBox( - height: itemHeight, - child: Text('Item $index'), - ); - }, - ); - }, + builder: (context, itemScrollController, child) => + ScrollablePositionedList.builder( + itemCount: 100, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + ), ), ), ], diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart index 3a052d0b..b0dac065 100644 --- a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart @@ -270,7 +270,7 @@ void main() { initialAlignment: 0.5); unawaited(itemScrollController.scrollTo( - index: 16, duration: scrollDuration, alignment: 1.0)); + index: 16, duration: scrollDuration, alignment: 1)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration + scrollDurationTolerance); @@ -477,7 +477,7 @@ void main() { }); testWidgets('List can be keyed', (WidgetTester tester) async { - final key = ValueKey('key'); + const key = ValueKey('key'); await setUpWidgetTest(tester, key: key); @@ -498,23 +498,20 @@ void main() { MaterialApp( home: ValueListenableBuilder( valueListenable: itemCount, - builder: (context, itemCount, child) { - return ScrollablePositionedList.separated( - initialScrollIndex: 0, - initialAlignment: 0, - itemCount: itemCount, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener, - itemBuilder: (context, index) => SizedBox( - height: itemHeight, - child: Text('Item $index'), - ), - separatorBuilder: (context, index) => SizedBox( - height: separatorHeight, - child: Text('Separator $index'), - ), - ); - }, + builder: (context, itemCount, child) => + ScrollablePositionedList.separated( + itemCount: itemCount, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + separatorBuilder: (context, index) => SizedBox( + height: separatorHeight, + child: Text('Separator $index'), + ), + ), ), ), ); @@ -542,23 +539,20 @@ void main() { MaterialApp( home: ValueListenableBuilder( valueListenable: itemCount, - builder: (context, itemCount, child) { - return ScrollablePositionedList.separated( - initialScrollIndex: 0, - initialAlignment: 0, - itemCount: itemCount, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener, - itemBuilder: (context, index) => SizedBox( - height: itemHeight, - child: Text('Item $index'), - ), - separatorBuilder: (context, index) => SizedBox( - height: separatorHeight, - child: Text('Separator $index'), - ), - ); - }, + builder: (context, itemCount, child) => + ScrollablePositionedList.separated( + itemCount: itemCount, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + separatorBuilder: (context, index) => SizedBox( + height: separatorHeight, + child: Text('Separator $index'), + ), + ), ), ), ); From f805419816720c5d3830e78d5030a64d2f71a27f Mon Sep 17 00:00:00 2001 From: Gordon Hayes Date: Mon, 18 Oct 2021 11:42:39 +0200 Subject: [PATCH 12/35] chore: linting --- .../lib/src/message_widget.dart | 10 ++- .../src/element_registry.dart | 8 +- .../src/item_positions_listener.dart | 11 +-- .../src/positioned_list.dart | 81 ++++++++++--------- .../src/scrollable_positioned_list.dart | 66 +++++++-------- .../src/viewport.dart | 77 ++++++++++-------- ...ontal_scrollable_positioned_list_test.dart | 16 ++-- .../positioned_list_test.dart | 6 +- .../reversed_positioned_list_test.dart | 8 +- ...ersed_scrollable_positioned_list_test.dart | 8 +- .../scrollable_positioned_list_test.dart | 16 ++-- .../separated_positioned_list_test.dart | 2 +- ...rated_scrollable_positioned_list_test.dart | 6 +- ...ontal_scrollable_positioned_list_test.dart | 4 +- 14 files changed, 170 insertions(+), 149 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/message_widget.dart b/packages/stream_chat_flutter/lib/src/message_widget.dart index b0155545..f6ae52c6 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget.dart @@ -678,14 +678,16 @@ class _MessageWidgetState extends State ? Container( transform: Matrix4.translationValues( - widget.reverse ? 12 : -12, - 0, - 0), + widget.reverse ? 12 : -12, + 0, + 0, + ), constraints: const BoxConstraints( maxWidth: 22 * 6.0, ), child: _buildReactionIndicator( - context), + context, + ), ) : null, portalAnchor: diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/element_registry.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/element_registry.dart index f4327246..218e2ca0 100644 --- a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/element_registry.dart +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/element_registry.dart @@ -46,9 +46,11 @@ class _RegistryWidgetState extends State { } class _InheritedRegistryWidget extends InheritedWidget { - const _InheritedRegistryWidget( - {Key? key, required this.state, required Widget child}) - : super(key: key, child: child); + const _InheritedRegistryWidget({ + Key? key, + required this.state, + required Widget child, + }) : super(key: key, child: child); final _RegistryWidgetState state; diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/item_positions_listener.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/item_positions_listener.dart index 886cabe7..fbfd9c56 100644 --- a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/item_positions_listener.dart +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/item_positions_listener.dart @@ -21,10 +21,11 @@ abstract class ItemPositionsListener { /// Position information for an item in the list. class ItemPosition { /// Create an [ItemPosition]. - const ItemPosition( - {required this.index, - required this.itemLeadingEdge, - required this.itemTrailingEdge}); + const ItemPosition({ + required this.index, + required this.itemLeadingEdge, + required this.itemTrailingEdge, + }); /// Index of the item. final int index; @@ -52,7 +53,7 @@ class ItemPosition { @override int get hashCode => - 31 * (31 * (7 + index.hashCode) + itemLeadingEdge.hashCode) + + 31 * (31 * (index.hashCode + 7) + itemLeadingEdge.hashCode) + itemTrailingEdge.hashCode; @override diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/positioned_list.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/positioned_list.dart index 43f3d9ab..03376fb3 100644 --- a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/positioned_list.dart +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/positioned_list.dart @@ -187,10 +187,11 @@ class _PositionedListState extends State { (context, index) => widget.separatorBuilder == null ? _buildItem(widget.positionedIndex - (index + 1)) : _buildSeparatedListElement( - 2 * widget.positionedIndex - (index + 1)), + widget.positionedIndex * 2 - (index + 1), + ), childCount: widget.separatorBuilder == null ? widget.positionedIndex - : 2 * widget.positionedIndex, + : widget.positionedIndex * 2, addSemanticIndexes: false, findChildIndexCallback: widget.findChildIndexCallback, addRepaintBoundaries: widget.addRepaintBoundaries, @@ -206,7 +207,8 @@ class _PositionedListState extends State { (context, index) => widget.separatorBuilder == null ? _buildItem(index + widget.positionedIndex) : _buildSeparatedListElement( - index + 2 * widget.positionedIndex), + index + widget.positionedIndex * 2, + ), childCount: widget.itemCount != 0 ? 1 : 0, findChildIndexCallback: widget.findChildIndexCallback, addSemanticIndexes: false, @@ -224,7 +226,8 @@ class _PositionedListState extends State { (context, index) => widget.separatorBuilder == null ? _buildItem(index + widget.positionedIndex + 1) : _buildSeparatedListElement( - index + 2 * widget.positionedIndex + 1), + index + widget.positionedIndex * 2 + 1, + ), childCount: widget.separatorBuilder == null ? widget.itemCount - widget.positionedIndex - 1 : 2 * (widget.itemCount - widget.positionedIndex - 1), @@ -270,27 +273,27 @@ class _PositionedListState extends State { EdgeInsets get _centerSliverPadding => widget.scrollDirection == Axis.vertical ? widget.reverse ? widget.padding?.copyWith( - top: widget.positionedIndex == widget.itemCount - 1 - ? widget.padding!.top - : 0, - bottom: widget.positionedIndex == 0 - ? widget.padding!.bottom - : 0) ?? + top: widget.positionedIndex == widget.itemCount - 1 + ? widget.padding!.top + : 0, + bottom: + widget.positionedIndex == 0 ? widget.padding!.bottom : 0, + ) ?? const EdgeInsets.all(0) : widget.padding?.copyWith( - top: widget.positionedIndex == 0 ? widget.padding!.top : 0, - bottom: widget.positionedIndex == widget.itemCount - 1 - ? widget.padding!.bottom - : 0) ?? + top: widget.positionedIndex == 0 ? widget.padding!.top : 0, + bottom: widget.positionedIndex == widget.itemCount - 1 + ? widget.padding!.bottom + : 0, + ) ?? const EdgeInsets.all(0) : widget.reverse ? widget.padding?.copyWith( - left: widget.positionedIndex == widget.itemCount - 1 - ? widget.padding!.left - : 0, - right: widget.positionedIndex == 0 - ? widget.padding!.right - : 0) ?? + left: widget.positionedIndex == widget.itemCount - 1 + ? widget.padding!.left + : 0, + right: widget.positionedIndex == 0 ? widget.padding!.right : 0, + ) ?? const EdgeInsets.all(0) : widget.padding?.copyWith( left: widget.positionedIndex == 0 ? widget.padding!.left : 0, @@ -329,28 +332,30 @@ class _PositionedListState extends State { viewport.offset.pixels + viewport.anchor * viewport.size.height; positions.add(ItemPosition( - index: key.index, - itemLeadingEdge: itemOffset.round() / - scrollController.position.viewportDimension, - itemTrailingEdge: (itemOffset + box.size.height).round() / - scrollController.position.viewportDimension)); + index: key.index, + itemLeadingEdge: itemOffset.round() / + scrollController.position.viewportDimension, + itemTrailingEdge: (itemOffset + box.size.height).round() / + scrollController.position.viewportDimension, + )); } else { final itemOffset = box.localToGlobal(Offset.zero, ancestor: viewport).dx; positions.add(ItemPosition( - index: key.index, - itemLeadingEdge: (widget.reverse - ? scrollController.position.viewportDimension - - (itemOffset + box.size.width) - : itemOffset) - .round() / - scrollController.position.viewportDimension, - itemTrailingEdge: (widget.reverse - ? scrollController.position.viewportDimension - - itemOffset - : (itemOffset + box.size.width)) - .round() / - scrollController.position.viewportDimension)); + index: key.index, + itemLeadingEdge: (widget.reverse + ? scrollController.position.viewportDimension - + (itemOffset + box.size.width) + : itemOffset) + .round() / + scrollController.position.viewportDimension, + itemTrailingEdge: (widget.reverse + ? scrollController.position.viewportDimension - + itemOffset + : (itemOffset + box.size.width)) + .round() / + scrollController.position.viewportDimension, + )); } } widget.itemPositionsNotifier?.itemPositions.value = positions; diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scrollable_positioned_list.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scrollable_positioned_list.dart index 704ed221..98ce0dbf 100644 --- a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scrollable_positioned_list.dart +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scrollable_positioned_list.dart @@ -315,20 +315,14 @@ class _ScrollablePositionedListState extends State } if (widget.itemCount == 0) { - setState(() { - primary.target = 0; - secondary.target = 0; - }); + primary.target = 0; + secondary.target = 0; } else { if (primary.target > widget.itemCount - 1) { - setState(() { - primary.target = widget.itemCount - 1; - }); + primary.target = widget.itemCount - 1; } if (secondary.target > widget.itemCount - 1) { - setState(() { - secondary.target = widget.itemCount - 1; - }); + secondary.target = widget.itemCount - 1; } } } @@ -465,19 +459,21 @@ class _ScrollablePositionedListState extends State required List opacityAnimationWeights, }) async { final direction = index > primary.target ? 1 : -1; - final itemPosition = primary.itemPositionsNotifier.itemPositions.value - .firstWhereOrNull( - (ItemPosition itemPosition) => itemPosition.index == index); + final itemPosition = + primary.itemPositionsNotifier.itemPositions.value.firstWhereOrNull( + (ItemPosition itemPosition) => itemPosition.index == index, + ); if (itemPosition != null) { // Scroll directly. final localScrollAmount = itemPosition.itemLeadingEdge * primary.scrollController.position.viewportDimension; await primary.scrollController.animateTo( - primary.scrollController.offset + - localScrollAmount - - alignment * primary.scrollController.position.viewportDimension, - duration: duration, - curve: curve); + primary.scrollController.offset + + localScrollAmount - + alignment * primary.scrollController.position.viewportDimension, + duration: duration, + curve: curve, + ); } else { final scrollAmount = _screenScrollCount * primary.scrollController.position.viewportDimension; @@ -488,7 +484,8 @@ class _ScrollablePositionedListState extends State startAnimationCallback = () {}; opacity.parent = _opacityAnimation(opacityAnimationWeights).animate( - AnimationController(vsync: this, duration: duration)..forward()); + AnimationController(vsync: this, duration: duration)..forward(), + ); secondary.scrollController.jumpTo(-direction * (_screenScrollCount * primary.scrollController.position.viewportDimension - @@ -496,9 +493,10 @@ class _ScrollablePositionedListState extends State secondary.scrollController.position.viewportDimension)); startCompleter.complete(primary.scrollController.animateTo( - primary.scrollController.offset + direction * scrollAmount, - duration: duration, - curve: curve)); + primary.scrollController.offset + direction * scrollAmount, + duration: duration, + curve: curve, + )); endCompleter.complete(secondary.scrollController .animateTo(0, duration: duration, curve: curve)); }); @@ -548,14 +546,17 @@ class _ScrollablePositionedListState extends State const endOpacity = 1.0; return TweenSequence(>[ TweenSequenceItem( - tween: ConstantTween(startOpacity), - weight: opacityAnimationWeights[0]), + tween: ConstantTween(startOpacity), + weight: opacityAnimationWeights[0], + ), TweenSequenceItem( - tween: Tween(begin: startOpacity, end: endOpacity), - weight: opacityAnimationWeights[1]), + tween: Tween(begin: startOpacity, end: endOpacity), + weight: opacityAnimationWeights[1], + ), TweenSequenceItem( - tween: ConstantTween(endOpacity), - weight: opacityAnimationWeights[2]), + tween: ConstantTween(endOpacity), + weight: opacityAnimationWeights[2], + ), ]); } @@ -565,11 +566,10 @@ class _ScrollablePositionedListState extends State position.itemLeadingEdge < 1 && position.itemTrailingEdge > 0); if (itemPositions.isNotEmpty) { PageStorage.of(context)!.writeState( - context, - itemPositions.reduce((value, element) => - value.itemLeadingEdge < element.itemLeadingEdge - ? value - : element)); + context, + itemPositions.reduce((value, element) => + value.itemLeadingEdge < element.itemLeadingEdge ? value : element), + ); } widget.itemPositionsNotifier?.itemPositions.value = itemPositions; } diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/viewport.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/viewport.dart index 7e307040..01dce022 100644 --- a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/viewport.dart +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/viewport.dart @@ -29,13 +29,14 @@ class UnboundedViewport extends Viewport { List slivers = const [], }) : _anchor = anchor, super( - key: key, - axisDirection: axisDirection, - crossAxisDirection: crossAxisDirection, - offset: offset, - center: center, - cacheExtent: cacheExtent, - slivers: slivers); + key: key, + axisDirection: axisDirection, + crossAxisDirection: crossAxisDirection, + offset: offset, + center: center, + cacheExtent: cacheExtent, + slivers: slivers, + ); // [Viewport] enforces constraints on [Viewport.anchor], so we need our own // version. @@ -76,12 +77,13 @@ class UnboundedRenderViewport extends RenderViewport { double? cacheExtent, }) : _anchor = anchor, super( - axisDirection: axisDirection, - crossAxisDirection: crossAxisDirection, - offset: offset, - center: center, - cacheExtent: cacheExtent, - children: children); + axisDirection: axisDirection, + crossAxisDirection: crossAxisDirection, + offset: offset, + center: center, + cacheExtent: cacheExtent, + children: children, + ); static const int _maxLayoutCycles = 10; @@ -179,8 +181,11 @@ class UnboundedRenderViewport extends RenderViewport { double correction; var count = 0; do { - correction = _attemptLayout(mainAxisExtent, crossAxisExtent, - offset.pixels + centerOffsetAdjustment); + correction = _attemptLayout( + mainAxisExtent, + crossAxisExtent, + offset.pixels + centerOffsetAdjustment, + ); if (correction != 0.0) { offset.correctBy(correction); } else { @@ -200,29 +205,33 @@ class UnboundedRenderViewport extends RenderViewport { if (count >= _maxLayoutCycles) { assert(count != 1, 'count not equal to 1'); throw FlutterError( - 'A RenderViewport exceeded its maximum number of layout cycles.\n' - 'RenderViewport render objects, during layout, can retry if either their ' - 'slivers or their ViewportOffset decide that the offset should be corrected ' - 'to take into account information collected during that layout.\n' - 'In the case of this RenderViewport object, however, this happened $count ' - 'times and still there was no consensus on the scroll offset. This usually ' - 'indicates a bug. Specifically, it means that one of the following three ' - 'problems is being experienced by the RenderViewport object:\n' - ' * One of the RenderSliver children or the ViewportOffset have a bug such' - ' that they always think that they need to correct the offset regardless.\n' - ' * Some combination of the RenderSliver children and the ViewportOffset' - ' have a bad interaction such that one applies a correction then another' - ' applies a reverse correction, leading to an infinite loop of corrections.\n' - ' * There is a pathological case that would eventually resolve, but it is' - ' so complicated that it cannot be resolved in any reasonable number of' - ' layout passes.'); + 'A RenderViewport exceeded its maximum number of layout cycles.\n' + 'RenderViewport render objects, during layout, can retry if either their ' + 'slivers or their ViewportOffset decide that the offset should be corrected ' + 'to take into account information collected during that layout.\n' + 'In the case of this RenderViewport object, however, this happened $count ' + 'times and still there was no consensus on the scroll offset. This usually ' + 'indicates a bug. Specifically, it means that one of the following three ' + 'problems is being experienced by the RenderViewport object:\n' + ' * One of the RenderSliver children or the ViewportOffset have a bug such' + ' that they always think that they need to correct the offset regardless.\n' + ' * Some combination of the RenderSliver children and the ViewportOffset' + ' have a bad interaction such that one applies a correction then another' + ' applies a reverse correction, leading to an infinite loop of corrections.\n' + ' * There is a pathological case that would eventually resolve, but it is' + ' so complicated that it cannot be resolved in any reasonable number of' + ' layout passes.', + ); } return true; }(), 'count needs to be bigger than _maxLayoutCycles'); } double _attemptLayout( - double mainAxisExtent, double crossAxisExtent, double correctedOffset) { + double mainAxisExtent, + double crossAxisExtent, + double correctedOffset, + ) { assert(!mainAxisExtent.isNaN, 'assert mainAxisExtent.isNaN'); assert(mainAxisExtent >= 0.0, 'assert mainAxisExtent >= 0.0'); assert(crossAxisExtent.isFinite, 'assert crossAxisExtent.isFinite'); @@ -301,7 +310,9 @@ class UnboundedRenderViewport extends RenderViewport { @override void updateOutOfBandData( - GrowthDirection growthDirection, SliverGeometry childLayoutGeometry) { + GrowthDirection growthDirection, + SliverGeometry childLayoutGeometry, + ) { switch (growthDirection) { case GrowthDirection.forward: _maxScrollExtent += childLayoutGeometry.scrollExtent; diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/horizontal_scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/horizontal_scrollable_positioned_list_test.dart index 61b22aa2..5f34fc15 100644 --- a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/horizontal_scrollable_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/horizontal_scrollable_positioned_list_test.dart @@ -208,9 +208,9 @@ void main() { expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10)); expect(tester.getTopLeft(find.text('Item 1')), - const Offset(10 + itemWidth, 10)); + const Offset(itemWidth + 10, 10)); expect(tester.getBottomRight(find.text('Item 1')), - const Offset(10 + 2 * itemWidth, screenHeight - 10)); + const Offset(10 + itemWidth * 2, screenHeight - 10)); unawaited( itemScrollController.scrollTo(index: 490, duration: scrollDuration)); @@ -240,9 +240,9 @@ void main() { expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10)); expect(tester.getTopLeft(find.text('Item 2')), - const Offset(10 + 2 * itemWidth, 10)); + const Offset(10 + itemWidth * 2, 10)); expect(tester.getTopLeft(find.text('Item 3')), - const Offset(10 + 3 * itemWidth, 10)); + const Offset(10 + itemWidth * 3, 10)); }); testWidgets('padding test - reversed - centered sliver at right', @@ -258,9 +258,9 @@ void main() { expect(tester.getTopRight(find.text('Item 0')), const Offset(screenWidth - 10, 10)); expect(tester.getTopRight(find.text('Item 1')), - const Offset(screenWidth - (10 + itemWidth), 10)); + const Offset(screenWidth - (itemWidth + 10), 10)); expect(tester.getBottomLeft(find.text('Item 1')), - const Offset(screenWidth - (10 + 2 * itemWidth), screenHeight - 10)); + const Offset(screenWidth - (10 + itemWidth * 2), screenHeight - 10)); unawaited( itemScrollController.scrollTo(index: 490, duration: scrollDuration)); @@ -291,8 +291,8 @@ void main() { expect(tester.getTopRight(find.text('Item 0')), const Offset(screenWidth - 10, 10)); expect(tester.getTopRight(find.text('Item 2')), - const Offset(screenWidth - (10 + 2 * itemWidth), 10)); + const Offset(screenWidth - (10 + itemWidth * 2), 10)); expect(tester.getTopRight(find.text('Item 3')), - const Offset(screenWidth - (10 + 3 * itemWidth), 10)); + const Offset(screenWidth - (10 + itemWidth * 3), 10)); }); } diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/positioned_list_test.dart b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/positioned_list_test.dart index 983f249f..b3522391 100644 --- a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/positioned_list_test.dart @@ -197,7 +197,7 @@ void main() { await setUpWidgetTest(tester, topItem: 5); await tester.drag( - find.byType(PositionedList), const Offset(0, 2 * itemHeight)); + find.byType(PositionedList), const Offset(0, itemHeight * 2)); await tester.pump(); expect(find.text('Item 2'), findsNothing); @@ -248,7 +248,7 @@ void main() { await setUpWidgetTest(tester, scrollController: scrollController); await tester.pump(); - scrollController.jumpTo(5 * itemHeight); + scrollController.jumpTo(itemHeight * 5); await tester.pump(); await tester.pumpAndSettle(); @@ -307,7 +307,7 @@ void main() { await setUpWidgetTest(tester, topItem: 5, scrollController: scrollController); - scrollController.jumpTo(20 * itemHeight); + scrollController.jumpTo(itemHeight * 20); await tester.pump(); expect( diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/reversed_positioned_list_test.dart b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/reversed_positioned_list_test.dart index 8b3a1ab5..54aa4c02 100644 --- a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/reversed_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/reversed_positioned_list_test.dart @@ -162,7 +162,7 @@ void main() { await setUpWidgetTest(tester, topItem: 5); await tester.drag( - find.byType(PositionedList), const Offset(0, 2 * itemHeight)); + find.byType(PositionedList), const Offset(0, itemHeight * 2)); await tester.pump(); expect(find.text('Item 6'), findsNothing); @@ -186,7 +186,7 @@ void main() { await setUpWidgetTest(tester, scrollController: scrollController); await tester.pump(); - scrollController.jumpTo(5 * itemHeight); + scrollController.jumpTo(itemHeight * 5); await tester.pump(); await tester.pumpAndSettle(); @@ -214,7 +214,7 @@ void main() { await setUpWidgetTest(tester, topItem: 5, scrollController: scrollController); - scrollController.jumpTo(2 * itemHeight); + scrollController.jumpTo(itemHeight * 2); await tester.pump(); expect(find.text('Item 6'), findsNothing); @@ -242,7 +242,7 @@ void main() { testWidgets('List positioned with 5 at bottom and initial scroll offset', (WidgetTester tester) async { final scrollController = - ScrollController(initialScrollOffset: 2 * itemHeight); + ScrollController(initialScrollOffset: itemHeight * 2); await setUpWidgetTest(tester, topItem: 5, scrollController: scrollController); diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/reversed_scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/reversed_scrollable_positioned_list_test.dart index f93bcf71..9e453259 100644 --- a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/reversed_scrollable_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/reversed_scrollable_positioned_list_test.dart @@ -200,9 +200,9 @@ void main() { expect(tester.getBottomLeft(find.text('Item 0')), const Offset(10, screenHeight - 10)); expect(tester.getBottomLeft(find.text('Item 1')), - const Offset(10, screenHeight - (10 + itemHeight))); + const Offset(10, screenHeight - (itemHeight + 10))); expect(tester.getTopRight(find.text('Item 1')), - const Offset(screenWidth - 10, screenHeight - (10 + 2 * itemHeight))); + const Offset(screenWidth - 10, screenHeight - (10 + itemHeight * 2))); unawaited( itemScrollController.scrollTo(index: 490, duration: scrollDuration)); @@ -232,8 +232,8 @@ void main() { expect(tester.getBottomLeft(find.text('Item 0')), const Offset(10, screenHeight - 10)); expect(tester.getBottomLeft(find.text('Item 2')), - const Offset(10, screenHeight - (10 + 2 * itemHeight))); + const Offset(10, screenHeight - (10 + itemHeight * 2))); expect(tester.getBottomLeft(find.text('Item 3')), - const Offset(10, screenHeight - (10 + 3 * itemHeight))); + const Offset(10, screenHeight - (10 + itemHeight * 3))); }); } diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/scrollable_positioned_list_test.dart index 243aeeb1..4f0dcc1d 100644 --- a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/scrollable_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/scrollable_positioned_list_test.dart @@ -1211,7 +1211,7 @@ void main() { itemScrollController: itemScrollController, initialIndex: 5); await tester.drag( - find.byType(ScrollablePositionedList), const Offset(0, 2 * itemHeight)); + find.byType(ScrollablePositionedList), const Offset(0, itemHeight * 2)); await tester.pumpAndSettle(); final indexSemantics3 = tester.widget(find.ancestor( @@ -1234,7 +1234,7 @@ void main() { await tester.pumpAndSettle(); await tester.drag( - find.byType(ScrollablePositionedList), const Offset(0, 2 * itemHeight)); + find.byType(ScrollablePositionedList), const Offset(0, itemHeight * 2)); await tester.pumpAndSettle(); final indexSemantics3b = tester.widget(find.ancestor( @@ -1339,9 +1339,9 @@ void main() { expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10)); expect(tester.getTopLeft(find.text('Item 1')), - const Offset(10, 10 + itemHeight)); + const Offset(10, itemHeight + 10)); expect(tester.getTopRight(find.text('Item 1')), - const Offset(screenWidth - 10, 10 + itemHeight)); + const Offset(screenWidth - 10, itemHeight + 10)); unawaited( itemScrollController.scrollTo(index: 490, duration: scrollDuration)); @@ -1371,9 +1371,9 @@ void main() { expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10)); expect(tester.getTopLeft(find.text('Item 2')), - const Offset(10, 10 + 2 * itemHeight)); + const Offset(10, 10 + itemHeight * 2)); expect(tester.getTopLeft(find.text('Item 3')), - const Offset(10, 10 + 3 * itemHeight)); + const Offset(10, 10 + itemHeight * 3)); }); testWidgets('padding - first element centered - scroll up', @@ -1862,7 +1862,7 @@ void main() { }); testWidgets('Large minCacheExtent', (WidgetTester tester) async { - await setUpWidgetTest(tester, minCacheExtent: 200 * itemHeight); + await setUpWidgetTest(tester, minCacheExtent: itemHeight * 200); expect(find.text('Item 100', skipOffstage: false), findsOneWidget); }); @@ -1911,7 +1911,7 @@ void main() { await setUpWidgetTest( tester, itemScrollController: itemScrollController, - minCacheExtent: 200 * itemHeight, + minCacheExtent: itemHeight * 200, ); var fadeTransition = tester.widget(find diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/separated_positioned_list_test.dart b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/separated_positioned_list_test.dart index 5afdb438..a4252a23 100644 --- a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/separated_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/separated_positioned_list_test.dart @@ -86,7 +86,7 @@ void main() { await setUpWidgetTest(tester, itemCount: 3, topItem: 1); await tester.drag( - find.byType(PositionedList), const Offset(0, 2 * itemHeight)); + find.byType(PositionedList), const Offset(0, itemHeight * 2)); await tester.pumpAndSettle(); expect(find.text('Item 0'), findsOneWidget); diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart index b0dac065..44164d6b 100644 --- a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart @@ -320,7 +320,7 @@ void main() { await setUpWidgetTest(tester, initialIndex: 5); await tester.drag( - find.byType(ScrollablePositionedList), const Offset(0, 4 * itemHeight)); + find.byType(ScrollablePositionedList), const Offset(0, itemHeight * 4)); await tester.pumpAndSettle(); final indexSemantics3 = tester.widget(find.ancestor( @@ -401,9 +401,9 @@ void main() { expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10)); expect(tester.getTopLeft(find.text('Item 1')), - const Offset(10, 10 + itemHeight + separatorHeight)); + const Offset(10, itemHeight + 10 + separatorHeight)); expect(tester.getTopRight(find.text('Item 1')), - const Offset(screenWidth - 10, 10 + itemHeight + separatorHeight)); + const Offset(screenWidth - 10, itemHeight + 10 + separatorHeight)); unawaited( itemScrollController.scrollTo(index: 494, duration: scrollDuration)); diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/seperated_horizontal_scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/seperated_horizontal_scrollable_positioned_list_test.dart index 2c8dbacb..55935638 100644 --- a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/seperated_horizontal_scrollable_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/seperated_horizontal_scrollable_positioned_list_test.dart @@ -166,9 +166,9 @@ void main() { expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10)); expect(tester.getTopLeft(find.text('Item 1')), - const Offset(10 + itemWidth + separatorWidth, 10)); + const Offset(itemWidth + 10 + separatorWidth, 10)); expect(tester.getBottomRight(find.text('Item 1')), - const Offset(10 + 2 * itemWidth + separatorWidth, screenHeight - 10)); + const Offset(10 + itemWidth * 2 + separatorWidth, screenHeight - 10)); unawaited( itemScrollController.scrollTo(index: 494, duration: scrollDuration)); From 0c864a14532d6aa19c986e4dc5134e16ddacd405 Mon Sep 17 00:00:00 2001 From: Gordon Hayes Date: Tue, 19 Oct 2021 17:58:33 +0200 Subject: [PATCH 13/35] fix: positioned list initial index --- packages/stream_chat_flutter/lib/src/message_list_view.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stream_chat_flutter/lib/src/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view.dart index 30d9420d..1a5f6819 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -311,7 +311,7 @@ class _MessageListViewState extends State { final messageIndex = messages.indexWhere((e) => e.id == streamChannel!.initialMessageId); final index = totalMessages - messageIndex; - if (index != 0) return index - 1; + if (index != 0) return index + 1; return index; } return 0; From b41b08cf346ac785996754292892af69336aec81 Mon Sep 17 00:00:00 2001 From: Gordon Hayes Date: Tue, 19 Oct 2021 18:02:03 +0200 Subject: [PATCH 14/35] fix: default message filter --- .../lib/src/message_list_view.dart | 7 ++++- .../lib/src/message_list_core.dart | 29 ++++++++++--------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view.dart index 1a5f6819..ecd9c220 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -306,7 +306,12 @@ class _MessageListViewState extends State { final initialScrollIndex = widget.initialScrollIndex; if (initialScrollIndex != null) return initialScrollIndex; if (streamChannel!.initialMessageId != null) { - final messages = streamChannel!.channel.state!.messages; + final messages = streamChannel!.channel.state!.messages + .where(widget.messageFilter ?? + defaultMessageFilter( + streamChannel!.channel.client.state.currentUser!.id, + )) + .toList(growable: false); final totalMessages = messages.length; final messageIndex = messages.indexWhere((e) => e.id == streamChannel!.initialMessageId); diff --git a/packages/stream_chat_flutter_core/lib/src/message_list_core.dart b/packages/stream_chat_flutter_core/lib/src/message_list_core.dart index b9b423ce..74e40d7f 100644 --- a/packages/stream_chat_flutter_core/lib/src/message_list_core.dart +++ b/packages/stream_chat_flutter_core/lib/src/message_list_core.dart @@ -9,6 +9,14 @@ import 'package:stream_chat_flutter_core/src/better_stream_builder.dart'; import 'package:stream_chat_flutter_core/src/stream_channel.dart'; import 'package:stream_chat_flutter_core/src/typedef.dart'; +/// Default filter for the message list +bool Function(Message) defaultMessageFilter(String currentUserId) => + (Message m) { + final isMyMessage = m.user?.id == currentUserId; + if (m.shadowed && !isMyMessage) return false; + return true; + }; + /// [MessageListCore] is a simplified class that allows fetching a list of /// messages while exposing UI builders. /// @@ -132,25 +140,20 @@ class MessageListCoreState extends State { ? _streamChannel!.channel.state?.threads[widget.parentMessage!.id] : _streamChannel!.channel.state?.messages; - bool defaultFilter(Message m) { - final isMyMessage = m.user?.id == _currentUser?.id; - if (m.shadowed && !isMyMessage) return false; - return true; - } - return BetterStreamBuilder>( initialData: initialData, comparator: const ListEquality().equals, - stream: messagesStream!.map( - (messages) => - messages?.where(widget.messageFilter ?? defaultFilter).toList( - growable: false, - ), - ), + stream: messagesStream, errorBuilder: widget.errorBuilder, noDataBuilder: widget.loadingBuilder, builder: (context, data) { - final messageList = data.reversed.toList(growable: false); + final messageList = data + .where( + widget.messageFilter ?? defaultMessageFilter(_currentUser!.id), + ) + .toList(growable: false) + .reversed + .toList(growable: false); if (messageList.isEmpty && !_isThreadConversation) { if (_upToDate) { return widget.emptyBuilder(context); From 3a9be7431f62bc5c4727a993d03c00a8ef6361b9 Mon Sep 17 00:00:00 2001 From: Gordon Hayes Date: Tue, 19 Oct 2021 18:13:31 +0200 Subject: [PATCH 15/35] chore: center message list initial aligment --- packages/stream_chat_flutter/lib/src/message_list_view.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stream_chat_flutter/lib/src/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view.dart index ecd9c220..681a3252 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -325,7 +325,7 @@ class _MessageListViewState extends State { double get _initialAlignment { final initialAlignment = widget.initialAlignment; if (initialAlignment != null) return initialAlignment; - return 0; + return 0.5; } bool _isInitialMessage(String id) => streamChannel!.initialMessageId == id; From cb4ed36c246baa2ffb67bd2b9e02fd09ed51c13b Mon Sep 17 00:00:00 2001 From: Gordon Hayes Date: Tue, 19 Oct 2021 18:17:19 +0200 Subject: [PATCH 16/35] fix: use didUpdateWidget to scroll to new initial index --- .../lib/src/message_list_view.dart | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view.dart index 681a3252..23585589 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -353,6 +353,20 @@ class _MessageListViewState extends State { MessageListController get _messageListController => widget.messageListController ?? _defaultController; + @override + void didUpdateWidget(MessageListView oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.initialScrollIndex != oldWidget.initialScrollIndex || + widget.initialAlignment != oldWidget.initialAlignment) { + initialIndex = _initialIndex; + initialAlignment = _initialAlignment; + _scrollController?.jumpTo( + index: initialIndex, + alignment: initialAlignment, + ); + } + } + @override Widget build(BuildContext context) => MessageListCore( paginationLimit: widget.paginationLimit, @@ -472,9 +486,6 @@ class _MessageListViewState extends State { _inBetweenList = true; }, child: ScrollablePositionedList.separated( - key: _upToDate - ? null - : ValueKey(initialIndex + initialAlignment), itemPositionsListener: _itemPositionListener, initialScrollIndex: initialIndex, initialAlignment: initialAlignment, From 92d1967e61543e6a127d460a91cbf65fa3b23f0b Mon Sep 17 00:00:00 2001 From: Gordon Hayes Date: Tue, 19 Oct 2021 18:32:48 +0200 Subject: [PATCH 17/35] chore: update scrollable positioned list --- .../lib/src/scrollable_positioned_list/src/positioned_list.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/positioned_list.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/positioned_list.dart index 03376fb3..96c9fb5a 100644 --- a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/positioned_list.dart +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/positioned_list.dart @@ -328,6 +328,7 @@ class _PositionedListState extends State { final key = element.widget.key as IndexedKey; if (widget.scrollDirection == Axis.vertical) { final reveal = viewport!.getOffsetToReveal(box, 0).offset; + if (!reveal.isFinite) continue; final itemOffset = reveal - viewport.offset.pixels + viewport.anchor * viewport.size.height; From 6b936311a99793a0bea9340e39f02a24f8420010 Mon Sep 17 00:00:00 2001 From: Gordon Hayes Date: Tue, 19 Oct 2021 18:47:20 +0200 Subject: [PATCH 18/35] chore: set initial alignment to bottom --- packages/stream_chat_flutter/lib/src/message_list_view.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stream_chat_flutter/lib/src/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view.dart index 23585589..95bed1bb 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -325,7 +325,7 @@ class _MessageListViewState extends State { double get _initialAlignment { final initialAlignment = widget.initialAlignment; if (initialAlignment != null) return initialAlignment; - return 0.5; + return 0; } bool _isInitialMessage(String id) => streamChannel!.initialMessageId == id; From 0ad0da21c6c043f719889033f8c41430cd41b306 Mon Sep 17 00:00:00 2001 From: Salvatore Giordano Date: Wed, 20 Oct 2021 10:51:53 +0200 Subject: [PATCH 19/35] chore(ui): remove scrollable_positioned_list from analysis --- analysis_options.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/analysis_options.yaml b/analysis_options.yaml index a1fa9388..ef1c2e56 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -4,6 +4,7 @@ analyzer: exclude: - packages/*/lib/**/*.g.dart - packages/*/lib/src/emoji/** + - packages/*/lib/src/scrollable_positioned_list/** - packages/*/lib/**/*.freezed.dart linter: From 03c2e44d73d005690232458c487f96bd666b7f6f Mon Sep 17 00:00:00 2001 From: Salvatore Giordano Date: Wed, 20 Oct 2021 10:52:26 +0200 Subject: [PATCH 20/35] fix(ui): add initialIndex and alignment to didUpdateWidget in scrollable list --- .../lib/src/message_list_view.dart | 28 ++++--------------- .../src/scrollable_positioned_list.dart | 8 ++++++ 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view.dart index 95bed1bb..ffab1d51 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -353,20 +353,6 @@ class _MessageListViewState extends State { MessageListController get _messageListController => widget.messageListController ?? _defaultController; - @override - void didUpdateWidget(MessageListView oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.initialScrollIndex != oldWidget.initialScrollIndex || - widget.initialAlignment != oldWidget.initialAlignment) { - initialIndex = _initialIndex; - initialAlignment = _initialAlignment; - _scrollController?.jumpTo( - index: initialIndex, - alignment: initialAlignment, - ); - } - } - @override Widget build(BuildContext context) => MessageListCore( paginationLimit: widget.paginationLimit, @@ -1255,14 +1241,12 @@ class _MessageListViewState extends State { initialIndex = _initialIndex; initialAlignment = _initialAlignment; - WidgetsBinding.instance!.addPostFrameCallback((timeStamp) { - if (_scrollController?.isAttached == true) { - _scrollController?.jumpTo( - index: initialIndex, - alignment: initialAlignment, - ); - } - }); + if (_scrollController?.isAttached == true) { + _scrollController?.jumpTo( + index: initialIndex, + alignment: initialAlignment, + ); + } _messageNewListener = streamChannel!.channel.on(EventType.messageNew).listen((event) { diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scrollable_positioned_list.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scrollable_positioned_list.dart index 98ce0dbf..4b64bdfb 100644 --- a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scrollable_positioned_list.dart +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scrollable_positioned_list.dart @@ -325,6 +325,14 @@ class _ScrollablePositionedListState extends State secondary.target = widget.itemCount - 1; } } + + if (widget.initialScrollIndex != oldWidget.initialScrollIndex || + widget.initialAlignment != oldWidget.initialAlignment) { + _jumpTo( + index: widget.initialScrollIndex, + alignment: widget.initialAlignment, + ); + } } @override From ac47f4a2fa1ac32273bed8b58072891ab70688d6 Mon Sep 17 00:00:00 2001 From: Salvatore Giordano Date: Wed, 20 Oct 2021 12:25:37 +0200 Subject: [PATCH 21/35] chore(ui): update changelog --- packages/stream_chat_flutter/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 8d4faca8..b4a187fb 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -5,6 +5,8 @@ 🐞 Fixed - [[#491]](https://github.com/GetStream/stream-chat-flutter/issues/491): Fix `MediaListView` showing media in wrong order. +- Fixed `MessageListView` initialIndex not working in some cases. +- Improved `MessageListView` rendering in case of reordering. ✅ Added From 18dfd5a0111037f917746e8df823879c9bac235f Mon Sep 17 00:00:00 2001 From: Gordon Hayes Date: Wed, 20 Oct 2021 14:25:15 +0200 Subject: [PATCH 22/35] chore: move scrollable positioned list directory --- analysis_options.yaml | 2 +- .../scrollable_positioned_list/LICENSE | 0 .../scrollable_positioned_list.dart | 0 .../src/element_registry.dart | 0 .../src/indexed_key.dart | 0 .../src/item_positions_listener.dart | 4 +- .../src/item_positions_notifier.dart | 2 +- .../src/positioned_list.dart | 10 ++-- .../src/post_mount_callback.dart | 0 .../src/scroll_view.dart | 2 +- .../src/scrollable_positioned_list.dart | 8 +-- .../src/viewport.dart | 0 .../lib/src/message_list_view.dart | 2 +- .../src/scrollable_positioned_list/README.md | 53 ------------------- ...ontal_scrollable_positioned_list_test.dart | 2 +- .../positioned_list_test.dart | 6 +-- .../reversed_positioned_list_test.dart | 6 +-- ...ersed_scrollable_positioned_list_test.dart | 2 +- .../scrollable_positioned_list_test.dart | 4 +- .../separated_positioned_list_test.dart | 6 +-- ...rated_scrollable_positioned_list_test.dart | 4 +- ...ontal_scrollable_positioned_list_test.dart | 2 +- 22 files changed, 31 insertions(+), 84 deletions(-) rename packages/stream_chat_flutter/lib/{src => }/scrollable_positioned_list/LICENSE (100%) rename packages/stream_chat_flutter/lib/{src => }/scrollable_positioned_list/scrollable_positioned_list.dart (100%) rename packages/stream_chat_flutter/lib/{src => }/scrollable_positioned_list/src/element_registry.dart (100%) rename packages/stream_chat_flutter/lib/{src => }/scrollable_positioned_list/src/indexed_key.dart (100%) rename packages/stream_chat_flutter/lib/{src => }/scrollable_positioned_list/src/item_positions_listener.dart (90%) rename packages/stream_chat_flutter/lib/{src => }/scrollable_positioned_list/src/item_positions_notifier.dart (80%) rename packages/stream_chat_flutter/lib/{src => }/scrollable_positioned_list/src/positioned_list.dart (96%) rename packages/stream_chat_flutter/lib/{src => }/scrollable_positioned_list/src/post_mount_callback.dart (100%) rename packages/stream_chat_flutter/lib/{src => }/scrollable_positioned_list/src/scroll_view.dart (96%) rename packages/stream_chat_flutter/lib/{src => }/scrollable_positioned_list/src/scrollable_positioned_list.dart (98%) rename packages/stream_chat_flutter/lib/{src => }/scrollable_positioned_list/src/viewport.dart (100%) delete mode 100644 packages/stream_chat_flutter/lib/src/scrollable_positioned_list/README.md rename packages/stream_chat_flutter/test/{src => }/scrollable_positioned_list/horizontal_scrollable_positioned_list_test.dart (99%) rename packages/stream_chat_flutter/test/{src => }/scrollable_positioned_list/positioned_list_test.dart (97%) rename packages/stream_chat_flutter/test/{src => }/scrollable_positioned_list/reversed_positioned_list_test.dart (96%) rename packages/stream_chat_flutter/test/{src => }/scrollable_positioned_list/reversed_scrollable_positioned_list_test.dart (98%) rename packages/stream_chat_flutter/test/{src => }/scrollable_positioned_list/scrollable_positioned_list_test.dart (99%) rename packages/stream_chat_flutter/test/{src => }/scrollable_positioned_list/separated_positioned_list_test.dart (96%) rename packages/stream_chat_flutter/test/{src => }/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart (99%) rename packages/stream_chat_flutter/test/{src => }/scrollable_positioned_list/seperated_horizontal_scrollable_positioned_list_test.dart (98%) diff --git a/analysis_options.yaml b/analysis_options.yaml index ef1c2e56..e5eaf7ca 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -4,7 +4,7 @@ analyzer: exclude: - packages/*/lib/**/*.g.dart - packages/*/lib/src/emoji/** - - packages/*/lib/src/scrollable_positioned_list/** + - packages/*/lib/scrollable_positioned_list/** - packages/*/lib/**/*.freezed.dart linter: diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/LICENSE b/packages/stream_chat_flutter/lib/scrollable_positioned_list/LICENSE similarity index 100% rename from packages/stream_chat_flutter/lib/src/scrollable_positioned_list/LICENSE rename to packages/stream_chat_flutter/lib/scrollable_positioned_list/LICENSE diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/scrollable_positioned_list.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/scrollable_positioned_list.dart similarity index 100% rename from packages/stream_chat_flutter/lib/src/scrollable_positioned_list/scrollable_positioned_list.dart rename to packages/stream_chat_flutter/lib/scrollable_positioned_list/scrollable_positioned_list.dart diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/element_registry.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/element_registry.dart similarity index 100% rename from packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/element_registry.dart rename to packages/stream_chat_flutter/lib/scrollable_positioned_list/src/element_registry.dart diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/indexed_key.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/indexed_key.dart similarity index 100% rename from packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/indexed_key.dart rename to packages/stream_chat_flutter/lib/scrollable_positioned_list/src/indexed_key.dart diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/item_positions_listener.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/item_positions_listener.dart similarity index 90% rename from packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/item_positions_listener.dart rename to packages/stream_chat_flutter/lib/scrollable_positioned_list/src/item_positions_listener.dart index fbfd9c56..1b611393 100644 --- a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/item_positions_listener.dart +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/item_positions_listener.dart @@ -4,8 +4,8 @@ import 'package:flutter/foundation.dart'; -import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/item_positions_notifier.dart'; -import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/scrollable_positioned_list.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/item_positions_notifier.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/scrollable_positioned_list.dart'; /// Provides a listenable iterable of [itemPositions] of items that are on /// screen and their locations. diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/item_positions_notifier.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/item_positions_notifier.dart similarity index 80% rename from packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/item_positions_notifier.dart rename to packages/stream_chat_flutter/lib/scrollable_positioned_list/src/item_positions_notifier.dart index 172426a5..3b3645cb 100644 --- a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/item_positions_notifier.dart +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/item_positions_notifier.dart @@ -4,7 +4,7 @@ import 'package:flutter/foundation.dart'; -import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/item_positions_listener.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/item_positions_listener.dart'; /// Internal implementation of [ItemPositionsListener]. class ItemPositionsNotifier implements ItemPositionsListener { diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/positioned_list.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/positioned_list.dart similarity index 96% rename from packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/positioned_list.dart rename to packages/stream_chat_flutter/lib/scrollable_positioned_list/src/positioned_list.dart index 96c9fb5a..081d67e0 100644 --- a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/positioned_list.dart +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/positioned_list.dart @@ -8,11 +8,11 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; -import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/element_registry.dart'; -import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/indexed_key.dart'; -import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/item_positions_listener.dart'; -import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/item_positions_notifier.dart'; -import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/scroll_view.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/element_registry.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/indexed_key.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/item_positions_listener.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/item_positions_notifier.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/scroll_view.dart'; /// A list of widgets similar to [ListView], except scroll control /// and position reporting is based on index rather than pixel offset. diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/post_mount_callback.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/post_mount_callback.dart similarity index 100% rename from packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/post_mount_callback.dart rename to packages/stream_chat_flutter/lib/scrollable_positioned_list/src/post_mount_callback.dart diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scroll_view.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scroll_view.dart similarity index 96% rename from packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scroll_view.dart rename to packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scroll_view.dart index 9adacfeb..a0b499c0 100644 --- a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scroll_view.dart +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scroll_view.dart @@ -6,7 +6,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; -import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/viewport.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/viewport.dart'; /// {@template custom_scroll_view} /// A version of [CustomScrollView] that does not constrict the extents diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scrollable_positioned_list.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scrollable_positioned_list.dart similarity index 98% rename from packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scrollable_positioned_list.dart rename to packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scrollable_positioned_list.dart index 4b64bdfb..388e5979 100644 --- a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scrollable_positioned_list.dart +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scrollable_positioned_list.dart @@ -10,10 +10,10 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; -import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/item_positions_listener.dart'; -import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/item_positions_notifier.dart'; -import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/positioned_list.dart'; -import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/post_mount_callback.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/item_positions_listener.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/item_positions_notifier.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/positioned_list.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/post_mount_callback.dart'; /// Number of screens to scroll when scrolling a long distance. const int _screenScrollCount = 2; diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/viewport.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/viewport.dart similarity index 100% rename from packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/viewport.dart rename to packages/stream_chat_flutter/lib/scrollable_positioned_list/src/viewport.dart diff --git a/packages/stream_chat_flutter/lib/src/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view.dart index ffab1d51..117ef211 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -7,10 +7,10 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; import 'package:rxdart/rxdart.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:stream_chat_flutter/src/extension.dart'; import 'package:stream_chat_flutter/src/info_tile.dart'; import 'package:stream_chat_flutter/src/message_widget.dart'; -import 'package:stream_chat_flutter/src/scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; import 'package:stream_chat_flutter/src/swipeable.dart'; import 'package:stream_chat_flutter/src/system_message.dart'; diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/README.md b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/README.md deleted file mode 100644 index 41b1f70b..00000000 --- a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# scrollable_positioned_list - -A flutter list that allows scrolling to a specific item in the list. - -Also allows determining what items are currently visible. - -## Usage - -A `ScrollablePositionedList` works much like the builder version of `ListView` -except that the list can be scrolled or jumped to a specific item. - -### Example - -A `ScrollablePositionedList` can be created with: - -```dart -final ItemScrollController itemScrollController = ItemScrollController(); -final ItemPositionsListener itemPositionsListener = ItemPositionsListener.create(); - -ScrollablePositionedList.builder( - itemCount: 500, - itemBuilder: (context, index) => Text('Item $index'), - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener, -); -``` - -One then can scroll to a particular item with: - -```dart -itemScrollController.scrollTo( - index: 150, - duration: Duration(seconds: 2), - curve: Curves.easeInOutCubic); -``` - -or jump to a particular item with: - -```dart -itemScrollController.jumpTo(index: 150); -``` - -One can monitor what items are visible on screen with: - -```dart -itemPositionsListener.itemPositions.addListener(() => ...); -``` - -A full example can be found in the example folder. - --------------------------------------------------------------------------------- - -This is not an officially supported Google product. diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/horizontal_scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/horizontal_scrollable_positioned_list_test.dart similarity index 99% rename from packages/stream_chat_flutter/test/src/scrollable_positioned_list/horizontal_scrollable_positioned_list_test.dart rename to packages/stream_chat_flutter/test/scrollable_positioned_list/horizontal_scrollable_positioned_list_test.dart index 5f34fc15..7c264e39 100644 --- a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/horizontal_scrollable_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/horizontal_scrollable_positioned_list_test.dart @@ -7,7 +7,7 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pedantic/pedantic.dart'; -import 'package:stream_chat_flutter/src/scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; const screenHeight = 400.0; const screenWidth = 400.0; diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/positioned_list_test.dart similarity index 97% rename from packages/stream_chat_flutter/test/src/scrollable_positioned_list/positioned_list_test.dart rename to packages/stream_chat_flutter/test/scrollable_positioned_list/positioned_list_test.dart index b3522391..9888249c 100644 --- a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/positioned_list_test.dart @@ -4,9 +4,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/src/scrollable_positioned_list/scrollable_positioned_list.dart'; -import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/item_positions_notifier.dart'; -import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/positioned_list.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/item_positions_notifier.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/positioned_list.dart'; const screenHeight = 400.0; const screenWidth = 400.0; diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/reversed_positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/reversed_positioned_list_test.dart similarity index 96% rename from packages/stream_chat_flutter/test/src/scrollable_positioned_list/reversed_positioned_list_test.dart rename to packages/stream_chat_flutter/test/scrollable_positioned_list/reversed_positioned_list_test.dart index 54aa4c02..272e2459 100644 --- a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/reversed_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/reversed_positioned_list_test.dart @@ -4,9 +4,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/src/scrollable_positioned_list/scrollable_positioned_list.dart'; -import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/item_positions_notifier.dart'; -import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/positioned_list.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/item_positions_notifier.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/positioned_list.dart'; const screenHeight = 400.0; const screenWidth = 400.0; diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/reversed_scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/reversed_scrollable_positioned_list_test.dart similarity index 98% rename from packages/stream_chat_flutter/test/src/scrollable_positioned_list/reversed_scrollable_positioned_list_test.dart rename to packages/stream_chat_flutter/test/scrollable_positioned_list/reversed_scrollable_positioned_list_test.dart index 9e453259..f177b7db 100644 --- a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/reversed_scrollable_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/reversed_scrollable_positioned_list_test.dart @@ -7,7 +7,7 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pedantic/pedantic.dart'; -import 'package:stream_chat_flutter/src/scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; const screenHeight = 400.0; const screenWidth = 400.0; diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/scrollable_positioned_list_test.dart similarity index 99% rename from packages/stream_chat_flutter/test/src/scrollable_positioned_list/scrollable_positioned_list_test.dart rename to packages/stream_chat_flutter/test/scrollable_positioned_list/scrollable_positioned_list_test.dart index 4f0dcc1d..ae0e44fa 100644 --- a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/scrollable_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/scrollable_positioned_list_test.dart @@ -10,8 +10,8 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pedantic/pedantic.dart'; -import 'package:stream_chat_flutter/src/scrollable_positioned_list/scrollable_positioned_list.dart'; -import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/scroll_view.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/scroll_view.dart'; const screenHeight = 400.0; const screenWidth = 400.0; diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/separated_positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/separated_positioned_list_test.dart similarity index 96% rename from packages/stream_chat_flutter/test/src/scrollable_positioned_list/separated_positioned_list_test.dart rename to packages/stream_chat_flutter/test/scrollable_positioned_list/separated_positioned_list_test.dart index a4252a23..47f75a5e 100644 --- a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/separated_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/separated_positioned_list_test.dart @@ -4,9 +4,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/src/scrollable_positioned_list/scrollable_positioned_list.dart'; -import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/item_positions_notifier.dart'; -import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/positioned_list.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/item_positions_notifier.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/positioned_list.dart'; const screenHeight = 400.0; const screenWidth = 400.0; diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart similarity index 99% rename from packages/stream_chat_flutter/test/src/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart rename to packages/stream_chat_flutter/test/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart index 44164d6b..2242c533 100644 --- a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart @@ -7,8 +7,8 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pedantic/pedantic.dart'; -import 'package:stream_chat_flutter/src/scrollable_positioned_list/scrollable_positioned_list.dart'; -import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/scroll_view.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/scroll_view.dart'; const screenHeight = 400.0; const screenWidth = 400.0; diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/seperated_horizontal_scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/seperated_horizontal_scrollable_positioned_list_test.dart similarity index 98% rename from packages/stream_chat_flutter/test/src/scrollable_positioned_list/seperated_horizontal_scrollable_positioned_list_test.dart rename to packages/stream_chat_flutter/test/scrollable_positioned_list/seperated_horizontal_scrollable_positioned_list_test.dart index 55935638..4057cab8 100644 --- a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/seperated_horizontal_scrollable_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/seperated_horizontal_scrollable_positioned_list_test.dart @@ -7,7 +7,7 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pedantic/pedantic.dart'; -import 'package:stream_chat_flutter/src/scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; const screenHeight = 400.0; const screenWidth = 400.0; From c4652b281b199df9b87220749a5acf6551bacc1a Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 18 Oct 2021 19:25:18 +0530 Subject: [PATCH 23/35] feat(doc): add E2EE encryption guide Signed-off-by: xsahil03x --- .../Flutter/assets/end_to_end_encryption.png | Bin 0 -> 130649 bytes .../guides/end_to_end_chat_encryption.mdx | 255 ++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 docusaurus/docs/Flutter/assets/end_to_end_encryption.png create mode 100644 docusaurus/docs/Flutter/guides/end_to_end_chat_encryption.mdx diff --git a/docusaurus/docs/Flutter/assets/end_to_end_encryption.png b/docusaurus/docs/Flutter/assets/end_to_end_encryption.png new file mode 100644 index 0000000000000000000000000000000000000000..b41e11ced6514a6f16f39483a96f9de9e760f20e GIT binary patch literal 130649 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≷^gqYzF-P6`Lh3uNT1z4@&XF4@IKDlEh zF;S_?{=cKj(pnMnKeHaVGW4c{Pw(|Rv*!m%4MiyMx46eJZf)4s+he|D5thMh z=KFgbtwPU0+4hQxuRFSSwHG9qfTO`IF)N9&@Sg;Q(I9oX^nbX_U-S)Z&+Je6<>;|X zMrP7ptJVCWuj2JVHFbl)W=mfVbR5LpUl5MS$2g@jgPHm%Yiqsz_Ige1&Zf!mi3BO{ z1r_uFVP>L{@5mC+O{mQg|E)Cp9!Nv+a^VWUxZ#Xrz_~XOdvv6$qvLa&fj%^uZCvER zZC+M2ENh?opv$t%{yb8YoBpmA&&n_g$XCDX0vfa?oF$umT7WzK;~ucvU+U*{xR`h@upO zm0IrGALF5wZ?<$Q%VKw zel;*nfBMTZfENG)8~2erKG7ZD^V<4k!RAW~w<(bZ)@mjLk1F=@lBjp_iGjm;D-;%y zrfF2Om%0Q72i7kZkwyFLt@!WH$O%gv13*u{Ru7e?anqnE{jI7Qw~dq;$0zZTJtV@- zKNY99}(}`}qTJ@ayuEM3qPQuqwT@2C@hs1-7H=<{cLR7*Z*&yk?P6mzYutg?~ z$M{kgdtAbehozkv%1optC!4Hvms?Q(Q+ImnomHP^)D13l)c?2uV}>4A!Are~wNdAX z36@qjj^Y3qltyG|W@3C%^z4@$z17mcXcwNNB43Q2exp~|im27BlbZ(MopY3u(I+)( zUg?y;HLFLX*FWw!38v_SGDc1w99U(&% z+%_e}>-hcD@p7uhO1cP zCNzm!RgMV=FL49>9Yu?bOdGa?#F|tCoJ~byG2)cp=!q#CMEVN}eV57Br%i_zUrV$gJe^cSGR* z8k4;2;ftt%dp;MI1WySgBXgMB)x^(!?qJ9KxDJTRW^D~@8oZ0^FNVbYcO?RepMGly?7X^oI5u1nurKzIOim;iLg)8jVN%yb zfxvSy^gv&6wAb;wzOf8_6EWS5Rc_rcX zXV=@$ZEXIL_jkvi%7nLHPNV@elkPOyvG<2)4l2e# z7ZrgADis8Qt9!~0pZ?!L3hOE6=fjKhSE<$+J{Y&~{N{7I`0X!sKyIV7u}i-%#gv!E zzZEXXsijRJUEy!VBu$_oZPiKiC5w$9(=x+r$jM0hM;rl^shZ$U0Krpj?ZJ;1-|_zs zPwySh=KH>nYjs5J9lJ3~s#a0dYJ=KERP9w&d)3~mK@ofJ+M~tGCN>o^9opT_Kx#+-FFyUuY97^+q z_ca_{wii1%4B^vee_p(b8Z<1vY7MH6Z@LzBY0|F$MqBVMM&#(|S6TpeqBIRr<$=a0r6$PBB7T zjR{LKsCI9&$Vu5MGQ4CZyd?D$y^?B+B!z?}!y9?*RepN)cJ>cnP7$hQ<%` zpW*_5@*-Lh4u{!6f2;7lW=2`AhF67Mzor)1pUz>k_Fp>{@zo4;krWP6H~>=#I$#3s@f)NR7T{z!nrSaR z@8k8Ff6IDbW8~igJ03VkQiO`|AZTD9=g2EQ^z}VK~f&zDLpj-aTE|?&NA(CL>-ux$C zZ~Rm(JLnSK%oe6z7%#jq)YYFHVU6ZBdR0@j?DNw=jA5a!+J2hX0hnkgbi+73FDog9 zT9~|Z)$WW|X6D|%C-k6Sow`mWSx|KnPV|vw51)A2`{fQ4&yv|ORckdEJ10)A`P~Wx zfe9Cj*)j*7{Q(|u8TZ<{r1V+O)1j7rN$hn1XvNUZ~lvUNRrCB_njzYk!`)`0{d@BC`G{i-M#DKe}8=0du8J@YerK7r6ta9C&d+N_+aDJ`fi8S-Ks^zus8 z-?(3W{Q3v(Lj9DSWxUfR&k+W~WxG?P$o+eWe{t($|6l*waq&l$3(Zbrho+i8bkLG8 z6}^=D@vkG-iWP3Bz9}g^rPoMGotw`ovdOvRm?F6)G9)`h(i6D%_Rj9x{y6rR3nTTO zlk+*PN53A4%`{@hX)b(zgdeap(H{(Qh&o#6Jk7OSMT!jF3`PDgEWG-6V1?`!6M-Na z3g%62m{}!tw*IFe5{Tv)-#W(TYe?c~LC0@xZBTd1e@@{dYeOV#=4$@UXg3)|0?f|L z+3Ts<(D+W_(Tf*Oz!E#FYX9le<2LsL$=4%5ic&O7^T`bPOI&#>XUA_CT5|hqN1fWb+TX zuFC%J?-{ZaS->3o*+hzhb=Ada?ocopWTw(Q1Bsvo2GNN9M8)vf95dGbUa+-I(Qx`> zx7n}x=8n@lCo7wJ4F@J4!qF?m{BovsL1P~v#P^WQnYo!ue(XrwD-ltM+0B39-|rSO zJ{dk)KW(GI2=n{CC$l2&QBdN^nv&Q2J5HO0ES*;iEc^Crh*RrPP=_5RH}F?3jz{3E zS8KuQTP zg#<&`OD0!5SZ_YNK?^}_-U)TH(+M(kTVQJ)lt0;su9QAhiS*i<*4NRK{;&RT(N5v% zpVx2EOpKS3&C14NSM%7Ve+?MY5U%x17tqKr1eNURzL^@7n$z`JAZtTu$8memK-3*z zVK!~fv^06Mp(!{Xm1*RUV_7+MubxtJXMZs|f@uG~tKjG-5QGXAqbX3`Dc;#qe3N#GvffQN z5{zZH3i3x#;bws#H?NAVro?88put!tyzGbMM7QVA3y)0{4@cF;-m_d60vSf^KxV1KjHp`Y-$)5Bwh( z+$VFrH>$1zy?Ck$S6JcwG&z4XVg)Zyd6Jfs6Za}q6dsxZ&3ENnl+UCgc~gQI(80G3lCTl-a;~Q<9k3~>{vB@gJ7WSQlL0y+2W2#40`WcEi0q$yfIAd!eo$TK&6rEf03h!@%j`61D&Gfq^<^R z^#^K$i1=|MFc}_(CUmUIa5c5^3OW+F7I1!OM$QK9k;|gu2cDikEY5Zx@>S#Z7($j4tKDG~Ff}xt92uwa zx0^3;;|kAyXq{bEQPqE5^FPzFj(etFwpYNKXqDmIN0~DtU_Y|6qy6uNB4;GY#EXM+ z8?%7Jh3eH_nsBLs={KZS7&vt6=>VxLFr<19h(!=up zsrjFDh6~LdT%W4hrJu5FKM4496r7*Ti5$uhe-B?mRrU#mQH5IuwE6u|=sX*1%mHk{ z#61Fm-haa0Vj%#lKjBPda7)vusx!O0%ElPC<>cH zX4AeWct|p4w*u!AfPx0)%=Xq!HFF2$jM}TJ^6e(}zfBviRpA>X{&k@NYtQjZDlP65 z@Mzq(1zRC^s@YHH>V!J(OG=Qg@&}4SMaK{TY(+ywA9cpZf%O`RUE+>s&Mu`~|sWe+cihRJXicf$|zQDhNLX zxe128>k5y_3fiwDOxJs6Rq$OK<3_j+=_!?et_pPMZcW|vz_gL5*}1EayUk^1f8MVj zFHhnf*+P|@=MXI2O@wTjao^MZ=9$LE#^AzejCUFVpW>TWwNPYt9};BFeYI9Y*f$We zxx~P~iXcm7bm&p)_h4^Td;2@IoOEO~Ff@``&c}kF$a&Jv(izy@Vq=u52}*#|Xwbwq zSz}^-I1Nn<;!_gBsf`-mv~Nm+u?f8qu0h)O!p!gdQL*{2R$bHL>49A;YoEOESKBh@htD(5yAMtAOx&ixAZxXwMazzUmlLxicO4*KnR#!8 ziZJ#ZwEgV8W#+%u@_ryHUQwXtxXnB5M=*h_-%VWa-PPd`Vy1Me%(&T!Aq+MW*jT@*AhwG1#cMt`>wbxSneWSatGEk0uv! zE8GGwqm+sBY}>Ee4aoaj`#Li6UzLau?V0FgrL$Qo}rQ4uu3@ z?I8pP=~xz$2thf2bmp)`@bR}9TbY`-KTdZmG7U>L1ix=vNzX}0T%DoEQGede2?*P_ z25O@=x?>)<;#|GiXBI#5ux4gr>}t1kCf~+$)yvP`V)g9xyZi4{%ce4y{OAr->m6xw zoYOJYRfk$5&3&hDMiscC-RtVe6gpiG4I4LsRes9VJf3e1Sh3W3o8PryIKY46+pjxd zInsLxL4;EE;Sp%P&;VOKKo}gIFoIP3-J+xK2uTsL^UilK zmqI&2_kMCk&?1vkmyg_4A6M3}^p0Hoh=;?~9$*e5CV3EiQA?<*U^VAeR3}qK0k=T3 zJ0u|)cI*8+1J2*oGb5galT+d)^eYlrBu->(gc??bgCOYC#5MQy=TJPUVbrPcii!e0 zN#^JV#X{JJ+QfqWdNFk8%4zi+1LR9JwW#&%htRg{rg_tsI;O4FwWoa&3?{A<<;3by z4=~~XRz7WFXDJTW%hNGCN~p6;&o1 z)@M+@dkdyJ-_|SEffbaSn)%rAZ%9Vzb6MZf8=bT<$q&I|=D`l#iPrqr--Y0O7g|=L z#*wppLu~Vo;%+Pk@G_kl`%qN)+3@*U%7W~U#9%Fbm>+TtWu^ldVlG!K%m)uAM`DWvX#ZY=sah|Ku{;7>MgM_s37LbY8qS?!cm|3i!ku$b-*1ar8>6+)20`slD-X z-AtnbkR=4cG57?aIdtKi+3sUvfSr!GOSeC}RoKlFI!LeZ`9ArOFE?uxLgDOwg(Ob; zaAU`RS{QBSbMPao!rpW;f|AK&{acbk{^GYJ-nK{Hay_dJEqg+_fzO=+du5O0XGf;~ zpysU^bG%P@7+v2D$zkhTwjca4`Fy%xx6H3^`snQYZT5>Z>1YOr%uMFU^Nm={L*3qT zD0ROlnZiPcyEkVBQnBYSuBaXgukF8VUGEU zfI)Gg@B)C{>e-V)#B-4Ba%sZ~XzgV5VIQzQzqS7+x@(!&dn}jYA3d%*nvsr{jf-Iu zkxZz+d)0crh)AfPOKo;)VTXMenzo9=PI>S7aP5a4?jJT3-66>Yxsq!c|JyB0yH?DTYOVikd}B^OVS)DufG5StFqN^9%l#&O2^S3e zom<53z8*QFw%#xEZwLO>K^vGkZ!HjxK~ALCkW3=q<<(T~RW4lqR)u^?(vod;+nd-M zk_ogR!23OA(X|gB-r3!oaG5GvGt%=Eu$mQ%5zlinIPZ>tCVap)U&RGiK5j(wQX(mX zfZ@Hc*;=RmPwoqbwT?=fPHeE_LO^HX5RUgt z9?i^r9Qm4?^*(fqm#PO~0ebSE$RvFJT=9rZmX|+?G(+W{r``d0%#++|1GmDusktQr z>dto8%dgYQV-Sv^lsD@w3b5*?8MXrQFfD^e&v@ZpKzw6~X+Zx_x?B#e zjq!3Jz%YLOz|@=!9k>Zq%)QWS?iY;4W&uvld|(ak#7Q&X&5)(gG_zm_7vGzDwt2Ix zx|3{^ePB$iXwXI(OI64Dx6(X4dL=cbd=IUY4o%~x_LIY&h+xm7|^^`weEO{+pFk{|=XlvN0JD(nr|bAof{Hs+htX3T;?<=V^lvhNNuvQUo1(O?UNFKG)ZB6j38us^x4S z`%6OXB}t9Xv~n&kg@L>WoTfk7eu~B*>-Jsk-7LGeqB7-Lra7t%T3ZA1D)_)y=}y8N zNnu38mrBt$8hJsxf3yMf9mgTxF0rC`uvO$pV5jfKk(OKv4GAS5ztkC$>Ed*yAIkty zRM}|?mRlGfhk@42jRJNy>ZD*PdgAF+ren8~Q$SEESdb*Z6}M+q0qow!rqXt`X;VHA zb768(aml#2*yYNOyzEgNF9^b#Ul4TjT?iDxS6wzLwh16RxntL`0=|+8Gg;iLxe@FR zch3hJzu&$#0)~y8z%d%d8TNHx;P}^^_wv!V7+xdgsG0}%o3n0KnIcAzc~pQZT# zE7geUONuJ+$<;SD7vdREQRf%jz)8lI<4GBXF8bOjABl-yRfjmL%~}`Rd3qx#oNXOv z>aC6SJe`c}2iO)`YXbjd(uNblQS8#bJ*&yCwp#_ojd(!=RL|S&u~MyFnOiznM58P< zV?l|?8)+o$KP>)p!iW;+$yiK-*&3w<%_3+=#TlA9) zr-QfIAvdax>N?6>m>@3<;^iIxcBM9lQPn7AAs`To-T3s` zsL2w$oAN0y=&*RO>Y_Th<8N(61+W8-FGr73ABbuyjEze>pE=ugwkeJRN53}R0GLBO zz*bxTBEeh)&29xBa39V*!JKWcO-ghYm6_YQS#sKClo*=o>gyO*R#f133wlDeh3_Je zyfi-qeg=0wl3PD7Bpd#W~PjaLh`S%PQnKahwdrGC_K-UiU4xPlg*)4mDQXnBJwz9_p@ zzyn!Ij}P{>I;ys*4F)oss;;)PhpWZal;--@6ja<3lE8(!56O5>eAgB>&@pUZoa1qr zbo#(cqg$#WN!So&P0NX=7Ze66wiwR{%B|o79$fhQYp}1k`Q>tswjQdLbI?l7JOpMo z#92|r5JT6*cIz9PITtcPv8+SNS!zCkg?;Cz3)Sl@h&H*04}o(F^e2ESG5@(eG5j{X zb$4R+x`*aI zC*=K}^h!U)H~aJ#0668F>g0lHF`g7d?u-pzN;5>8`!5%#I$c%C>zpuqQ(NuJXB8FB zt!sXIdO<-8?}KVdY_h2~{LI`eJD=RuUYIH>pUH9&I(A*YA9g=MWfkROif;LvM}dYS#(?c^*?KDiQhv#Tss?X}J3@8plSEEi^S$+(arwQQyTxokD0{aioDj&|3|TVYIlXYy}PD!G5Jb);NkGnWQ9>)L#tgtUxNa&xnPQP`tZi1P0N zHH;ZG&l>XzQTI^S2?I>tAi}MX%v8NGCP8OJf&(IE-P0TaBpbTJZOs69%zuA--eax_ z-t(IgkeXcjtG}(Sa|Y}yvp;`-ccK0O6U#&MB0n@5s3hj&WAXMCGqW@RedqJt3i&Dd z4wSgW_bg`8#QlV)5$nV5?{HZV!9h^JYXMM#vDg>C#D#M0drfpi-5;#IJn0#%F_=AQiyo487zwFWo*==vn$YhxUQFbfS(3n8H&h>(0#!bz&elMPyvRp!U@w*gE4=VGZv$rea3qjg^x zs^S$=zuNHV1pT9;q~sZ=zG)CO`pb4)uhj65Z$SqcovP3GJ^WTY-INaWRN=p;(51;S3c|B<4t7v zU;PS{idCZZl8}MOi&!8{(JY0L-^WKRGoSP`0Q6=`Q8KFJk#eDY3+@sV{PyVfpnSQL z?UcSDJw@2%>Aw3xjAm|s6zN$Evxu0pe=$!OKa0A5Qmz2C#sc;{PbR@3H(KCDpNDLVLKu_{<*4>np0Ert#7UY?Ez z&jn2aj5s3R4hoer%wV@d0A2n-=2cr)cc1?g|0jo{tAyvKz4cyTv!GfIVk# zKQPiwoT2#Jb+x^Cu)1|Qc0q3EwXf$zZXi7!eTiNRT{782*gLnyC!Sq$R6N0b=w+uE1_cT$^x z!|75=mWRR_8Z@wYMKD$=I!*3q7cbQD$a}Am?T+5L5>HZUa-zsewMeeY9SE6%1bMI3 ztXnp%-2CrC#8l&$kkC=0`y03#x#^ij-?|?DQDzAQAvrS(1l1?{No73Fk@Mp`WAY)t zvj-a!Ltr5ERD$Z5kCd`ePxw|PQ$k}i;|P$ zOKkU3IW8SHQN!Y@MMI&L zC36i@Z-wo<3CO zsD^>&&Num;3Dbc>?U$+fEO{=o*%`U12?-2wyo3DrJ@uR%?d8t)$3}Hw#eHD}zg;(& zaN^em^ZPGOCP}N?_O@OjMvC05oZHuCdO8AI;;*o;du#r^ z2)o#Sgd~8jAaHdCJ8cChjH03<7q@)ytp#|5Dl9duC^@QNeBzh<=@)bF zjd=xm zH)TLuu@oB6I*AAZW`18g6>k`;?57Dx_ThTJ>g!*0D?MO8s-C^^{@R%EgTd!UIlOji zk(>$fRdzK%q1DuM43_^9kBU^>9YN;+gf~rWez~%Pk4d-49YCJsauU83Cj5q-MZ`l} zP0aw5pu+vhVVxSL`9okw@u95v!F=9=6tGDm5zA?R43011{cb>&UpWT+MzkhinKwrB zM>#Yh>0V5eX_a;uoLTE}nyJS;kTQL8aa@9pPe>%|znF;y-r9Z$DB{TSJNp+ZlkuN!j}&7fp6>Z#BR?Y}cW33f{6dcfhFR>~9rfSM|!?tK;qe)Q(gUw~2O z5r`ILk3rue=gX0KgRo<+!YlRU1M&N8lH7iM-D(GcuPIq!z<2>^E|3*$eH+GOQG#f& zY9zHFKlH8aiU?qWe|ul3#ph5eb@^E7naObL*(9Y9;6RUXd+s$6C{J)RQd?cx8~zE% zH0r%B7+pH*_{3j#w7Y)0pp+Lx0N6*u?5uLyCQk?cLNajuPikrz-gmh3+)vkCLyta~ zLklV(0Ih$J1^gl2D%3_nhp%rH&Jr0$m07Yir*CL%u0^7xlFGn%I?Wdm*iUiP6-i;` z*t+!)^Q1SEGw|>L1199Fj^d`?wT(sl~qn}c( zEs^v}C$<3nboqOjU%#>>nf=sW1;NKdx?p^cSs?iKSDBVrZp=2E?a0fuSo~bFS{Sqn zb(*yL>~9?cyXyM)U_y%EWJF~*JGW1}KQom+2nhbR;w0_0HG}t^FDkb}$1;m-7OB3! zo&1HC0j8ppJW#bm>IEjmq*0B|oA752?kfp6WTNJ)p% zMFPkF<_FDlp{Kl9gLN-58LRM*W5;+UFnb1sQJUCzszi4=&)s8S6$tulzhCC4o@p-) zIGPVU@s*9hw#|Y){fz86GZaBLudiIe z5c?lCK#W1e=UuM)E8M2EjlK7bj7{w9^O(Fr$EcO#9FC>wIrs>uSUnQw2^GFdg1lXt z9|K1%@jEHTq03iTU_gAWf2~wv`eWI@wQy0x`PfA5zX0h0of4Di+o_GZ;lEh6OWnd7 zz!gds!eEjJg?x*r3jsr|lU1m*E|7GUwC~1;B5NVjmXiGP<&>s{_Vt3g_uWJ|*7d7f zi&n!C@r{Fi!-8s{{POjVfr0+Z6o%g|Sw#l_{MUVqTB?6i%zFuIGc0zjg9 z1-$ip04UnR&LZLWWi_Ff%v6dHwXLm2*f}QLV+$8P9CYPGNa6sTIec*uIng3R3E4kZ zIC=&6w0-U+;YCmF8A#HhR-)9a+xB75o3M1M0>1GYHZ&M5nX7U*INZ8r>|T}WUgOAO zok|l*(&%)%!E;HnOe>wzd}F@AK**Mcg;65Er*D^GzjL z2Zn}5#Wg|sc1B(=UvjhVy33qn`3WJKYS5yMwnD0Bv4gOcd`O%izxkbiN;0b-yu`9p zXfVWX`q35RnNh0oV|dWBd0(CTcqjHSbrZL@>F?aRjm{r@jr2vHWYB|DhkPACL{@-~ z`HNR}tQ$F$N8xLnIP35Ky38k^I_!%o%b-0pc@P1F6&8S*;AUN6Jl?1uQE;%Mj4YB_ zqMXC5XwRHL1}RvRPAKP__^C0}4# z@7g@iG0>gV*3<2@I~(fb2pdk9BT>r$yscJWzIK?u##oRRQxQv@__ex|{mxCU-@hJf-X&Fw@LT~0aH=UjS-_=<}A-BozD(~4IJ z6TkpbEAC1+IwHDXBD6MNCOZ%!ayitX{|fnF5Qu{L7r)upr%J2}6^NN`D{(#!m%k(i zTSbXjwH1{uXa)K3+sy8@amj?mih8w8i>!|9(>`{0x+gWv^J?# zCRob*H!`l0YZN`Ccq5Qx46dT52M}~y=hL#2KyS|EyvN{~y8QTy6cKi^8}pf+e`XTCm(vXbGUDE_WI7$6ZEnI7L3Y=-#Gq zj8o!0=`SM{T7+GR@=$3CV!$Zz{W_V&+BQWxEiCi zoP3|F69!C{E9LiSX$FM;4UhOnvn?YD*b^^AYQic_j9tHd4V2^2)p>69-x{n5w zc4+j9;zPriFGm4xk(+KkD-Y;ka$w2Ie1)V4SW3;YkGdetTf6B zB+V6gT2!+)Z;S@}yPWX0Ofx08f}XVl6%W^dGq?Kb2=F?X_$pcV2qaY>2Y!2vn& z$*#$qG1Ao497`@piWQ!#tY8GPzf~rbVJLXj6G-x^s6$QT+#smQDl!i?0++~}FN;x` zDDga(ib@xg4*NMNQLUA^PX^M!sL`fkQJEzGfRQ|Yjp@uEbx!qkHBsTdV7?a=X9oi% zs&IY*M=SR5YGpJpVfEY>%(Qil^$qnJ0{g4ZF3(D%U_d#G<{Qo|KuUs_U%s01ne7kM zFb82QNxu1@wKtp$sHyb&bNEaN@6+dfW|wj8N>At&z{q)6$my-s`vsStT~NO)+zBG2j*HE5|8Yf?(?GvUjPXaPWxIsSrP?n?5}p>r9#QtZ1e ze>M{^Y=u~S9-%(@B8+Ob)k}>=2S%p`Ep~zm0>=UjxQ5}|>)mtrq0`?^66(;E4%yBM z)XIioDe&=Qm)`zt2!PcEeZv+&2$Iknp6h@30J=QmnTO9jd~e+8|MLW=_d?HQy76MX zoOkY^5Cf=CrXZ?0a8A=rGhg~S2L~deh%xb}Iz&`7u=Mbbps~iLdE<4-D!gyqaC)wa zQTyLelGWq|V@X*7>(Qy-?u-HlGL5eePDcx_`+cr3npRBSUbfhf2-4}1+`JA`>vy}Anj&{P zQq9)?({s)%n@VU4YcHz`$dRE*4YlC58=evqnoYGnMm#OF`{ z%5q(nBoI%{PZj0n&98a74JOC4Ka&kv5*fJ?0p&lG!tLa$vsVtj)}Bh9o8g)mUL?Ey z^icju6|jjpl0eWX06z$cEux(Jl||5~DhPr)F}GimvA?5k)HBE8LBeaK$b{c-BON+D z9V(cK>#&uwG1_fZ!0oczIPSY1gB z>nLF)^{ImT)K=u-dE66DB^@Bq072#_Cdb8)A$Dp|L_7>M>8aV<1y+YT0SZ@3q%2xY zPu#;4AP$zJ6nkh?yTh=4H?Z1#Ayg}{sT@x`YWOWNO^q@G zE~mjof3ig(HyGLO-Jmsb?GB@Yxk4o6Eqd|}weo#2zN7khYIGG z_^z8Sgx4od!f4o?ZpAa+%0Nib!M&p_Pd`;+E;@=;kf-Tf>=3YafR2~50EduyVnB3~ z-syAFF~AH{?QD}P)yn!HLVf)fmMEepxBnI$BL&}DligWWMJ6i7*js~DG<)wkN{^VR zD>%eGmz`vu@OPU4kal;9`;;!sP9~F*sUXEvoHP`IJ}%$1wtSzctbryQP7Q$AZiOd<0*25<2Z*v5`+B)|Zz;vv()#dv9>97@h zx|Y@vFvrk16+Azm`TiZy*|N~qt0egMQhw8>a?~mpFEVUZTk6~$7#R`qg`l$g(X#BHH&B4Jm?e@D+=RiQATy z?TMxd`foz6np{580)^=Dv`UjY%RMipd{jw}NMLO~9+)K0G6`31Fc+k#>JwI(9HVEp zhT7uI3lwP%Rvi&*j{lo*jVM3~u7*?{U0$Y7K~@F5nWiu- zQws7Mo%N*x>ThwDM>&suW!z^Ab3Fm|bAl@3g>0)T20V$GnhRo=}=6fnw z4V?*SdIGD^cyO|);o5CF8QpK>wGEBqNsI;3KE@6<9wkW%Q!1&ct!?K8*|{h+P{GH>No_P)qywnz zV-ncTgNnm6F$W*}mnEL6WiXT2=1^*3X8xoXSS%3zuWax`;>K#qxjN7V?tp7=MY=(n z)8(u;fcVJ9sOA$=m;`^$EdAO#{7%S5~kpEZ_rr z{X#=0ghP_=6l)69%%C3Y=`rDol>nS@80$GIG&S|;E~%Ytv~%ExyY)8Pau~PkB?(zc zY$Op*m`5IgN4akYYek-wSkfKv{{?R!i{GOt(|dQsOQC`gakg`|i+x6&Sm{Oq<-xT% zvCO1t2Q7LR=2^bG=mh6axIV!Nij4cq^y5Z zRV9P@p>8PpC$ks%zkzy^dRtQNob+DfX4~E+FdER}4f9!-f#w8uykN@uQ}1w*H^`%J zL#&U=9Q#TEd1;nB=D+3oB;LDU;vVq>m?<(DUf5jH*wK*>1(W$8=^6g01N~@lKy4u2 zARyjwkXhk}Okp@GM4Wa5y1@dz{P?xJLlwk>2&pP{(2lpS$Y5nfInNDJ=vf+rrTl*N zqU`cnVycobaD^#QM#43`?ARYJBQ7cDuf%<)Nc)CZE;X?Tb?UDwFSm3I_D8z;|5qrx zQh@h+*`>{=N2f-jr)R&4va~I!VnI!uE{u?CiXtcc`HG7KNUqb$-`&+4#$bA$-5y_C zb<98h_^leX^uD3wZ9qBipHlMlaFMsJ)4v#VteO4#PuX%Fit+HuzQBNOU{99T^?9ii zVI^Td>QxqPom$EMf!#%ZCRX6qP}twILH!i{7t}DI?PFMOlUq|~N~GvEafr5q`H|)O zfeDwHR1Kr7rtM5gzjVu0nA$PXHKL9?@3gHw8=Nq|wW+wWCwYNJIvWkOat9+_cXdtE zoQjH6r!+cxc$k_pCnB`_Nnl`aL7&5?3J11+ptFuc+V4xq=#t`SB9M{11kkXtesy`~ zV!`KTFzrTZ(1t+ue@@aCnJrS&2`W>2GV&n>cofwVR%_CE9wo~heVh2*QcxhTCioML z(w%qQ1qfzTDqZLn?ua2DK?5{d(hn(FE@T_>#&xHIQHOem|5Z{)H=47tOBpG}OuV2$ zj$A%wHeFYD3E`+~-k4>&Ha7BNxon3bFCQp$C!D4>Z`M{rY{#QXpwMo+^6sx9%ibQ- z{N(>yJ}b`!1qSs48|~*9qy7*{KmC7N02sZJ?9qOC29sUmChMTc4Wh5rtG|5%Yk@|u zx~8pJ-G!;PK<|@POJcT3eE3zeB@5*8wR5umN9CC-A``q4t)Gkd9PKT2_k<3NQ|Cse zvU5`_9E=v)xU*4&Xp{W9h8b-k(s{gMMXy;L_>l958-J#&BxBHae<BhqSO)q1REF2{bR_UUU{3U-ikz z5_>paTM&aF^6{+${HX!;M)~7=Q{}vxd|PQQ(pxng3Pg*#^wuhbC7^RAEI!X={*j~h z^v@L_#p+E2M6-#h?N{Ox?M@-IyHGoz>Rg0zmfOv&i{r^my-g(mu?EnHzh@v!1?W(1 zI0Bcd5mb}z0E>d2s@*JENd=0a?oZ9~)_64wZcI){0P@iA_ucjN$s6A_1c8kW6pqEk zJu@rZy4BUAKs>Y5-61WWXG6x@#{kBE85sU98}jMlIYAEa{x?Q+PJ z=o>bA_piC`xspG2^0I&rb&Gn9cTjnJnWoIC8B2|DRw%9GnEfoCppwsRQIQ|QE-bds z!|IIfaQk0t^i`{n!|_E1tt$Rv`4gBHKA;1bC`eN#at&_xUY@ldJ*=zh*e13miaASX z`fefkeQ1NsJFV3uQT<_ky^0Z?)6a)%{I&xW1C1JnaN!LQKi$KF2qsHoOEt1FnsU>h z+((NPHr`&&-`{0)$J}C8CJtOsHqAJh9}a%)S@;euB1%+(LUrclIE^P5n(F1GWNaIE z9?id#9hM0ajuPe(2(bUo+P7217N{9>AX>LK`RrbJc>n2{pV~4=m-u>@O<}TNikg0S z6^m6eoHj|f^GGXrL2sq&4K?m82=LNdf_Jojs_PKM`L9%Z%OA^M{w3@L4%=Sjoo||- zj;aqQeI;B5U*IJce6_OAhRoY{`-EgzjG0h?X^jr+_Qrylep)nk>ZmB4C-8|JLz?7hBOFy27jeGKo?>Af?$e2RWk z?%I^yQTZKZWM{Cq8{--*=QLH5Y2)D@hx9F^7IOzDk`E^_2(tMd~K&}=jLb5Q-tIy4f-e) ze7bCail+5gtM`8U$-Htg{A4z45YyygrF$K)&4T7;X5?gKh@(GzXn2(LT-1HHL6rYc zRtqq9Utiy_b#(l1;sWDBI9Xas-1avooC0^!E}X`Uz&`?)b+`YWs5y%CT|E^Rjj;4t z8_-+W8?cZ|O{TdMZK=k&T>_+^17Br7csp3QaClOw1-L@l zzo+WzT~fTh(CTwCvqOuMu}VMbD^y;8JN*z<-xX-FB71>}&68mX{~5-wpv=foIczOL zAxIQ(3)b`bmXN8cpRgp3UDo4cVSB?vvs>;hFMy}ra!^159lG-Z%G^uk!c=uE=+QhNUF@cJhQ z#zk?)t&T2%IM*zjtKsO~^`2hesy)xi?UK!#YTq&l1`M{w!TCPP%hKZSx^4tzVxMCJ(cd)^k-g$U z1=y=fr?P5-3*pm(&;H0eSbo>{ciVZ7{V(M&Dw8b1%e5J=H%7}dGJq3pSahgG-#i$Q zoV{V79!|e<=1(#6yDXciFt~5p{^)bM?$e5<)+FFQ-KZt?vn_KMC&9}|pT?G<))3yL5 z#U)8+C%gI4F{jF3Mx+Vb{%KVv+|4aFyughQdT4&3K4 zBmZ3Lgn*9Y{e)glk3gUL7x#^E%q)0+f7C$2n31xMn9tSZz7RY*^jNMf59rgxZru-i z<@?$(I|D1}?BL|=;$mp>oxeD{;n~lw#pSnBV(8a(YXa@EfS;BWF40ms_(DIprA01D zW@dHVk*?oZ8WWXIpTMs$Y2~2AouhW(;pvxqKY}&<_sD@|aq_EQ{~ujn0TosB?LCB| zpma(P-2&33Fi3Yucc*lh5<^Ralyrl{(A|yFjdXYSchUF%-WTf~T}$WAnS0{yv*Wk- z-FtB|moBN`OIYcvncBB4?;%6suT6)wuR;%-fau|c7M9Ib!*17-Q1tP`!(?oB z_aic201K)`Q6~{#+YSMP{SI~OoW1n*6&R=_Z4c)Plb}!_&0Bd^hGoa3I#YceU9+eArLlFzq$IDU$D)=81?tqB?K1CRb=%!JcWc+G=F$U}=0`SG zprG}k=VM&owmHW3_RiG?S2M{+s3Y7#wgu_yzvM`)`9~i;8d$B5r-erO&QkUIb`M&B zK7?EQD-zVaCq50!^m*pAHX%U`els@cfSzth{JPm`Qe)GNI>f+maBJGNThO3Wm{Y69NIYM&*|nK=&$aY$+8uZ^wa9ysHMMBJvNC;> zhKKmjxu*Q*R#uBThAyuE+=(aWD)Mn{uEE8|=j4do@D+xrf^xz@^W}1|XU0*b(7Ex* zY4i1|(0w=GVf=vc=KSMz*@Nz;6}@;DC#vUT%|oT`Qe{JnGP5B*qc7i6KvQke&K|km zsy(=%_3`Sg6*yK^iUnj>J18SYqXqU*W~QU}FRiz?&GH9^S=MNBO-wfN01p2`5cCJP zFF$<{AeMI8dUvn0v(`W3gY(1Iny3f+wu3oH%sM3ajj==7;m6MBwsN^X_F#gg7EXTq zo4xD0>j3VEnoDbmOmx+qn#X~bqHyp1A(y?zP(z?X_ngM}%e|dPnC?SYUIY;{c4&7Z zUdz#5j2!>P!*OH>ZN&=e!IEX_J1&lg6E2?vw8twsnmp5!>pR0pqIa{glIsa+LN{nW zn>CLUq>p`DSBO!F-txK$3yU@9hj%+HO=s=DyShBlU9X~%|DYB+&x-*P_UASf`rF;Y z{bHhK2@gzTf7U0~z&rcRr4^x@NOMj;VRw5kF_|>wds4EdnuWOq)G+k*YY?Wyw0@YX zBv27|Y!hijr_s~RtX2y(2G#C<&YeO`PViw%=u!6}>d>v5eeSlJG7FwAlQx2PcJ3kX z@t`IZYjgg0UL-^z8-|*dUstew`Ove(<8*O%w566IFP*2DW-;>|yxNZbr#BX$YF8_IG5+j*OD6W;KEmIDFD9ry}ydixEPnS99Pgpf-CF>&U z0WxnQT^@SfMR|qvVu+SwOHsY-piI--cpJ9kP^Wu!lMu5oYQS1G%x1SYkC)~(Y6#9$ zT$5b4S4^({sk~)nxlo-Wh4oTlFJGQ<{S-)^1GCw;MD#5bd3v6G(5o~To1^|DzYkeX zoH1_8+dT}%t!BaPU*q42-?%pbBwTrlGWD^WVsIM%=Qa-pHJgTINe^|NY)akdlliW? zYf%Gm2H6qs+?=!ZQiaQA^~K}{ZN!uA$a+YGffb`_Vayf1XOt_YkGXAG@@c7Su*;yF z3@kj@$0S)m*g$4dJvq$0CJGf-RW)7d>iPmj4_34=_D3yI#ur_GEB#STrUEBN8M6)4 z%In{Y@PqTudt@|Z_8^6`$y-tySc6!)XLe0PmKvjeCxo9mFa1W-(0%ufqWYP)mEPH=p1fd2;(>` zEhWwCGHa|8(y8mZ^O-q@E`a>wv2PH>ziKkgwA#@rg}9bTlaE($=JzC@;Wa4QG+_BI|T+JOoYZX8lMSGIPC%hS&$fYT_N!oJrSu{o=~Z4pW98L z#8ge%fDF|TD8Oc+v?10$y0*;8ZDzS3LKQap>O&8y>lI)|u9sH@*Ev+wii7*sLy@Gj zE_#;{fr0gJ!=lS`6uOH!(SS zc9r3!K*WB6>Y5lW99h=vIF5fh%hw|?Liw7|a)Dir$fB^Uhno#V7>V^;^4V;djO-KT znIbWZ*-29Z%LBNGi{8j^V7(Q7SI%1_WoM8TIR6A7pTc1hcuqtNR*j)ymd4xPfQKoh zwG&Sj=d!+`$z}@lt`jd!S+eQo0?_qD?dEH8cKz{DWPA{DAS1Rjh=GU0%}GGma$WxR&~3CN04<+hjN1eE_e|HSuJzlz8C zZf$&!i2ZPTsoGRb7hhC`@1)fv+R?UrvI)?_mH#|W6xm{p0j$(y%>1kf9vG~Uxn%r0 z^pp49#c|Z}OR)1wwWEvRoo%K23 z!4#V}1un<4x)a}q{Bn6CNGh>|(RGXm)J9?8U=>hGqJQCr)cSEb1+h7S1=r_p_;{6O zxZ93VcsurSx=mn2;9AX#Srb)6m#tIyLUJ?%{ieFpp|pt`e*(5&D@PDW z-S6f5;}CCUyZ+YwVTqHkGVSLysxfJ5{60Eb^tY#V!a&o<$OyiEoqU|}ayD!Nw|UWMB!KR>*+e^Qaf> zC`?Pz60f4PObkp^FfpQ7J92>6S94 z&&Oyv3}d|fl-BUAw||u_s(8niBF@!6|75z#KIbIu2RTx*+KIYE&KE%bHr=hdP@8UG zSF&y-(U1VrtfPBNk;kaJ2N5*+40xmmu1yqOvCnwN)wWJmO+F_sHOOp$ zS1#inXhjh$V$aIMFkGQ#7Z3a(#vu+xI{Q^Qb&iC&>i1X-)#55MisHVjOt0U~v~pE6 z{SN)yoS9yE0lByCU{%S87R0CrbBxAU#$uYn^VCnXi&TepfNQ_w^bP6}_ zkKwaqF*f#4hl7clMW6fWAO&nJ1uyM`A{N00pQ#JlY6a{p8Wd2m0&D>uNG&|;Teh*R zWCw0O440Ko6%^_nJfF$#>{zfAm}XQ(BcqpyFZYas#?M_qip2@lQ8^$5+>CQwJP}g; zAN%8Zn3c3*^BMIs8sDxWI}mL+X}zn?Z-)<%Qr3wc{ep}_Ad%_6<`>|9)g58IepyOS zsNOW!KnE=t*PYL&)vvb$cQs;)zbMa*L&l&sF`)VosCbUK);q$(g0)+9KDuJ+RNYr* zX}~h16Vh14?q{|{r`s6Y$7!)z$&J~wh8@h!FwFEgxfZMpk71&7-wj6gdrjR3)c-*$ zYra1a%kx@t7+1W8;if^AFg#-DjtaZ2DoCZ7@lL!W2>IXnYzgxk4DNy0_ z$wLZDlAp6qlb$;Trd(h~rZ`(3z1AYB5`bf;@6G(3m~}bjQ`+Vo<2ud4oj=21K`kRQ zHldc-Z4j6CtyS{YQ2CK0k-eqXUVfE$mm4Oz zog}4N={DJ>e^O&rBDNddsGpf`?4h)tgG^!bg0Uvm7}2wHyI*TuF1Oo^Nm}+M0Ltr&+v0MOK~6Cgp1e6;Wa<3>1S9Nmb$3t1$^0%pBwA{pLP$gpqMQ>H61V>VC;k&RbZU>at+OEMD`x-mTiQEIZ7 z==Ed17~P6_IZ^Sw+09CwKI<=ugj3jb;K_>U;47`|Xo}35_f?pVdd25*O!*slsv2ARHBslw6`sZ{0d5(JasdkEw&iXCJ-&4@*R*EHYu z*P(p!TpH8x+Rw=XAl`gsR6|QUPdHyE6d;vwYakfhZ2FNVR}M|f0LbuL--EUvUeYzQ z)vX##PDy|*(#fN$QMDOsni{i`8L_!~r)76$U|~KzsJmGpMJ+U7Cz08Rm*AC?%9i=+ zI%&Q7QO2R48m)P!`?ZyfUB3)VQcg4+ldcS|`gcy0)igDiz7UD>bPj6K8C>%@jET$G zU3YeEK9%}mH}$xFx~_gvD~e5`O0a>V^Z*VS;?H4jj1hog-d(up@Cdo8n8!)vc|TaP z94!9+#2EK-jtS%mR+&=(k`d>>CTxj=LxZBz<;bQtaJSS#Ab`4KZe!I-OF}pzSr7pI zY+$q5kEkj-~RejyITy_*+4no}Jo|&Oo;@7#EZJBo=ql2>y7qlr1s;n_k$Q zoPi8UcDPv?8?1yoLP|&Z+g-MF3VUaue`D1<>({&P%D1vD4yJTtrOCI_^%2^!%09}A zJ{#+FF2BS4o!Wx0x*3_*Tke}kwd>teacVfY9!5vJ0FVUZs|%<#k906=FzRz%%;2EKr$4{Y4({$810_obtl(?3`I80pBQC{z1+%(O$CMI~z=cz5tf}9_ut%t%NkC*N~ zGAGl;i2%po$Xt0}E%1q-i705Kk~Z?P3UmzKduh(TRTy|4{c41K1u71e@Q9bG<+emt zve~e>?mr$J^yTtS)7rDL5*PQ#49h0`6qrf2rkfr!cMf7}mVZCER-BOjBS4?aeblUD zHevX_Ya_K_Uo5}8$7Jr#>Z@~9ty$e=n1;X+QtPG&gkO$@aiisY;Ztd(%v%Q~k%cnv zQ&YC2cPFTzd~$#ZfjCi3nB)3sMs)NtmFZm_ADQ2M=JL%hrYP7(H%>VbO$lv3S&#fu zP-%0doj^K;LP0T87q-iNhR?Oq@ zq)S6RbIt0u@LaRwq4#89oZ@grcmNKADD92!h$!3XrvmJU+r#^#o2^>KLgwV_INhrP z;4Ilg8xcT)PrkE%YOh6zL$u^|-iR(?Y9eOFzGg#>qsvFy<9uTVV`8vycO;~5v}*9R z7~bM5YFRhKOY6=rPpx7T(;6P4rVsmrY@g`z+2x zubc(MoKg!*yTlne4OIKo@~*TxIw-)Bk2z&bo~9@J5+#7oFTRAWlh#h>wrAH(@chjjOS) zsPLU!|5!itrT^8(Q8*4s|LBpzm;UhJ-_?vFhdHHPtYK z-MeBDu$y=Ah-fShRK{7QFRY|_i;BSdPQgmaB zY=0HBh(~%->PNw7ba>S7q9Sd->M=F| z=e%q~;(q~-mVIX1eEFNOM{rz&1}KRAU=7HT=~mDW&L=sO?*`6>r6r~EIV@Kg*jN-V zH8^MzX}!pN6)Drltht?bI8wugwAHe>t7XM-vQdaStOy+andO zv$VMj>4(;l=X2@5B#XF>N`kQkPd*DlDA#X$^DeK3>IBIQ#px>0 z4^?1zz*5o*YCs2=?ifNsd_1zSBVeKC2D*FFePS@GV+cj(6ViO+DwTS5**4)a9cbWr z%ub2g5!Eh9Lw#JiS6p{R;dQQ7b#Hby&80L)vYFp%mBhi`FYu@G^YF7~AMc}n7ag|_ zo=vw6)0UI=Hi7#bHsJ8OlD#zqNI=Eu0$m;j(rL!gQtN2)CmK3FVN5#HGN%!0olC7e zib@!YDv83rGoOBQ_=vG?+B*`G@R1a^_O)H7WaGQw^AT^kkAloX=av<>QrlL*citSa)KAV&DvdTnGV2KKQRr9g=HGN|HJ|$LtWEcw(q)v z3kWcP>UC4I^JYx$uyNn@MhbgsYntcRaXzI%LS)49@?r)GGjJ}|^K(=a@4M9RJA6!7 zTCg5FZ40_>j0Yq=AYTMPp7wo{Cmmc7GDE|`F{yLzXb$8~rPz&S;uOTX$Qmb-4*KQ;1;1|Q7KX8S! z3Qu(X{xzVXO`PrBP3&e#&;OpBpPL~0h#{*qRTuZbsG4WA#^|+4Iy&!k;(%X`APCF8 zjL{Wa-P~q*%#cP?U*{vFIQNxn=aIO609GJNY-6__?M z7!Bk*n;Z8AzcHEx8#d|Td}hpMzDXpjGF6#PwUgr1U)wiu*5o7YFx4lORf{0VSH5Oy zw!-gqNIDl_r_VHufr0t17Uc<9(p2-j?m2|mS(SL)9JMXnYYyO7V1q~+3yCA2SjG5T z6K3oIQ)VsPOh?{nSQbkNpsnqubBks72MFI~`LwZr>K)KaRIJrqq3u1{+P>=Xk&jsE zuj;wfc*$9ky(DS14^X3_m4OE20;u-6@|(ggY!3%S5?Gj;3Vu3?D#LZHm!*I+WMnwF z_kwAmWGI~WSZ&iNM-Iij`G~^ys@9cY+@^CKVLzecb!uXjL;SC%X9tW&O@^dt zz!nr@1E`zNdVncVN30zI#EJi8^T9%rN}M*4%HsPoes8p1fn)oe(u9?>+$8NJ02VUTM8?UtK_pGblMvNcXAFsUr^oKdGl}f7Ksu z>i|tC;H5CJSstC(1&_|oPtQ#(GxCa!gD~tML>u?*{$^-dOsxo1nfyNHu`3lX$Pofy z(RO7hT_*!�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 z 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 literal 0 HcmV?d00001 diff --git a/docusaurus/docs/Flutter/guides/end_to_end_chat_encryption.mdx b/docusaurus/docs/Flutter/guides/end_to_end_chat_encryption.mdx new file mode 100644 index 00000000..159b4c42 --- /dev/null +++ b/docusaurus/docs/Flutter/guides/end_to_end_chat_encryption.mdx @@ -0,0 +1,255 @@ +--- +id: end_to_end_chat_encryption +sidebar_position: 12 +title: End To End Chat Encryption +--- + +## Introduction + +When you communicate over a chat application with another person or group, +you may exchange sensitive information, like personally identifiable information, financial details, or passwords. +To ensure that your data stays secure, a chat application must use end-to-end encryption. + +:::note +Before you start, keep in mind that this guide is a basic example intended for educational purposes only. +If you want to implement end-to-end encryption in your production app, please consult a security professional first. +There’s a lot more to consider from a security perspective that isn’t covered here. +::: + +## What is End-to-End Encryption? + +End-to-end encryption (E2EE) is the process of securing a message from third parties so that only the sender and receiver can access the message. +E2EE provides security by storing the message in an encrypted form on the server or database running the application. + +You can only access the message by decrypting and signing it using a known public key (distributed freely) +and a corresponding private key (only known by the owner). + +Each user in the application has their own public-private key pair. +Public keys are distributed publicly and encrypt the sender’s messages. +The receiver can only decrypt the sender’s message with the matching private key, which is used to decrypt messages and to verify or sign them. + +Check out the diagram below for an example: + +![](../assets/end_to_end_encryption.png) + +## Setup + +### Add dependency + +Add the [webcrypto](https://pub.dev/packages/webcrypto) package in your pubspec.yaml. + +```dart +dependencies: + webcrypto: ^0.5.2 # latest version +``` + +### Generate key pair + +We will write a function that generates a key pair using the ECDH algorithm and the P-256 elliptic curve (P-256 is well-supported and +offers the right balance of security and performance). + +The pair will consist of two keys +1. PublicKey: The key we will link and send with every user in order to encrypt message. +2. PrivateKey: The key we will store locally to decrypt messages. + +```dart +Future generateKeys() async { + final keyPair = await EcdhPrivateKey.generateKey(EllipticCurve.p256); + final publicKeyJwk = await keyPair.publicKey.exportJsonWebKey(); + final privateKeyJwk = await keyPair.privateKey.exportJsonWebKey(); + + return JsonWebKeyPair( + privateKey: json.encode(privateKeyJwk), + publicKey: json.encode(publicKeyJwk), + ); +} + +// Model class for storing keys +class JsonWebKeyPair { + const JsonWebKeyPair({ + required this.privateKey, + required this.publicKey, + }); + + final String privateKey; + final String publicKey; +} +``` + +### Generate a Crypto Key + +The symmetric Crypto Key is generated using the keys generated in the previous step. +We will use those keys to encrypt and decrypt messages. + +```dart +// SendersJwk -> sender.privateKey +// ReceiverJwk -> receiver.publicKey +Future> deriveKey(String senderJwk, String receiverJwk) async { + // Sender's key + final senderPrivateKey = json.decode(senderJwk); + final senderEcdhKey = await EcdhPrivateKey.importJsonWebKey( + senderPrivateKey, + EllipticCurve.p256, + ); + + // Receiver's key + final receiverPublicKey = json.decode(receiverJwk); + final receiverEcdhKey = await EcdhPublicKey.importJsonWebKey( + receiverPublicKey, + EllipticCurve.p256, + ); + + // Generating CryptoKey + final derivedBits = await senderEcdhKey.deriveBits(256, receiverEcdhKey); + return derivedBits; +} +``` + +### Encrypting Messages + +Once we have generated the Crypto Key, we’re ready to encrypt the message. +We can use the AES-GCM algorithm for its known security/performance balance and browser availability. + +```dart +// The "iv" stands for initialization vector (IV). To ensure the encryption’s strength, +// each encryption process must use a random and distinct IV. +// It’s included in the message so that the decryption procedure can use it. +final Uint8List iv = Uint8List.fromList('Initialization Vector'.codeUnits); +``` + +```dart +Future encryptMessage(String message, List deriveKey) async { + // Importing cryptoKey + final aesGcmSecretKey = await AesGcmSecretKey.importRawKey(deriveKey); + + // Converting message into bytes + final messageBytes = Uint8List.fromList(message.codeUnits); + + // Encrypting the message + final encryptedMessageBytes = + await aesGcmSecretKey.encryptBytes(messageBytes, iv); + + // Converting encrypted message into String + final encryptedMessage = String.fromCharCodes(encryptedMessageBytes); + return encryptedMessage; +} +``` + +### Decrypting Messages + +Decrypting a message is the opposite of encrypting one. +To decrypt a message to a human-readable format, use the code snippet below: + +```dart +Future decryptMessage(String encryptedMessage, List deriveKey) async { + // Importing cryptoKey + final aesGcmSecretKey = await AesGcmSecretKey.importRawKey(deriveKey); + + // Converting message into bytes + final messageBytes = Uint8List.fromList(encryptedMessage.codeUnits); + + // Decrypting the message + final decryptedMessageBytes = + await aesGcmSecretKey.decryptBytes(messageBytes, iv); + + // Converting decrypted message into String + final decryptedMessage = String.fromCharCodes(decryptedMessageBytes); + return decryptedMessage; +} +``` + +## Implement as a Stream Chat Feature + +Now that our setup is complete, let's use it to implement end-to-end encryption in our app. + +### Store User's PublicKey + +The first thing we will do is to store the generated `publicKey` as a `extraData` property, in order +for other user's to use it for encrypting the messages. + +```dart +// Generating keyPair using the function defined in above steps +final keyPair = generateKeys(); +``` + +```dart +await client.connectUser( + User( + id: 'cool-shadow-7', + name: 'Cool Shadow', + image: 'https://getstream.io/cool-shadow', + + // set publicKey as a extraData property + extraData: { 'publicKey': keyPair.publicKey }, + ), + client.devToken('cool-shadow-7').rawValue, +); +``` + +### Sending Encrypted Messages + +Now we will use the `encryptMessage()` function created in our previous steps to encrypt the message. + +To do that, we will also make some minor changes in our `MessageInput` widget. + +```dart +final receiverJwk = receiver.extraData['publicKey']; + +// Generating derivedKey using user's privateKey and receiver's publicKey +final derivedKey = await deriveKey(keyPair.privateKey, receiverJwk); +``` + +```dart +MessageInput( + ... + preMessageSending: (message) async { + // Encrypting the message text using derivedKey + final encryptedMessage = await encryptMessage(message.text, derivedKey); + + // Creating a new message with the encrypted message text + final newMessage = message.copyWith(text: encryptedMessage); + + return newMessage; + }, +), +``` + +`preMessageSending` is a parameter that allows your app to process the message before it goes to Stream’s server. +Here, we have used it to encrypt the message before sending it to Stream’s backend. + +### Showing Decrypted Messages + +Now, it’s time to decrypt the message and present it in a human-readable format to the receiver. + +We will customize the `MessageListView` widget to have your own `messagebuilder`, including a method to decrypt messages. + +```dart +MessageListView( + ... + messageBuilder: (context, messageDetails, currentMessages, defaultWidget) { + // Retrieving the message from details + final message = messageDetails.message; + + // Decrypting the message text using the derivedKey + final decryptedMessageFuture = decryptMessage(message.text, derivedKey); + return FutureBuilder( + future: decryptedMessageFuture, + builder: (context, snapshot) { + if (snapshot.hasError) return Text('Error: ${snapshot.error}'); + if (!snapshot.hasData) return Container(); + + // Updating the original message with the decrypted text + final decryptedMessage = message.copyWith(text: snapshot.data); + + // Returning defaultWidget with updated message + return defaultWidget.copyWith( + message: decryptedMessage, + ); + }, + ); + }, +), +``` + +That’s it! that's all we need to do add E2EE in a Stream powered chat app. +For more details, checkout this [blogpost](https://getstream.io/blog/end-to-end-encrypted-chat-in-flutter/#whats-end-to-end-encryption) on our Stream blog. From 8babf0acac719fbc5f379d694553ee1d4daf9732 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 20 Oct 2021 13:14:01 +0530 Subject: [PATCH 24/35] Apply suggestions from code review Co-authored-by: Gordon --- .../guides/end_to_end_chat_encryption.mdx | 39 ++++++++++--------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/docusaurus/docs/Flutter/guides/end_to_end_chat_encryption.mdx b/docusaurus/docs/Flutter/guides/end_to_end_chat_encryption.mdx index 159b4c42..25752804 100644 --- a/docusaurus/docs/Flutter/guides/end_to_end_chat_encryption.mdx +++ b/docusaurus/docs/Flutter/guides/end_to_end_chat_encryption.mdx @@ -8,7 +8,7 @@ title: End To End Chat Encryption When you communicate over a chat application with another person or group, you may exchange sensitive information, like personally identifiable information, financial details, or passwords. -To ensure that your data stays secure, a chat application must use end-to-end encryption. +A chat application should use end-to-end encryption to ensure that users' data stays secure. :::note Before you start, keep in mind that this guide is a basic example intended for educational purposes only. @@ -19,14 +19,14 @@ There’s a lot more to consider from a security perspective that isn’t covere ## What is End-to-End Encryption? End-to-end encryption (E2EE) is the process of securing a message from third parties so that only the sender and receiver can access the message. -E2EE provides security by storing the message in an encrypted form on the server or database running the application. +E2EE provides security by storing the message in an encrypted form on the application's server or database. You can only access the message by decrypting and signing it using a known public key (distributed freely) and a corresponding private key (only known by the owner). Each user in the application has their own public-private key pair. Public keys are distributed publicly and encrypt the sender’s messages. -The receiver can only decrypt the sender’s message with the matching private key, which is used to decrypt messages and to verify or sign them. +The receiver can only decrypt the sender’s message with the matching private key. Check out the diagram below for an example: @@ -45,12 +45,12 @@ dependencies: ### Generate key pair -We will write a function that generates a key pair using the ECDH algorithm and the P-256 elliptic curve (P-256 is well-supported and +You will write a function that generates a key pair using the ECDH algorithm and the P-256 elliptic curve (P-256 is well-supported and offers the right balance of security and performance). The pair will consist of two keys -1. PublicKey: The key we will link and send with every user in order to encrypt message. -2. PrivateKey: The key we will store locally to decrypt messages. +1. PublicKey: The key that is linked to a user to encrypt messages. +2. PrivateKey: The key that is stored locally to decrypt messages. ```dart Future generateKeys() async { @@ -78,8 +78,8 @@ class JsonWebKeyPair { ### Generate a Crypto Key -The symmetric Crypto Key is generated using the keys generated in the previous step. -We will use those keys to encrypt and decrypt messages. +Next, create a symmetric **Crypto Key** using the keys generated in the previous step. +You will use those keys to encrypt and decrypt messages. ```dart // SendersJwk -> sender.privateKey @@ -107,8 +107,8 @@ Future> deriveKey(String senderJwk, String receiverJwk) async { ### Encrypting Messages -Once we have generated the Crypto Key, we’re ready to encrypt the message. -We can use the AES-GCM algorithm for its known security/performance balance and browser availability. +Once you have generated the Crypto Key, you're ready to encrypt the message. +You can use the AES-GCM algorithm for its known security/performance balance and browser availability. ```dart // The "iv" stands for initialization vector (IV). To ensure the encryption’s strength, @@ -160,12 +160,12 @@ Future decryptMessage(String encryptedMessage, List deriveKey) asyn ## Implement as a Stream Chat Feature -Now that our setup is complete, let's use it to implement end-to-end encryption in our app. +Now that your setup is complete you can use it to implement end-to-end encryption in your app. ### Store User's PublicKey -The first thing we will do is to store the generated `publicKey` as a `extraData` property, in order -for other user's to use it for encrypting the messages. +The first thing you need to do is store the generated `publicKey` as an `extraData` property, in order +for other users to use it for encrypting the messages. ```dart // Generating keyPair using the function defined in above steps @@ -188,9 +188,9 @@ await client.connectUser( ### Sending Encrypted Messages -Now we will use the `encryptMessage()` function created in our previous steps to encrypt the message. +Now you will use the `encryptMessage()` function created in the previous steps to encrypt the message. -To do that, we will also make some minor changes in our `MessageInput` widget. +To do that, you need to make some minor changes to the `MessageInput` widget. ```dart final receiverJwk = receiver.extraData['publicKey']; @@ -215,13 +215,13 @@ MessageInput( ``` `preMessageSending` is a parameter that allows your app to process the message before it goes to Stream’s server. -Here, we have used it to encrypt the message before sending it to Stream’s backend. +Here, you have used it to encrypt the message before sending it to Stream’s backend. ### Showing Decrypted Messages Now, it’s time to decrypt the message and present it in a human-readable format to the receiver. -We will customize the `MessageListView` widget to have your own `messagebuilder`, including a method to decrypt messages. +You can customize the `MessageListView` widget to have a custom `messagebuilder`, that can decrypt the message. ```dart MessageListView( @@ -251,5 +251,6 @@ MessageListView( ), ``` -That’s it! that's all we need to do add E2EE in a Stream powered chat app. -For more details, checkout this [blogpost](https://getstream.io/blog/end-to-end-encrypted-chat-in-flutter/#whats-end-to-end-encryption) on our Stream blog. +That's it! That's all you need to implement E2EE in a Stream powered chat app. + +For more details, check out our [end-to-end encrypted chat article](https://getstream.io/blog/end-to-end-encrypted-chat-in-flutter/#whats-end-to-end-encryption). From cc4bc38f95043593880baa3023c7b7e875aceece Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 20 Oct 2021 13:54:22 +0530 Subject: [PATCH 25/35] Apply suggestions from code review Co-authored-by: Gordon --- .../guides/end_to_end_chat_encryption.mdx | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/docusaurus/docs/Flutter/guides/end_to_end_chat_encryption.mdx b/docusaurus/docs/Flutter/guides/end_to_end_chat_encryption.mdx index 25752804..8a85c6a5 100644 --- a/docusaurus/docs/Flutter/guides/end_to_end_chat_encryption.mdx +++ b/docusaurus/docs/Flutter/guides/end_to_end_chat_encryption.mdx @@ -34,23 +34,23 @@ Check out the diagram below for an example: ## Setup -### Add dependency +### Dependencies -Add the [webcrypto](https://pub.dev/packages/webcrypto) package in your pubspec.yaml. +Add the [webcrypto](https://pub.dev/packages/webcrypto) package in your `pubspec.yaml` file. -```dart +```yaml dependencies: webcrypto: ^0.5.2 # latest version ``` -### Generate key pair +### Generate Key Pair -You will write a function that generates a key pair using the ECDH algorithm and the P-256 elliptic curve (P-256 is well-supported and +Write a function that generates a key pair using the **ECDH** algorithm and the **P-256** elliptic curve (**P-256** is well-supported and offers the right balance of security and performance). -The pair will consist of two keys -1. PublicKey: The key that is linked to a user to encrypt messages. -2. PrivateKey: The key that is stored locally to decrypt messages. +The pair will consist of two keys: +- **PublicKey**: The key that is linked to a user to encrypt messages. +- **PrivateKey**: The key that is stored locally to decrypt messages. ```dart Future generateKeys() async { @@ -107,8 +107,8 @@ Future> deriveKey(String senderJwk, String receiverJwk) async { ### Encrypting Messages -Once you have generated the Crypto Key, you're ready to encrypt the message. -You can use the AES-GCM algorithm for its known security/performance balance and browser availability. +Once you have generated the **Crypto Key**, you're ready to encrypt the message. +You can use the **AES-GCM** algorithm for its known security and performance balance and good browser availability. ```dart // The "iv" stands for initialization vector (IV). To ensure the encryption’s strength, @@ -162,10 +162,10 @@ Future decryptMessage(String encryptedMessage, List deriveKey) asyn Now that your setup is complete you can use it to implement end-to-end encryption in your app. -### Store User's PublicKey +### Store User's Public Key The first thing you need to do is store the generated `publicKey` as an `extraData` property, in order -for other users to use it for encrypting the messages. +for other users to encrypt messages. ```dart // Generating keyPair using the function defined in above steps @@ -190,7 +190,7 @@ await client.connectUser( Now you will use the `encryptMessage()` function created in the previous steps to encrypt the message. -To do that, you need to make some minor changes to the `MessageInput` widget. +To do that, you need to make some minor changes to the **MessageInput** widget. ```dart final receiverJwk = receiver.extraData['publicKey']; @@ -201,7 +201,9 @@ final derivedKey = await deriveKey(keyPair.privateKey, receiverJwk); ```dart MessageInput( + ... + preMessageSending: (message) async { // Encrypting the message text using derivedKey final encryptedMessage = await encryptMessage(message.text, derivedKey); @@ -221,7 +223,7 @@ Here, you have used it to encrypt the message before sending it to Stream’s ba Now, it’s time to decrypt the message and present it in a human-readable format to the receiver. -You can customize the `MessageListView` widget to have a custom `messagebuilder`, that can decrypt the message. +You can customize the **MessageListView** widget to have a custom `messagebuilder`, that can decrypt the message. ```dart MessageListView( From 3f291473e1a5d74b02a8f56ab6d7fb8f6396c131 Mon Sep 17 00:00:00 2001 From: Salvatore Giordano Date: Thu, 21 Oct 2021 10:12:30 +0200 Subject: [PATCH 26/35] fix(ui): fix image attachment resizing --- packages/stream_chat_flutter/CHANGELOG.md | 1 + .../lib/src/attachment/image_attachment.dart | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index b4a187fb..062be134 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -7,6 +7,7 @@ - [[#491]](https://github.com/GetStream/stream-chat-flutter/issues/491): Fix `MediaListView` showing media in wrong order. - Fixed `MessageListView` initialIndex not working in some cases. - Improved `MessageListView` rendering in case of reordering. +- Fix image thumbnail generation when using Stream CDN ✅ Added diff --git a/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart index 95229418..16b5f4b7 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart @@ -72,7 +72,7 @@ class ImageAttachment extends AttachmentWidget { } var imageUri = Uri.parse(imageUrl); - if (imageUri.host == 'stream-io-cdn.com') { + if (imageUri.host.endsWith('stream-io-cdn.com')) { imageUri = imageUri.replace(queryParameters: { ...imageUri.queryParameters, 'h': '400', @@ -80,7 +80,7 @@ class ImageAttachment extends AttachmentWidget { 'crop': 'center', 'resize': 'crop', }); - } else if (imageUri.host == 'stream-cloud-uploads.imgix.net') { + } else if (imageUri.host.endsWith('stream-cloud-uploads.imgix.net')) { imageUri = imageUri.replace(queryParameters: { ...imageUri.queryParameters, 'height': '400', From d35ca783e7b2aec17a1b92adc0bd63e2030f49e1 Mon Sep 17 00:00:00 2001 From: Salvatore Giordano Date: Thu, 21 Oct 2021 11:09:32 +0200 Subject: [PATCH 27/35] fix(ui): fix image attachment resizing --- .../lib/src/attachment/image_attachment.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart index 16b5f4b7..67c7dd2b 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart @@ -72,7 +72,11 @@ class ImageAttachment extends AttachmentWidget { } var imageUri = Uri.parse(imageUrl); - if (imageUri.host.endsWith('stream-io-cdn.com')) { + if (imageUri.host.endsWith('stream-io-cdn.com') && + imageUri.queryParameters['h'] == '*' && + imageUri.queryParameters['w'] == '*' && + imageUri.queryParameters['crop'] == '*' && + imageUri.queryParameters['resize'] == '*') { imageUri = imageUri.replace(queryParameters: { ...imageUri.queryParameters, 'h': '400', From 1806b3a6bd62189d3a0654b8a8c88e9624d0dd6a Mon Sep 17 00:00:00 2001 From: Salvatore Giordano Date: Thu, 21 Oct 2021 16:20:38 +0200 Subject: [PATCH 28/35] fix(ui): change default alignment --- packages/stream_chat_flutter/lib/src/message_list_view.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stream_chat_flutter/lib/src/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view.dart index 117ef211..30d86049 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -325,7 +325,7 @@ class _MessageListViewState extends State { double get _initialAlignment { final initialAlignment = widget.initialAlignment; if (initialAlignment != null) return initialAlignment; - return 0; + return 0.1; } bool _isInitialMessage(String id) => streamChannel!.initialMessageId == id; From 1bcf9a515f5b1c54248470a7d21ded90a770b237 Mon Sep 17 00:00:00 2001 From: Salvatore Giordano Date: Mon, 25 Oct 2021 16:18:58 +0200 Subject: [PATCH 29/35] fix(ui): refactor scroll to bottom button --- .../lib/src/message_list_view.dart | 47 ++++++++----------- 1 file changed, 19 insertions(+), 28 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view.dart index 30d86049..3760747d 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -145,7 +145,7 @@ class MessageListView extends StatefulWidget { this.threadBuilder, this.onThreadTap, this.dateDividerBuilder, - this.scrollPhysics = const ClampingScrollPhysics(), + this.scrollPhysics, this.initialScrollIndex, this.initialAlignment, this.scrollController, @@ -225,7 +225,7 @@ class MessageListView extends StatefulWidget { final ItemPositionsListener? itemPositionListener; /// The ScrollPhysics used by the ListView - final ScrollPhysics scrollPhysics; + final ScrollPhysics? scrollPhysics; /// Called when message item gets swiped final OnMessageSwiped? onMessageSwiped; @@ -296,7 +296,7 @@ class MessageListView extends StatefulWidget { class _MessageListViewState extends State { ItemScrollController? _scrollController; void Function(Message)? _onThreadTap; - bool _showScrollToBottom = false; + final ValueNotifier _showScrollToBottom = ValueNotifier(false); late final ItemPositionsListener _itemPositionListener; int? _messageListLength; StreamChannelState? streamChannel; @@ -334,7 +334,6 @@ class _MessageListViewState extends State { bool get _isThreadConversation => widget.parentMessage != null; - bool _topPaginationActive = false; bool _bottomPaginationActive = false; int initialIndex = 0; @@ -404,10 +403,6 @@ class _MessageListViewState extends State { initialAlignment = first.itemLeadingEdge; } } - } else if (!_topPaginationActive && _upToDate) { - // Reset the index in-case we send any new message - initialIndex = 0; - initialAlignment = 0; } } @@ -451,7 +446,6 @@ class _MessageListViewState extends State { onStartOfPage: () async { _inBetweenList = false; if (!_upToDate) { - _topPaginationActive = false; _bottomPaginationActive = true; return _paginateData( streamChannel, @@ -461,7 +455,6 @@ class _MessageListViewState extends State { }, onEndOfPage: () async { _inBetweenList = false; - _topPaginationActive = true; _bottomPaginationActive = false; return _paginateData( streamChannel, @@ -650,7 +643,16 @@ class _MessageListViewState extends State { ); }, ), - if (widget.showScrollToBottom) _buildScrollToBottom(), + ValueListenableBuilder( + valueListenable: _showScrollToBottom, + child: _buildScrollToBottom(), + builder: (context, value, child) { + if (!streamChannel!.channel.state!.isUpToDate || value) { + return child!; + } + return const Offstage(); + }, + ), if (widget.showFloatingDateDivider) _buildFloatingDateDivider(itemCount), ], @@ -770,24 +772,15 @@ class _MessageListViewState extends State { .index; } - Widget _buildScrollToBottom() => StreamBuilder>( - stream: Rx.combineLatest2( - streamChannel!.channel.state!.isUpToDateStream.distinct(), - streamChannel!.channel.state!.unreadCountStream.distinct(), - (bool isUpToDate, int unreadCount) => Tuple2(isUpToDate, unreadCount), - ), + Widget _buildScrollToBottom() => StreamBuilder( + stream: streamChannel!.channel.state!.unreadCountStream, builder: (_, snapshot) { if (snapshot.hasError) { return const Offstage(); } else if (!snapshot.hasData) { return const Offstage(); } - final isUpToDate = snapshot.data!.item1; - final showScrollToBottom = !isUpToDate || _showScrollToBottom; - if (!showScrollToBottom) { - return const Offstage(); - } - final unreadCount = snapshot.data!.item2; + final unreadCount = snapshot.data!; final showUnreadCount = unreadCount > 0 && streamChannel!.channel.state!.members.any((e) => e.userId == @@ -808,10 +801,9 @@ class _MessageListViewState extends State { } if (!_upToDate) { _bottomPaginationActive = false; - _topPaginationActive = false; streamChannel!.reloadChannel(); } else { - setState(() => _showScrollToBottom = false); + _showScrollToBottom.value = false; _scrollController!.scrollTo( index: 0, duration: const Duration(seconds: 1), @@ -886,8 +878,8 @@ class _MessageListViewState extends State { } } if (mounted) { - if (_showScrollToBottom == isVisible) { - setState(() => _showScrollToBottom = !isVisible); + if (_showScrollToBottom.value == isVisible) { + _showScrollToBottom.value = !isVisible; } } }, @@ -1252,7 +1244,6 @@ class _MessageListViewState extends State { streamChannel!.channel.on(EventType.messageNew).listen((event) { if (_upToDate) { _bottomPaginationActive = false; - _topPaginationActive = false; } if (event.message?.parentId == widget.parentMessage?.id && event.message!.user!.id == From 04ca2c6eebaf5be6d5da4c73041ce13d95fb14cb Mon Sep 17 00:00:00 2001 From: Salvatore Giordano Date: Tue, 26 Oct 2021 12:50:40 +0200 Subject: [PATCH 30/35] fix(ui): fix scroll to bottom logic --- .../src/positioned_list.dart | 5 ++++- .../src/scrollable_positioned_list.dart | 8 -------- .../lib/src/message_list_view.dart | 19 +++++++++++++++---- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/positioned_list.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/positioned_list.dart index 081d67e0..7cd4cf16 100644 --- a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/positioned_list.dart +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/positioned_list.dart @@ -325,9 +325,12 @@ class _PositionedListState extends State { for (final element in registeredElements.value!) { final box = element.renderObject as RenderBox; viewport ??= RenderAbstractViewport.of(box) as RenderViewport?; + if (viewport == null) { + continue; + } final key = element.widget.key as IndexedKey; if (widget.scrollDirection == Axis.vertical) { - final reveal = viewport!.getOffsetToReveal(box, 0).offset; + final reveal = viewport.getOffsetToReveal(box, 0).offset; if (!reveal.isFinite) continue; final itemOffset = reveal - viewport.offset.pixels + diff --git a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scrollable_positioned_list.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scrollable_positioned_list.dart index 388e5979..765a90a0 100644 --- a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scrollable_positioned_list.dart +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scrollable_positioned_list.dart @@ -325,14 +325,6 @@ class _ScrollablePositionedListState extends State secondary.target = widget.itemCount - 1; } } - - if (widget.initialScrollIndex != oldWidget.initialScrollIndex || - widget.initialAlignment != oldWidget.initialAlignment) { - _jumpTo( - index: widget.initialScrollIndex, - alignment: widget.initialAlignment, - ); - } } @override diff --git a/packages/stream_chat_flutter/lib/src/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view.dart index 3760747d..6798c432 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -465,6 +465,9 @@ class _MessageListViewState extends State { _inBetweenList = true; }, child: ScrollablePositionedList.separated( + key: (initialIndex != 0 && initialAlignment != 0) + ? ValueKey('$initialIndex-$initialAlignment') + : null, itemPositionsListener: _itemPositionListener, initialScrollIndex: initialIndex, initialAlignment: initialAlignment, @@ -795,13 +798,19 @@ class _MessageListViewState extends State { children: [ FloatingActionButton( backgroundColor: _streamTheme.colorTheme.barsBg, - onPressed: () { + onPressed: () async { if (unreadCount > 0) { streamChannel!.channel.markRead(); } if (!_upToDate) { _bottomPaginationActive = false; - streamChannel!.reloadChannel(); + initialAlignment = 0; + initialIndex = 0; + await streamChannel!.reloadChannel(); + + WidgetsBinding.instance?.addPostFrameCallback((_) { + _scrollController!.jumpTo(index: 0); + }); } else { _showScrollToBottom.value = false; _scrollController!.scrollTo( @@ -1057,8 +1066,10 @@ class _MessageListViewState extends State { final scrollToIndex = () { final index = messages.indexWhere((m) => m.id == quotedMessageId); _scrollController?.scrollTo( - index: index, - duration: const Duration(milliseconds: 350), + index: index + 2, // +2 to account for loader and footer + duration: const Duration(seconds: 1), + curve: Curves.easeInOut, + alignment: 0.1, ); }; if (messages.map((e) => e.id).contains(quotedMessageId)) { From 89f767886a5b1f919e961ccb9f79d9fe43b57db1 Mon Sep 17 00:00:00 2001 From: Salvatore Giordano Date: Tue, 26 Oct 2021 15:34:20 +0200 Subject: [PATCH 31/35] fix(ui): fix quoted message scroll --- .../src/positioned_list.dart | 4 ++-- .../lib/src/message_list_view.dart | 17 ++++++----------- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/positioned_list.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/positioned_list.dart index 7cd4cf16..c2c2a278 100644 --- a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/positioned_list.dart +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/positioned_list.dart @@ -323,9 +323,9 @@ class _PositionedListState extends State { final positions = []; RenderViewport? viewport; for (final element in registeredElements.value!) { - final box = element.renderObject as RenderBox; + final box = element.renderObject as RenderBox?; viewport ??= RenderAbstractViewport.of(box) as RenderViewport?; - if (viewport == null) { + if (viewport == null || box == null) { continue; } final key = element.widget.key as IndexedKey; diff --git a/packages/stream_chat_flutter/lib/src/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view.dart index 6798c432..cdfe2de8 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -1062,8 +1062,7 @@ class _MessageListViewState extends State { showSendingIndicator: showSendingIndicator, showUserAvatar: showUserAvatar, onQuotedMessageTap: (quotedMessageId) async { - // ignore: prefer_function_declarations_over_variables - final scrollToIndex = () { + if (messages.map((e) => e.id).contains(quotedMessageId)) { final index = messages.indexWhere((m) => m.id == quotedMessageId); _scrollController?.scrollTo( index: index + 2, // +2 to account for loader and footer @@ -1071,16 +1070,12 @@ class _MessageListViewState extends State { curve: Curves.easeInOut, alignment: 0.1, ); - }; - if (messages.map((e) => e.id).contains(quotedMessageId)) { - scrollToIndex(); } else { - await streamChannel!.loadChannelAtMessage(quotedMessageId).then((_) { - WidgetsBinding.instance!.addPostFrameCallback((_) { - if (messages.map((e) => e.id).contains(quotedMessageId)) { - scrollToIndex(); - } - }); + await streamChannel! + .loadChannelAtMessage(quotedMessageId) + .then((_) async { + initialIndex = 21; // 19 + 2 | 19 is the index of the message + initialAlignment = 0.1; }); } }, From 34eaae7a8c24bb4751b48b56ea42d5e463a78fdf Mon Sep 17 00:00:00 2001 From: Salvatore Giordano Date: Wed, 27 Oct 2021 11:29:27 +0200 Subject: [PATCH 32/35] fix(ui): revert scroll physics --- packages/stream_chat_flutter/lib/src/message_list_view.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/stream_chat_flutter/lib/src/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view.dart index cdfe2de8..7e34253c 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -145,7 +145,8 @@ class MessageListView extends StatefulWidget { this.threadBuilder, this.onThreadTap, this.dateDividerBuilder, - this.scrollPhysics, + this.scrollPhysics = + const ClampingScrollPhysics(), // we need to use ClampingScrollPhysics to avoid the list view to animate and break while loading this.initialScrollIndex, this.initialAlignment, this.scrollController, From e65843963e97b504caced5989166d75c5ee2269c Mon Sep 17 00:00:00 2001 From: Salvatore Giordano Date: Wed, 27 Oct 2021 16:29:34 +0200 Subject: [PATCH 33/35] fix(ui): use isUpToDateStream --- .../lib/src/message_list_view.dart | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view.dart index 7e34253c..9ff158b1 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -647,15 +647,19 @@ class _MessageListViewState extends State { ); }, ), - ValueListenableBuilder( - valueListenable: _showScrollToBottom, - child: _buildScrollToBottom(), - builder: (context, value, child) { - if (!streamChannel!.channel.state!.isUpToDate || value) { - return child!; - } - return const Offstage(); - }, + BetterStreamBuilder( + stream: streamChannel!.channel.state!.isUpToDateStream, + initialData: streamChannel!.channel.state!.isUpToDate, + builder: (context, snapshot) => ValueListenableBuilder( + valueListenable: _showScrollToBottom, + child: _buildScrollToBottom(), + builder: (context, value, child) { + if (!snapshot || value) { + return child!; + } + return const Offstage(); + }, + ), ), if (widget.showFloatingDateDivider) _buildFloatingDateDivider(itemCount), From 165e8682c506c6a2015381202b0a5ad42777a5f9 Mon Sep 17 00:00:00 2001 From: Salvatore Giordano Date: Thu, 28 Oct 2021 09:36:10 +0200 Subject: [PATCH 34/35] fix analysis --- packages/stream_chat_flutter/lib/src/message_list_view.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/stream_chat_flutter/lib/src/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view.dart index 9ff158b1..5c8e29d3 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -6,7 +6,6 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; -import 'package:rxdart/rxdart.dart'; import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:stream_chat_flutter/src/extension.dart'; import 'package:stream_chat_flutter/src/info_tile.dart'; From 74ebf73fbc4aa2a16a66cc938823fde10cbd08cb Mon Sep 17 00:00:00 2001 From: Salvatore Giordano Date: Fri, 29 Oct 2021 11:00:16 +0200 Subject: [PATCH 35/35] fix(ui): fix image reloading --- .../lib/src/attachment/image_attachment.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart index 67c7dd2b..41b8d039 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart @@ -97,7 +97,7 @@ class ImageAttachment extends AttachmentWidget { return _buildImageAttachment( context, CachedNetworkImage( - cacheKey: imageUrl, + cacheKey: imageUri.replace(queryParameters: {}).toString(), height: size?.height, width: size?.width, placeholder: (context, __) {