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,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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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('>', '>')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('&', '&');
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user