refactor(mobile): route avatar shapes through one bottleneck

avatar_shape.dart now owns the agent squircle and the person circle. avatarShape(isAgent:), avatarPath() and AvatarClip are the only way to get either, and the squircle border is private. A source guard test fails when code keys an avatar shape on agent identity inline, or uses a person shape on an agent-only surface.

Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Signed-off-by: Tom Brow <tomb@squareup.com>
This commit is contained in:
Tom Brow
2026-10-31 10:25:54 -07:00
parent 6703762481
commit 51559c75c7
16 changed files with 192 additions and 140 deletions

View File

@@ -12,7 +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/agent_avatar_border.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';
@@ -229,8 +229,8 @@ class AgentAvatarPicker extends HookConsumerWidget {
child: SizedBox.square(
dimension: 220,
child: selection.bytes != null
? ClipPath(
clipper: const ShapeBorderClipper(shape: AgentAvatarBorder()),
? AvatarClip(
isAgent: true,
child: Image.memory(selection.bytes!, fit: BoxFit.cover),
)
: PresetAgentAvatar(

View File

@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
import '../../shared/animated_avatar.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/agent_avatar_border.dart';
import '../../shared/widgets/avatar_shape.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/progressive_animated_avatar.dart';
@@ -117,10 +117,8 @@ class AgentCard extends StatelessWidget {
child: RepaintBoundary(
child: SizedBox.square(
dimension: 104,
child: ClipPath(
clipper: const ShapeBorderClipper(
shape: AgentAvatarBorder(),
),
child: AvatarClip(
isAgent: true,
child: portrait,
),
),

View File

@@ -12,7 +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/agent_avatar_border.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';
@@ -198,10 +198,8 @@ class CreateAgentPage extends HookConsumerWidget {
child: SizedBox.square(
dimension: 160,
child: avatar.value.bytes != null
? ClipPath(
clipper: const ShapeBorderClipper(
shape: AgentAvatarBorder(),
),
? AvatarClip(
isAgent: true,
child: Image.memory(
avatar.value.bytes!,
fit: BoxFit.cover,

View File

@@ -32,7 +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/agent_avatar_border.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

@@ -189,9 +189,7 @@ class _HuddleCallAvatar extends HookConsumerWidget {
width: speakingRingSize,
height: speakingRingSize,
decoration: ShapeDecoration(
shape: isAgent
? const AgentAvatarBorder()
: const CircleBorder(),
shape: avatarShape(isAgent: isAgent),
color: context.colors.primary.withValues(
alpha: 0.07,
),
@@ -215,9 +213,7 @@ class _HuddleCallAvatar extends HookConsumerWidget {
width: avatarRadius * 2,
height: avatarRadius * 2,
decoration: ShapeDecoration(
shape: isAgent
? const AgentAvatarBorder()
: const CircleBorder(),
shape: avatarShape(isAgent: isAgent),
color: context.controlSurface,
),
alignment: Alignment.center,

View File

@@ -199,9 +199,7 @@ class _HuddleParticipantSpotlight extends ConsumerWidget {
: const Duration(milliseconds: 180),
padding: EdgeInsets.all(active ? Grid.xxs : Grid.half),
decoration: ShapeDecoration(
shape: isAgent
? const AgentAvatarBorder()
: const CircleBorder(),
shape: avatarShape(isAgent: isAgent),
color: context.colors.primary.withValues(
alpha: active ? 0.18 : 0.08,
),

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/agent_avatar_border.dart';
import '../../shared/widgets/avatar_shape.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/profile/user_profile.dart';
@@ -36,9 +36,7 @@ class SmallAvatar extends StatelessWidget {
width: size,
height: size,
decoration: ShapeDecoration(
shape: isAgent
? AgentAvatarBorder(side: side)
: CircleBorder(side: side),
shape: avatarShape(isAgent: isAgent, side: side),
),
child: AvatarImage(
imageUrl: avatarUrl,

View File

@@ -14,7 +14,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/agent_avatar_border.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';
@@ -472,10 +472,9 @@ class _ProfileAvatar extends HookWidget {
imageUrl: animatedAvatar?.posterUrl ?? avatarUrl,
fallback: _AvatarFallback(initial: initial),
);
if (!isAgent) return ClipOval(child: avatar);
return ClipPath(
clipper: const ShapeBorderClipper(shape: AgentAvatarBorder()),
child: !usesInsetAgentArtwork(avatarUrl)
return AvatarClip(
isAgent: isAgent,
child: !isAgent || !usesInsetAgentArtwork(avatarUrl)
? avatar
: Transform.scale(
scale: animatedAvatarArtworkScale,

View File

@@ -1,58 +0,0 @@
import 'package:flutter/painting.dart';
/// Returns the agent avatar squircle scaled to [rect].
///
/// Matches desktop's `avatar-squircle.svg` mask so agent avatars share one
/// silhouette across clients.
Path agentAvatarPath(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();
}
/// The shape of every agent avatar, its halos, and its placeholders.
class AgentAvatarBorder extends OutlinedBorder {
const AgentAvatarBorder({super.side});
@override
AgentAvatarBorder copyWith({BorderSide? side}) =>
AgentAvatarBorder(side: side ?? this.side);
@override
EdgeInsetsGeometry get dimensions => EdgeInsets.all(side.strokeInset);
@override
Path getInnerPath(Rect rect, {TextDirection? textDirection}) =>
agentAvatarPath(rect.deflate(side.strokeInset));
@override
Path getOuterPath(Rect rect, {TextDirection? textDirection}) =>
agentAvatarPath(rect);
@override
void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) {
if (side.style == BorderStyle.none) return;
canvas.drawPath(
agentAvatarPath(rect.inflate(side.strokeOffset / 2)),
side.toPaint(),
);
}
@override
ShapeBorder scale(double t) => AgentAvatarBorder(side: side.scale(t));
@override
bool operator ==(Object other) =>
other is AgentAvatarBorder && other.side == side;
@override
int get hashCode => side.hashCode;
}

View File

@@ -11,7 +11,7 @@ import '../animated_avatar.dart';
import '../emoji/emoji_avatar.dart';
import '../emoji/native_emoji_glyph.dart';
import '../relay/relay.dart';
import 'agent_avatar_border.dart';
import 'avatar_shape.dart';
part 'avatar_image_native.dart';
@@ -47,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,
);
}
const shape = AgentAvatarBorder();
return DecoratedBox(
decoration: ShapeDecoration(color: color, shape: shape),
child: ClipPath(
clipper: const ShapeBorderClipper(shape: shape),
child: !usesInsetAgentArtwork(imageUrl)
? content
: Transform.scale(
scale: animatedAvatarArtworkScale,
child: content,
),
decoration: ShapeDecoration(
color: color,
shape: avatarShape(isAgent: true),
),
child: clipped,
);
}
}

View File

@@ -17,13 +17,7 @@ Future<Uint8List?> nativeAvatarImage({
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
final bounds = const Rect.fromLTWH(0, 0, dimension, dimension);
if (isAgent && !circular) {
canvas.clipPath(agentAvatarPath(bounds));
} else {
canvas.clipRRect(
RRect.fromRectAndRadius(bounds, const Radius.circular(dimension / 2)),
);
}
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,86 @@
import 'package:flutter/widgets.dart';
/// The single source of avatar silhouettes.
///
/// Agents use desktop's squircle (`avatar-squircle.svg`) 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) => ClipPath(
clipper: ShapeBorderClipper(shape: avatarShape(isAgent: isAgent)),
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

@@ -4,7 +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/agent_avatar_border.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';
@@ -86,7 +86,7 @@ void main() {
);
expect(
(previewClip.clipper! as ShapeBorderClipper).shape,
const AgentAvatarBorder(),
avatarShape(isAgent: true),
);
expect(
(tester

View File

@@ -79,7 +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/agent_avatar_border.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';
@@ -11322,9 +11322,10 @@ void main() {
)
.first,
);
final shape = (frame.decoration! as ShapeDecoration).shape;
expect(
(frame.decoration! as ShapeDecoration).shape,
isA<AgentAvatarBorder>(),
shape,
avatarShape(isAgent: true, side: (shape as OutlinedBorder).side),
);
}
// As on desktop, the reply branch waits for a reply.

View File

@@ -1,6 +1,6 @@
import 'dart:convert';
import 'package:buzz/shared/widgets/agent_avatar_border.dart';
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';
@@ -59,33 +59,10 @@ void main() {
final clip = tester.widget<ClipPath>(find.byType(ClipPath));
expect(
(clip.clipper! as ShapeBorderClipper).shape,
const AgentAvatarBorder(),
avatarShape(isAgent: true),
);
});
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.
final path = agentAvatarPath(const Rect.fromLTWH(0, 0, 100, 100));
expect(path.getBounds(), const Rect.fromLTWH(0, 0, 100, 100));
for (final corner in [
const Offset(9.5, 9.5),
const Offset(90.5, 9.5),
const Offset(90.5, 90.5),
const Offset(9.5, 90.5),
]) {
expect(path.contains(corner), isTrue, reason: '$corner');
}
for (final corner in [
const Offset(8, 8),
const Offset(92, 8),
const Offset(92, 92),
const Offset(8, 92),
]) {
expect(path.contains(corner), isFalse, reason: '$corner');
}
});
testWidgets('renders raccoon percent-encoded SVG data avatar', (
tester,
) async {

View File

@@ -0,0 +1,65 @@
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),
]) {
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,
// and person shapes on agent-only surfaces, must use avatar_shape.dart.
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',
);
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),
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);
});
}