From 51559c75c73f1102cfa3699c841543f1e615f1ee Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Sat, 31 Oct 2026 10:25:54 -0700 Subject: [PATCH] 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 Signed-off-by: Tom Brow --- .../features/agents/agent_avatar_picker.dart | 6 +- mobile/lib/features/agents/agent_card.dart | 8 +- .../features/agents/create_agent_page.dart | 8 +- .../channels/channel_detail_page.dart | 2 +- .../huddle_call_avatar.dart | 8 +- .../huddle_participant_overlay.dart | 4 +- .../lib/features/channels/small_avatar.dart | 6 +- .../features/profile/user_profile_sheet.dart | 9 +- .../shared/widgets/agent_avatar_border.dart | 58 ------------- mobile/lib/shared/widgets/avatar_image.dart | 26 +++--- .../shared/widgets/avatar_image_native.dart | 8 +- mobile/lib/shared/widgets/avatar_shape.dart | 86 +++++++++++++++++++ .../agents/agent_avatar_picker_test.dart | 4 +- .../channels/channel_detail_page_test.dart | 7 +- .../shared/widgets/avatar_image_test.dart | 27 +----- .../shared/widgets/avatar_shape_test.dart | 65 ++++++++++++++ 16 files changed, 192 insertions(+), 140 deletions(-) delete mode 100644 mobile/lib/shared/widgets/agent_avatar_border.dart create mode 100644 mobile/lib/shared/widgets/avatar_shape.dart create mode 100644 mobile/test/shared/widgets/avatar_shape_test.dart diff --git a/mobile/lib/features/agents/agent_avatar_picker.dart b/mobile/lib/features/agents/agent_avatar_picker.dart index 61bad9cc9b..f56b3bc53c 100644 --- a/mobile/lib/features/agents/agent_avatar_picker.dart +++ b/mobile/lib/features/agents/agent_avatar_picker.dart @@ -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( diff --git a/mobile/lib/features/agents/agent_card.dart b/mobile/lib/features/agents/agent_card.dart index 31cb98d283..6fc84fb48f 100644 --- a/mobile/lib/features/agents/agent_card.dart +++ b/mobile/lib/features/agents/agent_card.dart @@ -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, ), ), diff --git a/mobile/lib/features/agents/create_agent_page.dart b/mobile/lib/features/agents/create_agent_page.dart index b9f212e937..1cc2272ca0 100644 --- a/mobile/lib/features/agents/create_agent_page.dart +++ b/mobile/lib/features/agents/create_agent_page.dart @@ -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, diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 9bbc7f8b9a..27f6b040f7 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -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'; diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart b/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart index 67e75042db..f354046e0b 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart @@ -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, diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart b/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart index df2502e0b5..e5c379a058 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart @@ -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, ), diff --git a/mobile/lib/features/channels/small_avatar.dart b/mobile/lib/features/channels/small_avatar.dart index f15f79d761..7bef51a832 100644 --- a/mobile/lib/features/channels/small_avatar.dart +++ b/mobile/lib/features/channels/small_avatar.dart @@ -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, diff --git a/mobile/lib/features/profile/user_profile_sheet.dart b/mobile/lib/features/profile/user_profile_sheet.dart index 6d31d39bb3..a2a8e0e97e 100644 --- a/mobile/lib/features/profile/user_profile_sheet.dart +++ b/mobile/lib/features/profile/user_profile_sheet.dart @@ -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, diff --git a/mobile/lib/shared/widgets/agent_avatar_border.dart b/mobile/lib/shared/widgets/agent_avatar_border.dart deleted file mode 100644 index 1bb6e1ce56..0000000000 --- a/mobile/lib/shared/widgets/agent_avatar_border.dart +++ /dev/null @@ -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; -} diff --git a/mobile/lib/shared/widgets/avatar_image.dart b/mobile/lib/shared/widgets/avatar_image.dart index 0f1aa1654f..4de043da44 100644 --- a/mobile/lib/shared/widgets/avatar_image.dart +++ b/mobile/lib/shared/widgets/avatar_image.dart @@ -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, ); } } diff --git a/mobile/lib/shared/widgets/avatar_image_native.dart b/mobile/lib/shared/widgets/avatar_image_native.dart index 88fdf7e0a4..2b18b41f46 100644 --- a/mobile/lib/shared/widgets/avatar_image_native.dart +++ b/mobile/lib/shared/widgets/avatar_image_native.dart @@ -17,13 +17,7 @@ Future 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) diff --git a/mobile/lib/shared/widgets/avatar_shape.dart b/mobile/lib/shared/widgets/avatar_shape.dart new file mode 100644 index 0000000000..45c2cefce9 --- /dev/null +++ b/mobile/lib/shared/widgets/avatar_shape.dart @@ -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; +} diff --git a/mobile/test/features/agents/agent_avatar_picker_test.dart b/mobile/test/features/agents/agent_avatar_picker_test.dart index 0958175f51..04e487f28c 100644 --- a/mobile/test/features/agents/agent_avatar_picker_test.dart +++ b/mobile/test/features/agents/agent_avatar_picker_test.dart @@ -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 diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index e603e798d0..7a014c7fbe 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -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(), + shape, + avatarShape(isAgent: true, side: (shape as OutlinedBorder).side), ); } // As on desktop, the reply branch waits for a reply. diff --git a/mobile/test/shared/widgets/avatar_image_test.dart b/mobile/test/shared/widgets/avatar_image_test.dart index db46c2d6d0..fd63b975e9 100644 --- a/mobile/test/shared/widgets/avatar_image_test.dart +++ b/mobile/test/shared/widgets/avatar_image_test.dart @@ -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(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 { diff --git a/mobile/test/shared/widgets/avatar_shape_test.dart b/mobile/test/shared/widgets/avatar_shape_test.dart new file mode 100644 index 0000000000..9be3caf27c --- /dev/null +++ b/mobile/test/shared/widgets/avatar_shape_test.dart @@ -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 = []; + 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); + }); +}