Files
buzz/mobile/lib/features/profile/image_avatar_capture.dart
klopez4212 9aa332af03 Add inline profile camera capture (#6680)
## Summary

- add inline profile photo capture with smooth avatar-to-viewfinder
transitions
- add close, flip, shutter, retry, and use-photo states with haptics and
front-camera mirroring
- polish iOS liquid-glass controls and avatar editor motion

### Related issue

Follow-up to #6583.

### Testing

- just mobile-check
- just mobile-test (1,743 tests)
- built, installed, and launched on Pixel 10 and iPhone Air

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz>
Signed-off-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Signed-off-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Signed-off-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz>
Co-authored-by: Tyler <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
Co-authored-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-25 14:58:55 +01:00

663 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:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/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.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 circular cutout becomes a camera.
final Widget? initialPreview;
/// 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: ClipOval(
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(LucideIcons.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
? LucideIcons.camera
: LucideIcons.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
? LucideIcons.images
: LucideIcons.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));