fix(mobile): tighten avatar shape guard and iOS agent fallback

The iOS Add members fallback tile drew agents as a 30% rounded rect while their loaded avatar was a squircle. It now draws the same squircle. The geometry test now rejects the old rounded rect, and the source guard also flags the old 30%/60% agent radius idiom.

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:42:51 -07:00
parent f5188ee3b9
commit 00b1ec6cc1
2 changed files with 28 additions and 3 deletions

View File

@@ -281,8 +281,8 @@ final class NativeAddMembersViewController: UITableViewController, UISearchResul
let size = CGSize(width: 40, height: 40) let size = CGSize(width: 40, height: 40)
return UIGraphicsImageRenderer(size: size).image { _ in return UIGraphicsImageRenderer(size: size).image { _ in
(Self.color(user["avatarBackground"]) ?? .tertiarySystemFill).setFill() (Self.color(user["avatarBackground"]) ?? .tertiarySystemFill).setFill()
UIBezierPath(roundedRect: CGRect(origin: .zero, size: size), let rect = CGRect(origin: .zero, size: size)
cornerRadius: user["agent"] as? Bool == true ? 12 : 20).fill() (user["agent"] as? Bool == true ? Self.agentSquircle(rect) : UIBezierPath(ovalIn: rect)).fill()
let text = (user["initial"] as? String ?? "?") as NSString let text = (user["initial"] as? String ?? "?") as NSString
let attributes: [NSAttributedString.Key: Any] = [ let attributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 18, weight: .medium), .font: UIFont.systemFont(ofSize: 18, weight: .medium),
@@ -293,6 +293,21 @@ final class NativeAddMembersViewController: UITableViewController, UISearchResul
}.withRenderingMode(.alwaysOriginal) }.withRenderingMode(.alwaysOriginal)
} }
/// Mirrors avatar_shape.dart and desktop's avatar-squircle.svg.
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? { private static func color(_ value: Any?) -> UIColor? {
guard let number = value as? NSNumber else { return nil } guard let number = value as? NSNumber else { return nil }
let argb = number.uint32Value let argb = number.uint32Value

View File

@@ -20,6 +20,9 @@ void main() {
(const Offset(92, 8), false), (const Offset(92, 8), false),
(const Offset(92, 92), false), (const Offset(92, 92), false),
(const Offset(8, 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'); expect(path.contains(point), inside, reason: '$point');
} }
@@ -33,7 +36,9 @@ void main() {
test('avatar shapes come only from avatarShape', () { test('avatar shapes come only from avatarShape', () {
// Agent avatars drifted into circles and 30% rounded rects because each // Agent avatars drifted into circles and 30% rounded rects because each
// surface picked its own shape. Shape decisions keyed on agent identity, // surface picked its own shape. Shape decisions keyed on agent identity,
// and person shapes on agent-only surfaces, must use avatar_shape.dart. // 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( final inlineAgentShape = RegExp(
r'\b(isAgent|isBot|agent)\b\s*\?\s*(const\s+)?' r'\b(isAgent|isBot|agent)\b\s*\?\s*(const\s+)?'
r'(BoxShape|BorderRadius|Radius|CircleBorder|RoundedRectangleBorder|' r'(BoxShape|BorderRadius|Radius|CircleBorder|RoundedRectangleBorder|'
@@ -43,6 +48,10 @@ void main() {
r'if\s*\(\s*!?\s*(isAgent|isBot|agent)\s*\)\s*return\s+' r'if\s*\(\s*!?\s*(isAgent|isBot|agent)\s*\)\s*return\s+'
r'(ClipOval|ClipRRect|CircleAvatar)\b', 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 personShape = RegExp(r'\b(ClipOval|CircleAvatar|BoxShape\.circle)\b');
final violations = <String>[]; final violations = <String>[];
for (final file in Directory('lib').listSync(recursive: true)) { for (final file in Directory('lib').listSync(recursive: true)) {
@@ -53,6 +62,7 @@ void main() {
for (final match in [ for (final match in [
...inlineAgentShape.allMatches(source), ...inlineAgentShape.allMatches(source),
...inlineAgentClip.allMatches(source), ...inlineAgentClip.allMatches(source),
...agentRadius.allMatches(source),
if (path.startsWith('lib/features/agents/')) if (path.startsWith('lib/features/agents/'))
...personShape.allMatches(source), ...personShape.allMatches(source),
]) { ]) {