From 66ba68715905dd7fa0ff20e61a45900bfe0155f8 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 6 Aug 2026 16:06:08 +0100 Subject: [PATCH 1/2] feat(mobile): add bee pull-to-refresh Signed-off-by: kenny lopez --- .../lib/features/activity/activity_page.dart | 3 +- .../activity/activity_page/lists.dart | 2 +- .../lib/features/channels/channels_page.dart | 1 + .../features/channels/channels_page/body.dart | 2 +- .../lib/features/forum/forum_posts_view.dart | 3 +- mobile/lib/features/pulse/pulse_page.dart | 6 +- .../shared/widgets/bee_refresh_indicator.dart | 431 ++++++++++++++++++ .../shared/widgets/tappable_flapping_bee.dart | 68 ++- .../widgets/bee_refresh_indicator_test.dart | 379 +++++++++++++++ 9 files changed, 876 insertions(+), 19 deletions(-) create mode 100644 mobile/lib/shared/widgets/bee_refresh_indicator.dart create mode 100644 mobile/test/shared/widgets/bee_refresh_indicator_test.dart diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index 51a0861b51..99d0bbc031 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -14,6 +14,7 @@ import '../../shared/theme/theme.dart'; import '../../shared/utils/string_utils.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/anchored_popover_menu.dart'; +import '../../shared/widgets/bee_refresh_indicator.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; @@ -318,7 +319,7 @@ class ActivityPage extends HookConsumerWidget { : -1; bodyRidesOverTopSection = true; - body = RefreshIndicator( + body = BeeRefreshIndicator( edgeOffset: topSectionHeight, onRefresh: refresh, child: CustomScrollView( diff --git a/mobile/lib/features/activity/activity_page/lists.dart b/mobile/lib/features/activity/activity_page/lists.dart index 7b4be901e1..ad97049272 100644 --- a/mobile/lib/features/activity/activity_page/lists.dart +++ b/mobile/lib/features/activity/activity_page/lists.dart @@ -38,7 +38,7 @@ class _RemindersList extends ConsumerWidget { } final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - return RefreshIndicator( + return BeeRefreshIndicator( onRefresh: onRefresh, child: ListView.builder( controller: scrollController, diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index d5ffb281cb..5ecc21a73a 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -17,6 +17,7 @@ import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/anchored_popover_menu.dart'; +import '../../shared/widgets/bee_refresh_indicator.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; diff --git a/mobile/lib/features/channels/channels_page/body.dart b/mobile/lib/features/channels/channels_page/body.dart index ad71be1e50..2d68a26df8 100644 --- a/mobile/lib/features/channels/channels_page/body.dart +++ b/mobile/lib/features/channels/channels_page/body.dart @@ -40,7 +40,7 @@ class _ChannelsBody extends StatelessWidget { ) : loadedChannels == null ? const SizedBox.shrink() - : RefreshIndicator( + : BeeRefreshIndicator( edgeOffset: barHeight, onRefresh: onRefresh, child: CustomScrollView( diff --git a/mobile/lib/features/forum/forum_posts_view.dart b/mobile/lib/features/forum/forum_posts_view.dart index f11d0444b4..13880427cf 100644 --- a/mobile/lib/features/forum/forum_posts_view.dart +++ b/mobile/lib/features/forum/forum_posts_view.dart @@ -8,6 +8,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/frosted_app_bar.dart'; +import '../../shared/widgets/bee_refresh_indicator.dart'; import '../channels/channel.dart'; import '../channels/compose_bar.dart'; import 'forum_models.dart'; @@ -92,7 +93,7 @@ class ForumPostsView extends HookConsumerWidget { isArchived: channel.isArchived, ); } - return RefreshIndicator( + return BeeRefreshIndicator( onRefresh: () async { ref.invalidate(forumPostsProvider(channel.id)); await ref.read(forumPostsProvider(channel.id).future); diff --git a/mobile/lib/features/pulse/pulse_page.dart b/mobile/lib/features/pulse/pulse_page.dart index 860002d60a..08568fc8dc 100644 --- a/mobile/lib/features/pulse/pulse_page.dart +++ b/mobile/lib/features/pulse/pulse_page.dart @@ -6,6 +6,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/filter_chip_bar.dart'; +import '../../shared/widgets/bee_refresh_indicator.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import 'agent_activity_card.dart'; @@ -99,7 +100,7 @@ class PulsePage extends HookConsumerWidget { ], ), Expanded( - child: RefreshIndicator( + child: BeeRefreshIndicator( onRefresh: () async => _refresh(ref, active.value, currentPubkey), child: _PulseBody( tab: active.value, @@ -171,7 +172,6 @@ class _PulseBody extends ConsumerWidget { if (tab == PulseTab.agents) { final groups = groupAgentNotes(notes); return ListView.separated( - physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.fromLTRB( Grid.gutter, Grid.xxs, @@ -188,7 +188,6 @@ class _PulseBody extends ConsumerWidget { ); } return ListView.separated( - physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.fromLTRB( Grid.gutter, Grid.xxs, @@ -243,7 +242,6 @@ class _MessageListShell extends StatelessWidget { @override Widget build(BuildContext context) { return ListView( - physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.all(Grid.xs), children: [SizedBox(height: 260, child: Center(child: child))], ); diff --git a/mobile/lib/shared/widgets/bee_refresh_indicator.dart b/mobile/lib/shared/widgets/bee_refresh_indicator.dart new file mode 100644 index 0000000000..629bca45de --- /dev/null +++ b/mobile/lib/shared/widgets/bee_refresh_indicator.dart @@ -0,0 +1,431 @@ +import 'dart:async' show Timer, unawaited; +import 'dart:math' show cos, min, pi, sin; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../theme/theme.dart'; +import 'tappable_flapping_bee.dart'; + +/// Replaces the standard pull-to-refresh spinner with Buzz's loading bee. +/// +/// Flutter continues to own the gesture, refresh lifecycle, and accessibility +/// semantics. This widget maps those states into the elastic pull, retained +/// loading gap, and bee animation. +class BeeRefreshIndicator extends HookConsumerWidget { + final Future Function() onRefresh; + final Widget child; + + /// The vertical offset of the scrollable's top edge, such as a pinned header. + final double edgeOffset; + + const BeeRefreshIndicator({ + required this.onRefresh, + required this.child, + this.edgeOffset = 0, + super.key, + }); + + static const _beeWidth = 60.0; + static const _beeHeight = _beeWidth * 309 / 466; + static const _triggerDistance = 100.0; + static const _loadingGap = 72.0; + static const _beeVerticalAlignment = 0.75; + static const _beeInitialScale = 0.6; + static const _beeRevealStartProgress = 0.18; + static const _pupilStartBeyondArmDistance = 96.0; + static const _pupilFullBeforeEmojiDistance = 8.0; + static const _eyeEmojiSwapViewportFraction = 0.26; + static const _eyeEmojiMinSwapBeyondArmDistance = 220.0; + static const _eyeEmojiMaxSwapBeyondArmDistance = 280.0; + static const _pupilMinBeyondArmDuration = Duration(milliseconds: 300); + static const _eyeEmojiMinBeyondArmDuration = Duration(milliseconds: 700); + static const _eyeShakePeriod = Duration(milliseconds: 140); + static const _settleDuration = Duration(milliseconds: 180); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final status = useState(null); + final pullProgress = useState(0.0); + final pullDistance = useState(0.0); + final pullBeyondArmDistance = useState(0.0); + final pullBeyondArmDuration = useState(Duration.zero); + final activePointers = useRef({}); + final lastPointerTime = useRef(Duration.zero); + final armedAt = useRef(null); + final didTriggerArmHaptic = useRef(false); + final didTriggerEmojiHaptic = useRef(false); + final eyeShakeHapticTimer = useRef(null); + final completionController = useAnimationController( + duration: _settleDuration, + ); + final gapController = useAnimationController( + duration: _settleDuration, + reverseDuration: _settleDuration, + ); + final flapController = useAnimationController( + duration: const Duration(milliseconds: 480), + ); + final eyeShakeController = useAnimationController( + duration: _eyeShakePeriod, + ); + final completionProgress = useAnimation(completionController); + final gapProgress = useAnimation(gapController); + final flapProgress = useAnimation(flapController); + final eyeShakeProgress = useAnimation(eyeShakeController); + final reducedMotion = MediaQuery.disableAnimationsOf(context); + useEffect( + () => + () => eyeShakeHapticTimer.value?.cancel(), + const [], + ); + final eyeEmojiSwapDistance = + (MediaQuery.sizeOf(context).height * _eyeEmojiSwapViewportFraction) + .clamp( + _eyeEmojiMinSwapBeyondArmDistance, + _eyeEmojiMaxSwapBeyondArmDistance, + ) + .toDouble(); + + void stopEyeShake() { + eyeShakeController + ..stop() + ..reset(); + eyeShakeHapticTimer.value?.cancel(); + eyeShakeHapticTimer.value = null; + } + + void startEyeShake() { + stopEyeShake(); + if (reducedMotion) return; + + eyeShakeController.repeat(); + eyeShakeHapticTimer.value = Timer.periodic( + _eyeShakePeriod, + (_) => unawaited(HapticFeedback.selectionClick()), + ); + } + + void beginExpressionTracking() { + if (activePointers.value.isEmpty || armedAt.value != null) return; + pullBeyondArmDistance.value = 0; + pullBeyondArmDuration.value = Duration.zero; + armedAt.value = lastPointerTime.value; + if (!didTriggerArmHaptic.value) { + didTriggerArmHaptic.value = true; + unawaited(HapticFeedback.mediumImpact()); + } + } + + void updateStatus(RefreshIndicatorStatus? nextStatus) { + status.value = nextStatus; + + if (nextStatus == RefreshIndicatorStatus.drag) { + completionController.reset(); + gapController.reset(); + flapController.stop(); + if (!didTriggerArmHaptic.value) { + pullBeyondArmDistance.value = 0; + pullBeyondArmDuration.value = Duration.zero; + armedAt.value = null; + } + } else if (nextStatus == RefreshIndicatorStatus.armed) { + pullProgress.value = 1; + beginExpressionTracking(); + } else if (nextStatus == RefreshIndicatorStatus.snap || + nextStatus == RefreshIndicatorStatus.refresh) { + stopEyeShake(); + pullProgress.value = 1; + pullBeyondArmDistance.value = 0; + pullBeyondArmDuration.value = Duration.zero; + armedAt.value = null; + if (reducedMotion) { + gapController.value = 1; + } else { + gapController.animateTo(1, curve: Curves.easeOutCubic); + } + if (!reducedMotion) flapController.repeat(); + } else if (nextStatus == RefreshIndicatorStatus.done) { + stopEyeShake(); + if (reducedMotion) { + gapController.value = 0; + pullProgress.value = 0; + pullDistance.value = 0; + pullBeyondArmDistance.value = 0; + pullBeyondArmDuration.value = Duration.zero; + status.value = null; + } else { + flapController.repeat(); + gapController + .animateTo(1, curve: Curves.easeOutCubic) + .whenCompleteOrCancel(() { + if (!gapController.isCompleted || status.value == null) return; + gapController.animateBack(0, curve: Curves.easeInOutCubic); + completionController.forward(from: 0).whenCompleteOrCancel(() { + if (!completionController.isCompleted) return; + flapController.stop(); + pullProgress.value = 0; + pullDistance.value = 0; + pullBeyondArmDistance.value = 0; + pullBeyondArmDuration.value = Duration.zero; + status.value = null; + }); + }); + } + } else if (nextStatus == RefreshIndicatorStatus.canceled || + nextStatus == null) { + stopEyeShake(); + pullProgress.value = 0; + pullDistance.value = 0; + pullBeyondArmDistance.value = 0; + pullBeyondArmDuration.value = Duration.zero; + armedAt.value = null; + if (!gapController.isAnimating) gapController.reset(); + flapController.stop(); + } + } + + bool trackPull(ScrollNotification notification) { + if (notification.metrics.axis != Axis.vertical) return false; + + if (notification is! ScrollStartNotification && + notification.metrics.extentBefore == 0) { + // BouncingScrollPhysics reports a live negative scroll position while + // the user is pulling. Reading it keeps the bee coupled to the finger. + final elasticPull = + (notification.metrics.minScrollExtent - notification.metrics.pixels) + .clamp(0.0, double.infinity) + .toDouble(); + if (elasticPull > 0 || pullDistance.value > 0) { + pullDistance.value = elasticPull; + final nextProgress = (elasticPull / _triggerDistance).clamp(0.0, 1.0); + pullProgress.value = nextProgress; + if (nextProgress >= 1) beginExpressionTracking(); + } else if (notification case OverscrollNotification()) { + // Clamping physics does not expose a negative position, so build the + // same progress from its overscroll deltas. + final nextDistance = + pullDistance.value + notification.overscroll.abs(); + pullDistance.value = nextDistance; + pullProgress.value = (nextDistance / _triggerDistance).clamp( + 0.0, + 1.0, + ); + if (pullProgress.value >= 1) beginExpressionTracking(); + } + } + return false; + } + + void startPointer(PointerDownEvent event) { + final isNewGesture = activePointers.value.isEmpty; + activePointers.value.add(event.pointer); + if (!isNewGesture) return; + + lastPointerTime.value = event.timeStamp; + armedAt.value = null; + didTriggerArmHaptic.value = false; + didTriggerEmojiHaptic.value = false; + stopEyeShake(); + pullProgress.value = 0; + pullDistance.value = 0; + pullBeyondArmDistance.value = 0; + pullBeyondArmDuration.value = Duration.zero; + } + + void trackPointer(PointerMoveEvent event) { + if (!activePointers.value.contains(event.pointer)) return; + lastPointerTime.value = event.timeStamp; + if (armedAt.value == null) return; + + pullBeyondArmDistance.value = + (pullBeyondArmDistance.value + event.delta.dy) + .clamp(0.0, double.infinity) + .toDouble(); + if (armedAt.value case final armedTime?) { + pullBeyondArmDuration.value = event.timeStamp - armedTime; + } + if (!didTriggerEmojiHaptic.value && + pullBeyondArmDuration.value >= _eyeEmojiMinBeyondArmDuration && + pullBeyondArmDistance.value >= eyeEmojiSwapDistance) { + didTriggerEmojiHaptic.value = true; + unawaited(HapticFeedback.heavyImpact()); + startEyeShake(); + } + } + + void finishPointer(PointerEvent event) { + if (!activePointers.value.remove(event.pointer)) return; + if (activePointers.value.isNotEmpty) return; + + stopEyeShake(); + armedAt.value = null; + pullBeyondArmDistance.value = 0; + pullBeyondArmDuration.value = Duration.zero; + } + + final isLoading = switch (status.value) { + RefreshIndicatorStatus.snap || + RefreshIndicatorStatus.refresh || + RefreshIndicatorStatus.done => true, + _ => false, + }; + final dragRevealProgress = + ((pullProgress.value - _beeRevealStartProgress) / + (1 - _beeRevealStartProgress)) + .clamp(0.0, 1.0); + final isVisible = + status.value != null && + (isLoading || dragRevealProgress > 0 || completionProgress > 0); + final retainedGap = _loadingGap * gapProgress; + final visibleGap = pullDistance.value + retainedGap; + final top = edgeOffset + (visibleGap - _beeHeight) * _beeVerticalAlignment; + final opacity = isLoading ? 1 - completionProgress : dragRevealProgress; + final beeScale = isLoading + ? 1.0 + : _beeInitialScale + (1 - _beeInitialScale) * dragRevealProgress; + final flapAmount = reducedMotion + ? 0.0 + : isLoading + ? 0.5 - (0.5 * cos(flapProgress * 4 * pi)) + : pullProgress.value * 0.18; + final pupilFullDistance = + eyeEmojiSwapDistance - _pupilFullBeforeEmojiDistance; + final pupilDistanceProgress = + ((pullBeyondArmDistance.value - _pupilStartBeyondArmDistance) / + (pupilFullDistance - _pupilStartBeyondArmDistance)) + .clamp(0.0, 1.0); + final pupilGrowthDuration = + _eyeEmojiMinBeyondArmDuration - _pupilMinBeyondArmDuration; + final pupilProgress = + pullBeyondArmDuration.value >= _pupilMinBeyondArmDuration + ? min( + pupilDistanceProgress, + ((pullBeyondArmDuration.value - _pupilMinBeyondArmDuration) + .inMicroseconds / + pupilGrowthDuration.inMicroseconds) + .clamp(0.0, 1.0), + ).toDouble() + : 0.0; + final showEyeEmoji = !isLoading && didTriggerEmojiHaptic.value; + final eyeShakeOffset = showEyeEmoji && !reducedMotion + ? sin(eyeShakeProgress * 2 * pi) * 0.75 + : 0.0; + final scrollBehavior = ScrollConfiguration.of(context).copyWith( + overscroll: false, + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + ); + + return Stack( + clipBehavior: Clip.none, + children: [ + RefreshIndicator.noSpinner( + onRefresh: onRefresh, + onStatusChange: updateStatus, + semanticsLabel: 'Pull to refresh', + child: NotificationListener( + onNotification: trackPull, + child: Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: startPointer, + onPointerMove: trackPointer, + onPointerUp: finishPointer, + onPointerCancel: finishPointer, + child: ScrollConfiguration( + behavior: scrollBehavior, + child: Transform.translate( + key: const ValueKey('bee-refresh-retained-gap'), + offset: Offset(0, retainedGap), + child: child, + ), + ), + ), + ), + ), + if (isVisible) + Positioned( + top: edgeOffset, + left: 0, + right: 0, + bottom: 0, + child: ClipRect( + child: Align( + alignment: Alignment.topCenter, + child: Transform.translate( + offset: Offset(0, top - edgeOffset), + child: IgnorePointer( + child: Opacity( + key: const ValueKey('bee-refresh-opacity'), + opacity: opacity.clamp(0.0, 1.0), + child: Transform.scale( + key: const ValueKey('bee-refresh-scale'), + scale: beeScale, + child: SizedBox( + width: _beeWidth, + height: _beeHeight, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.topCenter, + children: [ + Positioned.fill( + child: FlappingBee( + width: _beeWidth, + color: context.colors.primary, + flapAmount: flapAmount, + eyeProgress: + !isLoading && + !showEyeEmoji && + pupilProgress > 0 + ? pupilProgress + : null, + ), + ), + if (showEyeEmoji) + Positioned( + top: 2, + left: 0, + right: 0, + child: ExcludeSemantics( + child: Transform.translate( + key: const ValueKey( + 'bee-refresh-eyes-emoji-offset', + ), + offset: const Offset(2, 0), + child: Transform.translate( + key: const ValueKey( + 'bee-refresh-eyes-emoji-shake', + ), + offset: Offset(eyeShakeOffset, 0), + child: const Text( + '👀', + key: ValueKey( + 'bee-refresh-eyes-emoji', + ), + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 18, + height: 1, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/mobile/lib/shared/widgets/tappable_flapping_bee.dart b/mobile/lib/shared/widgets/tappable_flapping_bee.dart index 5796e9749b..0c2f8efc35 100644 --- a/mobile/lib/shared/widgets/tappable_flapping_bee.dart +++ b/mobile/lib/shared/widgets/tappable_flapping_bee.dart @@ -47,12 +47,10 @@ class TappableFlappingBee extends HookConsumerWidget { animation: animation, builder: (context, _) { final flapAmount = 0.5 - (0.5 * cos(animation.value * 4 * pi)); - return CustomPaint( - size: Size(width, width * 309 / 466), - painter: _FlappingBeePainter( - color: color, - flapAmount: flapAmount, - ), + return FlappingBee( + width: width, + color: color, + flapAmount: flapAmount, ); }, ), @@ -62,11 +60,49 @@ class TappableFlappingBee extends HookConsumerWidget { } } +/// The Buzz mark at a caller-controlled wing position. +/// +/// [eyeProgress] is only used by the pull-to-refresh treatment. Leaving it null +/// preserves the mark's ordinary cutout eyes. +class FlappingBee extends StatelessWidget { + final double width; + final Color color; + final double flapAmount; + final double? eyeProgress; + + const FlappingBee({ + required this.width, + required this.color, + required this.flapAmount, + this.eyeProgress, + super.key, + }); + + @override + Widget build(BuildContext context) { + return RepaintBoundary( + child: CustomPaint( + size: Size(width, width * 309 / 466), + painter: _FlappingBeePainter( + color: color, + flapAmount: flapAmount, + eyeProgress: eyeProgress, + ), + ), + ); + } +} + class _FlappingBeePainter extends CustomPainter { final Color color; final double flapAmount; + final double? eyeProgress; - const _FlappingBeePainter({required this.color, required this.flapAmount}); + const _FlappingBeePainter({ + required this.color, + required this.flapAmount, + this.eyeProgress, + }); @override void paint(Canvas canvas, Size size) { @@ -138,12 +174,22 @@ class _FlappingBeePainter extends CustomPainter { cutouts, ); - canvas - ..drawPath(finishedMark, Paint()..color = color) - ..restore(); + canvas.drawPath(finishedMark, Paint()..color = color); + + if (eyeProgress case final progress?) { + final pupilRadius = 20 * progress.clamp(0.0, 1.0); + final pupilPaint = Paint()..color = color; + canvas + ..drawCircle(const Offset(193.3, 84.4), pupilRadius, pupilPaint) + ..drawCircle(const Offset(276, 84.4), pupilRadius, pupilPaint); + } + + canvas.restore(); } @override bool shouldRepaint(_FlappingBeePainter oldDelegate) => - color != oldDelegate.color || flapAmount != oldDelegate.flapAmount; + color != oldDelegate.color || + flapAmount != oldDelegate.flapAmount || + eyeProgress != oldDelegate.eyeProgress; } diff --git a/mobile/test/shared/widgets/bee_refresh_indicator_test.dart b/mobile/test/shared/widgets/bee_refresh_indicator_test.dart new file mode 100644 index 0000000000..c6dfa115de --- /dev/null +++ b/mobile/test/shared/widgets/bee_refresh_indicator_test.dart @@ -0,0 +1,379 @@ +import 'dart:async'; + +import 'package:buzz/shared/widgets/bee_refresh_indicator.dart'; +import 'package:buzz/shared/widgets/tappable_flapping_bee.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/widget_helpers.dart'; + +void main() { + testWidgets('shows the bee while pulling to refresh', (tester) async { + const contentKey = ValueKey('loading-content'); + var refreshes = 0; + final refreshCompleter = Completer(); + + await tester.pumpWidget( + WidgetHelpers.testable( + child: BeeRefreshIndicator( + onRefresh: () { + refreshes++; + return refreshCompleter.future; + }, + child: ListView( + children: const [SizedBox(key: contentKey, height: 800)], + ), + ), + ), + ); + + final listFinder = find.byType(ListView); + final restingTop = tester.getTopLeft(listFinder).dy; + final restingContentTop = tester.getTopLeft(find.byKey(contentKey)).dy; + await tester.timedDrag( + listFinder, + const Offset(0, 320), + const Duration(milliseconds: 500), + ); + await tester.pump(const Duration(milliseconds: 16)); + await tester.pump(const Duration(milliseconds: 300)); + + final beeFinder = find.byType(FlappingBee); + final loadingTop = tester.getTopLeft(listFinder).dy; + final loadingContentTop = tester.getTopLeft(find.byKey(contentKey)).dy; + final gapTransform = tester.widget( + find.byKey(const ValueKey('bee-refresh-retained-gap')), + ); + expect(beeFinder, findsOneWidget); + expect(refreshes, 1); + expect(gapTransform.transform.getTranslation().y, closeTo(72, 1)); + expect(loadingTop - restingTop, closeTo(72, 1)); + final loadingBeeRect = tester.getRect(beeFinder); + final loadingGap = loadingContentTop - restingContentTop; + expect( + loadingBeeRect.center.dy, + closeTo( + restingContentTop + + (loadingGap - loadingBeeRect.height) * 0.75 + + loadingBeeRect.height / 2, + 1, + ), + ); + + refreshCompleter.complete(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 90)); + + final closingTop = tester.getTopLeft(listFinder).dy; + expect(closingTop, greaterThan(restingTop)); + expect(closingTop, lessThan(loadingTop)); + + await tester.pumpAndSettle(); + expect(tester.getTopLeft(listFinder).dy, closeTo(restingTop, 1)); + expect(beeFinder, findsNothing); + }); + + testWidgets('tracks each stage of an active pull', (tester) async { + tester.view.physicalSize = const Size(420, 912); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final hapticCalls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'HapticFeedback.vibrate') hapticCalls.add(call); + return null; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null), + ); + + const contentKey = ValueKey('pull-content'); + final refreshCompleter = Completer(); + await tester.pumpWidget( + WidgetHelpers.testable( + child: BeeRefreshIndicator( + onRefresh: () => refreshCompleter.future, + child: ListView( + children: const [SizedBox(key: contentKey, height: 800)], + ), + ), + ), + ); + + final restingContentTop = tester.getTopLeft(find.byKey(contentKey)).dy; + final gesture = await tester.startGesture( + tester.getCenter(find.byType(ListView)), + pointer: 1, + ); + await gesture.moveBy(const Offset(0, 12)); + await tester.pump(); + + final beeFinder = find.byType(FlappingBee); + expect(beeFinder, findsNothing); + expect( + tester.getTopLeft(find.byKey(contentKey)).dy, + greaterThan(restingContentTop), + ); + + await gesture.moveBy(const Offset(0, 44)); + await tester.pump(); + + final earlyTop = tester.getTopLeft(beeFinder).dy; + final partialOpacity = tester.widget( + find.byKey(const ValueKey('bee-refresh-opacity')), + ); + expect(partialOpacity.opacity, greaterThan(0)); + expect(partialOpacity.opacity, lessThan(1)); + final partialScale = tester + .widget(find.byKey(const ValueKey('bee-refresh-scale'))) + .transform + .storage[0]; + expect(partialScale, greaterThan(0.6)); + expect(partialScale, lessThan(1)); + expect(tester.widget(beeFinder).eyeProgress, isNull); + expect(hapticCalls, isEmpty); + + await gesture.moveBy(const Offset(0, 120)); + await tester.pump(); + + final pulledContentTop = tester.getTopLeft(find.byKey(contentKey)).dy; + expect(tester.getTopLeft(beeFinder).dy, greaterThan(earlyTop)); + expect(tester.widget(beeFinder).eyeProgress, isNull); + expect(find.byKey(const ValueKey('bee-refresh-eyes-emoji')), findsNothing); + final pulledBeeRect = tester.getRect(beeFinder); + final pulledGap = pulledContentTop - restingContentTop; + expect( + pulledBeeRect.center.dy, + closeTo( + restingContentTop + + (pulledGap - pulledBeeRect.height) * 0.75 + + pulledBeeRect.height / 2, + 1, + ), + ); + + await gesture.moveBy(const Offset(0, 120)); + await tester.pump(); + + expect( + tester + .widget(find.byKey(const ValueKey('bee-refresh-scale'))) + .transform + .storage[0], + closeTo(1, 0.001), + ); + expect(hapticCalls, hasLength(1)); + expect(hapticCalls.single.arguments, 'HapticFeedbackType.mediumImpact'); + expect(tester.widget(beeFinder).eyeProgress, isNull); + expect(find.byKey(const ValueKey('bee-refresh-eyes-emoji')), findsNothing); + + await tester.pump(const Duration(milliseconds: 350)); + await gesture.moveBy( + const Offset(0, 120), + timeStamp: const Duration(milliseconds: 350), + ); + await tester.pump(); + + final pupilProgress = tester.widget(beeFinder).eyeProgress; + expect(pupilProgress, greaterThan(0)); + expect(pupilProgress, lessThan(0.5)); + expect(find.byKey(const ValueKey('bee-refresh-eyes-emoji')), findsNothing); + expect(hapticCalls, hasLength(1)); + + await tester.pump(const Duration(milliseconds: 400)); + await gesture.moveBy( + const Offset(0, 240), + timeStamp: const Duration(milliseconds: 750), + ); + await tester.pump(); + + expect(tester.widget(beeFinder).eyeProgress, isNull); + expect( + find.byKey(const ValueKey('bee-refresh-eyes-emoji')), + findsOneWidget, + ); + expect(hapticCalls, hasLength(2)); + expect(hapticCalls.last.arguments, 'HapticFeedbackType.heavyImpact'); + expect( + tester + .widget( + find.byKey(const ValueKey('bee-refresh-eyes-emoji-offset')), + ) + .transform + .storage[12], + 2, + ); + final initialShake = tester + .widget( + find.byKey(const ValueKey('bee-refresh-eyes-emoji-shake')), + ) + .transform + .storage[12]; + await tester.pump(const Duration(milliseconds: 35)); + final movedShake = tester + .widget( + find.byKey(const ValueKey('bee-refresh-eyes-emoji-shake')), + ) + .transform + .storage[12]; + expect(movedShake, isNot(closeTo(initialShake, 0.01))); + expect(movedShake.abs(), lessThanOrEqualTo(0.75)); + await tester.pump(const Duration(milliseconds: 105)); + expect(hapticCalls, hasLength(3)); + expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick'); + + final secondGesture = await tester.startGesture( + tester.getCenter(find.byType(ListView)), + pointer: 2, + ); + await secondGesture.moveBy( + const Offset(0, 40), + timeStamp: const Duration(milliseconds: 800), + ); + await gesture.up(); + await tester.pump(); + expect( + find.byKey(const ValueKey('bee-refresh-eyes-emoji')), + findsOneWidget, + ); + expect(hapticCalls, hasLength(3)); + + await secondGesture.moveBy( + const Offset(0, 80), + timeStamp: const Duration(milliseconds: 900), + ); + await tester.pump(); + expect( + find.byKey(const ValueKey('bee-refresh-eyes-emoji')), + findsOneWidget, + ); + expect(hapticCalls, hasLength(3)); + + await secondGesture.up(); + await tester.pump(); + final hapticsAfterRelease = hapticCalls.length; + await tester.pump(const Duration(milliseconds: 300)); + + expect(tester.widget(beeFinder).eyeProgress, isNull); + expect(find.byKey(const ValueKey('bee-refresh-eyes-emoji')), findsNothing); + expect(hapticCalls, hasLength(hapticsAfterRelease)); + + refreshCompleter.complete(); + await tester.pumpAndSettle(); + }); + + testWidgets('keeps expressive eyes hidden for a quick refresh flick', ( + tester, + ) async { + final hapticCalls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'HapticFeedback.vibrate') hapticCalls.add(call); + return null; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null), + ); + final refreshCompleter = Completer(); + var refreshes = 0; + await tester.pumpWidget( + WidgetHelpers.testable( + child: BeeRefreshIndicator( + onRefresh: () { + refreshes++; + return refreshCompleter.future; + }, + child: ListView(children: const [SizedBox(height: 800)]), + ), + ), + ); + + final gesture = await tester.startGesture( + tester.getCenter(find.byType(ListView)), + ); + final beeFinder = find.byType(FlappingBee); + for (var step = 0; step < 10; step++) { + await gesture.moveBy( + const Offset(0, 50), + timeStamp: Duration(milliseconds: (step + 1) * 10), + ); + await tester.pump(const Duration(milliseconds: 10)); + if (beeFinder.evaluate().isNotEmpty) { + expect(tester.widget(beeFinder).eyeProgress, isNull); + expect( + find.byKey(const ValueKey('bee-refresh-eyes-emoji')), + findsNothing, + ); + } + } + + await gesture.up(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(refreshes, 1); + expect(hapticCalls, hasLength(1)); + expect(hapticCalls.single.arguments, 'HapticFeedbackType.mediumImpact'); + expect(tester.widget(beeFinder).eyeProgress, isNull); + expect(find.byKey(const ValueKey('bee-refresh-eyes-emoji')), findsNothing); + + refreshCompleter.complete(); + await tester.pumpAndSettle(); + }); + + testWidgets('keeps the bee static when motion is disabled', (tester) async { + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(disableAnimations: true), + child: WidgetHelpers.testable( + child: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of(context).copyWith(disableAnimations: true), + child: BeeRefreshIndicator( + onRefresh: () async {}, + child: ListView(children: const [SizedBox(height: 800)]), + ), + ), + ), + ), + ), + ); + + await tester.timedDrag( + find.byType(ListView), + const Offset(0, 160), + const Duration(milliseconds: 400), + ); + await tester.pump(); + + final bee = tester.widget(find.byType(FlappingBee)); + expect(bee.flapAmount, 0); + }); + + testWidgets('provides elastic always-scrollable physics', (tester) async { + late ScrollPhysics physics; + + await tester.pumpWidget( + WidgetHelpers.testable( + child: BeeRefreshIndicator( + onRefresh: () async {}, + child: Builder( + builder: (context) { + physics = ScrollConfiguration.of( + context, + ).getScrollPhysics(context); + return ListView(children: const [SizedBox(height: 20)]); + }, + ), + ), + ), + ); + + expect(physics, isA()); + expect(physics.parent, isA()); + }); +} From a7937cefb88f364bbdb96c108355bed024f9cf98 Mon Sep 17 00:00:00 2001 From: Fizz <50a12680c76f1a52c0b7af8dbb17e02c583227c290fb93b9a3defb456114223f@buzz.block.builderlab.xyz> Date: Thu, 6 Aug 2026 16:35:32 +0100 Subject: [PATCH 2/2] refactor(mobile): split FlappingBee into its own file Addresses Codex review on #5059. Two structural fixes, no behavior change: - Move FlappingBee and its private painter out of tappable_flapping_bee.dart into shared/widgets/flapping_bee.dart, restoring one public widget per file. - Document the public properties on FlappingBee, and BeeRefreshIndicator's onRefresh and child. Signed-off-by: Fizz <50a12680c76f1a52c0b7af8dbb17e02c583227c290fb93b9a3defb456114223f@buzz.block.builderlab.xyz> Co-authored-by: kenny lopez Signed-off-by: kenny lopez --- .../shared/widgets/bee_refresh_indicator.dart | 11 +- mobile/lib/shared/widgets/flapping_bee.dart | 155 ++++++++++++++++++ .../shared/widgets/tappable_flapping_bee.dart | 138 +--------------- .../widgets/bee_refresh_indicator_test.dart | 2 +- 4 files changed, 169 insertions(+), 137 deletions(-) create mode 100644 mobile/lib/shared/widgets/flapping_bee.dart diff --git a/mobile/lib/shared/widgets/bee_refresh_indicator.dart b/mobile/lib/shared/widgets/bee_refresh_indicator.dart index 629bca45de..4a9802f0da 100644 --- a/mobile/lib/shared/widgets/bee_refresh_indicator.dart +++ b/mobile/lib/shared/widgets/bee_refresh_indicator.dart @@ -7,7 +7,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../theme/theme.dart'; -import 'tappable_flapping_bee.dart'; +import 'flapping_bee.dart'; /// Replaces the standard pull-to-refresh spinner with Buzz's loading bee. /// @@ -15,7 +15,16 @@ import 'tappable_flapping_bee.dart'; /// semantics. This widget maps those states into the elastic pull, retained /// loading gap, and bee animation. class BeeRefreshIndicator extends HookConsumerWidget { + /// Called when the user completes a pull, to load fresh data. + /// + /// The bee keeps flapping until this future settles, so it should complete + /// only once the refresh is done. final Future Function() onRefresh; + + /// The scrollable this indicator wraps. + /// + /// It must scroll vertically; the indicator reads its scroll notifications + /// to couple the bee to the user's finger. final Widget child; /// The vertical offset of the scrollable's top edge, such as a pinned header. diff --git a/mobile/lib/shared/widgets/flapping_bee.dart b/mobile/lib/shared/widgets/flapping_bee.dart new file mode 100644 index 0000000000..9a99bd8796 --- /dev/null +++ b/mobile/lib/shared/widgets/flapping_bee.dart @@ -0,0 +1,155 @@ +import 'dart:math' show min; + +import 'package:flutter/material.dart'; + +/// The Buzz mark at a caller-controlled wing position. +/// +/// The geometry matches the desktop loading bee. Callers drive the wings +/// themselves, which lets one painter serve both the tap-to-flutter mark +/// ([TappableFlappingBee]) and the pull-to-refresh indicator +/// ([BeeRefreshIndicator]). +class FlappingBee extends StatelessWidget { + /// The rendered width of the complete bee mark. + /// + /// Height follows from the mark's 466:309 aspect ratio. + final double width; + + /// The color used for the bee silhouette, wings, and pupils. + final Color color; + + /// How far the wings are tucked toward the body, from 0 to 1. + /// + /// 0 renders the wings fully spread; 1 renders them at their innermost + /// tuck. Callers animate this to flap the wings. + final double flapAmount; + + /// How far the pupils have grown, from 0 to 1, or null for cutout eyes. + /// + /// Only the pull-to-refresh treatment sets this. Leaving it null preserves + /// the mark's ordinary cutout eyes; a non-null value fills them in, with 1 + /// drawing the pupils at full size. + final double? eyeProgress; + + const FlappingBee({ + required this.width, + required this.color, + required this.flapAmount, + this.eyeProgress, + super.key, + }); + + @override + Widget build(BuildContext context) { + return RepaintBoundary( + child: CustomPaint( + size: Size(width, width * 309 / 466), + painter: _FlappingBeePainter( + color: color, + flapAmount: flapAmount, + eyeProgress: eyeProgress, + ), + ), + ); + } +} + +class _FlappingBeePainter extends CustomPainter { + final Color color; + final double flapAmount; + final double? eyeProgress; + + const _FlappingBeePainter({ + required this.color, + required this.flapAmount, + this.eyeProgress, + }); + + @override + void paint(Canvas canvas, Size size) { + final scale = min(size.width / 466, size.height / 309); + final renderedWidth = 466 * scale; + final renderedHeight = 309 * scale; + + canvas + ..save() + ..translate( + (size.width - renderedWidth) / 2, + (size.height - renderedHeight) / 2, + ) + ..scale(scale); + + final wingRadiusX = 91.7 * (1 - (0.38 * flapAmount)); + final wingTranslation = 30 * flapAmount; + final leftWing = Path() + ..addOval( + Rect.fromCenter( + center: Offset(91.7 + wingTranslation, 154.5), + width: wingRadiusX * 2, + height: 183.4, + ), + ); + final rightWing = Path() + ..addOval( + Rect.fromCenter( + center: Offset(374.3 - wingTranslation, 154.5), + width: wingRadiusX * 2, + height: 183.4, + ), + ); + final body = Path() + ..addRRect( + RRect.fromRectAndRadius( + const Rect.fromLTWH(128, 0, 210, 309), + const Radius.circular(34), + ), + ); + final cutouts = Path() + ..addOval( + Rect.fromCenter( + center: const Offset(193.3, 84.4), + width: 54, + height: 54, + ), + ) + ..addOval( + Rect.fromCenter(center: const Offset(276, 84.4), width: 54, height: 54), + ) + ..addRRect( + RRect.fromRectAndRadius( + const Rect.fromLTWH(166.3, 157.2, 136.9, 38.3), + const Radius.circular(5), + ), + ) + ..addRRect( + RRect.fromRectAndRadius( + const Rect.fromLTWH(166.9, 235.1, 136.2, 37.6), + const Radius.circular(5), + ), + ); + final wings = Path.combine(PathOperation.union, leftWing, rightWing); + final silhouette = Path.combine(PathOperation.union, wings, body); + final finishedMark = Path.combine( + PathOperation.difference, + silhouette, + cutouts, + ); + + canvas.drawPath(finishedMark, Paint()..color = color); + + if (eyeProgress case final progress?) { + final pupilRadius = 20 * progress.clamp(0.0, 1.0); + final pupilPaint = Paint()..color = color; + canvas + ..drawCircle(const Offset(193.3, 84.4), pupilRadius, pupilPaint) + ..drawCircle(const Offset(276, 84.4), pupilRadius, pupilPaint); + } + + canvas.restore(); + } + + @override + bool shouldRepaint(_FlappingBeePainter oldDelegate) => + color != oldDelegate.color || + flapAmount != oldDelegate.flapAmount || + eyeProgress != oldDelegate.eyeProgress; +} diff --git a/mobile/lib/shared/widgets/tappable_flapping_bee.dart b/mobile/lib/shared/widgets/tappable_flapping_bee.dart index 0c2f8efc35..2313de50f9 100644 --- a/mobile/lib/shared/widgets/tappable_flapping_bee.dart +++ b/mobile/lib/shared/widgets/tappable_flapping_bee.dart @@ -1,9 +1,11 @@ -import 'dart:math' show cos, min, pi; +import 'dart:math' show cos, pi; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'flapping_bee.dart'; + /// The Buzz mark with wings that flutter twice when the user taps it. /// /// The geometry and wing tuck match the desktop loading bee. When reduced @@ -59,137 +61,3 @@ class TappableFlappingBee extends HookConsumerWidget { ); } } - -/// The Buzz mark at a caller-controlled wing position. -/// -/// [eyeProgress] is only used by the pull-to-refresh treatment. Leaving it null -/// preserves the mark's ordinary cutout eyes. -class FlappingBee extends StatelessWidget { - final double width; - final Color color; - final double flapAmount; - final double? eyeProgress; - - const FlappingBee({ - required this.width, - required this.color, - required this.flapAmount, - this.eyeProgress, - super.key, - }); - - @override - Widget build(BuildContext context) { - return RepaintBoundary( - child: CustomPaint( - size: Size(width, width * 309 / 466), - painter: _FlappingBeePainter( - color: color, - flapAmount: flapAmount, - eyeProgress: eyeProgress, - ), - ), - ); - } -} - -class _FlappingBeePainter extends CustomPainter { - final Color color; - final double flapAmount; - final double? eyeProgress; - - const _FlappingBeePainter({ - required this.color, - required this.flapAmount, - this.eyeProgress, - }); - - @override - void paint(Canvas canvas, Size size) { - final scale = min(size.width / 466, size.height / 309); - final renderedWidth = 466 * scale; - final renderedHeight = 309 * scale; - - canvas - ..save() - ..translate( - (size.width - renderedWidth) / 2, - (size.height - renderedHeight) / 2, - ) - ..scale(scale); - - final wingRadiusX = 91.7 * (1 - (0.38 * flapAmount)); - final wingTranslation = 30 * flapAmount; - final leftWing = Path() - ..addOval( - Rect.fromCenter( - center: Offset(91.7 + wingTranslation, 154.5), - width: wingRadiusX * 2, - height: 183.4, - ), - ); - final rightWing = Path() - ..addOval( - Rect.fromCenter( - center: Offset(374.3 - wingTranslation, 154.5), - width: wingRadiusX * 2, - height: 183.4, - ), - ); - final body = Path() - ..addRRect( - RRect.fromRectAndRadius( - const Rect.fromLTWH(128, 0, 210, 309), - const Radius.circular(34), - ), - ); - final cutouts = Path() - ..addOval( - Rect.fromCenter( - center: const Offset(193.3, 84.4), - width: 54, - height: 54, - ), - ) - ..addOval( - Rect.fromCenter(center: const Offset(276, 84.4), width: 54, height: 54), - ) - ..addRRect( - RRect.fromRectAndRadius( - const Rect.fromLTWH(166.3, 157.2, 136.9, 38.3), - const Radius.circular(5), - ), - ) - ..addRRect( - RRect.fromRectAndRadius( - const Rect.fromLTWH(166.9, 235.1, 136.2, 37.6), - const Radius.circular(5), - ), - ); - final wings = Path.combine(PathOperation.union, leftWing, rightWing); - final silhouette = Path.combine(PathOperation.union, wings, body); - final finishedMark = Path.combine( - PathOperation.difference, - silhouette, - cutouts, - ); - - canvas.drawPath(finishedMark, Paint()..color = color); - - if (eyeProgress case final progress?) { - final pupilRadius = 20 * progress.clamp(0.0, 1.0); - final pupilPaint = Paint()..color = color; - canvas - ..drawCircle(const Offset(193.3, 84.4), pupilRadius, pupilPaint) - ..drawCircle(const Offset(276, 84.4), pupilRadius, pupilPaint); - } - - canvas.restore(); - } - - @override - bool shouldRepaint(_FlappingBeePainter oldDelegate) => - color != oldDelegate.color || - flapAmount != oldDelegate.flapAmount || - eyeProgress != oldDelegate.eyeProgress; -} diff --git a/mobile/test/shared/widgets/bee_refresh_indicator_test.dart b/mobile/test/shared/widgets/bee_refresh_indicator_test.dart index c6dfa115de..de440ebb62 100644 --- a/mobile/test/shared/widgets/bee_refresh_indicator_test.dart +++ b/mobile/test/shared/widgets/bee_refresh_indicator_test.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:buzz/shared/widgets/bee_refresh_indicator.dart'; -import 'package:buzz/shared/widgets/tappable_flapping_bee.dart'; +import 'package:buzz/shared/widgets/flapping_bee.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart';