mirror of
https://github.com/block/buzz.git
synced 2026-11-07 09:52:03 +00:00
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>
415 lines
18 KiB
Dart
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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|