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

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
@@ -0,0 +1,272 @@
import 'dart:math' as math;
import 'dart:ui' show SemanticsRole;
import 'package:flutter/material.dart';
import '../theme/theme.dart';
const _popoverEnterDuration = Duration(milliseconds: 150);
const _popoverExitDuration = Duration(milliseconds: 110);
const _popoverStartScale = 0.96;
/// Elevation shared by anchored menus and composer popover surfaces.
const appPopoverElevation = 8.0;
/// Returns the translucent surface color shared by app popovers.
Color appPopoverColor(BuildContext context) =>
context.colors.surface.withValues(alpha: 0.98);
/// Returns the shadow color shared by app popovers.
Color appPopoverShadowColor(BuildContext context) =>
context.colors.shadow.withValues(alpha: 0.18);
/// Returns the 20px shape and composer-matching hairline shared by popovers.
RoundedRectangleBorder appPopoverShape(BuildContext context) =>
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.popover),
side: BorderSide(color: Colors.black.withValues(alpha: 0.04), width: 1),
);
/// The horizontal edge a popover aligns to on its triggering control.
enum AnchoredPopoverAlignment {
/// Aligns the popover's leading edge with the trigger's leading edge.
start,
/// Centers the popover horizontally on the trigger.
center,
/// Aligns the popover's trailing edge with the trigger's trailing edge.
end,
}
/// Shows an anchored, cross-platform popup menu with the Activity controls'
/// sizing, motion, and safe-area placement.
Future<T?> showAnchoredPopover<T>({
required BuildContext context,
required List<PopupMenuEntry<T>> items,
required double width,
required AnchoredPopoverAlignment alignment,
Color? color,
ShapeBorder? shape,
double elevation = appPopoverElevation,
Color? shadowColor,
Offset offset = Offset.zero,
EdgeInsetsGeometry menuPadding = EdgeInsets.zero,
Clip clipBehavior = Clip.antiAlias,
Key? surfaceKey,
}) {
final navigator = Navigator.of(context);
final overlay = navigator.overlay;
final triggerRenderObject = context.findRenderObject();
final overlayRenderObject = overlay?.context.findRenderObject();
if (triggerRenderObject is! RenderBox || overlayRenderObject is! RenderBox) {
return Future<T?>.value();
}
final triggerRect = MatrixUtils.transformRect(
triggerRenderObject.getTransformTo(overlayRenderObject),
Offset.zero & triggerRenderObject.size,
);
final overlayRect = Offset.zero & overlayRenderObject.size;
final mediaQuery = MediaQuery.of(context);
return navigator.push<T>(
_AnchoredPopoverRoute<T>(
position: RelativeRect.fromRect(triggerRect, overlayRect),
items: items,
width: width,
alignment: alignment,
offset: offset,
color: color ?? appPopoverColor(context),
shape: shape ?? appPopoverShape(context),
elevation: elevation,
shadowColor: shadowColor ?? appPopoverShadowColor(context),
menuPadding: menuPadding,
clipBehavior: clipBehavior,
surfaceKey: surfaceKey,
screenPadding: EdgeInsets.fromLTRB(
math.max(Grid.xxs, mediaQuery.padding.left),
math.max(Grid.xxs, mediaQuery.padding.top),
math.max(Grid.xxs, mediaQuery.padding.right),
math.max(Grid.xxs, mediaQuery.padding.bottom),
),
reducedMotion: mediaQuery.disableAnimations,
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
),
);
}
class _AnchoredPopoverRoute<T> extends PopupRoute<T> {
final RelativeRect position;
final List<PopupMenuEntry<T>> items;
final double width;
final AnchoredPopoverAlignment alignment;
final Offset offset;
final Color color;
final ShapeBorder shape;
final double elevation;
final Color shadowColor;
final EdgeInsetsGeometry menuPadding;
final Clip clipBehavior;
final Key? surfaceKey;
final EdgeInsets screenPadding;
final bool reducedMotion;
final String _barrierLabel;
_AnchoredPopoverRoute({
required this.position,
required this.items,
required this.width,
required this.alignment,
required this.offset,
required this.color,
required this.shape,
required this.elevation,
required this.shadowColor,
required this.menuPadding,
required this.clipBehavior,
required this.surfaceKey,
required this.screenPadding,
required this.reducedMotion,
required String barrierLabel,
}) : _barrierLabel = barrierLabel;
@override
Color? get barrierColor => null;
@override
bool get barrierDismissible => true;
@override
String? get barrierLabel => _barrierLabel;
@override
Duration get transitionDuration =>
reducedMotion ? Duration.zero : _popoverEnterDuration;
@override
Duration get reverseTransitionDuration =>
reducedMotion ? Duration.zero : _popoverExitDuration;
@override
Widget buildPage(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
final curvedAnimation = animation.drive(
CurveTween(curve: Curves.easeOutCubic),
);
final scaleAnimation = Tween<double>(
begin: _popoverStartScale,
end: 1,
).animate(curvedAnimation);
final transformOrigin = switch (alignment) {
AnchoredPopoverAlignment.start => Alignment.topLeft,
AnchoredPopoverAlignment.center => Alignment.topCenter,
AnchoredPopoverAlignment.end => Alignment.topRight,
};
return CustomSingleChildLayout(
delegate: _AnchoredPopoverLayoutDelegate(
position: position,
alignment: alignment,
offset: offset,
screenPadding: screenPadding,
),
child: FadeTransition(
key: const ValueKey('activity-popover-fade'),
opacity: curvedAnimation,
child: ScaleTransition(
key: const ValueKey('activity-popover-scale'),
scale: scaleAnimation,
alignment: transformOrigin,
child: Material(
key: surfaceKey,
type: MaterialType.card,
color: color,
surfaceTintColor: Colors.transparent,
elevation: elevation,
shadowColor: shadowColor,
shape: shape,
clipBehavior: clipBehavior,
child: SizedBox(
width: width,
child: Semantics(
role: SemanticsRole.menu,
scopesRoute: true,
namesRoute: true,
explicitChildNodes: true,
child: SingleChildScrollView(
padding: menuPadding,
child: ListBody(children: items),
),
),
),
),
),
),
);
}
}
class _AnchoredPopoverLayoutDelegate extends SingleChildLayoutDelegate {
final RelativeRect position;
final AnchoredPopoverAlignment alignment;
final Offset offset;
final EdgeInsets screenPadding;
const _AnchoredPopoverLayoutDelegate({
required this.position,
required this.alignment,
required this.offset,
required this.screenPadding,
});
@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
return BoxConstraints.loose(
Size(
constraints.maxWidth - screenPadding.horizontal,
constraints.maxHeight - screenPadding.vertical,
),
);
}
@override
Offset getPositionForChild(Size size, Size childSize) {
final anchorBottom = size.height - position.bottom;
final desiredX = switch (alignment) {
AnchoredPopoverAlignment.start => position.left + offset.dx,
AnchoredPopoverAlignment.center =>
position.left +
(size.width - position.left - position.right - childSize.width) /
2 +
offset.dx,
AnchoredPopoverAlignment.end =>
size.width - position.right - childSize.width + offset.dx,
};
final minX = screenPadding.left;
final maxX = size.width - screenPadding.right - childSize.width;
final x = desiredX.clamp(minX, maxX).toDouble();
final belowY = anchorBottom + offset.dy;
final aboveY = position.top - childSize.height - offset.dy;
final maxY = size.height - screenPadding.bottom - childSize.height;
final desiredY =
belowY + childSize.height <= size.height - screenPadding.bottom
? belowY
: aboveY;
final y = desiredY.clamp(screenPadding.top, maxY).toDouble();
return Offset(x, y);
}
@override
bool shouldRelayout(_AnchoredPopoverLayoutDelegate oldDelegate) {
return position != oldDelegate.position ||
alignment != oldDelegate.alignment ||
offset != oldDelegate.offset ||
screenPadding != oldDelegate.screenPadding;
}
}
+172
View File
@@ -0,0 +1,172 @@
import 'package:flutter/material.dart';
import '../theme/theme.dart';
import 'app_list_inset.dart';
/// Widest an inline [AppListRow.value] may grow before it ellipsises, leaving
/// room for a reasonable title beside it.
const double _maxValueWidth = 180.0;
/// Row height comes entirely from this padding — rows carry a single line of
/// text most of the time, so it sets how airy a card reads.
const double _rowVerticalPadding = Grid.xs;
/// A flush, borderless settings/list row: leading icon, title, optional
/// subtitle and trailing widget. Its own background comes from whatever
/// contains it — a grouped card, or the page itself.
class AppListRow extends StatelessWidget {
const AppListRow({
super.key,
this.icon,
required this.title,
this.subtitle,
this.subtitleStyle,
this.subtitleMaxLines,
this.value,
this.trailing,
this.titleColor,
this.onTap,
});
final IconData? icon;
final String title;
final String? subtitle;
final TextStyle? subtitleStyle;
final int? subtitleMaxLines;
/// The row's current setting, shown muted on the trailing side next to
/// [trailing] — for rows whose value is short enough to sit inline.
final String? value;
final Widget? trailing;
final Color? titleColor;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final row = Padding(
padding: EdgeInsets.symmetric(
horizontal: AppListInset.of(context),
vertical: _rowVerticalPadding,
),
child: Row(
// Centred rather than baseline-aligned to the title: on a two-line row
// the icon and trailing control read as belonging to the row, not to
// its first line.
children: [
if (icon != null) ...[
Icon(
icon,
size: 22,
color: titleColor ?? context.colors.onSurfaceVariant,
),
const SizedBox(width: Grid.xs),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: context.textTheme.bodyLarge?.copyWith(
color: titleColor,
),
),
if (subtitle != null) ...[
const SizedBox(height: Grid.quarter),
Text(
subtitle!,
style:
subtitleStyle ??
context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
maxLines: subtitleMaxLines,
overflow: subtitleMaxLines == null
? null
: TextOverflow.ellipsis,
),
],
],
),
),
if (value != null) ...[
const SizedBox(width: Grid.xxs),
// Inflexible, so the title's Expanded absorbs the slack and the
// value stays flush against the trailing edge; capped instead of
// flexed so a long value ellipsises rather than overflowing.
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: _maxValueWidth),
child: Text(
value!,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
),
),
],
if (trailing != null) ...[const SizedBox(width: Grid.xxs), trailing!],
],
),
);
if (onTap == null) return row;
return InkWell(onTap: onTap, child: row);
}
}
/// A custom leading widget variant of [AppListRow] for rows whose leading
/// slot is not a plain [IconData] (e.g. an emoji or image).
class AppListRowRaw extends StatelessWidget {
const AppListRowRaw({
super.key,
required this.leading,
required this.title,
this.subtitle,
this.trailing,
this.onTap,
});
final Widget leading;
final Widget title;
final Widget? subtitle;
final Widget? trailing;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final row = Padding(
padding: EdgeInsets.symmetric(
horizontal: AppListInset.of(context),
vertical: _rowVerticalPadding,
),
child: Row(
children: [
leading,
const SizedBox(width: Grid.xs),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
title,
if (subtitle != null) ...[
const SizedBox(height: Grid.quarter),
subtitle!,
],
],
),
),
if (trailing != null) ...[const SizedBox(width: Grid.xxs), trailing!],
],
),
);
if (onTap == null) return row;
return InkWell(onTap: onTap, child: row);
}
}
@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import '../theme/theme.dart';
import 'app_list_inset.dart';
/// A group of list rows in a rounded container, with an optional [label] above
/// it. Rows inside are hairline-separated and inset to the card rather than the
/// page, via [AppListInset].
class AppListCard extends StatelessWidget {
const AppListCard({super.key, this.label, required this.children});
/// Rendered above the card in sentence case, as written — no uppercasing.
final String? label;
final List<Widget> children;
static const _inset = Grid.xs;
/// Separators start at the label column, clearing the leading icon.
static const _dividerIndent = _inset + _iconColumnWidth;
static const _iconColumnWidth = 22.0 + Grid.xs;
@override
Widget build(BuildContext context) {
final separated = <Widget>[];
for (var index = 0; index < children.length; index++) {
if (index > 0) {
separated.add(
Divider(
height: 1,
thickness: 1,
indent: _dividerIndent,
endIndent: _inset,
// The scheme's own border tokens are derived from the page surface,
// which lands them within a few levels of the card fill — invisible.
// Tinting with the text color instead keeps the hairline readable on
// the card in both brightnesses.
color: context.colors.onSurface.withValues(alpha: 0.12),
),
);
}
separated.add(children[index]);
}
return Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
Grid.xxs,
Grid.gutter,
Grid.xxs,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (label != null)
Padding(
padding: const EdgeInsets.only(left: Grid.half, bottom: Grid.xxs),
child: Text(
label!,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
),
Material(
// The softest step on the elevation ramp — a rung below the home tab
// bar's active pill (primaryContainer, see PR #2810), since a
// full-width card at the pill's contrast reads heavier than the pill
// does. The dividers carry the group structure, so the fill only has
// to separate the card from the page.
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.card),
// Keeps row ripples inside the rounded corners.
clipBehavior: Clip.antiAlias,
child: AppListInset(
horizontal: _inset,
child: Column(children: separated),
),
),
],
),
);
}
}
@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import '../theme/theme.dart';
/// The horizontal padding list rows use, allowing grouped containers to replace
/// the page-level inset without changing each row.
class AppListInset extends InheritedWidget {
const AppListInset({
super.key,
required this.horizontal,
required super.child,
});
final double horizontal;
static double of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<AppListInset>()?.horizontal ??
Grid.gutter;
@override
bool updateShouldNotify(AppListInset oldWidget) =>
oldWidget.horizontal != horizontal;
}
+176
View File
@@ -0,0 +1,176 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../relay/relay.dart';
/// A circular avatar that supports both remote URLs and inline image data.
///
/// Flutter's [NetworkImage] only loads network URLs, while desktop browsers also
/// accept `data:image/*` sources directly. Agent emoji avatars are inline SVGs,
/// so mobile must decode those before rendering them.
class AvatarImage extends StatelessWidget {
final String? imageUrl;
final double radius;
final Color? backgroundColor;
final Widget fallback;
const AvatarImage({
super.key,
required this.imageUrl,
required this.radius,
required this.fallback,
this.backgroundColor,
});
@override
Widget build(BuildContext context) {
return CircleAvatar(
radius: radius,
backgroundColor: backgroundColor,
child: ClipOval(
child: SizedBox.square(
dimension: radius * 2,
child: AvatarImageContent(imageUrl: imageUrl, fallback: fallback),
),
),
);
}
}
/// Image content for avatar surfaces whose shape is supplied by their parent.
class AvatarImageContent extends StatefulWidget {
final String? imageUrl;
final Widget fallback;
final BoxFit fit;
const AvatarImageContent({
super.key,
required this.imageUrl,
required this.fallback,
this.fit = BoxFit.cover,
});
@override
State<AvatarImageContent> createState() => _AvatarImageContentState();
}
class _AvatarImageContentState extends State<AvatarImageContent> {
late _AvatarSource? _source = _AvatarSource.parse(widget.imageUrl);
@override
void didUpdateWidget(AvatarImageContent oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.imageUrl != oldWidget.imageUrl) {
_source = _AvatarSource.parse(widget.imageUrl);
}
}
@override
Widget build(BuildContext context) {
final centeredFallback = Center(child: widget.fallback);
return switch (_source) {
_EmojiAvatarSource(:final emoji, :final color) => ColoredBox(
color: color,
child: LayoutBuilder(
builder: (_, constraints) => Center(
child: Text(
emoji,
textScaler: TextScaler.noScaling,
style: TextStyle(
fontSize: constraints.biggest.shortestSide * 258 / 512,
height: 1,
),
),
),
),
),
_SvgAvatarSource(:final svg) => SvgPicture.string(
svg,
fit: widget.fit,
placeholderBuilder: (_) => centeredFallback,
errorBuilder: (_, _, _) => centeredFallback,
),
_RasterDataAvatarSource(:final bytes) => Image.memory(
bytes,
fit: widget.fit,
errorBuilder: (_, _, _) => centeredFallback,
),
_NetworkAvatarSource(:final url) => MediaImage(
url: url,
fit: widget.fit,
errorBuilder: (_, _, _) => centeredFallback,
),
null => centeredFallback,
};
}
}
sealed class _AvatarSource {
const _AvatarSource();
static _AvatarSource? parse(String? value) {
final url = value?.trim();
if (url == null || url.isEmpty) return null;
if (!url.startsWith('data:image/')) return _NetworkAvatarSource(url);
try {
final data = UriData.parse(url);
if (data.mimeType == 'image/svg+xml') {
final Uint8List bytes = data.contentAsBytes();
final svg = utf8.decode(bytes);
return _parseEmojiAvatar(svg) ?? _SvgAvatarSource(svg);
}
return _RasterDataAvatarSource(data.contentAsBytes());
} on FormatException {
return null;
}
}
}
_EmojiAvatarSource? _parseEmojiAvatar(String svg) {
final colorValue = RegExp(
r'<rect\b[^>]*\sfill="([^"]+)"',
).firstMatch(svg)?[1];
final emojiValue = RegExp(r'<text\b[^>]*>(.*?)</text>').firstMatch(svg)?[1];
if (colorValue == null || emojiValue == null) return null;
final color = _parseHexColor(colorValue);
if (color == null) return null;
final emoji = emojiValue
.replaceAll('&gt;', '>')
.replaceAll('&lt;', '<')
.replaceAll('&amp;', '&');
return _EmojiAvatarSource(emoji, color);
}
Color? _parseHexColor(String value) {
final hex = value.startsWith('#') ? value.substring(1) : value;
if (!RegExp(r'^[0-9a-fA-F]{6}$').hasMatch(hex)) return null;
final rgb = int.tryParse(hex, radix: 16);
return rgb == null ? null : Color(0xFF000000 | rgb);
}
class _EmojiAvatarSource extends _AvatarSource {
final String emoji;
final Color color;
const _EmojiAvatarSource(this.emoji, this.color);
}
class _SvgAvatarSource extends _AvatarSource {
final String svg;
const _SvgAvatarSource(this.svg);
}
class _RasterDataAvatarSource extends _AvatarSource {
final Uint8List bytes;
const _RasterDataAvatarSource(this.bytes);
}
class _NetworkAvatarSource extends _AvatarSource {
final String url;
const _NetworkAvatarSource(this.url);
}
@@ -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,
),
),
),
),
),
),
],
),
),
),
),
),
),
),
),
),
],
);
}
}
@@ -0,0 +1,99 @@
import 'dart:math' show pi;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../l10n/l10n.dart';
import '../theme/theme.dart';
/// The shared mobile loading indicator, matching the desktop arc spinner.
class BuzzLoadingIndicator extends HookConsumerWidget {
/// The spinner diameter.
final double size;
/// An optional spinner color. Defaults to the active accent color.
final Color? color;
/// The accessibility announcement for this loading state.
final String? semanticLabel;
/// Creates a looping arc loading indicator.
const BuzzLoadingIndicator({
this.size = 40,
this.color,
this.semanticLabel,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final animation = useAnimationController(
duration: const Duration(milliseconds: 500),
);
useEffect(() {
if (reducedMotion) {
animation
..stop()
..value = 0;
} else {
animation.repeat();
}
return animation.stop;
}, [animation, reducedMotion]);
final spinnerColor = color ?? context.colors.primary;
final strokeWidth = (size / 6).clamp(2.0, 4.0);
return Semantics(
liveRegion: true,
label: semanticLabel ?? context.l10n.loading,
child: ExcludeSemantics(
child: RotationTransition(
key: const ValueKey('buzz-loading-indicator-spinner'),
turns: animation,
child: CustomPaint(
size: Size.square(size),
painter: _ArcSpinnerPainter(
color: spinnerColor,
strokeWidth: strokeWidth,
),
),
),
),
);
}
}
class _ArcSpinnerPainter extends CustomPainter {
final Color color;
final double strokeWidth;
const _ArcSpinnerPainter({required this.color, required this.strokeWidth});
@override
void paint(Canvas canvas, Size size) {
final center = size.center(Offset.zero);
final radius = (size.shortestSide - strokeWidth) / 2;
final bounds = Rect.fromCircle(center: center, radius: radius);
final trackPaint = Paint()
..color = color.withValues(alpha: color.a * 0.1)
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth;
final arcPaint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth;
canvas
..drawCircle(center, radius, trackPaint)
..drawArc(bounds, -pi / 2, pi / 2, false, arcPaint);
}
@override
bool shouldRepaint(_ArcSpinnerPainter oldDelegate) {
return color != oldDelegate.color || strokeWidth != oldDelegate.strokeWidth;
}
}
@@ -0,0 +1,91 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import '../theme/theme.dart';
/// An iOS-native sheet surface that adopts the system's concentric corners on
/// iOS 26 and newer. Other platforms keep the normal Flutter shape.
class ConcentricSheetSurface extends HookWidget {
const ConcentricSheetSurface({
required this.child,
required this.enabled,
this.color,
super.key,
});
final Widget child;
final bool enabled;
final Color? color;
static const _surfaceChannel = MethodChannel('buzz/concentric_sheet_surface');
Future<bool> _checkNativeSurfaceSupport() async {
try {
final supported = await _surfaceChannel.invokeMethod<bool>('isSupported');
return supported == true;
} on MissingPluginException {
// The registrar is unavailable, so retain the Flutter surface.
return false;
} on PlatformException {
// The native surface is optional; retain the Flutter surface on failure.
return false;
}
}
@override
Widget build(BuildContext context) {
final shouldCheckNativeSurface =
enabled && defaultTargetPlatform == TargetPlatform.iOS;
final supportFuture = useMemoized(
() => shouldCheckNativeSurface
? _checkNativeSurfaceSupport()
: Future<bool>.value(false),
[shouldCheckNativeSurface],
);
final nativeSurfaceSupported = useFuture(supportFuture).data ?? false;
if (!shouldCheckNativeSurface) {
return child;
}
final surfaceColor = color ?? context.colors.surface;
return Padding(
padding: const EdgeInsets.only(
left: Grid.xxs,
right: Grid.xxs,
bottom: Grid.xxs,
),
child: Stack(
children: [
if (nativeSurfaceSupported)
Positioned.fill(
child: ExcludeSemantics(
child: UiKitView(
viewType: 'buzz/concentric_sheet_surface',
hitTestBehavior: PlatformViewHitTestBehavior.transparent,
creationParams: <String, Object>{
'color': surfaceColor.toARGB32(),
'minimumRadius': Radii.dialog,
},
creationParamsCodec: const StandardMessageCodec(),
),
),
)
else
Positioned.fill(
child: Material(
color: surfaceColor,
borderRadius: BorderRadius.circular(Radii.dialog),
clipBehavior: Clip.antiAlias,
),
),
child,
],
),
);
}
}
@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
/// Supplies a shared directional entrance to separate foreground surfaces.
///
/// Descendants opt in with [DirectionalTransitionMotion], which lets a page
/// move its body and app-bar content together while leaving decorative
/// backgrounds stationary.
class DirectionalTransitionScope extends InheritedWidget {
/// Horizontal displacement remaining in the transition.
final double horizontalOffset;
/// Current foreground opacity, from zero to one.
final double opacity;
/// Creates a directional transition scope.
const DirectionalTransitionScope({
super.key,
required this.horizontalOffset,
required this.opacity,
required super.child,
});
/// Returns the closest transition, or null outside a transitioning surface.
static DirectionalTransitionScope? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<DirectionalTransitionScope>();
@override
bool updateShouldNotify(DirectionalTransitionScope oldWidget) =>
horizontalOffset != oldWidget.horizontalOffset ||
opacity != oldWidget.opacity;
}
/// Applies the nearest [DirectionalTransitionScope] to one foreground layer.
class DirectionalTransitionMotion extends StatelessWidget {
/// Foreground content that participates in the shared transition.
final Widget child;
/// Optional key for inspecting the composited translation layer.
final Key? transformKey;
/// Optional key for inspecting the composited opacity layer.
final Key? opacityKey;
/// Creates a foreground transition participant.
const DirectionalTransitionMotion({
super.key,
required this.child,
this.transformKey,
this.opacityKey,
});
@override
Widget build(BuildContext context) {
final transition = DirectionalTransitionScope.maybeOf(context);
if (transition == null) return child;
return Transform.translate(
key: transformKey,
offset: Offset(transition.horizontalOffset, 0),
child: Opacity(
key: opacityKey,
opacity: transition.opacity,
child: child,
),
);
}
}
@@ -0,0 +1,160 @@
import 'package:flutter/material.dart';
import '../theme/theme.dart';
/// A single entry in a [FilterChipBar].
class FilterChipItem<T> {
/// Value identifying this chip; compared against the bar's `selected`.
final T id;
/// Visible label.
final String label;
/// Optional leading icon.
final IconData? icon;
/// Optional count appended to the label as " (n)".
final int? count;
const FilterChipItem({
required this.id,
required this.label,
this.icon,
this.count,
});
}
/// A horizontally-scrolling row of Material [FilterChip]s.
///
/// This is the single shared filter/segment selector used across Pulse,
/// Search, and Activity. It relies on the app's `chipTheme` for styling so
/// every screen looks identical — do not hand-roll chip rows elsewhere.
class FilterChipBar<T> extends StatelessWidget {
final List<FilterChipItem<T>> items;
final T selected;
final ValueChanged<T> onSelected;
/// When enabled, every chip shares the width rather than scrolling.
final bool expandItems;
/// Material density applied to each chip.
final VisualDensity visualDensity;
/// Vertical padding around the content inside each chip.
final double chipVerticalPadding;
/// Vertical spacing around the chip row.
final double barVerticalPadding;
const FilterChipBar({
super.key,
required this.items,
required this.selected,
required this.onSelected,
this.expandItems = false,
this.visualDensity = VisualDensity.compact,
this.chipVerticalPadding = Grid.quarter,
this.barVerticalPadding = Grid.xxs,
});
@override
Widget build(BuildContext context) {
final chips = [
for (var index = 0; index < items.length; index++) ...[
if (index > 0) const SizedBox(width: Grid.xxs),
if (expandItems)
Expanded(child: _chip(context, items[index], fillWidth: true))
else
_chip(context, items[index]),
],
];
if (expandItems) {
return Padding(
padding: EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: barVerticalPadding,
),
child: Row(children: chips),
);
}
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: barVerticalPadding,
),
child: Row(children: chips),
);
}
Widget _chip(
BuildContext context,
FilterChipItem<T> item, {
bool fillWidth = false,
}) {
final isSelected = selected == item.id;
final text = item.count != null
? '${item.label} (${item.count})'
: item.label;
final fg = isSelected
? context.colors.onPrimary
: context.colors.onSurfaceVariant;
final labelStyle = filterChipTextStyle.copyWith(
color: fg,
fontWeight: isSelected ? FontWeight.w500 : FontWeight.w400,
);
final label = item.icon != null
? Row(
mainAxisSize: fillWidth ? MainAxisSize.max : MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(item.icon, size: 14, color: fg),
const SizedBox(width: Grid.half),
if (fillWidth)
Flexible(
child: Text(
text,
textAlign: TextAlign.center,
style: labelStyle,
),
)
else
Text(text, style: labelStyle),
],
)
: Text(
text,
textAlign: fillWidth ? TextAlign.center : TextAlign.start,
style: labelStyle,
);
final centeredLabel = Align(
alignment: Alignment.center,
widthFactor: fillWidth ? null : 1,
heightFactor: 1,
child: label,
);
final chip = FilterChip(
selected: isSelected,
showCheckmark: false,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.lg),
),
side: BorderSide.none,
label: fillWidth
? SizedBox(width: double.infinity, child: centeredLabel)
: centeredLabel,
labelPadding: EdgeInsets.zero,
onSelected: (_) => onSelected(item.id),
padding: EdgeInsets.symmetric(
horizontal: fillWidth ? Grid.quarter : Grid.twelve,
vertical: chipVerticalPadding,
),
visualDensity: visualDensity,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
return fillWidth ? SizedBox(width: double.infinity, child: chip) : chip;
}
}
+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;
}
@@ -0,0 +1,298 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../l10n/l10n.dart';
import '../theme/theme.dart';
import 'directional_transition_scope.dart';
/// Minimum height of the frosted app bar content area below the safe area.
const _kBarContentMinHeight = Grid.xxs + 32 + Grid.xxs; // 48
const _kBottomBorderWidth = 1.0;
TextStyle _effectiveTitleStyle(BuildContext context, TextStyle? titleStyle) {
final baseStyle =
context.textTheme.titleMedium ??
const TextStyle(fontSize: 20, height: 1.3);
return baseStyle.copyWith(fontWeight: FontWeight.w600).merge(titleStyle);
}
double _barContentHeight(
BuildContext context,
TextStyle? titleStyle,
double titleContentHeight,
) {
final style = _effectiveTitleStyle(context, titleStyle);
final scaledFontSize = MediaQuery.textScalerOf(
context,
).scale(style.fontSize ?? 20);
final scaledTitleHeight = scaledFontSize * (style.height ?? 1);
final effectiveTitleHeight = titleContentHeight > scaledTitleHeight
? titleContentHeight
: scaledTitleHeight;
final accessibleHeight = Grid.xxs + effectiveTitleHeight + Grid.xxs;
return accessibleHeight > _kBarContentMinHeight
? accessibleHeight
: _kBarContentMinHeight;
}
/// Height for a compact title rail below the app bar's action row.
///
/// The rail normally stays at 40dp, but grows with an accessible title rather
/// than clipping text at larger system text sizes.
double frostedAppBarLowerTitleHeight(
BuildContext context, {
TextStyle? titleStyle,
}) {
final style = _effectiveTitleStyle(context, titleStyle);
final scaledFontSize = MediaQuery.textScalerOf(
context,
).scale(style.fontSize ?? 20);
final titleHeight = scaledFontSize * (style.height ?? 1);
return titleHeight > 40 ? titleHeight : 40;
}
/// Returns the total height of the [FrostedAppBar] including safe area padding.
///
/// Use this to add top spacing to body content so it starts below the bar.
/// Pass the same [titleStyle] and [titleContentHeight] to the bar and this
/// helper when customizing them.
double frostedAppBarHeight(
BuildContext context, {
double bottomHeight = 0,
TextStyle? titleStyle,
double titleContentHeight = 0,
}) {
return MediaQuery.paddingOf(context).top +
_barContentHeight(context, titleStyle, titleContentHeight) +
bottomHeight +
_kBottomBorderWidth;
}
/// A frosted-glass floating app bar designed to sit inside a [Stack].
///
/// Renders as a [Positioned] widget pinned to the top of its parent Stack.
/// Content scrolls underneath with a translucent backdrop blur effect.
class FrostedAppBar extends StatelessWidget {
/// Widget displayed on the leading (left) side. If null and the navigator
/// can pop, a back button is shown automatically.
final Widget? leading;
/// Whether to infer a back button from the current navigator.
final bool automaticallyImplyLeading;
/// Widget displayed in the center/title area.
final Widget? title;
/// Optional style merged over the default title style.
final TextStyle? titleStyle;
/// Scaled height needed by a custom title with multiple text lines.
///
/// Pass the same value to [frostedAppBarHeight] when spacing body content.
final double titleContentHeight;
/// Optional content displayed below the title row in the same surface.
final Widget? bottom;
/// Height reserved for [bottom].
final double bottomHeight;
/// Extends [bottom] upward into the title row without moving the app bar's
/// outer bounds. This keeps overlapping controls inside the app bar's hit
/// test region as well as its paint region.
final double bottomOverlap;
/// Widgets displayed on the trailing (right) side.
final List<Widget> actions;
/// Horizontal inset for the app bar's leading, title, and actions.
final double horizontalInset;
/// Color applied to icons in the app bar.
final Color? iconColor;
/// Paints over the frosted fill instead of the default translucent surface.
/// Used by the Buzz themes to carry their branded gradient across the app's
/// top section — see [buzzTopSectionGradient].
final Gradient? gradient;
/// Whether to apply the translucent blur treatment behind the app bar.
///
/// A page can leave its painted backdrop exposed at rest, then turn this on
/// when scrolling moves content beneath the controls.
final bool frosted;
/// Opacity of the frosted surface above the blurred backdrop.
final double frostedSurfaceOpacity;
/// Blur strength of the frosted backdrop.
final double frostedBlurSigma;
/// Whether to draw a divider below the app bar.
final bool showBottomDivider;
/// Opacity of the divider below the app bar.
final double bottomDividerOpacity;
const FrostedAppBar({
super.key,
this.leading,
this.automaticallyImplyLeading = true,
this.title,
this.titleStyle,
this.titleContentHeight = 0,
this.bottom,
this.bottomHeight = 0,
this.bottomOverlap = 0,
this.actions = const [],
this.horizontalInset = Grid.quarter,
this.iconColor,
this.gradient,
this.frosted = true,
this.frostedSurfaceOpacity = 0.5,
this.frostedBlurSigma = 20,
this.showBottomDivider = true,
this.bottomDividerOpacity = 0.15,
}) : assert(bottom == null || bottomHeight > 0),
assert(bottomOverlap >= 0),
assert(bottom != null || bottomOverlap == 0),
assert(frostedBlurSigma >= 0),
assert(bottomDividerOpacity >= 0 && bottomDividerOpacity <= 1);
@override
Widget build(BuildContext context) {
final topPadding = MediaQuery.paddingOf(context).top;
final canPop = Navigator.canPop(context);
final effectiveTitleStyle = _effectiveTitleStyle(context, titleStyle);
final barContentHeight = _barContentHeight(
context,
titleStyle,
titleContentHeight,
);
final effectiveLeading =
leading ??
(automaticallyImplyLeading && canPop
? SizedBox(
width: 48,
height: 48,
child: IconButton(
onPressed: () => Navigator.of(context).pop(),
color: iconColor,
icon: const Icon(LucideIcons.chevronLeft),
tooltip: context.l10n.back,
),
)
: null);
final titleRow = SizedBox(
height: barContentHeight,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: horizontalInset),
child: IconTheme.merge(
data: IconThemeData(color: iconColor),
child: Row(
children: [
?effectiveLeading,
if (title != null)
Expanded(
child: Padding(
padding: EdgeInsets.only(
left: effectiveLeading != null
? 0
: horizontalInset < Grid.gutter
? Grid.gutter - horizontalInset
: 0,
right: actions.isEmpty
? horizontalInset < Grid.gutter
? Grid.gutter - horizontalInset
: 0
: 0,
),
child: DefaultTextStyle.merge(
style: effectiveTitleStyle,
overflow: TextOverflow.ellipsis,
maxLines: 1,
child: title!,
),
),
)
else
const Spacer(),
...actions,
],
),
),
),
);
final contentBody = bottom != null && bottomOverlap > 0
? SizedBox(
height: barContentHeight + bottomHeight,
child: Stack(
children: [
Positioned(top: 0, left: 0, right: 0, child: titleRow),
Positioned(
top: barContentHeight - bottomOverlap,
left: 0,
right: 0,
height: bottomHeight + bottomOverlap,
child: bottom!,
),
],
),
)
: Column(
mainAxisSize: MainAxisSize.min,
children: [
titleRow,
if (bottom != null) SizedBox(height: bottomHeight, child: bottom),
],
);
final content = DirectionalTransitionMotion(
transformKey: const ValueKey(
'frosted-app-bar-content-transition-transform',
),
opacityKey: const ValueKey('frosted-app-bar-content-transition-opacity'),
child: contentBody,
);
final background = Container(
key: const ValueKey('frosted-app-bar-background'),
padding: EdgeInsets.only(top: topPadding),
decoration: BoxDecoration(
color: !frosted
? Colors.transparent
: gradient == null
? context.colors.surface.withValues(alpha: frostedSurfaceOpacity)
: null,
gradient: gradient,
border: showBottomDivider
? Border(
bottom: BorderSide(
color: navigationDivider(context, bottomDividerOpacity),
width: _kBottomBorderWidth,
),
)
: null,
),
child: content,
);
final child = ClipRect(
child: frosted
? BackdropFilter(
filter: ImageFilter.blur(
sigmaX: frostedBlurSigma,
sigmaY: frostedBlurSigma,
),
child: background,
)
: background,
);
return Positioned(top: 0, left: 0, right: 0, child: child);
}
}
@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'directional_transition_scope.dart';
import 'frosted_app_bar.dart';
/// A convenience [Scaffold] that overlays a [FrostedAppBar] on top of its body.
///
/// The body is rendered full-bleed inside a [Stack] with the frosted app bar
/// floating above it. The body is responsible for adding its own top spacing
/// using [frostedAppBarHeight] so content starts below the bar.
class FrostedScaffold extends StatelessWidget {
/// The frosted app bar displayed at the top of the screen.
final FrostedAppBar appBar;
/// The primary content of the scaffold. Must handle its own top spacing
/// using [frostedAppBarHeight] — the scaffold does NOT add automatic padding.
final Widget body;
/// Optional floating action button, passed through to [Scaffold].
final Widget? floatingActionButton;
/// Whether the body should resize when the on-screen keyboard appears.
final bool? resizeToAvoidBottomInset;
/// Optional scaffold background, useful when a parent supplies a shared
/// surface behind this page.
final Color? backgroundColor;
/// A fixed gradient painted behind the app bar and scrolling body.
final Gradient? backgroundGradient;
const FrostedScaffold({
super.key,
required this.appBar,
required this.body,
this.floatingActionButton,
this.resizeToAvoidBottomInset,
this.backgroundColor,
this.backgroundGradient,
});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: backgroundColor,
resizeToAvoidBottomInset: resizeToAvoidBottomInset,
floatingActionButton: floatingActionButton,
body: Stack(children: _stackChildren()),
);
}
List<Widget> _stackChildren() {
final backdrop = backgroundGradient == null
? const <Widget>[]
: [
Positioned.fill(
child: _PinnedGradientBackground(gradient: backgroundGradient!),
),
];
final bodyMotion = DirectionalTransitionMotion(
transformKey: const ValueKey(
'frosted-scaffold-body-transition-transform',
),
opacityKey: const ValueKey('frosted-scaffold-body-transition-opacity'),
child: body,
);
// The bar must be painted after the scrollable sheet: [BackdropFilter]
// only samples pixels that were already painted behind it. This is the
// same composition as channel navigation, so top-level headers blur their
// content rather than only the fixed gradient.
return [...backdrop, bodyMotion, appBar];
}
}
class _PinnedGradientBackground extends StatelessWidget {
final Gradient gradient;
const _PinnedGradientBackground({required this.gradient});
@override
Widget build(BuildContext context) => DecoratedBox(
key: const ValueKey('frosted-scaffold-pinned-gradient'),
decoration: BoxDecoration(gradient: gradient),
);
}
@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
/// How far a finger must travel downward before the keyboard is dismissed.
///
/// Large enough that reading-speed scrolling never trips it, small enough that
/// a deliberate "push it away" swipe feels immediate.
const keyboardDismissDragThreshold = 48.0;
/// Dismiss the software keyboard when the user deliberately drags downward.
///
/// Flutter only offers `ScrollViewKeyboardDismissBehavior.manual` and `.onDrag`
/// — and `.onDrag` fires the instant a drag begins, so a one-pixel scroll to
/// re-read a message would tear the keyboard away. Neither is available on
/// `ScrollablePositionedList` regardless, which is what both message lists use.
///
/// So: accumulate raw finger travel and only unfocus once a downward drag
/// clears [keyboardDismissDragThreshold]. Any upward movement resets the
/// accumulator, so a scroll that wanders up and down never adds up to a
/// dismissal.
///
/// This tracks the *finger*, not the scroll direction, so it behaves the same
/// in the reversed channel timeline and the top-anchored thread list. It is not
/// interactive dismissal — the keyboard animates away on the OS's own curve
/// rather than following the finger, which Flutter can't do without a native
/// plugin (iOS `UIScrollView.keyboardDismissMode`, Android
/// `WindowInsetsAnimationController`).
class KeyboardDismissOnDrag extends HookWidget {
final Widget child;
final VoidCallback? onUserScrollStart;
const KeyboardDismissOnDrag({
super.key,
this.onUserScrollStart,
required this.child,
});
@override
Widget build(BuildContext context) {
// A ref, not state: accumulating travel must never trigger a rebuild of
// the message list this wraps.
final downwardTravel = useRef(0.0);
bool handle(ScrollNotification notification) {
if (notification is ScrollStartNotification) {
if (notification.dragDetails != null) onUserScrollStart?.call();
downwardTravel.value = 0;
return false;
}
if (notification is ScrollEndNotification) {
downwardTravel.value = 0;
return false;
}
// Overscroll counts as much as ordinary scrolling. You focus the composer
// at the resting end of the list far more often than not, and there a
// downward drag moves the position nowhere — it is pure overscroll, and
// reports `OverscrollNotification` instead. Listening only for
// `ScrollUpdateNotification` meant the most common dismissal gesture in
// both lists produced no notification this saw at all.
final DragUpdateDetails? drag;
if (notification is ScrollUpdateNotification) {
drag = notification.dragDetails;
} else if (notification is OverscrollNotification) {
drag = notification.dragDetails;
} else {
return false;
}
// Null during a momentum fling — only a finger still on the glass should
// dismiss, so a fling that coasts downward leaves the keyboard up.
if (drag == null) {
downwardTravel.value = 0;
return false;
}
final dy = drag.delta.dy;
if (dy <= 0) {
downwardTravel.value = 0;
return false;
}
downwardTravel.value += dy;
if (downwardTravel.value < keyboardDismissDragThreshold) return false;
downwardTravel.value = 0;
dismissKeyboard(context);
return false;
}
return NotificationListener<ScrollNotification>(
onNotification: handle,
child: child,
);
}
}
/// Drop focus so the OS retracts the keyboard. No-op when nothing is focused
/// or the keyboard is already down, so callers can fire it freely.
void dismissKeyboard(BuildContext context) {
// Read the view's own insets, not MediaQuery's. `Scaffold` strips the bottom
// view inset from its body while `resizeToAvoidBottomInset` is on — it has
// already resized to account for it — so a MediaQuery read from anywhere
// inside the body reports 0 whether the keyboard is up or not, which made
// this gate a permanent no-op for both message lists and the compose bar.
if (View.of(context).viewInsets.bottom <= 0) return;
FocusManager.instance.primaryFocus?.unfocus();
}
@@ -0,0 +1,356 @@
import 'dart:math' as math;
import 'package:flutter/widgets.dart';
/// Avatar-with-badge geometry ported from desktop's `MaskedAvatarBadgeFrame`
/// (`desktop/src/features/profile/ui/MaskedAvatarBadgeFrame.tsx`).
///
/// A badge (presence dot, status glyph) sits in a notch cut out of the avatar
/// rather than on top of it. The notch is not a plain circular subtraction: the
/// two edges are joined by cubic fillets so the avatar's outline flows into the
/// cutout instead of meeting it at a cusp. That fillet is the whole visual
/// signature, which is why the curve constants are ported verbatim.
@immutable
class AvatarBadgeCurve {
const AvatarBadgeCurve({
this.avatarRoundingAngle = 0.075,
this.cutoutRoundingLength = 5.5,
this.cutoutRoundingMinAngle = 0.22,
this.cutoutRoundingMaxAngle = 0.38,
this.handleDistanceRatio = 0.42,
this.handleLengthRatio = 0.14,
});
/// Radians the avatar's edge pulls back from the intersection before the
/// fillet starts.
final double avatarRoundingAngle;
/// Fillet length along the cutout, converted to an angle by dividing by the
/// cutout radius and clamped between the two angle bounds below.
final double cutoutRoundingLength;
final double cutoutRoundingMinAngle;
final double cutoutRoundingMaxAngle;
/// Bezier handle length, as a fraction of the gap between the two fillet
/// endpoints and of the cutout radius — whichever is shorter wins.
final double handleDistanceRatio;
final double handleLengthRatio;
/// Desktop's `DEFAULT_AVATAR_BADGE_CURVE`, used for large badges.
static const standard = AvatarBadgeCurve();
/// Desktop's `STATUS_DOT_MASK_CURVE` — a rounder, softer notch that reads
/// correctly at presence-dot sizes.
static const statusDot = AvatarBadgeCurve(
avatarRoundingAngle: 0.11,
cutoutRoundingLength: 6.5,
cutoutRoundingMinAngle: 0.28,
cutoutRoundingMaxAngle: 0.44,
handleDistanceRatio: 0.5,
handleLengthRatio: 0.2,
);
@override
bool operator ==(Object other) =>
other is AvatarBadgeCurve &&
other.avatarRoundingAngle == avatarRoundingAngle &&
other.cutoutRoundingLength == cutoutRoundingLength &&
other.cutoutRoundingMinAngle == cutoutRoundingMinAngle &&
other.cutoutRoundingMaxAngle == cutoutRoundingMaxAngle &&
other.handleDistanceRatio == handleDistanceRatio &&
other.handleLengthRatio == handleLengthRatio;
@override
int get hashCode => Object.hash(
avatarRoundingAngle,
cutoutRoundingLength,
cutoutRoundingMinAngle,
cutoutRoundingMaxAngle,
handleDistanceRatio,
handleLengthRatio,
);
}
/// Where the notch sits and how big the badge in it is, as fractions of the
/// avatar box so one constant covers every avatar size.
@immutable
class AvatarBadgeMaskGeometry {
const AvatarBadgeMaskGeometry({
required this.cutoutCenterRatio,
required this.cutoutRadiusRatio,
required this.badgeSizeRatio,
required this.curve,
});
/// Distance of the notch centre from the top-left, on both axes.
final double cutoutCenterRatio;
final double cutoutRadiusRatio;
/// The badge is centred on the notch and is deliberately smaller than it, so
/// a ring of background shows between badge and avatar.
final double badgeSizeRatio;
final AvatarBadgeCurve curve;
/// Desktop's settings-card treatment: a 192px avatar with a 54px badge notched
/// in at (165, 165) with radius 30.
static const badge = AvatarBadgeMaskGeometry(
cutoutCenterRatio: 165 / 192,
cutoutRadiusRatio: 30 / 192,
badgeSizeRatio: 54 / 192,
curve: AvatarBadgeCurve.standard,
);
/// Desktop's sidebar presence treatment: a 32px avatar with a 14px dot frame
/// notched in at (28, 28) with radius 7.5.
static const presenceDot = AvatarBadgeMaskGeometry(
cutoutCenterRatio: 28 / 32,
cutoutRadiusRatio: 7.5 / 32,
badgeSizeRatio: 14 / 32,
curve: AvatarBadgeCurve.statusDot,
);
Offset cutoutCenter(double size) =>
Offset(size * cutoutCenterRatio, size * cutoutCenterRatio);
double cutoutRadius(double size) => size * cutoutRadiusRatio;
double badgeSize(double size) => size * badgeSizeRatio;
@override
bool operator ==(Object other) =>
other is AvatarBadgeMaskGeometry &&
other.cutoutCenterRatio == cutoutCenterRatio &&
other.cutoutRadiusRatio == cutoutRadiusRatio &&
other.badgeSizeRatio == badgeSizeRatio &&
other.curve == curve;
@override
int get hashCode =>
Object.hash(cutoutCenterRatio, cutoutRadiusRatio, badgeSizeRatio, curve);
}
double _angleTo(Offset center, Offset point) =>
math.atan2(point.dy - center.dy, point.dx - center.dx);
Offset _pointOn(Offset center, double radius, double angle) =>
center + Offset(math.cos(angle), math.sin(angle)) * radius;
/// Unit tangent at [angle]; [clockwise] picks which way around the circle.
Offset _tangent(double angle, {required bool clockwise}) => clockwise
? Offset(-math.sin(angle), math.cos(angle))
: Offset(math.sin(angle), -math.cos(angle));
/// Signed sweep from [startAngle] to [endAngle], ported from desktop's
/// `getArcSweep`. Flutter shares SVG's angle convention (y grows downward, so a
/// positive sweep runs clockwise on screen), which is why the port is direct.
double _arcSweep(
double startAngle,
double endAngle, {
required bool clockwise,
required bool largeArc,
}) {
const fullTurn = math.pi * 2;
var sweep = clockwise ? endAngle - startAngle : startAngle - endAngle;
while (sweep < 0) {
sweep += fullTurn;
}
if (largeArc && sweep < math.pi) sweep += fullTurn;
if (!largeArc && sweep > math.pi) sweep -= fullTurn;
return clockwise ? sweep : -sweep;
}
/// The avatar outline with [geometry]'s notch cut out of its bottom-right.
///
/// Falls back to a plain circle when the notch does not actually cross the
/// avatar's edge — there is nothing to fillet in that case.
Path avatarBadgeMaskPath({
required double size,
required AvatarBadgeMaskGeometry geometry,
}) {
final avatarRadius = size / 2;
final avatarCenter = Offset(avatarRadius, avatarRadius);
final avatarRect = Rect.fromCircle(
center: avatarCenter,
radius: avatarRadius,
);
final cutoutCenter = geometry.cutoutCenter(size);
final cutoutRadius = geometry.cutoutRadius(size);
final curve = geometry.curve;
final span = cutoutCenter - avatarCenter;
final distance = span.distance;
if (distance >= avatarRadius + cutoutRadius ||
distance <= (avatarRadius - cutoutRadius).abs()) {
return Path()..addOval(avatarRect);
}
// Chord where the two circles cross, split into the upper and lower crossing.
final distanceToMidpoint =
(avatarRadius * avatarRadius -
cutoutRadius * cutoutRadius +
distance * distance) /
(2 * distance);
final halfChord = math.sqrt(
math.max(
0,
avatarRadius * avatarRadius - distanceToMidpoint * distanceToMidpoint,
),
);
final unit = span / distance;
final midpoint = avatarCenter + unit * distanceToMidpoint;
final perpendicular = Offset(-unit.dy, unit.dx);
final first = midpoint + perpendicular * halfChord;
final second = midpoint - perpendicular * halfChord;
final upperCrossing = first.dy < second.dy ? first : second;
final lowerCrossing = first.dy < second.dy ? second : first;
final cutoutRoundingAngle = math.min(
curve.cutoutRoundingMaxAngle,
math.max(
curve.cutoutRoundingMinAngle,
curve.cutoutRoundingLength / cutoutRadius,
),
);
// Pull each edge back from its crossing; the gap left behind is the fillet.
final avatarUpperAngle =
_angleTo(avatarCenter, upperCrossing) - curve.avatarRoundingAngle;
final avatarLowerAngle =
_angleTo(avatarCenter, lowerCrossing) + curve.avatarRoundingAngle;
final cutoutUpperAngle =
_angleTo(cutoutCenter, upperCrossing) - cutoutRoundingAngle;
final cutoutLowerAngle =
_angleTo(cutoutCenter, lowerCrossing) + cutoutRoundingAngle;
final avatarUpper = _pointOn(avatarCenter, avatarRadius, avatarUpperAngle);
final avatarLower = _pointOn(avatarCenter, avatarRadius, avatarLowerAngle);
final cutoutUpper = _pointOn(cutoutCenter, cutoutRadius, cutoutUpperAngle);
final cutoutLower = _pointOn(cutoutCenter, cutoutRadius, cutoutLowerAngle);
final upperHandle = math.min(
cutoutRadius * curve.handleLengthRatio,
(cutoutUpper - avatarUpper).distance * curve.handleDistanceRatio,
);
final lowerHandle = math.min(
cutoutRadius * curve.handleLengthRatio,
(avatarLower - cutoutLower).distance * curve.handleDistanceRatio,
);
final upperFromCutout =
cutoutUpper + _tangent(cutoutUpperAngle, clockwise: true) * upperHandle;
final upperIntoAvatar =
avatarUpper - _tangent(avatarUpperAngle, clockwise: false) * upperHandle;
final lowerFromAvatar =
avatarLower + _tangent(avatarLowerAngle, clockwise: false) * lowerHandle;
final lowerIntoCutout =
cutoutLower - _tangent(cutoutLowerAngle, clockwise: true) * lowerHandle;
return Path()
..moveTo(cutoutUpper.dx, cutoutUpper.dy)
..cubicTo(
upperFromCutout.dx,
upperFromCutout.dy,
upperIntoAvatar.dx,
upperIntoAvatar.dy,
avatarUpper.dx,
avatarUpper.dy,
)
// The long way round the avatar, back to the other side of the notch.
..arcTo(
avatarRect,
avatarUpperAngle,
_arcSweep(
avatarUpperAngle,
avatarLowerAngle,
clockwise: false,
largeArc: true,
),
false,
)
..cubicTo(
lowerFromAvatar.dx,
lowerFromAvatar.dy,
lowerIntoCutout.dx,
lowerIntoCutout.dy,
cutoutLower.dx,
cutoutLower.dy,
)
// Back across the notch itself, which bites into the avatar.
..arcTo(
Rect.fromCircle(center: cutoutCenter, radius: cutoutRadius),
cutoutLowerAngle,
_arcSweep(
cutoutLowerAngle,
cutoutUpperAngle,
clockwise: true,
largeArc: false,
),
false,
)
..close();
}
/// Clips an avatar to the rounded badge-notch path described by [geometry].
class AvatarBadgeMaskClipper extends CustomClipper<Path> {
const AvatarBadgeMaskClipper({required this.geometry});
final AvatarBadgeMaskGeometry geometry;
@override
Path getClip(Size size) =>
avatarBadgeMaskPath(size: size.shortestSide, geometry: geometry);
@override
bool shouldReclip(covariant AvatarBadgeMaskClipper oldClipper) =>
oldClipper.geometry != geometry;
}
/// A square [avatar] with [badge] seated in a notch cut out of its bottom-right
/// corner. With no [badge] the avatar is left whole — there is nothing to make
/// room for.
class MaskedAvatarBadge extends StatelessWidget {
const MaskedAvatarBadge({
super.key,
required this.size,
required this.avatar,
this.geometry = AvatarBadgeMaskGeometry.badge,
this.badge,
});
final double size;
final Widget avatar;
final AvatarBadgeMaskGeometry geometry;
final Widget? badge;
@override
Widget build(BuildContext context) {
if (badge == null) {
return SizedBox.square(dimension: size, child: avatar);
}
final badgeSize = geometry.badgeSize(size);
final center = geometry.cutoutCenter(size);
return SizedBox.square(
dimension: size,
child: Stack(
// The notch reaches past the avatar box, so the badge does too.
clipBehavior: Clip.none,
children: [
ClipPath(
clipper: AvatarBadgeMaskClipper(geometry: geometry),
child: SizedBox.square(dimension: size, child: avatar),
),
Positioned(
left: center.dx - badgeSize / 2,
top: center.dy - badgeSize / 2,
width: badgeSize,
height: badgeSize,
child: badge!,
),
],
),
);
}
}
@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import '../theme/theme.dart';
/// A consistent inline author row for message-oriented surfaces.
class MessageAuthorMeta extends StatelessWidget {
/// Primary author name shown at the start of the row.
final String displayName;
/// Optional secondary username, hidden when blank or equal to [displayName].
final String? username;
/// Timestamp label shown after the author metadata.
final String timestamp;
/// Color applied to [displayName].
final Color nameColor;
/// Color applied to the username, separator, and [timestamp].
final Color metadataColor;
/// Optional callback invoked when [displayName] is tapped.
final VoidCallback? onAuthorTap;
/// Optional key assigned to the display-name text.
final Key? displayNameKey;
/// Optional key assigned to the username text.
final Key? usernameKey;
/// Optional key assigned to the timestamp text.
final Key? timestampKey;
/// Base text style for [displayName], with [nameColor] applied.
final TextStyle nameStyle;
/// Base text style for secondary metadata, with [metadataColor] applied.
final TextStyle metadataStyle;
/// Creates an inline author row with optional username and tap handling.
const MessageAuthorMeta({
super.key,
required this.displayName,
required this.timestamp,
required this.nameColor,
required this.metadataColor,
this.username,
this.onAuthorTap,
this.displayNameKey,
this.usernameKey,
this.timestampKey,
this.nameStyle = messageUsernameTextStyle,
this.metadataStyle = messageMetadataTextStyle,
});
@override
Widget build(BuildContext context) {
final normalizedUsername = username?.trim();
final showUsername =
normalizedUsername != null &&
normalizedUsername.isNotEmpty &&
normalizedUsername != displayName.trim();
final resolvedNameStyle = nameStyle.copyWith(color: nameColor);
final resolvedMetadataStyle = metadataStyle.copyWith(color: metadataColor);
Widget authorName = Text(
displayName,
key: displayNameKey,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: resolvedNameStyle,
);
if (onAuthorTap != null) {
authorName = GestureDetector(onTap: onAuthorTap, child: authorName);
}
return LayoutBuilder(
builder: (context, constraints) {
final metadataMaxWidth = constraints.hasBoundedWidth
? constraints.maxWidth / (showUsername ? 3 : 2)
: double.infinity;
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(child: authorName),
if (showUsername) ...[
const SizedBox(width: Grid.half),
ConstrainedBox(
constraints: BoxConstraints(maxWidth: metadataMaxWidth),
child: Text(
normalizedUsername,
key: usernameKey,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: resolvedMetadataStyle,
),
),
],
const SizedBox(width: Grid.half),
Text('·', style: resolvedMetadataStyle),
const SizedBox(width: Grid.half),
ConstrainedBox(
constraints: BoxConstraints(maxWidth: metadataMaxWidth),
child: Text(
timestamp,
key: timestampKey,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: resolvedMetadataStyle,
),
),
],
);
},
);
}
}
@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import '../theme/theme.dart';
/// Height of the floating mobile tab bar, excluding its bottom clearance.
const mobileTabBarHeight = 56.0;
/// Gap between the floating mobile tab bar and the bottom safe area.
const mobileTabBarBottomGap = Grid.twelve;
/// Fixed visual height of the shared footer fade behind the floating tab bar.
double mobileTabFooterBackdropHeight(BuildContext _) => 180;
/// Builds the shared transparent-to-surface footer fade.
///
/// Kept separate from [MobileTabFooterBackdrop] so floating controls such as
/// the channel composer can paint the exact same fade behind their own content.
LinearGradient mobileTabFooterBackdropGradient(
BuildContext context, {
List<double> stops = const [0, 0.18, 0.38, 0.6, 0.8, 1],
List<double> opacities = const [0, 0.03, 0.12, 0.34, 0.7, 1],
Color? tint,
double tintBlend = 0,
}) {
assert(stops.length == opacities.length);
assert(tintBlend >= 0 && tintBlend <= 1);
final surface = Color.lerp(context.colors.surface, tint, tintBlend)!;
return LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: stops,
colors: [
for (final opacity in opacities) surface.withValues(alpha: opacity),
],
);
}
/// Shared fade behind the floating mobile tab bar.
class MobileTabFooterBackdrop extends StatelessWidget {
/// Vertical extent of the backdrop in logical pixels.
final double height;
/// Gradient stop positions, from the transparent top to the opaque bottom.
final List<double> stops;
/// Surface-color alpha values paired with [stops].
final List<double> opacities;
/// Optional color blended into the surface before opacity is applied.
final Color? tint;
/// Amount of [tint] mixed into the surface, from 0 to 1.
final double tintBlend;
/// Creates a footer backdrop with the required [height].
///
/// Override [stops] and [opacities] together to customize the gradient.
const MobileTabFooterBackdrop({
super.key,
required this.height,
this.stops = const [0, 0.18, 0.38, 0.6, 0.8, 1],
this.opacities = const [0, 0.03, 0.12, 0.34, 0.7, 1],
this.tint,
this.tintBlend = 0,
}) : assert(stops.length == opacities.length),
assert(tintBlend >= 0 && tintBlend <= 1);
@override
Widget build(BuildContext context) {
return SizedBox(
height: height,
width: double.infinity,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: mobileTabFooterBackdropGradient(
context,
stops: stops,
opacities: opacities,
tint: tint,
tintBlend: tintBlend,
),
),
),
);
}
}
@@ -0,0 +1,221 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../l10n/l10n.dart';
import '../theme/theme.dart';
import 'concentric_sheet_surface.dart';
/// Shared motion for occasional modal UI.
///
/// The strong ease-out makes entrances respond immediately, while the shorter
/// exit keeps dismissals from feeling sluggish.
const buzzModalAnimationStyle = AnimationStyle(
curve: Cubic(0.23, 1, 0.32, 1),
duration: Duration(milliseconds: 280),
reverseCurve: Cubic(0.77, 0, 0.175, 1),
reverseDuration: Duration(milliseconds: 220),
);
/// Shows a bottom sheet with Buzz's shared motion and sheet chrome.
///
/// Sheets include the shared close control by default. On iOS, the surface
/// uses native concentric corners when available and paints a requested drag
/// handle inside that inset surface; other platforms retain Flutter's handle.
Future<T?> showBuzzModalBottomSheet<T>({
required BuildContext context,
required WidgetBuilder builder,
Color? backgroundColor,
String? barrierLabel,
double? elevation,
ShapeBorder? shape,
Clip? clipBehavior,
BoxConstraints? constraints,
Color? barrierColor,
bool isScrollControlled = false,
double scrollControlDisabledMaxHeightRatio = 9.0 / 16.0,
bool useRootNavigator = false,
bool isDismissible = true,
bool enableDrag = true,
bool? showDragHandle,
bool showCloseButton = true,
bool useSafeArea = false,
RouteSettings? routeSettings,
AnimationController? transitionAnimationController,
Offset? anchorPoint,
AnimationStyle? sheetAnimationStyle,
bool? requestFocus,
}) {
final isIos = defaultTargetPlatform == TargetPlatform.iOS;
final theme = Theme.of(context);
final surfaceColor =
backgroundColor ??
theme.bottomSheetTheme.modalBackgroundColor ??
theme.bottomSheetTheme.backgroundColor ??
context.colors.surface;
final reduceMotion = MediaQuery.disableAnimationsOf(context);
return showModalBottomSheet<T>(
context: context,
builder: (sheetContext) => ConcentricSheetSurface(
enabled: isIos,
color: surfaceColor,
child: _SheetContent(
showCloseButton: showCloseButton,
showDragHandle: isIos && showDragHandle == true,
child: builder(sheetContext),
),
),
backgroundColor: isIos ? Colors.transparent : backgroundColor,
barrierLabel: barrierLabel,
elevation: elevation,
shape: shape,
clipBehavior: clipBehavior,
constraints: constraints,
barrierColor: barrierColor,
isScrollControlled: isScrollControlled,
scrollControlDisabledMaxHeightRatio: scrollControlDisabledMaxHeightRatio,
useRootNavigator: useRootNavigator,
isDismissible: isDismissible,
enableDrag: enableDrag,
// The iOS route is transparent so its stock handle sits outside the inset
// concentric surface. Paint it inside the surface above instead.
showDragHandle: isIos ? false : showDragHandle,
useSafeArea: useSafeArea,
routeSettings: routeSettings,
transitionAnimationController: transitionAnimationController,
anchorPoint: anchorPoint,
sheetAnimationStyle: reduceMotion
? AnimationStyle.noAnimation
: (sheetAnimationStyle ?? buzzModalAnimationStyle),
requestFocus: requestFocus,
);
}
class _SheetContent extends StatelessWidget {
const _SheetContent({
required this.child,
required this.showCloseButton,
required this.showDragHandle,
});
final Widget child;
final bool showCloseButton;
final bool showDragHandle;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (showCloseButton)
Padding(
padding: const EdgeInsets.only(
top: Grid.xxs,
left: Grid.gutter,
right: Grid.gutter,
bottom: Grid.xs,
),
child: SizedBox(
height: 56,
child: Stack(
alignment: Alignment.topCenter,
children: [
if (showDragHandle) const _SheetDragHandle(),
Align(
alignment: Alignment.bottomRight,
child: SizedBox.square(
dimension: 44,
child: IconButton(
tooltip: context.l10n.closeSheet,
onPressed: () {
unawaited(HapticFeedback.lightImpact());
Navigator.of(context).pop();
},
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
backgroundColor:
context.colors.surfaceContainerHighest,
foregroundColor: context.colors.onSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.dialog),
),
),
icon: const Icon(LucideIcons.x, size: 22),
),
),
),
],
),
),
)
else if (showDragHandle)
const Padding(
padding: EdgeInsets.only(top: Grid.xxs, bottom: Grid.xs),
child: _SheetDragHandle(),
),
Flexible(child: child),
],
);
}
}
class _SheetDragHandle extends StatelessWidget {
const _SheetDragHandle();
@override
Widget build(BuildContext context) {
return Semantics(
label: context.l10n.dragHandle,
child: Container(
key: const ValueKey('buzz-sheet-drag-handle'),
width: 32,
height: 4,
decoration: BoxDecoration(
color: context.colors.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(Radii.full),
),
),
);
}
}
/// Shows a dialog with Buzz's shared motion, respecting reduced-motion settings.
Future<T?> showBuzzDialog<T>({
required BuildContext context,
required WidgetBuilder builder,
bool barrierDismissible = true,
Color? barrierColor,
String? barrierLabel,
bool useSafeArea = true,
bool useRootNavigator = true,
RouteSettings? routeSettings,
Offset? anchorPoint,
TraversalEdgeBehavior? traversalEdgeBehavior,
bool fullscreenDialog = false,
bool? requestFocus,
AnimationStyle? animationStyle,
}) {
final reduceMotion = MediaQuery.disableAnimationsOf(context);
return showDialog<T>(
context: context,
builder: builder,
barrierDismissible: barrierDismissible,
barrierColor: barrierColor,
barrierLabel: barrierLabel,
useSafeArea: useSafeArea,
useRootNavigator: useRootNavigator,
routeSettings: routeSettings,
anchorPoint: anchorPoint,
traversalEdgeBehavior: traversalEdgeBehavior,
fullscreenDialog: fullscreenDialog,
requestFocus: requestFocus,
animationStyle: reduceMotion
? AnimationStyle.noAnimation
: (animationStyle ?? buzzModalAnimationStyle),
);
}
@@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
import '../theme/theme.dart';
/// Hairline divider between action groups in a bottom sheet, matching the
/// `AppListSection` divider convention.
class SheetDivider extends StatelessWidget {
const SheetDivider({super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
child: Divider(height: 1, color: context.colors.outlineVariant),
);
}
}
+17
View File
@@ -0,0 +1,17 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import '../theme/theme.dart';
part 'skeleton_bar.dart';
part 'skeleton_mask.dart';
part 'skeleton_reveal.dart';
part 'skeleton_shimmer.dart';
const _shimmerDuration = Duration(milliseconds: 2000);
const _skeletonOpacity = 0.1;
const _shimmerMinOpacity = 0.5;
const _revealDuration = Duration(milliseconds: 400);
const _revealBlur = 2.0;
@@ -0,0 +1,33 @@
part of 'skeleton.dart';
/// A single rounded placeholder within a [SkeletonShimmer].
class SkeletonBar extends StatelessWidget {
final double width;
final double height;
final BorderRadius? borderRadius;
const SkeletonBar({
required this.width,
required this.height,
this.borderRadius,
super.key,
});
@override
Widget build(BuildContext context) {
return ExcludeSemantics(
child: SizedBox(
width: width,
height: height,
child: DecoratedBox(
decoration: BoxDecoration(
color: _SkeletonMask.isActive(context)
? Colors.white
: context.colors.primary.withValues(alpha: _skeletonOpacity),
borderRadius: borderRadius ?? BorderRadius.circular(Radii.sm),
),
),
),
);
}
}
@@ -0,0 +1,11 @@
part of 'skeleton.dart';
class _SkeletonMask extends InheritedWidget {
const _SkeletonMask({required super.child});
static bool isActive(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<_SkeletonMask>() != null;
@override
bool updateShouldNotify(_SkeletonMask oldWidget) => false;
}
@@ -0,0 +1,103 @@
part of 'skeleton.dart';
/// Stacks a skeleton and its content in the same slot, then reveals the content.
///
/// Entering the loading state is intentionally instant so reconnects do not
/// animate backwards. Leaving it cross-fades both layers while the skeleton
/// blurs out and the content sharpens over 400ms.
class SkeletonReveal extends HookWidget {
final bool loading;
final Widget skeleton;
final Widget content;
final bool shimmerEnabled;
const SkeletonReveal({
required this.loading,
required this.skeleton,
required this.content,
this.shimmerEnabled = true,
super.key,
});
@override
Widget build(BuildContext context) {
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final reveal = useAnimationController(
duration: _revealDuration,
initialValue: loading ? 0 : 1,
);
final previousLoading = usePrevious(loading);
useEffect(() {
if (reducedMotion || previousLoading == null) {
reveal
..stop()
..value = loading ? 0 : 1;
} else if (loading) {
// Reset directly to the loading state. Only the reveal animates.
reveal
..stop()
..value = 0;
} else if (previousLoading) {
reveal.forward(from: 0);
}
return null;
}, [loading, reducedMotion]);
return AnimatedBuilder(
animation: reveal,
builder: (context, _) {
final progress = reveal.value;
final skeletonBlur = reducedMotion ? 0.0 : _revealBlur * progress;
final contentBlur = reducedMotion ? 0.0 : _revealBlur * (1 - progress);
return Stack(
fit: StackFit.expand,
children: [
Opacity(
key: const Key('skeleton-reveal-content'),
opacity: progress,
child: ImageFiltered(
enabled: contentBlur > 0.01,
imageFilter: ImageFilter.blur(
sigmaX: contentBlur,
sigmaY: contentBlur,
),
child: IgnorePointer(
ignoring: loading || progress < 1,
child: ExcludeFocus(
excluding: loading || progress < 1,
child: ExcludeSemantics(
excluding: loading || progress < 1,
child: content,
),
),
),
),
),
Opacity(
key: const Key('skeleton-reveal-placeholder'),
opacity: 1 - progress,
child: ImageFiltered(
enabled: skeletonBlur > 0.01,
imageFilter: ImageFilter.blur(
sigmaX: skeletonBlur,
sigmaY: skeletonBlur,
),
child: IgnorePointer(
child: ExcludeSemantics(
excluding: !loading,
child: SkeletonShimmer(
enabled: loading && shimmerEnabled,
child: skeleton,
),
),
),
),
),
],
);
},
);
}
}
@@ -0,0 +1,71 @@
part of 'skeleton.dart';
/// Sweeps one shared highlight across a group of skeleton elements.
///
/// This mirrors desktop's two-second linear shimmer. Reduced-motion users see
/// the same element shapes without the moving highlight.
class SkeletonShimmer extends HookWidget {
final Widget child;
final bool enabled;
const SkeletonShimmer({required this.child, this.enabled = true, super.key});
@override
Widget build(BuildContext context) {
final animation = useAnimationController(duration: _shimmerDuration);
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final colors = context.colors;
final baseColor = colors.primary.withValues(alpha: _skeletonOpacity);
final highlightColor = colors.primary.withValues(
alpha: _skeletonOpacity * _shimmerMinOpacity,
);
useEffect(() {
if (reducedMotion || !enabled) {
animation
..stop()
..value = 0;
} else {
animation.repeat();
}
return animation.stop;
}, [animation, enabled, reducedMotion]);
if (reducedMotion || !enabled) {
return _SkeletonMask(
child: ColorFiltered(
colorFilter: ColorFilter.mode(baseColor, BlendMode.srcIn),
child: child,
),
);
}
return _SkeletonMask(
child: RepaintBoundary(
child: AnimatedBuilder(
animation: animation,
child: child,
builder: (context, child) {
final center = -1.5 + (animation.value * 3);
return ShaderMask(
blendMode: BlendMode.srcIn,
shaderCallback: (bounds) => LinearGradient(
begin: Alignment(center - 1, 0),
end: Alignment(center + 1, 0),
colors: [
baseColor,
baseColor,
highlightColor,
baseColor,
baseColor,
],
stops: const [0, 0.34, 0.5, 0.66, 1],
).createShader(bounds),
child: child,
);
},
),
),
);
}
}
@@ -0,0 +1,64 @@
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 '../l10n/l10n.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
/// motion is enabled, the mark stays static.
class TappableFlappingBee extends HookConsumerWidget {
/// The rendered width of the complete bee mark.
final double width;
/// The color used for the bee silhouette.
final Color color;
const TappableFlappingBee({
required this.width,
required this.color,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final animation = useAnimationController(
duration: const Duration(milliseconds: 480),
);
final reducedMotion = MediaQuery.disableAnimationsOf(context);
void flutterWings() {
if (reducedMotion) return;
animation.forward(from: 0);
}
return Semantics(
button: true,
label: context.l10n.buzzBee,
hint: context.l10n.buzzBeeHint,
onTap: flutterWings,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
excludeFromSemantics: true,
onTap: flutterWings,
child: RepaintBoundary(
child: AnimatedBuilder(
animation: animation,
builder: (context, _) {
final flapAmount = 0.5 - (0.5 * cos(animation.value * 4 * pi));
return FlappingBee(
width: width,
color: color,
flapAmount: flapAmount,
);
},
),
),
),
);
}
}