mirror of
https://github.com/block/buzz.git
synced 2026-10-31 22:41:12 +00:00
fix(mobile): use the desktop agent squircle for every agent avatar (#8873)
On mobile, the Create agent form previewed emoji and image avatars as circles. Once the agent was created, its avatar showed as a rounded square, and other surfaces used yet other radii. None of them matched the agent squircle desktop uses. This PR makes `lib/shared/widgets/avatar_shape.dart` the single source of avatar silhouettes. Agents get desktop's squircle (traced from buzz-app's `avatar-squircle.svg`), and people keep circles. `avatarShape(isAgent:)`, `avatarPath()` and `AvatarClip` are the only ways to get either shape, and the squircle border class is private. Every agent avatar surface now goes through that bottleneck: - Create agent form and avatar picker previews (previously circles for emoji and photo avatars, and unclipped for catalog characters; `PresetAgentAvatar` now owns the clip) - The avatar picker's camera cutout (`ImageAvatarCapture` with `isAgent: true`); profile editing keeps the circle - Agents grid card portrait (previously a 22pt rounded rect) - `AvatarImage` (message authors, DM rows, member lists), the profile sheet, `SmallAvatar`, and the native avatar PNG used by iOS Add members (previously 30% rounded rects) - Huddle speaking halo, preparing placeholder, and spotlight frame - The iOS Add members fallback tile (Swift), shown while an avatar loads, now draws the same squircle instead of a 30% rounded rect To catch future drift, `avatar_shape_test.dart` adds a source guard. It fails when code under `lib/` picks an avatar shape inline from agent identity (for example `isAgent ? BoxShape.rectangle` or `if (!isAgent) return ClipOval`), hand-rolls the old 30%/60% agent radius, or uses a person shape (`ClipOval`, `CircleAvatar`, `BoxShape.circle`) under `lib/features/agents/`. Against main it flags 8 of the 10 old sites. It is best effort: the native PNG's inline ternary and the card's RRect didn't match those patterns and are fixed directly here. The Create agent form also gets 8pt above **Choose avatar**, so the button and everything below it move down from the preview, as Kenny requested. ### Before / after Captured on the iPhone SE (iOS 27) with a release build against a hosted Builderlab community, using a throwaway agent with an emoji avatar. In each pair, before (main) is on the left and after is on the right. **Create agent form** (also shows the 8pt spacing)  **Avatar picker**  **Agents card**  **Agent profile sheet**  **Message author avatar** (`AvatarImage`)  **DM list row** (`AvatarImage`)  **Add members** (native iOS list, `nativeAvatarImage`)  Not captured on device: `SmallAvatar` (thread reply and search avatars) and the Huddle halo, placeholder, and spotlight, which need a live agent thread or huddle. Widget tests cover these. ### Validation - Device: the before/after captures above. They predate the catalog preview clip ([355ebca](355ebca27e)), which only trims the transparent character art at the squircle corners. The test agent was deleted afterwards. - Added tests: `avatar_shape_test.dart` pins the squircle geometry to desktop's mask and runs the source guard. The avatar picker journey now checks that the catalog preview, emoji preview, and camera cutout use the agent shape. Existing `AvatarImage` and channel-detail Huddle/thread-avatar assertions were updated to the new shapes. - Full mobile format, analyze, and test suite pass locally. The iOS release build compiles with the Swift change. - The device build predates a merge of main that brought in [#8773](https://github.com/block/buzz/pull/8773). That merge touched the card, create page, and profile sheet without changing any avatar shapes. Human pre-readiness app testing is not applicable under Tom's standing exception for Buzz mobile changes. An independent agent review ran, and its optional findings were addressed in00b1ec6. buzz-review-completed 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,12 +17,7 @@ Future<Uint8List?> 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)
|
||||
|
||||
90
mobile/lib/shared/widgets/avatar_shape.dart
Normal file
90
mobile/lib/shared/widgets/avatar_shape.dart
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<ClipPath>(
|
||||
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<void>();
|
||||
tester
|
||||
@@ -76,6 +92,7 @@ void main() {
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(selection.bytes, isNotNull);
|
||||
expectSquirclePreview();
|
||||
expect(
|
||||
(tester
|
||||
.widget<GridView>(
|
||||
@@ -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')),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<ClipRRect>(find.byType(ClipRRect));
|
||||
expect(clip.borderRadius, BorderRadius.circular(9.6));
|
||||
final clip = tester.widget<ClipPath>(find.byType(ClipPath));
|
||||
expect(
|
||||
(clip.clipper! as ShapeBorderClipper).shape,
|
||||
avatarShape(isAgent: true),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('renders raccoon percent-encoded SVG data avatar', (
|
||||
|
||||
75
mobile/test/shared/widgets/avatar_shape_test.dart
Normal file
75
mobile/test/shared/widgets/avatar_shape_test.dart
Normal file
@@ -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 = <String>[];
|
||||
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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user