mirror of
https://github.com/block/buzz.git
synced 2026-11-02 23:41:08 +00:00
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)  **Avatar picker**  **Agents card**  **Agent profile sheet**  **Message author avatar** (`AvatarImage`)  **DM list row** (`AvatarImage`)  **Add members** (native iOS list, `nativeAvatarImage`)  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 in00b1ec6. 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>
204 lines
6.3 KiB
Dart
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();
|
|
}
|
|
}
|