feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,441 @@
|
||||
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 '../l10n/l10n.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: context.l10n.pullToRefresh,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user