fix(chat): keep long-press from missing in fast-moving chat
Freeze the message list on a stationary touch-and-hold (pointer-counted, touch-slop aware) so an incoming message can't shift the row out from under a long-press, while still allowing scroll to cancel the freeze. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ef1b1ded5d
commit
4fc4f90087
5 changed files with 281 additions and 74 deletions
|
|
@ -86,25 +86,59 @@ class Chat extends StatelessWidget {
|
|||
? chatTabsStore!.mergedScrollController
|
||||
: chatStore.scrollController;
|
||||
|
||||
return FrostyScrollbar(
|
||||
controller: scrollController,
|
||||
padding: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top,
|
||||
bottom:
|
||||
chatStore.bottomBarHeight + bottomPadding,
|
||||
),
|
||||
child: Observer(
|
||||
builder: (context) {
|
||||
return _isMerged
|
||||
? _buildMergedList(
|
||||
scrollController,
|
||||
bottomPadding,
|
||||
)
|
||||
: _buildNormalList(
|
||||
scrollController,
|
||||
bottomPadding,
|
||||
);
|
||||
},
|
||||
// Freeze the message list while a finger rests on a
|
||||
// message so a long-press target can't scroll out
|
||||
// from under it (which would dispose its recognizer
|
||||
// mid-press). The store resumes flowing as soon as
|
||||
// the pointer moves like a scroll, so scrolling is
|
||||
// never interrupted — only a stationary hold pauses.
|
||||
void onDown(PointerDownEvent e) => _isMerged
|
||||
? chatTabsStore!.onMessageListPointerDown(
|
||||
e.pointer,
|
||||
e.position,
|
||||
)
|
||||
: chatStore.onMessageListPointerDown(
|
||||
e.pointer,
|
||||
e.position,
|
||||
);
|
||||
void onMove(PointerMoveEvent e) => _isMerged
|
||||
? chatTabsStore!.onMessageListPointerMove(
|
||||
e.pointer,
|
||||
e.position,
|
||||
)
|
||||
: chatStore.onMessageListPointerMove(
|
||||
e.pointer,
|
||||
e.position,
|
||||
);
|
||||
void onUp(int pointer) => _isMerged
|
||||
? chatTabsStore!.onMessageListPointerUp(pointer)
|
||||
: chatStore.onMessageListPointerUp(pointer);
|
||||
|
||||
return Listener(
|
||||
onPointerDown: onDown,
|
||||
onPointerMove: onMove,
|
||||
onPointerUp: (e) => onUp(e.pointer),
|
||||
onPointerCancel: (e) => onUp(e.pointer),
|
||||
child: FrostyScrollbar(
|
||||
controller: scrollController,
|
||||
padding: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top,
|
||||
bottom:
|
||||
chatStore.bottomBarHeight + bottomPadding,
|
||||
),
|
||||
child: Observer(
|
||||
builder: (context) {
|
||||
return _isMerged
|
||||
? _buildMergedList(
|
||||
scrollController,
|
||||
bottomPadding,
|
||||
)
|
||||
: _buildNormalList(
|
||||
scrollController,
|
||||
bottomPadding,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
@ -145,19 +179,25 @@ class Chat extends StatelessWidget {
|
|||
return ListView.builder(
|
||||
reverse: true,
|
||||
padding: (listPadding ?? EdgeInsets.zero).add(
|
||||
EdgeInsets.only(
|
||||
bottom: chatStore.bottomBarHeight + bottomPadding,
|
||||
),
|
||||
EdgeInsets.only(bottom: chatStore.bottomBarHeight + bottomPadding),
|
||||
),
|
||||
addAutomaticKeepAlives: false,
|
||||
scrollCacheExtent: _chatCacheExtent,
|
||||
controller: scrollController,
|
||||
itemCount: chatStore.renderMessages.length,
|
||||
itemBuilder: (context, index) => ChatMessage(
|
||||
ircMessage: chatStore.renderMessages[
|
||||
chatStore.renderMessages.length - 1 - index],
|
||||
chatStore: chatStore,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final ircMessage = chatStore
|
||||
.renderMessages[chatStore.renderMessages.length - 1 - index];
|
||||
// Key by message identity so the list reconciles by message rather
|
||||
// than by slot. This keeps each message's element (and its gesture
|
||||
// recognizers) bound to the same message when new messages arrive
|
||||
// mid-interaction, fixing long-press landing on the wrong message.
|
||||
return ChatMessage(
|
||||
key: ObjectKey(ircMessage),
|
||||
ircMessage: ircMessage,
|
||||
chatStore: chatStore,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -166,25 +206,22 @@ class Chat extends StatelessWidget {
|
|||
double bottomPadding,
|
||||
) {
|
||||
final mergedMessages = chatTabsStore!.mergedMessages;
|
||||
final channelIdToUserTwitch =
|
||||
chatTabsStore!.mergedChannelIdToUserTwitch;
|
||||
final channelIdToUserTwitch = chatTabsStore!.mergedChannelIdToUserTwitch;
|
||||
final currentChannelId = chatTabsStore!.activeTab.channelId;
|
||||
|
||||
return ListView.builder(
|
||||
reverse: true,
|
||||
padding: (listPadding ?? EdgeInsets.zero).add(
|
||||
EdgeInsets.only(
|
||||
bottom: chatStore.bottomBarHeight + bottomPadding,
|
||||
),
|
||||
EdgeInsets.only(bottom: chatStore.bottomBarHeight + bottomPadding),
|
||||
),
|
||||
addAutomaticKeepAlives: false,
|
||||
scrollCacheExtent: _chatCacheExtent,
|
||||
controller: scrollController,
|
||||
itemCount: mergedMessages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final merged =
|
||||
mergedMessages[mergedMessages.length - 1 - index];
|
||||
final merged = mergedMessages[mergedMessages.length - 1 - index];
|
||||
return ChatMessage(
|
||||
key: ObjectKey(merged.ircMessage),
|
||||
ircMessage: merged.ircMessage,
|
||||
chatStore: merged.chatStore,
|
||||
inputChatStore: chatStore,
|
||||
|
|
@ -218,8 +255,7 @@ class Chat extends StatelessWidget {
|
|||
right: 4,
|
||||
bottom:
|
||||
chatStore.bottomBarHeight +
|
||||
(chatStore.assetsStore.showEmoteMenu ||
|
||||
isHorizontalLandscape
|
||||
(chatStore.assetsStore.showEmoteMenu || isHorizontalLandscape
|
||||
? 0
|
||||
: MediaQuery.of(context).padding.bottom),
|
||||
),
|
||||
|
|
@ -243,17 +279,13 @@ class Chat extends StatelessWidget {
|
|||
? null
|
||||
: ElevatedButton.icon(
|
||||
onPressed: onResume,
|
||||
icon: const Icon(
|
||||
Icons.arrow_downward_rounded,
|
||||
),
|
||||
icon: const Icon(Icons.arrow_downward_rounded),
|
||||
label: Text(
|
||||
bufferCount > 0
|
||||
? '$bufferCount new ${bufferCount == 1 ? 'message' : 'messages'}'
|
||||
: 'Resume scroll',
|
||||
style: const TextStyle(
|
||||
fontFeatures: [
|
||||
FontFeature.tabularFigures(),
|
||||
],
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -292,17 +324,13 @@ class Chat extends StatelessWidget {
|
|||
child: FrostyPageView(
|
||||
headers: [
|
||||
'Recent',
|
||||
if (chatStore.settings.showTwitchEmotes)
|
||||
'Twitch',
|
||||
if (chatStore.settings.showTwitchEmotes) 'Twitch',
|
||||
if (chatStore.settings.show7TVEmotes) '7TV',
|
||||
if (chatStore.settings.showBTTVEmotes) 'BTTV',
|
||||
if (chatStore.settings.showFFZEmotes) 'FFZ',
|
||||
],
|
||||
tabActions: {
|
||||
if (chatStore
|
||||
.assetsStore
|
||||
.recentEmotes
|
||||
.isNotEmpty)
|
||||
if (chatStore.assetsStore.recentEmotes.isNotEmpty)
|
||||
0: IconButton(
|
||||
onPressed: () {
|
||||
chatStore.assetsStore.recentEmotes.clear();
|
||||
|
|
@ -310,9 +338,7 @@ class Chat extends StatelessWidget {
|
|||
'Recent emotes cleared',
|
||||
);
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.delete_outline_rounded,
|
||||
),
|
||||
icon: const Icon(Icons.delete_outline_rounded),
|
||||
tooltip: 'Clear recent emotes',
|
||||
iconSize: 20,
|
||||
),
|
||||
|
|
|
|||
54
lib/screens/channel/chat/stores/chat_interaction_pause.dart
Normal file
54
lib/screens/channel/chat/stores/chat_interaction_pause.dart
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import 'package:flutter/gestures.dart';
|
||||
|
||||
/// Decides whether the chat message list should hold buffered messages back
|
||||
/// because the user is *holding* a message (a long-press candidate).
|
||||
///
|
||||
/// Flushing buffered messages grows the reverse list and pushes the pressed
|
||||
/// message up and out of the build range, which disposes its gesture recognizer
|
||||
/// mid-press and cancels the long-press. Freezing the list while a finger rests
|
||||
/// on a message keeps it under the finger so the press lands.
|
||||
///
|
||||
/// Crucially, the freeze applies *only to a stationary finger*. The moment a
|
||||
/// pointer travels past [kTouchSlop] it's a scroll, not a hold, so the chat
|
||||
/// resumes flowing — otherwise freezing then batch-releasing mid-scroll makes
|
||||
/// scrolling feel like it gets cancelled. This is the same threshold the
|
||||
/// long-press recognizer uses to tell a hold from a drag.
|
||||
///
|
||||
/// Tracks pointers individually so multi-touch resolves correctly: the chat
|
||||
/// only resumes once every stationary pointer has lifted or started moving.
|
||||
class ChatInteractionPause {
|
||||
/// Down position of each active pointer, by pointer id.
|
||||
final _origins = <int, Offset>{};
|
||||
|
||||
/// Pointers that have moved past the slop this gesture — i.e. are scrolling.
|
||||
/// Once a pointer is here it stays until it lifts, so a drag never flips back
|
||||
/// to a hold mid-gesture.
|
||||
final _scrolling = <int>{};
|
||||
|
||||
/// Whether any pointer is down and still stationary (a long-press candidate).
|
||||
bool get isPaused => _origins.length > _scrolling.length;
|
||||
|
||||
/// Register a pointer touching down at [position].
|
||||
void pointerDown(int pointer, Offset position) {
|
||||
_origins[pointer] = position;
|
||||
}
|
||||
|
||||
/// Update a pointer's position; flags it as scrolling once it passes the slop.
|
||||
void pointerMove(int pointer, Offset position) {
|
||||
final origin = _origins[pointer];
|
||||
if (origin == null) return;
|
||||
if ((position - origin).distance > kTouchSlop) _scrolling.add(pointer);
|
||||
}
|
||||
|
||||
/// Register a pointer lifting off (or being cancelled).
|
||||
void pointerUp(int pointer) {
|
||||
_origins.remove(pointer);
|
||||
_scrolling.remove(pointer);
|
||||
}
|
||||
|
||||
/// Force-clear all active pointers (e.g. on dispose or tab switch).
|
||||
void reset() {
|
||||
_origins.clear();
|
||||
_scrolling.clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import 'package:frosty/models/events.dart';
|
|||
import 'package:frosty/models/irc.dart';
|
||||
import 'package:frosty/screens/channel/chat/details/chat_details_store.dart';
|
||||
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
|
||||
import 'package:frosty/screens/channel/chat/stores/chat_interaction_pause.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
import 'package:frosty/utils.dart';
|
||||
|
|
@ -97,11 +98,11 @@ abstract class ChatStoreBase with Store {
|
|||
return parts.length >= 3 && _delayBypassCommands.contains(parts[2]);
|
||||
}
|
||||
|
||||
Duration get _chatDelay => Duration(
|
||||
milliseconds: (settings.effectiveChatDelay * 1000).round(),
|
||||
);
|
||||
Duration get _chatDelay =>
|
||||
Duration(milliseconds: (settings.effectiveChatDelay * 1000).round());
|
||||
|
||||
bool get _hasActiveChatDelay => settings.showVideo && _chatDelay > Duration.zero;
|
||||
bool get _hasActiveChatDelay =>
|
||||
settings.showVideo && _chatDelay > Duration.zero;
|
||||
|
||||
final TwitchApi twitchApi;
|
||||
|
||||
|
|
@ -254,6 +255,20 @@ abstract class ChatStoreBase with Store {
|
|||
@readonly
|
||||
var _autoScroll = true;
|
||||
|
||||
/// Freezes the rendered list while the user is touching it, so a long-press
|
||||
/// target can't scroll out from under the finger and get its gesture
|
||||
/// recognizer disposed mid-press. Plain field (not observable): it only gates
|
||||
/// the periodic [addMessages] flush, which reads it each 200ms tick.
|
||||
final _interactionPause = ChatInteractionPause();
|
||||
|
||||
/// Hooks for the message list to report touch start/move/end (see [Chat]).
|
||||
void onMessageListPointerDown(int pointer, Offset position) =>
|
||||
_interactionPause.pointerDown(pointer, position);
|
||||
void onMessageListPointerMove(int pointer, Offset position) =>
|
||||
_interactionPause.pointerMove(pointer, position);
|
||||
void onMessageListPointerUp(int pointer) =>
|
||||
_interactionPause.pointerUp(pointer);
|
||||
|
||||
@readonly
|
||||
var _inputText = '';
|
||||
|
||||
|
|
@ -363,16 +378,12 @@ abstract class ChatStoreBase with Store {
|
|||
// Normalize muted words once per settings change instead of per message
|
||||
// — the filter runs in the IRC hot path (50+ msgs/s in busy channels).
|
||||
reactions.add(
|
||||
reaction(
|
||||
(_) => settings.mutedWords.toList(),
|
||||
(List<String> words) {
|
||||
_normalizedMutedWords = words
|
||||
.map((word) => word.trim().toLowerCase())
|
||||
.where((word) => word.isNotEmpty)
|
||||
.toList();
|
||||
},
|
||||
fireImmediately: true,
|
||||
),
|
||||
reaction((_) => settings.mutedWords.toList(), (List<String> words) {
|
||||
_normalizedMutedWords = words
|
||||
.map((word) => word.trim().toLowerCase())
|
||||
.where((word) => word.isNotEmpty)
|
||||
.toList();
|
||||
}, fireImmediately: true),
|
||||
);
|
||||
|
||||
reactions.add(
|
||||
|
|
@ -604,8 +615,7 @@ abstract class ChatStoreBase with Store {
|
|||
final msg = parsedIRCMessage.message!.toLowerCase();
|
||||
final words = settings.matchWholeWord ? msg.split(' ') : null;
|
||||
final muted = _normalizedMutedWords.any(
|
||||
(word) =>
|
||||
words != null ? words.contains(word) : msg.contains(word),
|
||||
(word) => words != null ? words.contains(word) : msg.contains(word),
|
||||
);
|
||||
if (muted) continue;
|
||||
}
|
||||
|
|
@ -1159,7 +1169,11 @@ abstract class ChatStoreBase with Store {
|
|||
void addMessages() {
|
||||
_flushDueDelayedCallbacks();
|
||||
|
||||
if (!_autoScroll || messageBuffer.isEmpty) return;
|
||||
// Hold messages back while the user is touching the list so the pressed
|
||||
// message stays put (and alive) for the duration of a long-press.
|
||||
if (!_autoScroll || _interactionPause.isPaused || messageBuffer.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
_messages.addAll(messageBuffer);
|
||||
messageBuffer.clear();
|
||||
|
|
@ -1313,7 +1327,8 @@ abstract class ChatStoreBase with Store {
|
|||
: null;
|
||||
if (toSend != null) {
|
||||
// Prefer the server id; fall back to any id already set, then a local one.
|
||||
toSend!.tags['id'] = serverMessageId ??
|
||||
toSend!.tags['id'] =
|
||||
serverMessageId ??
|
||||
toSend!.tags['id'] ??
|
||||
'local-${DateTime.now().millisecondsSinceEpoch}';
|
||||
messageBuffer.add(toSend!);
|
||||
|
|
@ -1467,6 +1482,8 @@ abstract class ChatStoreBase with Store {
|
|||
void dispose() {
|
||||
_shouldDisconnect = true;
|
||||
|
||||
_interactionPause.reset();
|
||||
|
||||
_clearPendingDelayedCallbacks();
|
||||
|
||||
_messageBufferTimer?.cancel();
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import 'package:frosty/models/irc.dart';
|
|||
import 'package:frosty/models/user.dart';
|
||||
import 'package:frosty/screens/channel/chat/details/chat_details_store.dart';
|
||||
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
|
||||
import 'package:frosty/screens/channel/chat/stores/chat_interaction_pause.dart';
|
||||
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
|
|
@ -151,6 +152,19 @@ abstract class ChatTabsStoreBase with Store {
|
|||
/// Timer that drives merged message rendering at a fixed cadence.
|
||||
Timer? _mergedRenderTimer;
|
||||
|
||||
/// Freezes the rendered merged list while the user is touching it, so a
|
||||
/// long-press target can't scroll out from under the finger. Gates the
|
||||
/// periodic [_refreshMergedMessages]; see [ChatInteractionPause].
|
||||
final _interactionPause = ChatInteractionPause();
|
||||
|
||||
/// Hooks for the merged message list to report touch start/move/end (see [Chat]).
|
||||
void onMessageListPointerDown(int pointer, Offset position) =>
|
||||
_interactionPause.pointerDown(pointer, position);
|
||||
void onMessageListPointerMove(int pointer, Offset position) =>
|
||||
_interactionPause.pointerMove(pointer, position);
|
||||
void onMessageListPointerUp(int pointer) =>
|
||||
_interactionPause.pointerUp(pointer);
|
||||
|
||||
/// Frozen snapshot of merged messages when user scrolls up.
|
||||
/// Prevents new messages from causing scroll jumps while reading.
|
||||
List<MergedMessage>? _mergedSnapshot;
|
||||
|
|
@ -188,8 +202,9 @@ abstract class ChatTabsStoreBase with Store {
|
|||
int? tailCount,
|
||||
}) {
|
||||
final msgs = store.messages;
|
||||
final start =
|
||||
tailCount != null && msgs.length > tailCount ? msgs.length - tailCount : 0;
|
||||
final start = tailCount != null && msgs.length > tailCount
|
||||
? msgs.length - tailCount
|
||||
: 0;
|
||||
for (var i = start; i < msgs.length; i++) {
|
||||
final id = msgs[i].tags['id'];
|
||||
if (id != null && !seenIds.add(id)) continue;
|
||||
|
|
@ -225,7 +240,12 @@ abstract class ChatTabsStoreBase with Store {
|
|||
final seenIds = <String>{};
|
||||
for (final tab in _tabs) {
|
||||
if (tab.chatStore == null) continue;
|
||||
_collectMessages(tab.chatStore!, recent, seenIds, tailCount: _mergedRenderLimit);
|
||||
_collectMessages(
|
||||
tab.chatStore!,
|
||||
recent,
|
||||
seenIds,
|
||||
tailCount: _mergedRenderLimit,
|
||||
);
|
||||
}
|
||||
recent.sort(_compareByTimestamp);
|
||||
if (recent.length > _mergedRenderLimit) {
|
||||
|
|
@ -249,7 +269,9 @@ abstract class ChatTabsStoreBase with Store {
|
|||
/// Refreshes the rendered merged messages. Called by the render timer.
|
||||
@action
|
||||
void _refreshMergedMessages() {
|
||||
if (!_mergedAutoScroll) return;
|
||||
// Hold the rendered list still while the user is touching it so a
|
||||
// long-press target stays put for the duration of the press.
|
||||
if (!_mergedAutoScroll || _interactionPause.isPaused) return;
|
||||
_mergedRenderedMessages = _computeRecentMergedMessages();
|
||||
}
|
||||
|
||||
|
|
@ -260,7 +282,8 @@ abstract class ChatTabsStoreBase with Store {
|
|||
for (final tab in _tabs) {
|
||||
if (tab.chatStore != null) {
|
||||
total +=
|
||||
tab.chatStore!.messages.length + tab.chatStore!.messageBuffer.length;
|
||||
tab.chatStore!.messages.length +
|
||||
tab.chatStore!.messageBuffer.length;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
|
|
@ -718,6 +741,7 @@ abstract class ChatTabsStoreBase with Store {
|
|||
void dispose() {
|
||||
_mergedRenderTimer?.cancel();
|
||||
_mergedRenderTimer = null;
|
||||
_interactionPause.reset();
|
||||
for (final tab in _tabs) {
|
||||
tab.chatStore?.dispose();
|
||||
}
|
||||
|
|
|
|||
86
test/screens/channel/chat/chat_interaction_pause_test.dart
Normal file
86
test/screens/channel/chat/chat_interaction_pause_test.dart
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frosty/screens/channel/chat/stores/chat_interaction_pause.dart';
|
||||
|
||||
void main() {
|
||||
late ChatInteractionPause pause;
|
||||
|
||||
setUp(() => pause = ChatInteractionPause());
|
||||
|
||||
// A move clearly past the touch slop (a scroll), and one well within it.
|
||||
final beyondSlop = Offset(0, kTouchSlop + 10);
|
||||
final withinSlop = Offset(0, kTouchSlop - 5);
|
||||
|
||||
test('is not paused initially', () {
|
||||
expect(pause.isPaused, isFalse);
|
||||
});
|
||||
|
||||
test('is paused while a stationary pointer is down (long-press candidate)', () {
|
||||
pause.pointerDown(1, Offset.zero);
|
||||
|
||||
expect(pause.isPaused, isTrue);
|
||||
});
|
||||
|
||||
test('resumes once the pointer is lifted', () {
|
||||
pause.pointerDown(1, Offset.zero);
|
||||
pause.pointerUp(1);
|
||||
|
||||
expect(pause.isPaused, isFalse);
|
||||
});
|
||||
|
||||
test('resumes when the pointer moves beyond the touch slop (scrolling)', () {
|
||||
pause.pointerDown(1, Offset.zero);
|
||||
pause.pointerMove(1, beyondSlop);
|
||||
|
||||
// The finger is dragging — this is a scroll, not a hold, so the chat must
|
||||
// keep flowing rather than freeze.
|
||||
expect(pause.isPaused, isFalse);
|
||||
});
|
||||
|
||||
test('stays paused for tiny jitter within the touch slop', () {
|
||||
pause.pointerDown(1, Offset.zero);
|
||||
pause.pointerMove(1, withinSlop);
|
||||
|
||||
expect(pause.isPaused, isTrue);
|
||||
});
|
||||
|
||||
test('stays resumed for the rest of the gesture even if the finger drifts '
|
||||
'back within slop', () {
|
||||
pause.pointerDown(1, Offset.zero);
|
||||
pause.pointerMove(1, beyondSlop);
|
||||
pause.pointerMove(1, withinSlop);
|
||||
|
||||
// Once a gesture is recognized as a scroll it should not flip back to a
|
||||
// hold mid-drag.
|
||||
expect(pause.isPaused, isFalse);
|
||||
});
|
||||
|
||||
test('stays paused until every stationary pointer lifts (multi-touch)', () {
|
||||
pause.pointerDown(1, Offset.zero);
|
||||
pause.pointerDown(2, Offset.zero);
|
||||
pause.pointerUp(1);
|
||||
|
||||
expect(pause.isPaused, isTrue);
|
||||
|
||||
pause.pointerUp(2);
|
||||
expect(pause.isPaused, isFalse);
|
||||
});
|
||||
|
||||
test('a stray pointer-up never leaves the gate stuck', () {
|
||||
pause.pointerUp(99);
|
||||
|
||||
expect(pause.isPaused, isFalse);
|
||||
|
||||
pause.pointerDown(1, Offset.zero);
|
||||
expect(pause.isPaused, isTrue);
|
||||
});
|
||||
|
||||
test('reset clears all active pointers', () {
|
||||
pause.pointerDown(1, Offset.zero);
|
||||
pause.pointerDown(2, Offset.zero);
|
||||
|
||||
pause.reset();
|
||||
|
||||
expect(pause.isPaused, isFalse);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue