Files
buzz/mobile/lib/shared/widgets/image_avatar_capture.dart
Tom Brow acf56fff6f 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)

![create](https://raw.githubusercontent.com/block/buzz/f1f6a019c69e245146db761a6344524969275d04/pr-8873--create.png)

**Avatar picker**

![picker](https://raw.githubusercontent.com/block/buzz/f1f6a019c69e245146db761a6344524969275d04/pr-8873--picker.png)

**Agents card**

![card](https://raw.githubusercontent.com/block/buzz/f1f6a019c69e245146db761a6344524969275d04/pr-8873--card.png)

**Agent profile sheet**

![profile](https://raw.githubusercontent.com/block/buzz/f1f6a019c69e245146db761a6344524969275d04/pr-8873--profile.png)

**Message author avatar** (`AvatarImage`)

![dm-message](https://raw.githubusercontent.com/block/buzz/f1f6a019c69e245146db761a6344524969275d04/pr-8873--dm-message.png)

**DM list row** (`AvatarImage`)

![dm-list](https://raw.githubusercontent.com/block/buzz/f1f6a019c69e245146db761a6344524969275d04/pr-8873--dm-list.png)

**Add members** (native iOS list, `nativeAvatarImage`)

![add-members](https://raw.githubusercontent.com/block/buzz/f1f6a019c69e245146db761a6344524969275d04/pr-8873--add-members.png)

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 in 00b1ec6.

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>
2026-10-31 11:55:47 -07:00

669 lines
25 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:camera/camera.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image/image.dart' as image;
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';
part 'image_avatar_capture/camera_preview.dart';
part 'image_avatar_capture/morphing_camera_action.dart';
part 'image_avatar_capture/shutter_button.dart';
const _avatarPreviewSize = 220.0;
/// Diameter of the expanded circular viewfinder while taking a profile photo.
const imageAvatarCameraPreviewSize = _avatarPreviewSize * 1.25;
const _cameraControlSize = 64.0;
const _cameraControlRailHeight = 115.0;
const _shutterSize = _cameraControlSize * 1.5625;
const _shutterCoreSize = _shutterSize * (99 / 115);
const _reviewControlWidth = 112.0;
const _expandedControlOffset = 119.5;
const _reviewControlGap = Grid.twelve;
const _captureMotionDuration = Duration(milliseconds: 180);
const _shutterExitDuration = Duration(milliseconds: 150);
const _cameraFlipHalfDuration = Duration(milliseconds: 100);
/// Builds the inline still-photo camera used by the profile avatar editor.
typedef ImageAvatarCaptureBuilder =
Widget Function({
required double height,
required ValueChanged<Uint8List> onAccepted,
required VoidCallback onClosed,
});
/// Captures a still profile photo inside the avatar's circular viewfinder.
class ImageAvatarCapture extends HookConsumerWidget {
/// Creates the inline image capture surface.
const ImageAvatarCapture({
super.key,
required this.height,
required this.onAccepted,
required this.onClosed,
this.initialPreview,
this.isAgent = false,
this.initialCapturedBytes,
this.loadCameras = availableCameras,
this.disposalBarrier,
});
/// The vertical space available to the camera and its controls.
final double height;
/// Accepts the captured, square image as an unsaved avatar draft.
final ValueChanged<Uint8List> onAccepted;
/// Leaves camera mode without changing the current avatar draft.
final VoidCallback onClosed;
/// 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;
/// Loads device cameras. Overridden by focused widget tests.
@visibleForTesting
final Future<List<CameraDescription>> Function() loadCameras;
/// Serializes ownership release with another profile capture surface.
final CameraDisposalBarrier? disposalBarrier;
@override
Widget build(BuildContext context, WidgetRef ref) {
final reduceMotion = MediaQuery.disableAnimationsOf(context);
final lifecycle = ref.watch(appLifecycleProvider);
final controller = useState<CameraController?>(null);
final controllerRef = useRef<CameraController?>(null);
final controllerDisposal = useRef(
disposalBarrier ?? CameraDisposalBarrier(),
);
final candidateRef = useRef<CameraController?>(null);
final cameras = useState<List<CameraDescription>>(const []);
final selectedLens = useState(CameraLensDirection.front);
final cameraGeneration = useState(0);
final flipAnimation = useAnimationController(
duration: _cameraFlipHalfDuration * 2,
);
final flipDirection = useState(1.0);
final isInitializing = useState(initialCapturedBytes == null);
final isFlipping = useState(false);
final isCapturing = useState(false);
final isProcessingCapture = useState(false);
final capturedBytes = useState<Uint8List?>(initialCapturedBytes);
final controlsExpanded = useState(false);
final isClosing = useState(false);
final error = useState<String?>(null);
Future<void> releaseController(CameraController? active) {
if (active == null) return controllerDisposal.value.settled;
return controllerDisposal.value.release(active.dispose);
}
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
controlsExpanded.value = true;
});
return null;
}, const []);
useEffect(
() => () {
final active = controllerRef.value;
controllerRef.value = null;
unawaited(releaseController(active));
},
const [],
);
useEffect(() {
var disposed = false;
final generation = cameraGeneration.value;
if (lifecycle != AppLifecycleState.resumed ||
capturedBytes.value != null) {
isInitializing.value = false;
final active = controllerRef.value;
controllerRef.value = null;
controller.value = null;
unawaited(releaseController(active));
return null;
}
isInitializing.value = true;
error.value = null;
Future<void> initialize() async {
CameraController? next;
CameraDisposalReservation? reservation;
var installed = false;
try {
await controllerDisposal.value.settled;
if (disposed || generation != cameraGeneration.value) return;
final available = await loadCameras();
if (disposed || generation != cameraGeneration.value) return;
cameras.value = available;
if (available.isEmpty) {
throw CameraException(
'no-cameras',
'No cameras are available on this device.',
);
}
final description = available.firstWhere(
(candidate) => candidate.lensDirection == selectedLens.value,
orElse: () => available.first,
);
selectedLens.value = description.lensDirection;
next = CameraController(
description,
ResolutionPreset.high,
enableAudio: false,
);
reservation = controllerDisposal.value.reserve();
candidateRef.value = next;
await reservation.ready;
if (disposed) {
if (identical(candidateRef.value, next)) candidateRef.value = null;
await reservation.dispose(next.dispose);
return;
}
await next.initialize();
await next.lockCaptureOrientation(DeviceOrientation.portraitUp);
if (disposed || generation != cameraGeneration.value) {
if (identical(candidateRef.value, next)) candidateRef.value = null;
await reservation.dispose(next.dispose);
return;
}
candidateRef.value = null;
reservation.complete();
final previous = controllerRef.value;
controllerRef.value = next;
controller.value = next;
installed = true;
if (previous != null && previous != next) {
unawaited(releaseController(previous));
}
} catch (_) {
if (!installed && next != null) {
final activeCandidate = identical(candidateRef.value, next);
if (activeCandidate) candidateRef.value = null;
if (activeCandidate) {
await reservation?.dispose(next.dispose);
}
}
if (!disposed && generation == cameraGeneration.value) {
final active = controllerRef.value;
if (active != null) {
selectedLens.value = active.description.lensDirection;
}
error.value = 'Could not access the camera.';
}
} finally {
if (!disposed && generation == cameraGeneration.value) {
isInitializing.value = false;
}
}
}
unawaited(initialize());
return () {
disposed = true;
};
}, [lifecycle, capturedBytes.value == null, cameraGeneration.value]);
Future<void> capture() async {
final active = controller.value;
if (active == null || isCapturing.value || active.value.isTakingPicture) {
return;
}
isCapturing.value = true;
error.value = null;
XFile? photo;
try {
unawaited(HapticFeedback.mediumImpact());
photo = await active.takePicture();
try {
await active.pausePreview();
} on CameraException {
// Some camera backends pause automatically after a still capture.
}
if (context.mounted) isProcessingCapture.value = true;
final prepared = await ref
.read(mediaUploadServiceProvider)
.prepareImageBytes(photo);
final cropped = await compute(_centerCropCameraImage, (
bytes: prepared,
mirror: active.description.lensDirection == CameraLensDirection.front,
));
if (context.mounted) capturedBytes.value = cropped;
} catch (_) {
if (context.mounted) {
error.value = "We couldn't take that photo. Try again.";
try {
await active.resumePreview();
} on CameraException {
if (identical(controllerRef.value, active)) {
controllerRef.value = null;
controller.value = null;
await releaseController(active);
if (context.mounted) cameraGeneration.value++;
}
}
}
} finally {
final path = photo?.path;
if (path != null && path.isNotEmpty) {
try {
await File(path).delete();
} on FileSystemException {
// The camera plugin can remove its temporary file independently.
}
}
if (context.mounted) {
isCapturing.value = false;
isProcessingCapture.value = false;
}
}
}
Future<void> flipCamera() async {
if (isInitializing.value ||
isFlipping.value ||
isCapturing.value ||
cameras.value.length < 2) {
return;
}
final active = controller.value;
if (active == null) return;
final nextLens = selectedLens.value == CameraLensDirection.front
? CameraLensDirection.back
: CameraLensDirection.front;
final matches = cameras.value.where(
(camera) => camera.lensDirection == nextLens,
);
if (matches.isEmpty) return;
unawaited(HapticFeedback.selectionClick());
isFlipping.value = true;
flipDirection.value = nextLens == CameraLensDirection.back ? 1 : -1;
error.value = null;
final flipMotion = reduceMotion
? null
: flipAnimation.animateTo(
1,
duration: _cameraFlipHalfDuration * 2,
curve: Curves.easeInOutCubic,
);
try {
if (!reduceMotion) await Future<void>.delayed(_cameraFlipHalfDuration);
if (!context.mounted) return;
await active.setDescription(matches.first);
if (context.mounted) selectedLens.value = nextLens;
await active.lockCaptureOrientation(DeviceOrientation.portraitUp);
} on CameraException {
if (context.mounted) error.value = 'Could not switch cameras.';
} finally {
if (context.mounted && flipMotion != null) {
try {
await flipMotion.orCancel;
} on TickerCanceled {
// The view was disposed while the camera was switching.
}
}
if (context.mounted) {
flipAnimation.value = 0;
isFlipping.value = false;
}
}
}
void retake() {
unawaited(HapticFeedback.selectionClick());
capturedBytes.value = null;
error.value = null;
cameraGeneration.value++;
}
Future<void> leaveCamera(Uint8List? acceptedBytes) async {
if (isClosing.value) return;
unawaited(
acceptedBytes == null
? HapticFeedback.selectionClick()
: HapticFeedback.mediumImpact(),
);
isClosing.value = true;
controlsExpanded.value = false;
if (!reduceMotion) await Future<void>.delayed(_captureMotionDuration);
if (!context.mounted) return;
if (acceptedBytes == null) {
onClosed();
} else {
onAccepted(acceptedBytes);
}
}
final captured = capturedBytes.value;
final previewSize = controlsExpanded.value
? imageAvatarCameraPreviewSize
: _avatarPreviewSize;
final captureEnabled =
controller.value != null &&
!isInitializing.value &&
!isFlipping.value &&
!isCapturing.value &&
!isClosing.value;
final hasOppositeLens = cameras.value.any(
(camera) =>
camera.lensDirection ==
(selectedLens.value == CameraLensDirection.front
? CameraLensDirection.back
: CameraLensDirection.front),
);
final flipEnabled =
hasOppositeLens &&
!isInitializing.value &&
!isFlipping.value &&
!isCapturing.value &&
!isClosing.value;
return SizedBox(
key: const ValueKey('image-avatar-camera'),
height: height,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: 0,
right: 0,
top: 0,
height: imageAvatarCameraPreviewSize,
child: Center(
child: AnimatedBuilder(
animation: flipAnimation,
builder: (context, child) {
final progress = flipAnimation.value;
final angle = progress <= 0.5
? progress * pi
: (progress - 1) * pi;
return Transform(
key: const ValueKey('image-camera-preview-flip'),
alignment: Alignment.center,
transform: Matrix4.identity()
..setEntry(3, 2, 0.0015)
..rotateY(angle * flipDirection.value),
child: child,
);
},
child: AnimatedContainer(
key: const ValueKey('image-camera-preview-size'),
duration: reduceMotion
? Duration.zero
: _captureMotionDuration,
curve: Curves.easeOutCubic,
width: previewSize,
height: previewSize,
child: AvatarClip(
isAgent: isAgent,
child: ColoredBox(
color: Colors.black,
child: captured != null
? Image.memory(captured, fit: BoxFit.cover)
: controller.value != null
? _CameraPreview(controller: controller.value!)
: initialPreview != null
? FittedBox(
key: const ValueKey(
'image-camera-initial-preview-scale',
),
fit: BoxFit.cover,
child: SizedBox.square(
dimension: _avatarPreviewSize,
child: initialPreview,
),
)
: Center(
child: isInitializing.value
? const BuzzLoadingIndicator(
semanticLabel: 'Starting camera',
)
: const Icon(BuzzIcons.cameraOff, size: 32),
),
),
),
),
),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
height: _cameraControlRailHeight,
child: TweenAnimationBuilder<double>(
tween: Tween(end: controlsExpanded.value ? 1 : 0),
duration: reduceMotion ? Duration.zero : _captureMotionDuration,
curve: Curves.easeOutCubic,
builder: (context, progress, _) => LayoutBuilder(
builder: (context, constraints) {
final collapsedControlOffset =
(constraints.maxWidth + Grid.half * 3) / 8;
final sideOffset =
collapsedControlOffset +
(_expandedControlOffset - collapsedControlOffset) *
progress;
return TweenAnimationBuilder<double>(
tween: Tween(
end: captured == null || isClosing.value ? 0 : 1,
),
duration: reduceMotion
? Duration.zero
: _captureMotionDuration,
curve: Curves.easeInOutCubic,
builder: (context, reviewProgress, _) {
final sideWidth =
_cameraControlSize +
(_reviewControlWidth - _cameraControlSize) *
reviewProgress;
final reviewSideOffset =
_reviewControlWidth / 2 + _reviewControlGap / 2;
final effectiveSideOffset =
sideOffset +
(reviewSideOffset - sideOffset) * reviewProgress;
return Stack(
alignment: Alignment.center,
children: [
Positioned(
left:
constraints.maxWidth / 2 -
effectiveSideOffset -
sideWidth / 2,
width: sideWidth,
height: _cameraControlRailHeight,
child: _MorphingCameraAction(
controlKey: const ValueKey(
'image-camera-left-action',
),
width: sideWidth,
icon: isClosing.value
? BuzzIcons.camera
: BuzzIcons.x,
iosIcon: isClosing.value
? IosGlassNavigationIcon.camera
: IosGlassNavigationIcon.close,
label: captured != null && !isClosing.value
? 'Retry'
: null,
transitionLabel: isClosing.value
? 'Camera'
: null,
transitionLabelMaxWidth: 96,
transitionProgress: isClosing.value
? 1 - progress
: 0,
showEnabledAppearance: isClosing.value,
semanticLabel: isClosing.value
? 'Camera'
: captured == null
? 'Close camera'
: 'Retry',
onTap:
isFlipping.value ||
isCapturing.value ||
isClosing.value
? null
: captured == null
? () => unawaited(leaveCamera(null))
: retake,
),
),
TweenAnimationBuilder<double>(
tween: Tween(end: controlsExpanded.value ? 1 : 0),
duration: reduceMotion
? Duration.zero
: isClosing.value
? _shutterExitDuration
: _captureMotionDuration,
curve: Curves.easeOutCubic,
builder: (context, shutterProgress, _) =>
Transform.scale(
scale:
(0.73 + 0.27 * shutterProgress) *
(1 - 0.28 * reviewProgress),
child: Opacity(
key: const ValueKey(
'image-camera-shutter-exit-opacity',
),
opacity:
shutterProgress * (1 - reviewProgress),
child: IgnorePointer(
ignoring: captured != null,
child: _ShutterButton(
busy: isProcessingCapture.value,
onTap: captureEnabled
? () => unawaited(capture())
: null,
),
),
),
),
),
Positioned(
left:
constraints.maxWidth / 2 +
effectiveSideOffset -
sideWidth / 2,
width: sideWidth,
height: _cameraControlRailHeight,
child: _MorphingCameraAction(
controlKey: const ValueKey(
'image-camera-right-action',
),
width: sideWidth,
icon: isClosing.value
? BuzzIcons.images
: BuzzIcons.switchCamera,
iosIcon: isClosing.value
? IosGlassNavigationIcon.photoLibrary
: IosGlassNavigationIcon.rotateCamera,
label: captured != null && !isClosing.value
? 'Use Photo'
: null,
transitionLabel: isClosing.value
? 'Photo Library'
: null,
transitionLabelMaxWidth: 104,
transitionProgress: isClosing.value
? 1 - progress
: 0,
showEnabledAppearance: isClosing.value,
semanticLabel: isClosing.value
? 'Photo Library'
: captured == null
? 'Flip camera'
: 'Use Photo',
onTap: isClosing.value
? null
: captured != null
? () => unawaited(leaveCamera(captured))
: flipEnabled
? () => unawaited(flipCamera())
: null,
),
),
],
);
},
);
},
),
),
),
if (error.value != null)
Positioned(
left: 0,
right: 0,
bottom: _cameraControlRailHeight + Grid.xs,
child: Semantics(
liveRegion: true,
child: Text(
error.value!,
textAlign: TextAlign.center,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
),
),
],
),
);
}
}
Uint8List _centerCropCameraImage(({Uint8List bytes, bool mirror}) request) {
var decoded = image.decodeImage(request.bytes);
if (decoded == null) throw const FormatException('Invalid camera image');
if (request.mirror) decoded = image.flipHorizontal(decoded);
final side = min(decoded.width, decoded.height);
final cropped = image.copyCrop(
decoded,
x: (decoded.width - side) ~/ 2,
y: (decoded.height - side) ~/ 2,
width: side,
height: side,
);
final resized = side == 512
? cropped
: image.copyResize(
cropped,
width: 512,
height: 512,
interpolation: image.Interpolation.cubic,
);
return Uint8List.fromList(image.encodeJpg(resized, quality: 92));
}
/// Prepares a camera image with the same mirroring and crop used by capture.
@visibleForTesting
Uint8List prepareCameraImageForTesting(
Uint8List bytes, {
required bool mirror,
}) => _centerCropCameraImage((bytes: bytes, mirror: mirror));