mirror of
https://github.com/block/buzz.git
synced 2026-11-03 16:01:35 +00:00
Mobile New chat now opens directly with the synced Bestie selected. The first send creates and names the chat; leaving an empty draft creates nothing. New and existing Solo chats can select or clear a workspace from the header popover, with a checkmark for the active choice and a No Workspace option. Workspace and agent controls have separate iOS glass surfaces, Bestie stays at the far right, and Back uses a chevron. The agent sheet fits its list, with scrolling for long lists. Workspace creation stays in the Home New menu. The New menu groups Solo above Team. Android expands across the full control row; iOS keeps its native menu while Search and Bestie merge into New through Liquid Glass, with synchronized icon motion and the residual glass fading away. Empty workspaces use a compact, muted message. Validation: 4,055 mobile tests passed (34 skipped), static analysis passed, and native menu checks passed. Earlier physical-device previews and the iPhone New-menu animation were visually approved before this consolidation. This PR consolidates the controls previously developed in #8891 into the existing draft/first-send implementation. The New menu animations, attachment handling, and durable retries remain in this PR. The combined controls have widget interaction coverage; the combined build has not yet had a new physical-device walkthrough. Current combined controls below are Android Flutter widget renders with fixture data, not device screenshots. Images are GitHub attachments only. | New conversation | Workspace popover | Compact agent sheet | | --- | --- | --- | |  |  |  | Empty workspace widget preview (unchanged):  --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
402 lines
18 KiB
Dart
402 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';
|
|
|
|
/// 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: [
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|