diff --git a/mobile/ios/Runner/JumpToLatestGlassButton.swift b/mobile/ios/Runner/JumpToLatestGlassButton.swift index 8a95831808a..0dce3fb048b 100644 --- a/mobile/ios/Runner/JumpToLatestGlassButton.swift +++ b/mobile/ios/Runner/JumpToLatestGlassButton.swift @@ -169,10 +169,18 @@ final class NavigationGlassButtonFactory: NSObject, FlutterPlatformViewFactory { } final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { + private static let shutterIconRatio: CGFloat = 80.0 / 115.0 + private static let shutterInsetRatio: CGFloat = 20.0 / 115.0 private let containerView: UIView private let channel: FlutterMethodChannel private let button = NavigationGlassButton(type: .system) + private let activityIndicator = UIActivityIndicatorView(style: .medium) private var buttonLabel: String? + private var buttonIconName = "chevron.backward" + private var contentIcon = "back" + private var buttonImage: UIImage? + private var isBusy = false + private var controlSize: CGFloat = 40 init( frame: CGRect, @@ -196,12 +204,11 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { (arguments?["hitTargetWidth"] as? NSNumber)?.doubleValue ?? 48 let hitTargetHeight = (arguments?["hitTargetHeight"] as? NSNumber)?.doubleValue ?? 48 - let label = arguments?["label"] as? String - buttonLabel = label - let icon = arguments?["icon"] as? String - let symbolName = icon == "close" ? "xmark" : "chevron.backward" let controlWidth = (arguments?["controlWidth"] as? NSNumber)?.doubleValue ?? 40 + controlSize = + (arguments?["controlSize"] as? NSNumber)?.doubleValue ?? 40 + let fillWidth = arguments?["fillWidth"] as? Bool ?? false var configuration: UIButton.Configuration if #available(iOS 26.0, *) { @@ -211,33 +218,15 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { configuration.baseBackgroundColor = UIColor.secondarySystemBackground } configuration.cornerStyle = .capsule - if let label { - configuration.title = label - configuration.titleLineBreakMode = .byClipping - configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { - incoming in - var outgoing = incoming - let preferred = UIFont.preferredFont(forTextStyle: .subheadline) - outgoing.font = UIFont.systemFont(ofSize: preferred.pointSize, weight: .semibold) - return outgoing - } - } else { - configuration.image = UIImage( - systemName: symbolName, - withConfiguration: UIImage.SymbolConfiguration( - pointSize: 17, - weight: .semibold - ) - ) - } button.configuration = configuration + applyContent(from: arguments) button.titleLabel?.numberOfLines = 1 button.titleLabel?.lineBreakMode = .byClipping button.hitTargetInsets = UIEdgeInsets( - top: max(0, (hitTargetHeight - 40) / 2), - left: max(0, buttonCenterX - 20), - bottom: max(0, (hitTargetHeight - 40) / 2), - right: max(0, hitTargetWidth - buttonCenterX - 20) + top: max(0, (hitTargetHeight - controlSize) / 2), + left: max(0, buttonCenterX - controlWidth / 2), + bottom: max(0, (hitTargetHeight - controlSize) / 2), + right: max(0, hitTargetWidth - buttonCenterX - controlWidth / 2) ) button.accessibilityLabel = arguments?["accessibilityLabel"] as? String ?? "Back" button.translatesAutoresizingMaskIntoConstraints = false @@ -248,8 +237,24 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { for: .touchUpInside ) + activityIndicator.translatesAutoresizingMaskIntoConstraints = false + activityIndicator.hidesWhenStopped = true + activityIndicator.isUserInteractionEnabled = false + button.addSubview(activityIndicator) + NSLayoutConstraint.activate([ + activityIndicator.centerXAnchor.constraint(equalTo: button.centerXAnchor), + activityIndicator.centerYAnchor.constraint(equalTo: button.centerYAnchor), + activityIndicator.widthAnchor.constraint(equalToConstant: 24), + activityIndicator.heightAnchor.constraint(equalToConstant: 24), + ]) + applyAppearance(from: args) channel.setMethodCallHandler { [weak self] call, result in + if call.method == "setContent" { + self?.setContent(from: call.arguments) + result(nil) + return + } guard call.method == "setAppearance" else { result(FlutterMethodNotImplemented) return @@ -260,14 +265,19 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { containerView.addSubview(button) NSLayoutConstraint.activate([ + button.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), + button.heightAnchor.constraint(equalToConstant: controlSize), + ]) + if fillWidth { + button.centerXAnchor.constraint(equalTo: containerView.centerXAnchor).isActive = true + button.widthAnchor.constraint(equalTo: containerView.widthAnchor).isActive = true + } else { button.centerXAnchor.constraint( equalTo: containerView.leadingAnchor, constant: buttonCenterX - ), - button.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), - button.widthAnchor.constraint(equalToConstant: controlWidth), - button.heightAnchor.constraint(equalToConstant: 40), - ]) + ).isActive = true + button.widthAnchor.constraint(equalToConstant: controlWidth).isActive = true + } } func view() -> UIView { @@ -331,14 +341,25 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { let foregroundColor = colorValue.map(Self.color(from:)) let enabled = arguments?["enabled"] as? Bool ?? true let busy = arguments?["busy"] as? Bool ?? false + let selected = arguments?["selected"] as? Bool ?? false containerView.overrideUserInterfaceStyle = interfaceStyle button.overrideUserInterfaceStyle = interfaceStyle button.isEnabled = enabled - button.configuration?.showsActivityIndicator = busy - button.configuration?.title = busy ? nil : buttonLabel + button.isSelected = selected + isBusy = busy + if selected { + button.accessibilityTraits.insert(.selected) + } else { + button.accessibilityTraits.remove(.selected) + } + button.configuration?.showsActivityIndicator = false if let foregroundColor { + // Glass uses the view tint for its selected treatment. Keep it aligned + // with the Buzz theme instead of falling back to the system blue tint. + button.tintColor = foregroundColor button.configuration?.baseForegroundColor = foregroundColor + activityIndicator.color = foregroundColor } if #unavailable(iOS 26.0) { button.configuration?.baseBackgroundColor = Self.fallbackBackgroundColor( @@ -346,9 +367,104 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { interfaceStyle: interfaceStyle ) } + updateDisplayedContent() + button.setNeedsUpdateConfiguration() + } + + private func applyContent(from value: Any?) { + let arguments = value as? [String: Any] + let icon = arguments?["icon"] as? String + contentIcon = icon ?? "back" + buttonLabel = arguments?["label"] as? String + if let accessibilityLabel = arguments?["accessibilityLabel"] as? String { + button.accessibilityLabel = accessibilityLabel + } + switch icon { + case "close": buttonIconName = "xmark" + case "camera": buttonIconName = "camera" + case "photoLibrary": buttonIconName = "photo.on.rectangle.angled" + case "palette": buttonIconName = "paintpalette" + case "emoji": buttonIconName = "face.smiling" + case "person": buttonIconName = "person" + case "frame": buttonIconName = "rectangle.stack" + case "rotateCamera": buttonIconName = "arrow.triangle.2.circlepath.camera" + case "shutter": buttonIconName = "circle.fill" + default: buttonIconName = "chevron.backward" + } + if buttonLabel != nil { + buttonImage = nil + button.configuration?.contentInsets = NSDirectionalEdgeInsets( + top: 8, + leading: 8, + bottom: 8, + trailing: 8 + ) + button.configuration?.titleLineBreakMode = .byClipping + button.configuration?.titleTextAttributesTransformer = + UIConfigurationTextAttributesTransformer { incoming in + var outgoing = incoming + let preferred = UIFont.preferredFont(forTextStyle: .subheadline) + outgoing.font = UIFont.systemFont( + ofSize: preferred.pointSize, + weight: .semibold + ) + return outgoing + } + } else { + button.configuration?.titleTextAttributesTransformer = nil + let iconInset: CGFloat = icon == "shutter" + ? controlSize * Self.shutterInsetRatio + : 8 + button.configuration?.contentInsets = NSDirectionalEdgeInsets( + top: iconInset, + leading: iconInset, + bottom: iconInset, + trailing: iconInset + ) + let pointSize: CGFloat = icon == "shutter" + ? controlSize * Self.shutterIconRatio + : 17 + buttonImage = UIImage( + systemName: buttonIconName, + withConfiguration: UIImage.SymbolConfiguration( + pointSize: pointSize, + weight: .semibold + ) + ) + } + updateDisplayedContent() button.setNeedsUpdateConfiguration() } + private func setContent(from value: Any?) { + let arguments = value as? [String: Any] + let nextIcon = arguments?["icon"] as? String ?? "back" + let nextLabel = arguments?["label"] as? String + guard nextIcon != contentIcon || nextLabel != buttonLabel else { + applyContent(from: value) + return + } + let duration = UIAccessibility.isReduceMotionEnabled ? 0 : 0.12 + UIView.transition( + with: button, + duration: duration, + options: [.transitionCrossDissolve, .beginFromCurrentState, .allowAnimatedContent], + animations: { [weak self] in self?.applyContent(from: value) } + ) + } + + private func updateDisplayedContent() { + if isBusy { + button.configuration?.title = nil + button.configuration?.image = nil + activityIndicator.startAnimating() + } else { + button.configuration?.title = buttonLabel + button.configuration?.image = buttonLabel == nil ? buttonImage : nil + activityIndicator.stopAnimating() + } + } + private static func color(from value: UInt32) -> UIColor { let alpha = CGFloat((value >> 24) & 0xFF) / 255 let red = CGFloat((value >> 16) & 0xFF) / 255 diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart index 0878b78e6b5..45264cf8841 100644 --- a/mobile/lib/features/profile/animated_avatar_capture.dart +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -18,7 +18,9 @@ import '../../shared/emoji/emoji_avatar.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 'avatar_background_grid.dart'; +import 'camera_disposal_barrier.dart'; import 'avatar_editor_option_button.dart'; import 'animated_avatar_orientation.dart'; import 'profile_avatar_draft.dart'; @@ -35,8 +37,6 @@ const _outputSize = 256; const _mobileDefaultPersonScale = 1.15; const _animatedReviewRailHeight = 88.0; -enum _AnimatedReviewSection { person, color, poster } - /// Records and prepares a short camera animation for a profile avatar. class AnimatedAvatarCapture extends HookConsumerWidget { /// Creates an animated-avatar capture and review surface. @@ -45,6 +45,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { required this.height, required this.onPrepareChanged, this.initialFrames = const [], + this.disposalBarrier, }); /// The vertical space available to the capture surface. @@ -56,10 +57,18 @@ class AnimatedAvatarCapture extends HookConsumerWidget { /// Seeds processed frames in lifecycle-focused widget tests. @visibleForTesting final List initialFrames; + + /// Serializes ownership release with another profile capture surface. + final CameraDisposalBarrier? disposalBarrier; + @override Widget build(BuildContext context, WidgetRef ref) { final controller = useState(null); final controllerRef = useRef(null); + final controllerDisposal = useRef( + disposalBarrier ?? CameraDisposalBarrier(), + ); + final candidateRef = useRef(null); final captureEpoch = useRef(0); final cameraGeneration = useState(0); final isInitializing = useState(true); @@ -96,6 +105,8 @@ class AnimatedAvatarCapture extends HookConsumerWidget { shapeOffsetX: shapeOffset.value.dx, shapeOffsetY: shapeOffset.value.dy, ); + final latestEncodeKey = useRef<_EncodeKey?>(null); + latestEncodeKey.value = encodeKey; useEffect(() { encodedCache.value = null; final key = encodeKey; @@ -129,15 +140,25 @@ class AnimatedAvatarCapture extends HookConsumerWidget { isInitializing.value = true; + Future releaseController(CameraController? active) { + if (active == null) return controllerDisposal.value.settled; + return controllerDisposal.value.release(active.dispose); + } + Future initialize() async { + CameraController? next; + CameraDisposalReservation? reservation; + var installed = false; try { + await controllerDisposal.value.settled; + if (disposed) return; final cameras = await availableCameras(); if (disposed || cameras.isEmpty) return; final selected = cameras.firstWhere( (camera) => camera.lensDirection == CameraLensDirection.front, orElse: () => cameras.first, ); - final next = CameraController( + next = CameraController( selected, ResolutionPreset.medium, enableAudio: false, @@ -145,14 +166,33 @@ class AnimatedAvatarCapture extends HookConsumerWidget { ? ImageFormatGroup.bgra8888 : ImageFormatGroup.yuv420, ); + 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) { - await next.dispose(); + if (identical(candidateRef.value, next)) candidateRef.value = null; + await reservation.dispose(next.dispose); return; } + candidateRef.value = null; + reservation.complete(); controllerRef.value = next; controller.value = next; + installed = true; } catch (_) { + if (!installed && + next != null && + identical(candidateRef.value, next)) { + candidateRef.value = null; + await reservation?.dispose(next.dispose); + } if (!disposed) error.value = 'Could not access the camera.'; } finally { if (!disposed) isInitializing.value = false; @@ -165,12 +205,12 @@ class AnimatedAvatarCapture extends HookConsumerWidget { captureEpoch.value++; final active = controllerRef.value; controllerRef.value = null; - unawaited(active?.dispose() ?? Future.value()); + unawaited(releaseController(active)); }; }, [lifecycle, frames.value.isEmpty, cameraGeneration.value]); Future prepare() async { - final key = encodeKey; + final key = latestEncodeKey.value; if (key == null) return null; isProcessing.value = true; error.value = null; @@ -242,7 +282,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { if (context.mounted && identical(controller.value, active)) { controller.value = null; } - await active.dispose(); + await controllerDisposal.value.release(active.dispose); } final timer = Timer.periodic(const Duration(milliseconds: 40), (_) { @@ -290,12 +330,14 @@ class AnimatedAvatarCapture extends HookConsumerWidget { await Future.delayed(const Duration(milliseconds: 10)); } if (captureEpoch.value != currentCapture || !context.mounted) return; - await releaseCamera(); if (captured.length < 2) { throw StateError('Not enough frames were captured.'); } isRecording.value = false; + // Enter the processing state before releasing the controller. Clearing + // the camera first briefly exposed the unavailable-camera placeholder. isPreparingFrames.value = true; + await releaseCamera(); // Cut out only the frames each device captured, then resample the // three-second window so Android and iOS use the same playback cadence. final cutouts = await _removeBackgrounds(captured); @@ -342,7 +384,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { final previewTop = activeSection.value == _AnimatedReviewSection.color ? -avatarBackgroundPreviewShift : 0.0; - final controlsTop = previewTop + 220; + final controlsTop = previewTop + _animatedAvatarPreviewSize; return SizedBox( height: height, child: Stack( @@ -357,7 +399,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { left: 0, right: 0, top: previewTop, - height: 220, + height: _animatedAvatarPreviewSize, child: Center( child: _RepositionablePreviewSemantics( offset: offset.value, @@ -385,7 +427,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { .toDouble(); }, child: SizedBox.square( - dimension: 220, + dimension: _animatedAvatarPreviewSize, child: Stack( fit: StackFit.expand, children: [ @@ -396,13 +438,17 @@ class AnimatedAvatarCapture extends HookConsumerWidget { Center( child: Transform.translate( offset: - const Offset(0, 20.625) + - shapeOffset.value * 51.5625, + const Offset( + 0, + _animatedAvatarShapeYOffset, + ) + + shapeOffset.value * + _animatedAvatarShapeTranslation, child: Transform.scale( scale: shapeScale.value, child: Container( - width: 172, - height: 172, + width: _animatedAvatarShapeSize, + height: _animatedAvatarShapeSize, decoration: BoxDecoration( color: Color(backdropColor.value), shape: BoxShape.circle, @@ -413,7 +459,9 @@ class AnimatedAvatarCapture extends HookConsumerWidget { ), _AnimatedPersonPreview( bytes: selectedFrame, - offset: offset.value * 48, + offset: + offset.value * + _animatedAvatarPersonTranslation, scale: scale.value, outline: personOutline.value, outlineColor: _personOutlineColor( @@ -634,13 +682,6 @@ List _resampleCapturedFrames( }, growable: false); } -Color _personOutlineColor(int backdropColor) { - final color = Color(backdropColor); - return color.computeLuminance() > 0.74 - ? const Color(0xFF111111) - : Colors.white; -} - @immutable class _FramePlane { const _FramePlane(this.bytes, this.bytesPerRow, this.bytesPerPixel); @@ -880,12 +921,14 @@ _EncodedAvatar _encodeAvatar(_EncodeRequest request) { height: _outputSize, numChannels: 4, ); - const previewSize = 220.0; - const previewTranslation = 48.0; + const previewSize = _animatedAvatarPreviewSize; + const previewTranslation = _animatedAvatarPersonTranslation; final translationScale = _outputSize / previewSize; image.compositeImage( person, scaledPerson, + dstW: scaledSize, + dstH: scaledSize, dstX: ((_outputSize - scaledSize) / 2 + request.offsetX * previewTranslation * translationScale) @@ -903,9 +946,9 @@ _EncodedAvatar _encodeAvatar(_EncodeRequest request) { final color = request.backdropColor; image.fillCircle( frame, - x: (_outputSize / 2 + request.shapeOffsetX * 60).round(), - y: (_outputSize / 2 + 24 + request.shapeOffsetY * 60).round(), - radius: (100 * request.shapeScale).round(), + x: _animatedAvatarShapeX(request.shapeOffsetX), + y: _animatedAvatarShapeY(request.shapeOffsetY), + radius: _animatedAvatarShapeRadius(request.shapeScale), color: image.ColorRgba8( (color >> 16) & 0xff, (color >> 8) & 0xff, @@ -923,16 +966,7 @@ _EncodedAvatar _encodeAvatar(_EncodeRequest request) { ..b = (outlineColor.b * 255).round() ..a = (pixel.a * 0.92).round(); } - for (final (x, y) in const [ - (-2, 0), - (2, 0), - (0, -2), - (0, 2), - (-1, -1), - (1, -1), - (-1, 1), - (1, 1), - ]) { + for (final (x, y) in _animatedAvatarOutlineOffsets) { image.compositeImage(frame, outline, dstX: x, dstY: y); } } @@ -956,26 +990,6 @@ _EncodedAvatar _encodeAvatar(_EncodeRequest request) { return _EncodedAvatar(animation, poster); } -/// Encodes one poster frame for validating animated-avatar framing parity. -@visibleForTesting -Uint8List encodeAnimatedAvatarPoster({ - required Uint8List frame, - required double scale, -}) => _encodeAvatar( - _EncodeRequest( - frames: [frame], - posterIndex: 0, - scale: scale, - offsetX: 0, - offsetY: 0, - backdropColor: 0xff0000ff, - personOutline: false, - shapeScale: 1, - shapeOffsetX: 0, - shapeOffsetY: 0, - ), -).poster; - extension on Iterable { Iterable skipLast(int count) { final values = toList(growable: false); diff --git a/mobile/lib/features/profile/animated_avatar_capture/capture_controls.dart b/mobile/lib/features/profile/animated_avatar_capture/capture_controls.dart index 0c87c10b504..762337466c2 100644 --- a/mobile/lib/features/profile/animated_avatar_capture/capture_controls.dart +++ b/mobile/lib/features/profile/animated_avatar_capture/capture_controls.dart @@ -1,5 +1,39 @@ part of '../animated_avatar_capture.dart'; +const _animatedAvatarPreviewSize = 220.0; +const _animatedAvatarPersonTranslation = 48.0; +const _animatedAvatarShapeSize = 172.0; +const _animatedAvatarShapeYOffset = 20.625; +const _animatedAvatarShapeTranslation = 51.5625; +const _animatedAvatarOutputScale = _outputSize / _animatedAvatarPreviewSize; +const _animatedAvatarOutlineOffsets = [ + (-3, 0), + (3, 0), + (0, -3), + (0, 3), + (-2, -2), + (2, -2), + (-2, 2), + (2, 2), +]; + +int _animatedAvatarShapeX(double offset) => + (_outputSize / 2 + + offset * + _animatedAvatarShapeTranslation * + _animatedAvatarOutputScale) + .round(); + +int _animatedAvatarShapeY(double offset) => + (_outputSize / 2 + + (_animatedAvatarShapeYOffset + + offset * _animatedAvatarShapeTranslation) * + _animatedAvatarOutputScale) + .round(); + +int _animatedAvatarShapeRadius(double scale) => + (_animatedAvatarShapeSize / 2 * _animatedAvatarOutputScale * scale).round(); + class _AnimatedRecordButton extends StatelessWidget { const _AnimatedRecordButton({required this.busy, required this.onPressed}); @@ -19,39 +53,57 @@ class _AnimatedRecordButton extends StatelessWidget { curve: Curves.easeOutCubic, width: busy ? 64 : constraints.maxWidth, height: 64, - child: Material( - color: context.colors.onSurface, - borderRadius: BorderRadius.circular(Radii.full), - clipBehavior: Clip.antiAlias, - child: InkWell( - key: const ValueKey('animated-avatar-record'), - onTap: busy ? null : onPressed, - child: Center( - child: AnimatedSwitcher( - duration: reduceMotion - ? Duration.zero - : const Duration(milliseconds: 150), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeOutCubic, - child: busy - ? BuzzLoadingIndicator( - key: const ValueKey('animated-avatar-capturing'), - size: 24, - color: context.colors.surface, - semanticLabel: 'Capturing animated avatar', - ) - : Text( - 'Record', - key: const ValueKey('animated-avatar-record-label'), - style: context.textTheme.labelLarge?.copyWith( - color: context.colors.surface, - fontWeight: FontWeight.w600, - ), - ), + child: defaultTargetPlatform == TargetPlatform.iOS + ? IosGlassNavigationButton( + key: const ValueKey('animated-avatar-record'), + icon: IosGlassNavigationIcon.shutter, + label: busy ? null : 'Record', + semanticLabel: 'Record animated avatar', + onPressed: busy ? null : onPressed, + width: busy ? 64 : constraints.maxWidth, + height: 64, + controlSize: 64, + fillWidth: true, + foregroundColor: context.colors.onSurface, + isBusy: busy, + ) + : Material( + color: context.colors.onSurface, + borderRadius: BorderRadius.circular(Radii.full), + clipBehavior: Clip.antiAlias, + child: InkWell( + key: const ValueKey('animated-avatar-record'), + onTap: busy ? null : onPressed, + child: Center( + child: AnimatedSwitcher( + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 150), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeOutCubic, + child: busy + ? BuzzLoadingIndicator( + key: const ValueKey( + 'animated-avatar-capturing', + ), + size: 24, + color: context.colors.surface, + semanticLabel: 'Capturing animated avatar', + ) + : Text( + 'Record', + key: const ValueKey( + 'animated-avatar-record-label', + ), + style: context.textTheme.labelLarge?.copyWith( + color: context.colors.surface, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), ), - ), - ), - ), ), ), ); @@ -65,13 +117,7 @@ class _AspectCorrectCameraPreview extends StatelessWidget { @override Widget build(BuildContext context) { - final orientation = controller.value.deviceOrientation; - final isLandscape = - orientation == DeviceOrientation.landscapeLeft || - orientation == DeviceOrientation.landscapeRight; - final aspectRatio = isLandscape - ? controller.value.aspectRatio - : 1 / controller.value.aspectRatio; + final aspectRatio = 1 / controller.value.aspectRatio; return FittedBox( fit: BoxFit.cover, clipBehavior: Clip.hardEdge, diff --git a/mobile/lib/features/profile/animated_avatar_capture/error_text.dart b/mobile/lib/features/profile/animated_avatar_capture/error_text.dart index 022a730009c..88086bfc163 100644 --- a/mobile/lib/features/profile/animated_avatar_capture/error_text.dart +++ b/mobile/lib/features/profile/animated_avatar_capture/error_text.dart @@ -20,3 +20,10 @@ class _ErrorText extends StatelessWidget { ), ); } + +Color _personOutlineColor(int backdropColor) { + final color = Color(backdropColor); + return color.computeLuminance() > 0.74 + ? const Color(0xFF111111) + : Colors.white; +} diff --git a/mobile/lib/features/profile/animated_avatar_capture/frame_processing.dart b/mobile/lib/features/profile/animated_avatar_capture/frame_processing.dart index b93175bf73a..6627c21deef 100644 --- a/mobile/lib/features/profile/animated_avatar_capture/frame_processing.dart +++ b/mobile/lib/features/profile/animated_avatar_capture/frame_processing.dart @@ -88,3 +88,23 @@ Uint8List _applySegmentationMask(_MaskRequest request) { } return image.encodePng(result, level: 4); } + +/// Encodes one poster frame for validating animated-avatar framing parity. +@visibleForTesting +Uint8List encodeAnimatedAvatarPoster({ + required Uint8List frame, + required double scale, +}) => _encodeAvatar( + _EncodeRequest( + frames: [frame], + posterIndex: 0, + scale: scale, + offsetX: 0, + offsetY: 0, + backdropColor: 0xff0000ff, + personOutline: false, + shapeScale: 1, + shapeOffsetX: 0, + shapeOffsetY: 0, + ), +).poster; diff --git a/mobile/lib/features/profile/animated_avatar_capture/review_controls.dart b/mobile/lib/features/profile/animated_avatar_capture/review_controls.dart index 7c9ce4d9709..2d45edd6836 100644 --- a/mobile/lib/features/profile/animated_avatar_capture/review_controls.dart +++ b/mobile/lib/features/profile/animated_avatar_capture/review_controls.dart @@ -1,5 +1,7 @@ part of '../animated_avatar_capture.dart'; +enum _AnimatedReviewSection { person, color, poster } + class _RepositionablePreviewSemantics extends StatelessWidget { const _RepositionablePreviewSemantics({ required this.offset, @@ -53,6 +55,7 @@ class _AnimatedReviewNav extends StatelessWidget { Expanded( child: AvatarEditorOptionButton( icon: LucideIcons.userRound, + iosIcon: IosGlassNavigationIcon.person, label: 'You', selected: selected == _AnimatedReviewSection.person, onTap: () => onSelected(_AnimatedReviewSection.person), @@ -62,6 +65,7 @@ class _AnimatedReviewNav extends StatelessWidget { Expanded( child: AvatarEditorOptionButton( icon: LucideIcons.palette, + iosIcon: IosGlassNavigationIcon.palette, label: 'Background', selected: selected == _AnimatedReviewSection.color, onTap: () => onSelected(_AnimatedReviewSection.color), @@ -71,6 +75,7 @@ class _AnimatedReviewNav extends StatelessWidget { Expanded( child: AvatarEditorOptionButton( icon: LucideIcons.galleryThumbnails, + iosIcon: IosGlassNavigationIcon.frame, label: 'Frame', selected: selected == _AnimatedReviewSection.poster, onTap: () => onSelected(_AnimatedReviewSection.poster), @@ -82,6 +87,7 @@ class _AnimatedReviewNav extends StatelessWidget { Expanded( child: AvatarEditorOptionButton( icon: LucideIcons.camera, + iosIcon: IosGlassNavigationIcon.camera, label: 'Retake', selected: false, onTap: onRetake, diff --git a/mobile/lib/features/profile/avatar_editor_option_button.dart b/mobile/lib/features/profile/avatar_editor_option_button.dart index 3ad51acb83a..c7d0d12da2e 100644 --- a/mobile/lib/features/profile/avatar_editor_option_button.dart +++ b/mobile/lib/features/profile/avatar_editor_option_button.dart @@ -1,9 +1,14 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/ios_glass_navigation_button.dart'; + +/// Space between avatar-editor controls and their labels. +const avatarEditorOptionLabelGap = Grid.half; /// A labelled circular option used by the profile avatar editor rails. class AvatarEditorOptionButton extends StatelessWidget { @@ -11,6 +16,7 @@ class AvatarEditorOptionButton extends StatelessWidget { const AvatarEditorOptionButton({ super.key, required this.icon, + this.iosIcon, required this.label, required this.selected, required this.onTap, @@ -20,6 +26,9 @@ class AvatarEditorOptionButton extends StatelessWidget { /// The symbol displayed inside the circular control. final IconData icon; + /// Native symbol used by the iOS liquid-glass control. + final IosGlassNavigationIcon? iosIcon; + /// The text displayed beneath the control. final String label; @@ -40,6 +49,59 @@ class AvatarEditorOptionButton extends StatelessWidget { unawaited(HapticFeedback.selectionClick()); onTap!(); }; + Widget labelWidget() { + final width = labelMaxWidth; + if (width != null) { + return SizedBox( + height: 20, + child: OverflowBox( + maxWidth: width, + maxHeight: 20, + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelSmall?.copyWith( + color: selected + ? context.colors.onSurface + : context.colors.onSurfaceVariant, + ), + ), + ), + ); + } + return Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelSmall?.copyWith( + color: selected + ? context.colors.onSurface + : context.colors.onSurfaceVariant, + ), + ); + } + + if (defaultTargetPlatform == TargetPlatform.iOS && iosIcon != null) { + return Column( + children: [ + IosGlassNavigationButton( + icon: iosIcon!, + semanticLabel: label, + onPressed: handleTap, + width: 64, + height: 64, + controlSize: 64, + foregroundColor: selected + ? context.colors.primary + : context.colors.onSurface, + isSelected: selected, + ), + const SizedBox(height: avatarEditorOptionLabelGap), + ExcludeSemantics(child: labelWidget()), + ], + ); + } return Semantics( label: label, button: true, @@ -75,36 +137,8 @@ class AvatarEditorOptionButton extends StatelessWidget { : context.colors.onSurface, ), ), - const SizedBox(height: Grid.quarter), - if (labelMaxWidth case final width?) - SizedBox( - height: 20, - child: OverflowBox( - maxWidth: width, - maxHeight: 20, - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.labelSmall?.copyWith( - color: selected - ? context.colors.onSurface - : context.colors.onSurfaceVariant, - ), - ), - ), - ) - else - Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.labelSmall?.copyWith( - color: selected - ? context.colors.onSurface - : context.colors.onSurfaceVariant, - ), - ), + const SizedBox(height: avatarEditorOptionLabelGap), + labelWidget(), ], ), ), diff --git a/mobile/lib/features/profile/camera_disposal_barrier.dart b/mobile/lib/features/profile/camera_disposal_barrier.dart new file mode 100644 index 00000000000..238d65267df --- /dev/null +++ b/mobile/lib/features/profile/camera_disposal_barrier.dart @@ -0,0 +1,71 @@ +import 'dart:async'; + +/// Serializes camera teardown so replacement sessions never overlap. +/// +/// A native disposal failure is intentionally contained: callers can proceed +/// with the next camera session after the failed release has settled. +class CameraDisposalBarrier { + Future _pending = Future.value(); + + /// Reserves camera ownership while a controller is initializing. + CameraDisposalReservation reserve() { + final reservation = CameraDisposalReservation._(_pending); + _pending = reservation._settled; + return reservation; + } + + /// Completes after earlier and [dispose] callbacks have settled. + /// + /// Exceptions from [dispose] are handled so they cannot prevent a later + /// replacement session from acquiring the camera. + Future release(Future Function() dispose) { + final previous = _pending; + final release = () async { + await previous; + try { + await dispose(); + } catch (_) { + // A failed native release must not permanently block camera recovery. + } + }(); + _pending = release; + return release; + } + + /// Completes once all scheduled teardown work has settled. + Future get settled => _pending; +} + +/// Owns one in-flight camera initialization in a [CameraDisposalBarrier]. +class CameraDisposalReservation { + CameraDisposalReservation._(this._previous) { + _settled = () async { + await _previous; + await _completion.future; + }(); + } + + final Future _previous; + final _completion = Completer(); + late final Future _settled; + + /// Waits for older camera teardown before this candidate initializes. + Future get ready => _previous; + + /// Releases this reservation after a successful initialization. + void complete() { + if (!_completion.isCompleted) _completion.complete(); + } + + /// Disposes a failed or cancelled candidate before allowing replacement. + Future dispose(Future Function() release) async { + await _previous; + try { + await release(); + } catch (_) { + // A failed native release must not permanently block camera recovery. + } finally { + complete(); + } + } +} diff --git a/mobile/lib/features/profile/image_avatar_capture.dart b/mobile/lib/features/profile/image_avatar_capture.dart new file mode 100644 index 00000000000..e9ffda995d7 --- /dev/null +++ b/mobile/lib/features/profile/image_avatar_capture.dart @@ -0,0 +1,662 @@ +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 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 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> 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(null); + final controllerRef = useRef(null); + final controllerDisposal = useRef( + disposalBarrier ?? CameraDisposalBarrier(), + ); + final candidateRef = useRef(null); + final cameras = useState>(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(initialCapturedBytes); + final controlsExpanded = useState(false); + final isClosing = useState(false); + final error = useState(null); + + Future 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 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 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 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.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 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.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( + 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( + 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( + 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)); diff --git a/mobile/lib/features/profile/image_avatar_capture/camera_preview.dart b/mobile/lib/features/profile/image_avatar_capture/camera_preview.dart new file mode 100644 index 00000000000..29a00d02091 --- /dev/null +++ b/mobile/lib/features/profile/image_avatar_capture/camera_preview.dart @@ -0,0 +1,21 @@ +part of '../image_avatar_capture.dart'; + +class _CameraPreview extends StatelessWidget { + const _CameraPreview({required this.controller}); + + final CameraController controller; + + @override + Widget build(BuildContext context) { + final aspectRatio = 1 / controller.value.aspectRatio; + return FittedBox( + fit: BoxFit.cover, + clipBehavior: Clip.hardEdge, + child: SizedBox( + width: imageAvatarCameraPreviewSize * aspectRatio, + height: imageAvatarCameraPreviewSize, + child: CameraPreview(controller), + ), + ); + } +} diff --git a/mobile/lib/features/profile/image_avatar_capture/morphing_camera_action.dart b/mobile/lib/features/profile/image_avatar_capture/morphing_camera_action.dart new file mode 100644 index 00000000000..9211b20f422 --- /dev/null +++ b/mobile/lib/features/profile/image_avatar_capture/morphing_camera_action.dart @@ -0,0 +1,149 @@ +part of '../image_avatar_capture.dart'; + +class _MorphingCameraAction extends StatelessWidget { + const _MorphingCameraAction({ + required this.controlKey, + required this.width, + required this.icon, + required this.iosIcon, + required this.label, + required this.transitionLabel, + required this.transitionLabelMaxWidth, + required this.transitionProgress, + required this.showEnabledAppearance, + required this.semanticLabel, + required this.onTap, + }); + + final Key controlKey; + final double width; + final IconData icon; + final IosGlassNavigationIcon iosIcon; + final String? label; + final String? transitionLabel; + final double transitionLabelMaxWidth; + final double transitionProgress; + final bool showEnabledAppearance; + final String semanticLabel; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + Widget control; + if (defaultTargetPlatform == TargetPlatform.iOS) { + control = IgnorePointer( + ignoring: onTap == null, + child: IosGlassNavigationButton( + icon: iosIcon, + label: label, + semanticLabel: semanticLabel, + onPressed: onTap ?? (showEnabledAppearance ? () {} : null), + width: width, + height: _cameraControlSize, + controlSize: _cameraControlSize, + fillWidth: true, + foregroundColor: context.colors.onSurface, + ), + ); + } else { + final dimmed = onTap == null && !showEnabledAppearance; + control = Semantics( + label: semanticLabel, + button: true, + enabled: onTap != null, + child: ExcludeSemantics( + child: Material( + color: context.colors.surfaceContainerHighest, + shape: const StadiumBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: SizedBox( + width: width, + height: _cameraControlSize, + child: Center( + child: AnimatedSwitcher( + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 120), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeOutCubic, + child: label == null + ? Icon( + icon, + key: ValueKey('camera-action-icon-${iosIcon.name}'), + size: 26, + color: dimmed + ? context.colors.onSurface.withValues( + alpha: 0.38, + ) + : context.colors.onSurface, + ) + : Text( + label!, + key: ValueKey(label), + maxLines: 1, + style: context.textTheme.labelMedium?.copyWith( + color: dimmed + ? context.colors.onSurface.withValues( + alpha: 0.38, + ) + : context.colors.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + + final labelProgress = transitionProgress.clamp(0.0, 1.0); + return Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + key: controlKey, + left: 0, + right: 0, + top: (_cameraControlRailHeight - _cameraControlSize) / 2, + height: _cameraControlSize, + child: Transform.translate( + offset: Offset(0, 1.5 * labelProgress), + child: control, + ), + ), + if (transitionLabel != null) + Positioned( + left: 0, + right: 0, + top: _cameraControlRailHeight - 20, + height: 20, + child: Opacity( + key: ValueKey('camera-transition-label-${transitionLabel!}'), + opacity: labelProgress, + child: Transform.translate( + offset: Offset(0, 2 * (1 - labelProgress)), + child: OverflowBox( + minWidth: 0, + maxWidth: transitionLabelMaxWidth, + maxHeight: 20, + child: Text( + transitionLabel!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/mobile/lib/features/profile/image_avatar_capture/shutter_button.dart b/mobile/lib/features/profile/image_avatar_capture/shutter_button.dart new file mode 100644 index 00000000000..e68a2b837e5 --- /dev/null +++ b/mobile/lib/features/profile/image_avatar_capture/shutter_button.dart @@ -0,0 +1,75 @@ +part of '../image_avatar_capture.dart'; + +class _ShutterButton extends StatelessWidget { + const _ShutterButton({required this.busy, required this.onTap}); + + final bool busy; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final reduceMotion = MediaQuery.disableAnimationsOf(context); + final isIos = defaultTargetPlatform == TargetPlatform.iOS; + final content = SizedBox( + key: const ValueKey('image-camera-shutter-morph'), + width: _shutterSize, + height: _shutterSize, + child: isIos + ? IosGlassNavigationButton( + icon: IosGlassNavigationIcon.shutter, + semanticLabel: 'Take photo', + onPressed: onTap, + width: _shutterSize, + height: _shutterSize, + controlSize: _shutterSize, + foregroundColor: context.colors.onSurface, + isBusy: busy, + ) + : Material( + color: context.colors.surfaceContainerHighest, + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + key: const ValueKey('image-camera-shutter'), + onTap: onTap, + child: Center( + child: AnimatedSwitcher( + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 140), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeOutCubic, + child: busy + ? BuzzLoadingIndicator( + key: const ValueKey('image-camera-capturing'), + size: 24, + color: context.colors.onSurface, + semanticLabel: 'Taking photo', + ) + : Container( + key: const ValueKey('image-camera-shutter-icon'), + width: _shutterCoreSize, + height: _shutterCoreSize, + decoration: BoxDecoration( + color: context.colors.onSurface, + shape: BoxShape.circle, + border: Border.all( + color: context.colors.surface, + width: 1.5, + ), + ), + ), + ), + ), + ), + ), + ); + if (isIos) return content; + return Semantics( + label: 'Take photo', + button: true, + enabled: onTap != null, + child: ExcludeSemantics(child: content), + ); + } +} diff --git a/mobile/lib/features/profile/profile_avatar_draft.dart b/mobile/lib/features/profile/profile_avatar_draft.dart index efb69004606..8fc48efaf3a 100644 --- a/mobile/lib/features/profile/profile_avatar_draft.dart +++ b/mobile/lib/features/profile/profile_avatar_draft.dart @@ -115,14 +115,12 @@ final class ProfileAnimatedAvatarDraft extends ProfileAvatarDraft { } final existing = _uploadedUrl; if (existing != null) return existing; - // Cache each content-addressed part independently. Upload sequentially so - // relays configured to allow only one in-flight media request can accept an - // animated avatar in a single Save attempt. - final upload = _uploadPoster(service).then( - (poster) => _uploadAnimation( - service, - ).then((animation) => buildAnimatedAvatarUrl(poster.url, animation.url)), - ); + // Cache each content-addressed part independently. If one request fails, + // retry only that part so a successful counterpart remains attached to + // this draft instead of becoming an abandoned duplicate. + final upload = Future.wait( + [_uploadPoster(service), _uploadAnimation(service)], + ).then((uploads) => buildAnimatedAvatarUrl(uploads[0].url, uploads[1].url)); _uploadedUrl = upload; try { return await upload; diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart index 4cc495eecb7..ec55df81736 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -18,12 +18,15 @@ import '../../shared/theme/theme.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/ios_native_segmented_control.dart'; +import '../../shared/widgets/ios_glass_navigation_button.dart'; import '../../shared/widgets/ios_native_skin_tone_control.dart'; import '../../shared/widgets/playing_avatar_image.dart'; import 'animated_avatar_capture.dart'; +import 'camera_disposal_barrier.dart'; import 'avatar_background_grid.dart'; import 'avatar_editor_option_button.dart'; import 'emoji_avatar_tile.dart'; +import 'image_avatar_capture.dart'; import 'profile_avatar_crop_page.dart'; import 'profile_avatar_draft.dart'; @@ -79,7 +82,9 @@ class ProfileAvatarEditor extends HookConsumerWidget { required this.onModeChanged, required this.onDraftChanged, required this.onAnimatedPrepareChanged, + required this.onImageCameraActiveChanged, this.animatedCaptureBuilder, + this.imageCaptureBuilder, }); /// The avatar URL shown until the user selects a new draft. @@ -107,9 +112,15 @@ class ProfileAvatarEditor extends HookConsumerWidget { final ValueChanged Function()?> onAnimatedPrepareChanged; + /// Reports whether the inline still camera currently owns the image editor. + final ValueChanged onImageCameraActiveChanged; + /// Overrides the animated capture surface, primarily for tests. final AnimatedAvatarCaptureBuilder? animatedCaptureBuilder; + /// Overrides the still-image capture surface, primarily for tests. + final ImageAvatarCaptureBuilder? imageCaptureBuilder; + @override Widget build(BuildContext context, WidgetRef ref) { final reduceMotion = MediaQuery.disableAnimationsOf(context); @@ -128,6 +139,8 @@ class ProfileAvatarEditor extends HookConsumerWidget { final emojiSection = useState(_EmojiEditorSection.emoji); final emojiPreviewKey = useState(0); final isPickingImage = useState(false); + final isCapturingImage = useState(false); + final cameraDisposal = useRef(CameraDisposalBarrier()); final imageSelectionGeneration = useRef(0); final currentMode = useRef(mode)..value = mode; final error = useState(null); @@ -154,6 +167,8 @@ class ProfileAvatarEditor extends HookConsumerWidget { if (mode == ProfileAvatarMode.image) { imageSelectionGeneration.value++; isPickingImage.value = false; + isCapturingImage.value = false; + onImageCameraActiveChanged(false); } if (!reduceMotion) modeTransitionController.value = 0; onModeChanged(nextMode); @@ -180,7 +195,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { ); } - Future selectImage({required bool camera}) async { + Future selectGalleryImage() async { if (isPickingImage.value) return; final operation = ++imageSelectionGeneration.value; bool isCurrentOperation() => @@ -192,9 +207,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { try { final service = ref.read(mediaUploadServiceProvider); unawaited(HapticFeedback.lightImpact()); - final picked = camera - ? await service.captureImage() - : await service.pickGalleryImage(); + final picked = await service.pickGalleryImage(); if (picked == null || !isCurrentOperation()) return; final preparedPhoto = await service.prepareImageBytes(picked); if (!context.mounted || !isCurrentOperation()) return; @@ -216,6 +229,16 @@ class ProfileAvatarEditor extends HookConsumerWidget { } } + void closeImageCamera() { + isCapturingImage.value = false; + onImageCameraActiveChanged(false); + } + + void acceptCameraImage(Uint8List bytes) { + onDraftChanged(ProfileImageAvatarDraft(bytes)); + closeImageCamera(); + } + final previewUrl = switch (draft) { ProfileUrlAvatarDraft(:final url) => url, _ => currentAvatarUrl, @@ -308,9 +331,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { ); final requestedShift = mode != ProfileAvatarMode.emoji ? 0.0 - : emojiSection.value == _EmojiEditorSection.emoji - ? _emojiPickerPreviewShift - : avatarBackgroundPreviewShift; + : _emojiPickerPreviewShift; final previewShift = min(requestedShift, maximumShift); final previewTop = basePreviewTop - previewShift; final returningToEmoji = @@ -332,20 +353,46 @@ class ProfileAvatarEditor extends HookConsumerWidget { } final fixedContentTop = previewTop + _previewBlockSize + _previewControlGap; + final imageCameraTop = + basePreviewTop + + _previewBlockSize / 2 - + imageAvatarCameraPreviewSize / 2; final modeTop = mode == ProfileAvatarMode.animated ? basePreviewTop + : mode == ProfileAvatarMode.image && isCapturingImage.value + ? imageCameraTop : fixedContentTop; final modeHeight = max( 0.0, viewportHeight - _editorControlsBottom - modeTop, ); final modeContent = switch (mode) { + ProfileAvatarMode.image when isCapturingImage.value => KeyedSubtree( + key: const ValueKey('image-camera-mode'), + child: + imageCaptureBuilder?.call( + height: modeHeight, + onAccepted: acceptCameraImage, + onClosed: closeImageCamera, + ) ?? + ImageAvatarCapture( + height: modeHeight, + initialPreview: fixedPreview, + onAccepted: acceptCameraImage, + onClosed: closeImageCamera, + disposalBarrier: cameraDisposal.value, + ), + ), ProfileAvatarMode.image => _ImageMode( key: const ValueKey(0), height: modeHeight, isPicking: isPickingImage.value, - onCamera: () => unawaited(selectImage(camera: true)), - onLibrary: () => unawaited(selectImage(camera: false)), + onCamera: () { + error.value = null; + isCapturingImage.value = true; + onImageCameraActiveChanged(true); + }, + onLibrary: () => unawaited(selectGalleryImage()), ), ProfileAvatarMode.emoji => _EmojiMode( key: const ValueKey(1), @@ -376,6 +423,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { AnimatedAvatarCapture( height: modeHeight, onPrepareChanged: onAnimatedPrepareChanged, + disposalBarrier: cameraDisposal.value, ), ), }; @@ -442,7 +490,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { ), ), ), - if (fixedPreview != null) + if (fixedPreview != null && !isCapturingImage.value) AnimatedPositioned( key: const ValueKey('avatar-preview-position'), curve: Curves.easeOutCubic, @@ -479,7 +527,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { ), ), AnimatedPositioned( - duration: reduceMotion + duration: isCapturingImage.value || reduceMotion ? Duration.zero : const Duration(milliseconds: 150), curve: Curves.easeOutCubic, @@ -660,22 +708,22 @@ class _ImageMode extends StatelessWidget { children: [ const Spacer(), Expanded( - child: AvatarEditorOptionButton( + child: _ImageSourceOption( key: const ValueKey('image-source-camera'), icon: LucideIcons.camera, + iosIcon: IosGlassNavigationIcon.camera, label: 'Camera', - selected: false, onTap: isPicking ? null : onCamera, labelMaxWidth: 96, ), ), const SizedBox(width: Grid.half), Expanded( - child: AvatarEditorOptionButton( + child: _ImageSourceOption( key: const ValueKey('image-source-library'), icon: LucideIcons.images, + iosIcon: IosGlassNavigationIcon.photoLibrary, label: 'Photo Library', - selected: false, onTap: isPicking ? null : onLibrary, labelMaxWidth: 104, ), @@ -687,3 +735,30 @@ class _ImageMode extends StatelessWidget { ), ); } + +class _ImageSourceOption extends StatelessWidget { + const _ImageSourceOption({ + super.key, + required this.icon, + required this.iosIcon, + required this.label, + required this.onTap, + required this.labelMaxWidth, + }); + + final IconData icon; + final IosGlassNavigationIcon iosIcon; + final String label; + final VoidCallback? onTap; + final double labelMaxWidth; + + @override + Widget build(BuildContext context) => AvatarEditorOptionButton( + icon: icon, + iosIcon: iosIcon, + label: label, + selected: false, + onTap: onTap, + labelMaxWidth: labelMaxWidth, + ); +} diff --git a/mobile/lib/features/profile/profile_avatar_editor/emoji_avatar_picker.dart b/mobile/lib/features/profile/profile_avatar_editor/emoji_avatar_picker.dart index 1712c0c99bd..6d658935f24 100644 --- a/mobile/lib/features/profile/profile_avatar_editor/emoji_avatar_picker.dart +++ b/mobile/lib/features/profile/profile_avatar_editor/emoji_avatar_picker.dart @@ -83,11 +83,16 @@ class _EmojiMode extends HookConsumerWidget { child: Opacity( opacity: transitionProgress, child: activeSection == _EmojiEditorSection.background - ? AvatarBackgroundGrid( - key: const ValueKey('emoji-background-editor'), - selectedColor: selectedColor, - onColorSelected: onColorSelected, - colorKeyPrefix: 'emoji-avatar-color', + ? Center( + key: const ValueKey( + 'emoji-background-editor-alignment', + ), + child: AvatarBackgroundGrid( + key: const ValueKey('emoji-background-editor'), + selectedColor: selectedColor, + onColorSelected: onColorSelected, + colorKeyPrefix: 'emoji-avatar-color', + ), ) : Column( key: const ValueKey('emoji-glyph-editor'), @@ -223,6 +228,7 @@ class _EmojiMode extends HookConsumerWidget { child: AvatarEditorOptionButton( key: const ValueKey('emoji-editor-background'), icon: LucideIcons.palette, + iosIcon: IosGlassNavigationIcon.palette, label: 'Background', selected: activeSection == _EmojiEditorSection.background, @@ -236,6 +242,7 @@ class _EmojiMode extends HookConsumerWidget { child: AvatarEditorOptionButton( key: const ValueKey('emoji-editor-emoji'), icon: LucideIcons.smile, + iosIcon: IosGlassNavigationIcon.emoji, label: 'Emoji', selected: activeSection == _EmojiEditorSection.emoji, onTap: () => diff --git a/mobile/lib/features/profile/profile_edit_page.dart b/mobile/lib/features/profile/profile_edit_page.dart index 12e4e3efd7b..3584808bee6 100644 --- a/mobile/lib/features/profile/profile_edit_page.dart +++ b/mobile/lib/features/profile/profile_edit_page.dart @@ -21,11 +21,11 @@ import '../../shared/widgets/ios_glass_navigation_button.dart'; import '../../shared/widgets/modal_presentation.dart'; import '../../shared/widgets/playing_avatar_image.dart'; import 'ios_profile_text_editor.dart'; +import 'image_avatar_capture.dart'; import 'profile_avatar_editor.dart'; import 'profile_avatar_draft.dart'; import 'profile_provider.dart'; import 'profile_text_editor.dart'; -import 'profile_text_edit_sheet.dart'; /// Edits the current user's public profile metadata. class ProfileEditPage extends HookConsumerWidget { @@ -34,6 +34,7 @@ class ProfileEditPage extends HookConsumerWidget { super.key, this.startInPhotoEditor = false, this.animatedAvatarCaptureBuilder, + this.imageAvatarCaptureBuilder, }); /// Opens directly into the photo editor when launched from Settings. @@ -42,6 +43,9 @@ class ProfileEditPage extends HookConsumerWidget { /// Overrides animated capture for focused integration tests. final AnimatedAvatarCaptureBuilder? animatedAvatarCaptureBuilder; + /// Overrides still-image capture for focused integration tests. + final ImageAvatarCaptureBuilder? imageAvatarCaptureBuilder; + static const _avatarRadius = 64.0; @override @@ -62,6 +66,7 @@ class ProfileEditPage extends HookConsumerWidget { useRef Function()?>(null); final avatarSaveError = useState(null); final canPrepareAnimatedAvatar = useState(false); + final isImageCameraActive = useState(false); final avatarMode = useState(ProfileAvatarMode.image); final avatarTransition = useAnimationController( duration: reduceMotion @@ -87,11 +92,10 @@ class ProfileEditPage extends HookConsumerWidget { bool multiline = false, }) => showBuzzModalBottomSheet( context: context, + title: title, isScrollControlled: true, requestFocus: true, - showCloseButton: false, - builder: (_) => ProfileTextEditSheet( - title: title, + builder: (_) => _ProfileTextEditSheet( initialValue: initialValue, hintText: hintText, multiline: multiline, @@ -172,6 +176,7 @@ class ProfileEditPage extends HookConsumerWidget { avatarEditConfig.value = null; prepareAnimatedAvatar.value = null; canPrepareAnimatedAvatar.value = false; + isImageCameraActive.value = false; avatarMode.value = ProfileAvatarMode.image; isClosingAvatar.value = false; } @@ -224,6 +229,19 @@ class ProfileEditPage extends HookConsumerWidget { requireCurrentCommunity(); await ref.read(profileProvider.notifier).updateAvatarUrl(nextAvatar); requireCurrentCommunity(); + if (nextDraft is ProfileAnimatedAvatarDraft) { + ref + .read(profileAvatarHandoffProvider.notifier) + .show( + ProfileAvatarHandoff( + avatarUrl: nextAvatar, + animation: nextDraft.animation, + poster: nextDraft.poster, + ), + ); + } else { + ref.read(profileAvatarHandoffProvider.notifier).clearAny(); + } if (context.mounted) await closeAvatarEditor(whileSaving: true); } on ProfileCommunityChangedException { await discardStaleEditor(); @@ -243,6 +261,7 @@ class ProfileEditPage extends HookConsumerWidget { final canSaveAvatar = profileHydrated && + !isImageCameraActive.value && (avatarMode.value == ProfileAvatarMode.animated ? canPrepareAnimatedAvatar.value : avatarDraftMode.value == avatarMode.value && @@ -250,6 +269,7 @@ class ProfileEditPage extends HookConsumerWidget { final activeDraft = avatarDraftMode.value == avatarMode.value ? avatarDraft.value : null; + final avatarHandoff = ref.watch(profileAvatarHandoffProvider); return PopScope( canPop: !isEditingAvatar.value, @@ -369,8 +389,12 @@ class ProfileEditPage extends HookConsumerWidget { avatarDraftMode.value = null; } }, + onImageCameraActiveChanged: (active) { + isImageCameraActive.value = active; + }, animatedCaptureBuilder: animatedAvatarCaptureBuilder, + imageCaptureBuilder: imageAvatarCaptureBuilder, ), ), ), @@ -410,6 +434,7 @@ class ProfileEditPage extends HookConsumerWidget { children: [ _ProfilePhotoEditor( profile: profile, + handoff: avatarHandoff, onEditPhoto: profileHydrated ? openAvatarEditor : null, ), AppListCard( @@ -488,36 +513,57 @@ String _fieldValue(String? value) { return trimmed.isEmpty ? 'Not set' : trimmed; } -class _ProfilePhotoEditor extends StatelessWidget { - const _ProfilePhotoEditor({required this.profile, required this.onEditPhoto}); +class _ProfilePhotoEditor extends ConsumerWidget { + const _ProfilePhotoEditor({ + required this.profile, + required this.handoff, + required this.onEditPhoto, + }); final UserProfile? profile; + final ProfileAvatarHandoff? handoff; final VoidCallback? onEditPhoto; @override - Widget build(BuildContext context) => Column( - children: [ - PlayingAvatarImage( - key: const ValueKey('profile-edit-avatar'), - imageUrl: profile?.avatarUrl, - radius: ProfileEditPage._avatarRadius, - backgroundColor: context.colors.primaryContainer, - fallback: Text( - profile?.initial ?? '?', - style: context.textTheme.displaySmall?.copyWith( - color: context.colors.onPrimaryContainer, + Widget build(BuildContext context, WidgetRef ref) { + final activeHandoff = handoff?.avatarUrl == profile?.avatarUrl + ? handoff + : null; + return Column( + children: [ + PlayingAvatarImage( + key: const ValueKey('profile-edit-avatar'), + imageUrl: profile?.avatarUrl, + radius: ProfileEditPage._avatarRadius, + backgroundColor: context.colors.primaryContainer, + loadingImage: activeHandoff == null + ? null + : MemoryImage(activeHandoff.animation), + loadingPosterImage: activeHandoff == null + ? null + : MemoryImage(activeHandoff.poster), + onAnimationReady: activeHandoff == null + ? null + : () => ref + .read(profileAvatarHandoffProvider.notifier) + .clear(activeHandoff.avatarUrl), + fallback: Text( + profile?.initial ?? '?', + style: context.textTheme.displaySmall?.copyWith( + color: context.colors.onPrimaryContainer, + ), ), ), - ), - const SizedBox(height: Grid.twelve), - _ProfileActionPill( - key: const ValueKey('profile-edit-photo-pill'), - semanticLabel: 'Edit profile photo', - label: 'Edit Photo', - onTap: onEditPhoto, - ), - ], - ); + const SizedBox(height: Grid.twelve), + _ProfileActionPill( + key: const ValueKey('profile-edit-photo-pill'), + semanticLabel: 'Edit profile photo', + label: 'Edit Photo', + onTap: onEditPhoto, + ), + ], + ); + } } class _ProfileActionPill extends StatelessWidget { @@ -582,3 +628,89 @@ class _EditChevron extends StatelessWidget { color: context.colors.onSurfaceVariant, ); } + +class _ProfileTextEditSheet extends HookWidget { + const _ProfileTextEditSheet({ + required this.initialValue, + required this.hintText, + required this.multiline, + required this.onSave, + }); + + final String initialValue; + final String hintText; + final bool multiline; + final Future Function(String value) onSave; + + @override + Widget build(BuildContext context) { + final controller = useTextEditingController(text: initialValue); + useListenable(controller); + final isSaving = useState(false); + final error = useState(null); + final hasChanges = controller.text.trim() != initialValue.trim(); + + Future save() async { + if (!hasChanges || isSaving.value) return; + isSaving.value = true; + error.value = null; + try { + await onSave(controller.text); + if (context.mounted) Navigator.of(context).pop(); + } on ProfileCommunityChangedException { + if (context.mounted) Navigator.of(context).pop(); + } catch (_) { + error.value = "We couldn't save this change. Try again."; + } finally { + if (context.mounted) isSaving.value = false; + } + } + + return SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.fromLTRB( + Grid.gutter, + Grid.xxs, + Grid.gutter, + MediaQuery.viewInsetsOf(context).bottom + Grid.xs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + key: const ValueKey('profile-field-input'), + controller: controller, + autofocus: true, + enabled: !isSaving.value, + minLines: multiline ? 4 : 1, + maxLines: multiline ? 6 : 1, + textCapitalization: TextCapitalization.sentences, + textInputAction: multiline + ? TextInputAction.newline + : TextInputAction.done, + onSubmitted: multiline ? null : (_) => unawaited(save()), + decoration: InputDecoration(hintText: hintText), + ), + if (error.value != null) ...[ + const SizedBox(height: Grid.xxs), + Text( + error.value!, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), + ], + const SizedBox(height: Grid.xs), + FilledButton( + key: const ValueKey('profile-field-save'), + onPressed: hasChanges && !isSaving.value ? save : null, + child: Text(isSaving.value ? 'Saving…' : 'Save'), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index 38231d0f0d3..93621f7608a 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:typed_data'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -17,6 +18,53 @@ class ProfileCommunityChangedException extends StateError { : super('Profile update cancelled because the active community changed.'); } +/// Keeps a freshly saved animated avatar local until its remote first frame is +/// ready, preserving the editor-to-profile visual handoff. +@immutable +class ProfileAvatarHandoff { + /// Creates a local handoff for [avatarUrl]. + const ProfileAvatarHandoff({ + required this.avatarUrl, + required this.animation, + required this.poster, + }); + + /// The persisted animated-avatar descriptor this handoff belongs to. + final String avatarUrl; + + /// The locally encoded animation shown while remote media warms up. + final Uint8List animation; + + /// The locally encoded poster used when motion is disabled. + final Uint8List poster; +} + +/// Owns the temporary local avatar shown across the save route transition. +class ProfileAvatarHandoffNotifier extends Notifier { + @override + ProfileAvatarHandoff? build() { + ref.watch(relayConfigProvider); + return null; + } + + /// Starts a handoff for a freshly published animated avatar. + void show(ProfileAvatarHandoff handoff) => state = handoff; + + /// Clears the handoff once the matching remote animation is ready. + void clear(String avatarUrl) { + if (state?.avatarUrl == avatarUrl) state = null; + } + + /// Clears a handoff superseded by a non-animated avatar save. + void clearAny() => state = null; +} + +/// The freshly saved local animated avatar, while its remote copy warms up. +final profileAvatarHandoffProvider = + NotifierProvider( + ProfileAvatarHandoffNotifier.new, + ); + /// The current user's profile (kind:0 metadata) loaded over the relay /// WebSocket. Returns null when no nsec is configured or when the user has /// not yet published a profile. diff --git a/mobile/lib/features/profile/profile_text_editor.dart b/mobile/lib/features/profile/profile_text_editor.dart index f37c113c9f4..2804c36d711 100644 --- a/mobile/lib/features/profile/profile_text_editor.dart +++ b/mobile/lib/features/profile/profile_text_editor.dart @@ -3,13 +3,14 @@ import 'dart:async'; 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 '../../shared/relay/relay.dart'; +import '../../shared/theme/theme.dart'; import '../../shared/widgets/modal_presentation.dart'; import 'ios_profile_text_editor.dart'; import 'profile_provider.dart'; -import 'profile_text_edit_sheet.dart'; /// Opens the current user's display-name editor from a profile action surface. Future showProfileDisplayNameEditor(BuildContext context) async { @@ -123,11 +124,10 @@ Future _showProfileTextEditor({ if (!context.mounted) return; await showBuzzModalBottomSheet( context: context, + title: title, isScrollControlled: true, requestFocus: true, - showCloseButton: false, - builder: (_) => ProfileTextEditSheet( - title: title, + builder: (_) => _ProfileTextEditSheet( initialValue: initialValue, hintText: hintText, multiline: multiline, @@ -141,3 +141,89 @@ void _showSaveError(BuildContext context) { const SnackBar(content: Text("We couldn't save this change. Try again.")), ); } + +class _ProfileTextEditSheet extends HookWidget { + const _ProfileTextEditSheet({ + required this.initialValue, + required this.hintText, + required this.multiline, + required this.onSave, + }); + + final String initialValue; + final String hintText; + final bool multiline; + final Future Function(String value) onSave; + + @override + Widget build(BuildContext context) { + final controller = useTextEditingController(text: initialValue); + useListenable(controller); + final isSaving = useState(false); + final error = useState(null); + final hasChanges = controller.text.trim() != initialValue.trim(); + + Future save() async { + if (!hasChanges || isSaving.value) return; + isSaving.value = true; + error.value = null; + try { + await onSave(controller.text); + if (context.mounted) Navigator.of(context).pop(); + } on ProfileCommunityChangedException { + if (context.mounted) Navigator.of(context).pop(); + } catch (_) { + error.value = "We couldn't save this change. Try again."; + } finally { + if (context.mounted) isSaving.value = false; + } + } + + return SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.fromLTRB( + Grid.gutter, + Grid.xxs, + Grid.gutter, + MediaQuery.viewInsetsOf(context).bottom + Grid.xs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + key: const ValueKey('profile-field-input'), + controller: controller, + autofocus: true, + enabled: !isSaving.value, + minLines: multiline ? 4 : 1, + maxLines: multiline ? 6 : 1, + textCapitalization: TextCapitalization.sentences, + textInputAction: multiline + ? TextInputAction.newline + : TextInputAction.done, + onSubmitted: multiline ? null : (_) => unawaited(save()), + decoration: InputDecoration(hintText: hintText), + ), + if (error.value != null) ...[ + const SizedBox(height: Grid.xxs), + Text( + error.value!, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), + ], + const SizedBox(height: Grid.xs), + FilledButton( + key: const ValueKey('profile-field-save'), + onPressed: hasChanges && !isSaving.value ? save : null, + child: Text(isSaving.value ? 'Saving…' : 'Save'), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/features/profile/settings_profile_header.dart b/mobile/lib/features/profile/settings_profile_header.dart index ac2df53ef42..1852188ecf2 100644 --- a/mobile/lib/features/profile/settings_profile_header.dart +++ b/mobile/lib/features/profile/settings_profile_header.dart @@ -8,6 +8,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/animated_avatar.dart'; import '../../shared/custom_emoji/custom_emoji_provider.dart'; import '../../shared/custom_emoji/custom_emoji_render.dart'; +import '../../shared/relay/media_image.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/anchored_popover_menu.dart'; @@ -34,6 +35,11 @@ class SettingsProfileHeader extends HookConsumerWidget { final hasStatus = status != null && !status.isEmpty; final presence = ref.watch(presenceProvider).value ?? 'offline'; final animatedAvatar = parseAnimatedAvatarUrl(profile?.avatarUrl); + final animatedPosterUrl = animatedAvatar?.posterUrl; + final handoff = ref.watch(profileAvatarHandoffProvider); + final activeHandoff = handoff?.avatarUrl == profile?.avatarUrl + ? handoff + : null; final stoppedAnimationUrl = useState(null); final avatarUrl = animatedAvatar == null ? profile?.avatarUrl @@ -71,10 +77,62 @@ class SettingsProfileHeader extends HookConsumerWidget { key: ValueKey(animatedAvatar.animationUrl), descriptor: animatedAvatar, fallback: _AvatarFallback(initial: profile?.initial), + loadingImage: activeHandoff == null + ? null + : MemoryImage(activeHandoff.animation), + onAnimationReady: activeHandoff == null + ? null + : () => ref + .read(profileAvatarHandoffProvider.notifier) + .clear(activeHandoff.avatarUrl), ) - : AvatarImageContent( + : activeHandoff == null || animatedPosterUrl == null + ? AvatarImageContent( imageUrl: avatarUrl, fallback: _AvatarFallback(initial: profile?.initial), + ) + : Stack( + fit: StackFit.expand, + children: [ + Image( + image: MemoryImage(activeHandoff.poster), + fit: BoxFit.cover, + gaplessPlayback: true, + ), + Offstage( + offstage: true, + child: MediaImage( + key: ValueKey( + 'settings-profile-paused-handoff-${activeHandoff.avatarUrl}', + ), + url: animatedPosterUrl, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => + const SizedBox.shrink(), + frameBuilder: + ( + context, + child, + frame, + wasSynchronouslyLoaded, + ) { + if (wasSynchronouslyLoaded || + frame != null) { + WidgetsBinding.instance + .addPostFrameCallback((_) { + ref + .read( + profileAvatarHandoffProvider + .notifier, + ) + .clear(activeHandoff.avatarUrl); + }); + } + return child; + }, + ), + ), + ], ), ), ), diff --git a/mobile/lib/shared/widgets/ios_glass_navigation_button.dart b/mobile/lib/shared/widgets/ios_glass_navigation_button.dart index f54c898d7cb..802b7c4f05d 100644 --- a/mobile/lib/shared/widgets/ios_glass_navigation_button.dart +++ b/mobile/lib/shared/widgets/ios_glass_navigation_button.dart @@ -9,7 +9,18 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import '../theme/theme.dart'; /// The navigation glyph displayed by [IosGlassNavigationButton]. -enum IosGlassNavigationIcon { back, close } +enum IosGlassNavigationIcon { + back, + close, + camera, + photoLibrary, + palette, + emoji, + person, + frame, + rotateCamera, + shutter, +} /// Leading width used by iOS channel-style headers. const iosGlassChannelHeaderLeadingWidth = 58.0; @@ -30,22 +41,57 @@ class IosGlassNavigationButton extends HookWidget { required this.icon, required this.semanticLabel, required this.onPressed, + this.label, this.width = 48, this.height = 48, + this.controlSize = 40, + this.fillWidth = false, this.buttonCenterX, this.foregroundColor, + this.isBusy = false, + this.isSelected = false, this.nativeViewSuppressed, }); static const viewType = 'buzz/navigation_glass'; + /// The SF Symbol-style glyph rendered by the native control. final IosGlassNavigationIcon icon; + + /// Optional text rendered by the native control instead of [icon]. + final String? label; + + /// Accessibility label exposed by the native view or Flutter fallback. final String semanticLabel; + + /// Invoked when the enabled control is activated. final VoidCallback? onPressed; + + /// Width of the platform-view hit target. final double width; + + /// Height of the platform-view hit target. final double height; + + /// Diameter of the visual glass control inside its hit target. + final double controlSize; + + /// Whether the native visual control fills the available width. + final bool fillWidth; + + /// Horizontal center for the visual control within its hit target. final double? buttonCenterX; + + /// Optional foreground tint for the native control and Flutter fallback. final Color? foregroundColor; + + /// Whether the native control presents its busy state. + final bool isBusy; + + /// Whether the native control exposes its selected state. + final bool isSelected; + + /// When true, substitutes an accessible Flutter control for the native view. final ValueListenable? nativeViewSuppressed; @override @@ -69,19 +115,44 @@ class IosGlassNavigationButton extends HookWidget { return () => channel.setMethodCallHandler(null); }, [nativeChannel.value]); + useEffect( + () { + final channel = nativeChannel.value; + if (channel != null) { + unawaited( + channel.invokeMethod('setAppearance', { + 'brightness': brightness, + 'foregroundColor': foregroundValue, + 'enabled': enabled, + 'busy': isBusy, + 'selected': isSelected, + }), + ); + } + return null; + }, + [ + nativeChannel.value, + brightness, + foregroundValue, + enabled, + isBusy, + isSelected, + ], + ); + useEffect(() { final channel = nativeChannel.value; if (channel != null) { - unawaited( - channel.invokeMethod('setAppearance', { - 'brightness': brightness, - 'foregroundColor': foregroundValue, - 'enabled': enabled, - }), - ); + final content = { + 'icon': icon.name, + 'accessibilityLabel': semanticLabel, + }; + if (label != null) content['label'] = label!; + unawaited(channel.invokeMethod('setContent', content)); } return null; - }, [nativeChannel.value, brightness, foregroundValue, enabled]); + }, [nativeChannel.value, icon, label, semanticLabel]); Widget buildControl({required bool suppressNativeView}) { if (suppressNativeView) { @@ -90,6 +161,7 @@ class IosGlassNavigationButton extends HookWidget { container: true, button: true, enabled: enabled, + selected: isSelected, label: semanticLabel, onTap: onPressed, child: ExcludeSemantics( @@ -97,10 +169,10 @@ class IosGlassNavigationButton extends HookWidget { key: const ValueKey('ios-glass-navigation-flutter-fallback'), children: [ Positioned( - left: resolvedButtonCenterX - 20, - top: (height - 40) / 2, - width: 40, - height: 40, + left: resolvedButtonCenterX - controlSize / 2, + top: (height - controlSize) / 2, + width: controlSize, + height: controlSize, child: DecoratedBox( decoration: BoxDecoration( color: context.colors.surface.withValues(alpha: 0.72), @@ -111,13 +183,52 @@ class IosGlassNavigationButton extends HookWidget { ), ), ), - child: Icon( - icon == IosGlassNavigationIcon.back - ? Icons.arrow_back_ios_new_rounded - : Icons.close_rounded, - size: 20, - color: effectiveForeground, - ), + child: isBusy + ? Center( + child: SizedBox.square( + dimension: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + color: effectiveForeground, + ), + ), + ) + : label != null + ? Text( + label!, + maxLines: 1, + style: context.textTheme.labelMedium?.copyWith( + color: effectiveForeground, + fontWeight: FontWeight.w600, + ), + ) + : Icon( + switch (icon) { + IosGlassNavigationIcon.back => + Icons.arrow_back_ios_new_rounded, + IosGlassNavigationIcon.close => + Icons.close_rounded, + IosGlassNavigationIcon.camera => + Icons.camera_alt_rounded, + IosGlassNavigationIcon.photoLibrary => + Icons.photo_library_rounded, + IosGlassNavigationIcon.palette => + Icons.palette_rounded, + IosGlassNavigationIcon.emoji => + Icons.emoji_emotions_rounded, + IosGlassNavigationIcon.person => + Icons.person_rounded, + IosGlassNavigationIcon.frame => + Icons.photo_size_select_actual_rounded, + IosGlassNavigationIcon.rotateCamera => + Icons.cameraswitch_rounded, + IosGlassNavigationIcon.shutter => Icons.circle, + }, + size: icon == IosGlassNavigationIcon.shutter + ? controlSize * 0.72 + : 22, + color: effectiveForeground, + ), ), ), ], @@ -125,19 +236,26 @@ class IosGlassNavigationButton extends HookWidget { ), ); } + final creationParams = { + 'icon': icon.name, + 'accessibilityLabel': semanticLabel, + 'brightness': brightness, + 'foregroundColor': foregroundValue, + 'enabled': enabled, + 'busy': isBusy, + 'selected': isSelected, + 'controlSize': controlSize, + 'controlWidth': controlSize, + 'fillWidth': fillWidth, + 'buttonCenterX': buttonCenterX ?? width / 2, + 'hitTargetWidth': width, + 'hitTargetHeight': height, + }; + if (label != null) creationParams['label'] = label!; return UiKitView( viewType: viewType, hitTestBehavior: PlatformViewHitTestBehavior.opaque, - creationParams: { - 'icon': icon.name, - 'accessibilityLabel': semanticLabel, - 'brightness': brightness, - 'foregroundColor': foregroundValue, - 'enabled': enabled, - 'buttonCenterX': buttonCenterX ?? width / 2, - 'hitTargetWidth': width, - 'hitTargetHeight': height, - }, + creationParams: creationParams, creationParamsCodec: const StandardMessageCodec(), onPlatformViewCreated: (viewId) { nativeChannel.value = MethodChannel('$viewType/$viewId'); diff --git a/mobile/lib/shared/widgets/playing_avatar_image.dart b/mobile/lib/shared/widgets/playing_avatar_image.dart index ef84d4549e7..8715e392170 100644 --- a/mobile/lib/shared/widgets/playing_avatar_image.dart +++ b/mobile/lib/shared/widgets/playing_avatar_image.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../relay/media_image.dart'; import '../animated_avatar.dart'; import 'avatar_image.dart'; import 'progressive_animated_avatar.dart'; @@ -13,6 +14,9 @@ class PlayingAvatarImage extends StatelessWidget { required this.radius, required this.fallback, this.backgroundColor, + this.loadingImage, + this.loadingPosterImage, + this.onAnimationReady, }); /// The still-image URL or encoded animated-avatar descriptor. @@ -24,12 +28,60 @@ class PlayingAvatarImage extends StatelessWidget { /// Optional background shown behind still-image content. final Color? backgroundColor; + /// A local image shown while the persisted animation warms up. + final ImageProvider? loadingImage; + + /// A local poster shown when motion is disabled while remote media loads. + final ImageProvider? loadingPosterImage; + + /// Called after persisted avatar media produces its first frame. + final VoidCallback? onAnimationReady; + /// Content shown while media is unavailable or still loading. final Widget fallback; @override Widget build(BuildContext context) { final descriptor = parseAnimatedAvatarUrl(imageUrl); + if (descriptor != null && + MediaQuery.disableAnimationsOf(context) && + loadingPosterImage != null) { + return CircleAvatar( + radius: radius, + backgroundColor: Colors.transparent, + child: ClipOval( + child: SizedBox.square( + dimension: radius * 2, + child: Stack( + fit: StackFit.expand, + children: [ + Image( + image: loadingPosterImage!, + fit: BoxFit.cover, + gaplessPlayback: true, + ), + Offstage( + offstage: true, + child: MediaImage( + url: descriptor.posterUrl, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + frameBuilder: (_, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + onAnimationReady?.call(); + }); + } + return child; + }, + ), + ), + ], + ), + ), + ), + ); + } if (descriptor == null || MediaQuery.disableAnimationsOf(context)) { return AvatarImage( imageUrl: descriptor?.posterUrl ?? imageUrl, @@ -48,6 +100,8 @@ class PlayingAvatarImage extends StatelessWidget { child: ProgressiveAnimatedAvatar( descriptor: descriptor, fallback: fallback, + loadingImage: loadingImage, + onAnimationReady: onAnimationReady, ), ), ), diff --git a/mobile/lib/shared/widgets/progressive_animated_avatar.dart b/mobile/lib/shared/widgets/progressive_animated_avatar.dart index 94a4785fdf4..ed0c54e089e 100644 --- a/mobile/lib/shared/widgets/progressive_animated_avatar.dart +++ b/mobile/lib/shared/widgets/progressive_animated_avatar.dart @@ -15,12 +15,20 @@ class ProgressiveAnimatedAvatar extends HookWidget { required this.descriptor, required this.fallback, this.fit = BoxFit.cover, + this.loadingImage, + this.onAnimationReady, }); final AnimatedAvatarDescriptor descriptor; final Widget fallback; final BoxFit fit; + /// A local image shown instead of the poster while remote media warms up. + final ImageProvider? loadingImage; + + /// Called after the remote animation has produced its first frame. + final VoidCallback? onAnimationReady; + @override Widget build(BuildContext context) { final readyAnimationUrl = useState(null); @@ -42,6 +50,7 @@ class ProgressiveAnimatedAvatar extends HookWidget { WidgetsBinding.instance.addPostFrameCallback((_) { if (context.mounted) { readyAnimationUrl.value = descriptor.animationUrl; + onAnimationReady?.call(); } }); } @@ -60,12 +69,20 @@ class ProgressiveAnimatedAvatar extends HookWidget { return Stack( fit: StackFit.expand, children: [ - AvatarImageContent( - key: const ValueKey('progressive-animated-avatar-poster'), - imageUrl: descriptor.posterUrl, - fallback: fallback, - fit: fit, - ), + if (loadingImage case final image?) + Image( + key: const ValueKey('progressive-animated-avatar-local-handoff'), + image: image, + fit: fit, + gaplessPlayback: true, + ) + else + AvatarImageContent( + key: const ValueKey('progressive-animated-avatar-poster'), + imageUrl: descriptor.posterUrl, + fallback: fallback, + fit: fit, + ), Offstage( key: const ValueKey('progressive-animated-avatar-animation-loading'), offstage: true, diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 0d4e95a80c9..46ea8cf564a 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -162,7 +162,7 @@ packages: source: hosted version: "0.10.2" camera_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: camera_platform_interface sha256: "4524ca6eb4176b066864036ad4fe02c3e4863e63b77eadc21a5bf56824f43498" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 6c51dc9cc5c..4543888551b 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -51,6 +51,7 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 + camera_platform_interface: ^2.13.1 crypto: ^3.0.7 custom_lint: ^0.8.0 riverpod_lint: ^3.1.0 diff --git a/mobile/test/features/profile/animated_avatar_capture_test.dart b/mobile/test/features/profile/animated_avatar_capture_test.dart index 63059266321..6558d0ac369 100644 --- a/mobile/test/features/profile/animated_avatar_capture_test.dart +++ b/mobile/test/features/profile/animated_avatar_capture_test.dart @@ -1,12 +1,15 @@ import 'dart:io'; +import 'dart:async'; import 'dart:ui' show SemanticsAction; -import 'package:buzz/features/profile/animated_avatar_orientation.dart'; +import 'package:camera_platform_interface/camera_platform_interface.dart'; import 'package:buzz/features/profile/animated_avatar_capture.dart'; +import 'package:buzz/features/profile/animated_avatar_orientation.dart'; +import 'package:buzz/features/profile/camera_disposal_barrier.dart'; +import 'package:buzz/features/profile/image_avatar_capture.dart'; import 'package:buzz/features/profile/profile_avatar_draft.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; -import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -28,6 +31,18 @@ void main() { expect(center.r, 255); }); + test('encoded poster preserves avatar scales above one', () { + final source = image.Image(width: 256, height: 256, numChannels: 4); + image.fill(source, color: image.ColorRgba8(255, 0, 0, 255)); + + final poster = image.decodePng( + encodeAnimatedAvatarPoster(frame: image.encodePng(source), scale: 1.5), + )!; + + expect(poster.getPixel(8, 8).r, 255); + expect(poster.getPixel(247, 247).r, 255); + }); + test('capture frame workspaces are isolated', () async { final first = await createAnimatedAvatarFrameDirectory( parent: Directory.systemTemp, @@ -43,46 +58,238 @@ void main() { expect(first.path, isNot(second.path)); }); - group('animatedAvatarFrameRotationDegrees', () { - test('compensates front camera frames for every device orientation', () { - const expected = { - DeviceOrientation.portraitUp: 270, - DeviceOrientation.landscapeRight: 180, - DeviceOrientation.portraitDown: 90, - DeviceOrientation.landscapeLeft: 0, - }; - - for (final entry in expected.entries) { - expect( - animatedAvatarFrameRotationDegrees( - sensorOrientation: 270, - deviceOrientation: entry.key, - lensDirection: CameraLensDirection.front, - ), - entry.value, - ); - } + test('accounts for device orientation and lens direction', () { + expect( + animatedAvatarFrameRotationDegrees( + sensorOrientation: 270, + deviceOrientation: DeviceOrientation.landscapeRight, + lensDirection: CameraLensDirection.front, + ), + 180, + ); + expect( + animatedAvatarFrameRotationDegrees( + sensorOrientation: 270, + deviceOrientation: DeviceOrientation.landscapeRight, + lensDirection: CameraLensDirection.back, + ), + 0, + ); + }); + + testWidgets('animated capture releases a failed orientation-lock candidate', ( + tester, + ) async { + final platform = _TestCameraPlatform(failLockForCameraIds: {1}); + final previousPlatform = CameraPlatform.instance; + CameraPlatform.instance = platform; + addTearDown(() { + CameraPlatform.instance = previousPlatform; }); - test('compensates back camera frames for every device orientation', () { - const expected = { - DeviceOrientation.portraitUp: 90, - DeviceOrientation.landscapeRight: 180, - DeviceOrientation.portraitDown: 270, - DeviceOrientation.landscapeLeft: 0, - }; - - for (final entry in expected.entries) { - expect( - animatedAvatarFrameRotationDegrees( - sensorOrientation: 90, - deviceOrientation: entry.key, - lensDirection: CameraLensDirection.back, + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: SizedBox( + height: 600, + child: AnimatedAvatarCapture( + height: 600, + onPrepareChanged: (_) {}, + ), + ), ), - entry.value, - ); - } - }); + ), + ), + ); + await tester.pump(); + await tester.pump(); + + expect(platform.createdCameraIds, [1]); + expect(platform.disposedCameraIds, [1]); + expect(find.text('Could not access the camera.'), findsOneWidget); + final error = find.byWidgetPredicate( + (widget) => widget is Semantics && widget.properties.liveRegion == true, + ); + expect(error, findsOneWidget); + + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + }); + + testWidgets('animated capture waits for disposal before lifecycle resume', ( + tester, + ) async { + final platform = _TestCameraPlatform(blockDisposeForCameraIds: {1}); + final previousPlatform = CameraPlatform.instance; + CameraPlatform.instance = platform; + addTearDown(() => CameraPlatform.instance = previousPlatform); + final lifecycle = _TestLifecycleNotifier(); + + await tester.pumpWidget( + ProviderScope( + overrides: [appLifecycleProvider.overrideWith(() => lifecycle)], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: SizedBox( + height: 600, + child: AnimatedAvatarCapture( + height: 600, + onPrepareChanged: (_) {}, + ), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + expect(platform.createdCameraIds, [1]); + + lifecycle.setLifecycle(AppLifecycleState.paused); + await tester.pump(); + expect(platform.disposedCameraIds, [1]); + + lifecycle.setLifecycle(AppLifecycleState.resumed); + await tester.pump(); + await tester.pump(); + expect(platform.createdCameraIds, [1]); + + platform.failDispose(1); + await tester.pump(); + await tester.pump(); + + expect(platform.createdCameraIds, [1, 2]); + final record = tester.widget( + find.byKey(const ValueKey('animated-avatar-record')), + ); + expect(record.onTap, isNotNull); + + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + }); + + testWidgets('waits for an in-flight camera before lifecycle replacement', ( + tester, + ) async { + final platform = _TestCameraPlatform( + blockDisposeForCameraIds: {1}, + blockInitializeForCameraIds: {1}, + ); + final previousPlatform = CameraPlatform.instance; + CameraPlatform.instance = platform; + addTearDown(() => CameraPlatform.instance = previousPlatform); + final lifecycle = _TestLifecycleNotifier(); + + await tester.pumpWidget( + ProviderScope( + overrides: [appLifecycleProvider.overrideWith(() => lifecycle)], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: SizedBox( + height: 600, + child: AnimatedAvatarCapture( + height: 600, + onPrepareChanged: (_) {}, + ), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + expect(platform.createdCameraIds, [1]); + + lifecycle.setLifecycle(AppLifecycleState.paused); + await tester.pump(); + lifecycle.setLifecycle(AppLifecycleState.resumed); + await tester.pump(); + await tester.pump(); + expect(platform.createdCameraIds, [1]); + + platform.completeInitialize(1); + await tester.pump(); + await tester.pump(); + expect(platform.disposedCameraIds, [1]); + expect(platform.createdCameraIds, [1]); + + platform.completeDispose(1); + await tester.pump(); + await tester.pump(); + expect(platform.createdCameraIds, [1, 2]); + final record = tester.widget( + find.byKey(const ValueKey('animated-avatar-record')), + ); + expect(record.onTap, isNotNull); + + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + }); + + testWidgets('shared disposal serializes camera-mode switches', ( + tester, + ) async { + for (final startsAnimated in [false, true]) { + final platform = _TestCameraPlatform(blockDisposeForCameraIds: {1}); + final previousPlatform = CameraPlatform.instance; + CameraPlatform.instance = platform; + final barrier = CameraDisposalBarrier(); + var animated = startsAnimated; + late StateSetter setMode; + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + setMode = setState; + return SizedBox( + height: 600, + child: animated + ? AnimatedAvatarCapture( + height: 600, + onPrepareChanged: (_) {}, + disposalBarrier: barrier, + ) + : ImageAvatarCapture( + height: 600, + onAccepted: (_) {}, + onClosed: () {}, + loadCameras: platform.availableCameras, + disposalBarrier: barrier, + ), + ); + }, + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + expect(platform.createdCameraIds, [1]); + + setMode(() => animated = !animated); + await tester.pump(); + await tester.pump(); + expect(platform.disposedCameraIds, [1]); + expect(platform.createdCameraIds, [1]); + + platform.completeDispose(1); + await tester.pump(); + await tester.pump(); + expect(platform.createdCameraIds, [1, 2]); + + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + CameraPlatform.instance = previousPlatform; + } }); testWidgets('completed review frames survive lifecycle changes', ( @@ -218,6 +425,187 @@ void main() { expect(tester.getSemantics(position).value, '10 horizontal, 0 vertical'); semantics.dispose(); }); + + testWidgets('an existing Save callback uses the latest cutout position', ( + tester, + ) async { + final source = image.Image(width: 256, height: 256, numChannels: 4); + image.fillRect( + source, + x1: 108, + y1: 108, + x2: 147, + y2: 147, + color: image.ColorRgba8(0, 255, 0, 255), + ); + final frame = image.encodePng(source); + Future Function()? prepare; + await tester.pumpWidget( + ProviderScope( + overrides: [ + appLifecycleProvider.overrideWith(_TestLifecycleNotifier.new), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: AnimatedAvatarCapture( + height: 600, + initialFrames: [frame], + onPrepareChanged: (value) => prepare = value, + ), + ), + ), + ), + ); + await tester.pump(); + final originalPrepare = prepare!; + final centered = await tester.runAsync(originalPrepare); + + final positionWidget = tester.widget( + find.byWidgetPredicate( + (widget) => + widget is Semantics && widget.properties.label == 'Avatar position', + ), + ); + positionWidget.properties.customSemanticsActions!.entries + .firstWhere((entry) => entry.key.label == 'Move right') + .value(); + await tester.pump(); + final moved = await tester.runAsync(originalPrepare); + + expect( + _greenCenterX((moved! as ProfileAnimatedAvatarDraft).poster), + greaterThan( + _greenCenterX((centered! as ProfileAnimatedAvatarDraft).poster) + 3, + ), + ); + }); +} + +double _greenCenterX(Uint8List bytes) { + final decoded = image.decodePng(bytes)!; + final matchingX = []; + for (final pixel in decoded) { + if (pixel.g > 200 && pixel.r < 30 && pixel.b < 30) { + matchingX.add(pixel.x); + } + } + return matchingX.reduce((left, right) => left + right) / matchingX.length; +} + +class _TestCameraPlatform extends CameraPlatform { + _TestCameraPlatform({ + Set? failLockForCameraIds, + Set? blockDisposeForCameraIds, + Set? blockInitializeForCameraIds, + }) : _failLockForCameraIds = failLockForCameraIds ?? const {}, + _blockDisposeForCameraIds = blockDisposeForCameraIds ?? const {}, + _blockInitializeForCameraIds = blockInitializeForCameraIds ?? const {}; + + final Set _failLockForCameraIds; + final Set _blockDisposeForCameraIds; + final Set _blockInitializeForCameraIds; + final _disposeCompleters = >{}; + final _initializeCompleters = >{}; + final _initializedControllers = + >{}; + final _errorControllers = >{}; + final _orientationController = + StreamController.broadcast(); + final createdCameraIds = []; + final disposedCameraIds = []; + var _nextCameraId = 1; + + @override + Future> availableCameras() async => const [ + CameraDescription( + name: 'front', + lensDirection: CameraLensDirection.front, + sensorOrientation: 0, + ), + ]; + + @override + Future createCameraWithSettings( + CameraDescription description, + MediaSettings mediaSettings, + ) async { + final cameraId = _nextCameraId++; + createdCameraIds.add(cameraId); + _initializedControllers[cameraId] = + StreamController.broadcast(); + _errorControllers[cameraId] = + StreamController.broadcast(); + return cameraId; + } + + @override + Stream onCameraInitialized(int cameraId) => + _initializedControllers[cameraId]!.stream.asBroadcastStream(); + + @override + Stream onCameraError(int cameraId) => + _errorControllers[cameraId]!.stream.asBroadcastStream(); + + @override + Stream onDeviceOrientationChanged() => + _orientationController.stream; + + @override + Future initializeCamera( + int cameraId, { + ImageFormatGroup imageFormatGroup = ImageFormatGroup.unknown, + }) async { + if (_blockInitializeForCameraIds.contains(cameraId)) { + await (_initializeCompleters[cameraId] ??= Completer()).future; + } + _initializedControllers[cameraId]!.add( + CameraInitializedEvent( + cameraId, + 640, + 480, + ExposureMode.auto, + false, + FocusMode.auto, + false, + ), + ); + } + + @override + Future lockCaptureOrientation( + int cameraId, + DeviceOrientation orientation, + ) async { + if (_failLockForCameraIds.contains(cameraId)) { + throw PlatformException(code: 'orientation-failed'); + } + } + + @override + Widget buildPreview(int cameraId) => const SizedBox.expand(); + + @override + Future dispose(int cameraId) async { + disposedCameraIds.add(cameraId); + if (_blockDisposeForCameraIds.contains(cameraId)) { + await (_disposeCompleters[cameraId] ??= Completer()).future; + } + } + + void completeInitialize(int cameraId) { + _initializeCompleters[cameraId]!.complete(); + } + + void failDispose(int cameraId) { + _disposeCompleters[cameraId]!.completeError( + PlatformException(code: 'dispose-failed'), + ); + } + + void completeDispose(int cameraId) { + _disposeCompleters[cameraId]!.complete(); + } } class _TestLifecycleNotifier extends AppLifecycleNotifier { diff --git a/mobile/test/features/profile/camera_disposal_barrier_test.dart b/mobile/test/features/profile/camera_disposal_barrier_test.dart new file mode 100644 index 00000000000..fc54132ffd1 --- /dev/null +++ b/mobile/test/features/profile/camera_disposal_barrier_test.dart @@ -0,0 +1,58 @@ +import 'dart:async'; + +import 'package:buzz/features/profile/camera_disposal_barrier.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('failed disposal settles before a replacement camera release', () async { + final barrier = CameraDisposalBarrier(); + final firstDispose = Completer(); + final secondDispose = Completer(); + var secondStarted = false; + + final first = barrier.release(() => firstDispose.future); + final second = barrier.release(() { + secondStarted = true; + return secondDispose.future; + }); + + expect(secondStarted, isFalse); + firstDispose.completeError(StateError('native camera teardown failed')); + await first; + await Future.delayed(Duration.zero); + expect(secondStarted, isTrue); + + secondDispose.complete(); + await second; + await barrier.settled; + }); + + test( + 'serializes replacement releases without overlapping ownership', + () async { + final barrier = CameraDisposalBarrier(); + final firstDispose = Completer(); + var activeDisposals = 0; + var overlapped = false; + + final first = barrier.release(() async { + activeDisposals++; + if (activeDisposals > 1) overlapped = true; + await firstDispose.future; + activeDisposals--; + }); + final second = barrier.release(() async { + activeDisposals++; + if (activeDisposals > 1) overlapped = true; + activeDisposals--; + }); + + await Future.delayed(Duration.zero); + expect(activeDisposals, 1); + firstDispose.complete(); + await Future.wait([first, second]); + + expect(overlapped, isFalse); + }, + ); +} diff --git a/mobile/test/features/profile/image_avatar_capture_lifecycle_test.dart b/mobile/test/features/profile/image_avatar_capture_lifecycle_test.dart new file mode 100644 index 00000000000..790a00cccf9 --- /dev/null +++ b/mobile/test/features/profile/image_avatar_capture_lifecycle_test.dart @@ -0,0 +1,248 @@ +import 'dart:async'; + +import 'package:buzz/features/profile/image_avatar_capture.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:camera_platform_interface/camera_platform_interface.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +void main() { + testWidgets('resumes with a replacement after native disposal fails', ( + tester, + ) async { + final platform = _TestCameraPlatform(blockDisposeForCameraIds: {1}); + final previousPlatform = CameraPlatform.instance; + CameraPlatform.instance = platform; + addTearDown(() => CameraPlatform.instance = previousPlatform); + final lifecycle = _TestLifecycleNotifier(); + + await tester.pumpWidget( + ProviderScope( + overrides: [appLifecycleProvider.overrideWith(() => lifecycle)], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: SizedBox( + height: 400, + child: ImageAvatarCapture( + height: 400, + onAccepted: (_) {}, + onClosed: () {}, + loadCameras: platform.availableCameras, + ), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + expect(platform.createdCameraIds, [1]); + + lifecycle.setLifecycle(AppLifecycleState.paused); + await tester.pump(); + expect(platform.disposedCameraIds, [1]); + + lifecycle.setLifecycle(AppLifecycleState.resumed); + await tester.pump(); + await tester.pump(); + expect(platform.createdCameraIds, [1]); + + platform.failDispose(1); + await tester.pump(); + await tester.pump(); + + expect(platform.createdCameraIds, [1, 2]); + final shutter = tester.widget( + find.byKey(const ValueKey('image-camera-shutter')), + ); + expect(shutter.onTap, isNotNull); + + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + }); + + testWidgets('waits for an in-flight camera before lifecycle replacement', ( + tester, + ) async { + final platform = _TestCameraPlatform( + blockDisposeForCameraIds: {1}, + blockInitializeForCameraIds: {1}, + ); + final previousPlatform = CameraPlatform.instance; + CameraPlatform.instance = platform; + addTearDown(() => CameraPlatform.instance = previousPlatform); + final lifecycle = _TestLifecycleNotifier(); + + await tester.pumpWidget( + ProviderScope( + overrides: [appLifecycleProvider.overrideWith(() => lifecycle)], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: SizedBox( + height: 400, + child: ImageAvatarCapture( + height: 400, + onAccepted: (_) {}, + onClosed: () {}, + loadCameras: platform.availableCameras, + ), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + expect(platform.createdCameraIds, [1]); + + lifecycle.setLifecycle(AppLifecycleState.paused); + await tester.pump(); + lifecycle.setLifecycle(AppLifecycleState.resumed); + await tester.pump(); + await tester.pump(); + expect(platform.createdCameraIds, [1]); + + platform.completeInitialize(1); + await tester.pump(); + await tester.pump(); + expect(platform.disposedCameraIds, [1]); + expect(platform.createdCameraIds, [1]); + + platform.completeDispose(1); + await tester.pump(); + await tester.pump(); + expect(platform.createdCameraIds, [1, 2]); + final shutter = tester.widget( + find.byKey(const ValueKey('image-camera-shutter')), + ); + expect(shutter.onTap, isNotNull); + + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + }); +} + +class _TestLifecycleNotifier extends AppLifecycleNotifier { + AppLifecycleState _lifecycle = AppLifecycleState.resumed; + + @override + AppLifecycleState build() => _lifecycle; + + void setLifecycle(AppLifecycleState value) { + _lifecycle = value; + state = value; + } +} + +class _TestCameraPlatform extends CameraPlatform { + _TestCameraPlatform({ + Set? blockDisposeForCameraIds, + Set? blockInitializeForCameraIds, + }) : _blockDisposeForCameraIds = blockDisposeForCameraIds ?? const {}, + _blockInitializeForCameraIds = blockInitializeForCameraIds ?? const {}; + + final Set _blockDisposeForCameraIds; + final Set _blockInitializeForCameraIds; + final _disposeCompleters = >{}; + final _initializeCompleters = >{}; + final _initializedControllers = + >{}; + final _errorControllers = >{}; + final _orientationController = + StreamController.broadcast(); + final createdCameraIds = []; + final disposedCameraIds = []; + var _nextCameraId = 1; + + @override + Future> availableCameras() async => const [ + CameraDescription( + name: 'front', + lensDirection: CameraLensDirection.front, + sensorOrientation: 0, + ), + ]; + + @override + Future createCameraWithSettings( + CameraDescription description, + MediaSettings mediaSettings, + ) async { + final cameraId = _nextCameraId++; + createdCameraIds.add(cameraId); + _initializedControllers[cameraId] = + StreamController.broadcast(); + _errorControllers[cameraId] = + StreamController.broadcast(); + return cameraId; + } + + @override + Stream onCameraInitialized(int cameraId) => + _initializedControllers[cameraId]!.stream.asBroadcastStream(); + + @override + Stream onCameraError(int cameraId) => + _errorControllers[cameraId]!.stream.asBroadcastStream(); + + @override + Stream onDeviceOrientationChanged() => + _orientationController.stream; + + @override + Future initializeCamera( + int cameraId, { + ImageFormatGroup imageFormatGroup = ImageFormatGroup.unknown, + }) async { + if (_blockInitializeForCameraIds.contains(cameraId)) { + await (_initializeCompleters[cameraId] ??= Completer()).future; + } + _initializedControllers[cameraId]!.add( + CameraInitializedEvent( + cameraId, + 640, + 480, + ExposureMode.auto, + false, + FocusMode.auto, + false, + ), + ); + } + + @override + Future lockCaptureOrientation( + int cameraId, + DeviceOrientation orientation, + ) async {} + + @override + Widget buildPreview(int cameraId) => const SizedBox.expand(); + + @override + Future dispose(int cameraId) async { + disposedCameraIds.add(cameraId); + if (_blockDisposeForCameraIds.contains(cameraId)) { + await (_disposeCompleters[cameraId] ??= Completer()).future; + } + } + + void completeInitialize(int cameraId) { + _initializeCompleters[cameraId]!.complete(); + } + + void failDispose(int cameraId) { + _disposeCompleters[cameraId]!.completeError( + PlatformException(code: 'dispose-failed'), + ); + } + + void completeDispose(int cameraId) { + _disposeCompleters[cameraId]!.complete(); + } +} diff --git a/mobile/test/features/profile/profile_avatar_draft_test.dart b/mobile/test/features/profile/profile_avatar_draft_test.dart index 1c92ac9fdc3..6cf3f0b578d 100644 --- a/mobile/test/features/profile/profile_avatar_draft_test.dart +++ b/mobile/test/features/profile/profile_avatar_draft_test.dart @@ -28,25 +28,6 @@ void main() { expect(service.uploadedParts, ['poster', 'animation', 'animation']); }); - test('animated draft supports one upload at a time', () async { - final service = _SingleUploadService(); - addTearDown(service.dispose); - final draft = ProfileAnimatedAvatarDraft( - poster: Uint8List.fromList([1]), - animation: Uint8List.fromList([2]), - ); - - final url = await draft.upload(service); - - expect(service.maxInFlight, 1); - expect(service.uploadedParts, ['poster', 'animation']); - expect(parseAnimatedAvatarUrl(url)?.posterUrl, 'https://relay/poster.png'); - expect( - parseAnimatedAvatarUrl(url)?.animationUrl, - 'https://relay/animation.png', - ); - }); - test('animated draft reuploads every part for a new community', () async { final first = _RecordingUploadService('first'); final second = _RecordingUploadService('second'); @@ -67,46 +48,6 @@ void main() { }); } -final class _SingleUploadService extends MediaUploadService { - _SingleUploadService() - : super( - baseUrl: 'https://relay.example', - nsec: null, - pickGalleryImage: () async => null, - pickGalleryVideo: () async => null, - ); - - final uploadedParts = []; - int _inFlight = 0; - int maxInFlight = 0; - - @override - Future uploadBytes( - Uint8List bytes, { - required String mimeType, - ValueChanged? onProgress, - UploadCancellationToken? cancellationToken, - }) async { - _inFlight++; - maxInFlight = _inFlight > maxInFlight ? _inFlight : maxInFlight; - if (_inFlight > 1) { - _inFlight--; - throw Exception('upload concurrency limit reached'); - } - final part = bytes.single == 1 ? 'poster' : 'animation'; - uploadedParts.add(part); - await Future.delayed(Duration.zero); - _inFlight--; - return BlobDescriptor( - url: 'https://relay/$part.png', - sha256: '$part-hash', - size: bytes.length, - type: mimeType, - uploaded: 1, - ); - } -} - final class _RecordingUploadService extends MediaUploadService { _RecordingUploadService(this.community) : super( diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index d100e0f5ae4..4e9d724cb79 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -2,10 +2,13 @@ import 'dart:async'; import 'dart:math' as math; import 'package:buzz/features/profile/profile_edit_page.dart'; +import 'package:buzz/features/profile/profile_avatar_draft.dart'; import 'package:buzz/features/profile/profile_avatar_crop_page.dart'; import 'package:buzz/features/profile/avatar_background_grid.dart'; import 'package:buzz/features/profile/avatar_editor_option_button.dart'; +import 'package:buzz/features/profile/animated_avatar_capture.dart'; import 'package:buzz/features/profile/emoji_avatar_tile.dart'; +import 'package:buzz/features/profile/image_avatar_capture.dart'; import 'package:buzz/shared/widgets/immediate_page_route.dart'; import 'package:buzz/features/profile/profile_provider.dart'; import 'package:buzz/shared/emoji/emoji_avatar.dart'; @@ -18,6 +21,7 @@ import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/avatar_image.dart'; import 'package:buzz/shared/widgets/frosted_app_bar.dart'; import 'package:buzz/shared/widgets/ios_native_segmented_control.dart'; +import 'package:buzz/shared/widgets/ios_glass_navigation_button.dart'; import 'package:buzz/shared/widgets/playing_avatar_image.dart'; import 'package:buzz/shared/widgets/progressive_animated_avatar.dart'; import 'package:flutter/foundation.dart'; @@ -540,13 +544,19 @@ void main() { await tester.pump(const Duration(milliseconds: 150)); expect( tester.getCenter(find.byKey(const ValueKey('emoji-avatar-preview'))).dy, - closeTo(screenSize.height / 2 - avatarBackgroundPreviewShift, 0.01), + closeTo(screenSize.height / 2 - 140, 0.01), ); expect( tester .getSize(find.byKey(const ValueKey('emoji-avatar-picker-content'))) .height, - lessThan(expandedPickerHeight), + expandedPickerHeight, + ); + expect( + tester.getCenter(find.byKey(const ValueKey('emoji-background-editor'))), + tester.getCenter( + find.byKey(const ValueKey('emoji-background-editor-alignment')), + ), ); await tester.tap(find.text('Animated')); @@ -632,7 +642,14 @@ void main() { profileProvider.overrideWith(() => notifier), mediaUploadServiceProvider.overrideWithValue(uploadService), ], - child: const ProfileEditPage(), + child: ProfileEditPage( + imageAvatarCaptureBuilder: + ({required height, required onAccepted, required onClosed}) => + _FakeImageAvatarCapture( + onAccepted: onAccepted, + onClosed: onClosed, + ), + ), ), ); await tester.pumpAndSettle(); @@ -640,18 +657,67 @@ void main() { await tester.tap(find.text('Edit Photo')); await tester.pumpAndSettle(); await tester.tap(find.text('Camera')); - await _waitForAvatarCropToLoad(tester); - expect(find.text('Position Photo'), findsOneWidget); - await tester.tap(find.byKey(const ValueKey('avatar-crop-use-photo'))); - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 200)), - ); + await tester.pump(); + expect(find.byKey(const ValueKey('fake-image-camera')), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('fake-image-camera-accept'))); await tester.pumpAndSettle(); expect(notifier.savedAvatarUrls, isEmpty); await tester.tap(find.byKey(const ValueKey('avatar-save'))); await tester.pumpAndSettle(); - expect(notifier.savedAvatarUrls, ['https://relay.example/camera.png']); + expect(notifier.savedAvatarUrls, ['https://relay.example/profile.png']); + }); + + testWidgets('keeps the local animation visible after profile Save', ( + tester, + ) async { + final notifier = _FakeProfileNotifier(updatesProfileState: true); + final uploadService = _FakeMediaUploadService(); + addTearDown(uploadService.dispose); + final frame = Uint8List.fromList( + image.encodePng( + image.Image(width: 8, height: 8)..setPixelRgba(4, 4, 255, 0, 0, 255), + ), + ); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(() => notifier), + mediaUploadServiceProvider.overrideWithValue(uploadService), + ], + child: ProfileEditPage( + animatedAvatarCaptureBuilder: + ({required height, required onPrepareChanged}) { + WidgetsBinding.instance.addPostFrameCallback((_) { + onPrepareChanged( + () async => ProfileAnimatedAvatarDraft( + animation: frame, + poster: frame, + ), + ); + }); + return const SizedBox( + key: ValueKey('fake-animated-avatar-review'), + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Animated')); + await tester.pump(); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('progressive-animated-avatar-local-handoff')), + findsOneWidget, + ); + expect(notifier.savedAvatarUrls.single, contains('#buzz-anim=')); }); testWidgets('saves a desktop-compatible emoji avatar', (tester) async { @@ -847,10 +913,12 @@ class _FakeProfileNotifier extends ProfileNotifier { about: 'Building Buzz', ), this.failedAvatarSaves = 0, + this.updatesProfileState = false, }); final UserProfile profile; int failedAvatarSaves; + final bool updatesProfileState; final savedDisplayNames = []; final savedDescriptions = []; final savedAvatarUrls = []; @@ -895,27 +963,35 @@ class _FakeProfileNotifier extends ProfileNotifier { throw Exception('profile publish failed'); } savedAvatarUrls.add(avatarUrl); - } -} - -class _FailingPreparationMediaUploadService extends _FakeMediaUploadService { - @override - Future prepareImageBytes(XFile image) async { - throw Exception('image preparation failed'); + if (updatesProfileState) { + final current = state.requireValue!; + state = AsyncData( + UserProfile( + pubkey: current.pubkey, + displayName: current.displayName, + avatarUrl: avatarUrl, + about: current.about, + nip05Handle: current.nip05Handle, + ), + ); + } } } class _FakeMediaUploadService extends MediaUploadService { - _FakeMediaUploadService({this.delayGallery = false}) - : super( - baseUrl: 'https://relay.example', - nsec: null, - pickGalleryImage: () async => null, - pickGalleryVideo: () async => null, - ); + _FakeMediaUploadService({ + this.delayGallery = false, + this.failImagePreparation = false, + }) : super( + baseUrl: 'https://relay.example', + nsec: null, + pickGalleryImage: () async => null, + pickGalleryVideo: () async => null, + ); var _camera = false; final bool delayGallery; + final bool failImagePreparation; final _gallerySelection = Completer(); int uploadCount = 0; @@ -941,7 +1017,10 @@ class _FakeMediaUploadService extends MediaUploadService { } @override - Future prepareImageBytes(XFile image) => image.readAsBytes(); + Future prepareImageBytes(XFile image) async { + if (failImagePreparation) throw Exception('image preparation failed'); + return image.readAsBytes(); + } @override Future uploadBytes( @@ -962,3 +1041,32 @@ class _FakeMediaUploadService extends MediaUploadService { ); } } + +class _FakeImageAvatarCapture extends StatelessWidget { + const _FakeImageAvatarCapture({ + required this.onAccepted, + required this.onClosed, + }); + + final ValueChanged onAccepted; + final VoidCallback onClosed; + + @override + Widget build(BuildContext context) => Row( + key: const ValueKey('fake-image-camera'), + children: [ + TextButton( + key: const ValueKey('fake-image-camera-close'), + onPressed: onClosed, + child: const Text('Close fake camera'), + ), + TextButton( + key: const ValueKey('fake-image-camera-accept'), + onPressed: () => onAccepted( + Uint8List.fromList(image.encodeJpg(image.Image(width: 8, height: 8))), + ), + child: const Text('Accept fake photo'), + ), + ], + ); +} diff --git a/mobile/test/features/profile/profile_edit_page_test/image_selection_tests.dart b/mobile/test/features/profile/profile_edit_page_test/image_selection_tests.dart index 62c5455dc1a..92e7120bbf8 100644 --- a/mobile/test/features/profile/profile_edit_page_test/image_selection_tests.dart +++ b/mobile/test/features/profile/profile_edit_page_test/image_selection_tests.dart @@ -1,6 +1,474 @@ part of '../profile_edit_page_test.dart'; +double _paintedWidth(WidgetTester tester, String key) { + final box = tester.renderObject(find.byKey(ValueKey(key))); + final left = box.localToGlobal(Offset.zero); + final right = box.localToGlobal(Offset(box.size.width, 0)); + return (right - left).distance; +} + void runProfileEditImageSelectionTests() { + testWidgets('uses glass image source controls on iOS', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: const ProfileEditPage(startInPhotoEditor: true), + ), + ); + await tester.pumpAndSettle(); + + final nativeControls = tester + .widgetList(find.byType(UiKitView)) + .where((view) => view.viewType == IosGlassNavigationButton.viewType) + .map((view) => view.creationParams as Map) + .toList(); + expect(nativeControls.any((params) => params['icon'] == 'camera'), isTrue); + expect( + nativeControls.any((params) => params['icon'] == 'photoLibrary'), + isTrue, + ); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('opens the inline camera around the existing avatar center', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: const ProfileEditPage(startInPhotoEditor: true), + ), + ); + await tester.pumpAndSettle(); + + final avatarCenter = tester.getCenter( + find.byKey(const ValueKey('avatar-editor-fixed-preview')), + ); + await tester.tap(find.byKey(const ValueKey('image-source-camera'))); + await tester.pump(); + + expect( + tester.getCenter(find.byKey(const ValueKey('image-camera-preview-size'))), + avatarCenter, + ); + }); + + testWidgets('expands the avatar with the controls while camera loads', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + child: Scaffold( + body: SizedBox( + height: 400, + child: ImageAvatarCapture( + height: 400, + onAccepted: (_) {}, + initialPreview: const SizedBox.square( + dimension: 220, + child: ColoredBox( + key: ValueKey('existing-avatar-preview'), + color: Colors.pink, + ), + ), + onClosed: () {}, + loadCameras: () async => const [], + ), + ), + ), + ), + ); + + final preview = find.byKey(const ValueKey('image-camera-preview-size')); + expect(tester.getSize(preview), const Size.square(220)); + expect(tester.getCenter(preview).dy, imageAvatarCameraPreviewSize / 2); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 90)); + final midSize = tester.getSize(preview).width; + expect(midSize, greaterThan(220)); + expect(midSize, lessThan(275)); + expect( + _paintedWidth(tester, 'existing-avatar-preview'), + closeTo(midSize, 1), + ); + expect(tester.getCenter(preview).dy, imageAvatarCameraPreviewSize / 2); + await tester.pump(const Duration(milliseconds: 90)); + expect(tester.getSize(preview), const Size.square(275)); + expect(_paintedWidth(tester, 'existing-avatar-preview'), closeTo(275, 1)); + expect( + find.byKey(const ValueKey('existing-avatar-preview')), + findsOneWidget, + ); + expect( + tester.getSize(find.byKey(const ValueKey('image-camera-left-action'))), + const Size.square(64), + ); + expect( + tester.getSize(find.byKey(const ValueKey('image-camera-right-action'))), + const Size.square(64), + ); + expect( + tester.getSize(find.byKey(const ValueKey('image-camera-shutter-morph'))), + const Size.square(100), + ); + expect(find.bySemanticsLabel('Close camera'), findsOneWidget); + expect(find.bySemanticsLabel('Flip camera'), findsOneWidget); + expect(find.bySemanticsLabel('Take photo'), findsOneWidget); + }); + + testWidgets('reverses the camera controls before closing', (tester) async { + var closed = false; + await tester.pumpWidget( + WidgetHelpers.testable( + child: Scaffold( + body: SizedBox( + height: 400, + child: ImageAvatarCapture( + height: 400, + onAccepted: (_) {}, + onClosed: () => closed = true, + loadCameras: () async => const [], + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 180)); + final leftAction = find.byKey(const ValueKey('image-camera-left-action')); + final rightAction = find.byKey(const ValueKey('image-camera-right-action')); + final expandedDistance = + tester.getCenter(rightAction).dx - tester.getCenter(leftAction).dx; + + tester + .widget( + find.descendant(of: leftAction, matching: find.byType(InkWell)), + ) + .onTap!(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 90)); + final midDistance = + tester.getCenter(rightAction).dx - tester.getCenter(leftAction).dx; + expect(midDistance, lessThan(expandedDistance)); + expect( + find.byKey(const ValueKey('camera-action-icon-camera')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('camera-action-icon-photoLibrary')), + findsOneWidget, + ); + expect(find.text('Camera'), findsOneWidget); + expect(find.text('Photo Library'), findsOneWidget); + final cameraIcon = tester.widget( + find.byKey(const ValueKey('camera-action-icon-camera')), + ); + expect(cameraIcon.color?.a, 1); + expect(closed, isFalse); + + await tester.pump(const Duration(milliseconds: 60)); + expect( + tester + .widget( + find.byKey(const ValueKey('image-camera-shutter-exit-opacity')), + ) + .opacity, + 0, + ); + expect(closed, isFalse); + + await tester.pump(const Duration(milliseconds: 30)); + expect(closed, isTrue); + expect( + tester.getSize(find.byKey(const ValueKey('image-camera-preview-size'))), + const Size.square(220), + ); + }); + + test('front camera output keeps the mirrored preview orientation', () { + final source = image.Image(width: 2, height: 2); + for (var y = 0; y < 2; y++) { + source.setPixelRgb(0, y, 255, 0, 0); + source.setPixelRgb(1, y, 0, 0, 255); + } + final encoded = Uint8List.fromList(image.encodePng(source)); + + final regular = image.decodeJpg( + prepareCameraImageForTesting(encoded, mirror: false), + )!; + final mirrored = image.decodeJpg( + prepareCameraImageForTesting(encoded, mirror: true), + )!; + final regularLeft = regular.getPixel(40, 256); + final mirroredLeft = mirrored.getPixel(40, 256); + + expect(regularLeft.r, greaterThan(regularLeft.b)); + expect(mirroredLeft.b, greaterThan(mirroredLeft.r)); + }); + + testWidgets('provides haptics when closing or accepting a camera photo', ( + tester, + ) async { + final haptics = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'HapticFeedback.vibrate') { + haptics.add(call.arguments); + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + + await tester.pumpWidget( + WidgetHelpers.testable( + child: Scaffold( + body: ImageAvatarCapture( + key: const ValueKey('close-haptic-camera'), + height: 400, + onAccepted: (_) {}, + onClosed: () {}, + loadCameras: () async => const [], + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 180)); + final closeAction = find.byKey(const ValueKey('image-camera-left-action')); + tester + .widget( + find.descendant(of: closeAction, matching: find.byType(InkWell)), + ) + .onTap!(); + await tester.pump(); + expect(haptics, contains('HapticFeedbackType.selectionClick')); + await tester.pump(const Duration(milliseconds: 180)); + + final bytes = Uint8List.fromList( + image.encodeJpg(image.Image(width: 8, height: 8)), + ); + await tester.pumpWidget( + WidgetHelpers.testable( + child: Scaffold( + body: ImageAvatarCapture( + key: const ValueKey('accept-haptic-camera'), + height: 400, + initialCapturedBytes: bytes, + onAccepted: (_) {}, + onClosed: () {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Use Photo')); + await tester.pump(); + expect(haptics, contains('HapticFeedbackType.mediumImpact')); + await tester.pump(const Duration(milliseconds: 180)); + }); + + testWidgets('reviews a captured photo before scaling down to accept it', ( + tester, + ) async { + final bytes = Uint8List.fromList( + image.encodeJpg(image.Image(width: 8, height: 8)), + ); + Uint8List? accepted; + await tester.pumpWidget( + WidgetHelpers.testable( + child: Scaffold( + body: SizedBox( + height: 400, + child: ImageAvatarCapture( + height: 400, + initialCapturedBytes: bytes, + onAccepted: (value) => accepted = value, + onClosed: () {}, + ), + ), + ), + ), + ); + final preview = find.byKey(const ValueKey('image-camera-preview-size')); + final compactCenter = tester.getCenter(preview); + expect(tester.getSize(preview), const Size.square(220)); + await tester.pumpAndSettle(); + + expect(tester.getSize(preview), const Size.square(275)); + expect(tester.getCenter(preview), compactCenter); + expect(find.text('Retry'), findsOneWidget); + expect(find.text('Use Photo'), findsOneWidget); + expect( + tester.getSize(find.byKey(const ValueKey('image-camera-left-action'))), + const Size(112, 64), + ); + expect( + tester.getSize(find.byKey(const ValueKey('image-camera-right-action'))), + const Size(112, 64), + ); + final leftRect = tester.getRect( + find.byKey(const ValueKey('image-camera-left-action')), + ); + final rightRect = tester.getRect( + find.byKey(const ValueKey('image-camera-right-action')), + ); + expect(rightRect.left - leftRect.right, 12); + + await tester.tap(find.text('Use Photo')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 90)); + final midSize = tester + .getSize(find.byKey(const ValueKey('image-camera-preview-size'))) + .width; + expect(midSize, greaterThan(220)); + expect(midSize, lessThan(275)); + expect(accepted, isNull); + await tester.pump(const Duration(milliseconds: 90)); + expect(accepted, same(bytes)); + expect( + tester.getSize(find.byKey(const ValueKey('image-camera-preview-size'))), + const Size.square(220), + ); + }); + + testWidgets('retry moves the review actions apart again', (tester) async { + final bytes = Uint8List.fromList( + image.encodeJpg(image.Image(width: 8, height: 8)), + ); + await tester.pumpWidget( + WidgetHelpers.testable( + child: Scaffold( + body: SizedBox( + height: 400, + child: ImageAvatarCapture( + height: 400, + initialCapturedBytes: bytes, + onAccepted: (_) {}, + onClosed: () {}, + loadCameras: () async => const [], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final leftAction = find.byKey(const ValueKey('image-camera-left-action')); + final rightAction = find.byKey(const ValueKey('image-camera-right-action')); + final reviewGap = + tester.getRect(rightAction).left - tester.getRect(leftAction).right; + + await tester.tap(find.text('Retry')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 180)); + + final cameraGap = + tester.getRect(rightAction).left - tester.getRect(leftAction).right; + expect(reviewGap, 12); + expect(cameraGap, greaterThan(reviewGap)); + expect(find.bySemanticsLabel('Close camera'), findsOneWidget); + expect(find.bySemanticsLabel('Flip camera'), findsOneWidget); + }); + + testWidgets('clears gallery errors when opening the inline camera', ( + tester, + ) async { + final uploadService = _FakeMediaUploadService(failImagePreparation: true); + addTearDown(uploadService.dispose); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(_FakeProfileNotifier.new), + mediaUploadServiceProvider.overrideWithValue(uploadService), + ], + child: ProfileEditPage( + imageAvatarCaptureBuilder: + ({required height, required onAccepted, required onClosed}) => + _FakeImageAvatarCapture( + onAccepted: onAccepted, + onClosed: onClosed, + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('image-source-library'))); + await tester.pumpAndSettle(); + + expect( + find.text("We couldn't prepare that photo. Try again."), + findsOneWidget, + ); + await tester.tap(find.byKey(const ValueKey('image-source-camera'))); + await tester.pump(); + + expect(find.byKey(const ValueKey('fake-image-camera')), findsOneWidget); + expect( + find.text("We couldn't prepare that photo. Try again."), + findsNothing, + ); + await tester.tap(find.byKey(const ValueKey('fake-image-camera-accept'))); + await tester.pumpAndSettle(); + + expect( + find.text("We couldn't prepare that photo. Try again."), + findsNothing, + ); + }); + + testWidgets('accepts an inline camera photo before enabling profile Save', ( + tester, + ) async { + final notifier = _FakeProfileNotifier(); + final uploadService = _FakeMediaUploadService(); + addTearDown(uploadService.dispose); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(() => notifier), + mediaUploadServiceProvider.overrideWithValue(uploadService), + ], + child: ProfileEditPage( + imageAvatarCaptureBuilder: + ({required height, required onAccepted, required onClosed}) => + _FakeImageAvatarCapture( + onAccepted: onAccepted, + onClosed: onClosed, + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('image-source-camera'))); + await tester.pump(); + + expect(find.byKey(const ValueKey('fake-image-camera')), findsOneWidget); + expect(find.byKey(const ValueKey('image-source-library')), findsNothing); + await tester.tap(find.byKey(const ValueKey('fake-image-camera-accept'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('fake-image-camera')), findsNothing); + expect(find.byKey(const ValueKey('image-source-camera')), findsOneWidget); + expect(find.byKey(const ValueKey('image-source-library')), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pumpAndSettle(); + + expect(notifier.savedAvatarUrls, ['https://relay.example/profile.png']); + expect(uploadService.uploadCount, 1); + }); + testWidgets('duplicate avatar Back taps pop only the editor route', ( tester, ) async { diff --git a/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart b/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart index 2cffbb0891a..b5bed5eddc4 100644 --- a/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart +++ b/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart @@ -53,97 +53,6 @@ void runProfileEditMotionAndAccessibilityTests() { ); }); - testWidgets( - 'keeps the multiline text editor usable with large text and the keyboard', - (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.android; - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(320, 568); - tester.platformDispatcher.textScaleFactorTestValue = 2; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); - addTearDown(tester.view.reset); - final notifier = _FakeProfileNotifier(); - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [profileProvider.overrideWith(() => notifier)], - child: const ProfileEditPage(), - ), - ); - await tester.pumpAndSettle(); - final descriptionRow = find.byKey( - const ValueKey('profile-description-row'), - ); - await tester.ensureVisible(descriptionRow); - await tester.pumpAndSettle(); - await tester.tap(descriptionRow); - await tester.pumpAndSettle(); - - tester.view.viewInsets = const FakeViewPadding(bottom: 300); - await tester.pumpAndSettle(); - - expect( - MediaQuery.textScalerOf( - tester.element(find.byKey(const ValueKey('profile-field-input'))), - ).scale(10), - 20, - ); - expect( - MediaQuery.viewInsetsOf( - tester.element(find.byKey(const ValueKey('profile-field-input'))), - ).bottom, - 300, - ); - expect(tester.takeException(), isNull); - expect( - find.byKey(const ValueKey('profile-field-scroll-view')), - findsOneWidget, - ); - await tester.enterText( - find.byKey(const ValueKey('profile-field-input')), - 'Making collaboration feel effortless.', - ); - await tester.pump(); - final save = find.byKey(const ValueKey('profile-field-save')); - await tester.ensureVisible(save); - await tester.pumpAndSettle(); - expect(save.hitTestable(), findsOneWidget); - await tester.tap(save); - await tester.pumpAndSettle(); - expect(notifier.savedDescriptions, [ - 'Making collaboration feel effortless.', - ]); - debugDefaultTargetPlatformOverride = null; - }, - ); - - testWidgets('photo preparation errors are accessibility live regions', ( - tester, - ) async { - final uploadService = _FailingPreparationMediaUploadService(); - addTearDown(uploadService.dispose); - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [ - profileProvider.overrideWith(_FakeProfileNotifier.new), - mediaUploadServiceProvider.overrideWithValue(uploadService), - ], - child: const ProfileEditPage(startInPhotoEditor: true), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Photo Library')); - await tester.pumpAndSettle(); - - final message = find.text("We couldn't prepare that photo. Try again."); - expect(message, findsOneWidget); - final errorSemantics = tester.widget( - find.ancestor(of: message, matching: find.byType(Semantics)).first, - ); - expect(errorSemantics.properties.liveRegion, isTrue); - }); - testWidgets('exposes the selected avatar mode on Android', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.android; addTearDown(() => debugDefaultTargetPlatformOverride = null); @@ -472,6 +381,135 @@ void runProfileEditMotionAndAccessibilityTests() { expect(selectedTile.properties.selected, isTrue); }); + testWidgets('uses liquid-glass avatar rail icons on iOS', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + await tester.pumpWidget( + WidgetHelpers.testable( + child: Row( + children: [ + Expanded( + child: AvatarEditorOptionButton( + icon: Icons.palette, + iosIcon: IosGlassNavigationIcon.palette, + label: 'Background', + selected: true, + onTap: () {}, + ), + ), + Expanded( + child: AvatarEditorOptionButton( + icon: Icons.face, + iosIcon: IosGlassNavigationIcon.emoji, + label: 'Emoji', + selected: false, + onTap: () {}, + ), + ), + ], + ), + ), + ); + + final nativeIcons = tester + .widgetList(find.byType(UiKitView)) + .map((view) => view.creationParams as Map) + .map((params) => params['icon']) + .toList(); + expect(nativeIcons, containsAll(['palette', 'emoji'])); + final selectedControls = tester + .widgetList(find.byType(UiKitView)) + .map((view) => view.creationParams as Map) + .where((params) => params['selected'] == true) + .toList(); + expect(selectedControls.single['icon'], 'palette'); + expect( + selectedControls.single['foregroundColor'], + AppTheme.light().colorScheme.primary.toARGB32(), + ); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('doubles the avatar rail icon-to-label spacing', (tester) async { + await tester.pumpWidget( + WidgetHelpers.testable( + child: AvatarEditorOptionButton( + icon: Icons.palette, + label: 'Background', + selected: false, + onTap: () {}, + ), + ), + ); + + final surface = find.byType(AnimatedContainer); + expect( + tester.getRect(find.text('Background')).top - + tester.getRect(surface).bottom, + avatarEditorOptionLabelGap, + ); + }); + + testWidgets('uses liquid glass for animated capture and review on iOS', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + await tester.pumpWidget( + WidgetHelpers.testable( + child: SizedBox( + height: 500, + child: AnimatedAvatarCapture( + key: ValueKey('animated-capture-glass-test'), + height: 500, + onPrepareChanged: (_) {}, + ), + ), + ), + ); + await tester.pump(); + + var nativeControls = tester + .widgetList(find.byType(UiKitView)) + .map((view) => view.creationParams as Map) + .toList(); + expect( + nativeControls.any( + (params) => params['icon'] == 'shutter' && params['label'] == 'Record', + ), + isTrue, + ); + + final frame = Uint8List.fromList( + image.encodePng(image.Image(width: 8, height: 8)), + ); + await tester.pumpWidget( + WidgetHelpers.testable( + child: SizedBox( + height: 500, + child: AnimatedAvatarCapture( + key: ValueKey('animated-review-glass-test'), + height: 500, + initialFrames: [frame, frame], + onPrepareChanged: (_) {}, + ), + ), + ), + ); + await tester.pump(); + + nativeControls = tester + .widgetList(find.byType(UiKitView)) + .map((view) => view.creationParams as Map) + .toList(); + final reviewIcons = nativeControls + .map((params) => params['icon']) + .whereType() + .toList(); + expect(reviewIcons, containsAll(['person', 'palette', 'frame', 'camera'])); + debugDefaultTargetPlatformOverride = null; + }); + testWidgets('uses the shared animated background grid for emoji avatars', ( tester, ) async { diff --git a/mobile/test/features/profile/profile_edit_retry_test.dart b/mobile/test/features/profile/profile_edit_retry_test.dart index 6eea4c9915f..0c157c6930d 100644 --- a/mobile/test/features/profile/profile_edit_retry_test.dart +++ b/mobile/test/features/profile/profile_edit_retry_test.dart @@ -274,127 +274,6 @@ void main() { debugDefaultTargetPlatformOverride = null; }); - testWidgets('standalone Flutter text draft survives Back while saving', ( - tester, - ) async { - debugDefaultTargetPlatformOverride = TargetPlatform.android; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - final notifier = _DeferredFailureProfileNotifier(); - - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [profileProvider.overrideWith(() => notifier)], - child: Builder( - builder: (context) => TextButton( - onPressed: () => unawaited(showProfileDisplayNameEditor(context)), - child: const Text('Open editor'), - ), - ), - ), - ); - await tester.pumpAndSettle(); - await tester.tap(find.text('Open editor')); - await tester.pumpAndSettle(); - await tester.enterText( - find.byKey(const ValueKey('profile-field-input')), - 'Pending standalone draft', - ); - await tester.pump(); - await tester.tap(find.byKey(const ValueKey('profile-field-save'))); - await tester.pump(); - - await tester.binding.handlePopRoute(); - await tester.pump(); - expect(find.byKey(const ValueKey('profile-field-input')), findsOneWidget); - expect( - tester - .widget(find.byKey(const ValueKey('profile-field-close'))) - .onPressed, - isNull, - ); - - notifier.failSave(); - await tester.pumpAndSettle(); - expect( - find.text("We couldn't save this change. Try again."), - findsOneWidget, - ); - expect( - tester - .widget( - find - .ancestor( - of: find.text("We couldn't save this change. Try again."), - matching: find.byType(Semantics), - ) - .first, - ) - .properties - .liveRegion, - isTrue, - ); - expect(tester.takeException(), isNull); - debugDefaultTargetPlatformOverride = null; - }); - - testWidgets('in-page Flutter text draft survives Back while saving', ( - tester, - ) async { - debugDefaultTargetPlatformOverride = TargetPlatform.android; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - final notifier = _DeferredFailureProfileNotifier(); - - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [profileProvider.overrideWith(() => notifier)], - child: const ProfileEditPage(), - ), - ); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const ValueKey('profile-display-name-row'))); - await tester.pumpAndSettle(); - await tester.enterText( - find.byKey(const ValueKey('profile-field-input')), - 'Pending in-page draft', - ); - await tester.pump(); - await tester.tap(find.byKey(const ValueKey('profile-field-save'))); - await tester.pump(); - - await tester.binding.handlePopRoute(); - await tester.pump(); - expect(find.byKey(const ValueKey('profile-field-input')), findsOneWidget); - expect( - tester - .widget(find.byKey(const ValueKey('profile-field-close'))) - .onPressed, - isNull, - ); - - notifier.failSave(); - await tester.pumpAndSettle(); - expect( - find.text("We couldn't save this change. Try again."), - findsOneWidget, - ); - expect( - tester - .widget( - find - .ancestor( - of: find.text("We couldn't save this change. Try again."), - matching: find.byType(Semantics), - ) - .first, - ) - .properties - .liveRegion, - isTrue, - ); - expect(tester.takeException(), isNull); - debugDefaultTargetPlatformOverride = null; - }); - testWidgets('in-page text editor rejects its first save after a switch', ( tester, ) async { diff --git a/mobile/test/features/profile/settings_profile_header_test.dart b/mobile/test/features/profile/settings_profile_header_test.dart index a22c8c58f81..67682a86bc9 100644 --- a/mobile/test/features/profile/settings_profile_header_test.dart +++ b/mobile/test/features/profile/settings_profile_header_test.dart @@ -13,6 +13,7 @@ import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/masked_avatar_badge.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart' as http_testing; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -124,7 +125,6 @@ void main() { .map((image) => image.url), containsAll([posterUrl, animationUrl]), ); - animationResponse.complete(http.Response.bytes(_transparentPng, 200)); await tester.runAsync( () => Future.delayed(const Duration(milliseconds: 50)), @@ -168,6 +168,78 @@ void main() { ); }); + testWidgets('warms and clears a paused animated-avatar handoff', ( + tester, + ) async { + const posterUrl = 'https://relay.example/media/poster.png'; + const animationUrl = 'https://relay.example/media/animation.png'; + final profileUrl = + '$posterUrl#buzz-anim=${Uri.encodeComponent(animationUrl)}'; + final posterResponse = Completer(); + final animationResponse = Completer(); + final client = http_testing.MockClient( + (request) => switch (request.url.toString()) { + posterUrl => posterResponse.future, + animationUrl => animationResponse.future, + _ => Future.value(http.Response.bytes(_transparentPng, 200)), + }, + ); + addTearDown(client.close); + final container = ProviderContainer( + overrides: [ + profileProvider.overrideWith( + () => _FakeProfileNotifier(avatarUrl: profileUrl), + ), + presenceProvider.overrideWith(() => _FakePresenceNotifier('online')), + userStatusProvider.overrideWith(() => _FakeUserStatusNotifier(null)), + customEmojiListProvider.overrideWithValue(const []), + mediaGetAuthServiceProvider.overrideWithValue( + MediaGetAuthService(baseUrl: 'https://relay.example', nsec: null), + ), + mediaHttpClientProvider.overrideWithValue(client), + ], + ); + addTearDown(container.dispose); + container + .read(profileAvatarHandoffProvider.notifier) + .show( + ProfileAvatarHandoff( + avatarUrl: profileUrl, + animation: _transparentPng, + poster: _transparentPng, + ), + ); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: AppTheme.light(), + home: const Scaffold(body: SettingsProfileHeader()), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('settings-profile-avatar'))); + await tester.pump(); + + expect( + find.byKey( + ValueKey('settings-profile-paused-handoff-$profileUrl'), + skipOffstage: false, + ), + findsOneWidget, + ); + expect(container.read(profileAvatarHandoffProvider), isNotNull); + + posterResponse.complete(http.Response.bytes(_transparentPng, 200)); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 50)), + ); + await tester.pumpAndSettle(); + + expect(container.read(profileAvatarHandoffProvider), isNull); + }); testWidgets('uses a bounded icon for an unresolved status shortcode', ( tester, ) async {