mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Fix mobile Activity thread navigation (#5850)
## Summary - Open Activity items directly in their target thread while preserving Activity as the Back destination. - Fade in a muted target-message highlight after navigation settles, hold it for three seconds, then fade it away. ## Why Activity deep links hydrated the channel before opening the thread, which left the temporary channel route in the navigation stack. The target highlight also appeared before the route settled and remained indefinitely. ## Validation - `just mobile-check` - Full Flutter test suite (1,357 tests) - Signed Profile build installed and launched on a physical iPhone --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Signed-off-by: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz> Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz> Signed-off-by: Kenny Lopez <klopez4212@gmail.com> Co-authored-by: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz> Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz> Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -220,6 +220,8 @@ class ActivityPage extends HookConsumerWidget {
|
||||
channel: channel,
|
||||
initialMessageId: target.id,
|
||||
initialThreadRootId: threadRootId,
|
||||
initialThreadRouteBehavior:
|
||||
InitialThreadRouteBehavior.replaceCurrentRoute,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -240,6 +242,8 @@ class ActivityPage extends HookConsumerWidget {
|
||||
builder: (_) => ChannelDetailPage(
|
||||
channel: channel,
|
||||
initialThreadRootId: draft.threadHeadId,
|
||||
initialThreadRouteBehavior:
|
||||
InitialThreadRouteBehavior.replaceCurrentRoute,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -123,16 +123,29 @@ int? _channelReadTimestamp({
|
||||
return dateTimeToUnixSeconds(channel.lastMessageAt);
|
||||
}
|
||||
|
||||
/// Controls how a hydrated initial thread is added to the navigation stack.
|
||||
enum InitialThreadRouteBehavior {
|
||||
/// Keep the channel route beneath the thread.
|
||||
push,
|
||||
|
||||
/// Replace the temporary channel route so Back returns to its origin.
|
||||
replaceCurrentRoute,
|
||||
}
|
||||
|
||||
class ChannelDetailPage extends HookConsumerWidget {
|
||||
final Channel channel;
|
||||
final String? initialMessageId;
|
||||
final String? initialThreadRootId;
|
||||
|
||||
/// How the automatically opened initial thread affects the route stack.
|
||||
final InitialThreadRouteBehavior initialThreadRouteBehavior;
|
||||
|
||||
const ChannelDetailPage({
|
||||
super.key,
|
||||
required this.channel,
|
||||
this.initialMessageId,
|
||||
this.initialThreadRootId,
|
||||
this.initialThreadRouteBehavior = InitialThreadRouteBehavior.push,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -453,6 +466,8 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
allMessages: messages,
|
||||
initialMessageId: initialMessageId,
|
||||
initialThreadRootId: initialThreadRootId,
|
||||
initialThreadRouteBehavior:
|
||||
initialThreadRouteBehavior,
|
||||
initialOrdinaryUnreadMessageIds:
|
||||
initialOrdinaryUnreadMessageIds,
|
||||
initialOldestOrdinaryUnreadMessageId:
|
||||
|
||||
@@ -5,6 +5,7 @@ class _MessageList extends HookConsumerWidget {
|
||||
final List<TimelineMessage> allMessages;
|
||||
final String? initialMessageId;
|
||||
final String? initialThreadRootId;
|
||||
final InitialThreadRouteBehavior initialThreadRouteBehavior;
|
||||
final Set<String> initialOrdinaryUnreadMessageIds;
|
||||
final String? initialOldestOrdinaryUnreadMessageId;
|
||||
final Set<String> initialForcedUnreadMessageIds;
|
||||
@@ -23,6 +24,7 @@ class _MessageList extends HookConsumerWidget {
|
||||
required this.allMessages,
|
||||
required this.initialMessageId,
|
||||
required this.initialThreadRootId,
|
||||
required this.initialThreadRouteBehavior,
|
||||
required this.initialOrdinaryUnreadMessageIds,
|
||||
required this.initialOldestOrdinaryUnreadMessageId,
|
||||
required this.initialForcedUnreadMessageIds,
|
||||
@@ -569,23 +571,30 @@ class _MessageList extends HookConsumerWidget {
|
||||
if (threadHead == null) return null;
|
||||
didOpenInitialThread.value = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: threadHead,
|
||||
allMessages: allMessages,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
initialMessageId: initialMessageId,
|
||||
),
|
||||
if (!context.mounted || ModalRoute.of(context)?.isCurrent != true) {
|
||||
return;
|
||||
}
|
||||
final route = MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: threadHead,
|
||||
allMessages: allMessages,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
initialMessageId: initialMessageId,
|
||||
),
|
||||
);
|
||||
final navigator = Navigator.of(context);
|
||||
switch (initialThreadRouteBehavior) {
|
||||
case InitialThreadRouteBehavior.push:
|
||||
navigator.push(route);
|
||||
case InitialThreadRouteBehavior.replaceCurrentRoute:
|
||||
navigator.pushReplacement(route);
|
||||
}
|
||||
});
|
||||
return null;
|
||||
}, [initialThreadRootId, allMessages]);
|
||||
}, [initialThreadRootId, allMessages, initialThreadRouteBehavior]);
|
||||
|
||||
useEffect(() {
|
||||
final targetIndex = reversedIndexOf(initialMessageId);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@@ -45,6 +47,11 @@ part 'thread_detail_page/tail_alignment.dart';
|
||||
part 'thread_detail_page/thread_message.dart';
|
||||
part 'thread_detail_page/avatar.dart';
|
||||
|
||||
const _landingHighlightDuration = Duration(seconds: 3);
|
||||
const _landingHighlightDelay = Duration(milliseconds: 50);
|
||||
const _landingHighlightTransitionDuration = Duration(milliseconds: 300);
|
||||
const _landingHighlightOpacity = 0.12;
|
||||
|
||||
/// Full-screen thread detail page.
|
||||
///
|
||||
/// Shows the thread head message, direct replies, typing indicators scoped to
|
||||
@@ -80,16 +87,21 @@ class ThreadDetailPage extends HookConsumerWidget {
|
||||
? appView.viewInsets.bottom / appView.devicePixelRatio
|
||||
: 0.0,
|
||||
);
|
||||
useEffect(() {
|
||||
final session = ref.read(relaySessionProvider.notifier);
|
||||
return session.registerVisibleChannel(channelId);
|
||||
}, [channelId]);
|
||||
final sendMessage = ref.read(sendMessageProvider);
|
||||
// Relay thread queries are keyed by the outermost root, even when this
|
||||
// page displays a nested branch. Query that root, then select this head's
|
||||
// direct children from the returned subtree below.
|
||||
final queryRootId = threadHead.rootId ?? threadHead.id;
|
||||
final repliesState = ref.watch(
|
||||
threadRepliesWithLocalProvider(
|
||||
ThreadRepliesArgs(channelId: channelId, rootId: queryRootId),
|
||||
),
|
||||
final repliesArgs = ThreadRepliesArgs(
|
||||
channelId: channelId,
|
||||
rootId: queryRootId,
|
||||
);
|
||||
final relayReplyState = ref.watch(threadRepliesProvider(repliesArgs));
|
||||
final repliesState = ref.watch(threadRepliesWithLocalProvider(repliesArgs));
|
||||
// The thread query is one-shot and asks only for content kinds, so a
|
||||
// reaction, edit, or deletion that lands while the thread is open never
|
||||
// reaches it — a new pill (and its burst) only showed up after leaving and
|
||||
@@ -106,6 +118,12 @@ class ThreadDetailPage extends HookConsumerWidget {
|
||||
});
|
||||
|
||||
final fetchedReplies = replyMessages.value;
|
||||
// A terminal query error cannot produce a more authoritative list. Keep
|
||||
// loading states provisional, but let the hydrated route snapshot drive
|
||||
// the one-shot target jump when the relay query has definitively failed.
|
||||
final canUseMessagesForInitialTarget =
|
||||
relayReplyState.value != null ||
|
||||
(relayReplyState.hasError && !relayReplyState.retrying);
|
||||
final liveDeletionHidesHead = _isDeletedBy(
|
||||
liveChannelEvents,
|
||||
threadHead.id,
|
||||
@@ -122,6 +140,68 @@ class ThreadDetailPage extends HookConsumerWidget {
|
||||
threadHead,
|
||||
...fetchedReplies,
|
||||
];
|
||||
final routeAnimation = ModalRoute.of(context)?.animation;
|
||||
final reducedLandingHighlightMotion = MediaQuery.disableAnimationsOf(
|
||||
context,
|
||||
);
|
||||
final highlightedMessageId = useState<String?>(null);
|
||||
final initialTargetReadyForHighlight = useState(false);
|
||||
useEffect(
|
||||
() {
|
||||
final messageId = initialMessageId;
|
||||
if (messageId == null || !initialTargetReadyForHighlight.value) {
|
||||
return null;
|
||||
}
|
||||
var disposed = false;
|
||||
Timer? revealTimer;
|
||||
Timer? dismissTimer;
|
||||
|
||||
void revealHighlight() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (disposed) return;
|
||||
revealTimer = Timer(_landingHighlightDelay, () {
|
||||
if (disposed) return;
|
||||
highlightedMessageId.value = messageId;
|
||||
dismissTimer = Timer(
|
||||
_landingHighlightDuration +
|
||||
(reducedLandingHighlightMotion
|
||||
? Duration.zero
|
||||
: _landingHighlightTransitionDuration),
|
||||
() {
|
||||
if (!disposed) highlightedMessageId.value = null;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void handleRouteStatus(AnimationStatus status) {
|
||||
if (status != AnimationStatus.completed) return;
|
||||
routeAnimation?.removeStatusListener(handleRouteStatus);
|
||||
revealHighlight();
|
||||
}
|
||||
|
||||
if (routeAnimation == null ||
|
||||
routeAnimation.status == AnimationStatus.completed) {
|
||||
revealHighlight();
|
||||
} else {
|
||||
routeAnimation.addStatusListener(handleRouteStatus);
|
||||
}
|
||||
|
||||
return () {
|
||||
disposed = true;
|
||||
routeAnimation?.removeStatusListener(handleRouteStatus);
|
||||
revealTimer?.cancel();
|
||||
dismissTimer?.cancel();
|
||||
};
|
||||
},
|
||||
[
|
||||
initialMessageId,
|
||||
initialTargetReadyForHighlight.value,
|
||||
reducedLandingHighlightMotion,
|
||||
routeAnimation,
|
||||
],
|
||||
);
|
||||
|
||||
// Index all messages by parentId so we can find direct children of any
|
||||
// message and compute thread summaries for nested threads.
|
||||
@@ -138,6 +218,7 @@ class ThreadDetailPage extends HookConsumerWidget {
|
||||
final listViewport = useMemoized(LaidOutViewport.new);
|
||||
useEffect(() => listViewport.dispose, [listViewport]);
|
||||
final didJumpToInitialMessage = useRef(false);
|
||||
final initialHighlightTargetIndex = useState<int?>(null);
|
||||
final followsThreadTail = useRef(false);
|
||||
final userOptedOutOfTailFollow = useRef(false);
|
||||
final userDragDetachedTailFollow = useRef(false);
|
||||
@@ -271,42 +352,84 @@ class ThreadDetailPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
final messageId = initialMessageId;
|
||||
// Wait for the authoritative thread query before consuming the one-shot
|
||||
// jump; the fallback main-timeline list can contain only the linked reply.
|
||||
if (messageId == null || fetchedReplies == null) return null;
|
||||
final chronologicalIndex = replies.indexWhere(
|
||||
(reply) => reply.id == messageId,
|
||||
);
|
||||
final targetIndex = messageId == threadHead.id
|
||||
? headIndex
|
||||
: chronologicalIndex < 0
|
||||
? null
|
||||
: indexForReply(chronologicalIndex);
|
||||
if (targetIndex == null || didJumpToInitialMessage.value) return null;
|
||||
didJumpToInitialMessage.value = true;
|
||||
initialTailSettle.abandon();
|
||||
userOptedOutOfTailFollow.value = true;
|
||||
userDragDetachedTailFollow.value = false;
|
||||
tailIntent.schedule(
|
||||
allowed: true,
|
||||
revalidate: () =>
|
||||
context.mounted &&
|
||||
itemScrollController.isAttached &&
|
||||
!tailIntent.isDragging,
|
||||
action: () {
|
||||
// The provisional route snapshot can make the linked reply look like
|
||||
// the tail. This authoritative deep-link jump intentionally leaves
|
||||
// the user at an older item, so it must opt out of follow-tail first.
|
||||
tailIntent.detach();
|
||||
followsThreadTail.value = false;
|
||||
isAtThreadTail.value = false;
|
||||
itemScrollController.jumpTo(index: targetIndex, alignment: 0.35);
|
||||
},
|
||||
);
|
||||
return null;
|
||||
}, [initialMessageId, fetchedReplies, replies.length]);
|
||||
useEffect(
|
||||
() {
|
||||
final messageId = initialMessageId;
|
||||
// Wait for either the authoritative thread query or a terminal query
|
||||
// error before consuming the one-shot jump. During loading, the fallback
|
||||
// main-timeline list can contain only the linked reply; after an error,
|
||||
// that hydrated snapshot is the best available target list.
|
||||
if (messageId == null || !canUseMessagesForInitialTarget) return null;
|
||||
final chronologicalIndex = replies.indexWhere(
|
||||
(reply) => reply.id == messageId,
|
||||
);
|
||||
final targetIndex = messageId == threadHead.id
|
||||
? headIndex
|
||||
: chronologicalIndex < 0
|
||||
? null
|
||||
: indexForReply(chronologicalIndex);
|
||||
if (targetIndex == null || didJumpToInitialMessage.value) return null;
|
||||
didJumpToInitialMessage.value = true;
|
||||
initialTailSettle.abandon();
|
||||
userOptedOutOfTailFollow.value = true;
|
||||
userDragDetachedTailFollow.value = false;
|
||||
tailIntent.schedule(
|
||||
allowed: true,
|
||||
revalidate: () =>
|
||||
context.mounted &&
|
||||
itemScrollController.isAttached &&
|
||||
!tailIntent.isDragging,
|
||||
action: () {
|
||||
// The provisional route snapshot can make the linked reply look like
|
||||
// the tail. This authoritative deep-link jump intentionally leaves
|
||||
// the user at an older item, so it must opt out of follow-tail first.
|
||||
tailIntent.detach();
|
||||
followsThreadTail.value = false;
|
||||
isAtThreadTail.value = false;
|
||||
itemScrollController.jumpTo(index: targetIndex, alignment: 0.35);
|
||||
initialHighlightTargetIndex.value = targetIndex;
|
||||
},
|
||||
);
|
||||
return null;
|
||||
},
|
||||
[
|
||||
initialMessageId,
|
||||
canUseMessagesForInitialTarget,
|
||||
fetchedReplies,
|
||||
replies.length,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
final targetIndex = initialHighlightTargetIndex.value;
|
||||
if (targetIndex == null || initialTargetReadyForHighlight.value) {
|
||||
return null;
|
||||
}
|
||||
var completionScheduled = false;
|
||||
void markReadyAfterTargetLayout() {
|
||||
if (completionScheduled ||
|
||||
!itemPositionsListener.itemPositions.value.any(
|
||||
(position) => position.index == targetIndex,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
completionScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted) initialTargetReadyForHighlight.value = true;
|
||||
});
|
||||
}
|
||||
|
||||
itemPositionsListener.itemPositions.addListener(
|
||||
markReadyAfterTargetLayout,
|
||||
);
|
||||
markReadyAfterTargetLayout();
|
||||
return () => itemPositionsListener.itemPositions.removeListener(
|
||||
markReadyAfterTargetLayout,
|
||||
);
|
||||
},
|
||||
[initialHighlightTargetIndex.value, initialTargetReadyForHighlight.value],
|
||||
);
|
||||
|
||||
// A top-anchored list doesn't stick to the newest item the way the old
|
||||
// reversed one did, so follow the tail explicitly: when a reply arrives
|
||||
@@ -633,7 +756,7 @@ class ThreadDetailPage extends HookConsumerWidget {
|
||||
currentPubkey: currentPubkey,
|
||||
showAuthor: true,
|
||||
isHighlighted:
|
||||
liveHead.id == initialMessageId,
|
||||
liveHead.id == highlightedMessageId.value,
|
||||
allMessages: allMsgs,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
@@ -716,7 +839,8 @@ class ThreadDetailPage extends HookConsumerWidget {
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
showAuthor: showAuthor,
|
||||
isHighlighted: reply.id == initialMessageId,
|
||||
isHighlighted:
|
||||
reply.id == highlightedMessageId.value,
|
||||
allMessages: allMsgs,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
|
||||
@@ -92,14 +92,37 @@ class _ThreadMessage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
final reducedMotion = MediaQuery.disableAnimationsOf(context);
|
||||
final highlightController = useAnimationController(
|
||||
duration: _landingHighlightTransitionDuration,
|
||||
);
|
||||
final highlightProgress = useAnimation(highlightController);
|
||||
useEffect(() {
|
||||
if (reducedMotion) {
|
||||
highlightController.value = isHighlighted ? 1 : 0;
|
||||
} else {
|
||||
unawaited(
|
||||
highlightController.animateTo(
|
||||
isHighlighted ? 1 : 0,
|
||||
duration: _landingHighlightTransitionDuration,
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}, [highlightController, isHighlighted, reducedMotion]);
|
||||
final highlightColor = highlightProgress == 0
|
||||
? Colors.transparent
|
||||
: context.colors.primary.withValues(
|
||||
alpha: _landingHighlightOpacity * highlightProgress,
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0),
|
||||
child: DecoratedBox(
|
||||
key: ValueKey('thread-message-${message.id}'),
|
||||
decoration: BoxDecoration(
|
||||
color: isHighlighted
|
||||
? context.colors.primary.withValues(alpha: 0.12)
|
||||
: Colors.transparent,
|
||||
color: highlightColor,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
),
|
||||
child: Material(
|
||||
|
||||
@@ -385,10 +385,9 @@ class BeeRefreshIndicator extends HookConsumerWidget {
|
||||
width: _beeWidth,
|
||||
color: context.colors.primary,
|
||||
flapAmount: flapAmount,
|
||||
eyeProgress:
|
||||
!isLoading &&
|
||||
!showEyeEmoji &&
|
||||
pupilProgress > 0
|
||||
eyeProgress: showEyeEmoji
|
||||
? 1
|
||||
: !isLoading && pupilProgress > 0
|
||||
? pupilProgress
|
||||
: null,
|
||||
),
|
||||
|
||||
@@ -137,7 +137,9 @@ class _FlappingBeePainter extends CustomPainter {
|
||||
canvas.drawPath(finishedMark, Paint()..color = color);
|
||||
|
||||
if (eyeProgress case final progress?) {
|
||||
final pupilRadius = 20 * progress.clamp(0.0, 1.0);
|
||||
// The eye cutouts are 54px wide. A full pupil must reach their 27px
|
||||
// radius so the emoji-eye overlay never exposes the background beneath.
|
||||
final pupilRadius = 27 * progress.clamp(0.0, 1.0);
|
||||
final pupilPaint = Paint()..color = color;
|
||||
canvas
|
||||
..drawCircle(const Offset(193.3, 84.4), pupilRadius, pupilPaint)
|
||||
|
||||
@@ -684,6 +684,10 @@ void main() {
|
||||
expect(page.channel.id, 'ch1');
|
||||
expect(page.initialThreadRootId, 'parent-reply');
|
||||
expect(page.initialMessageId, 'reply-event');
|
||||
expect(
|
||||
page.initialThreadRouteBehavior,
|
||||
InitialThreadRouteBehavior.replaceCurrentRoute,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('thread filter matches grouped thread replies', (tester) async {
|
||||
|
||||
@@ -187,12 +187,20 @@ Widget _buildTestable({
|
||||
String? canvasContent,
|
||||
String? initialMessageId,
|
||||
String? initialThreadRootId,
|
||||
InitialThreadRouteBehavior initialThreadRouteBehavior =
|
||||
InitialThreadRouteBehavior.push,
|
||||
Map<String, List<NostrEvent>> threadReplies = const {},
|
||||
Map<String, Future<List<NostrEvent>>> pendingThreadReplies = const {},
|
||||
Map<String, Future<List<NostrEvent>> Function()> threadReplyLoaders =
|
||||
const {},
|
||||
Map<String, List<NostrEvent>> localThreadReplies = const {},
|
||||
TextScaler textScaler = TextScaler.noScaling,
|
||||
bool disableAnimations = false,
|
||||
bool disableRetries = false,
|
||||
Duration? Function(int retryCount, Object error)? providerRetry,
|
||||
RelaySessionNotifier? relaySessionNotifier,
|
||||
http.Client? mediaClient,
|
||||
Widget? home,
|
||||
}) {
|
||||
final resolvedChannel = channel ?? _testChannel;
|
||||
final fakeChannelsNotifier =
|
||||
@@ -200,6 +208,7 @@ Widget _buildTestable({
|
||||
final fakeMessagesNotifier =
|
||||
messagesNotifier ?? _FakeMessagesNotifier(messages);
|
||||
return ProviderScope(
|
||||
retry: providerRetry ?? (disableRetries ? (_, _) => null : null),
|
||||
overrides: [
|
||||
channelMessagesProvider(
|
||||
_channelId,
|
||||
@@ -240,6 +249,19 @@ Widget _buildTestable({
|
||||
threadRepliesProvider(
|
||||
ThreadRepliesArgs(channelId: _channelId, rootId: entry.key),
|
||||
).overrideWith((ref) => entry.value),
|
||||
for (final entry in threadReplyLoaders.entries)
|
||||
threadRepliesProvider(
|
||||
ThreadRepliesArgs(channelId: _channelId, rootId: entry.key),
|
||||
).overrideWith((ref) => entry.value()),
|
||||
for (final entry in localThreadReplies.entries)
|
||||
threadLocalRepliesProvider(
|
||||
ThreadRepliesArgs(channelId: _channelId, rootId: entry.key),
|
||||
).overrideWith(
|
||||
() => _FakeThreadLocalRepliesNotifier(
|
||||
ThreadRepliesArgs(channelId: _channelId, rootId: entry.key),
|
||||
entry.value,
|
||||
),
|
||||
),
|
||||
// Stub the relay client provider so preloadMembers doesn't crash.
|
||||
relayClientProvider.overrideWithValue(
|
||||
RelayClient(baseUrl: 'http://localhost:3000'),
|
||||
@@ -265,11 +287,14 @@ Widget _buildTestable({
|
||||
child: child!,
|
||||
),
|
||||
navigatorObservers: navigatorObservers,
|
||||
home: ChannelDetailPage(
|
||||
channel: resolvedChannel,
|
||||
initialMessageId: initialMessageId,
|
||||
initialThreadRootId: initialThreadRootId,
|
||||
),
|
||||
home:
|
||||
home ??
|
||||
ChannelDetailPage(
|
||||
channel: resolvedChannel,
|
||||
initialMessageId: initialMessageId,
|
||||
initialThreadRootId: initialThreadRootId,
|
||||
initialThreadRouteBehavior: initialThreadRouteBehavior,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -4434,6 +4459,372 @@ void main() {
|
||||
});
|
||||
|
||||
group('Deep-link navigation', () {
|
||||
testWidgets('fades the target highlight in after the thread route lands', (
|
||||
tester,
|
||||
) async {
|
||||
final root = _textMsg(
|
||||
id: 'root',
|
||||
pubkey: 'alice',
|
||||
content: 'Thread root',
|
||||
createdAt: 1000,
|
||||
);
|
||||
final target = _textMsg(
|
||||
id: 'target',
|
||||
pubkey: 'bob',
|
||||
content: 'Target reply',
|
||||
createdAt: 1100,
|
||||
extraTags: const [
|
||||
['e', 'root', '', 'reply'],
|
||||
],
|
||||
);
|
||||
final timelineMessages = formatTimeline([root, target]);
|
||||
final threadHead = timelineMessages.firstWhere(
|
||||
(message) => message.id == root.id,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [root, target],
|
||||
threadReplies: {
|
||||
'root': [target],
|
||||
},
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
|
||||
},
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: Center(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: threadHead,
|
||||
allMessages: timelineMessages,
|
||||
channelId: _testChannel.id,
|
||||
currentPubkey: null,
|
||||
isMember: true,
|
||||
isArchived: false,
|
||||
initialMessageId: 'target',
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Text('Open highlighted thread'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Open highlighted thread'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 1));
|
||||
|
||||
final threadRoute =
|
||||
ModalRoute.of(tester.element(find.byType(ThreadDetailPage)))!
|
||||
as MaterialPageRoute<void>;
|
||||
expect(threadRoute.animation!.status, AnimationStatus.forward);
|
||||
final transitionDecoration =
|
||||
tester
|
||||
.widget<DecoratedBox>(
|
||||
find.byKey(const ValueKey('thread-message-target')),
|
||||
)
|
||||
.decoration
|
||||
as BoxDecoration;
|
||||
expect(transitionDecoration.color, Colors.transparent);
|
||||
|
||||
await tester.pump(threadRoute.transitionDuration);
|
||||
expect(threadRoute.animation!.status, AnimationStatus.completed);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
final landedDecoration =
|
||||
tester
|
||||
.widget<DecoratedBox>(
|
||||
find.byKey(const ValueKey('thread-message-target')),
|
||||
)
|
||||
.decoration
|
||||
as BoxDecoration;
|
||||
expect(landedDecoration.color, Colors.transparent);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
await tester.pump(const Duration(milliseconds: 150));
|
||||
final enteringDecoration =
|
||||
tester
|
||||
.widget<DecoratedBox>(
|
||||
find.byKey(const ValueKey('thread-message-target')),
|
||||
)
|
||||
.decoration
|
||||
as BoxDecoration;
|
||||
expect(enteringDecoration.color!.a, greaterThan(0));
|
||||
expect(enteringDecoration.color!.a, lessThan(0.12));
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 150));
|
||||
final visibleDecoration =
|
||||
tester
|
||||
.widget<DecoratedBox>(
|
||||
find.byKey(const ValueKey('thread-message-target')),
|
||||
)
|
||||
.decoration
|
||||
as BoxDecoration;
|
||||
expect(visibleDecoration.color!.a, closeTo(0.12, 0.001));
|
||||
});
|
||||
|
||||
testWidgets('waits for a delayed target jump before highlighting', (
|
||||
tester,
|
||||
) async {
|
||||
final root = _textMsg(
|
||||
id: 'root',
|
||||
pubkey: 'alice',
|
||||
content: 'Thread root',
|
||||
createdAt: 1000,
|
||||
);
|
||||
final replies = [
|
||||
for (var i = 0; i < 40; i++)
|
||||
_textMsg(
|
||||
id: 'reply-$i',
|
||||
pubkey: 'bob',
|
||||
content: 'Reply $i',
|
||||
createdAt: 1100 + i,
|
||||
extraTags: const [
|
||||
['e', 'root', '', 'reply'],
|
||||
],
|
||||
),
|
||||
];
|
||||
final timelineMessages = formatTimeline([root, ...replies]);
|
||||
final threadHead = timelineMessages.first;
|
||||
final replyCompleter = Completer<List<NostrEvent>>();
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [root],
|
||||
pendingThreadReplies: {'root': replyCompleter.future},
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
|
||||
},
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: Center(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: threadHead,
|
||||
allMessages: timelineMessages,
|
||||
channelId: _testChannel.id,
|
||||
currentPubkey: null,
|
||||
isMember: true,
|
||||
isArchived: false,
|
||||
initialMessageId: 'reply-30',
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Text('Open delayed thread'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Open delayed thread'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(seconds: 4));
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('thread-message-group-reply-30')),
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
replyCompleter.complete(replies);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final target = find.byKey(const ValueKey('thread-message-reply-30'));
|
||||
expect(target, findsOneWidget);
|
||||
final landedDecoration =
|
||||
tester.widget<DecoratedBox>(target).decoration as BoxDecoration;
|
||||
expect(landedDecoration.color, Colors.transparent);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
await tester.pump(const Duration(milliseconds: 150));
|
||||
final enteringDecoration =
|
||||
tester.widget<DecoratedBox>(target).decoration as BoxDecoration;
|
||||
expect(enteringDecoration.color!.a, greaterThan(0));
|
||||
expect(enteringDecoration.color!.a, lessThan(0.12));
|
||||
});
|
||||
|
||||
testWidgets('waits for a retry before jumping to a hydrated target', (
|
||||
tester,
|
||||
) async {
|
||||
final root = _textMsg(
|
||||
id: 'root',
|
||||
pubkey: 'alice',
|
||||
content: 'Thread root',
|
||||
createdAt: 1000,
|
||||
);
|
||||
final target = _textMsg(
|
||||
id: 'target',
|
||||
pubkey: 'bob',
|
||||
content: 'Hydrated target',
|
||||
createdAt: 1400,
|
||||
extraTags: const [
|
||||
['e', 'root', '', 'reply'],
|
||||
],
|
||||
);
|
||||
final earlierReplies = [
|
||||
for (var i = 0; i < 30; i++)
|
||||
_textMsg(
|
||||
id: 'reply-$i',
|
||||
pubkey: 'bob',
|
||||
content: 'Reply $i',
|
||||
createdAt: 1100 + i,
|
||||
extraTags: const [
|
||||
['e', 'root', '', 'reply'],
|
||||
],
|
||||
),
|
||||
];
|
||||
final timelineMessages = formatTimeline([root, target]);
|
||||
final firstAttempt = Completer<List<NostrEvent>>();
|
||||
var attempts = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [root, target],
|
||||
providerRetry: (retryCount, _) =>
|
||||
retryCount == 0 ? const Duration(seconds: 30) : null,
|
||||
localThreadReplies: {
|
||||
'root': [target],
|
||||
},
|
||||
threadReplyLoaders: {
|
||||
'root': () {
|
||||
attempts++;
|
||||
if (attempts == 1) return firstAttempt.future;
|
||||
return Future.value([...earlierReplies, target]);
|
||||
},
|
||||
},
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
|
||||
},
|
||||
home: ThreadDetailPage(
|
||||
threadHead: timelineMessages.first,
|
||||
allMessages: timelineMessages,
|
||||
channelId: _testChannel.id,
|
||||
currentPubkey: null,
|
||||
isMember: true,
|
||||
isArchived: false,
|
||||
initialMessageId: 'target',
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
firstAttempt.completeError(Exception('transient thread query failure'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
final targetFinder = find.byKey(const ValueKey('thread-message-target'));
|
||||
expect(targetFinder, findsOneWidget);
|
||||
final retryingDecoration =
|
||||
tester.widget<DecoratedBox>(targetFinder).decoration as BoxDecoration;
|
||||
expect(retryingDecoration.color, Colors.transparent);
|
||||
expect(attempts, 1);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
await tester.pump(const Duration(milliseconds: 150));
|
||||
final stillRetryingDecoration =
|
||||
tester.widget<DecoratedBox>(targetFinder).decoration as BoxDecoration;
|
||||
expect(stillRetryingDecoration.color, Colors.transparent);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 2800));
|
||||
expect(attempts, 1);
|
||||
final expiredJumpDecoration =
|
||||
tester.widget<DecoratedBox>(targetFinder).decoration as BoxDecoration;
|
||||
expect(expiredJumpDecoration.color, Colors.transparent);
|
||||
|
||||
await tester.pump(const Duration(seconds: 30));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(attempts, 2);
|
||||
expect(
|
||||
find.byKey(const ValueKey('thread-message-group-target')),
|
||||
findsOneWidget,
|
||||
);
|
||||
final landedDecoration =
|
||||
tester.widget<DecoratedBox>(targetFinder).decoration as BoxDecoration;
|
||||
expect(landedDecoration.color, Colors.transparent);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
await tester.pump(const Duration(milliseconds: 150));
|
||||
final highlightedDecoration =
|
||||
tester.widget<DecoratedBox>(targetFinder).decoration as BoxDecoration;
|
||||
expect(highlightedDecoration.color!.a, greaterThan(0));
|
||||
});
|
||||
|
||||
testWidgets('highlights a hydrated target after the thread query fails', (
|
||||
tester,
|
||||
) async {
|
||||
final root = _textMsg(
|
||||
id: 'root',
|
||||
pubkey: 'alice',
|
||||
content: 'Thread root',
|
||||
createdAt: 1000,
|
||||
);
|
||||
final target = _textMsg(
|
||||
id: 'target',
|
||||
pubkey: 'bob',
|
||||
content: 'Hydrated target',
|
||||
createdAt: 1100,
|
||||
extraTags: const [
|
||||
['e', 'root', '', 'reply'],
|
||||
],
|
||||
);
|
||||
final timelineMessages = formatTimeline([root, target]);
|
||||
final replyCompleter = Completer<List<NostrEvent>>();
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [root, target],
|
||||
pendingThreadReplies: {'root': replyCompleter.future},
|
||||
disableRetries: true,
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
|
||||
},
|
||||
home: ThreadDetailPage(
|
||||
threadHead: timelineMessages.first,
|
||||
allMessages: timelineMessages,
|
||||
channelId: _testChannel.id,
|
||||
currentPubkey: null,
|
||||
isMember: true,
|
||||
isArchived: false,
|
||||
initialMessageId: 'target',
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final targetFinder = find.byKey(const ValueKey('thread-message-target'));
|
||||
expect(targetFinder, findsOneWidget);
|
||||
final loadingDecoration =
|
||||
tester.widget<DecoratedBox>(targetFinder).decoration as BoxDecoration;
|
||||
expect(loadingDecoration.color, Colors.transparent);
|
||||
|
||||
replyCompleter.completeError(Exception('thread query failed'));
|
||||
for (var i = 0; i < 8; i++) {
|
||||
await tester.pump();
|
||||
}
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
await tester.pump(const Duration(milliseconds: 150));
|
||||
|
||||
final highlightedDecoration =
|
||||
tester.widget<DecoratedBox>(targetFinder).decoration as BoxDecoration;
|
||||
expect(highlightedDecoration.color!.a, greaterThan(0));
|
||||
expect(highlightedDecoration.color!.a, lessThan(0.12));
|
||||
});
|
||||
|
||||
testWidgets('opens a nested reply in its direct-parent thread', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -4492,7 +4883,147 @@ void main() {
|
||||
find.byKey(const ValueKey('thread-message-target')),
|
||||
);
|
||||
final decoration = highlighted.decoration as BoxDecoration;
|
||||
expect(decoration.color, isNot(Colors.transparent));
|
||||
final initialHighlight = decoration.color!;
|
||||
expect(initialHighlight, isNot(Colors.transparent));
|
||||
expect(initialHighlight.a, closeTo(0.12, 0.001));
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 2999));
|
||||
final heldDecoration =
|
||||
tester
|
||||
.widget<DecoratedBox>(
|
||||
find.byKey(const ValueKey('thread-message-target')),
|
||||
)
|
||||
.decoration
|
||||
as BoxDecoration;
|
||||
expect(heldDecoration.color, initialHighlight);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 1));
|
||||
await tester.pump(const Duration(milliseconds: 150));
|
||||
|
||||
final fadingDecoration =
|
||||
tester
|
||||
.widget<DecoratedBox>(
|
||||
find.byKey(const ValueKey('thread-message-target')),
|
||||
)
|
||||
.decoration
|
||||
as BoxDecoration;
|
||||
expect(fadingDecoration.color!.a, greaterThan(0));
|
||||
expect(fadingDecoration.color!.a, lessThan(initialHighlight.a));
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 150));
|
||||
final dismissedDecoration =
|
||||
tester
|
||||
.widget<DecoratedBox>(
|
||||
find.byKey(const ValueKey('thread-message-target')),
|
||||
)
|
||||
.decoration
|
||||
as BoxDecoration;
|
||||
expect(dismissedDecoration.color, Colors.transparent);
|
||||
});
|
||||
|
||||
testWidgets('does not replace a newer route after delayed hydration', (
|
||||
tester,
|
||||
) async {
|
||||
final root = _textMsg(
|
||||
id: 'root',
|
||||
pubkey: 'alice',
|
||||
content: 'Thread root',
|
||||
);
|
||||
final messagesNotifier = _FakeMessagesNotifier(const []);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: const [],
|
||||
messagesNotifier: messagesNotifier,
|
||||
initialThreadRootId: 'root',
|
||||
initialThreadRouteBehavior:
|
||||
InitialThreadRouteBehavior.replaceCurrentRoute,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final navigator = Navigator.of(
|
||||
tester.element(find.byType(ChannelDetailPage)),
|
||||
);
|
||||
messagesNotifier.setMessages([root]);
|
||||
navigator.push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const Scaffold(body: Text('New destination')),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('New destination'), findsOneWidget);
|
||||
expect(find.byType(ThreadDetailPage), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('replaces a temporary channel route for an initial thread', (
|
||||
tester,
|
||||
) async {
|
||||
final root = _textMsg(
|
||||
id: 'root',
|
||||
pubkey: 'alice',
|
||||
content: 'Thread root',
|
||||
);
|
||||
final target = _textMsg(
|
||||
id: 'target',
|
||||
pubkey: 'bob',
|
||||
content: 'Target reply',
|
||||
createdAt: 1100,
|
||||
extraTags: const [
|
||||
['e', 'root', '', 'reply'],
|
||||
],
|
||||
);
|
||||
final relaySession = _TrackingRelaySession();
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [root, target],
|
||||
relaySessionNotifier: relaySession,
|
||||
threadReplies: {
|
||||
'root': [target],
|
||||
},
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
|
||||
},
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: Center(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ChannelDetailPage(
|
||||
channel: _testChannel,
|
||||
initialMessageId: 'target',
|
||||
initialThreadRootId: 'root',
|
||||
initialThreadRouteBehavior:
|
||||
InitialThreadRouteBehavior.replaceCurrentRoute,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Text('Open activity thread'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Open activity thread'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(ThreadDetailPage), findsOneWidget);
|
||||
expect(find.byType(ChannelDetailPage), findsNothing);
|
||||
expect(relaySession.visibleChannels, [_testChannel.id]);
|
||||
|
||||
await tester.pageBack();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Open activity thread'), findsOneWidget);
|
||||
expect(find.byType(ChannelDetailPage), findsNothing);
|
||||
expect(relaySession.visibleChannels, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7292,6 +7823,15 @@ Channel _channel({required String id, required String name}) => Channel(
|
||||
isMember: true,
|
||||
);
|
||||
|
||||
class _FakeThreadLocalRepliesNotifier extends ThreadLocalRepliesNotifier {
|
||||
final List<NostrEvent> _replies;
|
||||
|
||||
_FakeThreadLocalRepliesNotifier(super.args, this._replies);
|
||||
|
||||
@override
|
||||
List<NostrEvent> build() => _replies;
|
||||
}
|
||||
|
||||
class _FakeMessagesNotifier extends ChannelMessagesNotifier {
|
||||
List<NostrEvent> _messages;
|
||||
bool _hasLoadedMessages;
|
||||
@@ -7343,6 +7883,27 @@ class _ErrorMessagesNotifier extends ChannelMessagesNotifier {
|
||||
AsyncError('Connection failed', StackTrace.current);
|
||||
}
|
||||
|
||||
class _TrackingRelaySession extends RelaySessionNotifier {
|
||||
final visibleChannels = <String>[];
|
||||
|
||||
@override
|
||||
SessionState build() =>
|
||||
const SessionState(status: SessionStatus.disconnected);
|
||||
|
||||
@override
|
||||
void Function() registerVisibleChannel(String channelId) {
|
||||
final release = super.registerVisibleChannel(channelId);
|
||||
visibleChannels.add(channelId);
|
||||
var released = false;
|
||||
return () {
|
||||
if (released) return;
|
||||
released = true;
|
||||
visibleChannels.remove(channelId);
|
||||
release();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _ReconnectingRelaySession extends RelaySessionNotifier {
|
||||
@override
|
||||
SessionState build() =>
|
||||
|
||||
@@ -1,14 +1,56 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:buzz/shared/widgets/bee_refresh_indicator.dart';
|
||||
import 'package:buzz/shared/widgets/flapping_bee.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/widget_helpers.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('full eye progress fills each eye cutout to its edge', (
|
||||
tester,
|
||||
) async {
|
||||
const beeKey = ValueKey('full-eye-bee');
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Center(
|
||||
child: FlappingBee(
|
||||
key: beeKey,
|
||||
width: 466,
|
||||
color: Colors.black,
|
||||
flapAmount: 0,
|
||||
eyeProgress: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final boundary = tester.renderObject<RenderRepaintBoundary>(
|
||||
find.descendant(
|
||||
of: find.byKey(beeKey),
|
||||
matching: find.byType(RepaintBoundary),
|
||||
),
|
||||
);
|
||||
final bytes = await tester.runAsync(() async {
|
||||
final image = await boundary.toImage();
|
||||
final data = await image.toByteData(format: ui.ImageByteFormat.rawRgba);
|
||||
image.dispose();
|
||||
return data;
|
||||
});
|
||||
expect(bytes, isNotNull);
|
||||
|
||||
int alphaAt(int x, int y) => bytes!.getUint8(((y * 466) + x) * 4 + 3);
|
||||
|
||||
// These points sit inside the 27px eye cutouts but outside the old 20px
|
||||
// pupil radius, directly covering the light rings seen behind the emoji.
|
||||
expect(alphaAt(217, 84), 255);
|
||||
expect(alphaAt(300, 84), 255);
|
||||
});
|
||||
|
||||
testWidgets('shows the bee while pulling to refresh', (tester) async {
|
||||
const contentKey = ValueKey('loading-content');
|
||||
var refreshes = 0;
|
||||
@@ -190,7 +232,7 @@ void main() {
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(tester.widget<FlappingBee>(beeFinder).eyeProgress, isNull);
|
||||
expect(tester.widget<FlappingBee>(beeFinder).eyeProgress, 1);
|
||||
expect(
|
||||
find.byKey(const ValueKey('bee-refresh-eyes-emoji')),
|
||||
findsOneWidget,
|
||||
@@ -239,6 +281,7 @@ void main() {
|
||||
find.byKey(const ValueKey('bee-refresh-eyes-emoji')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(tester.widget<FlappingBee>(beeFinder).eyeProgress, 1);
|
||||
expect(hapticCalls, hasLength(3));
|
||||
|
||||
await secondGesture.moveBy(
|
||||
@@ -250,6 +293,7 @@ void main() {
|
||||
find.byKey(const ValueKey('bee-refresh-eyes-emoji')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(tester.widget<FlappingBee>(beeFinder).eyeProgress, 1);
|
||||
expect(hapticCalls, hasLength(3));
|
||||
|
||||
await secondGesture.up();
|
||||
|
||||
Reference in New Issue
Block a user