Files
buzz/mobile/lib/shared/widgets/avatar_image_native.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

204 lines
6.3 KiB
Dart

part of 'avatar_image.dart';
/// Renders the same avatar sources used by Flutter as a PNG for native bars.
Future<Uint8List?> nativeAvatarImage({
required String? url,
required String initial,
bool isAgent = false,
bool circular = false,
required Color background,
required Color foreground,
required ImageProvider Function(String) networkImage,
}) async {
const dimension = 72.0;
final animatedAvatar = parseAnimatedAvatarUrl(url);
final poster = animatedAvatar?.posterUrl ?? url;
final source = _AvatarSource.parse(poster);
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
final bounds = const Rect.fromLTWH(0, 0, dimension, dimension);
canvas.clipPath(avatarPath(bounds, isAgent: isAgent && !circular));
canvas.drawColor(background, BlendMode.src);
final imageBounds = isAgent && usesInsetAgentArtwork(url)
? bounds.deflate(dimension * (1 - animatedAvatarArtworkScale) / 2)
: bounds;
var rendered = false;
try {
switch (source) {
case _EmojiAvatarSource(:final emoji, :final color):
canvas.drawColor(color, BlendMode.src);
await _paintNativeAvatarEmoji(
canvas,
emoji,
dimension * 258 / 512,
foreground,
);
rendered = true;
case _SvgAvatarSource(:final svg):
final picture = await vg.loadPicture(SvgStringLoader(svg), null);
final scale = dimension / picture.size.shortestSide;
canvas.save();
canvas.translate(
(dimension - picture.size.width * scale) / 2,
(dimension - picture.size.height * scale) / 2,
);
canvas.scale(scale);
canvas.drawPicture(picture.picture);
canvas.restore();
picture.picture.dispose();
rendered = true;
case _RasterDataAvatarSource(:final bytes):
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
final codec = await ui.instantiateImageCodecWithSize(
buffer,
getTargetSize: (width, height) => width >= height
? ui.TargetImageSize(width: width.clamp(1, dimension.toInt()))
: ui.TargetImageSize(height: height.clamp(1, dimension.toInt())),
);
try {
final frame = await codec.getNextFrame();
paintImage(
canvas: canvas,
rect: imageBounds,
image: frame.image,
fit: BoxFit.cover,
);
frame.image.dispose();
} finally {
codec.dispose();
}
rendered = true;
case _NetworkAvatarSource(:final url):
final stream = ResizeImage(
networkImage(url),
width: dimension.toInt(),
height: dimension.toInt(),
policy: ResizeImagePolicy.fit,
).resolve(ImageConfiguration.empty);
final completer = Completer<ImageInfo>();
final listener = ImageStreamListener(
(image, _) {
if (!completer.isCompleted) completer.complete(image.clone());
},
onError: (Object error, StackTrace? stack) {
if (!completer.isCompleted) completer.completeError(error, stack);
},
);
stream.addListener(listener);
try {
final image = await completer.future.timeout(
const Duration(seconds: 10),
);
paintImage(
canvas: canvas,
rect: imageBounds,
image: image.image,
fit: BoxFit.cover,
);
image.dispose();
rendered = true;
} finally {
stream.removeListener(listener);
}
case null:
break;
}
} catch (_) {
// Match the normal avatar's initial fallback when media is unavailable.
}
if (!rendered) _paintNativeAvatarText(canvas, initial, 32, foreground);
final picture = recorder.endRecording();
final image = await picture.toImage(dimension.toInt(), dimension.toInt());
picture.dispose();
try {
return (await image.toByteData(
format: ui.ImageByteFormat.png,
))?.buffer.asUint8List();
} finally {
image.dispose();
}
}
void _paintNativeAvatarText(
Canvas canvas,
String text,
double size,
Color color,
) {
final painter = TextPainter(
text: TextSpan(
text: text,
style: TextStyle(fontSize: size, color: color, height: 1),
),
textDirection: TextDirection.ltr,
)..layout();
painter.paint(
canvas,
Offset((72 - painter.width) / 2, (72 - painter.height) / 2),
);
painter.dispose();
}
// Text advances and baselines are not the visible bounds of a color emoji.
// Rasterize on a padded transparent surface, then center the painted pixels.
Future<void> _paintNativeAvatarEmoji(
Canvas canvas,
String emoji,
double size,
Color color,
) async {
final painter = TextPainter(
text: TextSpan(
text: emoji,
style: TextStyle(fontSize: size, color: color),
),
textDirection: TextDirection.ltr,
)..layout();
final width = (painter.width + size * 2).ceil();
final height = (painter.height + size * 2).ceil();
final recorder = ui.PictureRecorder();
painter.paint(Canvas(recorder), Offset(size, size));
painter.dispose();
final picture = recorder.endRecording();
final image = await picture.toImage(width, height);
picture.dispose();
try {
final pixels = await image.toByteData(format: ui.ImageByteFormat.rawRgba);
if (pixels == null) return;
var left = width;
var top = height;
var right = -1;
var bottom = -1;
for (var y = 0; y < height; y++) {
for (var x = 0; x < width; x++) {
if (pixels.getUint8((y * width + x) * 4 + 3) < 8) continue;
if (x < left) left = x;
if (x > right) right = x;
if (y < top) top = y;
if (y > bottom) bottom = y;
}
}
if (right < left || bottom < top) return;
final ink = Rect.fromLTRB(
left.toDouble(),
top.toDouble(),
right + 1.0,
bottom + 1.0,
);
final longest = ink.width > ink.height ? ink.width : ink.height;
final scale = (size / longest).clamp(0.0, 1.0);
canvas.drawImageRect(
image,
ink,
Rect.fromCenter(
center: const Offset(36, 36),
width: ink.width * scale,
height: ink.height * scale,
),
Paint()..filterQuality = FilterQuality.high,
);
} finally {
image.dispose();
}
}