feat(mobile): add bee pull-to-refresh (#5059)

## Summary

Replace Flutter's standard mobile pull-to-refresh indicator with our
animated Buzz bee.

## Testing

- `bin/just mobile-check`
- `bin/just mobile-test` (1,248 tests)
- Connected iPhone and Pixel 10

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Fizz <50a12680c76f1a52c0b7af8dbb17e02c583227c290fb93b9a3defb456114223f@buzz.block.builderlab.xyz>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Fizz <50a12680c76f1a52c0b7af8dbb17e02c583227c290fb93b9a3defb456114223f@buzz.block.builderlab.xyz>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-08-07 16:09:19 +00:00
committed by GitHub
co-authored by Fizz Wes Carl
parent c8743b2f20
commit 626e2c34a3
10 changed files with 990 additions and 101 deletions
@@ -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(
@@ -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,
@@ -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';
@@ -40,7 +40,7 @@ class _ChannelsBody extends StatelessWidget {
)
: loadedChannels == null
? const SizedBox.shrink()
: RefreshIndicator(
: BeeRefreshIndicator(
edgeOffset: barHeight,
onRefresh: onRefresh,
child: CustomScrollView(
@@ -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);
+2 -4
View File
@@ -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))],
);
@@ -0,0 +1,440 @@
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 '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 {
/// 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<void> 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.
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<RefreshIndicatorStatus?>(null);
final pullProgress = useState(0.0);
final pullDistance = useState(0.0);
final pullBeyondArmDistance = useState(0.0);
final pullBeyondArmDuration = useState(Duration.zero);
final activePointers = useRef(<int>{});
final lastPointerTime = useRef(Duration.zero);
final armedAt = useRef<Duration?>(null);
final didTriggerArmHaptic = useRef(false);
final didTriggerEmojiHaptic = useRef(false);
final eyeShakeHapticTimer = useRef<Timer?>(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<ScrollNotification>(
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,
),
),
),
),
),
),
],
),
),
),
),
),
),
),
),
),
],
);
}
}
+155
View File
@@ -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;
}
@@ -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
@@ -47,12 +49,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,
);
},
),
@@ -61,89 +61,3 @@ class TappableFlappingBee extends HookConsumerWidget {
);
}
}
class _FlappingBeePainter extends CustomPainter {
final Color color;
final double flapAmount;
const _FlappingBeePainter({required this.color, required this.flapAmount});
@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)
..restore();
}
@override
bool shouldRepaint(_FlappingBeePainter oldDelegate) =>
color != oldDelegate.color || flapAmount != oldDelegate.flapAmount;
}
@@ -0,0 +1,379 @@
import 'dart:async';
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/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<void>();
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<Transform>(
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 = <MethodCall>[];
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<void>();
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<Opacity>(
find.byKey(const ValueKey('bee-refresh-opacity')),
);
expect(partialOpacity.opacity, greaterThan(0));
expect(partialOpacity.opacity, lessThan(1));
final partialScale = tester
.widget<Transform>(find.byKey(const ValueKey('bee-refresh-scale')))
.transform
.storage[0];
expect(partialScale, greaterThan(0.6));
expect(partialScale, lessThan(1));
expect(tester.widget<FlappingBee>(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<FlappingBee>(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<Transform>(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<FlappingBee>(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<FlappingBee>(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<FlappingBee>(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<Transform>(
find.byKey(const ValueKey('bee-refresh-eyes-emoji-offset')),
)
.transform
.storage[12],
2,
);
final initialShake = tester
.widget<Transform>(
find.byKey(const ValueKey('bee-refresh-eyes-emoji-shake')),
)
.transform
.storage[12];
await tester.pump(const Duration(milliseconds: 35));
final movedShake = tester
.widget<Transform>(
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<FlappingBee>(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 = <MethodCall>[];
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<void>();
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<FlappingBee>(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<FlappingBee>(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<FlappingBee>(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<BouncingScrollPhysics>());
expect(physics.parent, isA<AlwaysScrollableScrollPhysics>());
});
}