Files
buzz/mobile/lib/shared/widgets/android_expanding_menu_button.dart
klopez4212 0cde1fe2f4 Make mobile Agents a full-page owned-agent directory (#8693)
Replace the mobile Agents sheet with a full-page directory of verified
owned agents. Add dedicated creation and avatar pages with
catalog-backed model selection, reuse the profile emoji/photo/camera
controls, and expose Builderbot model settings while labeling desktop
agents “Manage on desktop.” Share one account-scoped model catalog
across editable cards and retries. Choose Bestie from the agent profile
using the shared encrypted main-agent record (text-only action, filled
star once selected), and move confirmed deletion to a top-left trash
control with native iOS glass. Include uploaded avatars in owner
registrations so desktop can display them.

Validation: mobile analysis and all 3,958 hosted-enabled app tests
passed; default-build tests run by the push hook. Earlier iPhone build
installed and iterated with the user; the current iOS build succeeds,
but final changes still need a device pass. Independent review found no
new blockers. Actual camera capture and live Bestie sync remain
unconfirmed; the existing shared `main-agent` wire format and Bestie
terminology are retained.

### Snapshots

Widget-harness captures with test data (not native iOS chrome).

| Agents directory | Image avatar editor |
| --- | --- |
| ![Agents
directory](https://github.com/user-attachments/assets/c68a0505-90a5-41db-980f-e3197cac63ac)
| ![Image avatar
editor](https://github.com/user-attachments/assets/9b126186-1e7c-4169-9a74-8f7f252cfa93)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-29 18:30:17 +00:00

347 lines
15 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';
/// Expands an Android shortcut into a rounded, translucent action menu.
class AndroidExpandingMenuButton extends HookWidget {
const AndroidExpandingMenuButton({
required this.actions,
this.label = 'New',
this.trigger,
this.width = 112,
this.height = 56,
this.dividerBefore = const {},
super.key,
});
final String label;
final Widget? trigger;
final double width, height;
final Set<int> dividerBefore;
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 captured = actions;
final route = _CreateMenuRoute(
anchor: rect,
label: label,
trigger: trigger ?? const _NewLabel(),
dividerBefore: dividerBefore,
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.label,
required this.trigger,
required this.dividerBefore,
required this.actions,
required this.reducedMotion,
required this.dismissLabel,
});
final Rect anchor;
final String label;
final Widget trigger;
final Set<int> dividerBefore;
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(
240.0,
media.size.width - media.padding.horizontal - Grid.lg,
);
final rowHeight = math.max(48.0, media.textScaler.scale(20) + Grid.sm);
final height = math.min(
actions.length * rowHeight + Grid.xs + dividerBefore.length,
media.size.height - media.padding.vertical - Grid.lg,
);
final expandDown = anchor.center.dy < media.size.height / 2;
final 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 = 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),
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: [
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,
'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,
),
),
),
],
),
),
),
),
],
],
),
),
),
),
),
),
],
),
),
),
),
),
),
],
);
},
);
}
}