Files
buzz/mobile/lib/shared/widgets/android_expanding_menu_button.dart
Tom Brow beec055300 feat(mobile): move Activity to a Home bell and align Home spacing (#9212)
The UI-only part of #9193 by @klopez4212, split out so it can land
without the Activity sync changes.

- Activity moves from a row in the Home list to a bell button in the
Home header, with an unread dot driven by the existing Activity feed and
read state.
- Home filter capsules match native iOS and Android widths, with a
subtle outline on Android when the header collapses.
- Home section spacing is tighter.

Left out: the reconnect rewrite in #9193's last commit (live events
applied in place, no rebuild on reconnect, fatal fallback reads). The
Activity provider is unchanged from main. Channel and Solo rows keep
their bottom padding so they stay at the 44 pt minimum tap height.

### Validation

- Kenny's added tests: `inbox_unread_provider_test` (unread dot) and the
Home page bell and filter width tests.
- Full mobile suite: all pass except 11 push and notification-toggle
tests that fail identically on an unchanged checkout in this
environment.
- Automated simulator run (iPhone 17 Pro, iOS 27) of a build combining
this PR with its sibling split: Home rows measure 44 pt, the bell opens
Activity and shows its dot after a row is marked unread,
type/sender/Unread filters each narrow the feed, a row opens its
conversation, Search results scroll under the fade, and filter labels
fit at the largest text size. Screenshots are in Buzz rather than here
because they show internal test data. Android was covered only by widget
tests.

### Before merge

- [x] Automated simulator run of Home, the bell, Activity and channel
navigation, with screenshots.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:10:11 -08:00

415 lines
18 KiB
Dart

