diff --git a/mobile/ios/Runner/NativeAddMembersSheet.swift b/mobile/ios/Runner/NativeAddMembersSheet.swift index da0db0066a..0938a1be05 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,22 @@ final class NativeAddMembersViewController: UITableViewController, UISearchResul }.withRenderingMode(.alwaysOriginal) } + /// Mirrors avatar_shape.dart and desktop's `ROUNDED_SQUIRCLE_PATH` + /// (desktop/src/shared/ui/AvatarClipPaths.tsx). + 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/lib/features/agents/agent_avatar_picker.dart b/mobile/lib/features/agents/agent_avatar_picker.dart index 0d1de947da..c9d98808d8 100644 --- a/mobile/lib/features/agents/agent_avatar_picker.dart +++ b/mobile/lib/features/agents/agent_avatar_picker.dart @@ -12,6 +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/avatar_shape.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/avatar_image_source_controls.dart'; import '../../shared/widgets/image_avatar_capture.dart'; @@ -228,7 +229,10 @@ class AgentAvatarPicker extends HookConsumerWidget { child: SizedBox.square( dimension: 220, child: selection.bytes != null - ? ClipOval(child: Image.memory(selection.bytes!, fit: BoxFit.cover)) + ? AvatarClip( + isAgent: true, + child: Image.memory(selection.bytes!, fit: BoxFit.cover), + ) : PresetAgentAvatar( imageUrl: selection.url!, size: 220, @@ -356,6 +360,7 @@ class AgentAvatarPicker extends HookConsumerWidget { height: imageHeight - cameraTop, disposalBarrier: cameraDisposal, initialPreview: preview, + isAgent: true, onAccepted: acceptCameraImage, onClosed: () { cameraActive.value = false; diff --git a/mobile/lib/features/agents/agent_card.dart b/mobile/lib/features/agents/agent_card.dart index eb5ca4f412..d627668a52 100644 --- a/mobile/lib/features/agents/agent_card.dart +++ b/mobile/lib/features/agents/agent_card.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import '../../shared/animated_avatar.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/avatar_shape.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/progressive_animated_avatar.dart'; @@ -122,10 +123,8 @@ class AgentCard extends StatelessWidget { child: RepaintBoundary( child: SizedBox.square( dimension: 104, - child: ClipRRect( - borderRadius: BorderRadius.circular( - Radii.card, - ), + 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 0ebaa52b7c..78b47c8605 100644 --- a/mobile/lib/features/agents/create_agent_page.dart +++ b/mobile/lib/features/agents/create_agent_page.dart @@ -12,6 +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/avatar_shape.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../../shared/widgets/ios_glass_navigation_action.dart'; @@ -197,7 +198,8 @@ class CreateAgentPage extends HookConsumerWidget { child: SizedBox.square( dimension: 160, child: avatar.value.bytes != null - ? ClipOval( + ? AvatarClip( + isAgent: true, child: Image.memory( avatar.value.bytes!, fit: BoxFit.cover, @@ -213,7 +215,8 @@ class CreateAgentPage extends HookConsumerWidget { ), ), ), - if (!pending) + if (!pending) ...[ + const SizedBox(height: Grid.xxs), _ChooseAvatarButton( onPressed: busy ? null @@ -232,6 +235,7 @@ class CreateAgentPage extends HookConsumerWidget { } }, ), + ], ], ), ), diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 73e7f2cc94..27f6b040f7 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -32,6 +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/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 41de674c4d..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 @@ -188,15 +188,8 @@ class _HuddleCallAvatar extends HookConsumerWidget { key: ValueKey('huddle-speaking-halo-$pubkey'), width: speakingRingSize, height: speakingRingSize, - decoration: BoxDecoration( - shape: isAgent - ? BoxShape.rectangle - : BoxShape.circle, - borderRadius: isAgent - ? BorderRadius.circular( - speakingRingSize * 0.3, - ) - : null, + decoration: ShapeDecoration( + shape: avatarShape(isAgent: isAgent), color: context.colors.primary.withValues( alpha: 0.07, ), @@ -219,15 +212,8 @@ class _HuddleCallAvatar extends HookConsumerWidget { ), width: avatarRadius * 2, height: avatarRadius * 2, - decoration: BoxDecoration( - shape: isAgent - ? BoxShape.rectangle - : BoxShape.circle, - borderRadius: isAgent - ? BorderRadius.circular( - avatarRadius * 0.6, - ) - : null, + decoration: ShapeDecoration( + 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 0411fbaef0..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 @@ -198,13 +198,8 @@ class _HuddleParticipantSpotlight extends ConsumerWidget { ? Duration.zero : const Duration(milliseconds: 180), padding: EdgeInsets.all(active ? Grid.xxs : Grid.half), - decoration: BoxDecoration( - shape: isAgent ? BoxShape.rectangle : BoxShape.circle, - borderRadius: isAgent - ? BorderRadius.circular( - (_huddleParticipantSpotlightRadius + Grid.half) * 0.6, - ) - : null, + decoration: ShapeDecoration( + 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 f1a7bf1320..7bef51a832 100644 --- a/mobile/lib/features/channels/small_avatar.dart +++ b/mobile/lib/features/channels/small_avatar.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/avatar_shape.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/profile/user_profile.dart'; @@ -29,14 +30,13 @@ class SmallAvatar extends StatelessWidget { final initial = profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'); final isAgent = agent || profile?.ownerPubkey != null; + final side = BorderSide(color: context.colors.surface, width: 1.5); return Container( width: size, height: size, - decoration: BoxDecoration( - shape: isAgent ? BoxShape.rectangle : BoxShape.circle, - borderRadius: isAgent ? BorderRadius.circular(size * 0.3) : null, - border: Border.all(color: context.colors.surface, width: 1.5), + decoration: ShapeDecoration( + 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 92bf39315a..db79f98375 100644 --- a/mobile/lib/features/profile/user_profile_sheet.dart +++ b/mobile/lib/features/profile/user_profile_sheet.dart @@ -16,6 +16,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/avatar_shape.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/agent_artwork_panel.dart'; import '../../shared/widgets/buzz_sheet_header.dart'; @@ -603,12 +604,9 @@ class _ProfileAvatar extends HookWidget { imageUrl: animatedAvatar?.posterUrl ?? avatarUrl, fallback: _AvatarFallback(initial: initial), ); - if (!isAgent) return ClipOval(child: avatar); - return ClipRRect( - borderRadius: BorderRadius.circular( - constraints.biggest.shortestSide * 0.3, - ), - child: !usesInsetAgentArtwork(avatarUrl) + return AvatarClip( + isAgent: isAgent, + child: !isAgent || !usesInsetAgentArtwork(avatarUrl) ? avatar : Transform.scale( scale: animatedAvatarArtworkScale, diff --git a/mobile/lib/shared/widgets/avatar_image.dart b/mobile/lib/shared/widgets/avatar_image.dart index 5d05459ab6..4de043da44 100644 --- a/mobile/lib/shared/widgets/avatar_image.dart +++ b/mobile/lib/shared/widgets/avatar_image.dart @@ -11,6 +11,7 @@ import '../animated_avatar.dart'; import '../emoji/emoji_avatar.dart'; import '../emoji/native_emoji_glyph.dart'; import '../relay/relay.dart'; +import 'avatar_shape.dart'; part 'avatar_image_native.dart'; @@ -46,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, ); } - - final borderRadius = BorderRadius.circular(radius * 0.6); return DecoratedBox( - decoration: BoxDecoration(color: color, borderRadius: borderRadius), - child: ClipRRect( - borderRadius: borderRadius, - 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 b1cc52ba5e..2b18b41f46 100644 --- a/mobile/lib/shared/widgets/avatar_image_native.dart +++ b/mobile/lib/shared/widgets/avatar_image_native.dart @@ -17,12 +17,7 @@ Future nativeAvatarImage({ final recorder = ui.PictureRecorder(); final canvas = Canvas(recorder); final bounds = const Rect.fromLTWH(0, 0, dimension, dimension); - canvas.clipRRect( - RRect.fromRectAndRadius( - bounds, - Radius.circular(dimension * (isAgent && !circular ? 0.3 : 0.5)), - ), - ); + 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..fe0d50aed0 --- /dev/null +++ b/mobile/lib/shared/widgets/avatar_shape.dart @@ -0,0 +1,90 @@ +import 'package:flutter/widgets.dart'; + +/// The single source of avatar silhouettes. +/// +/// Agents use desktop's squircle (`ROUNDED_SQUIRCLE_PATH` in +/// `desktop/src/shared/ui/AvatarClipPaths.tsx`) 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) => isAgent + ? ClipPath( + clipper: ShapeBorderClipper(shape: avatarShape(isAgent: true)), + child: child, + ) + // People keep the dedicated oval clip, the fast path in dense lists. + : ClipOval(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/lib/shared/widgets/image_avatar_capture.dart b/mobile/lib/shared/widgets/image_avatar_capture.dart index 04c00b99e7..6a906e74ae 100644 --- a/mobile/lib/shared/widgets/image_avatar_capture.dart +++ b/mobile/lib/shared/widgets/image_avatar_capture.dart @@ -13,6 +13,7 @@ import 'package:buzz/shared/theme/buzz_icons.dart'; import '../relay/relay.dart'; import '../theme/theme.dart'; +import '../widgets/avatar_shape.dart'; import '../widgets/buzz_loading_indicator.dart'; import '../widgets/ios_glass_navigation_button.dart'; import 'camera_disposal_barrier.dart'; @@ -53,6 +54,7 @@ class ImageAvatarCapture extends HookConsumerWidget { required this.onAccepted, required this.onClosed, this.initialPreview, + this.isAgent = false, this.initialCapturedBytes, this.loadCameras = availableCameras, this.disposalBarrier, @@ -67,9 +69,12 @@ class ImageAvatarCapture extends HookConsumerWidget { /// Leaves camera mode without changing the current avatar draft. final VoidCallback onClosed; - /// The existing avatar shown while the same circular cutout becomes a camera. + /// The existing avatar shown while the same cutout becomes a camera. final Widget? initialPreview; + /// Whether the cutout uses the agent avatar shape instead of a circle. + final bool isAgent; + /// Seeds the captured-photo review state in focused widget tests. @visibleForTesting final Uint8List? initialCapturedBytes; @@ -414,7 +419,8 @@ class ImageAvatarCapture extends HookConsumerWidget { curve: Curves.easeOutCubic, width: previewSize, height: previewSize, - child: ClipOval( + child: AvatarClip( + isAgent: isAgent, child: ColoredBox( color: Colors.black, child: captured != null diff --git a/mobile/lib/shared/widgets/preset_agent_avatar.dart b/mobile/lib/shared/widgets/preset_agent_avatar.dart index c63787d6dc..1b6dea74b2 100644 --- a/mobile/lib/shared/widgets/preset_agent_avatar.dart +++ b/mobile/lib/shared/widgets/preset_agent_avatar.dart @@ -7,6 +7,7 @@ import 'package:video_player/video_player.dart'; import '../animated_avatar.dart'; import 'avatar_image.dart'; +import 'avatar_shape.dart'; import 'android_transparent_avatar.dart'; /// Plays the transparent video paired with a bundled character poster. @@ -98,21 +99,24 @@ class PresetAgentAvatar extends HookWidget { return SizedBox.square( dimension: size, - child: Transform.scale( - scale: animatedAvatarArtworkScale, - child: isAndroid && videoUrl != null - ? AndroidTransparentAvatar( - key: ValueKey(videoUrl), - url: videoUrl, - playing: playing, - fallback: AvatarImageContent( - imageUrl: poster, - fallback: fallback, - ), - ) - : controller != null && ready.value == controller - ? VideoPlayer(controller) - : AvatarImageContent(imageUrl: poster, fallback: fallback), + child: AvatarClip( + isAgent: true, + child: Transform.scale( + scale: animatedAvatarArtworkScale, + child: isAndroid && videoUrl != null + ? AndroidTransparentAvatar( + key: ValueKey(videoUrl), + url: videoUrl, + playing: playing, + fallback: AvatarImageContent( + imageUrl: poster, + fallback: fallback, + ), + ) + : controller != null && ready.value == controller + ? VideoPlayer(controller) + : AvatarImageContent(imageUrl: poster, fallback: fallback), + ), ), ); } diff --git a/mobile/test/features/agents/agent_avatar_picker_test.dart b/mobile/test/features/agents/agent_avatar_picker_test.dart index 27b4c76c38..884c9e62a8 100644 --- a/mobile/test/features/agents/agent_avatar_picker_test.dart +++ b/mobile/test/features/agents/agent_avatar_picker_test.dart @@ -4,6 +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/avatar_shape.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -66,6 +67,21 @@ void main() { await tester.pumpAndSettle(); expect(selection.url, isNot(agentAvatarCatalog.first.url)); final character = selection.url; + // Every preview uses the same squircle the agent gets once created. + void expectSquirclePreview() { + final previewClip = tester.widget( + find.descendant( + of: find.bySemanticsLabel('Selected agent avatar'), + matching: find.byType(ClipPath), + ), + ); + expect( + (previewClip.clipper! as ShapeBorderClipper).shape, + avatarShape(isAgent: true), + ); + } + + expectSquirclePreview(); await tester.runAsync(() async { rendered = Completer(); tester @@ -76,6 +92,7 @@ void main() { await tester.pumpAndSettle(); expect(selection.bytes, isNotNull); + expectSquirclePreview(); expect( (tester .widget( @@ -116,6 +133,21 @@ void main() { await tester.tap(find.byKey(const ValueKey('image-source-camera'))); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('image-avatar-camera')), findsOneWidget); + // The camera cutout keeps the agent shape while shooting and reviewing. + final cameraClip = tester.widget( + find + .descendant( + of: find.byKey(const ValueKey('image-camera-preview-size')), + matching: find.byWidgetPredicate( + (widget) => widget is ClipPath || widget is ClipOval, + ), + ) + .first, + ); + expect( + ((cameraClip as ClipPath).clipper! as ShapeBorderClipper).shape, + avatarShape(isAgent: true), + ); await tester.ensureVisible( find.byKey(const ValueKey('image-camera-left-action')), ); diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 2d4a2636d5..7a014c7fbe 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -79,6 +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/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'; @@ -7902,8 +7903,8 @@ void main() { find.byKey(const ValueKey('huddle-speaking-halo-desktop')), ); final speakingHaloDecoration = - speakingHalo.decoration! as BoxDecoration; - expect(speakingHaloDecoration.border, isNull); + speakingHalo.decoration! as ShapeDecoration; + expect(speakingHaloDecoration.shape, const CircleBorder()); expect(speakingHaloDecoration.color?.a, closeTo(0.07, 0.001)); await tester.pump(const Duration(milliseconds: 70)); final mediumScaleMidTransition = tester @@ -11321,7 +11322,11 @@ void main() { ) .first, ); - expect((frame.decoration! as BoxDecoration).shape, BoxShape.rectangle); + final shape = (frame.decoration! as ShapeDecoration).shape; + expect( + shape, + avatarShape(isAgent: true, side: (shape as OutlinedBorder).side), + ); } // As on desktop, the reply branch waits for a reply. expect(connector, findsNothing); diff --git a/mobile/test/shared/widgets/avatar_image_test.dart b/mobile/test/shared/widgets/avatar_image_test.dart index 2eef174204..fd63b975e9 100644 --- a/mobile/test/shared/widgets/avatar_image_test.dart +++ b/mobile/test/shared/widgets/avatar_image_test.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +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'; @@ -38,7 +39,7 @@ void main() { 'https://dwwgwmfqqjotj.cloudfront.net/avatars/20260925T154625166Z/poster/pollies/pollies-10.png'; for (final url in [poster, animated, preset]) { await tester.pumpWidget(subject(url, isAgent: true)); - final clip = find.byType(ClipRRect); + final clip = find.byType(ClipPath); final scale = find.descendant(of: clip, matching: find.byType(Transform)); if (url != poster) { expect(scale, findsOneWidget); @@ -51,12 +52,15 @@ void main() { } }); - testWidgets('clips agents to a 30 percent squircle', (tester) async { + testWidgets('clips agents to the desktop squircle', (tester) async { await tester.pumpWidget(subject(null, isAgent: true)); expect(find.byType(CircleAvatar), findsNothing); - final clip = tester.widget(find.byType(ClipRRect)); - expect(clip.borderRadius, BorderRadius.circular(9.6)); + final clip = tester.widget(find.byType(ClipPath)); + expect( + (clip.clipper! as ShapeBorderClipper).shape, + avatarShape(isAgent: true), + ); }); testWidgets('renders raccoon percent-encoded SVG data avatar', ( 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..c37be004bb --- /dev/null +++ b/mobile/test/shared/widgets/avatar_shape_test.dart @@ -0,0 +1,75 @@ +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), + // 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'); + } + // 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, + // 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|' + 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', + ); + // 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)) { + 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), + ...agentRadius.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); + }); +}