mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
## Summary - Polish mobile Home, Activity, Search, and Settings navigation chrome. - Add progressive Buzz gradients/frost, aligned theme colors, dividers, typography, and section spacing. - Refine Search and Settings motion, including automatic keyboard focus on search activation. <img width="630" height="1368" alt="C78FA3CE-F2B3-45F2-B9F5-7EA7500778CC" src="https://github.com/user-attachments/assets/5935514b-d894-4010-80dd-a938363fee93" /> <img width="630" height="1368" alt="5C058A73-1879-476A-881C-531ACC256D84" src="https://github.com/user-attachments/assets/5266c841-17ee-49e8-9841-b06d84f4195f" /> <img width="630" height="1368" alt="3F50ADB7-9BDA-4A8D-A81E-20560C3B9EA6" src="https://github.com/user-attachments/assets/f615f61e-9e96-4bce-b261-ae5ec54db872" /> <img width="630" height="1368" alt="35ECE741-01F3-4B79-80C5-1DDD447121A7" src="https://github.com/user-attachments/assets/b5145186-9884-44eb-8ebc-f3303831c0a4" /> ## Validation - `flutter analyze` - Focused Home, Activity, Channels, Search, theme, and footer widget tests - Full pre-push checks, including mobile tests, desktop checks, and Tauri checks - On-device iPhone review during the visual polish pass --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Signed-off-by: npub1glqcqfjxdens59scl477pmejh8lht4hqkhx0y4w38jxr6e6w6y2sm29y4e <47c18026466e670a1618fd7de0ef32b9ff75d6e0b5ccf255d13c8c3d674ed115@buzz.block.builderlab.xyz> Signed-off-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> Signed-off-by: Kenny Lopez <klopez4212@gmail.com> Co-authored-by: npub1glqcqfjxdens59scl477pmejh8lht4hqkhx0y4w38jxr6e6w6y2sm29y4e <47c18026466e670a1618fd7de0ef32b9ff75d6e0b5ccf255d13c8c3d674ed115@buzz.block.builderlab.xyz> Co-authored-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz>
273 lines
8.5 KiB
Dart
273 lines
8.5 KiB
Dart
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;
|
|
}
|
|
}
|