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>
This commit is contained in:
Tom Brow
2026-10-31 11:55:47 -07:00
committed by GitHub
parent f59b326568
commit acf56fff6f
18 changed files with 302 additions and 86 deletions

View File

@@ -281,8 +281,8 @@ final class NativeAddMembersViewController: UITableViewController, UISearchResul
let size = CGSize(width: 40, height: 40)
return UIGraphicsImageRenderer(size: size).image { _ in
(Self.color(user["avatarBackground"]) ?? .tertiarySystemFill).setFill()
UIBezierPath(roundedRect: CGRect(origin: .zero, size: size),
cornerRadius: user["agent"] as? Bool == true ? 12 : 20).fill()
let rect = CGRect(origin: .zero, size: size)
(user["agent"] as? Bool == true ? Self.agentSquircle(rect) : UIBezierPath(ovalIn: rect)).fill()
let text = (user["initial"] as? String ?? "?") as NSString
let attributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 18, weight: .medium),
@@ -293,6 +293,22 @@ final class NativeAddMembersViewController: UITableViewController, UISearchResul
}.withRenderingMode(.alwaysOriginal)
}
/// Mirrors avatar_shape.dart and desktop's `ROUNDED_SQUIRCLE_PATH`
/// (desktop/src/shared/ui/AvatarClipPaths.tsx).
private static func agentSquircle(_ rect: CGRect) -> UIBezierPath {
func at(_ x: CGFloat, _ y: CGFloat) -> CGPoint {
CGPoint(x: rect.minX + rect.width * x, y: rect.minY + rect.height * y)
}
let path = UIBezierPath()
path.move(to: at(0.5, 0))
path.addCurve(to: at(1, 0.5), controlPoint1: at(0.93, 0), controlPoint2: at(1, 0.07))
path.addCurve(to: at(0.5, 1), controlPoint1: at(1, 0.93), controlPoint2: at(0.93, 1))
path.addCurve(to: at(0, 0.5), controlPoint1: at(0.07, 1), controlPoint2: at(0, 0.93))
path.addCurve(to: at(0.5, 0), controlPoint1: at(0, 0.07), controlPoint2: at(0.07, 0))
path.close()
return path
}
private static func color(_ value: Any?) -> UIColor? {
guard let number = value as? NSNumber else { return nil }
let argb = number.uint32Value

View File

@@ -12,6 +12,7 @@ import '../../shared/emoji/emoji_avatar.dart';
import '../../shared/emoji/emoji_data_provider.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_shape.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/avatar_image_source_controls.dart';
import '../../shared/widgets/image_avatar_capture.dart';
@@ -228,7 +229,10 @@ class AgentAvatarPicker extends HookConsumerWidget {
child: SizedBox.square(
dimension: 220,
child: selection.bytes != null
? ClipOval(child: Image.memory(selection.bytes!, fit: BoxFit.cover))
? AvatarClip(
isAgent: true,
child: Image.memory(selection.bytes!, fit: BoxFit.cover),
)
: PresetAgentAvatar(
imageUrl: selection.url!,
size: 220,
@@ -356,6 +360,7 @@ class AgentAvatarPicker extends HookConsumerWidget {
height: imageHeight - cameraTop,
disposalBarrier: cameraDisposal,
initialPreview: preview,
isAgent: true,
onAccepted: acceptCameraImage,
onClosed: () {
cameraActive.value = false;

View File

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import '../../shared/animated_avatar.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_shape.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/progressive_animated_avatar.dart';
@@ -122,10 +123,8 @@ class AgentCard extends StatelessWidget {
child: RepaintBoundary(
child: SizedBox.square(
dimension: 104,
child: ClipRRect(
borderRadius: BorderRadius.circular(
Radii.card,
),
child: AvatarClip(
isAgent: true,
child: portrait,
),
),

View File

@@ -12,6 +12,7 @@ import 'agent_avatar_picker.dart';
import '../../shared/community/community.dart';
import '../../shared/community/community_provider.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_shape.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/ios_glass_navigation_action.dart';
@@ -197,7 +198,8 @@ class CreateAgentPage extends HookConsumerWidget {
child: SizedBox.square(
dimension: 160,
child: avatar.value.bytes != null
? ClipOval(
? AvatarClip(
isAgent: true,
child: Image.memory(
avatar.value.bytes!,
fit: BoxFit.cover,
@@ -213,7 +215,8 @@ class CreateAgentPage extends HookConsumerWidget {
),
),
),
if (!pending)
if (!pending) ...[
const SizedBox(height: Grid.xxs),
_ChooseAvatarButton(
onPressed: busy
? null
@@ -232,6 +235,7 @@ class CreateAgentPage extends HookConsumerWidget {
}
},
),
],
],
),
),

View File

@@ -32,6 +32,7 @@ import '../../shared/identity_names/identity_names_provider.dart';
import '../../shared/mentions/agent_identity_provider.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_shape.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/bouncing_dots_indicator.dart';

View File

@@ -188,15 +188,8 @@ class _HuddleCallAvatar extends HookConsumerWidget {
key: ValueKey('huddle-speaking-halo-$pubkey'),
width: speakingRingSize,
height: speakingRingSize,
decoration: BoxDecoration(
shape: isAgent
? BoxShape.rectangle
: BoxShape.circle,
borderRadius: isAgent
? BorderRadius.circular(
speakingRingSize * 0.3,
)
: null,
decoration: ShapeDecoration(
shape: avatarShape(isAgent: isAgent),
color: context.colors.primary.withValues(
alpha: 0.07,
),
@@ -219,15 +212,8 @@ class _HuddleCallAvatar extends HookConsumerWidget {
),
width: avatarRadius * 2,
height: avatarRadius * 2,
decoration: BoxDecoration(
shape: isAgent
? BoxShape.rectangle
: BoxShape.circle,
borderRadius: isAgent
? BorderRadius.circular(
avatarRadius * 0.6,
)
: null,
decoration: ShapeDecoration(
shape: avatarShape(isAgent: isAgent),
color: context.controlSurface,
),
alignment: Alignment.center,

View File

@@ -198,13 +198,8 @@ class _HuddleParticipantSpotlight extends ConsumerWidget {
? Duration.zero
: const Duration(milliseconds: 180),
padding: EdgeInsets.all(active ? Grid.xxs : Grid.half),
decoration: BoxDecoration(
shape: isAgent ? BoxShape.rectangle : BoxShape.circle,
borderRadius: isAgent
? BorderRadius.circular(
(_huddleParticipantSpotlightRadius + Grid.half) * 0.6,
)
: null,
decoration: ShapeDecoration(
shape: avatarShape(isAgent: isAgent),
color: context.colors.primary.withValues(
alpha: active ? 0.18 : 0.08,
),

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_shape.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/profile/user_profile.dart';
@@ -29,14 +30,13 @@ class SmallAvatar extends StatelessWidget {
final initial =
profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?');
final isAgent = agent || profile?.ownerPubkey != null;
final side = BorderSide(color: context.colors.surface, width: 1.5);
return Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: isAgent ? BoxShape.rectangle : BoxShape.circle,
borderRadius: isAgent ? BorderRadius.circular(size * 0.3) : null,
border: Border.all(color: context.colors.surface, width: 1.5),
decoration: ShapeDecoration(
shape: avatarShape(isAgent: isAgent, side: side),
),
child: AvatarImage(
imageUrl: avatarUrl,

View File

@@ -16,6 +16,7 @@ import '../../shared/identity_names/identity_names_provider.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/utils/string_utils.dart';
import '../../shared/widgets/avatar_shape.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/agent_artwork_panel.dart';
import '../../shared/widgets/buzz_sheet_header.dart';
@@ -603,12 +604,9 @@ class _ProfileAvatar extends HookWidget {
imageUrl: animatedAvatar?.posterUrl ?? avatarUrl,
fallback: _AvatarFallback(initial: initial),
);
if (!isAgent) return ClipOval(child: avatar);
return ClipRRect(
borderRadius: BorderRadius.circular(
constraints.biggest.shortestSide * 0.3,
),
child: !usesInsetAgentArtwork(avatarUrl)
return AvatarClip(
isAgent: isAgent,
child: !isAgent || !usesInsetAgentArtwork(avatarUrl)
? avatar
: Transform.scale(
scale: animatedAvatarArtworkScale,

View File

@@ -11,6 +11,7 @@ import '../animated_avatar.dart';
import '../emoji/emoji_avatar.dart';
import '../emoji/native_emoji_glyph.dart';
import '../relay/relay.dart';
import 'avatar_shape.dart';
part 'avatar_image_native.dart';
@@ -46,26 +47,26 @@ class AvatarImage extends StatelessWidget {
fallback: fallback,
),
);
final clipped = AvatarClip(
isAgent: isAgent,
child: isAgent && usesInsetAgentArtwork(imageUrl)
? Transform.scale(scale: animatedAvatarArtworkScale, child: content)
: content,
);
// CircleAvatar keeps its theme default when people have no background.
if (!isAgent) {
return CircleAvatar(
radius: radius,
backgroundColor: color,
child: ClipOval(child: content),
child: clipped,
);
}
final borderRadius = BorderRadius.circular(radius * 0.6);
return DecoratedBox(
decoration: BoxDecoration(color: color, borderRadius: borderRadius),
child: ClipRRect(
borderRadius: borderRadius,
child: !usesInsetAgentArtwork(imageUrl)
? content
: Transform.scale(
scale: animatedAvatarArtworkScale,
child: content,
),
decoration: ShapeDecoration(
color: color,
shape: avatarShape(isAgent: true),
),
child: clipped,
);
}
}

View File

@@ -17,12 +17,7 @@ Future<Uint8List?> nativeAvatarImage({
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
final bounds = const Rect.fromLTWH(0, 0, dimension, dimension);
canvas.clipRRect(
RRect.fromRectAndRadius(
bounds,
Radius.circular(dimension * (isAgent && !circular ? 0.3 : 0.5)),
),
);
canvas.clipPath(avatarPath(bounds, isAgent: isAgent && !circular));
canvas.drawColor(background, BlendMode.src);
final imageBounds = isAgent && usesInsetAgentArtwork(url)
? bounds.deflate(dimension * (1 - animatedAvatarArtworkScale) / 2)

View File

@@ -0,0 +1,90 @@
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;
}

View File

@@ -13,6 +13,7 @@ import 'package:buzz/shared/theme/buzz_icons.dart';
import '../relay/relay.dart';
import '../theme/theme.dart';
import '../widgets/avatar_shape.dart';
import '../widgets/buzz_loading_indicator.dart';
import '../widgets/ios_glass_navigation_button.dart';
import 'camera_disposal_barrier.dart';
@@ -53,6 +54,7 @@ class ImageAvatarCapture extends HookConsumerWidget {
required this.onAccepted,
required this.onClosed,
this.initialPreview,
this.isAgent = false,
this.initialCapturedBytes,
this.loadCameras = availableCameras,
this.disposalBarrier,
@@ -67,9 +69,12 @@ class ImageAvatarCapture extends HookConsumerWidget {
/// Leaves camera mode without changing the current avatar draft.
final VoidCallback onClosed;
/// The existing avatar shown while the same circular cutout becomes a camera.
/// The existing avatar shown while the same cutout becomes a camera.
final Widget? initialPreview;
/// Whether the cutout uses the agent avatar shape instead of a circle.
final bool isAgent;
/// Seeds the captured-photo review state in focused widget tests.
@visibleForTesting
final Uint8List? initialCapturedBytes;
@@ -414,7 +419,8 @@ class ImageAvatarCapture extends HookConsumerWidget {
curve: Curves.easeOutCubic,
width: previewSize,
height: previewSize,
child: ClipOval(
child: AvatarClip(
isAgent: isAgent,
child: ColoredBox(
color: Colors.black,
child: captured != null

View File

@@ -7,6 +7,7 @@ import 'package:video_player/video_player.dart';
import '../animated_avatar.dart';
import 'avatar_image.dart';
import 'avatar_shape.dart';
import 'android_transparent_avatar.dart';
/// Plays the transparent video paired with a bundled character poster.
@@ -98,21 +99,24 @@ class PresetAgentAvatar extends HookWidget {
return SizedBox.square(
dimension: size,
child: Transform.scale(
scale: animatedAvatarArtworkScale,
child: isAndroid && videoUrl != null
? AndroidTransparentAvatar(
key: ValueKey(videoUrl),
url: videoUrl,
playing: playing,
fallback: AvatarImageContent(
imageUrl: poster,
fallback: fallback,
),
)
: controller != null && ready.value == controller
? VideoPlayer(controller)
: AvatarImageContent(imageUrl: poster, fallback: fallback),
child: AvatarClip(
isAgent: true,
child: Transform.scale(
scale: animatedAvatarArtworkScale,
child: isAndroid && videoUrl != null
? AndroidTransparentAvatar(
key: ValueKey(videoUrl),
url: videoUrl,
playing: playing,
fallback: AvatarImageContent(
imageUrl: poster,
fallback: fallback,
),
)
: controller != null && ready.value == controller
? VideoPlayer(controller)
: AvatarImageContent(imageUrl: poster, fallback: fallback),
),
),
);
}

View File

@@ -4,6 +4,7 @@ import 'package:buzz/features/agents/agent_avatar_picker.dart';
import 'package:buzz/shared/agents/agent_avatar_catalog.dart';
import 'package:buzz/shared/emoji/emoji_data.dart';
import 'package:buzz/shared/emoji/emoji_data_provider.dart';
import 'package:buzz/shared/widgets/avatar_shape.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -66,6 +67,21 @@ void main() {
await tester.pumpAndSettle();
expect(selection.url, isNot(agentAvatarCatalog.first.url));
final character = selection.url;
// Every preview uses the same squircle the agent gets once created.
void expectSquirclePreview() {
final previewClip = tester.widget<ClipPath>(
find.descendant(
of: find.bySemanticsLabel('Selected agent avatar'),
matching: find.byType(ClipPath),
),
);
expect(
(previewClip.clipper! as ShapeBorderClipper).shape,
avatarShape(isAgent: true),
);
}
expectSquirclePreview();
await tester.runAsync(() async {
rendered = Completer<void>();
tester
@@ -76,6 +92,7 @@ void main() {
await tester.pumpAndSettle();
expect(selection.bytes, isNotNull);
expectSquirclePreview();
expect(
(tester
.widget<GridView>(
@@ -116,6 +133,21 @@ void main() {
await tester.tap(find.byKey(const ValueKey('image-source-camera')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('image-avatar-camera')), findsOneWidget);
// The camera cutout keeps the agent shape while shooting and reviewing.
final cameraClip = tester.widget(
find
.descendant(
of: find.byKey(const ValueKey('image-camera-preview-size')),
matching: find.byWidgetPredicate(
(widget) => widget is ClipPath || widget is ClipOval,
),
)
.first,
);
expect(
((cameraClip as ClipPath).clipper! as ShapeBorderClipper).shape,
avatarShape(isAgent: true),
);
await tester.ensureVisible(
find.byKey(const ValueKey('image-camera-left-action')),
);

View File

@@ -79,6 +79,7 @@ import 'package:buzz/shared/mentions/agent_identity_provider.dart';
import 'package:buzz/shared/huddle/huddle.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/avatar_shape.dart';
import 'package:buzz/shared/widgets/app_list_card.dart';
import 'package:buzz/shared/widgets/avatar_image.dart';
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
@@ -7902,8 +7903,8 @@ void main() {
find.byKey(const ValueKey('huddle-speaking-halo-desktop')),
);
final speakingHaloDecoration =
speakingHalo.decoration! as BoxDecoration;
expect(speakingHaloDecoration.border, isNull);
speakingHalo.decoration! as ShapeDecoration;
expect(speakingHaloDecoration.shape, const CircleBorder());
expect(speakingHaloDecoration.color?.a, closeTo(0.07, 0.001));
await tester.pump(const Duration(milliseconds: 70));
final mediumScaleMidTransition = tester
@@ -11321,7 +11322,11 @@ void main() {
)
.first,
);
expect((frame.decoration! as BoxDecoration).shape, BoxShape.rectangle);
final shape = (frame.decoration! as ShapeDecoration).shape;
expect(
shape,
avatarShape(isAgent: true, side: (shape as OutlinedBorder).side),
);
}
// As on desktop, the reply branch waits for a reply.
expect(connector, findsNothing);

View File

@@ -1,5 +1,6 @@
import 'dart:convert';
import 'package:buzz/shared/widgets/avatar_shape.dart';
import 'package:buzz/shared/widgets/avatar_image.dart';
import 'package:buzz/shared/emoji/native_emoji_glyph.dart';
import 'package:flutter/material.dart';
@@ -38,7 +39,7 @@ void main() {
'https://dwwgwmfqqjotj.cloudfront.net/avatars/20260925T154625166Z/poster/pollies/pollies-10.png';
for (final url in [poster, animated, preset]) {
await tester.pumpWidget(subject(url, isAgent: true));
final clip = find.byType(ClipRRect);
final clip = find.byType(ClipPath);
final scale = find.descendant(of: clip, matching: find.byType(Transform));
if (url != poster) {
expect(scale, findsOneWidget);
@@ -51,12 +52,15 @@ void main() {
}
});
testWidgets('clips agents to a 30 percent squircle', (tester) async {
testWidgets('clips agents to the desktop squircle', (tester) async {
await tester.pumpWidget(subject(null, isAgent: true));
expect(find.byType(CircleAvatar), findsNothing);
final clip = tester.widget<ClipRRect>(find.byType(ClipRRect));
expect(clip.borderRadius, BorderRadius.circular(9.6));
final clip = tester.widget<ClipPath>(find.byType(ClipPath));
expect(
(clip.clipper! as ShapeBorderClipper).shape,
avatarShape(isAgent: true),
);
});
testWidgets('renders raccoon percent-encoded SVG data avatar', (

View File

@@ -0,0 +1,75 @@
import 'dart:io';
import 'package:buzz/shared/widgets/avatar_shape.dart';
import 'package:flutter/painting.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('agent squircle follows the desktop mask geometry', () {
// Desktop's avatar-squircle.svg passes through (.089, .089) at each
// corner's midpoint, outside a circle and inside the square.
const rect = Rect.fromLTWH(0, 0, 100, 100);
final path = avatarPath(rect, isAgent: true);
expect(path.getBounds(), rect);
for (final (point, inside) in [
(const Offset(9.5, 9.5), true),
(const Offset(90.5, 9.5), true),
(const Offset(90.5, 90.5), true),
(const Offset(9.5, 90.5), true),
(const Offset(8, 8), false),
(const Offset(92, 8), false),
(const Offset(92, 92), false),
(const Offset(8, 92), false),
// Inside the old 30% rounded rect, outside the squircle.
(const Offset(1.9, 22), false),
(const Offset(98.1, 78), false),
]) {
expect(path.contains(point), inside, reason: '$point');
}
// People keep circles: the squircle corner points fall outside them.
expect(
avatarPath(rect, isAgent: false).contains(const Offset(9.5, 9.5)),
isFalse,
);
});
test('avatar shapes come only from avatarShape', () {
// Agent avatars drifted into circles and 30% rounded rects because each
// surface picked its own shape. Shape decisions keyed on agent identity,
// the old agent radius idiom, and person shapes on agent-only surfaces
// must use avatar_shape.dart. Best effort: it matches the patterns that
// produced past drift, not every possible spelling.
final inlineAgentShape = RegExp(
r'\b(isAgent|isBot|agent)\b\s*\?\s*(const\s+)?'
r'(BoxShape|BorderRadius|Radius|CircleBorder|RoundedRectangleBorder|'
r'RoundedSuperellipseBorder|ClipOval|ClipRRect|CircleAvatar)\b',
);
final inlineAgentClip = RegExp(
r'if\s*\(\s*!?\s*(isAgent|isBot|agent)\s*\)\s*return\s+'
r'(ClipOval|ClipRRect|CircleAvatar)\b',
);
// The old hand-rolled agent radius: 30% of a size, or 60% of a radius.
final agentRadius = RegExp(
r'Radius\.circular\(\s*[\w.()+\s]*\*\s*0\.[36]\s*,?\s*\)',
);
final personShape = RegExp(r'\b(ClipOval|CircleAvatar|BoxShape\.circle)\b');
final violations = <String>[];
for (final file in Directory('lib').listSync(recursive: true)) {
if (file is! File || !file.path.endsWith('.dart')) continue;
final path = file.path.replaceAll(r'\', '/');
if (path.endsWith('shared/widgets/avatar_shape.dart')) continue;
final source = file.readAsStringSync();
for (final match in [
...inlineAgentShape.allMatches(source),
...inlineAgentClip.allMatches(source),
...agentRadius.allMatches(source),
if (path.startsWith('lib/features/agents/'))
...personShape.allMatches(source),
]) {
final line = '\n'.allMatches(source.substring(0, match.start)).length;
violations.add('$path:${line + 1}: ${match[0]}');
}
}
expect(violations, isEmpty);
});
}