From 00b1ec6cc1afbb62d4a653a4207a7034464c3723 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Sat, 31 Oct 2026 10:42:51 -0700 Subject: [PATCH] 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 Signed-off-by: Tom Brow --- mobile/ios/Runner/NativeAddMembersSheet.swift | 19 +++++++++++++++++-- .../shared/widgets/avatar_shape_test.dart | 12 +++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/mobile/ios/Runner/NativeAddMembersSheet.swift b/mobile/ios/Runner/NativeAddMembersSheet.swift index da0db0066a..16c25bafc9 100644 --- a/mobile/ios/Runner/NativeAddMembersSheet.swift +++ b/mobile/ios/Runner/NativeAddMembersSheet.swift @@ -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,21 @@ final class NativeAddMembersViewController: UITableViewController, UISearchResul }.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? { guard let number = value as? NSNumber else { return nil } let argb = number.uint32Value diff --git a/mobile/test/shared/widgets/avatar_shape_test.dart b/mobile/test/shared/widgets/avatar_shape_test.dart index 9be3caf27c..c37be004bb 100644 --- a/mobile/test/shared/widgets/avatar_shape_test.dart +++ b/mobile/test/shared/widgets/avatar_shape_test.dart @@ -20,6 +20,9 @@ void main() { (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'); } @@ -33,7 +36,9 @@ void main() { 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. + // 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|' @@ -43,6 +48,10 @@ void main() { 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 = []; for (final file in Directory('lib').listSync(recursive: true)) { @@ -53,6 +62,7 @@ void main() { for (final match in [ ...inlineAgentShape.allMatches(source), ...inlineAgentClip.allMatches(source), + ...agentRadius.allMatches(source), if (path.startsWith('lib/features/agents/')) ...personShape.allMatches(source), ]) {