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), ]) {