mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Fizz
Wes
Carl
parent
c8743b2f20
commit
626e2c34a3
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>());
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user