Files
buzz/mobile/lib/shared/widgets/avatar_shape.dart
Tom Brow acf56fff6f fix(mobile): use the desktop agent squircle for every agent avatar (#8873)
On mobile, the Create agent form previewed emoji and image avatars as
circles. Once the agent was created, its avatar showed as a rounded
square, and other surfaces used yet other radii. None of them matched
the agent squircle desktop uses.

This PR makes `lib/shared/widgets/avatar_shape.dart` the single source
of avatar silhouettes. Agents get desktop's squircle (traced from
buzz-app's `avatar-squircle.svg`), and people keep circles.
`avatarShape(isAgent:)`, `avatarPath()` and `AvatarClip` are the only
ways to get either shape, and the squircle border class is private.

Every agent avatar surface now goes through that bottleneck:

- Create agent form and avatar picker previews (previously circles for
emoji and photo avatars, and unclipped for catalog characters;
`PresetAgentAvatar` now owns the clip)
- The avatar picker's camera cutout (`ImageAvatarCapture` with `isAgent:
true`); profile editing keeps the circle
- Agents grid card portrait (previously a 22pt rounded rect)
- `AvatarImage` (message authors, DM rows, member lists), the profile
sheet, `SmallAvatar`, and the native avatar PNG used by iOS Add members
(previously 30% rounded rects)
- Huddle speaking halo, preparing placeholder, and spotlight frame
- The iOS Add members fallback tile (Swift), shown while an avatar
loads, now draws the same squircle instead of a 30% rounded rect

To catch future drift, `avatar_shape_test.dart` adds a source guard. It
fails when code under `lib/` picks an avatar shape inline from agent
identity (for example `isAgent ? BoxShape.rectangle` or `if (!isAgent)
return ClipOval`), hand-rolls the old 30%/60% agent radius, or uses a
person shape (`ClipOval`, `CircleAvatar`, `BoxShape.circle`) under
`lib/features/agents/`. Against main it flags 8 of the 10 old sites. It
is best effort: the native PNG's inline ternary and the card's RRect
didn't match those patterns and are fixed directly here.

The Create agent form also gets 8pt above **Choose avatar**, so the
button and everything below it move down from the preview, as Kenny
requested.

### Before / after

Captured on the iPhone SE (iOS 27) with a release build against a hosted
Builderlab community, using a throwaway agent with an emoji avatar. In
each pair, before (main) is on the left and after is on the right.

**Create agent form** (also shows the 8pt spacing)

![create](https://raw.githubusercontent.com/block/buzz/f1f6a019c69e245146db761a6344524969275d04/pr-8873--create.png)

**Avatar picker**

![picker](https://raw.githubusercontent.com/block/buzz/f1f6a019c69e245146db761a6344524969275d04/pr-8873--picker.png)

**Agents card**

![card](https://raw.githubusercontent.com/block/buzz/f1f6a019c69e245146db761a6344524969275d04/pr-8873--card.png)

**Agent profile sheet**

![profile](https://raw.githubusercontent.com/block/buzz/f1f6a019c69e245146db761a6344524969275d04/pr-8873--profile.png)

**Message author avatar** (`AvatarImage`)

![dm-message](https://raw.githubusercontent.com/block/buzz/f1f6a019c69e245146db761a6344524969275d04/pr-8873--dm-message.png)

**DM list row** (`AvatarImage`)

![dm-list](https://raw.githubusercontent.com/block/buzz/f1f6a019c69e245146db761a6344524969275d04/pr-8873--dm-list.png)

**Add members** (native iOS list, `nativeAvatarImage`)

![add-members](https://raw.githubusercontent.com/block/buzz/f1f6a019c69e245146db761a6344524969275d04/pr-8873--add-members.png)

Not captured on device: `SmallAvatar` (thread reply and search avatars)
and the Huddle halo, placeholder, and spotlight, which need a live agent
thread or huddle. Widget tests cover these.

### Validation

- Device: the before/after captures above. They predate the catalog
preview clip
([355ebca](355ebca27e)),
which only trims the transparent character art at the squircle corners.
The test agent was deleted afterwards.
- Added tests: `avatar_shape_test.dart` pins the squircle geometry to
desktop's mask and runs the source guard. The avatar picker journey now
checks that the catalog preview, emoji preview, and camera cutout use
the agent shape. Existing `AvatarImage` and channel-detail
Huddle/thread-avatar assertions were updated to the new shapes.
- Full mobile format, analyze, and test suite pass locally. The iOS
release build compiles with the Swift change.
- The device build predates a merge of main that brought in
[#8773](https://github.com/block/buzz/pull/8773). That merge touched the
card, create page, and profile sheet without changing any avatar shapes.

Human pre-readiness app testing is not applicable under Tom's standing
exception for Buzz mobile changes. An independent agent review ran, and
its optional findings were addressed in 00b1ec6.

buzz-review-completed

🤖 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: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 11:55:47 -07:00

91 lines
2.9 KiB
Dart

import 'package:flutter/widgets.dart';
/// The single source of avatar silhouettes.
///
/// Agents use desktop's squircle (`ROUNDED_SQUIRCLE_PATH` in
/// `desktop/src/shared/ui/AvatarClipPaths.tsx`) and people use circles.
/// Every avatar frame, clip, halo and placeholder takes its shape
/// from here, so the two never drift apart across surfaces or clients.
OutlinedBorder avatarShape({
required bool isAgent,
BorderSide side = BorderSide.none,
}) => isAgent ? _AgentSquircleBorder(side: side) : CircleBorder(side: side);
/// The avatar silhouette scaled to [rect], for canvas painting.
Path avatarPath(Rect rect, {required bool isAgent}) =>
avatarShape(isAgent: isAgent).getOuterPath(rect);
/// Clips [child] to the avatar silhouette for [isAgent].
class AvatarClip extends StatelessWidget {
/// Creates an avatar clip.
const AvatarClip({required this.isAgent, required this.child, super.key});
/// Whether [child] is an agent avatar.
final bool isAgent;
/// The avatar artwork.
final Widget child;
@override
Widget build(BuildContext context) => isAgent
? ClipPath(
clipper: ShapeBorderClipper(shape: avatarShape(isAgent: true)),
child: child,
)
// People keep the dedicated oval clip, the fast path in dense lists.
: ClipOval(child: child);
}
Path _agentSquirclePath(Rect rect) {
Offset at(double x, double y) =>
Offset(rect.left + rect.width * x, rect.top + rect.height * y);
final start = at(.5, 0);
final path = Path()..moveTo(start.dx, start.dy);
void curve(Offset a, Offset b, Offset c) =>
path.cubicTo(a.dx, a.dy, b.dx, b.dy, c.dx, c.dy);
curve(at(.93, 0), at(1, .07), at(1, .5));
curve(at(1, .93), at(.93, 1), at(.5, 1));
curve(at(.07, 1), at(0, .93), at(0, .5));
curve(at(0, .07), at(.07, 0), start);
return path..close();
}
/// Private so agent avatars can only get their shape from [avatarShape].
class _AgentSquircleBorder extends OutlinedBorder {
const _AgentSquircleBorder({super.side});
@override
_AgentSquircleBorder copyWith({BorderSide? side}) =>
_AgentSquircleBorder(side: side ?? this.side);
@override
EdgeInsetsGeometry get dimensions => EdgeInsets.all(side.strokeInset);
@override
Path getInnerPath(Rect rect, {TextDirection? textDirection}) =>
_agentSquirclePath(rect.deflate(side.strokeInset));
@override
Path getOuterPath(Rect rect, {TextDirection? textDirection}) =>
_agentSquirclePath(rect);
@override
void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) {
if (side.style == BorderStyle.none) return;
canvas.drawPath(
_agentSquirclePath(rect.inflate(side.strokeOffset / 2)),
side.toPaint(),
);
}
@override
ShapeBorder scale(double t) => _AgentSquircleBorder(side: side.scale(t));
@override
bool operator ==(Object other) =>
other is _AgentSquircleBorder && other.side == side;
@override
int get hashCode => side.hashCode;
}