From b2e2cafd6c77ba4ce2d24289adefc70ca6487d43 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Mon, 12 Sep 2022 18:32:14 -0400 Subject: [PATCH] feat: add pull to refresh when user overscrolls (#709) --- lib/components/chat_panel.dart | 54 +- .../pinnable/reverse_refresh_indicator.dart | 573 ++++++++++++++++++ lib/models/adapters/messages.dart | 8 +- 3 files changed, 611 insertions(+), 24 deletions(-) create mode 100644 lib/components/pinnable/reverse_refresh_indicator.dart diff --git a/lib/components/chat_panel.dart b/lib/components/chat_panel.dart index 7c3cf8e44..957a4b188 100644 --- a/lib/components/chat_panel.dart +++ b/lib/components/chat_panel.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:rtchat/components/connection_status.dart'; import 'package:rtchat/components/chat_history/message.dart'; +import 'package:rtchat/components/pinnable/reverse_refresh_indicator.dart'; import 'package:rtchat/components/pinnable/scroll_view.dart'; import 'package:rtchat/components/style_model_theme.dart'; import 'package:rtchat/models/adapters/messages.dart'; @@ -198,6 +199,9 @@ class ChatPanelWidget extends StatefulWidget { class _ChatPanelWidgetState extends State with TickerProviderStateMixin { + final GlobalKey _refreshIndicatorKey = + GlobalKey(); + final _controller = ScrollController(keepScrollOffset: true); // don't render anything after this message if not null. @@ -277,28 +281,34 @@ class _ChatPanelWidgetState extends State builder: (context) { final now = DateTime.now(); final oneSecondAgo = now.subtract(const Duration(seconds: 1)); - return PinnableMessageScrollView( - vsync: this, - controller: _controller, - itemBuilder: (index) => StyleModelTheme( - key: Key(messages[index].messageId), - child: ChatHistoryMessage( - message: messages[index], channel: widget.channel)), - findChildIndexCallback: (key) => messages - .indexWhere((element) => key == Key(element.messageId)), - isPinnedBuilder: (index) { - final expiration = expirations[index]; - if (expiration == null || - // if the message is too expired, it can't be pinned again. - // note: we track unpinned separately to permit animations. - expiration.isBefore(oneSecondAgo)) { - return PinState.notPinnable; - } - return expiration.isAfter(now) - ? PinState.pinned - : PinState.unpinned; - }, - count: messages.length, + return ReverseRefreshIndicator( + key: _refreshIndicatorKey, + onRefresh: () => + MessagesAdapter.instance.subscribe(widget.channel), + // Pull from top to show refresh indicator. + child: PinnableMessageScrollView( + vsync: this, + controller: _controller, + itemBuilder: (index) => StyleModelTheme( + key: Key(messages[index].messageId), + child: ChatHistoryMessage( + message: messages[index], channel: widget.channel)), + findChildIndexCallback: (key) => messages + .indexWhere((element) => key == Key(element.messageId)), + isPinnedBuilder: (index) { + final expiration = expirations[index]; + if (expiration == null || + // if the message is too expired, it can't be pinned again. + // note: we track unpinned separately to permit animations. + expiration.isBefore(oneSecondAgo)) { + return PinState.notPinnable; + } + return expiration.isAfter(now) + ? PinState.pinned + : PinState.unpinned; + }, + count: messages.length, + ), ); }); }), diff --git a/lib/components/pinnable/reverse_refresh_indicator.dart b/lib/components/pinnable/reverse_refresh_indicator.dart new file mode 100644 index 000000000..b23529639 --- /dev/null +++ b/lib/components/pinnable/reverse_refresh_indicator.dart @@ -0,0 +1,573 @@ +// This file is forked from refresh_indicator.dart in flutter to revert this PR: +// https://github.com/flutter/flutter/pull/93779 + +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +// The over-scroll distance that moves the indicator to its maximum +// displacement, as a percentage of the scrollable's container extent. +const double _kDragContainerExtentPercentage = 0.25; + +// How much the scroll's drag gesture can overshoot the RefreshIndicator's +// displacement; max displacement = _kDragSizeFactorLimit * displacement. +const double _kDragSizeFactorLimit = 1.5; + +// When the scroll ends, the duration of the refresh indicator's animation +// to the RefreshIndicator's displacement. +const Duration _kIndicatorSnapDuration = Duration(milliseconds: 150); + +// The duration of the ScaleTransition that starts when the refresh action +// has completed. +const Duration _kIndicatorScaleDuration = Duration(milliseconds: 200); + +/// The signature for a function that's called when the user has dragged a +/// [ReverseRefreshIndicator] far enough to demonstrate that they want the app to +/// refresh. The returned [Future] must complete when the refresh operation is +/// finished. +/// +/// Used by [ReverseRefreshIndicator.onRefresh]. +typedef RefreshCallback = Future Function(); + +// The state machine moves through these modes only when the scrollable +// identified by scrollableKey has been scrolled to its min or max limit. +enum _RefreshIndicatorMode { + drag, // Pointer is down. + armed, // Dragged far enough that an up event will run the onRefresh callback. + snap, // Animating to the indicator's final "displacement". + refresh, // Running the refresh callback. + done, // Animating the indicator's fade-out after refreshing. + canceled, // Animating the indicator's fade-out after not arming. +} + +/// Used to configure how [ReverseRefreshIndicator] can be triggered. +enum RefreshIndicatorTriggerMode { + /// The indicator can be triggered regardless of the scroll position + /// of the [Scrollable] when the drag starts. + anywhere, + + /// The indicator can only be triggered if the [Scrollable] is at the edge + /// when the drag starts. + onEdge, +} + +/// A widget that supports the Material "swipe to refresh" idiom. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=ORApMlzwMdM} +/// +/// When the child's [Scrollable] descendant overscrolls, an animated circular +/// progress indicator is faded into view. When the scroll ends, if the +/// indicator has been dragged far enough for it to become completely opaque, +/// the [onRefresh] callback is called. The callback is expected to update the +/// scrollable's contents and then complete the [Future] it returns. The refresh +/// indicator disappears after the callback's [Future] has completed. +/// +/// The trigger mode is configured by [ReverseRefreshIndicator.triggerMode]. +/// +/// {@tool dartpad} +/// This example shows how [ReverseRefreshIndicator] can be triggered in different ways. +/// +/// ** See code in examples/api/lib/material/refresh_indicator/refresh_indicator.0.dart ** +/// {@end-tool} +/// +/// ## Troubleshooting +/// +/// ### Refresh indicator does not show up +/// +/// The [ReverseRefreshIndicator] will appear if its scrollable descendant can be +/// overscrolled, i.e. if the scrollable's content is bigger than its viewport. +/// To ensure that the [ReverseRefreshIndicator] will always appear, even if the +/// scrollable's content fits within its viewport, set the scrollable's +/// [Scrollable.physics] property to [AlwaysScrollableScrollPhysics]: +/// +/// ```dart +/// ListView( +/// physics: const AlwaysScrollableScrollPhysics(), +/// children: ... +/// ) +/// ``` +/// +/// A [ReverseRefreshIndicator] can only be used with a vertical scroll view. +/// +/// See also: +/// +/// * +/// * [ReverseRefreshIndicatorState], can be used to programmatically show the refresh indicator. +/// * [RefreshProgressIndicator], widget used by [ReverseRefreshIndicator] to show +/// the inner circular progress spinner during refreshes. +/// * [CupertinoSliverRefreshControl], an iOS equivalent of the pull-to-refresh pattern. +/// Must be used as a sliver inside a [CustomScrollView] instead of wrapping +/// around a [ScrollView] because it's a part of the scrollable instead of +/// being overlaid on top of it. +class ReverseRefreshIndicator extends StatefulWidget { + /// Creates a refresh indicator. + /// + /// The [onRefresh], [child], and [notificationPredicate] arguments must be + /// non-null. The default + /// [displacement] is 40.0 logical pixels. + /// + /// The [semanticsLabel] is used to specify an accessibility label for this widget. + /// If it is null, it will be defaulted to [MaterialLocalizations.refreshIndicatorSemanticLabel]. + /// An empty string may be passed to avoid having anything read by screen reading software. + /// The [semanticsValue] may be used to specify progress on the widget. + const ReverseRefreshIndicator({ + Key? key, + required this.child, + this.displacement = 40.0, + this.edgeOffset = 0.0, + required this.onRefresh, + this.color, + this.backgroundColor, + this.notificationPredicate = defaultScrollNotificationPredicate, + this.semanticsLabel, + this.semanticsValue, + this.strokeWidth = RefreshProgressIndicator.defaultStrokeWidth, + this.triggerMode = RefreshIndicatorTriggerMode.onEdge, + }) : super(key: key); + + /// The widget below this widget in the tree. + /// + /// The refresh indicator will be stacked on top of this child. The indicator + /// will appear when child's Scrollable descendant is over-scrolled. + /// + /// Typically a [ListView] or [CustomScrollView]. + final Widget child; + + /// The distance from the child's top or bottom [edgeOffset] where + /// the refresh indicator will settle. During the drag that exposes the refresh + /// indicator, its actual displacement may significantly exceed this value. + /// + /// In most cases, [displacement] distance starts counting from the parent's + /// edges. However, if [edgeOffset] is larger than zero then the [displacement] + /// value is calculated from that offset instead of the parent's edge. + final double displacement; + + /// The offset where [RefreshProgressIndicator] starts to appear on drag start. + /// + /// Depending whether the indicator is showing on the top or bottom, the value + /// of this variable controls how far from the parent's edge the progress + /// indicator starts to appear. This may come in handy when, for example, the + /// UI contains a top [Widget] which covers the parent's edge where the progress + /// indicator would otherwise appear. + /// + /// By default, the edge offset is set to 0. + /// + /// See also: + /// + /// * [displacement], can be used to change the distance from the edge that + /// the indicator settles. + final double edgeOffset; + + /// A function that's called when the user has dragged the refresh indicator + /// far enough to demonstrate that they want the app to refresh. The returned + /// [Future] must complete when the refresh operation is finished. + final RefreshCallback onRefresh; + + /// The progress indicator's foreground color. The current theme's + /// [ColorScheme.primary] by default. + final Color? color; + + /// The progress indicator's background color. The current theme's + /// [ThemeData.canvasColor] by default. + final Color? backgroundColor; + + /// A check that specifies whether a [ScrollNotification] should be + /// handled by this widget. + /// + /// By default, checks whether `notification.depth == 0`. Set it to something + /// else for more complicated layouts. + final ScrollNotificationPredicate notificationPredicate; + + /// {@macro flutter.progress_indicator.ProgressIndicator.semanticsLabel} + /// + /// This will be defaulted to [MaterialLocalizations.refreshIndicatorSemanticLabel] + /// if it is null. + final String? semanticsLabel; + + /// {@macro flutter.progress_indicator.ProgressIndicator.semanticsValue} + final String? semanticsValue; + + /// Defines `strokeWidth` for `RefreshIndicator`. + /// + /// By default, the value of `strokeWidth` is 2.0 pixels. + final double strokeWidth; + + /// Defines how this [ReverseRefreshIndicator] can be triggered when users overscroll. + /// + /// The [ReverseRefreshIndicator] can be pulled out in two cases, + /// 1, Keep dragging if the scrollable widget at the edge with zero scroll position + /// when the drag starts. + /// 2, Keep dragging after overscroll occurs if the scrollable widget has + /// a non-zero scroll position when the drag starts. + /// + /// If this is [RefreshIndicatorTriggerMode.anywhere], both of the cases above can be triggered. + /// + /// If this is [RefreshIndicatorTriggerMode.onEdge], only case 1 can be triggered. + /// + /// Defaults to [RefreshIndicatorTriggerMode.onEdge]. + final RefreshIndicatorTriggerMode triggerMode; + + @override + ReverseRefreshIndicatorState createState() => ReverseRefreshIndicatorState(); +} + +/// Contains the state for a [ReverseRefreshIndicator]. This class can be used to +/// programmatically show the refresh indicator, see the [show] method. +class ReverseRefreshIndicatorState extends State + with TickerProviderStateMixin { + late AnimationController _positionController; + late AnimationController _scaleController; + late Animation _positionFactor; + late Animation _scaleFactor; + late Animation _value; + late Animation _valueColor; + + _RefreshIndicatorMode? _mode; + late Future _pendingRefreshFuture; + bool? _isIndicatorAtTop; + double? _dragOffset; + + static final Animatable _threeQuarterTween = + Tween(begin: 0.0, end: 0.75); + static final Animatable _kDragSizeFactorLimitTween = + Tween(begin: 0.0, end: _kDragSizeFactorLimit); + static final Animatable _oneToZeroTween = + Tween(begin: 1.0, end: 0.0); + + @override + void initState() { + super.initState(); + _positionController = AnimationController(vsync: this); + _positionFactor = _positionController.drive(_kDragSizeFactorLimitTween); + _value = _positionController.drive( + _threeQuarterTween); // The "value" of the circular progress indicator during a drag. + + _scaleController = AnimationController(vsync: this); + _scaleFactor = _scaleController.drive(_oneToZeroTween); + } + + @override + void didChangeDependencies() { + final ThemeData theme = Theme.of(context); + _valueColor = _positionController.drive( + ColorTween( + begin: (widget.color ?? theme.colorScheme.primary).withOpacity(0.0), + end: (widget.color ?? theme.colorScheme.primary).withOpacity(1.0), + ).chain(CurveTween( + curve: const Interval(0.0, 1.0 / _kDragSizeFactorLimit), + )), + ); + super.didChangeDependencies(); + } + + @override + void didUpdateWidget(covariant ReverseRefreshIndicator oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.color != widget.color) { + final ThemeData theme = Theme.of(context); + _valueColor = _positionController.drive( + ColorTween( + begin: (widget.color ?? theme.colorScheme.primary).withOpacity(0.0), + end: (widget.color ?? theme.colorScheme.primary).withOpacity(1.0), + ).chain(CurveTween( + curve: const Interval(0.0, 1.0 / _kDragSizeFactorLimit), + )), + ); + } + } + + @override + void dispose() { + _positionController.dispose(); + _scaleController.dispose(); + super.dispose(); + } + + bool _shouldStart(ScrollNotification notification) { + // If the notification.dragDetails is null, this scroll is not triggered by + // user dragging. It may be a result of ScrollController.jumpTo or ballistic scroll. + // In this case, we don't want to trigger the refresh indicator. + return ((notification is ScrollStartNotification && + notification.dragDetails != null) || + (notification is ScrollUpdateNotification && + notification.dragDetails != null && + widget.triggerMode == RefreshIndicatorTriggerMode.anywhere)) && + notification.metrics.extentBefore == 0.0 && + _mode == null && + _start(notification.metrics.axisDirection); + } + + bool _handleScrollNotification(ScrollNotification notification) { + if (!widget.notificationPredicate(notification)) return false; + if (_shouldStart(notification)) { + setState(() { + _mode = _RefreshIndicatorMode.drag; + }); + return false; + } + bool? indicatorAtTopNow; + switch (notification.metrics.axisDirection) { + case AxisDirection.down: + indicatorAtTopNow = true; + break; + case AxisDirection.up: + indicatorAtTopNow = false; + break; + case AxisDirection.left: + case AxisDirection.right: + indicatorAtTopNow = null; + break; + } + if (indicatorAtTopNow != _isIndicatorAtTop) { + if (_mode == _RefreshIndicatorMode.drag || + _mode == _RefreshIndicatorMode.armed) { + _dismiss(_RefreshIndicatorMode.canceled); + } + } else if (notification is ScrollUpdateNotification) { + if (_mode == _RefreshIndicatorMode.drag || + _mode == _RefreshIndicatorMode.armed) { + if (notification.metrics.extentBefore > 0.0) { + _dismiss(_RefreshIndicatorMode.canceled); + } else { + _dragOffset = _dragOffset! - notification.scrollDelta!; + _checkDragOffset(notification.metrics.viewportDimension); + } + } + if (_mode == _RefreshIndicatorMode.armed && + notification.dragDetails == null) { + // On iOS start the refresh when the Scrollable bounces back from the + // overscroll (ScrollNotification indicating this don't have dragDetails + // because the scroll activity is not directly triggered by a drag). + _show(); + } + } else if (notification is OverscrollNotification) { + if (_mode == _RefreshIndicatorMode.drag || + _mode == _RefreshIndicatorMode.armed) { + _dragOffset = _dragOffset! - notification.overscroll; + _checkDragOffset(notification.metrics.viewportDimension); + } + } else if (notification is ScrollEndNotification) { + switch (_mode) { + case _RefreshIndicatorMode.armed: + _show(); + break; + case _RefreshIndicatorMode.drag: + _dismiss(_RefreshIndicatorMode.canceled); + break; + case _RefreshIndicatorMode.canceled: + case _RefreshIndicatorMode.done: + case _RefreshIndicatorMode.refresh: + case _RefreshIndicatorMode.snap: + case null: + // do nothing + break; + } + } + return false; + } + + bool _handleGlowNotification(OverscrollIndicatorNotification notification) { + if (notification.depth != 0 || !notification.leading) return false; + if (_mode == _RefreshIndicatorMode.drag) { + notification.disallowIndicator(); + return true; + } + return false; + } + + bool _start(AxisDirection direction) { + assert(_mode == null); + assert(_isIndicatorAtTop == null); + assert(_dragOffset == null); + switch (direction) { + case AxisDirection.down: + _isIndicatorAtTop = true; + break; + case AxisDirection.up: + _isIndicatorAtTop = false; + break; + case AxisDirection.left: + case AxisDirection.right: + _isIndicatorAtTop = null; + // we do not support horizontal scroll views. + return false; + } + _dragOffset = 0.0; + _scaleController.value = 0.0; + _positionController.value = 0.0; + return true; + } + + void _checkDragOffset(double containerExtent) { + assert(_mode == _RefreshIndicatorMode.drag || + _mode == _RefreshIndicatorMode.armed); + double newValue = + _dragOffset! / (containerExtent * _kDragContainerExtentPercentage); + if (_mode == _RefreshIndicatorMode.armed) { + newValue = math.max(newValue, 1.0 / _kDragSizeFactorLimit); + } + _positionController.value = + newValue.clamp(0.0, 1.0); // this triggers various rebuilds + if (_mode == _RefreshIndicatorMode.drag && + _valueColor.value!.alpha == 0xFF) { + _mode = _RefreshIndicatorMode.armed; + } + } + + // Stop showing the refresh indicator. + Future _dismiss(_RefreshIndicatorMode newMode) async { + await Future.value(); + // This can only be called from _show() when refreshing and + // _handleScrollNotification in response to a ScrollEndNotification or + // direction change. + assert(newMode == _RefreshIndicatorMode.canceled || + newMode == _RefreshIndicatorMode.done); + setState(() { + _mode = newMode; + }); + switch (_mode!) { + case _RefreshIndicatorMode.done: + await _scaleController.animateTo(1.0, + duration: _kIndicatorScaleDuration); + break; + case _RefreshIndicatorMode.canceled: + await _positionController.animateTo(0.0, + duration: _kIndicatorScaleDuration); + break; + case _RefreshIndicatorMode.armed: + case _RefreshIndicatorMode.drag: + case _RefreshIndicatorMode.refresh: + case _RefreshIndicatorMode.snap: + assert(false); + } + if (mounted && _mode == newMode) { + _dragOffset = null; + _isIndicatorAtTop = null; + setState(() { + _mode = null; + }); + } + } + + void _show() { + assert(_mode != _RefreshIndicatorMode.refresh); + assert(_mode != _RefreshIndicatorMode.snap); + final Completer completer = Completer(); + _pendingRefreshFuture = completer.future; + _mode = _RefreshIndicatorMode.snap; + _positionController + .animateTo(1.0 / _kDragSizeFactorLimit, + duration: _kIndicatorSnapDuration) + .then((void value) { + if (mounted && _mode == _RefreshIndicatorMode.snap) { + setState(() { + // Show the indeterminate progress indicator. + _mode = _RefreshIndicatorMode.refresh; + }); + + final Future refreshResult = widget.onRefresh(); + refreshResult.whenComplete(() { + if (mounted && _mode == _RefreshIndicatorMode.refresh) { + completer.complete(); + _dismiss(_RefreshIndicatorMode.done); + } + }); + } + }); + } + + /// Show the refresh indicator and run the refresh callback as if it had + /// been started interactively. If this method is called while the refresh + /// callback is running, it quietly does nothing. + /// + /// Creating the [ReverseRefreshIndicator] with a [GlobalKey] + /// makes it possible to refer to the [ReverseRefreshIndicatorState]. + /// + /// The future returned from this method completes when the + /// [ReverseRefreshIndicator.onRefresh] callback's future completes. + /// + /// If you await the future returned by this function from a [State], you + /// should check that the state is still [mounted] before calling [setState]. + /// + /// When initiated in this manner, the refresh indicator is independent of any + /// actual scroll view. It defaults to showing the indicator at the top. To + /// show it at the bottom, set `atTop` to false. + Future show({bool atTop = true}) { + if (_mode != _RefreshIndicatorMode.refresh && + _mode != _RefreshIndicatorMode.snap) { + if (_mode == null) _start(atTop ? AxisDirection.down : AxisDirection.up); + _show(); + } + return _pendingRefreshFuture; + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterialLocalizations(context)); + final Widget child = NotificationListener( + onNotification: _handleScrollNotification, + child: NotificationListener( + onNotification: _handleGlowNotification, + child: widget.child, + ), + ); + assert(() { + if (_mode == null) { + assert(_dragOffset == null); + assert(_isIndicatorAtTop == null); + } else { + assert(_dragOffset != null); + assert(_isIndicatorAtTop != null); + } + return true; + }()); + + final bool showIndeterminateIndicator = + _mode == _RefreshIndicatorMode.refresh || + _mode == _RefreshIndicatorMode.done; + + return Stack( + children: [ + child, + if (_mode != null) + Positioned( + top: _isIndicatorAtTop! ? widget.edgeOffset : null, + bottom: !_isIndicatorAtTop! ? widget.edgeOffset : null, + left: 0.0, + right: 0.0, + child: SizeTransition( + axisAlignment: _isIndicatorAtTop! ? 1.0 : -1.0, + sizeFactor: _positionFactor, // this is what brings it down + child: Container( + padding: _isIndicatorAtTop! + ? EdgeInsets.only(top: widget.displacement) + : EdgeInsets.only(bottom: widget.displacement), + alignment: _isIndicatorAtTop! + ? Alignment.topCenter + : Alignment.bottomCenter, + child: ScaleTransition( + scale: _scaleFactor, + child: AnimatedBuilder( + animation: _positionController, + builder: (BuildContext context, Widget? child) { + return RefreshProgressIndicator( + semanticsLabel: widget.semanticsLabel ?? + MaterialLocalizations.of(context) + .refreshIndicatorSemanticLabel, + semanticsValue: widget.semanticsValue, + value: showIndeterminateIndicator ? null : _value.value, + valueColor: _valueColor, + backgroundColor: widget.backgroundColor, + strokeWidth: widget.strokeWidth, + ); + }, + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/models/adapters/messages.dart b/lib/models/adapters/messages.dart index 25867ed7b..2e4a49183 100644 --- a/lib/models/adapters/messages.dart +++ b/lib/models/adapters/messages.dart @@ -293,12 +293,16 @@ class MessagesAdapter { db: FirebaseFirestore.instance, functions: FirebaseFunctions.instance); static MessagesAdapter? _instance; - Stream forChannel(Channel channel) async* { + Future subscribe(Channel channel) async { final subscribe = functions.httpsCallable('subscribe'); - subscribe({ + await subscribe({ "provider": channel.provider, "channelId": channel.channelId, }); + } + + Stream forChannel(Channel channel) async* { + await subscribe(channel); final emotes = await getEmotes(channel); var lastAdTimestamp = DateTime.now(); var lastAdMessageCount = 0;