Files
buzz/mobile/lib/shared/widgets/anchored_popover_menu.dart
klopez4212 4555899ab2 Polish mobile navigation and menus (#3486)
## Summary
- Add a shared footer fade behind the floating tabs on Home, Activity,
and Search.
- Use a shared anchored popover for Activity filters and section
actions, with working section move controls.
- Polish message grouping/press states and remove the initial Search
back button.
<img width="630" height="1368" alt="Screenshot 2026-07-29 at 08 49 37"
src="https://github.com/user-attachments/assets/9e787adf-0bb3-49c6-8224-5819e8cfb1ad"
/>

### Testing
- `flutter analyze`
- `flutter test`
- Release build installed and checked on a connected iPhone

### Screenshots
A real-device Activity baseline showing the original solid footer is
attached in a PR comment. The updated review build was checked on the
connected iPhone.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-07-29 16:10:51 +01:00

246 lines
7.4 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;
/// 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,
/// 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,
required Color color,
required ShapeBorder shape,
required double elevation,
required 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,
shape: shape,
elevation: elevation,
shadowColor: shadowColor,
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.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.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;
}
}