mirror of
https://github.com/block/buzz.git
synced 2026-11-09 02:41:57 +00:00
The inverse surface is a flat light gray in dark mode, which washed the first-agent card out. In dark mode the callout now uses the raised dark surface and regular text color, as toasts do. Light mode keeps the inverse colors. Co-authored-by: Carl <c217fe6b9d958f41c3a5e030dccc7f626775a923089cb6491305eade75ea1f1b@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
388 lines
11 KiB
Dart
388 lines
11 KiB
Dart
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/rendering.dart';
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
|
|
import '../theme/buzz_icons.dart';
|
|
import '../theme/theme.dart';
|
|
import 'anchored_popover_menu.dart';
|
|
|
|
const _arrowHeight = Grid.xxs;
|
|
const _arrowWidth = Grid.xs;
|
|
const _targetGap = Grid.half;
|
|
const _cardMaxWidth = Grid.xxxl * 6;
|
|
|
|
/// A non-blocking callout that points down at [child] while [visible].
|
|
///
|
|
/// It floats above everything else on the current route without a scrim, so
|
|
/// the screen underneath stays usable, and hides while another route covers
|
|
/// this one.
|
|
///
|
|
/// The default tip is a short message: any tap, on the tip or elsewhere,
|
|
/// calls [onDismiss]. [BuzzCallout.card] is the loud variant, a full-width
|
|
/// card with [artwork] scattered around a centered message: tapping it calls
|
|
/// [onTap], and only its close button calls [onDismiss]. Screen readers can
|
|
/// dismiss either one.
|
|
class BuzzCallout extends HookWidget {
|
|
const BuzzCallout({
|
|
required this.message,
|
|
required this.visible,
|
|
required this.onDismiss,
|
|
required this.child,
|
|
super.key,
|
|
}) : onTap = null,
|
|
artwork = const [];
|
|
|
|
const BuzzCallout.card({
|
|
required this.message,
|
|
required this.visible,
|
|
required this.onDismiss,
|
|
required VoidCallback this.onTap,
|
|
required this.child,
|
|
this.artwork = const [],
|
|
super.key,
|
|
});
|
|
|
|
final String message;
|
|
final bool visible;
|
|
final VoidCallback onDismiss;
|
|
final VoidCallback? onTap;
|
|
|
|
/// Up to six pictures scattered around the card's edges.
|
|
final List<Widget> artwork;
|
|
final Widget child;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final showing = visible && (ModalRoute.of(context)?.isCurrent ?? true);
|
|
final controller = useMemoized(OverlayPortalController.new);
|
|
useEffect(() {
|
|
// The controller can't change during a build, so it follows a frame
|
|
// behind; the builder below hides the callout in the meantime.
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!context.mounted || showing == controller.isShowing) return;
|
|
showing ? controller.show() : controller.hide();
|
|
});
|
|
return null;
|
|
}, [showing]);
|
|
final onTap = this.onTap;
|
|
return OverlayPortal.overlayChildLayoutBuilder(
|
|
controller: controller,
|
|
overlayChildBuilder: (context, info) {
|
|
if (!showing) return const SizedBox.shrink();
|
|
final target = MatrixUtils.transformRect(
|
|
info.childPaintTransform,
|
|
Offset.zero & info.childSize,
|
|
);
|
|
final padding = MediaQuery.paddingOf(context);
|
|
return _CalloutPlacement(
|
|
target: target,
|
|
margin: EdgeInsets.only(
|
|
left: padding.left + Grid.xs,
|
|
right: padding.right + Grid.xs,
|
|
top: padding.top + Grid.xs,
|
|
),
|
|
fullWidth: onTap != null,
|
|
color: _calloutColors(context).$1,
|
|
shadowColor: appPopoverShadowColor(context),
|
|
child: onTap == null
|
|
? _Tip(message: message, onDismiss: onDismiss)
|
|
: _Card(
|
|
message: message,
|
|
artwork: artwork,
|
|
onTap: onTap,
|
|
onDismiss: onDismiss,
|
|
),
|
|
);
|
|
},
|
|
child: child,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The callout's background and foreground. Inverse colors set it apart from
|
|
/// menus and the page; in dark mode, whose inverse is a flat light gray, it
|
|
/// uses the raised dark surface instead, as toasts do.
|
|
(Color, Color) _calloutColors(BuildContext context) {
|
|
final colors = context.colors;
|
|
return Theme.of(context).brightness == Brightness.dark
|
|
? (colors.surfaceContainer, colors.onSurface)
|
|
: (colors.inverseSurface, colors.onInverseSurface);
|
|
}
|
|
|
|
class _Tip extends StatelessWidget {
|
|
const _Tip({required this.message, required this.onDismiss});
|
|
|
|
final String message;
|
|
final VoidCallback onDismiss;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return TapRegion(
|
|
onTapInside: (_) => onDismiss(),
|
|
onTapOutside: (_) => onDismiss(),
|
|
child: Semantics(
|
|
container: true,
|
|
liveRegion: true,
|
|
onDismiss: onDismiss,
|
|
onTap: onDismiss,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: Grid.xs,
|
|
vertical: Grid.twelve,
|
|
),
|
|
child: Text(
|
|
message,
|
|
textAlign: TextAlign.center,
|
|
style: context.textTheme.bodyMedium?.copyWith(
|
|
color: _calloutColors(context).$2,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Where the card's artwork sits, after desktop's empty Agents tile: each
|
|
/// picture hangs partly off an edge, tilted, clear of the close button. The
|
|
/// message sits on top where they meet.
|
|
const _scatter = [
|
|
(Alignment(-1.12, -1.3), -0.21),
|
|
(Alignment(-0.42, -1.45), 0.12),
|
|
(Alignment(-0.78, 1.4), -0.17),
|
|
(Alignment(0.12, 1.5), 0.08),
|
|
(Alignment(0.45, -1.4), -0.1),
|
|
(Alignment(1.12, 1.25), 0.21),
|
|
];
|
|
|
|
class _Card extends StatelessWidget {
|
|
const _Card({
|
|
required this.message,
|
|
required this.artwork,
|
|
required this.onTap,
|
|
required this.onDismiss,
|
|
});
|
|
|
|
final String message;
|
|
final List<Widget> artwork;
|
|
final VoidCallback onTap;
|
|
final VoidCallback onDismiss;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Semantics(
|
|
container: true,
|
|
liveRegion: true,
|
|
button: true,
|
|
onTap: onTap,
|
|
onDismiss: onDismiss,
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
excludeFromSemantics: true,
|
|
onTap: onTap,
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(Radii.popover),
|
|
child: Stack(
|
|
children: [
|
|
for (final (index, picture)
|
|
in artwork.take(_scatter.length).indexed)
|
|
Positioned.fill(
|
|
child: Align(
|
|
alignment: _scatter[index].$1,
|
|
child: Transform.rotate(
|
|
angle: _scatter[index].$2,
|
|
child: SizedBox.square(
|
|
dimension: Grid.xxxl,
|
|
child: ExcludeSemantics(child: picture),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
// The message sets the card's height, keeping a close-button
|
|
// row above it at any text size.
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: Grid.xs,
|
|
vertical: Grid.xxl,
|
|
),
|
|
child: Center(
|
|
heightFactor: 1,
|
|
child: Text(
|
|
message,
|
|
textAlign: TextAlign.center,
|
|
style: context.textTheme.titleLarge?.copyWith(
|
|
color: _calloutColors(context).$2,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
top: Grid.half,
|
|
right: Grid.half,
|
|
child: IconButton(
|
|
tooltip: 'Dismiss',
|
|
onPressed: onDismiss,
|
|
color: _calloutColors(context).$2,
|
|
icon: const Icon(BuzzIcons.x),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Fills the overlay, placing its child above [target] inside [margin] and
|
|
/// painting one surface for the bubble and its arrow. Only the bubble is
|
|
/// hit-testable, so the rest of the screen keeps receiving touches.
|
|
class _CalloutPlacement extends SingleChildRenderObjectWidget {
|
|
const _CalloutPlacement({
|
|
required this.target,
|
|
required this.margin,
|
|
required this.fullWidth,
|
|
required this.color,
|
|
required this.shadowColor,
|
|
required super.child,
|
|
});
|
|
|
|
final Rect target;
|
|
final EdgeInsets margin;
|
|
final bool fullWidth;
|
|
final Color color;
|
|
final Color shadowColor;
|
|
|
|
@override
|
|
_RenderCalloutPlacement createRenderObject(BuildContext context) =>
|
|
_RenderCalloutPlacement(target, margin, fullWidth, color, shadowColor);
|
|
|
|
@override
|
|
void updateRenderObject(
|
|
BuildContext context,
|
|
_RenderCalloutPlacement renderObject,
|
|
) {
|
|
renderObject
|
|
..target = target
|
|
..margin = margin
|
|
..fullWidth = fullWidth
|
|
..color = color
|
|
..shadowColor = shadowColor;
|
|
}
|
|
}
|
|
|
|
class _RenderCalloutPlacement extends RenderShiftedBox {
|
|
_RenderCalloutPlacement(
|
|
this._target,
|
|
this._margin,
|
|
this._fullWidth,
|
|
this._color,
|
|
this._shadowColor,
|
|
) : super(null);
|
|
|
|
Rect _target;
|
|
set target(Rect value) {
|
|
if (value == _target) return;
|
|
_target = value;
|
|
markNeedsLayout();
|
|
}
|
|
|
|
EdgeInsets _margin;
|
|
set margin(EdgeInsets value) {
|
|
if (value == _margin) return;
|
|
_margin = value;
|
|
markNeedsLayout();
|
|
}
|
|
|
|
bool _fullWidth;
|
|
set fullWidth(bool value) {
|
|
if (value == _fullWidth) return;
|
|
_fullWidth = value;
|
|
markNeedsLayout();
|
|
}
|
|
|
|
Color _color;
|
|
set color(Color value) {
|
|
if (value == _color) return;
|
|
_color = value;
|
|
markNeedsPaint();
|
|
}
|
|
|
|
Color _shadowColor;
|
|
set shadowColor(Color value) {
|
|
if (value == _shadowColor) return;
|
|
_shadowColor = value;
|
|
markNeedsPaint();
|
|
}
|
|
|
|
Path _path = Path();
|
|
|
|
@override
|
|
void performLayout() {
|
|
size = constraints.biggest;
|
|
final child = this.child;
|
|
if (child == null) return;
|
|
final bottom = _target.top - _targetGap - _arrowHeight;
|
|
final room = math.max(0.0, size.width - _margin.horizontal);
|
|
// A card spans a phone; on wider screens it stays phone-sized.
|
|
final width = _fullWidth ? math.min(room, _cardMaxWidth) : room;
|
|
child.layout(
|
|
BoxConstraints(
|
|
minWidth: _fullWidth ? width : 0,
|
|
maxWidth: width,
|
|
maxHeight: math.max(0, bottom - _margin.top),
|
|
),
|
|
parentUsesSize: true,
|
|
);
|
|
final left = (_target.center.dx - child.size.width / 2)
|
|
.clamp(
|
|
_margin.left,
|
|
math.max(_margin.left, size.width - _margin.right - child.size.width),
|
|
)
|
|
.toDouble();
|
|
final offset = Offset(left, bottom - child.size.height);
|
|
(child.parentData! as BoxParentData).offset = offset;
|
|
|
|
final bubble = offset & child.size;
|
|
final radius = math.min(Radii.popover, bubble.shortestSide / 2);
|
|
// Keep the arrow's base clear of the rounded corners.
|
|
final arrowX = _target.center.dx
|
|
.clamp(
|
|
bubble.left + radius + _arrowWidth / 2,
|
|
math.max(
|
|
bubble.left + radius + _arrowWidth / 2,
|
|
bubble.right - radius - _arrowWidth / 2,
|
|
),
|
|
)
|
|
.toDouble();
|
|
// One outline, so the shadow doesn't cross where the arrow joins.
|
|
_path = Path.combine(
|
|
PathOperation.union,
|
|
Path()
|
|
..addRRect(RRect.fromRectAndRadius(bubble, Radius.circular(radius))),
|
|
Path()..addPolygon([
|
|
Offset(arrowX - _arrowWidth / 2, bubble.bottom - 1),
|
|
Offset(arrowX, bubble.bottom + _arrowHeight),
|
|
Offset(arrowX + _arrowWidth / 2, bubble.bottom - 1),
|
|
], true),
|
|
);
|
|
}
|
|
|
|
@override
|
|
void paint(PaintingContext context, Offset offset) {
|
|
final child = this.child;
|
|
if (child == null) return;
|
|
final path = _path.shift(offset);
|
|
final canvas = context.canvas;
|
|
canvas.drawShadow(path, _shadowColor, appPopoverElevation, _color.a < 1);
|
|
canvas.drawPath(path, Paint()..color = _color);
|
|
super.paint(context, offset);
|
|
}
|
|
|
|
@override
|
|
bool hitTest(BoxHitTestResult result, {required Offset position}) =>
|
|
hitTestChildren(result, position: position);
|
|
}
|