import 'dart:async';
import 'dart:math' as math;
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import '../theme/theme.dart';
import '../theme/buzz_icons.dart';
import 'navigation_button_surface.dart';
import 'agent_navigation_icon.dart';
/// Expands an Android shortcut into a rounded, translucent action menu.
class AndroidExpandingMenuButton extends HookWidget {
const AndroidExpandingMenuButton({
required this.actions,
this.label = 'New',
this.expansionBoundsContext,
this.trigger,
this.backgroundColor,
this.width = 112,
this.height = 56,
this.dividerBefore = const {},
this.sectionTitles = const {},
super.key,
});
/// Optional control row whose bounds the expanded menu should cover.
final BuildContext? expansionBoundsContext;
final String label;
final Widget? trigger;
final Color? backgroundColor;
final double width, height;
final Set<int> dividerBefore;
final Map<int, String> sectionTitles;
final List<({String label, String symbol, VoidCallback? onPressed})> actions;
@override
Widget build(BuildContext context) {
final open = useState(false);
return IgnorePointer(
ignoring: open.value,
child: Opacity(
opacity: open.value ? 0 : 1,
child: NavigationButtonSurface(
backgroundColor: backgroundColor,
child: Builder(
builder: (buttonContext) => Tooltip(
message: label,
child: TextButton(
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
foregroundColor: context.colors.onSurface,
),
onPressed: () async {
final navigator = Navigator.of(context);
final box = buttonContext.findRenderObject() as RenderBox;
final overlay =
navigator.overlay!.context.findRenderObject()
as RenderBox;
final rect = MatrixUtils.transformRect(
box.getTransformTo(overlay),
Offset.zero & box.size,
);
final expansionBox =
expansionBoundsContext?.findRenderObject() as RenderBox?;
final expansionBounds = expansionBox == null
? null
: MatrixUtils.transformRect(
expansionBox.getTransformTo(overlay),
Offset.zero & expansionBox.size,
);
final captured = actions;
final route = _CreateMenuRoute(
anchor: rect,
backgroundColor: backgroundColor ?? context.controlSurface,
expansionBounds: expansionBounds,
label: label,
trigger: trigger ?? const _NewLabel(),
dividerBefore: dividerBefore,
sectionTitles: sectionTitles,
actions: captured,
reducedMotion: MediaQuery.disableAnimationsOf(context),
dismissLabel: MaterialLocalizations.of(
context,
).modalBarrierDismissLabel,
);
unawaited(HapticFeedback.lightImpact());
open.value = true;
try {
final index = await navigator.push<int>(route);
await route.completed;
if (!context.mounted) return;
open.value = false;
if (index != null) captured[index].onPressed?.call();
} finally {
if (context.mounted) open.value = false;
}
},
child: SizedBox(
width: width,
height: height,
child: Center(child: trigger ?? const _NewLabel()),
),
),
),
),
),
),
);
}
}
class _NewLabel extends StatelessWidget {
const _NewLabel();
@override
Widget build(BuildContext context) => Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Icon(BuzzIcons.plus, size: 24),
SizedBox(width: Grid.xxs),
Text('New'),
],
);
}
class _CreateMenuRoute extends PopupRoute<int> {
_CreateMenuRoute({
required this.anchor,
required this.expansionBounds,
required this.label,
required this.trigger,
required this.backgroundColor,
required this.dividerBefore,
required this.sectionTitles,
required this.actions,
required this.reducedMotion,
required this.dismissLabel,
});
final Rect anchor;
final Rect? expansionBounds;
final String label;
final Widget trigger;
final Color backgroundColor;
final Set<int> dividerBefore;
final Map<int, String> sectionTitles;
final List<({String label, String symbol, VoidCallback? onPressed})> actions;
final bool reducedMotion;
final String dismissLabel;
@override
Color? get barrierColor => null;
@override
bool get barrierDismissible => true;
@override
String get barrierLabel => dismissLabel;
@override
Duration get transitionDuration =>
Duration(milliseconds: reducedMotion ? 0 : 240);
@override
Duration get reverseTransitionDuration =>
Duration(milliseconds: reducedMotion ? 0 : 180);
@override
Widget buildPage(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
final media = MediaQuery.of(context);
final width = math.min(
expansionBounds?.width ?? 240.0,
media.size.width - media.padding.horizontal - Grid.lg,
);
final rowHeight = math.max(48.0, media.textScaler.scale(20) + Grid.sm);
final sectionHeight = media.textScaler.scale(12) + Grid.sm;
final height = math.min(
actions.length * rowHeight +
Grid.xs +
dividerBefore.length +
sectionTitles.length * sectionHeight,
media.size.height - media.padding.vertical - Grid.lg,
);
final expandDown = anchor.center.dy < media.size.height / 2;
final left =
(expansionBounds?.left ??
(expandDown
? anchor.right - width
: anchor.center.dx - width / 2))
.clamp(
media.padding.left + Grid.xs,
media.size.width - media.padding.right - Grid.xs - width,
)
.toDouble();
final bottom = (expansionBounds ?? anchor).bottom
.clamp(
media.padding.top + Grid.xs + height,
media.size.height - media.padding.bottom,
)
.toDouble();
final top = expandDown
? anchor.top
.clamp(
media.padding.top,
media.size.height - media.padding.bottom - height,
)
.toDouble()
: bottom - height;
final destination = Rect.fromLTWH(left, top, width, height);
return AnimatedBuilder(
animation: animation,
builder: (context, _) {
final t = Curves.easeOutCubic.transform(animation.value);
final rect = Rect.lerp(anchor, destination, t)!;
final contentOpacity = ((t - .25) / .75).clamp(0.0, 1.0);
return Stack(
children: [
Positioned.fromRect(
rect: rect,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(Radii.navigationCapsule),
boxShadow: [
BoxShadow(
color: context.colors.shadow.withValues(alpha: .16 * t),
blurRadius: 24 * t,
offset: Offset(0, 6 * t),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(Radii.navigationCapsule),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 16 * t, sigmaY: 16 * t),
child: Material(
color: backgroundColor.withValues(alpha: 1 - .12 * t),
child: Stack(
children: [
Positioned.fill(
child: IgnorePointer(
child: Opacity(
opacity: (1 - t * 4).clamp(0.0, 1.0),
child: Center(child: trigger),
),
),
),
Positioned.fill(
child: OverflowBox(
alignment: expandDown
? Alignment.topCenter
: Alignment.bottomCenter,
minWidth: width,
maxWidth: width,
minHeight: height,
maxHeight: height,
child: Opacity(
opacity: contentOpacity,
child: Semantics(
scopesRoute: true,
namesRoute: true,
label: label,
explicitChildNodes: true,
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(
vertical: Grid.xxs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (
var i = 0;
i < actions.length;
i++
) ...[
if (dividerBefore.contains(i))
const Divider(height: 1),
if (sectionTitles[i]
case final title?)
SizedBox(
height: sectionHeight,
child: Padding(
padding:
const EdgeInsets.symmetric(
horizontal: Grid.gutter,
),
child: Align(
alignment:
Alignment.centerLeft,
child: Semantics(
header: true,
child: Text(
title,
style: TextStyle(
fontSize: 12,
color: context
.colors
.onSurfaceVariant,
),
),
),
),
),
),
SizedBox(
height: rowHeight,
child: InkWell(
onTap:
actions[i].onPressed == null
? null
: () => Navigator.of(
context,
).pop(i),
child: Padding(
padding:
const EdgeInsets.symmetric(
horizontal: Grid.gutter,
),
child: Row(
children: [
actions[i].symbol == 'agent'
? const AgentNavigationIcon()
: Icon(switch (actions[i]
.symbol) {
'' => null,
'checkmark' =>
BuzzIcons.check,
'star' =>
Icons.star_border,
'info.circle' =>
Icons
.info_outline,
'desktopcomputer' =>
Icons
.desktop_windows_outlined,
'square.and.pencil' =>
BuzzIcons.edit,
'workspace' =>
BuzzIcons
.workspace,
'folder.badge.plus' =>
BuzzIcons
.folderPlus,
'bubble.left' =>
BuzzIcons
.messageSquare,
'number' =>
BuzzIcons.hash,
'arrow.left.arrow.right' =>
BuzzIcons
.arrowLeftRight,
'person.badge.plus' =>
BuzzIcons
.userPlus,
'circle.lefthalf.filled' =>
BuzzIcons.sunMoon,
'person.2' =>
BuzzIcons.users,
'gearshape' =>
BuzzIcons
.settings,
_ => BuzzIcons.plus,
}, size: 22),
const SizedBox(
width: Grid.sm,
),
Expanded(
child: Text(
actions[i].label,
style: TextStyle(
color:
actions[i]
.onPressed ==
null
? context
.colors
.onSurface
.withValues(
alpha: .4,
)
: context
.colors
.onSurface,
),
),
),
],
),
),
),
),
],
],
),
),
),
),
),
),
],
),
),
),
),
),
),
],
);
},
);
}
}