Files
buzz/mobile/lib/shared/widgets/android_expanding_menu_button.dart
klopez4212 b4509dee8b feat(mobile): add Buzzies and refresh agent avatar picker (#8921)
## Summary

Adds Buzzies and refreshes the agent avatar picker with larger previews,
rounded option strips, and clearer category choices. Also moves Agents
above Appearance in the home menu and adds a custom agent icon.

### Related issue

None found.

### Testing

Analysis, 4,138 app tests, package tests, and 121 configured-auth tests
pass. Earlier visual build exercised on Pixel and iOS simulator, with
iPhone review; snapshots below show that build.

| Android · Pixel | iOS · simulator preview |
| --- | --- |
| ![Buzzies in the Android avatar
picker](https://github.com/user-attachments/assets/bf56120c-0128-4683-a0ad-189587a13c8d)
| ![Buzzies in the iOS avatar
picker](https://github.com/user-attachments/assets/63c692f5-822c-4490-883f-36bec8d2be15)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-11-02 17:00:12 +00:00

411 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.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 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(
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,
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.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 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: context.controlSurface.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,
),
),
),
],
),
),
),
),
],
],
),
),
),
),
),
),
],
),
),
),
),
),
),
],
);
},
);
}
}