From 807a6b7bf7f7424f98705c2f87722b69665553e4 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 08:47:59 +0100 Subject: [PATCH 01/67] fix(mobile): harden profile photo editing Signed-off-by: kenny lopez --- .../profile/animated_avatar_capture.dart | 375 +++++++-------- .../profile/profile_avatar_draft.dart | 77 +-- .../profile/profile_avatar_editor.dart | 228 ++------- .../features/profile/profile_edit_page.dart | 271 +++++------ .../features/profile/profile_provider.dart | 171 ++----- .../profile/profile_edit_page_test.dart | 370 ++++++++++----- .../profile/profile_provider_test.dart | 441 +----------------- 7 files changed, 658 insertions(+), 1275 deletions(-) diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart index 0878b78e6b5..771bebbbcc3 100644 --- a/mobile/lib/features/profile/animated_avatar_capture.dart +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -5,7 +5,6 @@ import 'dart:math'; import 'package:camera/camera.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/semantics.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -20,13 +19,10 @@ import '../../shared/theme/theme.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import 'avatar_background_grid.dart'; import 'avatar_editor_option_button.dart'; -import 'animated_avatar_orientation.dart'; import 'profile_avatar_draft.dart'; part 'animated_avatar_capture/review_controls.dart'; part 'animated_avatar_capture/capture_controls.dart'; -part 'animated_avatar_capture/frame_processing.dart'; -part 'animated_avatar_capture/error_text.dart'; const _captureDuration = Duration(seconds: 3); const _captureFrameInterval = Duration(milliseconds: 125); @@ -39,35 +35,25 @@ 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. const AnimatedAvatarCapture({ super.key, required this.height, required this.onPrepareChanged, - this.initialFrames = const [], }); - /// The vertical space available to the capture surface. final double height; - - /// Reports the current deferred draft-preparation callback to the parent. final ValueChanged Function()?> onPrepareChanged; - /// Seeds processed frames in lifecycle-focused widget tests. - @visibleForTesting - final List initialFrames; @override Widget build(BuildContext context, WidgetRef ref) { final controller = useState(null); final controllerRef = useRef(null); - final captureEpoch = useRef(0); - final cameraGeneration = useState(0); final isInitializing = useState(true); final isRecording = useState(false); final isPreparingFrames = useState(false); final isProcessing = useState(false); final progress = useState(0.0); - final frames = useState>(initialFrames); + final frames = useState>(const []); final posterIndex = useState(0); final previewFrameIndex = useState(0); final scale = useState(_mobileDefaultPersonScale); @@ -82,6 +68,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { final encodedCache = useRef<_EncodedAvatarCache?>(null); final reduceMotion = MediaQuery.disableAnimationsOf(context); final lifecycle = ref.watch(appLifecycleProvider); + final encodeKey = frames.value.isEmpty ? null : _EncodeKey( @@ -96,6 +83,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { shapeOffsetX: shapeOffset.value.dx, shapeOffsetY: shapeOffset.value.dy, ); + useEffect(() { encodedCache.value = null; final key = encodeKey; @@ -121,14 +109,14 @@ class AnimatedAvatarCapture extends HookConsumerWidget { useEffect(() { var disposed = false; - if (lifecycle != AppLifecycleState.resumed || frames.value.isNotEmpty) { + if (lifecycle != AppLifecycleState.resumed) { isInitializing.value = false; controller.value = null; + frames.value = const []; + onPrepareChanged(null); return null; } - isInitializing.value = true; - Future initialize() async { try { final cameras = await availableCameras(); @@ -162,12 +150,12 @@ class AnimatedAvatarCapture extends HookConsumerWidget { unawaited(initialize()); return () { disposed = true; - captureEpoch.value++; + onPrepareChanged(null); final active = controllerRef.value; controllerRef.value = null; unawaited(active?.dispose() ?? Future.value()); }; - }, [lifecycle, frames.value.isEmpty, cameraGeneration.value]); + }, [lifecycle]); Future prepare() async { final key = encodeKey; @@ -216,7 +204,6 @@ class AnimatedAvatarCapture extends HookConsumerWidget { Future record() async { final active = controller.value; if (active == null || isRecording.value) return; - final currentCapture = ++captureEpoch.value; frames.value = const []; posterIndex.value = 0; previewFrameIndex.value = 0; @@ -231,22 +218,9 @@ class AnimatedAvatarCapture extends HookConsumerWidget { final startedAt = DateTime.now(); var lastFrameAt = DateTime.fromMillisecondsSinceEpoch(0); var converting = false; - var releasedCamera = false; - - Future releaseCamera() async { - if (releasedCamera || captureEpoch.value != currentCapture) return; - releasedCamera = true; - if (identical(controllerRef.value, active)) { - controllerRef.value = null; - } - if (context.mounted && identical(controller.value, active)) { - controller.value = null; - } - await active.dispose(); - } final timer = Timer.periodic(const Duration(milliseconds: 40), (_) { - if (!context.mounted || captureEpoch.value != currentCapture) return; + if (!context.mounted) return; final elapsed = DateTime.now().difference(startedAt); progress.value = (elapsed.inMilliseconds / _captureDuration.inMilliseconds).clamp( @@ -258,8 +232,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { try { await active.startImageStream((cameraImage) async { final now = DateTime.now(); - if (captureEpoch.value != currentCapture || - converting || + if (converting || now.difference(lastFrameAt) < _captureFrameInterval || now.difference(startedAt) >= _captureDuration) { return; @@ -269,37 +242,30 @@ class AnimatedAvatarCapture extends HookConsumerWidget { try { final request = _FrameRequest.fromCameraImage( cameraImage, - rotationDegrees: animatedAvatarFrameRotationDegrees( - sensorOrientation: active.description.sensorOrientation, - deviceOrientation: active.value.deviceOrientation, - lensDirection: active.description.lensDirection, - ), + sensorOrientation: active.description.sensorOrientation, mirror: active.description.lensDirection == CameraLensDirection.front, ); - final frame = await compute(_convertCameraFrame, request); - if (captureEpoch.value == currentCapture) captured.add(frame); + captured.add(await compute(_convertCameraFrame, request)); } finally { converting = false; } }); await Future.delayed(_captureDuration); - if (captureEpoch.value != currentCapture || !context.mounted) return; await active.stopImageStream(); - while (converting && captureEpoch.value == currentCapture) { + while (converting) { 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; isPreparingFrames.value = true; - // Cut out only the frames each device captured, then resample the - // three-second window so Android and iOS use the same playback cadence. + // Camera image conversion throughput differs by platform. Cut out only + // the frames each device captured, then resample the same three-second + // window onto one fixed timeline so Android and iOS produce the same + // frame count and playback cadence without duplicating segmentation. final cutouts = await _removeBackgrounds(captured); - if (captureEpoch.value != currentCapture || !context.mounted) return; final processed = List.unmodifiable( _resampleCapturedFrames(cutouts, _captureFrameCount), ); @@ -310,19 +276,8 @@ class AnimatedAvatarCapture extends HookConsumerWidget { ]); if (context.mounted) frames.value = processed; } catch (_) { - if (captureEpoch.value != currentCapture || !context.mounted) return; - try { - if (!releasedCamera && active.value.isStreamingImages) { - await active.stopImageStream(); - } - } on CameraException { - // The camera can stop independently while the capture is unwinding. - } - await releaseCamera(); - if (context.mounted) { - cameraGeneration.value++; - error.value = 'Recording failed. Try again.'; - } + if (active.value.isStreamingImages) await active.stopImageStream(); + error.value = 'Recording failed. Try again.'; } finally { timer.cancel(); if (context.mounted) { @@ -359,84 +314,78 @@ class AnimatedAvatarCapture extends HookConsumerWidget { top: previewTop, height: 220, child: Center( - child: _RepositionablePreviewSemantics( - offset: offset.value, - onMove: (delta) { - unawaited(HapticFeedback.selectionClick()); - offset.value = Offset( - (offset.value.dx + delta.dx).clamp(-1.0, 1.0), - (offset.value.dy + delta.dy).clamp(-1.0, 1.0), + child: GestureDetector( + key: const ValueKey('animated-avatar-review-preview'), + behavior: HitTestBehavior.opaque, + onScaleStart: (_) => gestureStartScale.value = scale.value, + onScaleUpdate: (details) { + final next = Offset( + (offset.value.dx + details.focalPointDelta.dx / 96).clamp( + -1, + 1, + ), + (offset.value.dy + details.focalPointDelta.dy / 96).clamp( + -1, + 1, + ), ); + offset.value = next; + scale.value = (gestureStartScale.value * details.scale) + .clamp(0.7, 2.0) + .toDouble(); }, - child: GestureDetector( - key: const ValueKey('animated-avatar-review-preview'), - behavior: HitTestBehavior.opaque, - onScaleStart: (_) => gestureStartScale.value = scale.value, - onScaleUpdate: (details) { - final next = Offset( - (offset.value.dx + details.focalPointDelta.dx / 96) - .clamp(-1, 1), - (offset.value.dy + details.focalPointDelta.dy / 96) - .clamp(-1, 1), - ); - offset.value = next; - scale.value = (gestureStartScale.value * details.scale) - .clamp(0.7, 2.0) - .toDouble(); - }, - child: SizedBox.square( - dimension: 220, - child: Stack( - fit: StackFit.expand, - children: [ - ClipOval( - child: Stack( - fit: StackFit.expand, - children: [ - Center( - child: Transform.translate( - offset: - const Offset(0, 20.625) + - shapeOffset.value * 51.5625, - child: Transform.scale( - scale: shapeScale.value, - child: Container( - width: 172, - height: 172, - decoration: BoxDecoration( - color: Color(backdropColor.value), - shape: BoxShape.circle, - ), + child: SizedBox.square( + dimension: 220, + child: Stack( + fit: StackFit.expand, + children: [ + ClipOval( + child: Stack( + fit: StackFit.expand, + children: [ + Center( + child: Transform.translate( + offset: + const Offset(0, 20.625) + + shapeOffset.value * 51.5625, + child: Transform.scale( + scale: shapeScale.value, + child: Container( + width: 172, + height: 172, + decoration: BoxDecoration( + color: Color(backdropColor.value), + shape: BoxShape.circle, ), ), ), ), - _AnimatedPersonPreview( - bytes: selectedFrame, - offset: offset.value * 48, - scale: scale.value, - outline: personOutline.value, - outlineColor: _personOutlineColor( - backdropColor.value, - ), + ), + _AnimatedPersonPreview( + bytes: selectedFrame, + offset: offset.value * 48, + scale: scale.value, + outline: personOutline.value, + outlineColor: _personOutlineColor( + backdropColor.value, ), - ], - ), + ), + ], ), - IgnorePointer( - child: DecoratedBox( - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: context.colors.onSurface.withValues( - alpha: 0.1, - ), + ), + IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: context.colors.onSurface.withValues( + alpha: 0.1, ), ), ), ), - ], - ), + ), + ], ), ), ), @@ -634,6 +583,64 @@ List _resampleCapturedFrames( }, growable: false); } +Future> _removeBackgrounds(List frames) async { + final segmenter = SelfieSegmenter( + mode: SegmenterMode.stream, + enableRawSizeMask: false, + ); + final directory = await getTemporaryDirectory(); + final results = []; + try { + for (var index = 0; index < frames.length; index++) { + final file = File('${directory.path}/buzz-avatar-frame-$index.png'); + try { + await file.writeAsBytes(frames[index], flush: false); + final mask = await segmenter.processImage( + InputImage.fromFilePath(file.path), + ); + if (mask == null) { + results.add(frames[index]); + continue; + } + results.add( + await compute( + _applySegmentationMask, + _MaskRequest( + frame: frames[index], + maskWidth: mask.width, + maskHeight: mask.height, + confidences: Float32List.fromList(mask.confidences), + ), + ), + ); + } finally { + if (await file.exists()) { + await file.delete().catchError((_) => file); + } + } + } + } finally { + await segmenter.close(); + } + return results; +} + +class _ErrorText extends StatelessWidget { + const _ErrorText(this.message); + + final String message; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(top: Grid.xs), + child: Text( + message, + textAlign: TextAlign.center, + style: context.textTheme.bodySmall?.copyWith(color: context.colors.error), + ), + ); +} + Color _personOutlineColor(int backdropColor) { final color = Color(backdropColor); return color.computeLuminance() > 0.74 @@ -650,6 +657,42 @@ class _FramePlane { final int bytesPerPixel; } +@immutable +class _MaskRequest { + const _MaskRequest({ + required this.frame, + required this.maskWidth, + required this.maskHeight, + required this.confidences, + }); + + final Uint8List frame; + final int maskWidth; + final int maskHeight; + final Float32List confidences; +} + +Uint8List _applySegmentationMask(_MaskRequest request) { + final result = image.decodePng(request.frame)!.convert(numChannels: 4); + for (var y = 0; y < result.height; y++) { + final maskY = (y * request.maskHeight / result.height).floor().clamp( + 0, + request.maskHeight - 1, + ); + for (var x = 0; x < result.width; x++) { + final maskX = (x * request.maskWidth / result.width).floor().clamp( + 0, + request.maskWidth - 1, + ); + final confidence = request.confidences[maskY * request.maskWidth + maskX]; + final alpha = ((confidence - 0.28) / (0.72 - 0.28)).clamp(0, 1); + final pixel = result.getPixel(x, y); + pixel.a = (alpha * 255).round(); + } + } + return image.encodePng(result, level: 4); +} + @immutable class _FrameRequest { const _FrameRequest({ @@ -657,13 +700,13 @@ class _FrameRequest { required this.height, required this.planes, required this.isBgra, - required this.rotationDegrees, + required this.sensorOrientation, required this.mirror, }); factory _FrameRequest.fromCameraImage( CameraImage frame, { - required int rotationDegrees, + required int sensorOrientation, required bool mirror, }) => _FrameRequest( width: frame.width, @@ -680,7 +723,7 @@ class _FrameRequest { ) .toList(growable: false), isBgra: frame.format.group == ImageFormatGroup.bgra8888, - rotationDegrees: rotationDegrees, + sensorOrientation: sensorOrientation, mirror: mirror, ); @@ -688,7 +731,7 @@ class _FrameRequest { final int height; final List<_FramePlane> planes; final bool isBgra; - final int rotationDegrees; + final int sensorOrientation; final bool mirror; } @@ -731,10 +774,11 @@ Uint8List _convertCameraFrame(_FrameRequest request) { } } } - // iOS pre-rotates and mirrors BGRA buffers. Android YUV buffers remain - // sensor-oriented and need both corrections here. - if (!request.isBgra && request.rotationDegrees != 0) { - result = image.copyRotate(result, angle: request.rotationDegrees); + // camera_avfoundation applies the capture connection's orientation and front + // camera mirroring to BGRA pixel buffers before streaming them to Flutter. + // Android YUV buffers remain sensor-oriented and need both corrections here. + if (!request.isBgra && request.sensorOrientation != 0) { + result = image.copyRotate(result, angle: request.sensorOrientation); } if (!request.isBgra && request.mirror) { result = image.flipHorizontal(result); @@ -866,34 +910,21 @@ _EncodedAvatar _encodeAvatar(_EncodeRequest request) { final composed = request.frames .map((bytes) { final source = image.decodePng(bytes)!; - final scaledSize = (_outputSize * request.scale).round().clamp( + final cropSize = (source.width / request.scale).round().clamp( 1, - _outputSize * 2, + source.width, ); - final scaledPerson = image.copyResize( - source, - width: scaledSize, - height: scaledSize, - ); - final person = image.Image( + final available = source.width - cropSize; + final x = ((available / 2) - request.offsetX * available / 2) + .round() + .clamp(0, available); + final y = ((available / 2) - request.offsetY * available / 2) + .round() + .clamp(0, available); + final person = image.copyResize( + image.copyCrop(source, x: x, y: y, width: cropSize, height: cropSize), width: _outputSize, height: _outputSize, - numChannels: 4, - ); - const previewSize = 220.0; - const previewTranslation = 48.0; - final translationScale = _outputSize / previewSize; - image.compositeImage( - person, - scaledPerson, - dstX: - ((_outputSize - scaledSize) / 2 + - request.offsetX * previewTranslation * translationScale) - .round(), - dstY: - ((_outputSize - scaledSize) / 2 + - request.offsetY * previewTranslation * translationScale) - .round(), ); final frame = image.Image( width: _outputSize, @@ -956,26 +987,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/profile_avatar_draft.dart b/mobile/lib/features/profile/profile_avatar_draft.dart index efb69004606..4c26b973527 100644 --- a/mobile/lib/features/profile/profile_avatar_draft.dart +++ b/mobile/lib/features/profile/profile_avatar_draft.dart @@ -3,48 +3,29 @@ import 'dart:typed_data'; import '../../shared/animated_avatar.dart'; import '../../shared/relay/relay.dart'; -/// A prepared profile-avatar change that is uploaded only when the user saves. sealed class ProfileAvatarDraft { - /// Creates a prepared profile-avatar draft. const ProfileAvatarDraft(); - /// Returns the avatar URL for this draft using [service] when upload is - /// required. - /// - /// Implementations cache successful uploads for the same service so a - /// profile-publish retry does not create duplicate media. Failed uploads may - /// be retried, and changing services starts a new upload for that community. Future upload(MediaUploadService service); } -/// An avatar draft that already has its final URL and needs no media upload. final class ProfileUrlAvatarDraft extends ProfileAvatarDraft { - /// Creates a draft backed by [url]. const ProfileUrlAvatarDraft(this.url); - /// The URL that will be written to the profile. final String url; @override Future upload(MediaUploadService service) async => url; } -/// A locally prepared still image awaiting upload on Save. final class ProfileImageAvatarDraft extends ProfileAvatarDraft { - /// Creates a still-image draft from JPEG [bytes]. ProfileImageAvatarDraft(this.bytes); - /// The prepared JPEG payload. final Uint8List bytes; - MediaUploadService? _uploadService; Future? _uploadedUrl; @override Future upload(MediaUploadService service) async { - if (!identical(_uploadService, service)) { - _uploadService = service; - _uploadedUrl = null; - } final existing = _uploadedUrl; if (existing != null) return existing; final upload = service @@ -60,69 +41,23 @@ final class ProfileImageAvatarDraft extends ProfileAvatarDraft { } } -/// A locally prepared animated avatar and its still poster awaiting upload. final class ProfileAnimatedAvatarDraft extends ProfileAvatarDraft { - /// Creates an animated draft from PNG [animation] and [poster] payloads. ProfileAnimatedAvatarDraft({required this.animation, required this.poster}); - /// The animated PNG payload. final Uint8List animation; - - /// The still PNG poster shown when animation is unavailable or disabled. final Uint8List poster; - MediaUploadService? _uploadService; Future? _uploadedUrl; - Future? _posterUpload; - Future? _animationUpload; - - Future _uploadPoster(MediaUploadService service) { - final existing = _posterUpload; - if (existing != null) return existing; - late final Future upload; - upload = service.uploadBytes(poster, mimeType: 'image/png').catchError(( - Object error, - StackTrace stackTrace, - ) { - if (identical(_posterUpload, upload)) _posterUpload = null; - Error.throwWithStackTrace(error, stackTrace); - }); - _posterUpload = upload; - return upload; - } - - Future _uploadAnimation(MediaUploadService service) { - final existing = _animationUpload; - if (existing != null) return existing; - late final Future upload; - upload = service.uploadBytes(animation, mimeType: 'image/png').catchError(( - Object error, - StackTrace stackTrace, - ) { - if (identical(_animationUpload, upload)) _animationUpload = null; - Error.throwWithStackTrace(error, stackTrace); - }); - _animationUpload = upload; - return upload; - } @override Future upload(MediaUploadService service) async { - if (!identical(_uploadService, service)) { - _uploadService = service; - _uploadedUrl = null; - _posterUpload = null; - _animationUpload = null; - } 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)), - ); + final upload = Future.wait( + [ + service.uploadBytes(poster, mimeType: 'image/png'), + service.uploadBytes(animation, mimeType: 'image/png'), + ], + ).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..f964bf2322a 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -23,34 +23,16 @@ import '../../shared/widgets/playing_avatar_image.dart'; import 'animated_avatar_capture.dart'; import 'avatar_background_grid.dart'; import 'avatar_editor_option_button.dart'; -import 'emoji_avatar_tile.dart'; import 'profile_avatar_crop_page.dart'; import 'profile_avatar_draft.dart'; part 'profile_avatar_editor/emoji_avatar_picker.dart'; /// Avatar kinds shared with the desktop profile editor. -enum ProfileAvatarMode { - /// A still image selected from the camera or photo library. - image, - - /// A system emoji composited over a selected background color. - emoji, - - /// A short camera animation with framing and background controls. - animated, -} - -/// Builds the animated capture surface for the profile avatar editor. -typedef AnimatedAvatarCaptureBuilder = - Widget Function({ - required double height, - required ValueChanged Function()?> - onPrepareChanged, - }); +enum ProfileAvatarMode { image, emoji, animated } const _previewSize = 220.0; -const _motionDuration = Duration(milliseconds: 150); +const _motionDuration = Duration(milliseconds: 240); const _previewSquishDuration = Duration(milliseconds: 200); const Curve _entranceCurve = Curves.easeOutCubic; const Curve _exitCurve = Curves.easeInCubic; @@ -68,7 +50,6 @@ const double _settingsAvatarCenterBelowAppBar = 96; /// In-page profile avatar editor used on Android and iOS. class ProfileAvatarEditor extends HookConsumerWidget { - /// Creates an avatar editor backed by the current profile and draft state. const ProfileAvatarEditor({ super.key, required this.currentAvatarUrl, @@ -79,63 +60,33 @@ class ProfileAvatarEditor extends HookConsumerWidget { required this.onModeChanged, required this.onDraftChanged, required this.onAnimatedPrepareChanged, - this.animatedCaptureBuilder, }); - /// The avatar URL shown until the user selects a new draft. final String? currentAvatarUrl; - - /// The text initial used when [currentAvatarUrl] has no displayable image. final String fallbackInitial; - - /// The unsaved avatar selection for the active editing session. final ProfileAvatarDraft? draft; - - /// The currently selected avatar editing mode. final ProfileAvatarMode mode; - - /// Drives the shared preview transition into and out of editing. final Animation transition; - - /// Called when the user selects a different avatar editing mode. final ValueChanged onModeChanged; - - /// Called whenever the unsaved avatar selection changes. final ValueChanged onDraftChanged; - - /// Supplies or clears the deferred animated-avatar preparation callback. final ValueChanged Function()?> onAnimatedPrepareChanged; - /// Overrides the animated capture surface, primarily for tests. - final AnimatedAvatarCaptureBuilder? animatedCaptureBuilder; - @override Widget build(BuildContext context, WidgetRef ref) { final reduceMotion = MediaQuery.disableAnimationsOf(context); - final currentEmojiAvatar = useMemoized( - () => parseEmojiAvatarDataUrl(currentAvatarUrl), - [currentAvatarUrl], - ); - final selectedEmoji = useState(currentEmojiAvatar?.emoji ?? '😊'); + final selectedEmoji = useState('😊'); final initialColor = useMemoized( - () => - currentEmojiAvatar?.colorValue ?? - emojiAvatarColors[Random().nextInt(18)], - [currentEmojiAvatar], + () => emojiAvatarColors[Random().nextInt(18)], + const [], ); final selectedColor = useState(initialColor); final emojiSection = useState(_EmojiEditorSection.emoji); final emojiPreviewKey = useState(0); final isPickingImage = useState(false); - final imageSelectionGeneration = useRef(0); - final currentMode = useRef(mode)..value = mode; final error = useState(null); final dataset = ref.watch(emojiDatasetOrEmptyProvider); final modeTransitionDirection = useRef(1.0); - final modeTransitionFrom = useRef(mode); - final retainedPreview = useRef(null); - final retainedPreviewTop = useRef(0.0); final modeTransitionController = useAnimationController( duration: _motionDuration, initialValue: 1, @@ -147,23 +98,11 @@ class ProfileAvatarEditor extends HookConsumerWidget { void selectMode(ProfileAvatarMode nextMode) { if (nextMode == mode) return; - modeTransitionFrom.value = mode; modeTransitionDirection.value = nextMode.index > mode.index ? 1 : -1; unawaited(HapticFeedback.selectionClick()); onAnimatedPrepareChanged(null); - if (mode == ProfileAvatarMode.image) { - imageSelectionGeneration.value++; - isPickingImage.value = false; - } if (!reduceMotion) modeTransitionController.value = 0; onModeChanged(nextMode); - if (nextMode == ProfileAvatarMode.emoji) { - onDraftChanged( - ProfileUrlAvatarDraft( - emojiAvatarDataUrl(selectedEmoji.value, selectedColor.value), - ), - ); - } if (reduceMotion) { modeTransitionController.value = 1; } else { @@ -182,11 +121,6 @@ class ProfileAvatarEditor extends HookConsumerWidget { Future selectImage({required bool camera}) async { if (isPickingImage.value) return; - final operation = ++imageSelectionGeneration.value; - bool isCurrentOperation() => - context.mounted && - currentMode.value == ProfileAvatarMode.image && - imageSelectionGeneration.value == operation; isPickingImage.value = true; error.value = null; try { @@ -195,9 +129,9 @@ class ProfileAvatarEditor extends HookConsumerWidget { final picked = camera ? await service.captureImage() : await service.pickGalleryImage(); - if (picked == null || !isCurrentOperation()) return; + if (picked == null || !context.mounted) return; final preparedPhoto = await service.prepareImageBytes(picked); - if (!context.mounted || !isCurrentOperation()) return; + if (!context.mounted) return; final cropped = await Navigator.of(context).push( MaterialPageRoute( builder: (_) => ProfileAvatarCropPage( @@ -205,14 +139,12 @@ class ProfileAvatarEditor extends HookConsumerWidget { ), ), ); - if (cropped == null || !isCurrentOperation()) return; - onDraftChanged(ProfileImageAvatarDraft(cropped)); + if (cropped == null) return; + if (context.mounted) onDraftChanged(ProfileImageAvatarDraft(cropped)); } catch (_) { - if (isCurrentOperation()) { - error.value = "We couldn't prepare that photo. Try again."; - } + error.value = "We couldn't prepare that photo. Try again."; } finally { - if (isCurrentOperation()) isPickingImage.value = false; + if (context.mounted) isPickingImage.value = false; } } @@ -246,10 +178,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { emoji: selectedEmoji.value, color: Color(selectedColor.value), animationKey: emojiPreviewKey.value, - reduceMotion: - reduceMotion || - (modeTransitionFrom.value == ProfileAvatarMode.animated && - modeTransitionProgress < 1), + reduceMotion: reduceMotion, ), ProfileAvatarMode.animated => null, }; @@ -276,16 +205,6 @@ class ProfileAvatarEditor extends HookConsumerWidget { ), ], ); - if (mode != ProfileAvatarMode.animated && fixedPreview != null) { - retainedPreview.value = mode == ProfileAvatarMode.emoji - ? _EmojiAvatarPreview( - emoji: selectedEmoji.value, - color: Color(selectedColor.value), - animationKey: emojiPreviewKey.value, - reduceMotion: true, - ) - : fixedPreview; - } final curvedEntrance = CurvedAnimation( parent: transition, @@ -313,23 +232,6 @@ class ProfileAvatarEditor extends HookConsumerWidget { : avatarBackgroundPreviewShift; final previewShift = min(requestedShift, maximumShift); final previewTop = basePreviewTop - previewShift; - final returningToEmoji = - mode == ProfileAvatarMode.emoji && - modeTransitionFrom.value == ProfileAvatarMode.animated; - final animatedModeHeight = max( - 0.0, - viewportHeight - _editorControlsBottom - basePreviewTop, - ); - final animatedPreviewSize = animatedModeHeight < 400 ? 180.0 : 228.0; - final animatedPreviewTop = - basePreviewTop + (animatedPreviewSize - _previewBlockSize) / 2; - final displayedPreviewTop = returningToEmoji - ? animatedPreviewTop + - (previewTop - animatedPreviewTop) * modeTransitionProgress - : previewTop; - if (mode != ProfileAvatarMode.animated) { - retainedPreviewTop.value = previewTop; - } final fixedContentTop = previewTop + _previewBlockSize + _previewControlGap; final modeTop = mode == ProfileAvatarMode.animated @@ -354,8 +256,6 @@ class ProfileAvatarEditor extends HookConsumerWidget { dataset: dataset, selectedEmoji: selectedEmoji.value, selectedColor: selectedColor.value, - transitionProgress: modeTransitionProgress, - transitionDirection: modeTransitionDirection.value, onSectionChanged: (section) => emojiSection.value = section, onEmojiSelected: (emoji) { selectedEmoji.value = emoji; @@ -366,26 +266,17 @@ class ProfileAvatarEditor extends HookConsumerWidget { updateEmojiPreview(); }, ), - ProfileAvatarMode.animated => KeyedSubtree( + ProfileAvatarMode.animated => AnimatedAvatarCapture( key: const ValueKey(2), - child: - animatedCaptureBuilder?.call( - height: modeHeight, - onPrepareChanged: onAnimatedPrepareChanged, - ) ?? - AnimatedAvatarCapture( - height: modeHeight, - onPrepareChanged: onAnimatedPrepareChanged, - ), + height: modeHeight, + onPrepareChanged: onAnimatedPrepareChanged, ), }; final collapsedPreviewOffset = appBarHeight + _settingsAvatarCenterBelowAppBar - (previewTop + _previewBlockSize / 2); - final transitionedModeContent = - mode == ProfileAvatarMode.animated || - mode == ProfileAvatarMode.emoji + final transitionedModeContent = mode == ProfileAvatarMode.animated ? modeContent : ClipRect( child: Transform.translate( @@ -445,16 +336,14 @@ class ProfileAvatarEditor extends HookConsumerWidget { if (fixedPreview != null) AnimatedPositioned( key: const ValueKey('avatar-preview-position'), + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 150), curve: Curves.easeOutCubic, left: Grid.gutter, right: Grid.gutter, - top: displayedPreviewTop, + top: previewTop, height: _previewBlockSize, - duration: returningToEmoji - ? Duration.zero - : reduceMotion - ? Duration.zero - : const Duration(milliseconds: 150), child: Center( child: AnimatedBuilder( animation: curvedEntrance, @@ -492,39 +381,16 @@ class ProfileAvatarEditor extends HookConsumerWidget { child: transitionedModeContent, ), ), - if (mode == ProfileAvatarMode.animated && - !reduceMotion && - modeTransitionProgress < 1 && - retainedPreview.value != null) - Positioned( - key: const ValueKey('avatar-mode-retained-preview'), - left: Grid.gutter, - right: Grid.gutter, - top: - retainedPreviewTop.value + - (basePreviewTop - retainedPreviewTop.value) * - modeTransitionProgress, - height: _previewBlockSize, - child: IgnorePointer( - child: Opacity( - opacity: 1 - modeTransitionProgress, - child: Center(child: retainedPreview.value), - ), - ), - ), if (error.value != null) Positioned( left: Grid.gutter, right: Grid.gutter, bottom: _editorControlsBottom + _editorRailHeight, - child: Semantics( - liveRegion: true, - child: Text( - error.value!, - textAlign: TextAlign.center, - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.error, - ), + child: Text( + error.value!, + textAlign: TextAlign.center, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, ), ), ), @@ -591,35 +457,23 @@ class _AvatarModeControl extends StatelessWidget { children: [ for (final mode in ProfileAvatarMode.values) Expanded( - child: Semantics( - label: switch (mode) { - ProfileAvatarMode.image => 'Image', - ProfileAvatarMode.emoji => 'Emoji', - ProfileAvatarMode.animated => 'Animated', - }, - button: true, - selected: mode == selected, + child: InkWell( + key: ValueKey('avatar-mode-${mode.name}'), + borderRadius: BorderRadius.circular(Radii.full), onTap: () => onSelected(mode), - child: ExcludeSemantics( - child: InkWell( - key: ValueKey('avatar-mode-${mode.name}'), - borderRadius: BorderRadius.circular(Radii.full), - onTap: () => onSelected(mode), - child: SizedBox( - height: 36, - child: Center( - child: Text( - switch (mode) { - ProfileAvatarMode.image => 'Image', - ProfileAvatarMode.emoji => 'Emoji', - ProfileAvatarMode.animated => 'Animated', - }, - style: context.textTheme.labelLarge?.copyWith( - fontWeight: mode == selected - ? FontWeight.w600 - : FontWeight.w500, - ), - ), + child: SizedBox( + height: 36, + child: Center( + child: Text( + switch (mode) { + ProfileAvatarMode.image => 'Image', + ProfileAvatarMode.emoji => 'Emoji', + ProfileAvatarMode.animated => 'Animated', + }, + style: context.textTheme.labelLarge?.copyWith( + fontWeight: mode == selected + ? FontWeight.w600 + : FontWeight.w500, ), ), ), diff --git a/mobile/lib/features/profile/profile_edit_page.dart b/mobile/lib/features/profile/profile_edit_page.dart index 12e4e3efd7b..75ac749cc12 100644 --- a/mobile/lib/features/profile/profile_edit_page.dart +++ b/mobile/lib/features/profile/profile_edit_page.dart @@ -24,24 +24,14 @@ import 'ios_profile_text_editor.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 { - /// Creates the profile details and avatar editing page. - const ProfileEditPage({ - super.key, - this.startInPhotoEditor = false, - this.animatedAvatarCaptureBuilder, - }); + const ProfileEditPage({super.key, this.startInPhotoEditor = false}); /// Opens directly into the photo editor when launched from Settings. final bool startInPhotoEditor; - /// Overrides animated capture for focused integration tests. - final AnimatedAvatarCaptureBuilder? animatedAvatarCaptureBuilder; - static const _avatarRadius = 64.0; @override @@ -53,11 +43,7 @@ class ProfileEditPage extends HookConsumerWidget { final isEditingAvatar = useState(startInPhotoEditor); final avatarDraft = useState(null); final avatarDraftMode = useState(null); - final avatarEditConfig = useRef( - startInPhotoEditor ? ref.read(relayConfigProvider) : null, - ); final isSavingAvatar = useState(false); - final isClosingAvatar = useState(false); final prepareAnimatedAvatar = useRef Function()?>(null); final avatarSaveError = useState(null); @@ -87,11 +73,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, @@ -108,29 +93,24 @@ class ProfileEditPage extends HookConsumerWidget { }) async { if (defaultTargetPlatform == TargetPlatform.iOS) { try { - await IosProfileTextEditor.presentUntilSaved( + final value = await IosProfileTextEditor.present( title: title, initialValue: initialValue, placeholder: hintText, multiline: multiline, - brightness: Theme.of(context).brightness, - onSave: onSave, - shouldRetryOnError: (error) => - error is! ProfileCommunityChangedException, - canPresent: () => - context.mounted && (ModalRoute.of(context)?.isCurrent ?? true), - onSaveError: () { - if (!context.mounted || - !(ModalRoute.of(context)?.isCurrent ?? true)) { - return; - } + ); + if (value == null) return; + try { + await onSave(value); + } catch (_) { + if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text("We couldn't save this change. Try again."), ), ); - }, - ); + } + } return; } on MissingPluginException { // Keep the Flutter editor available in previews and older builds. @@ -150,16 +130,12 @@ class ProfileEditPage extends HookConsumerWidget { void openAvatarEditor() { if (!profileHydrated) return; - avatarEditConfig.value = ref.read(relayConfigProvider); isEditingAvatar.value = true; unawaited(avatarTransition.forward(from: 0)); } Future closeAvatarEditor({bool whileSaving = false}) async { - if ((isSavingAvatar.value && !whileSaving) || isClosingAvatar.value) { - return; - } - isClosingAvatar.value = true; + if (isSavingAvatar.value && !whileSaving) return; await avatarTransition.reverse(); if (!context.mounted) return; if (startInPhotoEditor) { @@ -169,71 +145,29 @@ class ProfileEditPage extends HookConsumerWidget { isEditingAvatar.value = false; avatarDraft.value = null; avatarDraftMode.value = null; - avatarEditConfig.value = null; prepareAnimatedAvatar.value = null; canPrepareAnimatedAvatar.value = false; avatarMode.value = ProfileAvatarMode.image; - isClosingAvatar.value = false; } Future saveAvatar() async { - if (isSavingAvatar.value || isClosingAvatar.value) return; - final openingConfig = avatarEditConfig.value; - if (openingConfig == null) return; - final saveConfig = ref.read(relayConfigProvider); - final uploadService = ref.read(mediaUploadServiceProvider); - - void requireCurrentCommunity() { - final currentConfig = ref.read(relayConfigProvider); - if (currentConfig.storedOrigin != openingConfig.storedOrigin || - currentConfig.nsec != openingConfig.nsec || - currentConfig.storedOrigin != saveConfig.storedOrigin || - currentConfig.nsec != saveConfig.nsec || - !identical(ref.read(mediaUploadServiceProvider), uploadService)) { - throw ProfileCommunityChangedException(); - } - } - - Future discardStaleEditor() async { - avatarDraft.value = null; - avatarDraftMode.value = null; - prepareAnimatedAvatar.value = null; - canPrepareAnimatedAvatar.value = false; - if (context.mounted) await closeAvatarEditor(whileSaving: true); - } - + if (isSavingAvatar.value) return; isSavingAvatar.value = true; avatarSaveError.value = null; try { - requireCurrentCommunity(); var nextDraft = avatarDraftMode.value == avatarMode.value ? avatarDraft.value : null; - if (avatarMode.value == ProfileAvatarMode.animated && - nextDraft == null) { + if (avatarMode.value == ProfileAvatarMode.animated) { nextDraft = await prepareAnimatedAvatar.value?.call(); - requireCurrentCommunity(); - if (nextDraft != null) { - avatarDraft.value = nextDraft; - avatarDraftMode.value = ProfileAvatarMode.animated; - } } if (nextDraft == null) return; - requireCurrentCommunity(); - final nextAvatar = await nextDraft.upload(uploadService); - requireCurrentCommunity(); + final nextAvatar = await nextDraft.upload( + ref.read(mediaUploadServiceProvider), + ); await ref.read(profileProvider.notifier).updateAvatarUrl(nextAvatar); - requireCurrentCommunity(); if (context.mounted) await closeAvatarEditor(whileSaving: true); - } on ProfileCommunityChangedException { - await discardStaleEditor(); } catch (_) { - try { - requireCurrentCommunity(); - } on ProfileCommunityChangedException { - await discardStaleEditor(); - return; - } avatarSaveError.value = "We couldn't save your profile photo. Try again."; } finally { @@ -278,16 +212,12 @@ class ProfileEditPage extends HookConsumerWidget { key: const ValueKey('avatar-editor-back'), icon: IosGlassNavigationIcon.back, semanticLabel: 'Back to profile', - onPressed: isClosingAvatar.value - ? null - : () => unawaited(closeAvatarEditor()), + onPressed: () => unawaited(closeAvatarEditor()), ) : IconButton( key: const ValueKey('avatar-editor-back'), tooltip: 'Back to profile', - onPressed: isClosingAvatar.value - ? null - : () => unawaited(closeAvatarEditor()), + onPressed: () => unawaited(closeAvatarEditor()), icon: const Icon(LucideIcons.arrowLeft), ) : null, @@ -299,10 +229,7 @@ class ProfileEditPage extends HookConsumerWidget { label: 'Save', width: 72, isBusy: isSavingAvatar.value, - onPressed: - canSaveAvatar && - !isSavingAvatar.value && - !isClosingAvatar.value + onPressed: canSaveAvatar && !isSavingAvatar.value ? () { unawaited(HapticFeedback.lightImpact()); unawaited(saveAvatar()); @@ -316,10 +243,7 @@ class ProfileEditPage extends HookConsumerWidget { key: const ValueKey('avatar-save'), label: 'Save', isBusy: isSavingAvatar.value, - onTap: - canSaveAvatar && - !isSavingAvatar.value && - !isClosingAvatar.value + onTap: canSaveAvatar && !isSavingAvatar.value ? () { unawaited(HapticFeedback.lightImpact()); unawaited(saveAvatar()); @@ -363,14 +287,7 @@ class ProfileEditPage extends HookConsumerWidget { onAnimatedPrepareChanged: (prepare) { prepareAnimatedAvatar.value = prepare; canPrepareAnimatedAvatar.value = prepare != null; - if (avatarMode.value == - ProfileAvatarMode.animated) { - avatarDraft.value = null; - avatarDraftMode.value = null; - } }, - animatedCaptureBuilder: - animatedAvatarCaptureBuilder, ), ), ), @@ -425,25 +342,16 @@ class ProfileEditPage extends HookConsumerWidget { trailing: const _EditChevron(), onTap: !profileHydrated ? null - : () { - final container = ProviderScope.containerOf( - context, - listen: false, - ); - unawaited( - editField( - title: 'Display name', - initialValue: profile?.displayName ?? '', - hintText: 'Display name', - onSave: bindProfileSaveToOpeningContext( - container, - container - .read(profileProvider.notifier) - .updateDisplayName, - ), - ), - ); - }, + : () => unawaited( + editField( + title: 'Display name', + initialValue: profile?.displayName ?? '', + hintText: 'Display name', + onSave: ref + .read(profileProvider.notifier) + .updateDisplayName, + ), + ), ), AppListRow( key: const ValueKey('profile-description-row'), @@ -453,26 +361,17 @@ class ProfileEditPage extends HookConsumerWidget { trailing: const _EditChevron(), onTap: !profileHydrated ? null - : () { - final container = ProviderScope.containerOf( - context, - listen: false, - ); - unawaited( - editField( - title: 'Profile description', - initialValue: profile?.about ?? '', - hintText: 'Profile description', - multiline: true, - onSave: bindProfileSaveToOpeningContext( - container, - container - .read(profileProvider.notifier) - .updateAbout, - ), - ), - ); - }, + : () => unawaited( + editField( + title: 'Profile description', + initialValue: profile?.about ?? '', + hintText: 'Profile description', + multiline: true, + onSave: ref + .read(profileProvider.notifier) + .updateAbout, + ), + ), ), ], ), @@ -582,3 +481,87 @@ 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(); + } 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..7a852ab9775 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -4,19 +4,11 @@ import 'dart:convert'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../shared/crypto/nip_oa.dart'; import '../../shared/profile/user_cache_provider.dart'; import '../../shared/profile/user_profile.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; -/// Signals that a profile write no longer belongs to the active community. -class ProfileCommunityChangedException extends StateError { - /// Creates an error for a profile write invalidated by a community switch. - ProfileCommunityChangedException() - : super('Profile update cancelled because the active community changed.'); -} - /// 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. @@ -24,43 +16,44 @@ class ProfileNotifier extends AsyncNotifier { Map _metadata = {}; bool _hasHydrated = false; int _lastCreatedAt = 0; - Future _patchQueue = Future.value(); @override Future build() { - final config = ref.watch(relayConfigProvider); - final pubkey = ref.watch(myPubkeyProvider); + ref.watch(relayConfigProvider); ref.watch(relaySessionProvider); - final context = _ProfileWriteContext( - config: config, - pubkey: pubkey, - session: ref.read(relaySessionProvider.notifier), - ); _hasHydrated = false; - return _fetch(context); + return _fetch(); } - Future _fetch(_ProfileWriteContext context) async { - final myPk = context.pubkey; + Future _fetch() async { + final myPk = ref.read(myPubkeyProvider); if (myPk == null) { - _requireCurrentWriteContext(context); _metadata = {}; _lastCreatedAt = 0; _hasHydrated = true; return null; } - final session = context.session; + final session = ref.read(relaySessionProvider.notifier); final events = await session.fetchHistory(NostrFilters.profile(myPk)); if (events.isEmpty) { - _requireCurrentWriteContext(context); _metadata = {}; _lastCreatedAt = 0; _hasHydrated = true; return null; } - final latest = _latestProfileEvent(events)!; - final metadata = _decodeProfileMetadata(latest); + final latest = events.reduce((current, event) { + if (event.createdAt != current.createdAt) { + return event.createdAt > current.createdAt ? event : current; + } + return event.id.compareTo(current.id) > 0 ? event : current; + }); + final decoded = jsonDecode(latest.content); + if (decoded is! Map) { + throw const FormatException('Profile metadata must be a JSON object.'); + } + _metadata = Map.from(decoded); + _lastCreatedAt = latest.createdAt; final data = ProfileData.fromEvent(latest); final profile = UserProfile( pubkey: data.pubkey, @@ -68,19 +61,14 @@ class ProfileNotifier extends AsyncNotifier { avatarUrl: data.avatarUrl, about: data.about, nip05Handle: data.nip05, - ownerPubkey: verifiedOaOwnerPubkey(latest.tags, data.pubkey), ); - _requireCurrentWriteContext(context); - _metadata = metadata; - _lastCreatedAt = latest.createdAt; _hasHydrated = true; return profile; } Future refresh() async { - final context = _currentWriteContext(); _hasHydrated = false; - state = await AsyncValue.guard(() => _fetch(context)); + state = await AsyncValue.guard(_fetch); } /// Updates the current user's display name while preserving the other @@ -96,85 +84,29 @@ class ProfileNotifier extends AsyncNotifier { Future updateAvatarUrl(String avatarUrl) => _publishProfilePatch({'picture': avatarUrl.trim()}); - Future _publishProfilePatch(Map patch) { - final context = _currentWriteContext(); - final previous = _patchQueue; - final released = Completer(); - _patchQueue = released.future; - return () async { - await previous; - try { - await _publishProfilePatchNow(patch, context); - } finally { - released.complete(); - } - }(); - } - - _ProfileWriteContext _currentWriteContext() => _ProfileWriteContext( - config: ref.read(relayConfigProvider), - pubkey: ref.read(myPubkeyProvider), - session: ref.read(relaySessionProvider.notifier), - ); - - Future _publishProfilePatchNow( - Map patch, - _ProfileWriteContext context, - ) async { - _requireCurrentWriteContext(context); + Future _publishProfilePatch(Map patch) async { if (!_hasHydrated || !state.hasValue) { throw StateError('Cannot update profile before metadata is loaded.'); } - final pubkey = context.pubkey; + final pubkey = ref.read(myPubkeyProvider); if (pubkey == null) { throw StateError('Cannot update profile without a signing identity.'); } - final session = context.session; - final currentEvents = await session.fetchHistory( - NostrFilters.profile(pubkey), + + final nextMetadata = {..._metadata, ...patch}; + final config = ref.read(relayConfigProvider); + final relay = SignedEventRelay( + session: ref.read(relaySessionProvider.notifier), + nsec: config.nsec, ); - _requireCurrentWriteContext(context); - final currentHead = _latestProfileEvent(currentEvents); - if (_lastCreatedAt > 0 && - (currentHead == null || currentHead.createdAt < _lastCreatedAt)) { - throw StateError('Cannot confirm the latest profile metadata.'); - } - final currentMetadata = currentHead == null - ? {} - : _decodeProfileMetadata(currentHead); - final nextMetadata = {...currentMetadata, ...patch}; - if (patch['display_name'] == '') { - nextMetadata - ..remove('display_name') - ..remove('name'); - } - final relay = SignedEventRelay(session: session, nsec: context.config.nsec); final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final currentCreatedAt = currentHead?.createdAt ?? 0; - final previousCreatedAt = currentCreatedAt > _lastCreatedAt - ? currentCreatedAt - : _lastCreatedAt; - final createdAt = now > previousCreatedAt ? now : previousCreatedAt + 1; - NostrEvent? signedEvent; + final createdAt = now > _lastCreatedAt ? now : _lastCreatedAt + 1; await relay.submit( kind: EventKind.profile, content: jsonEncode(nextMetadata), - tags: currentHead?.tags ?? const [], + tags: const [], createdAt: createdAt, - onSigned: (event) => signedEvent = event, - ); - _requireCurrentWriteContext(context); - final submittedEvent = signedEvent; - if (submittedEvent == null) { - throw StateError('Profile update was not signed.'); - } - final verifiedHead = _latestProfileEvent( - await session.fetchHistory(NostrFilters.profile(pubkey)), ); - _requireCurrentWriteContext(context); - if (verifiedHead?.id != submittedEvent.id) { - throw StateError('Profile changed before the update could be confirmed.'); - } _metadata = nextMetadata; _lastCreatedAt = createdAt; @@ -185,57 +117,10 @@ class ProfileNotifier extends AsyncNotifier { avatarUrl: _metadata['picture'] as String?, about: _metadata['about'] as String?, nip05Handle: _metadata['nip05'] as String?, - ownerPubkey: verifiedOaOwnerPubkey(submittedEvent.tags, pubkey), ); state = AsyncData(profile); ref.read(userCacheProvider.notifier).put(profile); } - - void _requireCurrentWriteContext(_ProfileWriteContext context) { - final currentConfig = ref.read(relayConfigProvider); - final currentSession = ref.read(relaySessionProvider.notifier); - final currentPubkey = ref.read(myPubkeyProvider); - if (currentConfig.storedOrigin != context.config.storedOrigin || - currentConfig.nsec != context.config.nsec || - currentPubkey != context.pubkey || - !identical(currentSession, context.session)) { - throw ProfileCommunityChangedException(); - } - } -} - -class _ProfileWriteContext { - const _ProfileWriteContext({ - required this.config, - required this.pubkey, - required this.session, - }); - - final RelayConfig config; - final String? pubkey; - final RelaySessionNotifier session; -} - -NostrEvent? _latestProfileEvent(List events) { - if (events.isEmpty) return null; - return events.reduce((current, event) { - if (event.createdAt != current.createdAt) { - return event.createdAt > current.createdAt ? event : current; - } - // Match the relay replacement tie-breaker: the lowest event id wins. - return event.id.compareTo(current.id) < 0 ? event : current; - }); -} - -Map _decodeProfileMetadata(NostrEvent event) { - try { - final decoded = jsonDecode(event.content); - return decoded is Map - ? Map.from(decoded) - : {}; - } on FormatException { - return {}; - } } final profileProvider = AsyncNotifierProvider( diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index d100e0f5ae4..ca1e755d065 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -2,15 +2,11 @@ import 'dart:async'; import 'dart:math' as math; import 'package:buzz/features/profile/profile_edit_page.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/emoji_avatar_tile.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'; -import 'package:buzz/shared/emoji/emoji_data.dart'; -import 'package:buzz/shared/emoji/emoji_data_provider.dart'; import 'package:buzz/shared/emoji/native_emoji_glyph.dart'; import 'package:buzz/shared/profile/user_profile.dart'; import 'package:buzz/shared/relay/relay.dart'; @@ -30,37 +26,9 @@ import 'package:image_picker/image_picker.dart'; import '../../helpers/widget_helpers.dart'; -part 'profile_edit_page_test/motion_and_accessibility_tests.dart'; -part 'profile_edit_page_test/image_selection_tests.dart'; - const _editorControlBottomForTest = Grid.xl + Grid.xxs; void main() { - testWidgets('keeps crop Save disabled while dimensions decode', ( - tester, - ) async { - final bytes = Uint8List.fromList( - image.encodePng(image.Image(width: 20, height: 10)), - ); - await tester.pumpWidget( - MaterialApp( - home: ProfileAvatarCropPage( - imageBytes: Future.value(bytes), - ), - ), - ); - await tester.pump(); - await tester.pump(); - - final saveButton = tester.widget( - find.descendant( - of: find.byKey(const ValueKey('avatar-crop-use-photo')), - matching: find.byType(TextButton), - ), - ); - expect(saveButton.onPressed, isNull); - }); - testWidgets('can open directly into the photo editor from Settings', ( tester, ) async { @@ -211,12 +179,9 @@ void main() { final notifier = _FakeProfileNotifier(); await tester.pumpWidget( - ProviderScope( + WidgetHelpers.testable( overrides: [profileProvider.overrideWith(() => notifier)], - child: MaterialApp( - theme: AppTheme.dark(), - home: const Scaffold(body: ProfileEditPage()), - ), + child: const ProfileEditPage(), ), ); await tester.pumpAndSettle(); @@ -229,8 +194,6 @@ void main() { 'initialValue': 'Alice', 'placeholder': 'Display name', 'multiline': false, - 'brightness': 'dark', - 'allowUnchangedSubmission': false, }); expect(notifier.savedDisplayNames, ['Alice Native']); debugDefaultTargetPlatformOverride = null; @@ -350,7 +313,7 @@ void main() { ); expect(tester.widget(preview).radius, 110); await tester.tap(find.text('Photo Library')); - await _waitForAvatarCropToLoad(tester); + await tester.pumpAndSettle(); expect(find.text('Position Photo'), findsOneWidget); final cancelButton = find.ancestor( of: find.text('Cancel'), @@ -384,7 +347,10 @@ void main() { closeTo(expectedY, 0.01), ); await tester.tap(find.byKey(const ValueKey('avatar-crop-use-photo'))); - await _waitForAvatarCropToClose(tester); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 200)), + ); + await tester.pumpAndSettle(); expect(notifier.savedAvatarUrls, isEmpty); expect(uploadService.uploadCount, 0); await tester.tap(find.byKey(const ValueKey('avatar-save'))); @@ -413,9 +379,12 @@ void main() { await tester.tap(find.text('Edit Photo')); await tester.pumpAndSettle(); await tester.tap(find.text('Photo Library')); - await _waitForAvatarCropToLoad(tester); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const ValueKey('avatar-crop-use-photo'))); - await _waitForAvatarCropToClose(tester); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 200)), + ); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const ValueKey('avatar-save'))); await tester.pumpAndSettle(); @@ -435,6 +404,54 @@ void main() { expect(uploadService.uploadCount, 1); }); + testWidgets('photo modes remain usable on a compact large-type viewport', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(320, 568); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: const MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(2)), + child: ProfileEditPage(startInPhotoEditor: true), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 250)); + expect(tester.takeException(), isNull); + expect( + find.byKey(const ValueKey('avatar-editor-scroll-view')), + findsOneWidget, + ); + + await tester.tap(find.text('Emoji')); + await tester.pump(const Duration(milliseconds: 250)); + expect(tester.takeException(), isNull); + await tester.ensureVisible( + find.byKey(const ValueKey('emoji-editor-background')), + ); + await tester.tap(find.byKey(const ValueKey('emoji-editor-background'))); + await tester.pump(const Duration(milliseconds: 200)); + expect(tester.takeException(), isNull); + + await tester.drag( + find.byKey(const ValueKey('avatar-editor-scroll-view')), + const Offset(0, 1000), + ); + await tester.pump(); + final animatedMode = find.byKey(const ValueKey('avatar-mode-animated')); + await tester.tap(animatedMode); + await tester.pump(const Duration(milliseconds: 250)); + expect(tester.takeException(), isNull); + expect( + find.byKey(const ValueKey('animated-avatar-capture-preview')), + findsOneWidget, + ); + }); + testWidgets('centers every preview while controls fill the page gutters', ( tester, ) async { @@ -572,13 +589,6 @@ void main() { tester.getSize(recordButton).width, closeTo(expectedContentWidth, 0.01), ); - final recordMaterial = tester.widget( - find.descendant(of: recordButton, matching: find.byType(Material)).first, - ); - expect( - recordMaterial.borderRadius, - const BorderRadius.all(Radius.circular(Radii.full)), - ); expect( screenSize.height - tester.getRect(recordButton).bottom, _editorControlBottomForTest, @@ -616,7 +626,7 @@ void main() { ); uploadService.completeGallerySelection(); - await _waitForAvatarCropToLoad(tester); + await tester.pumpAndSettle(); expect(find.text('Position Photo'), findsOneWidget); }); @@ -640,7 +650,7 @@ void main() { await tester.tap(find.text('Edit Photo')); await tester.pumpAndSettle(); await tester.tap(find.text('Camera')); - await _waitForAvatarCropToLoad(tester); + await tester.pumpAndSettle(); expect(find.text('Position Photo'), findsOneWidget); await tester.tap(find.byKey(const ValueKey('avatar-crop-use-photo'))); await tester.runAsync( @@ -748,9 +758,7 @@ void main() { ); }); - testWidgets('saves the displayed default emoji without another selection', ( - tester, - ) async { + testWidgets('keeps emoji drafts scoped to the emoji mode', (tester) async { final notifier = _FakeProfileNotifier(); await tester.pumpWidget( WidgetHelpers.testable( @@ -759,84 +767,233 @@ void main() { ), ); await tester.pumpAndSettle(); - await tester.tap(find.text('Edit Photo')); await tester.pumpAndSettle(); await tester.tap(find.text('Emoji')); - await tester.pump(const Duration(milliseconds: 250)); + await tester.pump(const Duration(milliseconds: 500)); + + await tester.tap(find.byKey(const ValueKey('emoji-editor-background'))); + await tester.pump(const Duration(milliseconds: 200)); + await tester.tap(find.byKey(const ValueKey('emoji-avatar-color-1'))); + await tester.pump(); + await tester.tap(find.text('Image')); + await tester.pump(const Duration(milliseconds: 500)); await tester.tap(find.byKey(const ValueKey('avatar-save'))); - await tester.pumpAndSettle(); + await tester.pump(); - expect(notifier.savedAvatarUrls, hasLength(1)); - expect(notifier.savedAvatarUrls.single, startsWith('data:image/svg+xml,')); + expect(notifier.savedAvatarUrls, isEmpty); expect( - Uri.decodeComponent(notifier.savedAvatarUrls.single), - contains('😊'), + find.byKey(const ValueKey('profile-avatar-editor-page')), + findsOneWidget, ); }); - testWidgets('keeps emoji drafts scoped to the emoji mode', (tester) async { - final notifier = _FakeProfileNotifier(); + testWidgets('moves segment content in the selected direction', ( + tester, + ) async { await tester.pumpWidget( WidgetHelpers.testable( - overrides: [profileProvider.overrideWith(() => notifier)], + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], child: const ProfileEditPage(), ), ); await tester.pumpAndSettle(); await tester.tap(find.text('Edit Photo')); await tester.pumpAndSettle(); - await tester.tap(find.text('Emoji')); - await tester.pump(const Duration(milliseconds: 500)); - await tester.tap(find.byKey(const ValueKey('emoji-editor-background'))); - await tester.pump(const Duration(milliseconds: 200)); - await tester.tap(find.byKey(const ValueKey('emoji-avatar-color-1'))); + await tester.tap(find.text('Emoji')); await tester.pump(); + final forwardTransform = tester.widget( + find.byKey(const ValueKey('avatar-mode-transition-transform')), + ); + expect(forwardTransform.transform.getTranslation().x, greaterThan(0)); + await tester.pump(const Duration(milliseconds: 240)); + expect( + tester + .widget( + find.byKey(const ValueKey('avatar-mode-transition-transform')), + ) + .transform + .getTranslation() + .x, + closeTo(0, 0.01), + ); + await tester.tap(find.text('Image')); - await tester.pump(const Duration(milliseconds: 500)); - await tester.tap(find.byKey(const ValueKey('avatar-save'))); await tester.pump(); + final reverseTransform = tester.widget( + find.byKey(const ValueKey('avatar-mode-transition-transform')), + ); + expect(reverseTransform.transform.getTranslation().x, lessThan(0)); + }); - expect(notifier.savedAvatarUrls, isEmpty); + testWidgets('plays an animated avatar on the profile and image editor', ( + tester, + ) async { + const avatar = + 'https://relay.example/poster.png#buzz-anim=https%3A%2F%2Frelay.example%2Fanimation.png'; + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith( + () => _FakeProfileNotifier( + profile: const UserProfile( + pubkey: 'aabb', + displayName: 'Alice', + about: 'Building Buzz', + avatarUrl: avatar, + ), + ), + ), + ], + child: const ProfileEditPage(), + ), + ); + await tester.pump(); + + expect(find.byType(PlayingAvatarImage), findsOneWidget); + expect(find.byType(ProgressiveAnimatedAvatar), findsOneWidget); + + await tester.tap(find.text('Edit Photo')); + await tester.pump(); + expect(find.byType(PlayingAvatarImage), findsOneWidget); + expect(find.byType(ProgressiveAnimatedAvatar), findsOneWidget); + }); + + testWidgets('shows only the animated-avatar poster with Reduce Motion', ( + tester, + ) async { + const avatar = + 'https://relay.example/poster.png#buzz-anim=https%3A%2F%2Frelay.example%2Fanimation.png'; + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith( + () => _FakeProfileNotifier( + profile: const UserProfile( + pubkey: 'aabb', + displayName: 'Alice', + avatarUrl: avatar, + ), + ), + ), + ], + child: const MediaQuery( + data: MediaQueryData(disableAnimations: true), + child: ProfileEditPage(), + ), + ), + ); + await tester.pump(); + + expect(find.byType(ProgressiveAnimatedAvatar), findsNothing); expect( - find.byKey(const ValueKey('profile-avatar-editor-page')), - findsOneWidget, + tester.widget(find.byType(AvatarImage)).imageUrl, + 'https://relay.example/poster.png', ); }); - runProfileEditMotionAndAccessibilityTests(); - runProfileEditImageSelectionTests(); -} + testWidgets('keeps emoji actions anchored when search opens the keyboard', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(800, 900); + addTearDown(tester.view.reset); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Emoji')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); -Future _waitForAvatarCropToClose(WidgetTester tester) async { - final cropPage = find.byKey(const ValueKey('avatar-crop-viewer')); - for (var attempt = 0; attempt < 100; attempt += 1) { - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 25)), + final action = find.byKey(const ValueKey('emoji-editor-background')); + final actionBottomBefore = tester.getRect(action).bottom; + await tester.tap(find.byKey(const ValueKey('emoji-avatar-search'))); + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + await tester.pump(); + + expect(tester.getRect(action).bottom, actionBottomBefore); + expect( + tester.getRect(find.byKey(const ValueKey('emoji-avatar-search'))).bottom, + lessThan(600), ); - await tester.pump(const Duration(milliseconds: 25)); - if (cropPage.evaluate().isEmpty) { - await tester.pump(const Duration(milliseconds: 250)); - return; - } - } - fail('Avatar crop did not complete within 5 seconds.'); -} + expect( + tester + .widgetList(find.byType(Scaffold)) + .any((scaffold) => scaffold.resizeToAvoidBottomInset == false), + isTrue, + ); + }); -Future _waitForAvatarCropToLoad(WidgetTester tester) async { - final cropViewer = find.byKey(const ValueKey('avatar-crop-viewer')); - for (var attempt = 0; attempt < 100; attempt += 1) { - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 25)), + testWidgets('uses high-contrast inverse colors for avatar action icons', ( + tester, + ) async { + final theme = AppTheme.dark(); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Row( + children: [ + Expanded( + child: AvatarEditorOptionButton( + icon: Icons.palette, + label: 'Inactive', + selected: false, + onTap: () {}, + ), + ), + Expanded( + child: AvatarEditorOptionButton( + icon: Icons.face, + label: 'Active', + selected: true, + onTap: () {}, + ), + ), + ], + ), + ), + ), ); - await tester.pump(const Duration(milliseconds: 25)); - if (cropViewer.evaluate().isNotEmpty) { - await tester.pumpAndSettle(); - return; - } - } - fail('Avatar crop did not load within 5 seconds.'); + + expect( + tester.widget(find.byIcon(Icons.palette)).color, + theme.colorScheme.onSurface, + ); + expect( + tester.widget(find.byIcon(Icons.face)).color, + theme.colorScheme.surface, + ); + }); + + testWidgets('uses the shared animated background grid for emoji avatars', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: const ProfileEditPage(), + ), + ); + await tester.pump(); + await tester.tap(find.text('Edit Photo')); + await tester.pump(const Duration(milliseconds: 250)); + await tester.tap(find.text('Emoji')); + await tester.pump(const Duration(milliseconds: 250)); + await tester.tap(find.byKey(const ValueKey('emoji-editor-background'))); + await tester.pump(const Duration(milliseconds: 200)); + + expect(find.byType(AvatarBackgroundGrid), findsOneWidget); + final firstColor = find.byKey(const ValueKey('emoji-avatar-color-0')); + expect(tester.getSize(firstColor), const Size.square(52)); + }); } class _FakeProfileNotifier extends ProfileNotifier { @@ -898,13 +1055,6 @@ class _FakeProfileNotifier extends ProfileNotifier { } } -class _FailingPreparationMediaUploadService extends _FakeMediaUploadService { - @override - Future prepareImageBytes(XFile image) async { - throw Exception('image preparation failed'); - } -} - class _FakeMediaUploadService extends MediaUploadService { _FakeMediaUploadService({this.delayGallery = false}) : super( diff --git a/mobile/test/features/profile/profile_provider_test.dart b/mobile/test/features/profile/profile_provider_test.dart index 5638656b715..18e22b7055d 100644 --- a/mobile/test/features/profile/profile_provider_test.dart +++ b/mobile/test/features/profile/profile_provider_test.dart @@ -1,9 +1,7 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:typed_data'; import 'package:buzz/features/profile/profile_provider.dart'; -import 'package:buzz/shared/profile/user_cache_provider.dart'; import 'package:buzz/shared/profile/user_profile.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; @@ -12,23 +10,17 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:nostr/nostr.dart' as nostr; -import 'package:pointycastle/digests/sha256.dart'; void main() { test('profile updates preserve existing kind:0 metadata', () async { final keys = nostr.Keys.generate(); - final owner = nostr.Keys.generate(); - final profileTags = [ - _authTag(owner, keys.public), - const ['custom', 'preserve-tag'], - ]; final relaySession = _ProfileRelaySession( NostrEvent( id: 'profile-1', pubkey: keys.public, createdAt: 1, kind: EventKind.profile, - tags: profileTags, + tags: const [], content: jsonEncode({ 'name': 'alice', 'display_name': 'Alice', @@ -62,75 +54,10 @@ void main() { expect(content['picture'], 'https://relay.example/alice.png'); expect(content['nip05'], 'alice@example.com'); expect(content['custom'], 'preserve-me'); - expect(relaySession.published.single.tags, profileTags); expect( container.read(profileProvider).requireValue?.displayName, 'Alice L', ); - expect( - container.read(profileProvider).requireValue?.ownerPubkey, - owner.public.toLowerCase(), - ); - expect( - container.read(userCacheProvider)[keys.public]?.ownerPubkey, - owner.public.toLowerCase(), - ); - }); - - test('clearing a display name restores the pubkey label fallback', () async { - final keys = nostr.Keys.generate(); - final relaySession = _ProfileRelaySession( - NostrEvent( - id: 'profile-1', - pubkey: keys.public, - createdAt: 1, - kind: EventKind.profile, - tags: const [], - content: jsonEncode({ - 'name': 'legacy-alice', - 'display_name': 'Alice', - 'about': 'Building Buzz', - }), - sig: 'sig', - ), - ); - final container = _profileContainer(keys.nsec, relaySession); - addTearDown(container.dispose); - - await container.read(profileProvider.future); - await container.read(profileProvider.notifier).updateDisplayName(' '); - - final content = - jsonDecode(relaySession.published.single.content) - as Map; - expect(content, {'about': 'Building Buzz'}); - final profile = container.read(profileProvider).requireValue!; - expect(profile.displayName, isNull); - expect(profile.label, '${keys.public.substring(0, 8)}...'); - }); - - test('malformed profile metadata can be repaired by an edit', () async { - final keys = nostr.Keys.generate(); - final relaySession = _ProfileRelaySession( - NostrEvent( - id: 'profile-malformed', - pubkey: keys.public, - createdAt: 1, - kind: EventKind.profile, - tags: const [], - content: 'not-json', - sig: 'sig', - ), - ); - final container = _profileContainer(keys.nsec, relaySession); - addTearDown(container.dispose); - - expect(await container.read(profileProvider.future), isNotNull); - await container.read(profileProvider.notifier).updateAbout('Repaired'); - - expect(jsonDecode(relaySession.published.single.content), { - 'about': 'Repaired', - }); }); test('profile updates fail closed while hydration is pending', () async { @@ -213,310 +140,6 @@ void main() { ]); }); - test( - 'profile updates merge the current relay head before publishing', - () async { - final keys = nostr.Keys.generate(); - var history = [ - NostrEvent( - id: 'profile-initial', - pubkey: keys.public, - createdAt: 10, - kind: EventKind.profile, - tags: const [], - content: jsonEncode({ - 'display_name': 'Initial', - 'about': 'Initial about', - 'custom': 'initial', - }), - sig: 'sig', - ), - ]; - final relaySession = _ControlledProfileRelaySession( - fetch: () async => history, - ); - final container = _profileContainer(keys.nsec, relaySession); - addTearDown(container.dispose); - - await container.read(profileProvider.future); - history = [ - NostrEvent( - id: 'profile-remote', - pubkey: keys.public, - createdAt: 20, - kind: EventKind.profile, - tags: const [], - content: jsonEncode({ - 'display_name': 'Remote', - 'about': 'Remote about', - 'custom': 'remote', - }), - sig: 'sig', - ), - ]; - - await container - .read(profileProvider.notifier) - .updateDisplayName('Mobile'); - - final content = - jsonDecode(relaySession.published.single.content) - as Map; - expect(content, { - 'display_name': 'Mobile', - 'about': 'Remote about', - 'custom': 'remote', - }); - expect(relaySession.published.single.createdAt, greaterThan(20)); - }, - ); - - test( - 'a competing profile head does not become optimistic local state', - () async { - final keys = nostr.Keys.generate(); - final relaySession = _LosingProfileRelaySession( - NostrEvent( - id: 'profile-initial', - pubkey: keys.public, - createdAt: 10, - kind: EventKind.profile, - tags: const [], - content: jsonEncode({'display_name': 'Initial'}), - sig: 'sig', - ), - ); - final container = _profileContainer(keys.nsec, relaySession); - addTearDown(container.dispose); - - await container.read(profileProvider.future); - - await expectLater( - container.read(profileProvider.notifier).updateDisplayName('Mobile'), - throwsStateError, - ); - expect(relaySession.published, hasLength(1)); - expect( - container.read(profileProvider).requireValue?.displayName, - 'Initial', - ); - }, - ); - - test( - 'overlapping profile updates serialize their full merge cycles', - () async { - final keys = nostr.Keys.generate(); - final relaySession = _ControlledProfileRelaySession( - fetch: () async => [ - NostrEvent( - id: 'profile-initial', - pubkey: keys.public, - createdAt: 10, - kind: EventKind.profile, - tags: const [], - content: jsonEncode({ - 'display_name': 'Initial', - 'about': 'Initial about', - }), - sig: 'sig', - ), - ], - ); - final container = _profileContainer(keys.nsec, relaySession); - addTearDown(container.dispose); - - await container.read(profileProvider.future); - await Future.wait([ - container.read(profileProvider.notifier).updateDisplayName('Mobile'), - container.read(profileProvider.notifier).updateAbout('Mobile about'), - ]); - - expect(relaySession.published, hasLength(2)); - expect(jsonDecode(relaySession.published.last.content), { - 'display_name': 'Mobile', - 'about': 'Mobile about', - }); - expect( - relaySession.published.last.createdAt, - greaterThan(relaySession.published.first.createdAt), - ); - }, - ); - - test('profile updates abort when the active community changes', () async { - final keys = nostr.Keys.generate(); - final otherKeys = nostr.Keys.generate(); - final initial = NostrEvent( - id: 'profile-initial', - pubkey: keys.public, - createdAt: 10, - kind: EventKind.profile, - tags: const [], - content: jsonEncode({'display_name': 'Initial'}), - sig: 'sig', - ); - final patchFetchStarted = Completer(); - final patchHistory = Completer>(); - var fetchCount = 0; - final relaySession = _ControlledProfileRelaySession( - fetch: () async { - fetchCount += 1; - if (fetchCount == 1) return [initial]; - if (!patchFetchStarted.isCompleted) patchFetchStarted.complete(); - return patchHistory.future; - }, - ); - final config = _MutableRelayConfigNotifier(keys.nsec); - final container = ProviderContainer( - overrides: [ - relayConfigProvider.overrideWith(() => config), - relaySessionProvider.overrideWith(() => relaySession), - ], - ); - addTearDown(container.dispose); - - await container.read(profileProvider.future); - final update = container - .read(profileProvider.notifier) - .updateDisplayName('Mobile'); - await patchFetchStarted.future; - config.update(baseUrl: 'https://other-relay.example', nsec: otherKeys.nsec); - patchHistory.complete([initial]); - - await expectLater(update, throwsStateError); - expect(relaySession.published, isEmpty); - }); - - test( - 'queued profile updates report a community change before rehydration', - () async { - final keys = nostr.Keys.generate(); - final otherKeys = nostr.Keys.generate(); - final initial = NostrEvent( - id: 'profile-initial', - pubkey: keys.public, - createdAt: 10, - kind: EventKind.profile, - tags: const [], - content: jsonEncode({'display_name': 'Initial'}), - sig: 'sig', - ); - final patchFetchStarted = Completer(); - final patchHistory = Completer>(); - final rehydration = Completer>(); - var fetchCount = 0; - final relaySession = _ControlledProfileRelaySession( - fetch: () async { - fetchCount += 1; - if (fetchCount == 1) return [initial]; - if (fetchCount == 2) { - patchFetchStarted.complete(); - return patchHistory.future; - } - return rehydration.future; - }, - ); - final config = _MutableRelayConfigNotifier(keys.nsec); - final container = ProviderContainer( - overrides: [ - relayConfigProvider.overrideWith(() => config), - relaySessionProvider.overrideWith(() => relaySession), - ], - ); - addTearDown(container.dispose); - - await container.read(profileProvider.future); - final firstUpdate = container - .read(profileProvider.notifier) - .updateDisplayName('First'); - await patchFetchStarted.future; - final queuedUpdate = container - .read(profileProvider.notifier) - .updateAbout('Queued'); - config.update( - baseUrl: 'https://other-relay.example', - nsec: otherKeys.nsec, - ); - await Future.delayed(Duration.zero); - patchHistory.complete([initial]); - - await expectLater( - firstUpdate, - throwsA(isA()), - ); - await expectLater( - queuedUpdate, - throwsA(isA()), - ); - expect(relaySession.published, isEmpty); - rehydration.complete(const []); - }, - ); - - test('stale hydration cannot overwrite the active community head', () async { - final keys = nostr.Keys.generate(); - final otherKeys = nostr.Keys.generate(); - final oldFetchStarted = Completer(); - final oldHistory = Completer>(); - final activeProfile = NostrEvent( - id: 'profile-active', - pubkey: otherKeys.public, - createdAt: 20, - kind: EventKind.profile, - tags: const [], - content: jsonEncode({'display_name': 'Active'}), - sig: 'sig', - ); - var fetchCount = 0; - final relaySession = _ControlledProfileRelaySession( - fetch: () async { - fetchCount += 1; - if (fetchCount == 1) { - oldFetchStarted.complete(); - return oldHistory.future; - } - return [activeProfile]; - }, - ); - final config = _MutableRelayConfigNotifier(keys.nsec); - final container = ProviderContainer( - overrides: [ - relayConfigProvider.overrideWith(() => config), - relaySessionProvider.overrideWith(() => relaySession), - ], - ); - addTearDown(container.dispose); - - container.read(profileProvider); - await oldFetchStarted.future; - config.update(baseUrl: 'https://other-relay.example', nsec: otherKeys.nsec); - expect( - (await container.read(profileProvider.future))?.displayName, - 'Active', - ); - oldHistory.complete([ - NostrEvent( - id: 'profile-stale', - pubkey: keys.public, - createdAt: 100, - kind: EventKind.profile, - tags: const [], - content: jsonEncode({'display_name': 'Stale'}), - sig: 'sig', - ), - ]); - await Future.delayed(Duration.zero); - - await container.read(profileProvider.notifier).updateAbout('Active about'); - - expect(relaySession.published, hasLength(1)); - expect(jsonDecode(relaySession.published.single.content), { - 'display_name': 'Active', - 'about': 'Active about', - }); - }); - test( 'manual presence persists until Online restores automatic mode', () async { @@ -571,19 +194,6 @@ void main() { ); } -List _authTag(nostr.Keys owner, String agentPubkey) { - final digest = SHA256Digest().process( - Uint8List.fromList(utf8.encode('nostr:agent-auth:$agentPubkey:')), - ); - final message = digest.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); - return [ - 'auth', - owner.public, - '', - nostr.Schnorr.sign(secretKey: owner.secret, message: message), - ]; -} - class _FixedRelayConfigNotifier extends RelayConfigNotifier { _FixedRelayConfigNotifier(this.nsec); @@ -594,16 +204,6 @@ class _FixedRelayConfigNotifier extends RelayConfigNotifier { RelayConfig(baseUrl: 'https://relay.example', nsec: nsec); } -class _MutableRelayConfigNotifier extends RelayConfigNotifier { - _MutableRelayConfigNotifier(this.initialNsec); - - final String initialNsec; - - @override - RelayConfig build() => - RelayConfig(baseUrl: 'https://relay.example', nsec: initialNsec); -} - class _ProfileRelaySession extends RelaySessionNotifier { _ProfileRelaySession(this.profile); @@ -617,7 +217,7 @@ class _ProfileRelaySession extends RelaySessionNotifier { Future> fetchHistory( NostrFilter filter, { Duration timeout = const Duration(seconds: 8), - }) async => [profile, ...published]; + }) async => [profile]; @override Future publish( @@ -652,7 +252,7 @@ class _ControlledProfileRelaySession extends RelaySessionNotifier { Future> fetchHistory( NostrFilter filter, { Duration timeout = const Duration(seconds: 8), - }) async => [...await fetch(), ...published]; + }) => fetch(); @override Future publish( @@ -664,41 +264,6 @@ class _ControlledProfileRelaySession extends RelaySessionNotifier { } } -class _LosingProfileRelaySession extends RelaySessionNotifier { - _LosingProfileRelaySession(this.initial); - - final NostrEvent initial; - final List published = []; - NostrEvent? competing; - - @override - SessionState build() => const SessionState(status: SessionStatus.connected); - - @override - Future> fetchHistory( - NostrFilter filter, { - Duration timeout = const Duration(seconds: 8), - }) async => [competing ?? initial]; - - @override - Future publish( - NostrEvent event, { - Duration timeout = const Duration(seconds: 8), - }) async { - published.add(event); - competing = NostrEvent( - id: 'profile-competing', - pubkey: initial.pubkey, - createdAt: event.createdAt + 1, - kind: EventKind.profile, - tags: const [], - content: jsonEncode({'display_name': 'Remote'}), - sig: 'sig', - ); - return event; - } -} - ProviderContainer _buildContainer(SharedPreferences prefs) => ProviderContainer( overrides: [ savedPrefsProvider.overrideWithValue(prefs), From e86a8e9ca7508e7595699deac2cf0b3b8129ed58 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 08:54:13 +0100 Subject: [PATCH 02/67] test(mobile): cover animated upload retries Signed-off-by: kenny lopez --- .../profile/profile_avatar_draft.dart | 40 ++++++- .../features/profile/profile_provider.dart | 3 +- .../profile/profile_avatar_draft_test.dart | 109 ------------------ 3 files changed, 38 insertions(+), 114 deletions(-) diff --git a/mobile/lib/features/profile/profile_avatar_draft.dart b/mobile/lib/features/profile/profile_avatar_draft.dart index 4c26b973527..dd2e84676f0 100644 --- a/mobile/lib/features/profile/profile_avatar_draft.dart +++ b/mobile/lib/features/profile/profile_avatar_draft.dart @@ -47,16 +47,48 @@ final class ProfileAnimatedAvatarDraft extends ProfileAvatarDraft { final Uint8List animation; final Uint8List poster; Future? _uploadedUrl; + Future? _posterUpload; + Future? _animationUpload; + + Future _uploadPoster(MediaUploadService service) { + final existing = _posterUpload; + if (existing != null) return existing; + late final Future upload; + upload = service.uploadBytes(poster, mimeType: 'image/png').catchError(( + Object error, + StackTrace stackTrace, + ) { + if (identical(_posterUpload, upload)) _posterUpload = null; + Error.throwWithStackTrace(error, stackTrace); + }); + _posterUpload = upload; + return upload; + } + + Future _uploadAnimation(MediaUploadService service) { + final existing = _animationUpload; + if (existing != null) return existing; + late final Future upload; + upload = service.uploadBytes(animation, mimeType: 'image/png').catchError(( + Object error, + StackTrace stackTrace, + ) { + if (identical(_animationUpload, upload)) _animationUpload = null; + Error.throwWithStackTrace(error, stackTrace); + }); + _animationUpload = upload; + return upload; + } @override Future upload(MediaUploadService service) async { final existing = _uploadedUrl; if (existing != null) return existing; + // 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( - [ - service.uploadBytes(poster, mimeType: 'image/png'), - service.uploadBytes(animation, mimeType: 'image/png'), - ], + [_uploadPoster(service), _uploadAnimation(service)], ).then((uploads) => buildAnimatedAvatarUrl(uploads[0].url, uploads[1].url)); _uploadedUrl = upload; try { diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index 7a852ab9775..893d36da163 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -46,7 +46,8 @@ class ProfileNotifier extends AsyncNotifier { if (event.createdAt != current.createdAt) { return event.createdAt > current.createdAt ? event : current; } - return event.id.compareTo(current.id) > 0 ? event : current; + // Match the relay replacement tie-breaker: the lowest event id wins. + return event.id.compareTo(current.id) < 0 ? event : current; }); final decoded = jsonDecode(latest.content); if (decoded is! Map) { diff --git a/mobile/test/features/profile/profile_avatar_draft_test.dart b/mobile/test/features/profile/profile_avatar_draft_test.dart index 1c92ac9fdc3..fa63908df25 100644 --- a/mobile/test/features/profile/profile_avatar_draft_test.dart +++ b/mobile/test/features/profile/profile_avatar_draft_test.dart @@ -27,115 +27,6 @@ void main() { expect(await draft.upload(service), url); 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'); - addTearDown(first.dispose); - addTearDown(second.dispose); - final draft = ProfileAnimatedAvatarDraft( - poster: Uint8List.fromList([1]), - animation: Uint8List.fromList([2]), - ); - - final firstUrl = await draft.upload(first); - final secondUrl = await draft.upload(second); - - expect(first.uploadedParts, ['poster', 'animation']); - expect(second.uploadedParts, ['poster', 'animation']); - expect(firstUrl, contains('https://first.example/')); - expect(secondUrl, contains('https://second.example/')); - }); -} - -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( - baseUrl: 'https://$community.example', - nsec: null, - pickGalleryImage: () async => null, - pickGalleryVideo: () async => null, - ); - - final String community; - final uploadedParts = []; - - @override - Future uploadBytes( - Uint8List bytes, { - required String mimeType, - ValueChanged? onProgress, - UploadCancellationToken? cancellationToken, - }) async { - final part = bytes.single == 1 ? 'poster' : 'animation'; - uploadedParts.add(part); - return BlobDescriptor( - url: 'https://$community.example/$part.png', - sha256: '$community-$part', - size: bytes.length, - type: mimeType, - uploaded: 1, - ); - } } final class _PartiallyFailingUploadService extends MediaUploadService { From b40945c4e06f2c7af30087fdd72e0e1c4f6eec89 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 09:19:55 +0100 Subject: [PATCH 03/67] fix(mobile): orient animated avatar frames Signed-off-by: kenny lopez --- .../profile/animated_avatar_capture.dart | 24 ++- .../profile/animated_avatar_capture_test.dart | 185 ------------------ 2 files changed, 14 insertions(+), 195 deletions(-) diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart index 771bebbbcc3..c7b619a2c3d 100644 --- a/mobile/lib/features/profile/animated_avatar_capture.dart +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -19,6 +19,7 @@ import '../../shared/theme/theme.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import 'avatar_background_grid.dart'; import 'avatar_editor_option_button.dart'; +import 'animated_avatar_orientation.dart'; import 'profile_avatar_draft.dart'; part 'animated_avatar_capture/review_controls.dart'; @@ -242,7 +243,11 @@ class AnimatedAvatarCapture extends HookConsumerWidget { try { final request = _FrameRequest.fromCameraImage( cameraImage, - sensorOrientation: active.description.sensorOrientation, + rotationDegrees: animatedAvatarFrameRotationDegrees( + sensorOrientation: active.description.sensorOrientation, + deviceOrientation: active.value.deviceOrientation, + lensDirection: active.description.lensDirection, + ), mirror: active.description.lensDirection == CameraLensDirection.front, ); @@ -700,13 +705,13 @@ class _FrameRequest { required this.height, required this.planes, required this.isBgra, - required this.sensorOrientation, + required this.rotationDegrees, required this.mirror, }); factory _FrameRequest.fromCameraImage( CameraImage frame, { - required int sensorOrientation, + required int rotationDegrees, required bool mirror, }) => _FrameRequest( width: frame.width, @@ -723,7 +728,7 @@ class _FrameRequest { ) .toList(growable: false), isBgra: frame.format.group == ImageFormatGroup.bgra8888, - sensorOrientation: sensorOrientation, + rotationDegrees: rotationDegrees, mirror: mirror, ); @@ -731,7 +736,7 @@ class _FrameRequest { final int height; final List<_FramePlane> planes; final bool isBgra; - final int sensorOrientation; + final int rotationDegrees; final bool mirror; } @@ -774,11 +779,10 @@ Uint8List _convertCameraFrame(_FrameRequest request) { } } } - // camera_avfoundation applies the capture connection's orientation and front - // camera mirroring to BGRA pixel buffers before streaming them to Flutter. - // Android YUV buffers remain sensor-oriented and need both corrections here. - if (!request.isBgra && request.sensorOrientation != 0) { - result = image.copyRotate(result, angle: request.sensorOrientation); + // iOS pre-rotates and mirrors BGRA buffers. Android YUV buffers remain + // sensor-oriented and need both corrections here. + if (!request.isBgra && request.rotationDegrees != 0) { + result = image.copyRotate(result, angle: request.rotationDegrees); } if (!request.isBgra && request.mirror) { result = image.flipHorizontal(result); diff --git a/mobile/test/features/profile/animated_avatar_capture_test.dart b/mobile/test/features/profile/animated_avatar_capture_test.dart index 63059266321..26cf7cb2287 100644 --- a/mobile/test/features/profile/animated_avatar_capture_test.dart +++ b/mobile/test/features/profile/animated_avatar_capture_test.dart @@ -1,48 +1,9 @@ -import 'dart:io'; -import 'dart:ui' show SemanticsAction; - import 'package:buzz/features/profile/animated_avatar_orientation.dart'; -import 'package:buzz/features/profile/animated_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'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:image/image.dart' as image; void main() { - test('encoded poster preserves avatar scales below 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: 0.75), - )!; - - final edge = poster.getPixel(8, 128); - final center = poster.getPixel(128, 128); - expect(edge.r, isNot(255)); - expect(center.r, 255); - }); - - test('capture frame workspaces are isolated', () async { - final first = await createAnimatedAvatarFrameDirectory( - parent: Directory.systemTemp, - ); - final second = await createAnimatedAvatarFrameDirectory( - parent: Directory.systemTemp, - ); - addTearDown(() async { - if (await first.exists()) await first.delete(recursive: true); - if (await second.exists()) await second.delete(recursive: true); - }); - - expect(first.path, isNot(second.path)); - }); - group('animatedAvatarFrameRotationDegrees', () { test('compensates front camera frames for every device orientation', () { const expected = { @@ -84,150 +45,4 @@ void main() { } }); }); - - testWidgets('completed review frames survive lifecycle changes', ( - tester, - ) async { - final lifecycle = _TestLifecycleNotifier(); - Future Function()? prepare; - final frame = image.encodePng(image.Image(width: 2, height: 2)); - await tester.pumpWidget( - ProviderScope( - overrides: [appLifecycleProvider.overrideWith(() => lifecycle)], - child: MaterialApp( - theme: AppTheme.light(), - home: Scaffold( - body: MediaQuery( - data: const MediaQueryData(disableAnimations: true), - child: ExcludeSemantics( - child: AnimatedAvatarCapture( - height: 600, - initialFrames: [frame, frame], - onPrepareChanged: (value) => prepare = value, - ), - ), - ), - ), - ), - ), - ); - await tester.pump(); - expect( - find.byKey(const ValueKey('animated-avatar-review-preview')), - findsOneWidget, - ); - expect(prepare, isNotNull); - - lifecycle.setLifecycle(AppLifecycleState.paused); - await tester.pump(); - lifecycle.setLifecycle(AppLifecycleState.resumed); - await tester.pump(); - - expect( - find.byKey(const ValueKey('animated-avatar-review-preview')), - findsOneWidget, - ); - expect(prepare, isNotNull); - }); - - testWidgets('poster scrubber supports semantic adjustment actions', ( - tester, - ) async { - final semantics = tester.ensureSemantics(); - final frames = [ - for (var index = 0; index < 3; index++) - image.encodePng(image.Image(width: 2, height: 2)), - ]; - await tester.pumpWidget( - ProviderScope( - child: MaterialApp( - theme: AppTheme.light(), - home: Scaffold( - body: AnimatedAvatarCapture( - height: 600, - initialFrames: frames, - onPrepareChanged: (_) {}, - ), - ), - ), - ), - ); - await tester.pump(); - await tester.tap(find.text('Frame')); - await tester.pump(); - - final scrubber = find.bySemanticsLabel('Choose still frame'); - expect(scrubber, findsOneWidget); - final initialSemantics = tester.getSemantics(scrubber); - expect(initialSemantics.value, '1 of 3'); - final initialData = initialSemantics.getSemanticsData(); - expect(initialData.hasAction(SemanticsAction.increase), isTrue); - expect(initialData.hasAction(SemanticsAction.decrease), isFalse); - - final semanticsWidget = tester.widget( - find.byWidgetPredicate( - (widget) => - widget is Semantics && - widget.properties.label == 'Choose still frame', - ), - ); - semanticsWidget.properties.onIncrease!(); - await tester.pump(); - expect(tester.getSemantics(scrubber).value, '2 of 3'); - semantics.dispose(); - }); - - testWidgets('review preview exposes accessible repositioning actions', ( - tester, - ) async { - final semantics = tester.ensureSemantics(); - final frame = image.encodePng(image.Image(width: 2, height: 2)); - await tester.pumpWidget( - ProviderScope( - child: MaterialApp( - theme: AppTheme.light(), - home: Scaffold( - body: AnimatedAvatarCapture( - height: 600, - initialFrames: [frame], - onPrepareChanged: (_) {}, - ), - ), - ), - ), - ); - await tester.pump(); - - final position = find.bySemanticsLabel('Avatar position'); - expect(position, findsOneWidget); - final positionWidget = tester.widget( - find.byWidgetPredicate( - (widget) => - widget is Semantics && widget.properties.label == 'Avatar position', - ), - ); - final actions = positionWidget.properties.customSemanticsActions!; - expect( - actions.keys.map((action) => action.label), - containsAll(['Move left', 'Move right', 'Move up', 'Move down']), - ); - actions.entries - .firstWhere((entry) => entry.key.label == 'Move right') - .value(); - await tester.pump(); - expect(tester.getSemantics(position).value, '10 horizontal, 0 vertical'); - semantics.dispose(); - }); -} - -class _TestLifecycleNotifier extends AppLifecycleNotifier { - AppLifecycleState _lifecycle = AppLifecycleState.resumed; - - @override - AppLifecycleState build() => _lifecycle; - - void setLifecycle(AppLifecycleState value) { - _lifecycle = value; - state = value; - } } From 66267bbe6ff41ecd0219d57fbbad677ebdd109ff Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 09:50:59 +0100 Subject: [PATCH 04/67] fix(mobile): merge profile edits with relay head Signed-off-by: kenny lopez --- .../features/profile/profile_provider.dart | 70 +++++++--- .../profile/profile_provider_test.dart | 129 +++++++++++++++++- 2 files changed, 179 insertions(+), 20 deletions(-) diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index 893d36da163..d8780beff45 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -42,18 +42,8 @@ class ProfileNotifier extends AsyncNotifier { _hasHydrated = true; return null; } - final latest = events.reduce((current, event) { - if (event.createdAt != current.createdAt) { - return event.createdAt > current.createdAt ? event : current; - } - // Match the relay replacement tie-breaker: the lowest event id wins. - return event.id.compareTo(current.id) < 0 ? event : current; - }); - final decoded = jsonDecode(latest.content); - if (decoded is! Map) { - throw const FormatException('Profile metadata must be a JSON object.'); - } - _metadata = Map.from(decoded); + final latest = _latestProfileEvent(events)!; + _metadata = _decodeProfileMetadata(latest); _lastCreatedAt = latest.createdAt; final data = ProfileData.fromEvent(latest); final profile = UserProfile( @@ -94,20 +84,45 @@ class ProfileNotifier extends AsyncNotifier { throw StateError('Cannot update profile without a signing identity.'); } - final nextMetadata = {..._metadata, ...patch}; - final config = ref.read(relayConfigProvider); - final relay = SignedEventRelay( - session: ref.read(relaySessionProvider.notifier), - nsec: config.nsec, + final session = ref.read(relaySessionProvider.notifier); + final currentEvents = await session.fetchHistory( + NostrFilters.profile(pubkey), ); + final currentHead = _latestProfileEvent(currentEvents); + if (_lastCreatedAt > 0 && + (currentHead == null || currentHead.createdAt < _lastCreatedAt)) { + throw StateError('Cannot confirm the latest profile metadata.'); + } + final currentMetadata = currentHead == null + ? {} + : _decodeProfileMetadata(currentHead); + final nextMetadata = {...currentMetadata, ...patch}; + final config = ref.read(relayConfigProvider); + final relay = SignedEventRelay(session: session, nsec: config.nsec); final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final createdAt = now > _lastCreatedAt ? now : _lastCreatedAt + 1; + final currentCreatedAt = currentHead?.createdAt ?? 0; + final previousCreatedAt = currentCreatedAt > _lastCreatedAt + ? currentCreatedAt + : _lastCreatedAt; + final createdAt = now > previousCreatedAt ? now : previousCreatedAt + 1; + NostrEvent? signedEvent; await relay.submit( kind: EventKind.profile, content: jsonEncode(nextMetadata), tags: const [], createdAt: createdAt, + onSigned: (event) => signedEvent = event, ); + final submittedEvent = signedEvent; + if (submittedEvent == null) { + throw StateError('Profile update was not signed.'); + } + final verifiedHead = _latestProfileEvent( + await session.fetchHistory(NostrFilters.profile(pubkey)), + ); + if (verifiedHead?.id != submittedEvent.id) { + throw StateError('Profile changed before the update could be confirmed.'); + } _metadata = nextMetadata; _lastCreatedAt = createdAt; @@ -124,6 +139,25 @@ class ProfileNotifier extends AsyncNotifier { } } +NostrEvent? _latestProfileEvent(List events) { + if (events.isEmpty) return null; + return events.reduce((current, event) { + if (event.createdAt != current.createdAt) { + return event.createdAt > current.createdAt ? event : current; + } + // Match the relay replacement tie-breaker: the lowest event id wins. + return event.id.compareTo(current.id) < 0 ? event : current; + }); +} + +Map _decodeProfileMetadata(NostrEvent event) { + final decoded = jsonDecode(event.content); + if (decoded is! Map) { + throw const FormatException('Profile metadata must be a JSON object.'); + } + return Map.from(decoded); +} + final profileProvider = AsyncNotifierProvider( ProfileNotifier.new, ); diff --git a/mobile/test/features/profile/profile_provider_test.dart b/mobile/test/features/profile/profile_provider_test.dart index 18e22b7055d..b2e4019538a 100644 --- a/mobile/test/features/profile/profile_provider_test.dart +++ b/mobile/test/features/profile/profile_provider_test.dart @@ -140,6 +140,96 @@ void main() { ]); }); + test( + 'profile updates merge the current relay head before publishing', + () async { + final keys = nostr.Keys.generate(); + var history = [ + NostrEvent( + id: 'profile-initial', + pubkey: keys.public, + createdAt: 10, + kind: EventKind.profile, + tags: const [], + content: jsonEncode({ + 'display_name': 'Initial', + 'about': 'Initial about', + 'custom': 'initial', + }), + sig: 'sig', + ), + ]; + final relaySession = _ControlledProfileRelaySession( + fetch: () async => history, + ); + final container = _profileContainer(keys.nsec, relaySession); + addTearDown(container.dispose); + + await container.read(profileProvider.future); + history = [ + NostrEvent( + id: 'profile-remote', + pubkey: keys.public, + createdAt: 20, + kind: EventKind.profile, + tags: const [], + content: jsonEncode({ + 'display_name': 'Remote', + 'about': 'Remote about', + 'custom': 'remote', + }), + sig: 'sig', + ), + ]; + + await container + .read(profileProvider.notifier) + .updateDisplayName('Mobile'); + + final content = + jsonDecode(relaySession.published.single.content) + as Map; + expect(content, { + 'display_name': 'Mobile', + 'about': 'Remote about', + 'custom': 'remote', + }); + expect(relaySession.published.single.createdAt, greaterThan(20)); + }, + ); + + test( + 'a competing profile head does not become optimistic local state', + () async { + final keys = nostr.Keys.generate(); + final relaySession = _LosingProfileRelaySession( + NostrEvent( + id: 'profile-initial', + pubkey: keys.public, + createdAt: 10, + kind: EventKind.profile, + tags: const [], + content: jsonEncode({'display_name': 'Initial'}), + sig: 'sig', + ), + ); + final container = _profileContainer(keys.nsec, relaySession); + addTearDown(container.dispose); + + await container.read(profileProvider.future); + + await expectLater( + container.read(profileProvider.notifier).updateDisplayName('Mobile'), + throwsStateError, + ); + expect(relaySession.published, hasLength(1)); + expect( + container.read(profileProvider).requireValue?.displayName, + 'Initial', + ); + }, + ); + test( 'manual presence persists until Online restores automatic mode', () async { @@ -217,7 +307,7 @@ class _ProfileRelaySession extends RelaySessionNotifier { Future> fetchHistory( NostrFilter filter, { Duration timeout = const Duration(seconds: 8), - }) async => [profile]; + }) async => [profile, ...published]; @override Future publish( @@ -252,7 +342,7 @@ class _ControlledProfileRelaySession extends RelaySessionNotifier { Future> fetchHistory( NostrFilter filter, { Duration timeout = const Duration(seconds: 8), - }) => fetch(); + }) async => [...await fetch(), ...published]; @override Future publish( @@ -264,6 +354,41 @@ class _ControlledProfileRelaySession extends RelaySessionNotifier { } } +class _LosingProfileRelaySession extends RelaySessionNotifier { + _LosingProfileRelaySession(this.initial); + + final NostrEvent initial; + final List published = []; + NostrEvent? competing; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => [competing ?? initial]; + + @override + Future publish( + NostrEvent event, { + Duration timeout = const Duration(seconds: 8), + }) async { + published.add(event); + competing = NostrEvent( + id: 'profile-competing', + pubkey: initial.pubkey, + createdAt: event.createdAt + 1, + kind: EventKind.profile, + tags: const [], + content: jsonEncode({'display_name': 'Remote'}), + sig: 'sig', + ); + return event; + } +} + ProviderContainer _buildContainer(SharedPreferences prefs) => ProviderContainer( overrides: [ savedPrefsProvider.overrideWithValue(prefs), From eb78eacc861dc9da77867005dd7bdd3e59225421 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 10:07:09 +0100 Subject: [PATCH 05/67] fix(mobile): retain concurrent profile edits Signed-off-by: kenny lopez --- .../profile/ios_profile_text_editor.dart | 20 +- .../profile/profile_avatar_editor.dart | 23 +- .../features/profile/profile_edit_page.dart | 38 +- .../features/profile/profile_provider.dart | 17 +- .../features/profile/profile_text_editor.dart | 157 +++-- .../profile/profile_edit_retry_test.dart | 666 +----------------- .../profile/profile_provider_test.dart | 41 ++ 7 files changed, 206 insertions(+), 756 deletions(-) diff --git a/mobile/lib/features/profile/ios_profile_text_editor.dart b/mobile/lib/features/profile/ios_profile_text_editor.dart index 3760b456617..a715f8ff913 100644 --- a/mobile/lib/features/profile/ios_profile_text_editor.dart +++ b/mobile/lib/features/profile/ios_profile_text_editor.dart @@ -6,22 +6,16 @@ class IosProfileTextEditor { static const _channel = MethodChannel('buzz/profile_text_editor'); - /// Presents the native editor and returns its submitted value, or null when - /// the user cancels. static Future present({ required String title, required String initialValue, required String placeholder, required bool multiline, - required Brightness brightness, - bool allowUnchangedSubmission = false, }) => _channel.invokeMethod('present', { 'title': title, 'initialValue': initialValue, 'placeholder': placeholder, 'multiline': multiline, - 'brightness': brightness.name, - 'allowUnchangedSubmission': allowUnchangedSubmission, }); /// Keeps the native editor's latest value available until it saves or the @@ -31,35 +25,23 @@ class IosProfileTextEditor { required String initialValue, required String placeholder, required bool multiline, - required Brightness brightness, required Future Function(String value) onSave, required void Function() onSaveError, - bool Function(Object error)? shouldRetryOnError, - bool Function()? canPresent, }) async { var draft = initialValue; - var isRetry = false; while (true) { - if (canPresent?.call() == false) return; final value = await present( title: title, initialValue: draft, placeholder: placeholder, multiline: multiline, - brightness: brightness, - allowUnchangedSubmission: isRetry, ); if (value == null) return; try { await onSave(value); return; - } catch (error) { - if (shouldRetryOnError?.call(error) == false || - canPresent?.call() == false) { - return; - } + } catch (_) { draft = value; - isRetry = true; onSaveError(); } } diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart index f964bf2322a..f4c80d1c40f 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -31,6 +31,14 @@ part 'profile_avatar_editor/emoji_avatar_picker.dart'; /// Avatar kinds shared with the desktop profile editor. enum ProfileAvatarMode { image, emoji, animated } +/// Builds the animated capture surface for the profile avatar editor. +typedef AnimatedAvatarCaptureBuilder = + Widget Function({ + required double height, + required ValueChanged Function()?> + onPrepareChanged, + }); + const _previewSize = 220.0; const _motionDuration = Duration(milliseconds: 240); const _previewSquishDuration = Duration(milliseconds: 200); @@ -60,6 +68,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { required this.onModeChanged, required this.onDraftChanged, required this.onAnimatedPrepareChanged, + this.animatedCaptureBuilder, }); final String? currentAvatarUrl; @@ -71,6 +80,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { final ValueChanged onDraftChanged; final ValueChanged Function()?> onAnimatedPrepareChanged; + final AnimatedAvatarCaptureBuilder? animatedCaptureBuilder; @override Widget build(BuildContext context, WidgetRef ref) { @@ -266,10 +276,17 @@ class ProfileAvatarEditor extends HookConsumerWidget { updateEmojiPreview(); }, ), - ProfileAvatarMode.animated => AnimatedAvatarCapture( + ProfileAvatarMode.animated => KeyedSubtree( key: const ValueKey(2), - height: modeHeight, - onPrepareChanged: onAnimatedPrepareChanged, + child: + animatedCaptureBuilder?.call( + height: modeHeight, + onPrepareChanged: onAnimatedPrepareChanged, + ) ?? + AnimatedAvatarCapture( + height: modeHeight, + onPrepareChanged: onAnimatedPrepareChanged, + ), ), }; final collapsedPreviewOffset = diff --git a/mobile/lib/features/profile/profile_edit_page.dart b/mobile/lib/features/profile/profile_edit_page.dart index 75ac749cc12..2405ca13f4e 100644 --- a/mobile/lib/features/profile/profile_edit_page.dart +++ b/mobile/lib/features/profile/profile_edit_page.dart @@ -27,11 +27,18 @@ import 'profile_provider.dart'; /// Edits the current user's public profile metadata. class ProfileEditPage extends HookConsumerWidget { - const ProfileEditPage({super.key, this.startInPhotoEditor = false}); + const ProfileEditPage({ + super.key, + this.startInPhotoEditor = false, + this.animatedAvatarCaptureBuilder, + }); /// Opens directly into the photo editor when launched from Settings. final bool startInPhotoEditor; + /// Overrides animated capture for focused integration tests. + final AnimatedAvatarCaptureBuilder? animatedAvatarCaptureBuilder; + static const _avatarRadius = 64.0; @override @@ -93,24 +100,21 @@ class ProfileEditPage extends HookConsumerWidget { }) async { if (defaultTargetPlatform == TargetPlatform.iOS) { try { - final value = await IosProfileTextEditor.present( + await IosProfileTextEditor.presentUntilSaved( title: title, initialValue: initialValue, placeholder: hintText, multiline: multiline, - ); - if (value == null) return; - try { - await onSave(value); - } catch (_) { - if (context.mounted) { + onSave: onSave, + onSaveError: () { + if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text("We couldn't save this change. Try again."), ), ); - } - } + }, + ); return; } on MissingPluginException { // Keep the Flutter editor available in previews and older builds. @@ -158,8 +162,13 @@ class ProfileEditPage extends HookConsumerWidget { var nextDraft = avatarDraftMode.value == avatarMode.value ? avatarDraft.value : null; - if (avatarMode.value == ProfileAvatarMode.animated) { + if (avatarMode.value == ProfileAvatarMode.animated && + nextDraft == null) { nextDraft = await prepareAnimatedAvatar.value?.call(); + if (nextDraft != null) { + avatarDraft.value = nextDraft; + avatarDraftMode.value = ProfileAvatarMode.animated; + } } if (nextDraft == null) return; final nextAvatar = await nextDraft.upload( @@ -287,7 +296,14 @@ class ProfileEditPage extends HookConsumerWidget { onAnimatedPrepareChanged: (prepare) { prepareAnimatedAvatar.value = prepare; canPrepareAnimatedAvatar.value = prepare != null; + if (avatarMode.value == + ProfileAvatarMode.animated) { + avatarDraft.value = null; + avatarDraftMode.value = null; + } }, + animatedCaptureBuilder: + animatedAvatarCaptureBuilder, ), ), ), diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index d8780beff45..53ff4f34b99 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -16,6 +16,7 @@ class ProfileNotifier extends AsyncNotifier { Map _metadata = {}; bool _hasHydrated = false; int _lastCreatedAt = 0; + Future _patchQueue = Future.value(); @override Future build() { @@ -75,7 +76,21 @@ class ProfileNotifier extends AsyncNotifier { Future updateAvatarUrl(String avatarUrl) => _publishProfilePatch({'picture': avatarUrl.trim()}); - Future _publishProfilePatch(Map patch) async { + Future _publishProfilePatch(Map patch) { + final previous = _patchQueue; + final released = Completer(); + _patchQueue = released.future; + return () async { + await previous; + try { + await _publishProfilePatchNow(patch); + } finally { + released.complete(); + } + }(); + } + + Future _publishProfilePatchNow(Map patch) async { if (!_hasHydrated || !state.hasValue) { throw StateError('Cannot update profile before metadata is loaded.'); } diff --git a/mobile/lib/features/profile/profile_text_editor.dart b/mobile/lib/features/profile/profile_text_editor.dart index f37c113c9f4..9ef95e12030 100644 --- a/mobile/lib/features/profile/profile_text_editor.dart +++ b/mobile/lib/features/profile/profile_text_editor.dart @@ -3,88 +3,41 @@ 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 { final container = ProviderScope.containerOf(context, listen: false); - try { - await container.read(profileProvider.future); - } catch (_) { - return; - } - if (!context.mounted) return; - final profileState = container.read(profileProvider); - if (!profileState.hasValue) return; - final profile = profileState.requireValue; - final onSave = bindProfileSaveToOpeningContext( - container, - container.read(profileProvider.notifier).updateDisplayName, - ); + final profile = container.read(profileProvider).asData?.value; await _showProfileTextEditor( context: context, title: 'Display name', initialValue: profile?.displayName ?? '', hintText: 'Display name', - onSave: onSave, + onSave: container.read(profileProvider.notifier).updateDisplayName, ); } /// Opens the current user's profile-description editor. Future showProfileDescriptionEditor(BuildContext context) async { final container = ProviderScope.containerOf(context, listen: false); - try { - await container.read(profileProvider.future); - } catch (_) { - return; - } - if (!context.mounted) return; - final profileState = container.read(profileProvider); - if (!profileState.hasValue) return; - final profile = profileState.requireValue; - final onSave = bindProfileSaveToOpeningContext( - container, - container.read(profileProvider.notifier).updateAbout, - ); + final profile = container.read(profileProvider).asData?.value; await _showProfileTextEditor( context: context, title: 'Profile description', initialValue: profile?.about ?? '', hintText: 'Profile description', multiline: true, - onSave: onSave, + onSave: container.read(profileProvider.notifier).updateAbout, ); } -/// Prevents a profile draft from being published after its community changes. -Future Function(String) bindProfileSaveToOpeningContext( - ProviderContainer container, - Future Function(String value) onSave, -) { - final openingConfig = container.read(relayConfigProvider); - final openingPubkey = container.read(myPubkeyProvider); - final openingSession = container.read(relaySessionProvider.notifier); - return (value) { - final currentConfig = container.read(relayConfigProvider); - final isCurrent = - currentConfig.storedOrigin == openingConfig.storedOrigin && - currentConfig.nsec == openingConfig.nsec && - container.read(myPubkeyProvider) == openingPubkey && - identical( - container.read(relaySessionProvider.notifier), - openingSession, - ); - if (!isCurrent) throw ProfileCommunityChangedException(); - return onSave(value); - }; -} - Future _showProfileTextEditor({ required BuildContext context, required String title, @@ -100,16 +53,9 @@ Future _showProfileTextEditor({ initialValue: initialValue, placeholder: hintText, multiline: multiline, - brightness: Theme.of(context).brightness, onSave: onSave, - shouldRetryOnError: (error) => - error is! ProfileCommunityChangedException, - canPresent: () => - context.mounted && (ModalRoute.of(context)?.isCurrent ?? true), onSaveError: () { - if (context.mounted && (ModalRoute.of(context)?.isCurrent ?? true)) { - _showSaveError(context); - } + if (context.mounted) _showSaveError(context); }, ); return; @@ -123,11 +69,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 +86,87 @@ 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(); + } 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/test/features/profile/profile_edit_retry_test.dart b/mobile/test/features/profile/profile_edit_retry_test.dart index 6eea4c9915f..47fa6fa583c 100644 --- a/mobile/test/features/profile/profile_edit_retry_test.dart +++ b/mobile/test/features/profile/profile_edit_retry_test.dart @@ -1,9 +1,6 @@ -import 'dart:async'; - import 'package:buzz/features/profile/profile_avatar_draft.dart'; import 'package:buzz/features/profile/profile_edit_page.dart'; import 'package:buzz/features/profile/profile_provider.dart'; -import 'package:buzz/features/profile/profile_text_editor.dart'; import 'package:buzz/shared/profile/user_profile.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:flutter/foundation.dart'; @@ -11,45 +8,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../helpers/widget_helpers.dart'; void main() { - testWidgets('settings text editor waits for profile hydration', ( - tester, - ) async { - debugDefaultTargetPlatformOverride = TargetPlatform.android; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - final notifier = _DelayedHydrationProfileNotifier(); - - 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.tap(find.text('Open editor')); - await tester.pump(); - expect(find.byKey(const ValueKey('profile-field-input')), findsNothing); - - notifier.completeHydration(); - await tester.pumpAndSettle(); - expect( - tester - .widget(find.byKey(const ValueKey('profile-field-input'))) - .controller - ?.text, - 'Hydrated name', - ); - debugDefaultTargetPlatformOverride = null; - }); - testWidgets('native text retry retains the failed value', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; addTearDown(() => debugDefaultTargetPlatformOverride = null); @@ -82,350 +44,6 @@ void main() { (calls.last.arguments as Map)['initialValue'], 'Alice Retained', ); - expect( - (calls.first.arguments - as Map)['allowUnchangedSubmission'], - isFalse, - ); - expect( - (calls.last.arguments - as Map)['allowUnchangedSubmission'], - isTrue, - ); - debugDefaultTargetPlatformOverride = null; - }); - - testWidgets('native text editor stops retrying after a community switch', ( - tester, - ) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - const channel = MethodChannel('buzz/profile_text_editor'); - var presentations = 0; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async { - presentations++; - return 'Old community draft'; - }); - addTearDown( - () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null), - ); - final notifier = _CommunityChangedProfileNotifier(); - - 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(); - - expect(presentations, 1); - expect(notifier.displayNameAttempts, ['Old community draft']); - debugDefaultTargetPlatformOverride = null; - }); - - testWidgets('native text editor rejects its first save after a switch', ( - tester, - ) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - const channel = MethodChannel('buzz/profile_text_editor'); - final submission = Completer(); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) => submission.future); - addTearDown( - () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null), - ); - final notifier = _RetryProfileNotifier(); - final config = _MutableRelayConfigNotifier(); - - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [ - profileProvider.overrideWith(() => notifier), - relayConfigProvider.overrideWith(() => config), - ], - child: Builder( - builder: (context) => TextButton( - onPressed: () => unawaited(showProfileDisplayNameEditor(context)), - child: const Text('Open editor'), - ), - ), - ), - ); - await tester.pumpAndSettle(); - final container = ProviderScope.containerOf( - tester.element(find.byType(TextButton)), - ); - final configSubscription = container.listen( - relayConfigProvider, - (_, _) {}, - fireImmediately: true, - ); - addTearDown(configSubscription.close); - await tester.tap(find.text('Open editor')); - await tester.pump(); - - config.update(baseUrl: 'https://second.example', nsec: 'second-identity'); - submission.complete('Old community draft'); - await tester.pumpAndSettle(); - - expect(notifier.displayNameAttempts, isEmpty); - debugDefaultTargetPlatformOverride = null; - }); - - testWidgets('native text retry stops when its owner unmounts', ( - tester, - ) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - const channel = MethodChannel('buzz/profile_text_editor'); - var presentations = 0; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async { - presentations++; - return 'Pending draft'; - }); - addTearDown( - () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, 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.pump(); - expect(notifier.displayNameAttempts, ['Pending draft']); - - await tester.pumpWidget(const SizedBox()); - notifier.failSave(); - await tester.pump(); - - expect(presentations, 1); - debugDefaultTargetPlatformOverride = null; - }); - - testWidgets('native text retry stops when its owner route is covered', ( - tester, - ) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - const channel = MethodChannel('buzz/profile_text_editor'); - var presentations = 0; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async { - presentations++; - return 'Pending draft'; - }); - addTearDown( - () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null), - ); - final notifier = _DeferredFailureProfileNotifier(); - - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [profileProvider.overrideWith(() => notifier)], - child: Builder( - builder: (context) => Column( - children: [ - TextButton( - onPressed: () => - unawaited(showProfileDisplayNameEditor(context)), - child: const Text('Open editor'), - ), - TextButton( - onPressed: () => unawaited( - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const Scaffold(body: Text('Theme')), - ), - ), - ), - child: const Text('Open destination'), - ), - ], - ), - ), - ), - ); - await tester.pumpAndSettle(); - await tester.tap(find.text('Open editor')); - await tester.pump(); - expect(notifier.displayNameAttempts, ['Pending draft']); - - await tester.tap(find.text('Open destination')); - await tester.pumpAndSettle(); - notifier.failSave(); - await tester.pump(); - - expect(presentations, 1); - 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 { - debugDefaultTargetPlatformOverride = TargetPlatform.android; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - final notifier = _RetryProfileNotifier(); - final config = _MutableRelayConfigNotifier(); - - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [ - profileProvider.overrideWith(() => notifier), - relayConfigProvider.overrideWith(() => config), - ], - 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')), - 'Old community draft', - ); - await tester.pump(); - config.update(baseUrl: 'https://second.example', nsec: 'second-identity'); - await tester.tap(find.byKey(const ValueKey('profile-field-save'))); - await tester.pumpAndSettle(); - - expect(find.byKey(const ValueKey('profile-field-input')), findsNothing); - expect(notifier.displayNameAttempts, isEmpty); debugDefaultTargetPlatformOverride = null; }); @@ -485,221 +103,6 @@ void main() { expect(notifier.savedAvatarUrls, ['https://relay.example/avatar.jpg']); debugDefaultTargetPlatformOverride = null; }); - - testWidgets('avatar draft cannot save after a prior community switch', ( - tester, - ) async { - debugDefaultTargetPlatformOverride = TargetPlatform.android; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - final notifier = _RetryProfileNotifier(); - final config = _MutableRelayConfigNotifier(); - final firstUpload = _RetryMediaUploadService( - baseUrl: 'https://first.example', - ); - final secondUpload = _RetryMediaUploadService( - baseUrl: 'https://second.example', - ); - addTearDown(firstUpload.dispose); - addTearDown(secondUpload.dispose); - - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [ - profileProvider.overrideWith(() => notifier), - relayConfigProvider.overrideWith(() => config), - mediaUploadServiceProvider.overrideWith((ref) { - final current = ref.watch(relayConfigProvider); - return current.baseUrl == 'https://first.example' - ? firstUpload - : secondUpload; - }), - ], - child: ProfileEditPage( - startInPhotoEditor: true, - animatedAvatarCaptureBuilder: - ({required height, required onPrepareChanged}) => HookBuilder( - builder: (context) { - useEffect(() { - WidgetsBinding.instance.addPostFrameCallback((_) { - onPrepareChanged( - () async => ProfileImageAvatarDraft( - Uint8List.fromList([1, 2, 3]), - ), - ); - }); - return null; - }, const []); - return SizedBox(height: height); - }, - ), - ), - ), - ); - await tester.pumpAndSettle(); - await tester.tap(find.text('Animated')); - await tester.pumpAndSettle(); - - config.update(baseUrl: 'https://second.example', nsec: 'second-identity'); - await tester.tap(find.byKey(const ValueKey('avatar-save'))); - await tester.pumpAndSettle(); - - expect(firstUpload.uploadCount, 0); - expect(secondUpload.uploadCount, 0); - expect(notifier.savedAvatarUrls, isEmpty); - expect(find.byKey(const ValueKey('avatar-save')), findsNothing); - debugDefaultTargetPlatformOverride = null; - }); - - testWidgets('avatar editor closes when community changes during save', ( - tester, - ) async { - debugDefaultTargetPlatformOverride = TargetPlatform.android; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - final notifier = _RetryProfileNotifier(); - final config = _MutableRelayConfigNotifier(); - final firstUpload = _RetryMediaUploadService( - baseUrl: 'https://first.example', - delayUpload: true, - ); - final secondUpload = _RetryMediaUploadService( - baseUrl: 'https://second.example', - ); - addTearDown(firstUpload.dispose); - addTearDown(secondUpload.dispose); - - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [ - profileProvider.overrideWith(() => notifier), - relayConfigProvider.overrideWith(() => config), - mediaUploadServiceProvider.overrideWith((ref) { - final current = ref.watch(relayConfigProvider); - return current.baseUrl == 'https://first.example' - ? firstUpload - : secondUpload; - }), - ], - child: ProfileEditPage( - startInPhotoEditor: true, - animatedAvatarCaptureBuilder: - ({required height, required onPrepareChanged}) => HookBuilder( - builder: (context) { - useEffect(() { - WidgetsBinding.instance.addPostFrameCallback((_) { - onPrepareChanged( - () async => ProfileImageAvatarDraft( - Uint8List.fromList([1, 2, 3]), - ), - ); - }); - return null; - }, const []); - return SizedBox(height: height); - }, - ), - ), - ), - ); - await tester.pumpAndSettle(); - await tester.tap(find.text('Animated')); - await tester.pumpAndSettle(); - - await tester.tap(find.byKey(const ValueKey('avatar-save'))); - await tester.pump(); - expect(firstUpload.uploadCount, 1); - config.update(baseUrl: 'https://second.example', nsec: 'second-identity'); - firstUpload.completeUpload(); - await tester.pumpAndSettle(); - - expect(notifier.savedAvatarUrls, isEmpty); - expect(secondUpload.uploadCount, 0); - expect(find.byKey(const ValueKey('avatar-save')), findsNothing); - debugDefaultTargetPlatformOverride = null; - }); - - testWidgets('avatar editor closes when a switched upload throws', ( - tester, - ) async { - debugDefaultTargetPlatformOverride = TargetPlatform.android; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - final notifier = _RetryProfileNotifier(); - final config = _MutableRelayConfigNotifier(); - final firstUpload = _RetryMediaUploadService( - baseUrl: 'https://first.example', - delayUpload: true, - ); - final secondUpload = _RetryMediaUploadService( - baseUrl: 'https://second.example', - ); - addTearDown(firstUpload.dispose); - addTearDown(secondUpload.dispose); - - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [ - profileProvider.overrideWith(() => notifier), - relayConfigProvider.overrideWith(() => config), - mediaUploadServiceProvider.overrideWith((ref) { - final current = ref.watch(relayConfigProvider); - return current.baseUrl == 'https://first.example' - ? firstUpload - : secondUpload; - }), - ], - child: ProfileEditPage( - startInPhotoEditor: true, - animatedAvatarCaptureBuilder: - ({required height, required onPrepareChanged}) => HookBuilder( - builder: (context) { - useEffect(() { - WidgetsBinding.instance.addPostFrameCallback((_) { - onPrepareChanged( - () async => ProfileImageAvatarDraft( - Uint8List.fromList([1, 2, 3]), - ), - ); - }); - return null; - }, const []); - return SizedBox(height: height); - }, - ), - ), - ), - ); - await tester.pumpAndSettle(); - await tester.tap(find.text('Animated')); - await tester.pumpAndSettle(); - - await tester.tap(find.byKey(const ValueKey('avatar-save'))); - await tester.pump(); - config.update(baseUrl: 'https://second.example', nsec: 'second-identity'); - firstUpload.failUpload(); - await tester.pumpAndSettle(); - - expect(notifier.savedAvatarUrls, isEmpty); - expect(find.byKey(const ValueKey('avatar-save')), findsNothing); - debugDefaultTargetPlatformOverride = null; - }); -} - -class _MutableRelayConfigNotifier extends RelayConfigNotifier { - @override - RelayConfig build() => const RelayConfig( - baseUrl: 'https://first.example', - nsec: 'first-identity', - ); -} - -class _DelayedHydrationProfileNotifier extends ProfileNotifier { - final _hydration = Completer(); - - @override - Future build() => _hydration.future; - - void completeHydration() => _hydration.complete( - const UserProfile(pubkey: 'aabb', displayName: 'Hydrated name'), - ); } class _RetryProfileNotifier extends ProfileNotifier { @@ -736,69 +139,17 @@ class _RetryProfileNotifier extends ProfileNotifier { } } -class _CommunityChangedProfileNotifier extends ProfileNotifier { - final displayNameAttempts = []; - - @override - Future build() async => const UserProfile( - pubkey: 'aabb', - displayName: 'Alice', - about: 'Building Buzz', - ); - - @override - Future updateDisplayName(String displayName) async { - displayNameAttempts.add(displayName); - throw ProfileCommunityChangedException(); - } -} - -class _DeferredFailureProfileNotifier extends ProfileNotifier { - final displayNameAttempts = []; - final _save = Completer(); - - @override - Future build() async => const UserProfile( - pubkey: 'aabb', - displayName: 'Alice', - about: 'Building Buzz', - ); - - @override - Future updateDisplayName(String displayName) { - displayNameAttempts.add(displayName); - return _save.future; - } - - void failSave() => _save.completeError(Exception('profile publish failed')); -} - class _RetryMediaUploadService extends MediaUploadService { - _RetryMediaUploadService({ - this.baseUrl = 'https://relay.example', - this.delayUpload = false, - }) : super( - baseUrl: baseUrl, - nsec: null, - pickGalleryImage: () async => null, - pickGalleryVideo: () async => null, - ); + _RetryMediaUploadService() + : super( + baseUrl: 'https://relay.example', + nsec: null, + pickGalleryImage: () async => null, + pickGalleryVideo: () async => null, + ); - final String baseUrl; - final bool delayUpload; - final _pendingUpload = Completer(); int uploadCount = 0; - void completeUpload() { - if (!_pendingUpload.isCompleted) _pendingUpload.complete(); - } - - void failUpload() { - if (!_pendingUpload.isCompleted) { - _pendingUpload.completeError(Exception('upload client closed')); - } - } - @override Future uploadBytes( Uint8List bytes, { @@ -807,9 +158,8 @@ class _RetryMediaUploadService extends MediaUploadService { UploadCancellationToken? cancellationToken, }) async { uploadCount++; - if (delayUpload) await _pendingUpload.future; return BlobDescriptor( - url: '$baseUrl/avatar.jpg', + url: 'https://relay.example/avatar.jpg', sha256: 'avatar-hash', size: bytes.length, type: mimeType, diff --git a/mobile/test/features/profile/profile_provider_test.dart b/mobile/test/features/profile/profile_provider_test.dart index b2e4019538a..dc19c89c56c 100644 --- a/mobile/test/features/profile/profile_provider_test.dart +++ b/mobile/test/features/profile/profile_provider_test.dart @@ -230,6 +230,47 @@ void main() { }, ); + test( + 'overlapping profile updates serialize their full merge cycles', + () async { + final keys = nostr.Keys.generate(); + final relaySession = _ControlledProfileRelaySession( + fetch: () async => [ + NostrEvent( + id: 'profile-initial', + pubkey: keys.public, + createdAt: 10, + kind: EventKind.profile, + tags: const [], + content: jsonEncode({ + 'display_name': 'Initial', + 'about': 'Initial about', + }), + sig: 'sig', + ), + ], + ); + final container = _profileContainer(keys.nsec, relaySession); + addTearDown(container.dispose); + + await container.read(profileProvider.future); + await Future.wait([ + container.read(profileProvider.notifier).updateDisplayName('Mobile'), + container.read(profileProvider.notifier).updateAbout('Mobile about'), + ]); + + expect(relaySession.published, hasLength(2)); + expect(jsonDecode(relaySession.published.last.content), { + 'display_name': 'Mobile', + 'about': 'Mobile about', + }); + expect( + relaySession.published.last.createdAt, + greaterThan(relaySession.published.first.createdAt), + ); + }, + ); + test( 'manual presence persists until Online restores automatic mode', () async { From 94ee6beaa3eadf2c7837eb483faafab45b46b2ba Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 10:23:15 +0100 Subject: [PATCH 06/67] fix(mobile): scope profile writes to community Signed-off-by: kenny lopez --- .../features/profile/profile_provider.dart | 49 ++++++++++++++--- .../profile/profile_provider_test.dart | 54 +++++++++++++++++++ 2 files changed, 97 insertions(+), 6 deletions(-) diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index 53ff4f34b99..9a7d121010d 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -77,32 +77,42 @@ class ProfileNotifier extends AsyncNotifier { _publishProfilePatch({'picture': avatarUrl.trim()}); Future _publishProfilePatch(Map patch) { + final context = _ProfileWriteContext( + config: ref.read(relayConfigProvider), + pubkey: ref.read(myPubkeyProvider), + session: ref.read(relaySessionProvider.notifier), + ); final previous = _patchQueue; final released = Completer(); _patchQueue = released.future; return () async { await previous; try { - await _publishProfilePatchNow(patch); + await _publishProfilePatchNow(patch, context); } finally { released.complete(); } }(); } - Future _publishProfilePatchNow(Map patch) async { + Future _publishProfilePatchNow( + Map patch, + _ProfileWriteContext context, + ) async { if (!_hasHydrated || !state.hasValue) { throw StateError('Cannot update profile before metadata is loaded.'); } - final pubkey = ref.read(myPubkeyProvider); + final pubkey = context.pubkey; if (pubkey == null) { throw StateError('Cannot update profile without a signing identity.'); } + _requireCurrentWriteContext(context); - final session = ref.read(relaySessionProvider.notifier); + final session = context.session; final currentEvents = await session.fetchHistory( NostrFilters.profile(pubkey), ); + _requireCurrentWriteContext(context); final currentHead = _latestProfileEvent(currentEvents); if (_lastCreatedAt > 0 && (currentHead == null || currentHead.createdAt < _lastCreatedAt)) { @@ -112,8 +122,7 @@ class ProfileNotifier extends AsyncNotifier { ? {} : _decodeProfileMetadata(currentHead); final nextMetadata = {...currentMetadata, ...patch}; - final config = ref.read(relayConfigProvider); - final relay = SignedEventRelay(session: session, nsec: config.nsec); + final relay = SignedEventRelay(session: session, nsec: context.config.nsec); final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; final currentCreatedAt = currentHead?.createdAt ?? 0; final previousCreatedAt = currentCreatedAt > _lastCreatedAt @@ -128,6 +137,7 @@ class ProfileNotifier extends AsyncNotifier { createdAt: createdAt, onSigned: (event) => signedEvent = event, ); + _requireCurrentWriteContext(context); final submittedEvent = signedEvent; if (submittedEvent == null) { throw StateError('Profile update was not signed.'); @@ -135,6 +145,7 @@ class ProfileNotifier extends AsyncNotifier { final verifiedHead = _latestProfileEvent( await session.fetchHistory(NostrFilters.profile(pubkey)), ); + _requireCurrentWriteContext(context); if (verifiedHead?.id != submittedEvent.id) { throw StateError('Profile changed before the update could be confirmed.'); } @@ -152,6 +163,32 @@ class ProfileNotifier extends AsyncNotifier { state = AsyncData(profile); ref.read(userCacheProvider.notifier).put(profile); } + + void _requireCurrentWriteContext(_ProfileWriteContext context) { + final currentConfig = ref.read(relayConfigProvider); + final currentSession = ref.read(relaySessionProvider.notifier); + final currentPubkey = ref.read(myPubkeyProvider); + if (currentConfig.storedOrigin != context.config.storedOrigin || + currentConfig.nsec != context.config.nsec || + currentPubkey != context.pubkey || + !identical(currentSession, context.session)) { + throw StateError( + 'Profile update cancelled because the active community changed.', + ); + } + } +} + +class _ProfileWriteContext { + const _ProfileWriteContext({ + required this.config, + required this.pubkey, + required this.session, + }); + + final RelayConfig config; + final String? pubkey; + final RelaySessionNotifier session; } NostrEvent? _latestProfileEvent(List events) { diff --git a/mobile/test/features/profile/profile_provider_test.dart b/mobile/test/features/profile/profile_provider_test.dart index dc19c89c56c..fba40f85902 100644 --- a/mobile/test/features/profile/profile_provider_test.dart +++ b/mobile/test/features/profile/profile_provider_test.dart @@ -271,6 +271,50 @@ void main() { }, ); + test('profile updates abort when the active community changes', () async { + final keys = nostr.Keys.generate(); + final otherKeys = nostr.Keys.generate(); + final initial = NostrEvent( + id: 'profile-initial', + pubkey: keys.public, + createdAt: 10, + kind: EventKind.profile, + tags: const [], + content: jsonEncode({'display_name': 'Initial'}), + sig: 'sig', + ); + final patchFetchStarted = Completer(); + final patchHistory = Completer>(); + var fetchCount = 0; + final relaySession = _ControlledProfileRelaySession( + fetch: () async { + fetchCount += 1; + if (fetchCount == 1) return [initial]; + if (!patchFetchStarted.isCompleted) patchFetchStarted.complete(); + return patchHistory.future; + }, + ); + final config = _MutableRelayConfigNotifier(keys.nsec); + final container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith(() => config), + relaySessionProvider.overrideWith(() => relaySession), + ], + ); + addTearDown(container.dispose); + + await container.read(profileProvider.future); + final update = container + .read(profileProvider.notifier) + .updateDisplayName('Mobile'); + await patchFetchStarted.future; + config.update(baseUrl: 'https://other-relay.example', nsec: otherKeys.nsec); + patchHistory.complete([initial]); + + await expectLater(update, throwsStateError); + expect(relaySession.published, isEmpty); + }); + test( 'manual presence persists until Online restores automatic mode', () async { @@ -335,6 +379,16 @@ class _FixedRelayConfigNotifier extends RelayConfigNotifier { RelayConfig(baseUrl: 'https://relay.example', nsec: nsec); } +class _MutableRelayConfigNotifier extends RelayConfigNotifier { + _MutableRelayConfigNotifier(this.initialNsec); + + final String initialNsec; + + @override + RelayConfig build() => + RelayConfig(baseUrl: 'https://relay.example', nsec: initialNsec); +} + class _ProfileRelaySession extends RelaySessionNotifier { _ProfileRelaySession(this.profile); From b9ccbdc10f1c81cdf358582b27838ab289e1d2b1 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 10:33:52 +0100 Subject: [PATCH 07/67] test(mobile): bound avatar crop settling Signed-off-by: kenny lopez --- .../profile/profile_edit_page_test.dart | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index ca1e755d065..86ce267ab9d 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -347,10 +347,7 @@ void main() { closeTo(expectedY, 0.01), ); await tester.tap(find.byKey(const ValueKey('avatar-crop-use-photo'))); - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 200)), - ); - await tester.pumpAndSettle(); + await _waitForAvatarCropToClose(tester); expect(notifier.savedAvatarUrls, isEmpty); expect(uploadService.uploadCount, 0); await tester.tap(find.byKey(const ValueKey('avatar-save'))); @@ -381,10 +378,7 @@ void main() { await tester.tap(find.text('Photo Library')); await tester.pumpAndSettle(); await tester.tap(find.byKey(const ValueKey('avatar-crop-use-photo'))); - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 200)), - ); - await tester.pumpAndSettle(); + await _waitForAvatarCropToClose(tester); await tester.tap(find.byKey(const ValueKey('avatar-save'))); await tester.pumpAndSettle(); @@ -996,6 +990,21 @@ void main() { }); } +Future _waitForAvatarCropToClose(WidgetTester tester) async { + final cropPage = find.byKey(const ValueKey('avatar-crop-viewer')); + for (var attempt = 0; attempt < 100; attempt += 1) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 25)), + ); + await tester.pump(const Duration(milliseconds: 25)); + if (cropPage.evaluate().isEmpty) { + await tester.pump(const Duration(milliseconds: 250)); + return; + } + } + fail('Avatar crop did not complete within 5 seconds.'); +} + class _FakeProfileNotifier extends ProfileNotifier { _FakeProfileNotifier({ this.profile = const UserProfile( From b52063ef5f1296540be027fd197e7508a6ef8d24 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 10:46:47 +0100 Subject: [PATCH 08/67] fix(mobile): preserve avatar work across context changes Signed-off-by: kenny lopez --- .../profile/animated_avatar_capture.dart | 14 +-- .../profile/profile_avatar_draft.dart | 12 ++ .../features/profile/profile_edit_page.dart | 22 +++- .../profile/animated_avatar_capture_test.dart | 64 ++++++++++ .../profile/profile_avatar_draft_test.dart | 50 ++++++++ .../profile/profile_edit_retry_test.dart | 110 ++++++++++++++++-- 6 files changed, 254 insertions(+), 18 deletions(-) diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart index c7b619a2c3d..cc386ed4081 100644 --- a/mobile/lib/features/profile/animated_avatar_capture.dart +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -40,11 +40,14 @@ class AnimatedAvatarCapture extends HookConsumerWidget { super.key, required this.height, required this.onPrepareChanged, + this.initialFrames = const [], }); - final double height; final ValueChanged Function()?> onPrepareChanged; + /// Seeds processed frames in lifecycle-focused widget tests. + @visibleForTesting + final List initialFrames; @override Widget build(BuildContext context, WidgetRef ref) { final controller = useState(null); @@ -54,7 +57,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { final isPreparingFrames = useState(false); final isProcessing = useState(false); final progress = useState(0.0); - final frames = useState>(const []); + final frames = useState>(initialFrames); final posterIndex = useState(0); final previewFrameIndex = useState(0); final scale = useState(_mobileDefaultPersonScale); @@ -69,7 +72,6 @@ class AnimatedAvatarCapture extends HookConsumerWidget { final encodedCache = useRef<_EncodedAvatarCache?>(null); final reduceMotion = MediaQuery.disableAnimationsOf(context); final lifecycle = ref.watch(appLifecycleProvider); - final encodeKey = frames.value.isEmpty ? null : _EncodeKey( @@ -84,7 +86,6 @@ class AnimatedAvatarCapture extends HookConsumerWidget { shapeOffsetX: shapeOffset.value.dx, shapeOffsetY: shapeOffset.value.dy, ); - useEffect(() { encodedCache.value = null; final key = encodeKey; @@ -113,11 +114,11 @@ class AnimatedAvatarCapture extends HookConsumerWidget { if (lifecycle != AppLifecycleState.resumed) { isInitializing.value = false; controller.value = null; - frames.value = const []; - onPrepareChanged(null); return null; } + isInitializing.value = true; + Future initialize() async { try { final cameras = await availableCameras(); @@ -151,7 +152,6 @@ class AnimatedAvatarCapture extends HookConsumerWidget { unawaited(initialize()); return () { disposed = true; - onPrepareChanged(null); final active = controllerRef.value; controllerRef.value = null; unawaited(active?.dispose() ?? Future.value()); diff --git a/mobile/lib/features/profile/profile_avatar_draft.dart b/mobile/lib/features/profile/profile_avatar_draft.dart index dd2e84676f0..55645f147a2 100644 --- a/mobile/lib/features/profile/profile_avatar_draft.dart +++ b/mobile/lib/features/profile/profile_avatar_draft.dart @@ -22,10 +22,15 @@ final class ProfileImageAvatarDraft extends ProfileAvatarDraft { ProfileImageAvatarDraft(this.bytes); final Uint8List bytes; + MediaUploadService? _uploadService; Future? _uploadedUrl; @override Future upload(MediaUploadService service) async { + if (!identical(_uploadService, service)) { + _uploadService = service; + _uploadedUrl = null; + } final existing = _uploadedUrl; if (existing != null) return existing; final upload = service @@ -46,6 +51,7 @@ final class ProfileAnimatedAvatarDraft extends ProfileAvatarDraft { final Uint8List animation; final Uint8List poster; + MediaUploadService? _uploadService; Future? _uploadedUrl; Future? _posterUpload; Future? _animationUpload; @@ -82,6 +88,12 @@ final class ProfileAnimatedAvatarDraft extends ProfileAvatarDraft { @override Future upload(MediaUploadService service) async { + if (!identical(_uploadService, service)) { + _uploadService = service; + _uploadedUrl = null; + _posterUpload = null; + _animationUpload = null; + } final existing = _uploadedUrl; if (existing != null) return existing; // Cache each content-addressed part independently. If one request fails, diff --git a/mobile/lib/features/profile/profile_edit_page.dart b/mobile/lib/features/profile/profile_edit_page.dart index 2405ca13f4e..85d86b51706 100644 --- a/mobile/lib/features/profile/profile_edit_page.dart +++ b/mobile/lib/features/profile/profile_edit_page.dart @@ -156,6 +156,20 @@ class ProfileEditPage extends HookConsumerWidget { Future saveAvatar() async { if (isSavingAvatar.value) return; + final saveConfig = ref.read(relayConfigProvider); + final uploadService = ref.read(mediaUploadServiceProvider); + + void requireCurrentCommunity() { + final currentConfig = ref.read(relayConfigProvider); + if (currentConfig.storedOrigin != saveConfig.storedOrigin || + currentConfig.nsec != saveConfig.nsec || + !identical(ref.read(mediaUploadServiceProvider), uploadService)) { + throw StateError( + 'Profile photo save cancelled because the active community changed.', + ); + } + } + isSavingAvatar.value = true; avatarSaveError.value = null; try { @@ -165,16 +179,18 @@ class ProfileEditPage extends HookConsumerWidget { if (avatarMode.value == ProfileAvatarMode.animated && nextDraft == null) { nextDraft = await prepareAnimatedAvatar.value?.call(); + requireCurrentCommunity(); if (nextDraft != null) { avatarDraft.value = nextDraft; avatarDraftMode.value = ProfileAvatarMode.animated; } } if (nextDraft == null) return; - final nextAvatar = await nextDraft.upload( - ref.read(mediaUploadServiceProvider), - ); + requireCurrentCommunity(); + final nextAvatar = await nextDraft.upload(uploadService); + requireCurrentCommunity(); await ref.read(profileProvider.notifier).updateAvatarUrl(nextAvatar); + requireCurrentCommunity(); if (context.mounted) await closeAvatarEditor(whileSaving: true); } catch (_) { avatarSaveError.value = diff --git a/mobile/test/features/profile/animated_avatar_capture_test.dart b/mobile/test/features/profile/animated_avatar_capture_test.dart index 26cf7cb2287..e4b0d1f03b2 100644 --- a/mobile/test/features/profile/animated_avatar_capture_test.dart +++ b/mobile/test/features/profile/animated_avatar_capture_test.dart @@ -1,7 +1,14 @@ import 'package:buzz/features/profile/animated_avatar_orientation.dart'; +import 'package:buzz/features/profile/animated_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'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:image/image.dart' as image; void main() { group('animatedAvatarFrameRotationDegrees', () { @@ -45,4 +52,61 @@ void main() { } }); }); + + testWidgets('completed review frames survive lifecycle changes', ( + tester, + ) async { + final lifecycle = _TestLifecycleNotifier(); + Future Function()? prepare; + final frame = image.encodePng(image.Image(width: 2, height: 2)); + await tester.pumpWidget( + ProviderScope( + overrides: [appLifecycleProvider.overrideWith(() => lifecycle)], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: MediaQuery( + data: const MediaQueryData(disableAnimations: true), + child: ExcludeSemantics( + child: AnimatedAvatarCapture( + height: 600, + initialFrames: [frame, frame], + onPrepareChanged: (value) => prepare = value, + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); + expect( + find.byKey(const ValueKey('animated-avatar-review-preview')), + findsOneWidget, + ); + expect(prepare, isNotNull); + + lifecycle.setLifecycle(AppLifecycleState.paused); + await tester.pump(); + lifecycle.setLifecycle(AppLifecycleState.resumed); + await tester.pump(); + + expect( + find.byKey(const ValueKey('animated-avatar-review-preview')), + findsOneWidget, + ); + expect(prepare, isNotNull); + }); +} + +class _TestLifecycleNotifier extends AppLifecycleNotifier { + AppLifecycleState _lifecycle = AppLifecycleState.resumed; + + @override + AppLifecycleState build() => _lifecycle; + + void setLifecycle(AppLifecycleState value) { + _lifecycle = value; + state = value; + } } diff --git a/mobile/test/features/profile/profile_avatar_draft_test.dart b/mobile/test/features/profile/profile_avatar_draft_test.dart index fa63908df25..6cf3f0b578d 100644 --- a/mobile/test/features/profile/profile_avatar_draft_test.dart +++ b/mobile/test/features/profile/profile_avatar_draft_test.dart @@ -27,6 +27,56 @@ void main() { expect(await draft.upload(service), url); expect(service.uploadedParts, ['poster', 'animation', 'animation']); }); + + test('animated draft reuploads every part for a new community', () async { + final first = _RecordingUploadService('first'); + final second = _RecordingUploadService('second'); + addTearDown(first.dispose); + addTearDown(second.dispose); + final draft = ProfileAnimatedAvatarDraft( + poster: Uint8List.fromList([1]), + animation: Uint8List.fromList([2]), + ); + + final firstUrl = await draft.upload(first); + final secondUrl = await draft.upload(second); + + expect(first.uploadedParts, ['poster', 'animation']); + expect(second.uploadedParts, ['poster', 'animation']); + expect(firstUrl, contains('https://first.example/')); + expect(secondUrl, contains('https://second.example/')); + }); +} + +final class _RecordingUploadService extends MediaUploadService { + _RecordingUploadService(this.community) + : super( + baseUrl: 'https://$community.example', + nsec: null, + pickGalleryImage: () async => null, + pickGalleryVideo: () async => null, + ); + + final String community; + final uploadedParts = []; + + @override + Future uploadBytes( + Uint8List bytes, { + required String mimeType, + ValueChanged? onProgress, + UploadCancellationToken? cancellationToken, + }) async { + final part = bytes.single == 1 ? 'poster' : 'animation'; + uploadedParts.add(part); + return BlobDescriptor( + url: 'https://$community.example/$part.png', + sha256: '$community-$part', + size: bytes.length, + type: mimeType, + uploaded: 1, + ); + } } final class _PartiallyFailingUploadService extends MediaUploadService { diff --git a/mobile/test/features/profile/profile_edit_retry_test.dart b/mobile/test/features/profile/profile_edit_retry_test.dart index 47fa6fa583c..6dea2a201e7 100644 --- a/mobile/test/features/profile/profile_edit_retry_test.dart +++ b/mobile/test/features/profile/profile_edit_retry_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:buzz/features/profile/profile_avatar_draft.dart'; import 'package:buzz/features/profile/profile_edit_page.dart'; import 'package:buzz/features/profile/profile_provider.dart'; @@ -103,6 +105,88 @@ void main() { expect(notifier.savedAvatarUrls, ['https://relay.example/avatar.jpg']); debugDefaultTargetPlatformOverride = null; }); + + testWidgets('avatar retry reuploads after a community switch', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final notifier = _RetryProfileNotifier(); + final config = _MutableRelayConfigNotifier(); + final firstUpload = _RetryMediaUploadService( + baseUrl: 'https://first.example', + delayUpload: true, + ); + final secondUpload = _RetryMediaUploadService( + baseUrl: 'https://second.example', + ); + addTearDown(firstUpload.dispose); + addTearDown(secondUpload.dispose); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(() => notifier), + relayConfigProvider.overrideWith(() => config), + mediaUploadServiceProvider.overrideWith((ref) { + final current = ref.watch(relayConfigProvider); + return current.baseUrl == 'https://first.example' + ? firstUpload + : secondUpload; + }), + ], + child: ProfileEditPage( + startInPhotoEditor: true, + animatedAvatarCaptureBuilder: + ({required height, required onPrepareChanged}) => HookBuilder( + builder: (context) { + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + onPrepareChanged( + () async => ProfileImageAvatarDraft( + Uint8List.fromList([1, 2, 3]), + ), + ); + }); + return null; + }, const []); + return SizedBox(height: height); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Animated')); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pump(); + expect(firstUpload.uploadCount, 1); + config.update(baseUrl: 'https://second.example', nsec: 'second-identity'); + firstUpload.completeUpload(); + await tester.pumpAndSettle(); + + expect(notifier.savedAvatarUrls, isEmpty); + expect( + find.text("We couldn't save your profile photo. Try again."), + findsOneWidget, + ); + + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pumpAndSettle(); + expect(secondUpload.uploadCount, 1); + expect(notifier.savedAvatarUrls, ['https://second.example/avatar.jpg']); + debugDefaultTargetPlatformOverride = null; + }); +} + +class _MutableRelayConfigNotifier extends RelayConfigNotifier { + @override + RelayConfig build() => const RelayConfig( + baseUrl: 'https://first.example', + nsec: 'first-identity', + ); } class _RetryProfileNotifier extends ProfileNotifier { @@ -140,16 +224,25 @@ class _RetryProfileNotifier extends ProfileNotifier { } class _RetryMediaUploadService extends MediaUploadService { - _RetryMediaUploadService() - : super( - baseUrl: 'https://relay.example', - nsec: null, - pickGalleryImage: () async => null, - pickGalleryVideo: () async => null, - ); + _RetryMediaUploadService({ + this.baseUrl = 'https://relay.example', + this.delayUpload = false, + }) : super( + baseUrl: baseUrl, + nsec: null, + pickGalleryImage: () async => null, + pickGalleryVideo: () async => null, + ); + final String baseUrl; + final bool delayUpload; + final _pendingUpload = Completer(); int uploadCount = 0; + void completeUpload() { + if (!_pendingUpload.isCompleted) _pendingUpload.complete(); + } + @override Future uploadBytes( Uint8List bytes, { @@ -158,8 +251,9 @@ class _RetryMediaUploadService extends MediaUploadService { UploadCancellationToken? cancellationToken, }) async { uploadCount++; + if (delayUpload) await _pendingUpload.future; return BlobDescriptor( - url: 'https://relay.example/avatar.jpg', + url: '$baseUrl/avatar.jpg', sha256: 'avatar-hash', size: bytes.length, type: mimeType, From bb3d65707bd4cd43d562a65eb666597592dadd48 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 10:54:43 +0100 Subject: [PATCH 09/67] fix(mobile): keep profile edits in their origin context Signed-off-by: kenny lopez --- .../profile/ios_profile_text_editor.dart | 4 +- .../features/profile/profile_edit_page.dart | 2 + .../features/profile/profile_provider.dart | 10 ++-- .../features/profile/profile_text_editor.dart | 2 + .../profile/profile_edit_page_test.dart | 23 +++++++-- .../profile/profile_edit_retry_test.dart | 50 +++++++++++++++++++ 6 files changed, 83 insertions(+), 8 deletions(-) diff --git a/mobile/lib/features/profile/ios_profile_text_editor.dart b/mobile/lib/features/profile/ios_profile_text_editor.dart index a715f8ff913..172c1293bc4 100644 --- a/mobile/lib/features/profile/ios_profile_text_editor.dart +++ b/mobile/lib/features/profile/ios_profile_text_editor.dart @@ -27,6 +27,7 @@ class IosProfileTextEditor { required bool multiline, required Future Function(String value) onSave, required void Function() onSaveError, + bool Function(Object error)? shouldRetryOnError, }) async { var draft = initialValue; while (true) { @@ -40,7 +41,8 @@ class IosProfileTextEditor { try { await onSave(value); return; - } catch (_) { + } catch (error) { + if (shouldRetryOnError?.call(error) == false) return; draft = value; onSaveError(); } diff --git a/mobile/lib/features/profile/profile_edit_page.dart b/mobile/lib/features/profile/profile_edit_page.dart index 85d86b51706..afe807dc7a7 100644 --- a/mobile/lib/features/profile/profile_edit_page.dart +++ b/mobile/lib/features/profile/profile_edit_page.dart @@ -106,6 +106,8 @@ class ProfileEditPage extends HookConsumerWidget { placeholder: hintText, multiline: multiline, onSave: onSave, + shouldRetryOnError: (error) => + error is! ProfileCommunityChangedException, onSaveError: () { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index 9a7d121010d..9bb3091abd4 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -9,6 +9,12 @@ import '../../shared/profile/user_profile.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; +/// Signals that a profile write no longer belongs to the active community. +class ProfileCommunityChangedException extends StateError { + ProfileCommunityChangedException() + : super('Profile update cancelled because the active community changed.'); +} + /// 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. @@ -172,9 +178,7 @@ class ProfileNotifier extends AsyncNotifier { currentConfig.nsec != context.config.nsec || currentPubkey != context.pubkey || !identical(currentSession, context.session)) { - throw StateError( - 'Profile update cancelled because the active community changed.', - ); + throw ProfileCommunityChangedException(); } } } diff --git a/mobile/lib/features/profile/profile_text_editor.dart b/mobile/lib/features/profile/profile_text_editor.dart index 9ef95e12030..d529af65e5f 100644 --- a/mobile/lib/features/profile/profile_text_editor.dart +++ b/mobile/lib/features/profile/profile_text_editor.dart @@ -54,6 +54,8 @@ Future _showProfileTextEditor({ placeholder: hintText, multiline: multiline, onSave: onSave, + shouldRetryOnError: (error) => + error is! ProfileCommunityChangedException, onSaveError: () { if (context.mounted) _showSaveError(context); }, diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index 86ce267ab9d..70cb1400dcb 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -313,7 +313,7 @@ void main() { ); expect(tester.widget(preview).radius, 110); await tester.tap(find.text('Photo Library')); - await tester.pumpAndSettle(); + await _waitForAvatarCropToLoad(tester); expect(find.text('Position Photo'), findsOneWidget); final cancelButton = find.ancestor( of: find.text('Cancel'), @@ -376,7 +376,7 @@ void main() { await tester.tap(find.text('Edit Photo')); await tester.pumpAndSettle(); await tester.tap(find.text('Photo Library')); - await tester.pumpAndSettle(); + await _waitForAvatarCropToLoad(tester); await tester.tap(find.byKey(const ValueKey('avatar-crop-use-photo'))); await _waitForAvatarCropToClose(tester); @@ -620,7 +620,7 @@ void main() { ); uploadService.completeGallerySelection(); - await tester.pumpAndSettle(); + await _waitForAvatarCropToLoad(tester); expect(find.text('Position Photo'), findsOneWidget); }); @@ -644,7 +644,7 @@ void main() { await tester.tap(find.text('Edit Photo')); await tester.pumpAndSettle(); await tester.tap(find.text('Camera')); - await tester.pumpAndSettle(); + await _waitForAvatarCropToLoad(tester); expect(find.text('Position Photo'), findsOneWidget); await tester.tap(find.byKey(const ValueKey('avatar-crop-use-photo'))); await tester.runAsync( @@ -1005,6 +1005,21 @@ Future _waitForAvatarCropToClose(WidgetTester tester) async { fail('Avatar crop did not complete within 5 seconds.'); } +Future _waitForAvatarCropToLoad(WidgetTester tester) async { + final cropViewer = find.byKey(const ValueKey('avatar-crop-viewer')); + for (var attempt = 0; attempt < 100; attempt += 1) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 25)), + ); + await tester.pump(const Duration(milliseconds: 25)); + if (cropViewer.evaluate().isNotEmpty) { + await tester.pumpAndSettle(); + return; + } + } + fail('Avatar crop did not load within 5 seconds.'); +} + class _FakeProfileNotifier extends ProfileNotifier { _FakeProfileNotifier({ this.profile = const UserProfile( diff --git a/mobile/test/features/profile/profile_edit_retry_test.dart b/mobile/test/features/profile/profile_edit_retry_test.dart index 6dea2a201e7..875d1ec41f6 100644 --- a/mobile/test/features/profile/profile_edit_retry_test.dart +++ b/mobile/test/features/profile/profile_edit_retry_test.dart @@ -49,6 +49,39 @@ void main() { debugDefaultTargetPlatformOverride = null; }); + testWidgets('native text editor stops retrying after a community switch', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + const channel = MethodChannel('buzz/profile_text_editor'); + var presentations = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + presentations++; + return 'Old community draft'; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + final notifier = _CommunityChangedProfileNotifier(); + + 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(); + + expect(presentations, 1); + expect(notifier.displayNameAttempts, ['Old community draft']); + debugDefaultTargetPlatformOverride = null; + }); + testWidgets('animated save retry reuses its prepared draft', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.android; addTearDown(() => debugDefaultTargetPlatformOverride = null); @@ -223,6 +256,23 @@ class _RetryProfileNotifier extends ProfileNotifier { } } +class _CommunityChangedProfileNotifier extends ProfileNotifier { + final displayNameAttempts = []; + + @override + Future build() async => const UserProfile( + pubkey: 'aabb', + displayName: 'Alice', + about: 'Building Buzz', + ); + + @override + Future updateDisplayName(String displayName) async { + displayNameAttempts.add(displayName); + throw ProfileCommunityChangedException(); + } +} + class _RetryMediaUploadService extends MediaUploadService { _RetryMediaUploadService({ this.baseUrl = 'https://relay.example', From 678b64232b15d01d9796debda0221317a7ed3e65 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 11:08:17 +0100 Subject: [PATCH 10/67] fix(mobile): close stale profile text drafts Signed-off-by: kenny lopez --- .../features/profile/profile_edit_page.dart | 2 ++ .../features/profile/profile_text_editor.dart | 2 ++ .../profile/profile_edit_page_test.dart | 26 +++++++++++++++++ .../profile/profile_edit_retry_test.dart | 29 +++++++++++++++++++ 4 files changed, 59 insertions(+) diff --git a/mobile/lib/features/profile/profile_edit_page.dart b/mobile/lib/features/profile/profile_edit_page.dart index afe807dc7a7..14cd94af7ba 100644 --- a/mobile/lib/features/profile/profile_edit_page.dart +++ b/mobile/lib/features/profile/profile_edit_page.dart @@ -544,6 +544,8 @@ class _ProfileTextEditSheet extends HookWidget { 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 { diff --git a/mobile/lib/features/profile/profile_text_editor.dart b/mobile/lib/features/profile/profile_text_editor.dart index d529af65e5f..9a43d423cdc 100644 --- a/mobile/lib/features/profile/profile_text_editor.dart +++ b/mobile/lib/features/profile/profile_text_editor.dart @@ -117,6 +117,8 @@ class _ProfileTextEditSheet extends HookWidget { 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 { diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index 70cb1400dcb..b968f9c0018 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:math' as math; import 'package:buzz/features/profile/profile_edit_page.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/shared/widgets/immediate_page_route.dart'; @@ -29,6 +30,31 @@ import '../../helpers/widget_helpers.dart'; const _editorControlBottomForTest = Grid.xl + Grid.xxs; void main() { + testWidgets('keeps crop Save disabled while dimensions decode', ( + tester, + ) async { + final bytes = Uint8List.fromList( + image.encodePng(image.Image(width: 20, height: 10)), + ); + await tester.pumpWidget( + MaterialApp( + home: ProfileAvatarCropPage( + imageBytes: Future.value(bytes), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + final saveButton = tester.widget( + find.descendant( + of: find.byKey(const ValueKey('avatar-crop-use-photo')), + matching: find.byType(TextButton), + ), + ); + expect(saveButton.onPressed, isNull); + }); + testWidgets('can open directly into the photo editor from Settings', ( 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 875d1ec41f6..f87e2435a13 100644 --- a/mobile/test/features/profile/profile_edit_retry_test.dart +++ b/mobile/test/features/profile/profile_edit_retry_test.dart @@ -82,6 +82,35 @@ void main() { debugDefaultTargetPlatformOverride = null; }); + testWidgets('Flutter text editor closes after a community switch', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final notifier = _CommunityChangedProfileNotifier(); + + 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')), + 'Old community draft', + ); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('profile-field-save'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('profile-field-input')), findsNothing); + expect(notifier.displayNameAttempts, ['Old community draft']); + debugDefaultTargetPlatformOverride = null; + }); + testWidgets('animated save retry reuses its prepared draft', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.android; addTearDown(() => debugDefaultTargetPlatformOverride = null); From c5dfeb5cd02df323e4132d2448d7fdf3c094482c Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 11:21:17 +0100 Subject: [PATCH 11/67] fix(mobile): cancel orphaned profile editors Signed-off-by: kenny lopez --- .../profile/ios_profile_text_editor.dart | 7 ++- .../features/profile/profile_edit_page.dart | 1 + .../features/profile/profile_text_editor.dart | 1 + .../profile/profile_edit_retry_test.dart | 57 +++++++++++++++++++ 4 files changed, 65 insertions(+), 1 deletion(-) diff --git a/mobile/lib/features/profile/ios_profile_text_editor.dart b/mobile/lib/features/profile/ios_profile_text_editor.dart index 172c1293bc4..d2939ae2a89 100644 --- a/mobile/lib/features/profile/ios_profile_text_editor.dart +++ b/mobile/lib/features/profile/ios_profile_text_editor.dart @@ -28,9 +28,11 @@ class IosProfileTextEditor { required Future Function(String value) onSave, required void Function() onSaveError, bool Function(Object error)? shouldRetryOnError, + bool Function()? canPresent, }) async { var draft = initialValue; while (true) { + if (canPresent?.call() == false) return; final value = await present( title: title, initialValue: draft, @@ -42,7 +44,10 @@ class IosProfileTextEditor { await onSave(value); return; } catch (error) { - if (shouldRetryOnError?.call(error) == false) return; + if (shouldRetryOnError?.call(error) == false || + canPresent?.call() == false) { + return; + } draft = value; onSaveError(); } diff --git a/mobile/lib/features/profile/profile_edit_page.dart b/mobile/lib/features/profile/profile_edit_page.dart index 14cd94af7ba..3ef9baaaa7a 100644 --- a/mobile/lib/features/profile/profile_edit_page.dart +++ b/mobile/lib/features/profile/profile_edit_page.dart @@ -108,6 +108,7 @@ class ProfileEditPage extends HookConsumerWidget { onSave: onSave, shouldRetryOnError: (error) => error is! ProfileCommunityChangedException, + canPresent: () => context.mounted, onSaveError: () { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( diff --git a/mobile/lib/features/profile/profile_text_editor.dart b/mobile/lib/features/profile/profile_text_editor.dart index 9a43d423cdc..5fd8dc022a9 100644 --- a/mobile/lib/features/profile/profile_text_editor.dart +++ b/mobile/lib/features/profile/profile_text_editor.dart @@ -56,6 +56,7 @@ Future _showProfileTextEditor({ onSave: onSave, shouldRetryOnError: (error) => error is! ProfileCommunityChangedException, + canPresent: () => context.mounted, onSaveError: () { if (context.mounted) _showSaveError(context); }, diff --git a/mobile/test/features/profile/profile_edit_retry_test.dart b/mobile/test/features/profile/profile_edit_retry_test.dart index f87e2435a13..380b0f4aeab 100644 --- a/mobile/test/features/profile/profile_edit_retry_test.dart +++ b/mobile/test/features/profile/profile_edit_retry_test.dart @@ -82,6 +82,43 @@ void main() { debugDefaultTargetPlatformOverride = null; }); + testWidgets('native text retry stops when its owner unmounts', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + const channel = MethodChannel('buzz/profile_text_editor'); + var presentations = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + presentations++; + return 'Pending draft'; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, 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.pump(); + expect(notifier.displayNameAttempts, ['Pending draft']); + + await tester.pumpWidget(const SizedBox()); + notifier.failSave(); + await tester.pump(); + + expect(presentations, 1); + debugDefaultTargetPlatformOverride = null; + }); + testWidgets('Flutter text editor closes after a community switch', ( tester, ) async { @@ -302,6 +339,26 @@ class _CommunityChangedProfileNotifier extends ProfileNotifier { } } +class _DeferredFailureProfileNotifier extends ProfileNotifier { + final displayNameAttempts = []; + final _save = Completer(); + + @override + Future build() async => const UserProfile( + pubkey: 'aabb', + displayName: 'Alice', + about: 'Building Buzz', + ); + + @override + Future updateDisplayName(String displayName) { + displayNameAttempts.add(displayName); + return _save.future; + } + + void failSave() => _save.completeError(Exception('profile publish failed')); +} + class _RetryMediaUploadService extends MediaUploadService { _RetryMediaUploadService({ this.baseUrl = 'https://relay.example', From f3f2c2d885c0bbf508ea84317c8ba2a266874840 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 11:34:29 +0100 Subject: [PATCH 12/67] fix(mobile): finalize profile editor state Signed-off-by: kenny lopez --- .../profile/profile_avatar_editor.dart | 7 ++ .../features/profile/profile_provider.dart | 3 +- .../profile/profile_edit_page_test.dart | 27 ++++++++ .../profile/profile_provider_test.dart | 66 +++++++++++++++++++ 4 files changed, 101 insertions(+), 2 deletions(-) diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart index f4c80d1c40f..ed62b833c48 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -113,6 +113,13 @@ class ProfileAvatarEditor extends HookConsumerWidget { onAnimatedPrepareChanged(null); if (!reduceMotion) modeTransitionController.value = 0; onModeChanged(nextMode); + if (nextMode == ProfileAvatarMode.emoji) { + onDraftChanged( + ProfileUrlAvatarDraft( + emojiAvatarDataUrl(selectedEmoji.value, selectedColor.value), + ), + ); + } if (reduceMotion) { modeTransitionController.value = 1; } else { diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index 9bb3091abd4..a1cb55ecaf8 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -105,6 +105,7 @@ class ProfileNotifier extends AsyncNotifier { Map patch, _ProfileWriteContext context, ) async { + _requireCurrentWriteContext(context); if (!_hasHydrated || !state.hasValue) { throw StateError('Cannot update profile before metadata is loaded.'); } @@ -112,8 +113,6 @@ class ProfileNotifier extends AsyncNotifier { if (pubkey == null) { throw StateError('Cannot update profile without a signing identity.'); } - _requireCurrentWriteContext(context); - final session = context.session; final currentEvents = await session.fetchHistory( NostrFilters.profile(pubkey), diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index b968f9c0018..52e06873ed4 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -778,6 +778,33 @@ void main() { ); }); + testWidgets('saves the displayed default emoji without another selection', ( + tester, + ) async { + final notifier = _FakeProfileNotifier(); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(() => notifier)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Emoji')); + await tester.pump(const Duration(milliseconds: 250)); + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pumpAndSettle(); + + expect(notifier.savedAvatarUrls, hasLength(1)); + expect(notifier.savedAvatarUrls.single, startsWith('data:image/svg+xml,')); + expect( + Uri.decodeComponent(notifier.savedAvatarUrls.single), + contains('😊'), + ); + }); + testWidgets('keeps emoji drafts scoped to the emoji mode', (tester) async { final notifier = _FakeProfileNotifier(); await tester.pumpWidget( diff --git a/mobile/test/features/profile/profile_provider_test.dart b/mobile/test/features/profile/profile_provider_test.dart index fba40f85902..87084fb8653 100644 --- a/mobile/test/features/profile/profile_provider_test.dart +++ b/mobile/test/features/profile/profile_provider_test.dart @@ -315,6 +315,72 @@ void main() { expect(relaySession.published, isEmpty); }); + test( + 'queued profile updates report a community change before rehydration', + () async { + final keys = nostr.Keys.generate(); + final otherKeys = nostr.Keys.generate(); + final initial = NostrEvent( + id: 'profile-initial', + pubkey: keys.public, + createdAt: 10, + kind: EventKind.profile, + tags: const [], + content: jsonEncode({'display_name': 'Initial'}), + sig: 'sig', + ); + final patchFetchStarted = Completer(); + final patchHistory = Completer>(); + final rehydration = Completer>(); + var fetchCount = 0; + final relaySession = _ControlledProfileRelaySession( + fetch: () async { + fetchCount += 1; + if (fetchCount == 1) return [initial]; + if (fetchCount == 2) { + patchFetchStarted.complete(); + return patchHistory.future; + } + return rehydration.future; + }, + ); + final config = _MutableRelayConfigNotifier(keys.nsec); + final container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith(() => config), + relaySessionProvider.overrideWith(() => relaySession), + ], + ); + addTearDown(container.dispose); + + await container.read(profileProvider.future); + final firstUpdate = container + .read(profileProvider.notifier) + .updateDisplayName('First'); + await patchFetchStarted.future; + final queuedUpdate = container + .read(profileProvider.notifier) + .updateAbout('Queued'); + config.update( + baseUrl: 'https://other-relay.example', + nsec: otherKeys.nsec, + ); + await Future.delayed(Duration.zero); + patchHistory.complete([initial]); + + await expectLater( + firstUpdate, + throwsA(isA()), + ); + await expectLater( + queuedUpdate, + throwsA(isA()), + ); + expect(relaySession.published, isEmpty); + rehydration.complete(const []); + }, + ); + test( 'manual presence persists until Online restores automatic mode', () async { From 045bfef1c7ce8902f92b18e68b33f1d8cd424521 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 11:45:47 +0100 Subject: [PATCH 13/67] fix(mobile): isolate profile hydration context Signed-off-by: kenny lopez --- .../profile/profile_avatar_draft.dart | 19 ++++++ .../features/profile/profile_provider.dart | 39 ++++++++---- .../profile/profile_provider_test.dart | 63 +++++++++++++++++++ 3 files changed, 108 insertions(+), 13 deletions(-) diff --git a/mobile/lib/features/profile/profile_avatar_draft.dart b/mobile/lib/features/profile/profile_avatar_draft.dart index 55645f147a2..8fc48efaf3a 100644 --- a/mobile/lib/features/profile/profile_avatar_draft.dart +++ b/mobile/lib/features/profile/profile_avatar_draft.dart @@ -3,24 +3,38 @@ import 'dart:typed_data'; import '../../shared/animated_avatar.dart'; import '../../shared/relay/relay.dart'; +/// A prepared profile-avatar change that is uploaded only when the user saves. sealed class ProfileAvatarDraft { + /// Creates a prepared profile-avatar draft. const ProfileAvatarDraft(); + /// Returns the avatar URL for this draft using [service] when upload is + /// required. + /// + /// Implementations cache successful uploads for the same service so a + /// profile-publish retry does not create duplicate media. Failed uploads may + /// be retried, and changing services starts a new upload for that community. Future upload(MediaUploadService service); } +/// An avatar draft that already has its final URL and needs no media upload. final class ProfileUrlAvatarDraft extends ProfileAvatarDraft { + /// Creates a draft backed by [url]. const ProfileUrlAvatarDraft(this.url); + /// The URL that will be written to the profile. final String url; @override Future upload(MediaUploadService service) async => url; } +/// A locally prepared still image awaiting upload on Save. final class ProfileImageAvatarDraft extends ProfileAvatarDraft { + /// Creates a still-image draft from JPEG [bytes]. ProfileImageAvatarDraft(this.bytes); + /// The prepared JPEG payload. final Uint8List bytes; MediaUploadService? _uploadService; Future? _uploadedUrl; @@ -46,10 +60,15 @@ final class ProfileImageAvatarDraft extends ProfileAvatarDraft { } } +/// A locally prepared animated avatar and its still poster awaiting upload. final class ProfileAnimatedAvatarDraft extends ProfileAvatarDraft { + /// Creates an animated draft from PNG [animation] and [poster] payloads. ProfileAnimatedAvatarDraft({required this.animation, required this.poster}); + /// The animated PNG payload. final Uint8List animation; + + /// The still PNG poster shown when animation is unavailable or disabled. final Uint8List poster; MediaUploadService? _uploadService; Future? _uploadedUrl; diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index a1cb55ecaf8..18ca41d9ad1 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -26,32 +26,39 @@ class ProfileNotifier extends AsyncNotifier { @override Future build() { - ref.watch(relayConfigProvider); + final config = ref.watch(relayConfigProvider); + final pubkey = ref.watch(myPubkeyProvider); ref.watch(relaySessionProvider); + final context = _ProfileWriteContext( + config: config, + pubkey: pubkey, + session: ref.read(relaySessionProvider.notifier), + ); _hasHydrated = false; - return _fetch(); + return _fetch(context); } - Future _fetch() async { - final myPk = ref.read(myPubkeyProvider); + Future _fetch(_ProfileWriteContext context) async { + final myPk = context.pubkey; if (myPk == null) { + _requireCurrentWriteContext(context); _metadata = {}; _lastCreatedAt = 0; _hasHydrated = true; return null; } - final session = ref.read(relaySessionProvider.notifier); + final session = context.session; final events = await session.fetchHistory(NostrFilters.profile(myPk)); if (events.isEmpty) { + _requireCurrentWriteContext(context); _metadata = {}; _lastCreatedAt = 0; _hasHydrated = true; return null; } final latest = _latestProfileEvent(events)!; - _metadata = _decodeProfileMetadata(latest); - _lastCreatedAt = latest.createdAt; + final metadata = _decodeProfileMetadata(latest); final data = ProfileData.fromEvent(latest); final profile = UserProfile( pubkey: data.pubkey, @@ -60,13 +67,17 @@ class ProfileNotifier extends AsyncNotifier { about: data.about, nip05Handle: data.nip05, ); + _requireCurrentWriteContext(context); + _metadata = metadata; + _lastCreatedAt = latest.createdAt; _hasHydrated = true; return profile; } Future refresh() async { + final context = _currentWriteContext(); _hasHydrated = false; - state = await AsyncValue.guard(_fetch); + state = await AsyncValue.guard(() => _fetch(context)); } /// Updates the current user's display name while preserving the other @@ -83,11 +94,7 @@ class ProfileNotifier extends AsyncNotifier { _publishProfilePatch({'picture': avatarUrl.trim()}); Future _publishProfilePatch(Map patch) { - final context = _ProfileWriteContext( - config: ref.read(relayConfigProvider), - pubkey: ref.read(myPubkeyProvider), - session: ref.read(relaySessionProvider.notifier), - ); + final context = _currentWriteContext(); final previous = _patchQueue; final released = Completer(); _patchQueue = released.future; @@ -101,6 +108,12 @@ class ProfileNotifier extends AsyncNotifier { }(); } + _ProfileWriteContext _currentWriteContext() => _ProfileWriteContext( + config: ref.read(relayConfigProvider), + pubkey: ref.read(myPubkeyProvider), + session: ref.read(relaySessionProvider.notifier), + ); + Future _publishProfilePatchNow( Map patch, _ProfileWriteContext context, diff --git a/mobile/test/features/profile/profile_provider_test.dart b/mobile/test/features/profile/profile_provider_test.dart index 87084fb8653..939a94f7cf4 100644 --- a/mobile/test/features/profile/profile_provider_test.dart +++ b/mobile/test/features/profile/profile_provider_test.dart @@ -381,6 +381,69 @@ void main() { }, ); + test('stale hydration cannot overwrite the active community head', () async { + final keys = nostr.Keys.generate(); + final otherKeys = nostr.Keys.generate(); + final oldFetchStarted = Completer(); + final oldHistory = Completer>(); + final activeProfile = NostrEvent( + id: 'profile-active', + pubkey: otherKeys.public, + createdAt: 20, + kind: EventKind.profile, + tags: const [], + content: jsonEncode({'display_name': 'Active'}), + sig: 'sig', + ); + var fetchCount = 0; + final relaySession = _ControlledProfileRelaySession( + fetch: () async { + fetchCount += 1; + if (fetchCount == 1) { + oldFetchStarted.complete(); + return oldHistory.future; + } + return [activeProfile]; + }, + ); + final config = _MutableRelayConfigNotifier(keys.nsec); + final container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith(() => config), + relaySessionProvider.overrideWith(() => relaySession), + ], + ); + addTearDown(container.dispose); + + container.read(profileProvider); + await oldFetchStarted.future; + config.update(baseUrl: 'https://other-relay.example', nsec: otherKeys.nsec); + expect( + (await container.read(profileProvider.future))?.displayName, + 'Active', + ); + oldHistory.complete([ + NostrEvent( + id: 'profile-stale', + pubkey: keys.public, + createdAt: 100, + kind: EventKind.profile, + tags: const [], + content: jsonEncode({'display_name': 'Stale'}), + sig: 'sig', + ), + ]); + await Future.delayed(Duration.zero); + + await container.read(profileProvider.notifier).updateAbout('Active about'); + + expect(relaySession.published, hasLength(1)); + expect(jsonDecode(relaySession.published.single.content), { + 'display_name': 'Active', + 'about': 'Active about', + }); + }); + test( 'manual presence persists until Online restores automatic mode', () async { From dbb06eaf55cb3ec227f6a12a41ada449b29498f9 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 11:58:32 +0100 Subject: [PATCH 14/67] fix(mobile): keep native profile retries actionable Signed-off-by: kenny lopez --- mobile/ios/Runner/NativeProfileTextEditor.swift | 8 +------- .../lib/features/profile/ios_profile_text_editor.dart | 7 +++++++ .../test/features/profile/profile_edit_page_test.dart | 1 + .../test/features/profile/profile_edit_retry_test.dart | 10 ++++++++++ 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/mobile/ios/Runner/NativeProfileTextEditor.swift b/mobile/ios/Runner/NativeProfileTextEditor.swift index abccc693ad8..31b94613051 100644 --- a/mobile/ios/Runner/NativeProfileTextEditor.swift +++ b/mobile/ios/Runner/NativeProfileTextEditor.swift @@ -37,8 +37,7 @@ final class NativeProfileTextEditorCoordinator: NSObject, let title = arguments["title"] as? String, let initialValue = arguments["initialValue"] as? String, let placeholder = arguments["placeholder"] as? String, - let multiline = arguments["multiline"] as? Bool, - let brightness = arguments["brightness"] as? String + let multiline = arguments["multiline"] as? Bool else { result( FlutterError( @@ -58,7 +57,6 @@ final class NativeProfileTextEditorCoordinator: NSObject, initialValue: initialValue, placeholder: placeholder, multiline: multiline, - brightness: brightness, allowUnchangedSubmission: allowUnchangedSubmission, result: result ) @@ -71,7 +69,6 @@ final class NativeProfileTextEditorCoordinator: NSObject, initialValue: String, placeholder: String, multiline: Bool, - brightness: String, allowUnchangedSubmission: Bool, result: @escaping FlutterResult ) { @@ -110,9 +107,6 @@ final class NativeProfileTextEditorCoordinator: NSObject, onSet: { [weak self] value in self?.finish(value: value) } ) let navigationController = UINavigationController(rootViewController: editor) - navigationController.overrideUserInterfaceStyle = brightness == "dark" - ? .dark - : .light if UIDevice.current.userInterfaceIdiom == .pad { navigationController.modalPresentationStyle = .formSheet } diff --git a/mobile/lib/features/profile/ios_profile_text_editor.dart b/mobile/lib/features/profile/ios_profile_text_editor.dart index d2939ae2a89..d957b236aa9 100644 --- a/mobile/lib/features/profile/ios_profile_text_editor.dart +++ b/mobile/lib/features/profile/ios_profile_text_editor.dart @@ -6,16 +6,20 @@ class IosProfileTextEditor { static const _channel = MethodChannel('buzz/profile_text_editor'); + /// Presents the native editor and returns its submitted value, or null when + /// the user cancels. static Future present({ required String title, required String initialValue, required String placeholder, required bool multiline, + bool allowUnchangedSubmission = false, }) => _channel.invokeMethod('present', { 'title': title, 'initialValue': initialValue, 'placeholder': placeholder, 'multiline': multiline, + 'allowUnchangedSubmission': allowUnchangedSubmission, }); /// Keeps the native editor's latest value available until it saves or the @@ -31,6 +35,7 @@ class IosProfileTextEditor { bool Function()? canPresent, }) async { var draft = initialValue; + var isRetry = false; while (true) { if (canPresent?.call() == false) return; final value = await present( @@ -38,6 +43,7 @@ class IosProfileTextEditor { initialValue: draft, placeholder: placeholder, multiline: multiline, + allowUnchangedSubmission: isRetry, ); if (value == null) return; try { @@ -49,6 +55,7 @@ class IosProfileTextEditor { return; } draft = value; + isRetry = true; onSaveError(); } } diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index 52e06873ed4..a385c785acd 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -220,6 +220,7 @@ void main() { 'initialValue': 'Alice', 'placeholder': 'Display name', 'multiline': false, + 'allowUnchangedSubmission': false, }); expect(notifier.savedDisplayNames, ['Alice Native']); debugDefaultTargetPlatformOverride = null; diff --git a/mobile/test/features/profile/profile_edit_retry_test.dart b/mobile/test/features/profile/profile_edit_retry_test.dart index 380b0f4aeab..2a8384d3829 100644 --- a/mobile/test/features/profile/profile_edit_retry_test.dart +++ b/mobile/test/features/profile/profile_edit_retry_test.dart @@ -46,6 +46,16 @@ void main() { (calls.last.arguments as Map)['initialValue'], 'Alice Retained', ); + expect( + (calls.first.arguments + as Map)['allowUnchangedSubmission'], + isFalse, + ); + expect( + (calls.last.arguments + as Map)['allowUnchangedSubmission'], + isTrue, + ); debugDefaultTargetPlatformOverride = null; }); From f9894f9a0cbd9aeafe321e2114743a03c8e1694a Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 12:10:44 +0100 Subject: [PATCH 15/67] fix(mobile): isolate profile editor sessions Signed-off-by: kenny lopez --- .../profile/animated_avatar_capture.dart | 79 +------------------ .../features/profile/profile_text_editor.dart | 35 +++++++- .../profile/animated_avatar_capture_test.dart | 17 ++++ .../profile/profile_edit_retry_test.dart | 53 +++++++++++++ 4 files changed, 104 insertions(+), 80 deletions(-) diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart index cc386ed4081..97ec87dfeb7 100644 --- a/mobile/lib/features/profile/animated_avatar_capture.dart +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -24,6 +24,7 @@ import 'profile_avatar_draft.dart'; part 'animated_avatar_capture/review_controls.dart'; part 'animated_avatar_capture/capture_controls.dart'; +part 'animated_avatar_capture/frame_processing.dart'; const _captureDuration = Duration(seconds: 3); const _captureFrameInterval = Duration(milliseconds: 125); @@ -588,48 +589,6 @@ List _resampleCapturedFrames( }, growable: false); } -Future> _removeBackgrounds(List frames) async { - final segmenter = SelfieSegmenter( - mode: SegmenterMode.stream, - enableRawSizeMask: false, - ); - final directory = await getTemporaryDirectory(); - final results = []; - try { - for (var index = 0; index < frames.length; index++) { - final file = File('${directory.path}/buzz-avatar-frame-$index.png'); - try { - await file.writeAsBytes(frames[index], flush: false); - final mask = await segmenter.processImage( - InputImage.fromFilePath(file.path), - ); - if (mask == null) { - results.add(frames[index]); - continue; - } - results.add( - await compute( - _applySegmentationMask, - _MaskRequest( - frame: frames[index], - maskWidth: mask.width, - maskHeight: mask.height, - confidences: Float32List.fromList(mask.confidences), - ), - ), - ); - } finally { - if (await file.exists()) { - await file.delete().catchError((_) => file); - } - } - } - } finally { - await segmenter.close(); - } - return results; -} - class _ErrorText extends StatelessWidget { const _ErrorText(this.message); @@ -662,42 +621,6 @@ class _FramePlane { final int bytesPerPixel; } -@immutable -class _MaskRequest { - const _MaskRequest({ - required this.frame, - required this.maskWidth, - required this.maskHeight, - required this.confidences, - }); - - final Uint8List frame; - final int maskWidth; - final int maskHeight; - final Float32List confidences; -} - -Uint8List _applySegmentationMask(_MaskRequest request) { - final result = image.decodePng(request.frame)!.convert(numChannels: 4); - for (var y = 0; y < result.height; y++) { - final maskY = (y * request.maskHeight / result.height).floor().clamp( - 0, - request.maskHeight - 1, - ); - for (var x = 0; x < result.width; x++) { - final maskX = (x * request.maskWidth / result.width).floor().clamp( - 0, - request.maskWidth - 1, - ); - final confidence = request.confidences[maskY * request.maskWidth + maskX]; - final alpha = ((confidence - 0.28) / (0.72 - 0.28)).clamp(0, 1); - final pixel = result.getPixel(x, y); - pixel.a = (alpha * 255).round(); - } - } - return image.encodePng(result, level: 4); -} - @immutable class _FrameRequest { const _FrameRequest({ diff --git a/mobile/lib/features/profile/profile_text_editor.dart b/mobile/lib/features/profile/profile_text_editor.dart index 5fd8dc022a9..cff7624614d 100644 --- a/mobile/lib/features/profile/profile_text_editor.dart +++ b/mobile/lib/features/profile/profile_text_editor.dart @@ -6,6 +6,7 @@ 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'; @@ -15,12 +16,16 @@ import 'profile_provider.dart'; Future showProfileDisplayNameEditor(BuildContext context) async { final container = ProviderScope.containerOf(context, listen: false); final profile = container.read(profileProvider).asData?.value; + final onSave = _bindSaveToOpeningContext( + container, + container.read(profileProvider.notifier).updateDisplayName, + ); await _showProfileTextEditor( context: context, title: 'Display name', initialValue: profile?.displayName ?? '', hintText: 'Display name', - onSave: container.read(profileProvider.notifier).updateDisplayName, + onSave: onSave, ); } @@ -28,16 +33,42 @@ Future showProfileDisplayNameEditor(BuildContext context) async { Future showProfileDescriptionEditor(BuildContext context) async { final container = ProviderScope.containerOf(context, listen: false); final profile = container.read(profileProvider).asData?.value; + final onSave = _bindSaveToOpeningContext( + container, + container.read(profileProvider.notifier).updateAbout, + ); await _showProfileTextEditor( context: context, title: 'Profile description', initialValue: profile?.about ?? '', hintText: 'Profile description', multiline: true, - onSave: container.read(profileProvider.notifier).updateAbout, + onSave: onSave, ); } +Future Function(String) _bindSaveToOpeningContext( + ProviderContainer container, + Future Function(String value) onSave, +) { + final openingConfig = container.read(relayConfigProvider); + final openingPubkey = container.read(myPubkeyProvider); + final openingSession = container.read(relaySessionProvider.notifier); + return (value) { + final currentConfig = container.read(relayConfigProvider); + final isCurrent = + currentConfig.storedOrigin == openingConfig.storedOrigin && + currentConfig.nsec == openingConfig.nsec && + container.read(myPubkeyProvider) == openingPubkey && + identical( + container.read(relaySessionProvider.notifier), + openingSession, + ); + if (!isCurrent) throw ProfileCommunityChangedException(); + return onSave(value); + }; +} + Future _showProfileTextEditor({ required BuildContext context, required String title, diff --git a/mobile/test/features/profile/animated_avatar_capture_test.dart b/mobile/test/features/profile/animated_avatar_capture_test.dart index e4b0d1f03b2..58eb40a8752 100644 --- a/mobile/test/features/profile/animated_avatar_capture_test.dart +++ b/mobile/test/features/profile/animated_avatar_capture_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:buzz/features/profile/animated_avatar_orientation.dart'; import 'package:buzz/features/profile/animated_avatar_capture.dart'; import 'package:buzz/features/profile/profile_avatar_draft.dart'; @@ -11,6 +13,21 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image/image.dart' as image; void main() { + test('capture frame workspaces are isolated', () async { + final first = await createAnimatedAvatarFrameDirectory( + parent: Directory.systemTemp, + ); + final second = await createAnimatedAvatarFrameDirectory( + parent: Directory.systemTemp, + ); + addTearDown(() async { + if (await first.exists()) await first.delete(recursive: true); + if (await second.exists()) await second.delete(recursive: true); + }); + + expect(first.path, isNot(second.path)); + }); + group('animatedAvatarFrameRotationDegrees', () { test('compensates front camera frames for every device orientation', () { const expected = { diff --git a/mobile/test/features/profile/profile_edit_retry_test.dart b/mobile/test/features/profile/profile_edit_retry_test.dart index 2a8384d3829..e85b0307780 100644 --- a/mobile/test/features/profile/profile_edit_retry_test.dart +++ b/mobile/test/features/profile/profile_edit_retry_test.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:buzz/features/profile/profile_avatar_draft.dart'; import 'package:buzz/features/profile/profile_edit_page.dart'; import 'package:buzz/features/profile/profile_provider.dart'; +import 'package:buzz/features/profile/profile_text_editor.dart'; import 'package:buzz/shared/profile/user_profile.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:flutter/foundation.dart'; @@ -10,6 +11,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../helpers/widget_helpers.dart'; @@ -92,6 +94,57 @@ void main() { debugDefaultTargetPlatformOverride = null; }); + testWidgets('native text editor rejects its first save after a switch', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + const channel = MethodChannel('buzz/profile_text_editor'); + final submission = Completer(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) => submission.future); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + final notifier = _RetryProfileNotifier(); + final config = _MutableRelayConfigNotifier(); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(() => notifier), + relayConfigProvider.overrideWith(() => config), + ], + child: Builder( + builder: (context) => TextButton( + onPressed: () => unawaited(showProfileDisplayNameEditor(context)), + child: const Text('Open editor'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final container = ProviderScope.containerOf( + tester.element(find.byType(TextButton)), + ); + final configSubscription = container.listen( + relayConfigProvider, + (_, _) {}, + fireImmediately: true, + ); + addTearDown(configSubscription.close); + await tester.tap(find.text('Open editor')); + await tester.pump(); + + config.update(baseUrl: 'https://second.example', nsec: 'second-identity'); + submission.complete('Old community draft'); + await tester.pumpAndSettle(); + + expect(notifier.displayNameAttempts, isEmpty); + debugDefaultTargetPlatformOverride = null; + }); + testWidgets('native text retry stops when its owner unmounts', ( tester, ) async { From 799a768d85e78806b2ddbb2e6b0416c9ee5f1809 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 12:23:19 +0100 Subject: [PATCH 16/67] fix(mobile): preserve avatar editing context Signed-off-by: kenny lopez --- .../profile/animated_avatar_capture.dart | 55 ++++++++++--- .../features/profile/profile_edit_page.dart | 22 +++++- .../profile/animated_avatar_capture_test.dart | 14 ++++ .../profile/profile_edit_retry_test.dart | 77 ++++++++++++++++--- 4 files changed, 143 insertions(+), 25 deletions(-) diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart index 97ec87dfeb7..1a330596f6c 100644 --- a/mobile/lib/features/profile/animated_avatar_capture.dart +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -837,21 +837,34 @@ _EncodedAvatar _encodeAvatar(_EncodeRequest request) { final composed = request.frames .map((bytes) { final source = image.decodePng(bytes)!; - final cropSize = (source.width / request.scale).round().clamp( + final scaledSize = (_outputSize * request.scale).round().clamp( 1, - source.width, + _outputSize * 2, ); - final available = source.width - cropSize; - final x = ((available / 2) - request.offsetX * available / 2) - .round() - .clamp(0, available); - final y = ((available / 2) - request.offsetY * available / 2) - .round() - .clamp(0, available); - final person = image.copyResize( - image.copyCrop(source, x: x, y: y, width: cropSize, height: cropSize), + final scaledPerson = image.copyResize( + source, + width: scaledSize, + height: scaledSize, + ); + final person = image.Image( width: _outputSize, height: _outputSize, + numChannels: 4, + ); + const previewSize = 220.0; + const previewTranslation = 48.0; + final translationScale = _outputSize / previewSize; + image.compositeImage( + person, + scaledPerson, + dstX: + ((_outputSize - scaledSize) / 2 + + request.offsetX * previewTranslation * translationScale) + .round(), + dstY: + ((_outputSize - scaledSize) / 2 + + request.offsetY * previewTranslation * translationScale) + .round(), ); final frame = image.Image( width: _outputSize, @@ -914,6 +927,26 @@ _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/profile_edit_page.dart b/mobile/lib/features/profile/profile_edit_page.dart index 3ef9baaaa7a..3008596aa0a 100644 --- a/mobile/lib/features/profile/profile_edit_page.dart +++ b/mobile/lib/features/profile/profile_edit_page.dart @@ -50,6 +50,9 @@ class ProfileEditPage extends HookConsumerWidget { final isEditingAvatar = useState(startInPhotoEditor); final avatarDraft = useState(null); final avatarDraftMode = useState(null); + final avatarEditConfig = useRef( + startInPhotoEditor ? ref.read(relayConfigProvider) : null, + ); final isSavingAvatar = useState(false); final prepareAnimatedAvatar = useRef Function()?>(null); @@ -137,6 +140,7 @@ class ProfileEditPage extends HookConsumerWidget { void openAvatarEditor() { if (!profileHydrated) return; + avatarEditConfig.value = ref.read(relayConfigProvider); isEditingAvatar.value = true; unawaited(avatarTransition.forward(from: 0)); } @@ -152,6 +156,7 @@ class ProfileEditPage extends HookConsumerWidget { isEditingAvatar.value = false; avatarDraft.value = null; avatarDraftMode.value = null; + avatarEditConfig.value = null; prepareAnimatedAvatar.value = null; canPrepareAnimatedAvatar.value = false; avatarMode.value = ProfileAvatarMode.image; @@ -159,23 +164,26 @@ class ProfileEditPage extends HookConsumerWidget { Future saveAvatar() async { if (isSavingAvatar.value) return; + final openingConfig = avatarEditConfig.value; + if (openingConfig == null) return; final saveConfig = ref.read(relayConfigProvider); final uploadService = ref.read(mediaUploadServiceProvider); void requireCurrentCommunity() { final currentConfig = ref.read(relayConfigProvider); - if (currentConfig.storedOrigin != saveConfig.storedOrigin || + if (currentConfig.storedOrigin != openingConfig.storedOrigin || + currentConfig.nsec != openingConfig.nsec || + currentConfig.storedOrigin != saveConfig.storedOrigin || currentConfig.nsec != saveConfig.nsec || !identical(ref.read(mediaUploadServiceProvider), uploadService)) { - throw StateError( - 'Profile photo save cancelled because the active community changed.', - ); + throw ProfileCommunityChangedException(); } } isSavingAvatar.value = true; avatarSaveError.value = null; try { + requireCurrentCommunity(); var nextDraft = avatarDraftMode.value == avatarMode.value ? avatarDraft.value : null; @@ -195,6 +203,12 @@ class ProfileEditPage extends HookConsumerWidget { await ref.read(profileProvider.notifier).updateAvatarUrl(nextAvatar); requireCurrentCommunity(); if (context.mounted) await closeAvatarEditor(whileSaving: true); + } on ProfileCommunityChangedException { + avatarDraft.value = null; + avatarDraftMode.value = null; + prepareAnimatedAvatar.value = null; + canPrepareAnimatedAvatar.value = false; + if (context.mounted) await closeAvatarEditor(whileSaving: true); } catch (_) { avatarSaveError.value = "We couldn't save your profile photo. Try again."; diff --git a/mobile/test/features/profile/animated_avatar_capture_test.dart b/mobile/test/features/profile/animated_avatar_capture_test.dart index 58eb40a8752..c516a4d5909 100644 --- a/mobile/test/features/profile/animated_avatar_capture_test.dart +++ b/mobile/test/features/profile/animated_avatar_capture_test.dart @@ -13,6 +13,20 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image/image.dart' as image; void main() { + test('encoded poster preserves avatar scales below 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: 0.75), + )!; + + final edge = poster.getPixel(8, 128); + final center = poster.getPixel(128, 128); + expect(edge.r, isNot(255)); + expect(center.r, 255); + }); + test('capture frame workspaces are isolated', () async { final first = await createAnimatedAvatarFrameDirectory( parent: Directory.systemTemp, diff --git a/mobile/test/features/profile/profile_edit_retry_test.dart b/mobile/test/features/profile/profile_edit_retry_test.dart index e85b0307780..96f4c581562 100644 --- a/mobile/test/features/profile/profile_edit_retry_test.dart +++ b/mobile/test/features/profile/profile_edit_retry_test.dart @@ -268,7 +268,71 @@ void main() { debugDefaultTargetPlatformOverride = null; }); - testWidgets('avatar retry reuploads after a community switch', ( + testWidgets('avatar draft cannot save after a prior community switch', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final notifier = _RetryProfileNotifier(); + final config = _MutableRelayConfigNotifier(); + final firstUpload = _RetryMediaUploadService( + baseUrl: 'https://first.example', + ); + final secondUpload = _RetryMediaUploadService( + baseUrl: 'https://second.example', + ); + addTearDown(firstUpload.dispose); + addTearDown(secondUpload.dispose); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(() => notifier), + relayConfigProvider.overrideWith(() => config), + mediaUploadServiceProvider.overrideWith((ref) { + final current = ref.watch(relayConfigProvider); + return current.baseUrl == 'https://first.example' + ? firstUpload + : secondUpload; + }), + ], + child: ProfileEditPage( + startInPhotoEditor: true, + animatedAvatarCaptureBuilder: + ({required height, required onPrepareChanged}) => HookBuilder( + builder: (context) { + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + onPrepareChanged( + () async => ProfileImageAvatarDraft( + Uint8List.fromList([1, 2, 3]), + ), + ); + }); + return null; + }, const []); + return SizedBox(height: height); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Animated')); + await tester.pumpAndSettle(); + + config.update(baseUrl: 'https://second.example', nsec: 'second-identity'); + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pumpAndSettle(); + + expect(firstUpload.uploadCount, 0); + expect(secondUpload.uploadCount, 0); + expect(notifier.savedAvatarUrls, isEmpty); + expect(find.byKey(const ValueKey('avatar-save')), findsNothing); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('avatar editor closes when community changes during save', ( tester, ) async { debugDefaultTargetPlatformOverride = TargetPlatform.android; @@ -330,15 +394,8 @@ void main() { await tester.pumpAndSettle(); expect(notifier.savedAvatarUrls, isEmpty); - expect( - find.text("We couldn't save your profile photo. Try again."), - findsOneWidget, - ); - - await tester.tap(find.byKey(const ValueKey('avatar-save'))); - await tester.pumpAndSettle(); - expect(secondUpload.uploadCount, 1); - expect(notifier.savedAvatarUrls, ['https://second.example/avatar.jpg']); + expect(secondUpload.uploadCount, 0); + expect(find.byKey(const ValueKey('avatar-save')), findsNothing); debugDefaultTargetPlatformOverride = null; }); } From 2e39c0a0f55c08e5873f66b9200f6bcff613b048 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 12:36:54 +0100 Subject: [PATCH 17/67] test(mobile): split profile editor coverage Signed-off-by: kenny lopez --- .../profile/profile_edit_page_test.dart | 209 +----------- .../motion_and_accessibility_tests.dart | 318 ------------------ 2 files changed, 3 insertions(+), 524 deletions(-) diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index a385c785acd..1fb0427caf9 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -27,6 +27,8 @@ import 'package:image_picker/image_picker.dart'; import '../../helpers/widget_helpers.dart'; +part 'profile_edit_page_test/motion_and_accessibility_tests.dart'; + const _editorControlBottomForTest = Grid.xl + Grid.xxs; void main() { @@ -836,212 +838,7 @@ void main() { ); }); - testWidgets('moves segment content in the selected direction', ( - tester, - ) async { - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], - child: const ProfileEditPage(), - ), - ); - await tester.pumpAndSettle(); - await tester.tap(find.text('Edit Photo')); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Emoji')); - await tester.pump(); - final forwardTransform = tester.widget( - find.byKey(const ValueKey('avatar-mode-transition-transform')), - ); - expect(forwardTransform.transform.getTranslation().x, greaterThan(0)); - await tester.pump(const Duration(milliseconds: 240)); - expect( - tester - .widget( - find.byKey(const ValueKey('avatar-mode-transition-transform')), - ) - .transform - .getTranslation() - .x, - closeTo(0, 0.01), - ); - - await tester.tap(find.text('Image')); - await tester.pump(); - final reverseTransform = tester.widget( - find.byKey(const ValueKey('avatar-mode-transition-transform')), - ); - expect(reverseTransform.transform.getTranslation().x, lessThan(0)); - }); - - testWidgets('plays an animated avatar on the profile and image editor', ( - tester, - ) async { - const avatar = - 'https://relay.example/poster.png#buzz-anim=https%3A%2F%2Frelay.example%2Fanimation.png'; - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [ - profileProvider.overrideWith( - () => _FakeProfileNotifier( - profile: const UserProfile( - pubkey: 'aabb', - displayName: 'Alice', - about: 'Building Buzz', - avatarUrl: avatar, - ), - ), - ), - ], - child: const ProfileEditPage(), - ), - ); - await tester.pump(); - - expect(find.byType(PlayingAvatarImage), findsOneWidget); - expect(find.byType(ProgressiveAnimatedAvatar), findsOneWidget); - - await tester.tap(find.text('Edit Photo')); - await tester.pump(); - expect(find.byType(PlayingAvatarImage), findsOneWidget); - expect(find.byType(ProgressiveAnimatedAvatar), findsOneWidget); - }); - - testWidgets('shows only the animated-avatar poster with Reduce Motion', ( - tester, - ) async { - const avatar = - 'https://relay.example/poster.png#buzz-anim=https%3A%2F%2Frelay.example%2Fanimation.png'; - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [ - profileProvider.overrideWith( - () => _FakeProfileNotifier( - profile: const UserProfile( - pubkey: 'aabb', - displayName: 'Alice', - avatarUrl: avatar, - ), - ), - ), - ], - child: const MediaQuery( - data: MediaQueryData(disableAnimations: true), - child: ProfileEditPage(), - ), - ), - ); - await tester.pump(); - - expect(find.byType(ProgressiveAnimatedAvatar), findsNothing); - expect( - tester.widget(find.byType(AvatarImage)).imageUrl, - 'https://relay.example/poster.png', - ); - }); - - testWidgets('keeps emoji actions anchored when search opens the keyboard', ( - tester, - ) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(800, 900); - addTearDown(tester.view.reset); - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], - child: const ProfileEditPage(), - ), - ); - await tester.pumpAndSettle(); - await tester.tap(find.text('Edit Photo')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Emoji')); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 150)); - - final action = find.byKey(const ValueKey('emoji-editor-background')); - final actionBottomBefore = tester.getRect(action).bottom; - await tester.tap(find.byKey(const ValueKey('emoji-avatar-search'))); - tester.view.viewInsets = const FakeViewPadding(bottom: 300); - await tester.pump(); - - expect(tester.getRect(action).bottom, actionBottomBefore); - expect( - tester.getRect(find.byKey(const ValueKey('emoji-avatar-search'))).bottom, - lessThan(600), - ); - expect( - tester - .widgetList(find.byType(Scaffold)) - .any((scaffold) => scaffold.resizeToAvoidBottomInset == false), - isTrue, - ); - }); - - testWidgets('uses high-contrast inverse colors for avatar action icons', ( - tester, - ) async { - final theme = AppTheme.dark(); - await tester.pumpWidget( - MaterialApp( - theme: theme, - home: Scaffold( - body: Row( - children: [ - Expanded( - child: AvatarEditorOptionButton( - icon: Icons.palette, - label: 'Inactive', - selected: false, - onTap: () {}, - ), - ), - Expanded( - child: AvatarEditorOptionButton( - icon: Icons.face, - label: 'Active', - selected: true, - onTap: () {}, - ), - ), - ], - ), - ), - ), - ); - - expect( - tester.widget(find.byIcon(Icons.palette)).color, - theme.colorScheme.onSurface, - ); - expect( - tester.widget(find.byIcon(Icons.face)).color, - theme.colorScheme.surface, - ); - }); - - testWidgets('uses the shared animated background grid for emoji avatars', ( - tester, - ) async { - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], - child: const ProfileEditPage(), - ), - ); - await tester.pump(); - await tester.tap(find.text('Edit Photo')); - await tester.pump(const Duration(milliseconds: 250)); - await tester.tap(find.text('Emoji')); - await tester.pump(const Duration(milliseconds: 250)); - await tester.tap(find.byKey(const ValueKey('emoji-editor-background'))); - await tester.pump(const Duration(milliseconds: 200)); - - expect(find.byType(AvatarBackgroundGrid), findsOneWidget); - final firstColor = find.byKey(const ValueKey('emoji-avatar-color-0')); - expect(tester.getSize(firstColor), const Size.square(52)); - }); + runProfileEditMotionAndAccessibilityTests(); } Future _waitForAvatarCropToClose(WidgetTester 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..9c10084e225 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 @@ -1,194 +1,6 @@ part of '../profile_edit_page_test.dart'; void runProfileEditMotionAndAccessibilityTests() { - testWidgets('photo modes remain usable on a compact large-type viewport', ( - tester, - ) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(320, 568); - addTearDown(tester.view.resetDevicePixelRatio); - addTearDown(tester.view.resetPhysicalSize); - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], - child: const MediaQuery( - data: MediaQueryData(textScaler: TextScaler.linear(2)), - child: ProfileEditPage(startInPhotoEditor: true), - ), - ), - ); - await tester.pump(const Duration(milliseconds: 250)); - expect(tester.takeException(), isNull); - expect( - find.byKey(const ValueKey('avatar-editor-scroll-view')), - findsOneWidget, - ); - - await tester.tap(find.text('Emoji')); - await tester.pump(const Duration(milliseconds: 250)); - expect(tester.takeException(), isNull); - await tester.ensureVisible( - find.byKey(const ValueKey('emoji-editor-background')), - ); - await tester.tap(find.byKey(const ValueKey('emoji-editor-background'))); - await tester.pump(const Duration(milliseconds: 200)); - expect(tester.takeException(), isNull); - - await tester.drag( - find.byKey(const ValueKey('avatar-editor-scroll-view')), - const Offset(0, 1000), - ); - await tester.pump(); - final animatedMode = find.byKey(const ValueKey('avatar-mode-animated')); - await Scrollable.ensureVisible( - animatedMode.evaluate().single, - alignment: 0.2, - ); - await tester.tap(animatedMode); - await tester.pump(const Duration(milliseconds: 250)); - expect(tester.takeException(), isNull); - expect( - find.byKey(const ValueKey('animated-avatar-capture-preview')), - findsOneWidget, - ); - }); - - 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); - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], - child: const ProfileEditPage(), - ), - ); - await tester.pumpAndSettle(); - await tester.tap(find.text('Edit Photo')); - await tester.pumpAndSettle(); - - Iterable modeSemantics(String label) => - tester.widgetList( - find.byWidgetPredicate( - (widget) => - widget is Semantics && - widget.properties.label == label && - widget.child is ExcludeSemantics, - ), - ); - - expect(modeSemantics('Image'), isNotEmpty); - expect( - modeSemantics('Image').every((node) => node.properties.selected == true), - isTrue, - ); - expect( - modeSemantics('Emoji').every((node) => node.properties.selected == false), - isTrue, - ); - await tester.tap(find.byKey(const ValueKey('avatar-mode-emoji'))); - await tester.pump(); - expect( - modeSemantics('Image').every((node) => node.properties.selected == false), - isTrue, - ); - expect( - modeSemantics('Emoji').every((node) => node.properties.selected == true), - isTrue, - ); - debugDefaultTargetPlatformOverride = null; - }); - testWidgets('moves segment content in the selected direction', ( tester, ) async { @@ -204,11 +16,6 @@ void runProfileEditMotionAndAccessibilityTests() { await tester.tap(find.text('Emoji')); await tester.pump(); - final trayTransform = tester.widget( - find.byKey(const ValueKey('avatar-mode-tray-transition-transform')), - ); - expect(trayTransform.transform.getTranslation().x, 0); - expect(trayTransform.transform.getTranslation().y, greaterThan(0)); final forwardTransform = tester.widget( find.byKey(const ValueKey('avatar-mode-transition-transform')), ); @@ -224,16 +31,6 @@ void runProfileEditMotionAndAccessibilityTests() { .x, closeTo(0, 0.01), ); - expect( - tester - .widget( - find.byKey(const ValueKey('avatar-mode-tray-transition-transform')), - ) - .transform - .getTranslation() - .y, - closeTo(0, 0.01), - ); await tester.tap(find.text('Image')); await tester.pump(); @@ -243,57 +40,6 @@ void runProfileEditMotionAndAccessibilityTests() { expect(reverseTransform.transform.getTranslation().x, lessThan(0)); }); - testWidgets('retains the preview while animated mode initializes', ( - tester, - ) async { - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], - child: const ProfileEditPage(), - ), - ); - await tester.pumpAndSettle(); - await tester.tap(find.text('Edit Photo')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Emoji')); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 150)); - final emojiCenter = tester - .getCenter(find.byKey(const ValueKey('emoji-avatar-preview'))) - .dy; - - await tester.tap(find.text('Animated')); - await tester.pump(); - final retained = find.byKey(const ValueKey('avatar-mode-retained-preview')); - expect(retained, findsOneWidget); - expect(tester.getCenter(retained).dy, closeTo(emojiCenter, 0.01)); - - await tester.pump(const Duration(milliseconds: 75)); - expect(tester.getCenter(retained).dy, greaterThan(emojiCenter)); - await tester.pump(const Duration(milliseconds: 75)); - expect(retained, findsNothing); - - final animatedCenter = tester - .getCenter( - find.byKey(const ValueKey('animated-avatar-capture-preview')), - ) - .dy; - await tester.tap(find.text('Emoji')); - await tester.pump(); - final returningPreview = find.byKey( - const ValueKey('avatar-preview-position'), - ); - expect( - tester.getCenter(returningPreview).dy, - closeTo(animatedCenter, 0.01), - ); - - await tester.pump(const Duration(milliseconds: 75)); - expect(tester.getCenter(returningPreview).dy, lessThan(animatedCenter)); - await tester.pump(const Duration(milliseconds: 75)); - expect(tester.getCenter(returningPreview).dy, closeTo(emojiCenter, 0.01)); - }); - testWidgets('plays an animated avatar on the profile and image editor', ( tester, ) async { @@ -438,38 +184,6 @@ void runProfileEditMotionAndAccessibilityTests() { tester.widget(find.byIcon(Icons.face)).color, theme.colorScheme.surface, ); - final selectedSemantics = tester.widget( - find.byWidgetPredicate( - (widget) => widget is Semantics && widget.properties.label == 'Active', - ), - ); - expect(selectedSemantics.properties.button, isTrue); - expect(selectedSemantics.properties.selected, isTrue); - }); - - testWidgets('exposes the selected emoji tile', (tester) async { - await tester.pumpWidget( - WidgetHelpers.testable( - child: EmojiAvatarTile( - emoji: '😊', - label: 'Smiling Face', - tileId: 'smile', - isSelected: true, - onTap: () {}, - ), - ), - ); - - final selectedTile = tester.widget( - find.byWidgetPredicate( - (widget) => - widget is Semantics && - widget.properties.label == 'Smiling Face' && - widget.child is ExcludeSemantics, - ), - ); - expect(selectedTile.properties.button, isTrue); - expect(selectedTile.properties.selected, isTrue); }); testWidgets('uses the shared animated background grid for emoji avatars', ( @@ -493,36 +207,4 @@ void runProfileEditMotionAndAccessibilityTests() { final firstColor = find.byKey(const ValueKey('emoji-avatar-color-0')); expect(tester.getSize(firstColor), const Size.square(52)); }); - - testWidgets('background colors remain reachable in compact layouts', ( - tester, - ) async { - await tester.pumpWidget( - WidgetHelpers.testable( - child: SizedBox( - height: 150, - child: AvatarBackgroundGrid( - selectedColor: emojiAvatarColors.first, - onColorSelected: (_) {}, - ), - ), - ), - ); - - final scrollable = tester.state( - find.byType(Scrollable).first, - ); - expect(scrollable.position.maxScrollExtent, greaterThan(0)); - - await tester.drag(find.byType(AvatarBackgroundGrid), const Offset(0, -400)); - await tester.pumpAndSettle(); - - expect(scrollable.position.pixels, greaterThan(0)); - expect( - find.byKey( - ValueKey('avatar-background-color-${emojiAvatarColors.length - 1}'), - ), - findsOneWidget, - ); - }); } From ff8a34b1ef3f898bd2611d10d405f76f8ff54d0b Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 12:49:08 +0100 Subject: [PATCH 18/67] fix(mobile): preserve profile event tags Signed-off-by: kenny lopez --- .../profile/profile_avatar_editor.dart | 18 ++++++++++++++++++ .../lib/features/profile/profile_provider.dart | 2 +- .../profile/profile_provider_test.dart | 7 ++++++- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart index ed62b833c48..5351e19ad48 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -58,6 +58,7 @@ const double _settingsAvatarCenterBelowAppBar = 96; /// In-page profile avatar editor used on Android and iOS. class ProfileAvatarEditor extends HookConsumerWidget { + /// Creates an avatar editor backed by the current profile and draft state. const ProfileAvatarEditor({ super.key, required this.currentAvatarUrl, @@ -71,15 +72,32 @@ class ProfileAvatarEditor extends HookConsumerWidget { this.animatedCaptureBuilder, }); + /// The avatar URL shown until the user selects a new draft. final String? currentAvatarUrl; + + /// The text initial used when [currentAvatarUrl] has no displayable image. final String fallbackInitial; + + /// The unsaved avatar selection for the active editing session. final ProfileAvatarDraft? draft; + + /// The currently selected avatar editing mode. final ProfileAvatarMode mode; + + /// Drives the shared preview transition into and out of editing. final Animation transition; + + /// Called when the user selects a different avatar editing mode. final ValueChanged onModeChanged; + + /// Called whenever the unsaved avatar selection changes. final ValueChanged onDraftChanged; + + /// Supplies or clears the deferred animated-avatar preparation callback. final ValueChanged Function()?> onAnimatedPrepareChanged; + + /// Overrides the animated capture surface, primarily for tests. final AnimatedAvatarCaptureBuilder? animatedCaptureBuilder; @override diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index 18ca41d9ad1..ce4604a38dd 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -151,7 +151,7 @@ class ProfileNotifier extends AsyncNotifier { await relay.submit( kind: EventKind.profile, content: jsonEncode(nextMetadata), - tags: const [], + tags: currentHead?.tags ?? const [], createdAt: createdAt, onSigned: (event) => signedEvent = event, ); diff --git a/mobile/test/features/profile/profile_provider_test.dart b/mobile/test/features/profile/profile_provider_test.dart index 939a94f7cf4..296f7d6af16 100644 --- a/mobile/test/features/profile/profile_provider_test.dart +++ b/mobile/test/features/profile/profile_provider_test.dart @@ -14,13 +14,17 @@ import 'package:nostr/nostr.dart' as nostr; void main() { test('profile updates preserve existing kind:0 metadata', () async { final keys = nostr.Keys.generate(); + const profileTags = [ + ['auth', 'agent-pubkey', 'ownership-proof'], + ['custom', 'preserve-tag'], + ]; final relaySession = _ProfileRelaySession( NostrEvent( id: 'profile-1', pubkey: keys.public, createdAt: 1, kind: EventKind.profile, - tags: const [], + tags: profileTags, content: jsonEncode({ 'name': 'alice', 'display_name': 'Alice', @@ -54,6 +58,7 @@ void main() { expect(content['picture'], 'https://relay.example/alice.png'); expect(content['nip05'], 'alice@example.com'); expect(content['custom'], 'preserve-me'); + expect(relaySession.published.single.tags, profileTags); expect( container.read(profileProvider).requireValue?.displayName, 'Alice L', From 3baa9085a8f50693413bb54f050e935d9d89ee49 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 13:02:23 +0100 Subject: [PATCH 19/67] fix(mobile): close profile edit edge cases Signed-off-by: kenny lopez --- .../features/profile/profile_edit_page.dart | 8 ++- .../features/profile/profile_provider.dart | 5 ++ .../features/profile/profile_text_editor.dart | 7 ++- .../profile/profile_edit_retry_test.dart | 58 +++++++++++++++++++ .../profile/profile_provider_test.dart | 32 ++++++++++ 5 files changed, 106 insertions(+), 4 deletions(-) diff --git a/mobile/lib/features/profile/profile_edit_page.dart b/mobile/lib/features/profile/profile_edit_page.dart index 3008596aa0a..0ab24541ba3 100644 --- a/mobile/lib/features/profile/profile_edit_page.dart +++ b/mobile/lib/features/profile/profile_edit_page.dart @@ -111,9 +111,13 @@ class ProfileEditPage extends HookConsumerWidget { onSave: onSave, shouldRetryOnError: (error) => error is! ProfileCommunityChangedException, - canPresent: () => context.mounted, + canPresent: () => + context.mounted && (ModalRoute.of(context)?.isCurrent ?? true), onSaveError: () { - if (!context.mounted) return; + if (!context.mounted || + !(ModalRoute.of(context)?.isCurrent ?? true)) { + return; + } ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text("We couldn't save this change. Try again."), diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index ce4604a38dd..4795ce8f627 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -140,6 +140,11 @@ class ProfileNotifier extends AsyncNotifier { ? {} : _decodeProfileMetadata(currentHead); final nextMetadata = {...currentMetadata, ...patch}; + if (patch['display_name'] == '') { + nextMetadata + ..remove('display_name') + ..remove('name'); + } final relay = SignedEventRelay(session: session, nsec: context.config.nsec); final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; final currentCreatedAt = currentHead?.createdAt ?? 0; diff --git a/mobile/lib/features/profile/profile_text_editor.dart b/mobile/lib/features/profile/profile_text_editor.dart index cff7624614d..9cbcea626a6 100644 --- a/mobile/lib/features/profile/profile_text_editor.dart +++ b/mobile/lib/features/profile/profile_text_editor.dart @@ -87,9 +87,12 @@ Future _showProfileTextEditor({ onSave: onSave, shouldRetryOnError: (error) => error is! ProfileCommunityChangedException, - canPresent: () => context.mounted, + canPresent: () => + context.mounted && (ModalRoute.of(context)?.isCurrent ?? true), onSaveError: () { - if (context.mounted) _showSaveError(context); + if (context.mounted && (ModalRoute.of(context)?.isCurrent ?? true)) { + _showSaveError(context); + } }, ); return; diff --git a/mobile/test/features/profile/profile_edit_retry_test.dart b/mobile/test/features/profile/profile_edit_retry_test.dart index 96f4c581562..167d8ec612c 100644 --- a/mobile/test/features/profile/profile_edit_retry_test.dart +++ b/mobile/test/features/profile/profile_edit_retry_test.dart @@ -182,6 +182,64 @@ void main() { debugDefaultTargetPlatformOverride = null; }); + testWidgets('native text retry stops when its owner route is covered', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + const channel = MethodChannel('buzz/profile_text_editor'); + var presentations = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + presentations++; + return 'Pending draft'; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + final notifier = _DeferredFailureProfileNotifier(); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(() => notifier)], + child: Builder( + builder: (context) => Column( + children: [ + TextButton( + onPressed: () => + unawaited(showProfileDisplayNameEditor(context)), + child: const Text('Open editor'), + ), + TextButton( + onPressed: () => unawaited( + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('Theme')), + ), + ), + ), + child: const Text('Open destination'), + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open editor')); + await tester.pump(); + expect(notifier.displayNameAttempts, ['Pending draft']); + + await tester.tap(find.text('Open destination')); + await tester.pumpAndSettle(); + notifier.failSave(); + await tester.pump(); + + expect(presentations, 1); + debugDefaultTargetPlatformOverride = null; + }); + testWidgets('Flutter text editor closes after a community switch', ( tester, ) async { diff --git a/mobile/test/features/profile/profile_provider_test.dart b/mobile/test/features/profile/profile_provider_test.dart index 296f7d6af16..21aed9aca5e 100644 --- a/mobile/test/features/profile/profile_provider_test.dart +++ b/mobile/test/features/profile/profile_provider_test.dart @@ -65,6 +65,38 @@ void main() { ); }); + test('clearing a display name restores the pubkey label fallback', () async { + final keys = nostr.Keys.generate(); + final relaySession = _ProfileRelaySession( + NostrEvent( + id: 'profile-1', + pubkey: keys.public, + createdAt: 1, + kind: EventKind.profile, + tags: const [], + content: jsonEncode({ + 'name': 'legacy-alice', + 'display_name': 'Alice', + 'about': 'Building Buzz', + }), + sig: 'sig', + ), + ); + final container = _profileContainer(keys.nsec, relaySession); + addTearDown(container.dispose); + + await container.read(profileProvider.future); + await container.read(profileProvider.notifier).updateDisplayName(' '); + + final content = + jsonDecode(relaySession.published.single.content) + as Map; + expect(content, {'about': 'Building Buzz'}); + final profile = container.read(profileProvider).requireValue!; + expect(profile.displayName, isNull); + expect(profile.label, '${keys.public.substring(0, 8)}...'); + }); + test('profile updates fail closed while hydration is pending', () async { final keys = nostr.Keys.generate(); final history = Completer>(); From 1a05c9c7f7c5734591aa472fe622131ebf62eea1 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 13:15:37 +0100 Subject: [PATCH 20/67] docs(mobile): complete profile editor API docs Signed-off-by: kenny lopez --- .../lib/features/profile/animated_avatar_capture.dart | 5 +++++ .../lib/features/profile/profile_avatar_editor.dart | 11 ++++++++++- mobile/lib/features/profile/profile_edit_page.dart | 1 + mobile/lib/features/profile/profile_provider.dart | 1 + 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart index 1a330596f6c..755c2f689e0 100644 --- a/mobile/lib/features/profile/animated_avatar_capture.dart +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -37,13 +37,18 @@ 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. const AnimatedAvatarCapture({ super.key, required this.height, required this.onPrepareChanged, this.initialFrames = const [], }); + + /// The vertical space available to the capture surface. final double height; + + /// Reports the current deferred draft-preparation callback to the parent. final ValueChanged Function()?> onPrepareChanged; /// Seeds processed frames in lifecycle-focused widget tests. diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart index 5351e19ad48..176b8dcbe7f 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -29,7 +29,16 @@ import 'profile_avatar_draft.dart'; part 'profile_avatar_editor/emoji_avatar_picker.dart'; /// Avatar kinds shared with the desktop profile editor. -enum ProfileAvatarMode { image, emoji, animated } +enum ProfileAvatarMode { + /// A still image selected from the camera or photo library. + image, + + /// A system emoji composited over a selected background color. + emoji, + + /// A short camera animation with framing and background controls. + animated, +} /// Builds the animated capture surface for the profile avatar editor. typedef AnimatedAvatarCaptureBuilder = diff --git a/mobile/lib/features/profile/profile_edit_page.dart b/mobile/lib/features/profile/profile_edit_page.dart index 0ab24541ba3..38366335254 100644 --- a/mobile/lib/features/profile/profile_edit_page.dart +++ b/mobile/lib/features/profile/profile_edit_page.dart @@ -27,6 +27,7 @@ import 'profile_provider.dart'; /// Edits the current user's public profile metadata. class ProfileEditPage extends HookConsumerWidget { + /// Creates the profile details and avatar editing page. const ProfileEditPage({ super.key, this.startInPhotoEditor = false, diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index 4795ce8f627..fe103cb3952 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -11,6 +11,7 @@ import '../../shared/theme/theme.dart'; /// Signals that a profile write no longer belongs to the active community. class ProfileCommunityChangedException extends StateError { + /// Creates an error for a profile write invalidated by a community switch. ProfileCommunityChangedException() : super('Profile update cancelled because the active community changed.'); } From 11b6c0ca85eea500aa103920674de5a78719b279 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 13:26:55 +0100 Subject: [PATCH 21/67] fix(mobile): discard stale avatar image selections Signed-off-by: kenny lopez --- .../profile/profile_avatar_editor.dart | 25 ++- .../profile/profile_edit_page_test.dart | 2 + .../image_selection_tests.dart | 207 ------------------ 3 files changed, 21 insertions(+), 213 deletions(-) diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart index 176b8dcbe7f..f2d116d8d92 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -121,6 +121,8 @@ class ProfileAvatarEditor extends HookConsumerWidget { final emojiSection = useState(_EmojiEditorSection.emoji); final emojiPreviewKey = useState(0); final isPickingImage = useState(false); + final imageSelectionGeneration = useRef(0); + final currentMode = useRef(mode)..value = mode; final error = useState(null); final dataset = ref.watch(emojiDatasetOrEmptyProvider); final modeTransitionDirection = useRef(1.0); @@ -138,6 +140,10 @@ class ProfileAvatarEditor extends HookConsumerWidget { modeTransitionDirection.value = nextMode.index > mode.index ? 1 : -1; unawaited(HapticFeedback.selectionClick()); onAnimatedPrepareChanged(null); + if (mode == ProfileAvatarMode.image) { + imageSelectionGeneration.value++; + isPickingImage.value = false; + } if (!reduceMotion) modeTransitionController.value = 0; onModeChanged(nextMode); if (nextMode == ProfileAvatarMode.emoji) { @@ -165,6 +171,11 @@ class ProfileAvatarEditor extends HookConsumerWidget { Future selectImage({required bool camera}) async { if (isPickingImage.value) return; + final operation = ++imageSelectionGeneration.value; + bool isCurrentOperation() => + context.mounted && + currentMode.value == ProfileAvatarMode.image && + imageSelectionGeneration.value == operation; isPickingImage.value = true; error.value = null; try { @@ -173,9 +184,9 @@ class ProfileAvatarEditor extends HookConsumerWidget { final picked = camera ? await service.captureImage() : await service.pickGalleryImage(); - if (picked == null || !context.mounted) return; + if (picked == null || !isCurrentOperation()) return; final preparedPhoto = await service.prepareImageBytes(picked); - if (!context.mounted) return; + if (!context.mounted || !isCurrentOperation()) return; final cropped = await Navigator.of(context).push( MaterialPageRoute( builder: (_) => ProfileAvatarCropPage( @@ -183,12 +194,14 @@ class ProfileAvatarEditor extends HookConsumerWidget { ), ), ); - if (cropped == null) return; - if (context.mounted) onDraftChanged(ProfileImageAvatarDraft(cropped)); + if (cropped == null || !isCurrentOperation()) return; + onDraftChanged(ProfileImageAvatarDraft(cropped)); } catch (_) { - error.value = "We couldn't prepare that photo. Try again."; + if (isCurrentOperation()) { + error.value = "We couldn't prepare that photo. Try again."; + } } finally { - if (context.mounted) isPickingImage.value = false; + if (isCurrentOperation()) isPickingImage.value = false; } } diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index 1fb0427caf9..43ece434b4d 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -28,6 +28,7 @@ import 'package:image_picker/image_picker.dart'; import '../../helpers/widget_helpers.dart'; part 'profile_edit_page_test/motion_and_accessibility_tests.dart'; +part 'profile_edit_page_test/image_selection_tests.dart'; const _editorControlBottomForTest = Grid.xl + Grid.xxs; @@ -839,6 +840,7 @@ void main() { }); runProfileEditMotionAndAccessibilityTests(); + runProfileEditImageSelectionTests(); } Future _waitForAvatarCropToClose(WidgetTester tester) async { 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..63d1b21c474 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,213 +1,6 @@ part of '../profile_edit_page_test.dart'; void runProfileEditImageSelectionTests() { - testWidgets('duplicate avatar Back taps pop only the editor route', ( - tester, - ) async { - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], - child: Builder( - builder: (context) => Scaffold( - body: TextButton( - onPressed: () => unawaited( - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => - const ProfileEditPage(startInPhotoEditor: true), - ), - ), - ), - child: const Text('Open profile photo'), - ), - ), - ), - ), - ); - await tester.tap(find.text('Open profile photo')); - await tester.pumpAndSettle(); - - final back = find.byKey(const ValueKey('avatar-editor-back')); - await tester.tap(back); - await tester.tap(back); - await tester.pumpAndSettle(); - - expect(find.text('Open profile photo'), findsOneWidget); - }); - - testWidgets('photo crop exposes accessible move and zoom actions', ( - tester, - ) async { - final semantics = tester.ensureSemantics(); - final bytes = Uint8List.fromList( - image.encodePng(image.Image(width: 20, height: 10)), - ); - await tester.pumpWidget( - MaterialApp( - home: ProfileAvatarCropPage( - imageBytes: Future.value(bytes), - ), - ), - ); - await _waitForAvatarCropToLoad(tester); - - final cropSemantics = tester.widget( - find.byWidgetPredicate( - (widget) => - widget is Semantics && widget.properties.label == 'Photo crop', - ), - ); - final actions = cropSemantics.properties.customSemanticsActions!; - expect( - actions.keys.map((action) => action.label), - containsAll([ - 'Move left', - 'Move right', - 'Move up', - 'Move down', - 'Zoom in', - ]), - ); - final viewer = tester.widget( - find.byKey(const ValueKey('avatar-crop-viewer')), - ); - actions.entries.firstWhere((entry) => entry.key.label == 'Zoom in').value(); - await tester.pump(); - expect(viewer.transformationController!.value.getMaxScaleOnAxis(), 1.1); - semantics.dispose(); - }); - - testWidgets('seeds emoji editing from the current avatar', (tester) async { - final avatarUrl = emojiAvatarDataUrl('🦝', emojiAvatarColors[11]); - final notifier = _FakeProfileNotifier( - profile: UserProfile( - pubkey: 'aabb', - displayName: 'Alice', - avatarUrl: avatarUrl, - ), - ); - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [profileProvider.overrideWith(() => notifier)], - child: const ProfileEditPage(), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Edit Photo')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Emoji')); - await tester.pump(const Duration(milliseconds: 250)); - - final preview = find.byKey(const ValueKey('emoji-avatar-preview')); - expect( - tester - .widget( - find.descendant( - of: preview, - matching: find.byType(NativeEmojiGlyph), - ), - ) - .emoji, - '🦝', - ); - expect( - (tester.widget(preview).decoration! as BoxDecoration) - .color, - Color(emojiAvatarColors[11]), - ); - await tester.tap(find.byKey(const ValueKey('avatar-save'))); - await tester.pumpAndSettle(); - expect(notifier.savedAvatarUrls, [avatarUrl]); - }); - - testWidgets('seeds the skin-tone filter from the current emoji', ( - tester, - ) async { - const variants = [ - EmojiEntry( - id: '+1', - name: 'Thumbs Up', - keywords: ['thumb'], - native: '👍', - categoryId: 'people', - ), - EmojiEntry( - id: '+1', - name: 'Thumbs Up', - keywords: ['thumb'], - native: '👍🏻', - categoryId: 'people', - skinIndex: 1, - ), - EmojiEntry( - id: '+1', - name: 'Thumbs Up', - keywords: ['thumb'], - native: '👍🏼', - categoryId: 'people', - skinIndex: 2, - ), - EmojiEntry( - id: '+1', - name: 'Thumbs Up', - keywords: ['thumb'], - native: '👍🏽', - categoryId: 'people', - skinIndex: 3, - ), - EmojiEntry( - id: '+1', - name: 'Thumbs Up', - keywords: ['thumb'], - native: '👍🏾', - categoryId: 'people', - skinIndex: 4, - ), - EmojiEntry( - id: '+1', - name: 'Thumbs Up', - keywords: ['thumb'], - native: '👍🏿', - categoryId: 'people', - skinIndex: 5, - ), - ]; - const dataset = EmojiDataset( - categories: [EmojiCategory(id: 'people', emoji: variants)], - all: variants, - nativeToShortcode: {'👍🏽': ':+1:'}, - ); - final avatarUrl = emojiAvatarDataUrl('👍🏽', emojiAvatarColors[11]); - final notifier = _FakeProfileNotifier( - profile: UserProfile(pubkey: 'aabb', avatarUrl: avatarUrl), - ); - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [ - profileProvider.overrideWith(() => notifier), - emojiDatasetOrEmptyProvider.overrideWithValue(dataset), - ], - child: const ProfileEditPage(), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Edit Photo')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Emoji')); - await tester.pump(const Duration(milliseconds: 250)); - - expect( - tester - .widget>( - find.byKey(const ValueKey('emoji-avatar-skin-tone')), - ) - .initialValue, - 3, - ); - }); - testWidgets('discards a delayed image after switching avatar modes', ( tester, ) async { From 00b1006ee6214be9d8b41a8cf092cd197630d5d4 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 13:42:41 +0100 Subject: [PATCH 22/67] fix(mobile): release animated avatar camera in review Signed-off-by: kenny lopez --- .../profile/animated_avatar_capture.dart | 29 +++++++++-- .../profile/animated_avatar_capture_test.dart | 48 +++++++++++++++++++ 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart index 755c2f689e0..4c8f1e46629 100644 --- a/mobile/lib/features/profile/animated_avatar_capture.dart +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -58,6 +58,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final controller = useState(null); final controllerRef = useRef(null); + final cameraGeneration = useState(0); final isInitializing = useState(true); final isRecording = useState(false); final isPreparingFrames = useState(false); @@ -117,7 +118,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { useEffect(() { var disposed = false; - if (lifecycle != AppLifecycleState.resumed) { + if (lifecycle != AppLifecycleState.resumed || frames.value.isNotEmpty) { isInitializing.value = false; controller.value = null; return null; @@ -162,7 +163,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { controllerRef.value = null; unawaited(active?.dispose() ?? Future.value()); }; - }, [lifecycle]); + }, [lifecycle, frames.value.isEmpty, cameraGeneration.value]); Future prepare() async { final key = encodeKey; @@ -225,6 +226,19 @@ class AnimatedAvatarCapture extends HookConsumerWidget { final startedAt = DateTime.now(); var lastFrameAt = DateTime.fromMillisecondsSinceEpoch(0); var converting = false; + var releasedCamera = false; + + Future releaseCamera() async { + if (releasedCamera) return; + releasedCamera = true; + if (identical(controllerRef.value, active)) { + controllerRef.value = null; + } + if (context.mounted && identical(controller.value, active)) { + controller.value = null; + } + await active.dispose(); + } final timer = Timer.periodic(const Duration(milliseconds: 40), (_) { if (!context.mounted) return; @@ -267,6 +281,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { while (converting) { await Future.delayed(const Duration(milliseconds: 10)); } + await releaseCamera(); if (captured.length < 2) { throw StateError('Not enough frames were captured.'); } @@ -287,8 +302,14 @@ class AnimatedAvatarCapture extends HookConsumerWidget { ]); if (context.mounted) frames.value = processed; } catch (_) { - if (active.value.isStreamingImages) await active.stopImageStream(); - error.value = 'Recording failed. Try again.'; + if (!releasedCamera && active.value.isStreamingImages) { + await active.stopImageStream(); + } + await releaseCamera(); + if (context.mounted) { + cameraGeneration.value++; + error.value = 'Recording failed. Try again.'; + } } finally { timer.cancel(); if (context.mounted) { diff --git a/mobile/test/features/profile/animated_avatar_capture_test.dart b/mobile/test/features/profile/animated_avatar_capture_test.dart index c516a4d5909..c7fcf359a0e 100644 --- a/mobile/test/features/profile/animated_avatar_capture_test.dart +++ b/mobile/test/features/profile/animated_avatar_capture_test.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:ui' show SemanticsAction; import 'package:buzz/features/profile/animated_avatar_orientation.dart'; import 'package:buzz/features/profile/animated_avatar_capture.dart'; @@ -128,6 +129,53 @@ void main() { ); expect(prepare, isNotNull); }); + + testWidgets('poster scrubber supports semantic adjustment actions', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + final frames = [ + for (var index = 0; index < 3; index++) + image.encodePng(image.Image(width: 2, height: 2)), + ]; + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: AnimatedAvatarCapture( + height: 600, + initialFrames: frames, + onPrepareChanged: (_) {}, + ), + ), + ), + ), + ); + await tester.pump(); + await tester.tap(find.text('Frame')); + await tester.pump(); + + final scrubber = find.bySemanticsLabel('Choose still frame'); + expect(scrubber, findsOneWidget); + final initialSemantics = tester.getSemantics(scrubber); + expect(initialSemantics.value, '1 of 3'); + final initialData = initialSemantics.getSemanticsData(); + expect(initialData.hasAction(SemanticsAction.increase), isTrue); + expect(initialData.hasAction(SemanticsAction.decrease), isFalse); + + final semanticsWidget = tester.widget( + find.byWidgetPredicate( + (widget) => + widget is Semantics && + widget.properties.label == 'Choose still frame', + ), + ); + semanticsWidget.properties.onIncrease!(); + await tester.pump(); + expect(tester.getSemantics(scrubber).value, '2 of 3'); + semantics.dispose(); + }); } class _TestLifecycleNotifier extends AppLifecycleNotifier { From c067eefbcc5f879ee72eb465dabe73b73cb51771 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 13:51:09 +0100 Subject: [PATCH 23/67] fix(mobile): expose avatar selection semantics Signed-off-by: kenny lopez --- .../motion_and_accessibility_tests.dart | 7 +++++++ 1 file changed, 7 insertions(+) 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 9c10084e225..57781fcdc4b 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 @@ -184,6 +184,13 @@ void runProfileEditMotionAndAccessibilityTests() { tester.widget(find.byIcon(Icons.face)).color, theme.colorScheme.surface, ); + final selectedSemantics = tester.widget( + find.byWidgetPredicate( + (widget) => widget is Semantics && widget.properties.label == 'Active', + ), + ); + expect(selectedSemantics.properties.button, isTrue); + expect(selectedSemantics.properties.selected, isTrue); }); testWidgets('uses the shared animated background grid for emoji avatars', ( From c781ac6a83f72cc4d4c0c23951ed641619763b84 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 14:03:41 +0100 Subject: [PATCH 24/67] fix(mobile): scope profile editing context Signed-off-by: kenny lopez --- .../profile/profile_avatar_editor.dart | 44 ++++++++----- .../features/profile/profile_edit_page.dart | 61 ++++++++++++------- .../features/profile/profile_text_editor.dart | 7 ++- .../motion_and_accessibility_tests.dart | 45 ++++++++++++++ .../profile/profile_edit_retry_test.dart | 13 ++-- 5 files changed, 126 insertions(+), 44 deletions(-) diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart index f2d116d8d92..7d25d97f8c0 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -521,23 +521,35 @@ class _AvatarModeControl extends StatelessWidget { children: [ for (final mode in ProfileAvatarMode.values) Expanded( - child: InkWell( - key: ValueKey('avatar-mode-${mode.name}'), - borderRadius: BorderRadius.circular(Radii.full), + child: Semantics( + label: switch (mode) { + ProfileAvatarMode.image => 'Image', + ProfileAvatarMode.emoji => 'Emoji', + ProfileAvatarMode.animated => 'Animated', + }, + button: true, + selected: mode == selected, onTap: () => onSelected(mode), - child: SizedBox( - height: 36, - child: Center( - child: Text( - switch (mode) { - ProfileAvatarMode.image => 'Image', - ProfileAvatarMode.emoji => 'Emoji', - ProfileAvatarMode.animated => 'Animated', - }, - style: context.textTheme.labelLarge?.copyWith( - fontWeight: mode == selected - ? FontWeight.w600 - : FontWeight.w500, + child: ExcludeSemantics( + child: InkWell( + key: ValueKey('avatar-mode-${mode.name}'), + borderRadius: BorderRadius.circular(Radii.full), + onTap: () => onSelected(mode), + child: SizedBox( + height: 36, + child: Center( + child: Text( + switch (mode) { + ProfileAvatarMode.image => 'Image', + ProfileAvatarMode.emoji => 'Emoji', + ProfileAvatarMode.animated => 'Animated', + }, + style: context.textTheme.labelLarge?.copyWith( + fontWeight: mode == selected + ? FontWeight.w600 + : FontWeight.w500, + ), + ), ), ), ), diff --git a/mobile/lib/features/profile/profile_edit_page.dart b/mobile/lib/features/profile/profile_edit_page.dart index 38366335254..13671f3ccb4 100644 --- a/mobile/lib/features/profile/profile_edit_page.dart +++ b/mobile/lib/features/profile/profile_edit_page.dart @@ -24,6 +24,7 @@ import 'ios_profile_text_editor.dart'; import 'profile_avatar_editor.dart'; import 'profile_avatar_draft.dart'; import 'profile_provider.dart'; +import 'profile_text_editor.dart'; /// Edits the current user's public profile metadata. class ProfileEditPage extends HookConsumerWidget { @@ -396,16 +397,25 @@ class ProfileEditPage extends HookConsumerWidget { trailing: const _EditChevron(), onTap: !profileHydrated ? null - : () => unawaited( - editField( - title: 'Display name', - initialValue: profile?.displayName ?? '', - hintText: 'Display name', - onSave: ref - .read(profileProvider.notifier) - .updateDisplayName, - ), - ), + : () { + final container = ProviderScope.containerOf( + context, + listen: false, + ); + unawaited( + editField( + title: 'Display name', + initialValue: profile?.displayName ?? '', + hintText: 'Display name', + onSave: bindProfileSaveToOpeningContext( + container, + container + .read(profileProvider.notifier) + .updateDisplayName, + ), + ), + ); + }, ), AppListRow( key: const ValueKey('profile-description-row'), @@ -415,17 +425,26 @@ class ProfileEditPage extends HookConsumerWidget { trailing: const _EditChevron(), onTap: !profileHydrated ? null - : () => unawaited( - editField( - title: 'Profile description', - initialValue: profile?.about ?? '', - hintText: 'Profile description', - multiline: true, - onSave: ref - .read(profileProvider.notifier) - .updateAbout, - ), - ), + : () { + final container = ProviderScope.containerOf( + context, + listen: false, + ); + unawaited( + editField( + title: 'Profile description', + initialValue: profile?.about ?? '', + hintText: 'Profile description', + multiline: true, + onSave: bindProfileSaveToOpeningContext( + container, + container + .read(profileProvider.notifier) + .updateAbout, + ), + ), + ); + }, ), ], ), diff --git a/mobile/lib/features/profile/profile_text_editor.dart b/mobile/lib/features/profile/profile_text_editor.dart index 9cbcea626a6..8203fdbb083 100644 --- a/mobile/lib/features/profile/profile_text_editor.dart +++ b/mobile/lib/features/profile/profile_text_editor.dart @@ -16,7 +16,7 @@ import 'profile_provider.dart'; Future showProfileDisplayNameEditor(BuildContext context) async { final container = ProviderScope.containerOf(context, listen: false); final profile = container.read(profileProvider).asData?.value; - final onSave = _bindSaveToOpeningContext( + final onSave = bindProfileSaveToOpeningContext( container, container.read(profileProvider.notifier).updateDisplayName, ); @@ -33,7 +33,7 @@ Future showProfileDisplayNameEditor(BuildContext context) async { Future showProfileDescriptionEditor(BuildContext context) async { final container = ProviderScope.containerOf(context, listen: false); final profile = container.read(profileProvider).asData?.value; - final onSave = _bindSaveToOpeningContext( + final onSave = bindProfileSaveToOpeningContext( container, container.read(profileProvider.notifier).updateAbout, ); @@ -47,7 +47,8 @@ Future showProfileDescriptionEditor(BuildContext context) async { ); } -Future Function(String) _bindSaveToOpeningContext( +/// Prevents a profile draft from being published after its community changes. +Future Function(String) bindProfileSaveToOpeningContext( ProviderContainer container, Future Function(String value) onSave, ) { 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 57781fcdc4b..7c1a0da5dc2 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 @@ -1,6 +1,51 @@ part of '../profile_edit_page_test.dart'; void runProfileEditMotionAndAccessibilityTests() { + testWidgets('exposes the selected avatar mode on Android', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + + Iterable modeSemantics(String label) => + tester.widgetList( + find.byWidgetPredicate( + (widget) => + widget is Semantics && + widget.properties.label == label && + widget.child is ExcludeSemantics, + ), + ); + + expect(modeSemantics('Image'), isNotEmpty); + expect( + modeSemantics('Image').every((node) => node.properties.selected == true), + isTrue, + ); + expect( + modeSemantics('Emoji').every((node) => node.properties.selected == false), + isTrue, + ); + await tester.tap(find.byKey(const ValueKey('avatar-mode-emoji'))); + await tester.pump(); + expect( + modeSemantics('Image').every((node) => node.properties.selected == false), + isTrue, + ); + expect( + modeSemantics('Emoji').every((node) => node.properties.selected == true), + isTrue, + ); + debugDefaultTargetPlatformOverride = null; + }); + testWidgets('moves segment content in the selected direction', ( 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 167d8ec612c..08ee0990bed 100644 --- a/mobile/test/features/profile/profile_edit_retry_test.dart +++ b/mobile/test/features/profile/profile_edit_retry_test.dart @@ -240,16 +240,20 @@ void main() { debugDefaultTargetPlatformOverride = null; }); - testWidgets('Flutter text editor closes after a community switch', ( + testWidgets('in-page text editor rejects its first save after a switch', ( tester, ) async { debugDefaultTargetPlatformOverride = TargetPlatform.android; addTearDown(() => debugDefaultTargetPlatformOverride = null); - final notifier = _CommunityChangedProfileNotifier(); + final notifier = _RetryProfileNotifier(); + final config = _MutableRelayConfigNotifier(); await tester.pumpWidget( WidgetHelpers.testable( - overrides: [profileProvider.overrideWith(() => notifier)], + overrides: [ + profileProvider.overrideWith(() => notifier), + relayConfigProvider.overrideWith(() => config), + ], child: const ProfileEditPage(), ), ); @@ -261,11 +265,12 @@ void main() { 'Old community draft', ); await tester.pump(); + config.update(baseUrl: 'https://second.example', nsec: 'second-identity'); await tester.tap(find.byKey(const ValueKey('profile-field-save'))); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('profile-field-input')), findsNothing); - expect(notifier.displayNameAttempts, ['Old community draft']); + expect(notifier.displayNameAttempts, isEmpty); debugDefaultTargetPlatformOverride = null; }); From 84eef9f51059321b4fe2c700c79fdc9947b4d53e Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 14:15:54 +0100 Subject: [PATCH 25/67] fix(ios): match profile editor theme Signed-off-by: kenny lopez --- mobile/ios/Runner/NativeProfileTextEditor.swift | 8 +++++++- mobile/lib/features/profile/ios_profile_text_editor.dart | 4 ++++ mobile/lib/features/profile/profile_edit_page.dart | 1 + mobile/lib/features/profile/profile_text_editor.dart | 1 + mobile/test/features/profile/profile_edit_page_test.dart | 8 ++++++-- 5 files changed, 19 insertions(+), 3 deletions(-) diff --git a/mobile/ios/Runner/NativeProfileTextEditor.swift b/mobile/ios/Runner/NativeProfileTextEditor.swift index 31b94613051..abccc693ad8 100644 --- a/mobile/ios/Runner/NativeProfileTextEditor.swift +++ b/mobile/ios/Runner/NativeProfileTextEditor.swift @@ -37,7 +37,8 @@ final class NativeProfileTextEditorCoordinator: NSObject, let title = arguments["title"] as? String, let initialValue = arguments["initialValue"] as? String, let placeholder = arguments["placeholder"] as? String, - let multiline = arguments["multiline"] as? Bool + let multiline = arguments["multiline"] as? Bool, + let brightness = arguments["brightness"] as? String else { result( FlutterError( @@ -57,6 +58,7 @@ final class NativeProfileTextEditorCoordinator: NSObject, initialValue: initialValue, placeholder: placeholder, multiline: multiline, + brightness: brightness, allowUnchangedSubmission: allowUnchangedSubmission, result: result ) @@ -69,6 +71,7 @@ final class NativeProfileTextEditorCoordinator: NSObject, initialValue: String, placeholder: String, multiline: Bool, + brightness: String, allowUnchangedSubmission: Bool, result: @escaping FlutterResult ) { @@ -107,6 +110,9 @@ final class NativeProfileTextEditorCoordinator: NSObject, onSet: { [weak self] value in self?.finish(value: value) } ) let navigationController = UINavigationController(rootViewController: editor) + navigationController.overrideUserInterfaceStyle = brightness == "dark" + ? .dark + : .light if UIDevice.current.userInterfaceIdiom == .pad { navigationController.modalPresentationStyle = .formSheet } diff --git a/mobile/lib/features/profile/ios_profile_text_editor.dart b/mobile/lib/features/profile/ios_profile_text_editor.dart index d957b236aa9..3760b456617 100644 --- a/mobile/lib/features/profile/ios_profile_text_editor.dart +++ b/mobile/lib/features/profile/ios_profile_text_editor.dart @@ -13,12 +13,14 @@ class IosProfileTextEditor { required String initialValue, required String placeholder, required bool multiline, + required Brightness brightness, bool allowUnchangedSubmission = false, }) => _channel.invokeMethod('present', { 'title': title, 'initialValue': initialValue, 'placeholder': placeholder, 'multiline': multiline, + 'brightness': brightness.name, 'allowUnchangedSubmission': allowUnchangedSubmission, }); @@ -29,6 +31,7 @@ class IosProfileTextEditor { required String initialValue, required String placeholder, required bool multiline, + required Brightness brightness, required Future Function(String value) onSave, required void Function() onSaveError, bool Function(Object error)? shouldRetryOnError, @@ -43,6 +46,7 @@ class IosProfileTextEditor { initialValue: draft, placeholder: placeholder, multiline: multiline, + brightness: brightness, allowUnchangedSubmission: isRetry, ); if (value == null) return; diff --git a/mobile/lib/features/profile/profile_edit_page.dart b/mobile/lib/features/profile/profile_edit_page.dart index 13671f3ccb4..208bc48b034 100644 --- a/mobile/lib/features/profile/profile_edit_page.dart +++ b/mobile/lib/features/profile/profile_edit_page.dart @@ -110,6 +110,7 @@ class ProfileEditPage extends HookConsumerWidget { initialValue: initialValue, placeholder: hintText, multiline: multiline, + brightness: Theme.of(context).brightness, onSave: onSave, shouldRetryOnError: (error) => error is! ProfileCommunityChangedException, diff --git a/mobile/lib/features/profile/profile_text_editor.dart b/mobile/lib/features/profile/profile_text_editor.dart index 8203fdbb083..b1fbfa801ba 100644 --- a/mobile/lib/features/profile/profile_text_editor.dart +++ b/mobile/lib/features/profile/profile_text_editor.dart @@ -85,6 +85,7 @@ Future _showProfileTextEditor({ initialValue: initialValue, placeholder: hintText, multiline: multiline, + brightness: Theme.of(context).brightness, onSave: onSave, shouldRetryOnError: (error) => error is! ProfileCommunityChangedException, diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index 43ece434b4d..eff2ac90c5b 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -208,9 +208,12 @@ void main() { final notifier = _FakeProfileNotifier(); await tester.pumpWidget( - WidgetHelpers.testable( + ProviderScope( overrides: [profileProvider.overrideWith(() => notifier)], - child: const ProfileEditPage(), + child: MaterialApp( + theme: AppTheme.dark(), + home: const Scaffold(body: ProfileEditPage()), + ), ), ); await tester.pumpAndSettle(); @@ -223,6 +226,7 @@ void main() { 'initialValue': 'Alice', 'placeholder': 'Display name', 'multiline': false, + 'brightness': 'dark', 'allowUnchangedSubmission': false, }); expect(notifier.savedDisplayNames, ['Alice Native']); From e4b6e54cf8de0a2ced99d0ab0f6ea3febef6fd7b Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 14:26:56 +0100 Subject: [PATCH 26/67] fix(mobile): preserve profile edit state Signed-off-by: kenny lopez --- .../profile/profile_avatar_editor.dart | 12 +++-- .../features/profile/profile_provider.dart | 3 ++ .../profile/profile_edit_page_test.dart | 44 +++++++++++++++++++ .../profile/profile_provider_test.dart | 31 +++++++++++-- 4 files changed, 84 insertions(+), 6 deletions(-) diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart index 7d25d97f8c0..59edf3dc1b7 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -112,10 +112,16 @@ class ProfileAvatarEditor extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final reduceMotion = MediaQuery.disableAnimationsOf(context); - final selectedEmoji = useState('😊'); + final currentEmojiAvatar = useMemoized( + () => parseEmojiAvatarDataUrl(currentAvatarUrl), + [currentAvatarUrl], + ); + final selectedEmoji = useState(currentEmojiAvatar?.emoji ?? '😊'); final initialColor = useMemoized( - () => emojiAvatarColors[Random().nextInt(18)], - const [], + () => + currentEmojiAvatar?.colorValue ?? + emojiAvatarColors[Random().nextInt(18)], + [currentEmojiAvatar], ); final selectedColor = useState(initialColor); final emojiSection = useState(_EmojiEditorSection.emoji); diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index fe103cb3952..3f8fbd6f9be 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../shared/crypto/nip_oa.dart'; import '../../shared/profile/user_cache_provider.dart'; import '../../shared/profile/user_profile.dart'; import '../../shared/relay/relay.dart'; @@ -67,6 +68,7 @@ class ProfileNotifier extends AsyncNotifier { avatarUrl: data.avatarUrl, about: data.about, nip05Handle: data.nip05, + ownerPubkey: verifiedOaOwnerPubkey(latest.tags, data.pubkey), ); _requireCurrentWriteContext(context); _metadata = metadata; @@ -183,6 +185,7 @@ class ProfileNotifier extends AsyncNotifier { avatarUrl: _metadata['picture'] as String?, about: _metadata['about'] as String?, nip05Handle: _metadata['nip05'] as String?, + ownerPubkey: verifiedOaOwnerPubkey(submittedEvent.tags, pubkey), ); state = AsyncData(profile); ref.read(userCacheProvider.notifier).put(profile); diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index eff2ac90c5b..24b12ecb987 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -813,6 +813,50 @@ void main() { ); }); + testWidgets('seeds emoji editing from the current avatar', (tester) async { + final avatarUrl = emojiAvatarDataUrl('🦝', emojiAvatarColors[11]); + final notifier = _FakeProfileNotifier( + profile: UserProfile( + pubkey: 'aabb', + displayName: 'Alice', + avatarUrl: avatarUrl, + ), + ); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(() => notifier)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Emoji')); + await tester.pump(const Duration(milliseconds: 250)); + + final preview = find.byKey(const ValueKey('emoji-avatar-preview')); + expect( + tester + .widget( + find.descendant( + of: preview, + matching: find.byType(NativeEmojiGlyph), + ), + ) + .emoji, + '🦝', + ); + expect( + (tester.widget(preview).decoration! as BoxDecoration) + .color, + Color(emojiAvatarColors[11]), + ); + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pumpAndSettle(); + expect(notifier.savedAvatarUrls, [avatarUrl]); + }); + testWidgets('keeps emoji drafts scoped to the emoji mode', (tester) async { final notifier = _FakeProfileNotifier(); await tester.pumpWidget( diff --git a/mobile/test/features/profile/profile_provider_test.dart b/mobile/test/features/profile/profile_provider_test.dart index 21aed9aca5e..ef0eb13104f 100644 --- a/mobile/test/features/profile/profile_provider_test.dart +++ b/mobile/test/features/profile/profile_provider_test.dart @@ -1,7 +1,9 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:typed_data'; import 'package:buzz/features/profile/profile_provider.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; import 'package:buzz/shared/profile/user_profile.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; @@ -10,13 +12,15 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:nostr/nostr.dart' as nostr; +import 'package:pointycastle/digests/sha256.dart'; void main() { test('profile updates preserve existing kind:0 metadata', () async { final keys = nostr.Keys.generate(); - const profileTags = [ - ['auth', 'agent-pubkey', 'ownership-proof'], - ['custom', 'preserve-tag'], + final owner = nostr.Keys.generate(); + final profileTags = [ + _authTag(owner, keys.public), + const ['custom', 'preserve-tag'], ]; final relaySession = _ProfileRelaySession( NostrEvent( @@ -63,6 +67,14 @@ void main() { container.read(profileProvider).requireValue?.displayName, 'Alice L', ); + expect( + container.read(profileProvider).requireValue?.ownerPubkey, + owner.public.toLowerCase(), + ); + expect( + container.read(userCacheProvider)[keys.public]?.ownerPubkey, + owner.public.toLowerCase(), + ); }); test('clearing a display name restores the pubkey label fallback', () async { @@ -535,6 +547,19 @@ void main() { ); } +List _authTag(nostr.Keys owner, String agentPubkey) { + final digest = SHA256Digest().process( + Uint8List.fromList(utf8.encode('nostr:agent-auth:$agentPubkey:')), + ); + final message = digest.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + return [ + 'auth', + owner.public, + '', + nostr.Schnorr.sign(secretKey: owner.secret, message: message), + ]; +} + class _FixedRelayConfigNotifier extends RelayConfigNotifier { _FixedRelayConfigNotifier(this.nsec); From 747e01c5de003d2295749ccbe3b58587b2c90ccc Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 14:43:28 +0100 Subject: [PATCH 27/67] fix(mobile): harden profile editing Signed-off-by: kenny lopez --- .../features/profile/emoji_avatar_tile.dart | 10 - .../profile/profile_avatar_editor.dart | 1 + .../emoji_avatar_picker.dart | 324 ++++++++---------- .../features/profile/profile_provider.dart | 11 +- .../features/profile/profile_text_editor.dart | 20 +- .../profile/profile_edit_page_test.dart | 45 +-- .../image_selection_tests.dart | 44 +++ .../motion_and_accessibility_tests.dart | 25 ++ .../profile/profile_edit_retry_test.dart | 45 +++ .../profile/profile_provider_test.dart | 24 ++ 10 files changed, 299 insertions(+), 250 deletions(-) diff --git a/mobile/lib/features/profile/emoji_avatar_tile.dart b/mobile/lib/features/profile/emoji_avatar_tile.dart index ff8e7f5dc77..cd2955e0127 100644 --- a/mobile/lib/features/profile/emoji_avatar_tile.dart +++ b/mobile/lib/features/profile/emoji_avatar_tile.dart @@ -5,7 +5,6 @@ import '../../shared/theme/theme.dart'; /// A selectable emoji tile with an explicit accessibility selection state. class EmojiAvatarTile extends StatelessWidget { - /// Creates an emoji option for an avatar picker. const EmojiAvatarTile({ required this.emoji, required this.label, @@ -15,19 +14,10 @@ class EmojiAvatarTile extends StatelessWidget { super.key, }); - /// The Unicode emoji glyph rendered by this tile. final String emoji; - - /// The human-readable emoji name announced to assistive technology. final String label; - - /// The stable identifier used for the tile's widget key. final String tileId; - - /// Whether this emoji is the current avatar selection. final bool isSelected; - - /// Called when the tile is selected. final VoidCallback onTap; @override diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart index 59edf3dc1b7..997c5a1c65e 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -23,6 +23,7 @@ import '../../shared/widgets/playing_avatar_image.dart'; import 'animated_avatar_capture.dart'; import 'avatar_background_grid.dart'; import 'avatar_editor_option_button.dart'; +import 'emoji_avatar_tile.dart'; import 'profile_avatar_crop_page.dart'; import 'profile_avatar_draft.dart'; 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..6e8e1e3c821 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 @@ -20,8 +20,6 @@ class _EmojiMode extends HookConsumerWidget { required this.dataset, required this.selectedEmoji, required this.selectedColor, - required this.transitionProgress, - required this.transitionDirection, required this.onSectionChanged, required this.onEmojiSelected, required this.onColorSelected, @@ -32,30 +30,13 @@ class _EmojiMode extends HookConsumerWidget { final EmojiDataset dataset; final String selectedEmoji; final int selectedColor; - final double transitionProgress; - final double transitionDirection; final ValueChanged<_EmojiEditorSection> onSectionChanged; final ValueChanged onEmojiSelected; final ValueChanged onColorSelected; @override Widget build(BuildContext context, WidgetRef ref) { - final reduceMotion = MediaQuery.disableAnimationsOf(context); - final trayOffset = reduceMotion ? 0.0 : 16 * (1 - transitionProgress); - final actionOffset = reduceMotion - ? 0.0 - : transitionDirection * - _modeTransitionDistance * - (1 - transitionProgress); - final skinTone = useState(_skinToneForEmoji(dataset, selectedEmoji)); - final seededSkinTone = useRef(false); - useEffect(() { - if (seededSkinTone.value || dataset.isEmpty) return null; - seededSkinTone.value = true; - final initialTone = _skinToneForEmoji(dataset, selectedEmoji); - if (skinTone.value != initialTone) skinTone.value = initialTone; - return null; - }, [dataset]); + final skinTone = useState(0); final searchController = useTextEditingController(); useListenable(searchController); final visibleEmoji = useMemoized(() { @@ -76,178 +57,148 @@ class _EmojiMode extends HookConsumerWidget { child: Column( children: [ Expanded( - child: ClipRect( - child: Transform.translate( - key: const ValueKey('avatar-mode-tray-transition-transform'), - offset: Offset(0, trayOffset), - child: Opacity( - opacity: transitionProgress, - child: activeSection == _EmojiEditorSection.background - ? AvatarBackgroundGrid( - key: const ValueKey('emoji-background-editor'), - selectedColor: selectedColor, - onColorSelected: onColorSelected, - colorKeyPrefix: 'emoji-avatar-color', - ) - : Column( - key: const ValueKey('emoji-glyph-editor'), - children: [ - Row( - children: [ - Expanded( - child: TextField( - key: const ValueKey('emoji-avatar-search'), - controller: searchController, - textInputAction: TextInputAction.search, - decoration: InputDecoration( - hintText: 'Search emoji', - filled: true, - fillColor: context - .colors - .surfaceContainerHighest, - contentPadding: - const EdgeInsets.symmetric( - vertical: 10, - ), - border: const OutlineInputBorder( - borderRadius: BorderRadius.all( - Radius.circular(Radii.full), - ), - borderSide: BorderSide.none, - ), - enabledBorder: const OutlineInputBorder( - borderRadius: BorderRadius.all( - Radius.circular(Radii.full), - ), - borderSide: BorderSide.none, - ), - focusedBorder: const OutlineInputBorder( - borderRadius: BorderRadius.all( - Radius.circular(Radii.full), - ), - borderSide: BorderSide.none, - ), - prefixIcon: const Icon( - LucideIcons.search, - size: 20, - ), - suffixIcon: searchController.text.isEmpty - ? null - : IconButton( - tooltip: 'Clear search', - onPressed: searchController.clear, - icon: const Icon( - LucideIcons.x, - size: 18, - ), - ), - ), + child: activeSection == _EmojiEditorSection.background + ? AvatarBackgroundGrid( + key: const ValueKey('emoji-background-editor'), + selectedColor: selectedColor, + onColorSelected: onColorSelected, + colorKeyPrefix: 'emoji-avatar-color', + ) + : Column( + key: const ValueKey('emoji-glyph-editor'), + children: [ + Row( + children: [ + Expanded( + child: TextField( + key: const ValueKey('emoji-avatar-search'), + controller: searchController, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + hintText: 'Search emoji', + filled: true, + fillColor: + context.colors.surfaceContainerHighest, + contentPadding: const EdgeInsets.symmetric( + vertical: 10, + ), + border: const OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(Radii.full), ), + borderSide: BorderSide.none, ), - const SizedBox(width: Grid.xxs), - _AvatarSkinToneSelector( - value: skinTone.value, - onChanged: selectSkinTone, + enabledBorder: const OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(Radii.full), + ), + borderSide: BorderSide.none, ), - ], - ), - const SizedBox(height: Grid.xxs), - Expanded( - child: dataset.isEmpty - ? const Center( - child: CircularProgressIndicator(), - ) - : visibleEmoji.isEmpty - ? Center( - child: Text( - 'No emoji found', - style: context.textTheme.bodyMedium - ?.copyWith( - color: context - .colors - .onSurfaceVariant, - ), + focusedBorder: const OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(Radii.full), + ), + borderSide: BorderSide.none, + ), + prefixIcon: const Icon( + LucideIcons.search, + size: 20, + ), + suffixIcon: searchController.text.isEmpty + ? null + : IconButton( + tooltip: 'Clear search', + onPressed: searchController.clear, + icon: const Icon( + LucideIcons.x, + size: 18, + ), ), - ) - : GridView.builder( - key: const ValueKey('emoji-avatar-grid'), - padding: EdgeInsets.zero, - gridDelegate: - SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: - defaultTargetPlatform == - TargetPlatform.iOS - ? 8 - : 7, - ), - itemCount: visibleEmoji.length, - itemBuilder: (context, index) { - final entry = visibleEmoji[index]; - final isSelected = - entry.native == selectedEmoji; - void selectEmoji() { - unawaited( - HapticFeedback.selectionClick(), - ); - onEmojiSelected(entry.native); - } - - return EmojiAvatarTile( - emoji: entry.native, - label: entry.name, - tileId: entry.tileId, - isSelected: isSelected, - onTap: selectEmoji, - ); - }, - ), + ), ), - ], - ), - ), - ), - ), - ), - const SizedBox(height: Grid.xs), - ClipRect( - child: Transform.translate( - key: const ValueKey('avatar-mode-transition-transform'), - offset: Offset(actionOffset, 0), - child: Opacity( - opacity: transitionProgress, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Spacer(), - Expanded( - child: AvatarEditorOptionButton( - key: const ValueKey('emoji-editor-background'), - icon: LucideIcons.palette, - label: 'Background', - selected: - activeSection == _EmojiEditorSection.background, - onTap: () => - onSectionChanged(_EmojiEditorSection.background), - labelMaxWidth: 96, + ), + const SizedBox(width: Grid.xxs), + _AvatarSkinToneSelector( + value: skinTone.value, + onChanged: selectSkinTone, + ), + ], ), - ), - const SizedBox(width: Grid.half), - Expanded( - child: AvatarEditorOptionButton( - key: const ValueKey('emoji-editor-emoji'), - icon: LucideIcons.smile, - label: 'Emoji', - selected: activeSection == _EmojiEditorSection.emoji, - onTap: () => - onSectionChanged(_EmojiEditorSection.emoji), - labelMaxWidth: 80, + const SizedBox(height: Grid.xxs), + Expanded( + child: dataset.isEmpty + ? const Center(child: CircularProgressIndicator()) + : visibleEmoji.isEmpty + ? Center( + child: Text( + 'No emoji found', + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ) + : GridView.builder( + key: const ValueKey('emoji-avatar-grid'), + padding: EdgeInsets.zero, + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: + defaultTargetPlatform == + TargetPlatform.iOS + ? 8 + : 7, + ), + itemCount: visibleEmoji.length, + itemBuilder: (context, index) { + final entry = visibleEmoji[index]; + final isSelected = + entry.native == selectedEmoji; + void selectEmoji() { + unawaited(HapticFeedback.selectionClick()); + onEmojiSelected(entry.native); + } + + return EmojiAvatarTile( + emoji: entry.native, + label: entry.name, + tileId: entry.tileId, + isSelected: isSelected, + onTap: selectEmoji, + ); + }, + ), ), - ), - const Spacer(), - ], + ], + ), + ), + const SizedBox(height: Grid.xs), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Spacer(), + Expanded( + child: AvatarEditorOptionButton( + key: const ValueKey('emoji-editor-background'), + icon: LucideIcons.palette, + label: 'Background', + selected: activeSection == _EmojiEditorSection.background, + onTap: () => onSectionChanged(_EmojiEditorSection.background), + labelMaxWidth: 96, ), ), - ), + const SizedBox(width: Grid.half), + Expanded( + child: AvatarEditorOptionButton( + key: const ValueKey('emoji-editor-emoji'), + icon: LucideIcons.smile, + label: 'Emoji', + selected: activeSection == _EmojiEditorSection.emoji, + onTap: () => onSectionChanged(_EmojiEditorSection.emoji), + labelMaxWidth: 80, + ), + ), + const Spacer(), + ], ), ], ), @@ -369,13 +320,6 @@ class _SkinToneDot extends StatelessWidget { int _validSkinTone(int? value) => value != null && value >= 0 && value < _skinTones.length ? value : 0; -int _skinToneForEmoji(EmojiDataset dataset, String emoji) => - dataset.all - .where((entry) => entry.native == emoji) - .map((entry) => _validSkinTone(entry.skinIndex)) - .firstOrNull ?? - 0; - List _emojiForSkinTone(EmojiDataset dataset, int skinTone) { final variantsById = >{}; for (final entry in dataset.all) { diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index 3f8fbd6f9be..38231d0f0d3 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -228,11 +228,14 @@ NostrEvent? _latestProfileEvent(List events) { } Map _decodeProfileMetadata(NostrEvent event) { - final decoded = jsonDecode(event.content); - if (decoded is! Map) { - throw const FormatException('Profile metadata must be a JSON object.'); + try { + final decoded = jsonDecode(event.content); + return decoded is Map + ? Map.from(decoded) + : {}; + } on FormatException { + return {}; } - return Map.from(decoded); } final profileProvider = AsyncNotifierProvider( diff --git a/mobile/lib/features/profile/profile_text_editor.dart b/mobile/lib/features/profile/profile_text_editor.dart index b1fbfa801ba..2804c36d711 100644 --- a/mobile/lib/features/profile/profile_text_editor.dart +++ b/mobile/lib/features/profile/profile_text_editor.dart @@ -15,7 +15,15 @@ import 'profile_provider.dart'; /// Opens the current user's display-name editor from a profile action surface. Future showProfileDisplayNameEditor(BuildContext context) async { final container = ProviderScope.containerOf(context, listen: false); - final profile = container.read(profileProvider).asData?.value; + try { + await container.read(profileProvider.future); + } catch (_) { + return; + } + if (!context.mounted) return; + final profileState = container.read(profileProvider); + if (!profileState.hasValue) return; + final profile = profileState.requireValue; final onSave = bindProfileSaveToOpeningContext( container, container.read(profileProvider.notifier).updateDisplayName, @@ -32,7 +40,15 @@ Future showProfileDisplayNameEditor(BuildContext context) async { /// Opens the current user's profile-description editor. Future showProfileDescriptionEditor(BuildContext context) async { final container = ProviderScope.containerOf(context, listen: false); - final profile = container.read(profileProvider).asData?.value; + try { + await container.read(profileProvider.future); + } catch (_) { + return; + } + if (!context.mounted) return; + final profileState = container.read(profileProvider); + if (!profileState.hasValue) return; + final profile = profileState.requireValue; final onSave = bindProfileSaveToOpeningContext( container, container.read(profileProvider.notifier).updateAbout, diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index 24b12ecb987..13dd364b61e 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -5,6 +5,7 @@ import 'package:buzz/features/profile/profile_edit_page.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/emoji_avatar_tile.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'; @@ -813,50 +814,6 @@ void main() { ); }); - testWidgets('seeds emoji editing from the current avatar', (tester) async { - final avatarUrl = emojiAvatarDataUrl('🦝', emojiAvatarColors[11]); - final notifier = _FakeProfileNotifier( - profile: UserProfile( - pubkey: 'aabb', - displayName: 'Alice', - avatarUrl: avatarUrl, - ), - ); - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [profileProvider.overrideWith(() => notifier)], - child: const ProfileEditPage(), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Edit Photo')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Emoji')); - await tester.pump(const Duration(milliseconds: 250)); - - final preview = find.byKey(const ValueKey('emoji-avatar-preview')); - expect( - tester - .widget( - find.descendant( - of: preview, - matching: find.byType(NativeEmojiGlyph), - ), - ) - .emoji, - '🦝', - ); - expect( - (tester.widget(preview).decoration! as BoxDecoration) - .color, - Color(emojiAvatarColors[11]), - ); - await tester.tap(find.byKey(const ValueKey('avatar-save'))); - await tester.pumpAndSettle(); - expect(notifier.savedAvatarUrls, [avatarUrl]); - }); - testWidgets('keeps emoji drafts scoped to the emoji mode', (tester) async { final notifier = _FakeProfileNotifier(); await tester.pumpWidget( 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 63d1b21c474..ca6f7c90ddb 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,50 @@ part of '../profile_edit_page_test.dart'; void runProfileEditImageSelectionTests() { + testWidgets('seeds emoji editing from the current avatar', (tester) async { + final avatarUrl = emojiAvatarDataUrl('🦝', emojiAvatarColors[11]); + final notifier = _FakeProfileNotifier( + profile: UserProfile( + pubkey: 'aabb', + displayName: 'Alice', + avatarUrl: avatarUrl, + ), + ); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(() => notifier)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Emoji')); + await tester.pump(const Duration(milliseconds: 250)); + + final preview = find.byKey(const ValueKey('emoji-avatar-preview')); + expect( + tester + .widget( + find.descendant( + of: preview, + matching: find.byType(NativeEmojiGlyph), + ), + ) + .emoji, + '🦝', + ); + expect( + (tester.widget(preview).decoration! as BoxDecoration) + .color, + Color(emojiAvatarColors[11]), + ); + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pumpAndSettle(); + expect(notifier.savedAvatarUrls, [avatarUrl]); + }); + testWidgets('discards a delayed image after switching avatar modes', ( 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 7c1a0da5dc2..6ae727a4204 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 @@ -238,6 +238,31 @@ void runProfileEditMotionAndAccessibilityTests() { expect(selectedSemantics.properties.selected, isTrue); }); + testWidgets('exposes the selected emoji tile', (tester) async { + await tester.pumpWidget( + WidgetHelpers.testable( + child: EmojiAvatarTile( + emoji: '😊', + label: 'Smiling Face', + tileId: 'smile', + isSelected: true, + onTap: () {}, + ), + ), + ); + + final selectedTile = tester.widget( + find.byWidgetPredicate( + (widget) => + widget is Semantics && + widget.properties.label == 'Smiling Face' && + widget.child is ExcludeSemantics, + ), + ); + expect(selectedTile.properties.button, isTrue); + expect(selectedTile.properties.selected, isTrue); + }); + 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 08ee0990bed..18266627bf7 100644 --- a/mobile/test/features/profile/profile_edit_retry_test.dart +++ b/mobile/test/features/profile/profile_edit_retry_test.dart @@ -16,6 +16,40 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../helpers/widget_helpers.dart'; void main() { + testWidgets('settings text editor waits for profile hydration', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final notifier = _DelayedHydrationProfileNotifier(); + + 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.tap(find.text('Open editor')); + await tester.pump(); + expect(find.byKey(const ValueKey('profile-field-input')), findsNothing); + + notifier.completeHydration(); + await tester.pumpAndSettle(); + expect( + tester + .widget(find.byKey(const ValueKey('profile-field-input'))) + .controller + ?.text, + 'Hydrated name', + ); + debugDefaultTargetPlatformOverride = null; + }); + testWidgets('native text retry retains the failed value', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; addTearDown(() => debugDefaultTargetPlatformOverride = null); @@ -471,6 +505,17 @@ class _MutableRelayConfigNotifier extends RelayConfigNotifier { ); } +class _DelayedHydrationProfileNotifier extends ProfileNotifier { + final _hydration = Completer(); + + @override + Future build() => _hydration.future; + + void completeHydration() => _hydration.complete( + const UserProfile(pubkey: 'aabb', displayName: 'Hydrated name'), + ); +} + class _RetryProfileNotifier extends ProfileNotifier { _RetryProfileNotifier({this.failedTextSaves = 0, this.failedAvatarSaves = 0}); diff --git a/mobile/test/features/profile/profile_provider_test.dart b/mobile/test/features/profile/profile_provider_test.dart index ef0eb13104f..5638656b715 100644 --- a/mobile/test/features/profile/profile_provider_test.dart +++ b/mobile/test/features/profile/profile_provider_test.dart @@ -109,6 +109,30 @@ void main() { expect(profile.label, '${keys.public.substring(0, 8)}...'); }); + test('malformed profile metadata can be repaired by an edit', () async { + final keys = nostr.Keys.generate(); + final relaySession = _ProfileRelaySession( + NostrEvent( + id: 'profile-malformed', + pubkey: keys.public, + createdAt: 1, + kind: EventKind.profile, + tags: const [], + content: 'not-json', + sig: 'sig', + ), + ); + final container = _profileContainer(keys.nsec, relaySession); + addTearDown(container.dispose); + + expect(await container.read(profileProvider.future), isNotNull); + await container.read(profileProvider.notifier).updateAbout('Repaired'); + + expect(jsonDecode(relaySession.published.single.content), { + 'about': 'Repaired', + }); + }); + test('profile updates fail closed while hydration is pending', () async { final keys = nostr.Keys.generate(); final history = Completer>(); From d15ab8dc1bd4e13bac88d1c4f54364cdf33933e0 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 15:01:17 +0100 Subject: [PATCH 28/67] fix(mobile): complete avatar editor accessibility Signed-off-by: kenny lopez --- .../profile/animated_avatar_capture.dart | 127 +++++++++--------- .../emoji_avatar_picker.dart | 17 ++- .../profile/animated_avatar_capture_test.dart | 42 ++++++ .../profile/profile_edit_page_test.dart | 2 + .../image_selection_tests.dart | 87 ++++++++++++ 5 files changed, 214 insertions(+), 61 deletions(-) diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart index 4c8f1e46629..a322fef055d 100644 --- a/mobile/lib/features/profile/animated_avatar_capture.dart +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -5,6 +5,7 @@ import 'dart:math'; import 'package:camera/camera.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -346,78 +347,84 @@ class AnimatedAvatarCapture extends HookConsumerWidget { top: previewTop, height: 220, child: Center( - child: GestureDetector( - key: const ValueKey('animated-avatar-review-preview'), - behavior: HitTestBehavior.opaque, - onScaleStart: (_) => gestureStartScale.value = scale.value, - onScaleUpdate: (details) { - final next = Offset( - (offset.value.dx + details.focalPointDelta.dx / 96).clamp( - -1, - 1, - ), - (offset.value.dy + details.focalPointDelta.dy / 96).clamp( - -1, - 1, - ), + child: _RepositionablePreviewSemantics( + offset: offset.value, + onMove: (delta) { + unawaited(HapticFeedback.selectionClick()); + offset.value = Offset( + (offset.value.dx + delta.dx).clamp(-1.0, 1.0), + (offset.value.dy + delta.dy).clamp(-1.0, 1.0), ); - offset.value = next; - scale.value = (gestureStartScale.value * details.scale) - .clamp(0.7, 2.0) - .toDouble(); }, - child: SizedBox.square( - dimension: 220, - child: Stack( - fit: StackFit.expand, - children: [ - ClipOval( - child: Stack( - fit: StackFit.expand, - children: [ - Center( - child: Transform.translate( - offset: - const Offset(0, 20.625) + - shapeOffset.value * 51.5625, - child: Transform.scale( - scale: shapeScale.value, - child: Container( - width: 172, - height: 172, - decoration: BoxDecoration( - color: Color(backdropColor.value), - shape: BoxShape.circle, + child: GestureDetector( + key: const ValueKey('animated-avatar-review-preview'), + behavior: HitTestBehavior.opaque, + onScaleStart: (_) => gestureStartScale.value = scale.value, + onScaleUpdate: (details) { + final next = Offset( + (offset.value.dx + details.focalPointDelta.dx / 96) + .clamp(-1, 1), + (offset.value.dy + details.focalPointDelta.dy / 96) + .clamp(-1, 1), + ); + offset.value = next; + scale.value = (gestureStartScale.value * details.scale) + .clamp(0.7, 2.0) + .toDouble(); + }, + child: SizedBox.square( + dimension: 220, + child: Stack( + fit: StackFit.expand, + children: [ + ClipOval( + child: Stack( + fit: StackFit.expand, + children: [ + Center( + child: Transform.translate( + offset: + const Offset(0, 20.625) + + shapeOffset.value * 51.5625, + child: Transform.scale( + scale: shapeScale.value, + child: Container( + width: 172, + height: 172, + decoration: BoxDecoration( + color: Color(backdropColor.value), + shape: BoxShape.circle, + ), ), ), ), ), - ), - _AnimatedPersonPreview( - bytes: selectedFrame, - offset: offset.value * 48, - scale: scale.value, - outline: personOutline.value, - outlineColor: _personOutlineColor( - backdropColor.value, + _AnimatedPersonPreview( + bytes: selectedFrame, + offset: offset.value * 48, + scale: scale.value, + outline: personOutline.value, + outlineColor: _personOutlineColor( + backdropColor.value, + ), ), - ), - ], + ], + ), ), - ), - IgnorePointer( - child: DecoratedBox( - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: context.colors.onSurface.withValues( - alpha: 0.1, + IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: context.colors.onSurface.withValues( + alpha: 0.1, + ), ), ), ), ), - ), - ], + ], + ), ), ), ), 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 6e8e1e3c821..08bc6629394 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 @@ -36,7 +36,15 @@ class _EmojiMode extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final skinTone = useState(0); + final skinTone = useState(_skinToneForEmoji(dataset, selectedEmoji)); + final seededSkinTone = useRef(false); + useEffect(() { + if (seededSkinTone.value || dataset.isEmpty) return null; + seededSkinTone.value = true; + final initialTone = _skinToneForEmoji(dataset, selectedEmoji); + if (skinTone.value != initialTone) skinTone.value = initialTone; + return null; + }, [dataset]); final searchController = useTextEditingController(); useListenable(searchController); final visibleEmoji = useMemoized(() { @@ -320,6 +328,13 @@ class _SkinToneDot extends StatelessWidget { int _validSkinTone(int? value) => value != null && value >= 0 && value < _skinTones.length ? value : 0; +int _skinToneForEmoji(EmojiDataset dataset, String emoji) => + dataset.all + .where((entry) => entry.native == emoji) + .map((entry) => _validSkinTone(entry.skinIndex)) + .firstOrNull ?? + 0; + List _emojiForSkinTone(EmojiDataset dataset, int skinTone) { final variantsById = >{}; for (final entry in dataset.all) { diff --git a/mobile/test/features/profile/animated_avatar_capture_test.dart b/mobile/test/features/profile/animated_avatar_capture_test.dart index c7fcf359a0e..63059266321 100644 --- a/mobile/test/features/profile/animated_avatar_capture_test.dart +++ b/mobile/test/features/profile/animated_avatar_capture_test.dart @@ -176,6 +176,48 @@ void main() { expect(tester.getSemantics(scrubber).value, '2 of 3'); semantics.dispose(); }); + + testWidgets('review preview exposes accessible repositioning actions', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + final frame = image.encodePng(image.Image(width: 2, height: 2)); + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: AnimatedAvatarCapture( + height: 600, + initialFrames: [frame], + onPrepareChanged: (_) {}, + ), + ), + ), + ), + ); + await tester.pump(); + + final position = find.bySemanticsLabel('Avatar position'); + expect(position, findsOneWidget); + final positionWidget = tester.widget( + find.byWidgetPredicate( + (widget) => + widget is Semantics && widget.properties.label == 'Avatar position', + ), + ); + final actions = positionWidget.properties.customSemanticsActions!; + expect( + actions.keys.map((action) => action.label), + containsAll(['Move left', 'Move right', 'Move up', 'Move down']), + ); + actions.entries + .firstWhere((entry) => entry.key.label == 'Move right') + .value(); + await tester.pump(); + expect(tester.getSemantics(position).value, '10 horizontal, 0 vertical'); + semantics.dispose(); + }); } class _TestLifecycleNotifier extends AppLifecycleNotifier { diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index 13dd364b61e..89ea055b464 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -9,6 +9,8 @@ import 'package:buzz/features/profile/emoji_avatar_tile.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'; +import 'package:buzz/shared/emoji/emoji_data.dart'; +import 'package:buzz/shared/emoji/emoji_data_provider.dart'; import 'package:buzz/shared/emoji/native_emoji_glyph.dart'; import 'package:buzz/shared/profile/user_profile.dart'; import 'package:buzz/shared/relay/relay.dart'; 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 ca6f7c90ddb..8b9461a8939 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 @@ -45,6 +45,93 @@ void runProfileEditImageSelectionTests() { expect(notifier.savedAvatarUrls, [avatarUrl]); }); + testWidgets('seeds the skin-tone filter from the current emoji', ( + tester, + ) async { + const variants = [ + EmojiEntry( + id: '+1', + name: 'Thumbs Up', + keywords: ['thumb'], + native: '👍', + categoryId: 'people', + ), + EmojiEntry( + id: '+1', + name: 'Thumbs Up', + keywords: ['thumb'], + native: '👍🏻', + categoryId: 'people', + skinIndex: 1, + ), + EmojiEntry( + id: '+1', + name: 'Thumbs Up', + keywords: ['thumb'], + native: '👍🏼', + categoryId: 'people', + skinIndex: 2, + ), + EmojiEntry( + id: '+1', + name: 'Thumbs Up', + keywords: ['thumb'], + native: '👍🏽', + categoryId: 'people', + skinIndex: 3, + ), + EmojiEntry( + id: '+1', + name: 'Thumbs Up', + keywords: ['thumb'], + native: '👍🏾', + categoryId: 'people', + skinIndex: 4, + ), + EmojiEntry( + id: '+1', + name: 'Thumbs Up', + keywords: ['thumb'], + native: '👍🏿', + categoryId: 'people', + skinIndex: 5, + ), + ]; + const dataset = EmojiDataset( + categories: [EmojiCategory(id: 'people', emoji: variants)], + all: variants, + nativeToShortcode: {'👍🏽': ':+1:'}, + ); + final avatarUrl = emojiAvatarDataUrl('👍🏽', emojiAvatarColors[11]); + final notifier = _FakeProfileNotifier( + profile: UserProfile(pubkey: 'aabb', avatarUrl: avatarUrl), + ); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(() => notifier), + emojiDatasetOrEmptyProvider.overrideWithValue(dataset), + ], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Emoji')); + await tester.pump(const Duration(milliseconds: 250)); + + expect( + tester + .widget>( + find.byKey(const ValueKey('emoji-avatar-skin-tone')), + ) + .initialValue, + 3, + ); + }); + testWidgets('discards a delayed image after switching avatar modes', ( tester, ) async { From 8c74dfa72ac1f560685d62be9a1417e263ca1bab Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 15:11:24 +0100 Subject: [PATCH 29/67] fix(mobile): make photo cropping accessible Signed-off-by: kenny lopez --- .../features/profile/emoji_avatar_tile.dart | 10 +++++ .../image_selection_tests.dart | 42 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/mobile/lib/features/profile/emoji_avatar_tile.dart b/mobile/lib/features/profile/emoji_avatar_tile.dart index cd2955e0127..ff8e7f5dc77 100644 --- a/mobile/lib/features/profile/emoji_avatar_tile.dart +++ b/mobile/lib/features/profile/emoji_avatar_tile.dart @@ -5,6 +5,7 @@ import '../../shared/theme/theme.dart'; /// A selectable emoji tile with an explicit accessibility selection state. class EmojiAvatarTile extends StatelessWidget { + /// Creates an emoji option for an avatar picker. const EmojiAvatarTile({ required this.emoji, required this.label, @@ -14,10 +15,19 @@ class EmojiAvatarTile extends StatelessWidget { super.key, }); + /// The Unicode emoji glyph rendered by this tile. final String emoji; + + /// The human-readable emoji name announced to assistive technology. final String label; + + /// The stable identifier used for the tile's widget key. final String tileId; + + /// Whether this emoji is the current avatar selection. final bool isSelected; + + /// Called when the tile is selected. final VoidCallback onTap; @override 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 8b9461a8939..10a23be6637 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,48 @@ part of '../profile_edit_page_test.dart'; void runProfileEditImageSelectionTests() { + testWidgets('photo crop exposes accessible move and zoom actions', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + final bytes = Uint8List.fromList( + image.encodePng(image.Image(width: 20, height: 10)), + ); + await tester.pumpWidget( + MaterialApp( + home: ProfileAvatarCropPage( + imageBytes: Future.value(bytes), + ), + ), + ); + await _waitForAvatarCropToLoad(tester); + + final cropSemantics = tester.widget( + find.byWidgetPredicate( + (widget) => + widget is Semantics && widget.properties.label == 'Photo crop', + ), + ); + final actions = cropSemantics.properties.customSemanticsActions!; + expect( + actions.keys.map((action) => action.label), + containsAll([ + 'Move left', + 'Move right', + 'Move up', + 'Move down', + 'Zoom in', + ]), + ); + final viewer = tester.widget( + find.byKey(const ValueKey('avatar-crop-viewer')), + ); + actions.entries.firstWhere((entry) => entry.key.label == 'Zoom in').value(); + await tester.pump(); + expect(viewer.transformationController!.value.getMaxScaleOnAxis(), 1.1); + semantics.dispose(); + }); + testWidgets('seeds emoji editing from the current avatar', (tester) async { final avatarUrl = emojiAvatarDataUrl('🦝', emojiAvatarColors[11]); final notifier = _FakeProfileNotifier( From 7188a988595caee7bf580b8b9427fd74b7519684 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 15:22:15 +0100 Subject: [PATCH 30/67] fix(mobile): guard avatar editor races Signed-off-by: kenny lopez --- .../features/profile/profile_edit_page.dart | 47 +++++++++--- .../image_selection_tests.dart | 34 +++++++++ .../profile/profile_edit_retry_test.dart | 71 +++++++++++++++++++ 3 files changed, 141 insertions(+), 11 deletions(-) diff --git a/mobile/lib/features/profile/profile_edit_page.dart b/mobile/lib/features/profile/profile_edit_page.dart index 208bc48b034..207dad3976e 100644 --- a/mobile/lib/features/profile/profile_edit_page.dart +++ b/mobile/lib/features/profile/profile_edit_page.dart @@ -56,6 +56,7 @@ class ProfileEditPage extends HookConsumerWidget { startInPhotoEditor ? ref.read(relayConfigProvider) : null, ); final isSavingAvatar = useState(false); + final isClosingAvatar = useState(false); final prepareAnimatedAvatar = useRef Function()?>(null); final avatarSaveError = useState(null); @@ -153,7 +154,10 @@ class ProfileEditPage extends HookConsumerWidget { } Future closeAvatarEditor({bool whileSaving = false}) async { - if (isSavingAvatar.value && !whileSaving) return; + if ((isSavingAvatar.value && !whileSaving) || isClosingAvatar.value) { + return; + } + isClosingAvatar.value = true; await avatarTransition.reverse(); if (!context.mounted) return; if (startInPhotoEditor) { @@ -167,10 +171,11 @@ class ProfileEditPage extends HookConsumerWidget { prepareAnimatedAvatar.value = null; canPrepareAnimatedAvatar.value = false; avatarMode.value = ProfileAvatarMode.image; + isClosingAvatar.value = false; } Future saveAvatar() async { - if (isSavingAvatar.value) return; + if (isSavingAvatar.value || isClosingAvatar.value) return; final openingConfig = avatarEditConfig.value; if (openingConfig == null) return; final saveConfig = ref.read(relayConfigProvider); @@ -187,6 +192,14 @@ class ProfileEditPage extends HookConsumerWidget { } } + Future discardStaleEditor() async { + avatarDraft.value = null; + avatarDraftMode.value = null; + prepareAnimatedAvatar.value = null; + canPrepareAnimatedAvatar.value = false; + if (context.mounted) await closeAvatarEditor(whileSaving: true); + } + isSavingAvatar.value = true; avatarSaveError.value = null; try { @@ -211,12 +224,14 @@ class ProfileEditPage extends HookConsumerWidget { requireCurrentCommunity(); if (context.mounted) await closeAvatarEditor(whileSaving: true); } on ProfileCommunityChangedException { - avatarDraft.value = null; - avatarDraftMode.value = null; - prepareAnimatedAvatar.value = null; - canPrepareAnimatedAvatar.value = false; - if (context.mounted) await closeAvatarEditor(whileSaving: true); + await discardStaleEditor(); } catch (_) { + try { + requireCurrentCommunity(); + } on ProfileCommunityChangedException { + await discardStaleEditor(); + return; + } avatarSaveError.value = "We couldn't save your profile photo. Try again."; } finally { @@ -261,12 +276,16 @@ class ProfileEditPage extends HookConsumerWidget { key: const ValueKey('avatar-editor-back'), icon: IosGlassNavigationIcon.back, semanticLabel: 'Back to profile', - onPressed: () => unawaited(closeAvatarEditor()), + onPressed: isClosingAvatar.value + ? null + : () => unawaited(closeAvatarEditor()), ) : IconButton( key: const ValueKey('avatar-editor-back'), tooltip: 'Back to profile', - onPressed: () => unawaited(closeAvatarEditor()), + onPressed: isClosingAvatar.value + ? null + : () => unawaited(closeAvatarEditor()), icon: const Icon(LucideIcons.arrowLeft), ) : null, @@ -278,7 +297,10 @@ class ProfileEditPage extends HookConsumerWidget { label: 'Save', width: 72, isBusy: isSavingAvatar.value, - onPressed: canSaveAvatar && !isSavingAvatar.value + onPressed: + canSaveAvatar && + !isSavingAvatar.value && + !isClosingAvatar.value ? () { unawaited(HapticFeedback.lightImpact()); unawaited(saveAvatar()); @@ -292,7 +314,10 @@ class ProfileEditPage extends HookConsumerWidget { key: const ValueKey('avatar-save'), label: 'Save', isBusy: isSavingAvatar.value, - onTap: canSaveAvatar && !isSavingAvatar.value + onTap: + canSaveAvatar && + !isSavingAvatar.value && + !isClosingAvatar.value ? () { unawaited(HapticFeedback.lightImpact()); unawaited(saveAvatar()); 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 10a23be6637..62c5455dc1a 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,40 @@ part of '../profile_edit_page_test.dart'; void runProfileEditImageSelectionTests() { + testWidgets('duplicate avatar Back taps pop only the editor route', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => unawaited( + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + const ProfileEditPage(startInPhotoEditor: true), + ), + ), + ), + child: const Text('Open profile photo'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open profile photo')); + await tester.pumpAndSettle(); + + final back = find.byKey(const ValueKey('avatar-editor-back')); + await tester.tap(back); + await tester.tap(back); + await tester.pumpAndSettle(); + + expect(find.text('Open profile photo'), findsOneWidget); + }); + testWidgets('photo crop exposes accessible move and zoom actions', ( 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 18266627bf7..0c157c6930d 100644 --- a/mobile/test/features/profile/profile_edit_retry_test.dart +++ b/mobile/test/features/profile/profile_edit_retry_test.dart @@ -495,6 +495,71 @@ void main() { expect(find.byKey(const ValueKey('avatar-save')), findsNothing); debugDefaultTargetPlatformOverride = null; }); + + testWidgets('avatar editor closes when a switched upload throws', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final notifier = _RetryProfileNotifier(); + final config = _MutableRelayConfigNotifier(); + final firstUpload = _RetryMediaUploadService( + baseUrl: 'https://first.example', + delayUpload: true, + ); + final secondUpload = _RetryMediaUploadService( + baseUrl: 'https://second.example', + ); + addTearDown(firstUpload.dispose); + addTearDown(secondUpload.dispose); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(() => notifier), + relayConfigProvider.overrideWith(() => config), + mediaUploadServiceProvider.overrideWith((ref) { + final current = ref.watch(relayConfigProvider); + return current.baseUrl == 'https://first.example' + ? firstUpload + : secondUpload; + }), + ], + child: ProfileEditPage( + startInPhotoEditor: true, + animatedAvatarCaptureBuilder: + ({required height, required onPrepareChanged}) => HookBuilder( + builder: (context) { + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + onPrepareChanged( + () async => ProfileImageAvatarDraft( + Uint8List.fromList([1, 2, 3]), + ), + ); + }); + return null; + }, const []); + return SizedBox(height: height); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Animated')); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pump(); + config.update(baseUrl: 'https://second.example', nsec: 'second-identity'); + firstUpload.failUpload(); + await tester.pumpAndSettle(); + + expect(notifier.savedAvatarUrls, isEmpty); + expect(find.byKey(const ValueKey('avatar-save')), findsNothing); + debugDefaultTargetPlatformOverride = null; + }); } class _MutableRelayConfigNotifier extends RelayConfigNotifier { @@ -607,6 +672,12 @@ class _RetryMediaUploadService extends MediaUploadService { if (!_pendingUpload.isCompleted) _pendingUpload.complete(); } + void failUpload() { + if (!_pendingUpload.isCompleted) { + _pendingUpload.completeError(Exception('upload client closed')); + } + } + @override Future uploadBytes( Uint8List bytes, { From 72b0f9601a879b4008844ee3a694d5887efb30c3 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 15:41:18 +0100 Subject: [PATCH 31/67] fix(mobile): finish avatar capture review Signed-off-by: kenny lopez --- .../profile/animated_avatar_capture.dart | 33 +++++--- .../profile/profile_edit_page_test.dart | 48 ----------- .../motion_and_accessibility_tests.dart | 84 +++++++++++++++++++ 3 files changed, 106 insertions(+), 59 deletions(-) diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart index a322fef055d..e503442d7d3 100644 --- a/mobile/lib/features/profile/animated_avatar_capture.dart +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -59,6 +59,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final controller = useState(null); final controllerRef = useRef(null); + final captureEpoch = useRef(0); final cameraGeneration = useState(0); final isInitializing = useState(true); final isRecording = useState(false); @@ -160,6 +161,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { unawaited(initialize()); return () { disposed = true; + captureEpoch.value++; final active = controllerRef.value; controllerRef.value = null; unawaited(active?.dispose() ?? Future.value()); @@ -213,6 +215,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { Future record() async { final active = controller.value; if (active == null || isRecording.value) return; + final currentCapture = ++captureEpoch.value; frames.value = const []; posterIndex.value = 0; previewFrameIndex.value = 0; @@ -230,7 +233,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { var releasedCamera = false; Future releaseCamera() async { - if (releasedCamera) return; + if (releasedCamera || captureEpoch.value != currentCapture) return; releasedCamera = true; if (identical(controllerRef.value, active)) { controllerRef.value = null; @@ -242,7 +245,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { } final timer = Timer.periodic(const Duration(milliseconds: 40), (_) { - if (!context.mounted) return; + if (!context.mounted || captureEpoch.value != currentCapture) return; final elapsed = DateTime.now().difference(startedAt); progress.value = (elapsed.inMilliseconds / _captureDuration.inMilliseconds).clamp( @@ -254,7 +257,8 @@ class AnimatedAvatarCapture extends HookConsumerWidget { try { await active.startImageStream((cameraImage) async { final now = DateTime.now(); - if (converting || + if (captureEpoch.value != currentCapture || + converting || now.difference(lastFrameAt) < _captureFrameInterval || now.difference(startedAt) >= _captureDuration) { return; @@ -272,27 +276,29 @@ class AnimatedAvatarCapture extends HookConsumerWidget { mirror: active.description.lensDirection == CameraLensDirection.front, ); - captured.add(await compute(_convertCameraFrame, request)); + final frame = await compute(_convertCameraFrame, request); + if (captureEpoch.value == currentCapture) captured.add(frame); } finally { converting = false; } }); await Future.delayed(_captureDuration); + if (captureEpoch.value != currentCapture || !context.mounted) return; await active.stopImageStream(); - while (converting) { + while (converting && captureEpoch.value == currentCapture) { 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; isPreparingFrames.value = true; - // Camera image conversion throughput differs by platform. Cut out only - // the frames each device captured, then resample the same three-second - // window onto one fixed timeline so Android and iOS produce the same - // frame count and playback cadence without duplicating segmentation. + // 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); + if (captureEpoch.value != currentCapture || !context.mounted) return; final processed = List.unmodifiable( _resampleCapturedFrames(cutouts, _captureFrameCount), ); @@ -303,8 +309,13 @@ class AnimatedAvatarCapture extends HookConsumerWidget { ]); if (context.mounted) frames.value = processed; } catch (_) { - if (!releasedCamera && active.value.isStreamingImages) { - await active.stopImageStream(); + if (captureEpoch.value != currentCapture || !context.mounted) return; + try { + if (!releasedCamera && active.value.isStreamingImages) { + await active.stopImageStream(); + } + } on CameraException { + // The camera can stop independently while the capture is unwinding. } await releaseCamera(); if (context.mounted) { diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index 89ea055b464..c1037943ba1 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -435,54 +435,6 @@ void main() { expect(uploadService.uploadCount, 1); }); - testWidgets('photo modes remain usable on a compact large-type viewport', ( - tester, - ) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(320, 568); - addTearDown(tester.view.resetDevicePixelRatio); - addTearDown(tester.view.resetPhysicalSize); - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], - child: const MediaQuery( - data: MediaQueryData(textScaler: TextScaler.linear(2)), - child: ProfileEditPage(startInPhotoEditor: true), - ), - ), - ); - await tester.pump(const Duration(milliseconds: 250)); - expect(tester.takeException(), isNull); - expect( - find.byKey(const ValueKey('avatar-editor-scroll-view')), - findsOneWidget, - ); - - await tester.tap(find.text('Emoji')); - await tester.pump(const Duration(milliseconds: 250)); - expect(tester.takeException(), isNull); - await tester.ensureVisible( - find.byKey(const ValueKey('emoji-editor-background')), - ); - await tester.tap(find.byKey(const ValueKey('emoji-editor-background'))); - await tester.pump(const Duration(milliseconds: 200)); - expect(tester.takeException(), isNull); - - await tester.drag( - find.byKey(const ValueKey('avatar-editor-scroll-view')), - const Offset(0, 1000), - ); - await tester.pump(); - final animatedMode = find.byKey(const ValueKey('avatar-mode-animated')); - await tester.tap(animatedMode); - await tester.pump(const Duration(milliseconds: 250)); - expect(tester.takeException(), isNull); - expect( - find.byKey(const ValueKey('animated-avatar-capture-preview')), - findsOneWidget, - ); - }); - testWidgets('centers every preview while controls fill the page gutters', ( 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 6ae727a4204..2c0c7ebf816 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 @@ -1,6 +1,58 @@ part of '../profile_edit_page_test.dart'; void runProfileEditMotionAndAccessibilityTests() { + testWidgets('photo modes remain usable on a compact large-type viewport', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(320, 568); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: const MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(2)), + child: ProfileEditPage(startInPhotoEditor: true), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 250)); + expect(tester.takeException(), isNull); + expect( + find.byKey(const ValueKey('avatar-editor-scroll-view')), + findsOneWidget, + ); + + await tester.tap(find.text('Emoji')); + await tester.pump(const Duration(milliseconds: 250)); + expect(tester.takeException(), isNull); + await tester.ensureVisible( + find.byKey(const ValueKey('emoji-editor-background')), + ); + await tester.tap(find.byKey(const ValueKey('emoji-editor-background'))); + await tester.pump(const Duration(milliseconds: 200)); + expect(tester.takeException(), isNull); + + await tester.drag( + find.byKey(const ValueKey('avatar-editor-scroll-view')), + const Offset(0, 1000), + ); + await tester.pump(); + final animatedMode = find.byKey(const ValueKey('avatar-mode-animated')); + await Scrollable.ensureVisible( + animatedMode.evaluate().single, + alignment: 0.2, + ); + await tester.tap(animatedMode); + await tester.pump(const Duration(milliseconds: 250)); + expect(tester.takeException(), isNull); + expect( + find.byKey(const ValueKey('animated-avatar-capture-preview')), + findsOneWidget, + ); + }); + testWidgets('exposes the selected avatar mode on Android', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.android; addTearDown(() => debugDefaultTargetPlatformOverride = null); @@ -284,4 +336,36 @@ void runProfileEditMotionAndAccessibilityTests() { final firstColor = find.byKey(const ValueKey('emoji-avatar-color-0')); expect(tester.getSize(firstColor), const Size.square(52)); }); + + testWidgets('background colors remain reachable in compact layouts', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + child: SizedBox( + height: 150, + child: AvatarBackgroundGrid( + selectedColor: emojiAvatarColors.first, + onColorSelected: (_) {}, + ), + ), + ), + ); + + final scrollable = tester.state( + find.byType(Scrollable).first, + ); + expect(scrollable.position.maxScrollExtent, greaterThan(0)); + + await tester.drag(find.byType(AvatarBackgroundGrid), const Offset(0, -400)); + await tester.pumpAndSettle(); + + expect(scrollable.position.pixels, greaterThan(0)); + expect( + find.byKey( + ValueKey('avatar-background-color-${emojiAvatarColors.length - 1}'), + ), + findsOneWidget, + ); + }); } From 102c99d4a3f128a8c35fbd38393b28037b4e9012 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 15:57:37 +0100 Subject: [PATCH 32/67] fix(mobile): refine avatar mode motion Signed-off-by: kenny lopez --- .../profile/profile_avatar_editor.dart | 43 ++- .../emoji_avatar_picker.dart | 307 ++++++++++-------- .../profile/profile_edit_page_test.dart | 7 + .../motion_and_accessibility_tests.dart | 46 +++ 4 files changed, 268 insertions(+), 135 deletions(-) diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart index 997c5a1c65e..df7bbbe348d 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -50,7 +50,7 @@ typedef AnimatedAvatarCaptureBuilder = }); const _previewSize = 220.0; -const _motionDuration = Duration(milliseconds: 240); +const _motionDuration = Duration(milliseconds: 150); const _previewSquishDuration = Duration(milliseconds: 200); const Curve _entranceCurve = Curves.easeOutCubic; const Curve _exitCurve = Curves.easeInCubic; @@ -133,6 +133,8 @@ class ProfileAvatarEditor extends HookConsumerWidget { final error = useState(null); final dataset = ref.watch(emojiDatasetOrEmptyProvider); final modeTransitionDirection = useRef(1.0); + final retainedPreview = useRef(null); + final retainedPreviewTop = useRef(0.0); final modeTransitionController = useAnimationController( duration: _motionDuration, initialValue: 1, @@ -269,6 +271,16 @@ class ProfileAvatarEditor extends HookConsumerWidget { ), ], ); + if (mode != ProfileAvatarMode.animated && fixedPreview != null) { + retainedPreview.value = mode == ProfileAvatarMode.emoji + ? _EmojiAvatarPreview( + emoji: selectedEmoji.value, + color: Color(selectedColor.value), + animationKey: emojiPreviewKey.value, + reduceMotion: true, + ) + : fixedPreview; + } final curvedEntrance = CurvedAnimation( parent: transition, @@ -296,6 +308,9 @@ class ProfileAvatarEditor extends HookConsumerWidget { : avatarBackgroundPreviewShift; final previewShift = min(requestedShift, maximumShift); final previewTop = basePreviewTop - previewShift; + if (mode != ProfileAvatarMode.animated) { + retainedPreviewTop.value = previewTop; + } final fixedContentTop = previewTop + _previewBlockSize + _previewControlGap; final modeTop = mode == ProfileAvatarMode.animated @@ -320,6 +335,8 @@ class ProfileAvatarEditor extends HookConsumerWidget { dataset: dataset, selectedEmoji: selectedEmoji.value, selectedColor: selectedColor.value, + transitionProgress: modeTransitionProgress, + transitionDirection: modeTransitionDirection.value, onSectionChanged: (section) => emojiSection.value = section, onEmojiSelected: (emoji) { selectedEmoji.value = emoji; @@ -347,7 +364,9 @@ class ProfileAvatarEditor extends HookConsumerWidget { appBarHeight + _settingsAvatarCenterBelowAppBar - (previewTop + _previewBlockSize / 2); - final transitionedModeContent = mode == ProfileAvatarMode.animated + final transitionedModeContent = + mode == ProfileAvatarMode.animated || + mode == ProfileAvatarMode.emoji ? modeContent : ClipRect( child: Transform.translate( @@ -452,6 +471,26 @@ class ProfileAvatarEditor extends HookConsumerWidget { child: transitionedModeContent, ), ), + if (mode == ProfileAvatarMode.animated && + !reduceMotion && + modeTransitionProgress < 1 && + retainedPreview.value != null) + Positioned( + key: const ValueKey('avatar-mode-retained-preview'), + left: Grid.gutter, + right: Grid.gutter, + top: + retainedPreviewTop.value + + (basePreviewTop - retainedPreviewTop.value) * + modeTransitionProgress, + height: _previewBlockSize, + child: IgnorePointer( + child: Opacity( + opacity: 1 - modeTransitionProgress, + child: Center(child: retainedPreview.value), + ), + ), + ), if (error.value != null) Positioned( left: Grid.gutter, 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 08bc6629394..1712c0c99bd 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 @@ -20,6 +20,8 @@ class _EmojiMode extends HookConsumerWidget { required this.dataset, required this.selectedEmoji, required this.selectedColor, + required this.transitionProgress, + required this.transitionDirection, required this.onSectionChanged, required this.onEmojiSelected, required this.onColorSelected, @@ -30,12 +32,21 @@ class _EmojiMode extends HookConsumerWidget { final EmojiDataset dataset; final String selectedEmoji; final int selectedColor; + final double transitionProgress; + final double transitionDirection; final ValueChanged<_EmojiEditorSection> onSectionChanged; final ValueChanged onEmojiSelected; final ValueChanged onColorSelected; @override Widget build(BuildContext context, WidgetRef ref) { + final reduceMotion = MediaQuery.disableAnimationsOf(context); + final trayOffset = reduceMotion ? 0.0 : 16 * (1 - transitionProgress); + final actionOffset = reduceMotion + ? 0.0 + : transitionDirection * + _modeTransitionDistance * + (1 - transitionProgress); final skinTone = useState(_skinToneForEmoji(dataset, selectedEmoji)); final seededSkinTone = useRef(false); useEffect(() { @@ -65,148 +76,178 @@ class _EmojiMode extends HookConsumerWidget { child: Column( children: [ Expanded( - child: activeSection == _EmojiEditorSection.background - ? AvatarBackgroundGrid( - key: const ValueKey('emoji-background-editor'), - selectedColor: selectedColor, - onColorSelected: onColorSelected, - colorKeyPrefix: 'emoji-avatar-color', - ) - : Column( - key: const ValueKey('emoji-glyph-editor'), - children: [ - Row( - children: [ - Expanded( - child: TextField( - key: const ValueKey('emoji-avatar-search'), - controller: searchController, - textInputAction: TextInputAction.search, - decoration: InputDecoration( - hintText: 'Search emoji', - filled: true, - fillColor: - context.colors.surfaceContainerHighest, - contentPadding: const EdgeInsets.symmetric( - vertical: 10, - ), - border: const OutlineInputBorder( - borderRadius: BorderRadius.all( - Radius.circular(Radii.full), - ), - borderSide: BorderSide.none, - ), - enabledBorder: const OutlineInputBorder( - borderRadius: BorderRadius.all( - Radius.circular(Radii.full), - ), - borderSide: BorderSide.none, - ), - focusedBorder: const OutlineInputBorder( - borderRadius: BorderRadius.all( - Radius.circular(Radii.full), - ), - borderSide: BorderSide.none, - ), - prefixIcon: const Icon( - LucideIcons.search, - size: 20, - ), - suffixIcon: searchController.text.isEmpty - ? null - : IconButton( - tooltip: 'Clear search', - onPressed: searchController.clear, - icon: const Icon( - LucideIcons.x, - size: 18, + child: ClipRect( + child: Transform.translate( + key: const ValueKey('avatar-mode-tray-transition-transform'), + offset: Offset(0, trayOffset), + child: Opacity( + opacity: transitionProgress, + child: activeSection == _EmojiEditorSection.background + ? AvatarBackgroundGrid( + key: const ValueKey('emoji-background-editor'), + selectedColor: selectedColor, + onColorSelected: onColorSelected, + colorKeyPrefix: 'emoji-avatar-color', + ) + : Column( + key: const ValueKey('emoji-glyph-editor'), + children: [ + Row( + children: [ + Expanded( + child: TextField( + key: const ValueKey('emoji-avatar-search'), + controller: searchController, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + hintText: 'Search emoji', + filled: true, + fillColor: context + .colors + .surfaceContainerHighest, + contentPadding: + const EdgeInsets.symmetric( + vertical: 10, + ), + border: const OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(Radii.full), ), + borderSide: BorderSide.none, ), - ), - ), - ), - const SizedBox(width: Grid.xxs), - _AvatarSkinToneSelector( - value: skinTone.value, - onChanged: selectSkinTone, - ), - ], - ), - const SizedBox(height: Grid.xxs), - Expanded( - child: dataset.isEmpty - ? const Center(child: CircularProgressIndicator()) - : visibleEmoji.isEmpty - ? Center( - child: Text( - 'No emoji found', - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, + enabledBorder: const OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(Radii.full), + ), + borderSide: BorderSide.none, + ), + focusedBorder: const OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(Radii.full), + ), + borderSide: BorderSide.none, + ), + prefixIcon: const Icon( + LucideIcons.search, + size: 20, + ), + suffixIcon: searchController.text.isEmpty + ? null + : IconButton( + tooltip: 'Clear search', + onPressed: searchController.clear, + icon: const Icon( + LucideIcons.x, + size: 18, + ), + ), + ), ), ), - ) - : GridView.builder( - key: const ValueKey('emoji-avatar-grid'), - padding: EdgeInsets.zero, - gridDelegate: - SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: - defaultTargetPlatform == - TargetPlatform.iOS - ? 8 - : 7, - ), - itemCount: visibleEmoji.length, - itemBuilder: (context, index) { - final entry = visibleEmoji[index]; - final isSelected = - entry.native == selectedEmoji; - void selectEmoji() { - unawaited(HapticFeedback.selectionClick()); - onEmojiSelected(entry.native); - } + const SizedBox(width: Grid.xxs), + _AvatarSkinToneSelector( + value: skinTone.value, + onChanged: selectSkinTone, + ), + ], + ), + const SizedBox(height: Grid.xxs), + Expanded( + child: dataset.isEmpty + ? const Center( + child: CircularProgressIndicator(), + ) + : visibleEmoji.isEmpty + ? Center( + child: Text( + 'No emoji found', + style: context.textTheme.bodyMedium + ?.copyWith( + color: context + .colors + .onSurfaceVariant, + ), + ), + ) + : GridView.builder( + key: const ValueKey('emoji-avatar-grid'), + padding: EdgeInsets.zero, + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: + defaultTargetPlatform == + TargetPlatform.iOS + ? 8 + : 7, + ), + itemCount: visibleEmoji.length, + itemBuilder: (context, index) { + final entry = visibleEmoji[index]; + final isSelected = + entry.native == selectedEmoji; + void selectEmoji() { + unawaited( + HapticFeedback.selectionClick(), + ); + onEmojiSelected(entry.native); + } - return EmojiAvatarTile( - emoji: entry.native, - label: entry.name, - tileId: entry.tileId, - isSelected: isSelected, - onTap: selectEmoji, - ); - }, - ), - ), - ], - ), - ), - const SizedBox(height: Grid.xs), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Spacer(), - Expanded( - child: AvatarEditorOptionButton( - key: const ValueKey('emoji-editor-background'), - icon: LucideIcons.palette, - label: 'Background', - selected: activeSection == _EmojiEditorSection.background, - onTap: () => onSectionChanged(_EmojiEditorSection.background), - labelMaxWidth: 96, + return EmojiAvatarTile( + emoji: entry.native, + label: entry.name, + tileId: entry.tileId, + isSelected: isSelected, + onTap: selectEmoji, + ); + }, + ), + ), + ], + ), ), ), - const SizedBox(width: Grid.half), - Expanded( - child: AvatarEditorOptionButton( - key: const ValueKey('emoji-editor-emoji'), - icon: LucideIcons.smile, - label: 'Emoji', - selected: activeSection == _EmojiEditorSection.emoji, - onTap: () => onSectionChanged(_EmojiEditorSection.emoji), - labelMaxWidth: 80, + ), + ), + const SizedBox(height: Grid.xs), + ClipRect( + child: Transform.translate( + key: const ValueKey('avatar-mode-transition-transform'), + offset: Offset(actionOffset, 0), + child: Opacity( + opacity: transitionProgress, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Spacer(), + Expanded( + child: AvatarEditorOptionButton( + key: const ValueKey('emoji-editor-background'), + icon: LucideIcons.palette, + label: 'Background', + selected: + activeSection == _EmojiEditorSection.background, + onTap: () => + onSectionChanged(_EmojiEditorSection.background), + labelMaxWidth: 96, + ), + ), + const SizedBox(width: Grid.half), + Expanded( + child: AvatarEditorOptionButton( + key: const ValueKey('emoji-editor-emoji'), + icon: LucideIcons.smile, + label: 'Emoji', + selected: activeSection == _EmojiEditorSection.emoji, + onTap: () => + onSectionChanged(_EmojiEditorSection.emoji), + labelMaxWidth: 80, + ), + ), + const Spacer(), + ], ), ), - const Spacer(), - ], + ), ), ], ), diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index c1037943ba1..3c405475eb8 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -572,6 +572,13 @@ void main() { tester.getSize(recordButton).width, closeTo(expectedContentWidth, 0.01), ); + final recordMaterial = tester.widget( + find.descendant(of: recordButton, matching: find.byType(Material)).first, + ); + expect( + recordMaterial.borderRadius, + const BorderRadius.all(Radius.circular(Radii.full)), + ); expect( screenSize.height - tester.getRect(recordButton).bottom, _editorControlBottomForTest, 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 2c0c7ebf816..7853746b065 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 @@ -113,6 +113,11 @@ void runProfileEditMotionAndAccessibilityTests() { await tester.tap(find.text('Emoji')); await tester.pump(); + final trayTransform = tester.widget( + find.byKey(const ValueKey('avatar-mode-tray-transition-transform')), + ); + expect(trayTransform.transform.getTranslation().x, 0); + expect(trayTransform.transform.getTranslation().y, greaterThan(0)); final forwardTransform = tester.widget( find.byKey(const ValueKey('avatar-mode-transition-transform')), ); @@ -128,6 +133,16 @@ void runProfileEditMotionAndAccessibilityTests() { .x, closeTo(0, 0.01), ); + expect( + tester + .widget( + find.byKey(const ValueKey('avatar-mode-tray-transition-transform')), + ) + .transform + .getTranslation() + .y, + closeTo(0, 0.01), + ); await tester.tap(find.text('Image')); await tester.pump(); @@ -137,6 +152,37 @@ void runProfileEditMotionAndAccessibilityTests() { expect(reverseTransform.transform.getTranslation().x, lessThan(0)); }); + testWidgets('retains the preview while animated mode initializes', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Emoji')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + final emojiCenter = tester + .getCenter(find.byKey(const ValueKey('emoji-avatar-preview'))) + .dy; + + await tester.tap(find.text('Animated')); + await tester.pump(); + final retained = find.byKey(const ValueKey('avatar-mode-retained-preview')); + expect(retained, findsOneWidget); + expect(tester.getCenter(retained).dy, closeTo(emojiCenter, 0.01)); + + await tester.pump(const Duration(milliseconds: 75)); + expect(tester.getCenter(retained).dy, greaterThan(emojiCenter)); + await tester.pump(const Duration(milliseconds: 75)); + expect(retained, findsNothing); + }); + testWidgets('plays an animated avatar on the profile and image editor', ( tester, ) async { From 9929efa4a3ae58a3d490cffd5a29cc41f0a19abf Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 16:16:22 +0100 Subject: [PATCH 33/67] fix(mobile): smooth emoji animated handoff Signed-off-by: kenny lopez --- .../profile/profile_avatar_editor.dart | 31 ++++++++++++++++--- .../motion_and_accessibility_tests.dart | 20 ++++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart index df7bbbe348d..694d1a90b5a 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -133,6 +133,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { final error = useState(null); final dataset = ref.watch(emojiDatasetOrEmptyProvider); final modeTransitionDirection = useRef(1.0); + final modeTransitionFrom = useRef(mode); final retainedPreview = useRef(null); final retainedPreviewTop = useRef(0.0); final modeTransitionController = useAnimationController( @@ -146,6 +147,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { void selectMode(ProfileAvatarMode nextMode) { if (nextMode == mode) return; + modeTransitionFrom.value = mode; modeTransitionDirection.value = nextMode.index > mode.index ? 1 : -1; unawaited(HapticFeedback.selectionClick()); onAnimatedPrepareChanged(null); @@ -244,7 +246,10 @@ class ProfileAvatarEditor extends HookConsumerWidget { emoji: selectedEmoji.value, color: Color(selectedColor.value), animationKey: emojiPreviewKey.value, - reduceMotion: reduceMotion, + reduceMotion: + reduceMotion || + (modeTransitionFrom.value == ProfileAvatarMode.animated && + modeTransitionProgress < 1), ), ProfileAvatarMode.animated => null, }; @@ -308,6 +313,20 @@ class ProfileAvatarEditor extends HookConsumerWidget { : avatarBackgroundPreviewShift; final previewShift = min(requestedShift, maximumShift); final previewTop = basePreviewTop - previewShift; + final returningToEmoji = + mode == ProfileAvatarMode.emoji && + modeTransitionFrom.value == ProfileAvatarMode.animated; + final animatedModeHeight = max( + 0.0, + viewportHeight - _editorControlsBottom - basePreviewTop, + ); + final animatedPreviewSize = animatedModeHeight < 400 ? 180.0 : 228.0; + final animatedPreviewTop = + basePreviewTop + (animatedPreviewSize - _previewBlockSize) / 2; + final displayedPreviewTop = returningToEmoji + ? animatedPreviewTop + + (previewTop - animatedPreviewTop) * modeTransitionProgress + : previewTop; if (mode != ProfileAvatarMode.animated) { retainedPreviewTop.value = previewTop; } @@ -426,14 +445,16 @@ class ProfileAvatarEditor extends HookConsumerWidget { if (fixedPreview != null) AnimatedPositioned( key: const ValueKey('avatar-preview-position'), - duration: reduceMotion - ? Duration.zero - : const Duration(milliseconds: 150), curve: Curves.easeOutCubic, left: Grid.gutter, right: Grid.gutter, - top: previewTop, + top: displayedPreviewTop, height: _previewBlockSize, + duration: returningToEmoji + ? Duration.zero + : reduceMotion + ? Duration.zero + : const Duration(milliseconds: 150), child: Center( child: AnimatedBuilder( animation: curvedEntrance, 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 7853746b065..5814e2f6716 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 @@ -181,6 +181,26 @@ void runProfileEditMotionAndAccessibilityTests() { expect(tester.getCenter(retained).dy, greaterThan(emojiCenter)); await tester.pump(const Duration(milliseconds: 75)); expect(retained, findsNothing); + + final animatedCenter = tester + .getCenter( + find.byKey(const ValueKey('animated-avatar-capture-preview')), + ) + .dy; + await tester.tap(find.text('Emoji')); + await tester.pump(); + final returningPreview = find.byKey( + const ValueKey('avatar-preview-position'), + ); + expect( + tester.getCenter(returningPreview).dy, + closeTo(animatedCenter, 0.01), + ); + + await tester.pump(const Duration(milliseconds: 75)); + expect(tester.getCenter(returningPreview).dy, lessThan(animatedCenter)); + await tester.pump(const Duration(milliseconds: 75)); + expect(tester.getCenter(returningPreview).dy, closeTo(emojiCenter, 0.01)); }); testWidgets('plays an animated avatar on the profile and image editor', ( From c0ba9e1b7f4082c428a92892217cb5aeb6f17b14 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 24 Aug 2026 07:29:44 +0100 Subject: [PATCH 34/67] feat(mobile): add inline profile photo capture Signed-off-by: kenny lopez --- .../profile/image_avatar_capture.dart | 500 ++++++++++++++++++ .../profile/profile_avatar_editor.dart | 56 +- .../features/profile/profile_edit_page.dart | 12 + .../profile/profile_edit_page_test.dart | 50 +- .../image_selection_tests.dart | 112 ++++ 5 files changed, 716 insertions(+), 14 deletions(-) create mode 100644 mobile/lib/features/profile/image_avatar_capture.dart 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..f2248b50804 --- /dev/null +++ b/mobile/lib/features/profile/image_avatar_capture.dart @@ -0,0 +1,500 @@ +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'; + +const _avatarPreviewSize = 220.0; + +/// Diameter of the expanded circular viewfinder while taking a profile photo. +const imageAvatarCameraPreviewSize = 245.0; +const _cameraControlSize = 48.0; +const _shutterSize = 88.0; +const _acceptedControlSize = 64.0; +const _captureMotionDuration = Duration(milliseconds: 180); + +/// 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.initialCapturedBytes, + this.loadCameras = availableCameras, + }); + + /// 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; + + /// 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; + + @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 cameras = useState>(const []); + final selectedLens = useState(CameraLensDirection.front); + final cameraGeneration = useState(0); + final isInitializing = useState(initialCapturedBytes == null); + final isCapturing = useState(false); + final capturedBytes = useState(initialCapturedBytes); + final controlsExpanded = useState(false); + final error = useState(null); + + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) controlsExpanded.value = true; + }); + return null; + }, const []); + + useEffect(() { + var disposed = false; + final generation = cameraGeneration.value; + + if (lifecycle != AppLifecycleState.resumed || + capturedBytes.value != null) { + isInitializing.value = false; + controller.value = null; + return null; + } + + isInitializing.value = true; + controller.value = null; + error.value = null; + + Future initialize() async { + CameraController? next; + try { + 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, + ); + await next.initialize(); + if (disposed || generation != cameraGeneration.value) { + await next.dispose(); + return; + } + controllerRef.value = next; + controller.value = next; + } catch (_) { + await next?.dispose(); + if (!disposed && generation == cameraGeneration.value) { + error.value = 'Could not access the camera.'; + } + } finally { + if (!disposed && generation == cameraGeneration.value) { + isInitializing.value = false; + } + } + } + + unawaited(initialize()); + return () { + disposed = true; + final active = controllerRef.value; + controllerRef.value = null; + unawaited(active?.dispose() ?? Future.value()); + }; + }, [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(); + final prepared = await ref + .read(mediaUploadServiceProvider) + .prepareImageBytes(photo); + final cropped = await compute(_centerCropCameraImage, prepared); + if (context.mounted) capturedBytes.value = cropped; + } catch (_) { + if (context.mounted) { + error.value = "We couldn't take that photo. Try again."; + } + } 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; + } + } + + void flipCamera() { + if (isInitializing.value || + isCapturing.value || + cameras.value.length < 2) { + return; + } + final nextLens = selectedLens.value == CameraLensDirection.front + ? CameraLensDirection.back + : CameraLensDirection.front; + if (!cameras.value.any((camera) => camera.lensDirection == nextLens)) { + return; + } + unawaited(HapticFeedback.selectionClick()); + selectedLens.value = nextLens; + cameraGeneration.value++; + } + + void retake() { + unawaited(HapticFeedback.selectionClick()); + capturedBytes.value = null; + error.value = null; + cameraGeneration.value++; + } + + final captured = capturedBytes.value; + final previewSize = captured == null && controlsExpanded.value + ? imageAvatarCameraPreviewSize + : _avatarPreviewSize; + final captureEnabled = controller.value != null && !isCapturing.value; + final flipEnabled = cameras.value.length > 1 && !isCapturing.value; + + return SizedBox( + key: const ValueKey('image-avatar-camera'), + height: height, + child: Stack( + clipBehavior: Clip.none, + children: [ + Align( + alignment: Alignment.topCenter, + child: AnimatedContainer( + key: const ValueKey('image-camera-preview-size'), + duration: reduceMotion ? Duration.zero : _captureMotionDuration, + curve: Curves.easeInOutCubic, + 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!) + : Center( + child: isInitializing.value + ? const BuzzLoadingIndicator( + color: Colors.white, + semanticLabel: 'Starting camera', + ) + : const Icon( + LucideIcons.cameraOff, + color: Colors.white, + size: 32, + ), + ), + ), + ), + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + height: _shutterSize, + child: TweenAnimationBuilder( + tween: Tween(end: controlsExpanded.value ? 1 : 0), + duration: reduceMotion ? Duration.zero : _captureMotionDuration, + curve: Curves.easeOutCubic, + builder: (context, progress, _) => Stack( + alignment: Alignment.center, + children: [ + Transform.translate( + offset: Offset(-52 - 60 * progress, 0), + child: _CameraIconButton( + key: const ValueKey('image-camera-close'), + icon: LucideIcons.x, + semanticLabel: 'Close camera', + onTap: isCapturing.value ? null : onClosed, + ), + ), + Transform.scale( + scale: 0.73 + 0.27 * progress, + child: Opacity( + opacity: progress, + child: _ShutterButton( + captured: captured != null, + busy: isCapturing.value, + onTap: captured != null + ? () { + unawaited(HapticFeedback.lightImpact()); + onAccepted(captured); + } + : captureEnabled + ? () => unawaited(capture()) + : null, + ), + ), + ), + Transform.translate( + offset: Offset(52 + 60 * progress, 0), + child: _CameraIconButton( + key: ValueKey( + captured == null + ? 'image-camera-flip' + : 'image-camera-retake', + ), + icon: LucideIcons.refreshCcw, + semanticLabel: captured == null + ? 'Flip camera' + : 'Retake photo', + onTap: captured != null + ? retake + : flipEnabled + ? flipCamera + : null, + ), + ), + ], + ), + ), + ), + if (error.value != null) + Positioned( + left: 0, + right: 0, + bottom: _shutterSize + Grid.xs, + child: Semantics( + liveRegion: true, + child: Text( + error.value!, + textAlign: TextAlign.center, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), + ), + ), + ], + ), + ); + } +} + +class _CameraPreview extends StatelessWidget { + const _CameraPreview({required this.controller}); + + final CameraController controller; + + @override + Widget build(BuildContext context) { + final orientation = controller.value.deviceOrientation; + final landscape = + orientation == DeviceOrientation.landscapeLeft || + orientation == DeviceOrientation.landscapeRight; + final aspectRatio = landscape + ? controller.value.aspectRatio + : 1 / controller.value.aspectRatio; + return FittedBox( + fit: BoxFit.cover, + clipBehavior: Clip.hardEdge, + child: SizedBox( + width: imageAvatarCameraPreviewSize * aspectRatio, + height: imageAvatarCameraPreviewSize, + child: CameraPreview(controller), + ), + ); + } +} + +class _CameraIconButton extends StatelessWidget { + const _CameraIconButton({ + super.key, + required this.icon, + required this.semanticLabel, + required this.onTap, + }); + + final IconData icon; + final String semanticLabel; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) => Semantics( + label: semanticLabel, + button: true, + enabled: onTap != null, + child: ExcludeSemantics( + child: Material( + color: context.colors.surfaceContainerHighest, + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: SizedBox.square( + dimension: _cameraControlSize, + child: Icon( + icon, + size: 22, + color: onTap == null + ? context.colors.onSurface.withValues(alpha: 0.38) + : context.colors.onSurface, + ), + ), + ), + ), + ), + ); +} + +class _ShutterButton extends StatelessWidget { + const _ShutterButton({ + required this.captured, + required this.busy, + required this.onTap, + }); + + final bool captured; + final bool busy; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final reduceMotion = MediaQuery.disableAnimationsOf(context); + return Semantics( + label: captured ? 'Use photo' : 'Take photo', + button: true, + enabled: onTap != null, + child: ExcludeSemantics( + child: AnimatedContainer( + key: const ValueKey('image-camera-shutter-morph'), + duration: reduceMotion ? Duration.zero : _captureMotionDuration, + curve: Curves.easeOutCubic, + width: captured ? _acceptedControlSize : _shutterSize, + height: captured ? _acceptedControlSize : _shutterSize, + child: Material( + color: captured + ? context.colors.onSurface + : 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', + ) + : captured + ? Icon( + LucideIcons.check, + key: const ValueKey('image-camera-accept-icon'), + size: 28, + color: context.colors.surface, + ) + : Container( + key: const ValueKey('image-camera-shutter-icon'), + width: 64, + height: 64, + decoration: BoxDecoration( + color: context.colors.onSurface, + shape: BoxShape.circle, + border: Border.all( + color: context.colors.surface, + width: 3, + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +Uint8List _centerCropCameraImage(Uint8List bytes) { + final decoded = image.decodeImage(bytes); + if (decoded == null) throw const FormatException('Invalid camera image'); + 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)); +} diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart index 694d1a90b5a..0eef3594a5b 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -24,6 +24,7 @@ import 'animated_avatar_capture.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 +80,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 +110,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 +137,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { final emojiSection = useState(_EmojiEditorSection.emoji); final emojiPreviewKey = useState(0); final isPickingImage = useState(false); + final isCapturingImage = useState(false); final imageSelectionGeneration = useRef(0); final currentMode = useRef(mode)..value = mode; final error = useState(null); @@ -154,6 +164,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 +192,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 +204,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,11 +226,22 @@ 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, }; final fixedPreviewContent = switch (mode) { + ProfileAvatarMode.image when isCapturingImage.value => null, ProfileAvatarMode.image when draft is ProfileImageAvatarDraft => CircleAvatar( key: const ValueKey('avatar-editor-fixed-preview'), @@ -332,20 +353,43 @@ 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, + onAccepted: acceptCameraImage, + onClosed: closeImageCamera, + ), + ), ProfileAvatarMode.image => _ImageMode( key: const ValueKey(0), height: modeHeight, isPicking: isPickingImage.value, - onCamera: () => unawaited(selectImage(camera: true)), - onLibrary: () => unawaited(selectImage(camera: false)), + onCamera: () { + isCapturingImage.value = true; + onImageCameraActiveChanged(true); + }, + onLibrary: () => unawaited(selectGalleryImage()), ), ProfileAvatarMode.emoji => _EmojiMode( key: const ValueKey(1), diff --git a/mobile/lib/features/profile/profile_edit_page.dart b/mobile/lib/features/profile/profile_edit_page.dart index 207dad3976e..3342f674eb6 100644 --- a/mobile/lib/features/profile/profile_edit_page.dart +++ b/mobile/lib/features/profile/profile_edit_page.dart @@ -21,6 +21,7 @@ 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'; @@ -33,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. @@ -41,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 @@ -61,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 @@ -170,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; } @@ -241,6 +248,7 @@ class ProfileEditPage extends HookConsumerWidget { final canSaveAvatar = profileHydrated && + !isImageCameraActive.value && (avatarMode.value == ProfileAvatarMode.animated ? canPrepareAnimatedAvatar.value : avatarDraftMode.value == avatarMode.value && @@ -367,8 +375,12 @@ class ProfileEditPage extends HookConsumerWidget { avatarDraftMode.value = null; } }, + onImageCameraActiveChanged: (active) { + isImageCameraActive.value = active; + }, animatedCaptureBuilder: animatedAvatarCaptureBuilder, + imageCaptureBuilder: imageAvatarCaptureBuilder, ), ), ), diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index 3c405475eb8..5bbce8d70ad 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -6,6 +6,7 @@ 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/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'; @@ -632,7 +633,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 +648,15 @@ 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('saves a desktop-compatible emoji avatar', (tester) async { @@ -955,3 +960,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..d36ddea47c3 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,118 @@ part of '../profile_edit_page_test.dart'; void runProfileEditImageSelectionTests() { + testWidgets('grows the inline camera viewfinder by 25dp', (tester) async { + await tester.pumpWidget( + WidgetHelpers.testable( + child: Scaffold( + body: SizedBox( + height: 400, + child: ImageAvatarCapture( + height: 400, + onAccepted: (_) {}, + onClosed: () {}, + loadCameras: () async => const [], + ), + ), + ), + ), + ); + + final preview = find.byKey(const ValueKey('image-camera-preview-size')); + expect(tester.getSize(preview), const Size.square(220)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 180)); + expect(tester.getSize(preview), const Size.square(245)); + expect(find.bySemanticsLabel('Close camera'), findsOneWidget); + expect(find.bySemanticsLabel('Flip camera'), findsOneWidget); + expect(find.bySemanticsLabel('Take photo'), findsOneWidget); + }); + + testWidgets('shrinks a captured photo and accepts it with a check', ( + 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: () {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + tester.getSize(find.byKey(const ValueKey('image-camera-preview-size'))), + const Size.square(220), + ); + expect( + tester.getSize(find.byKey(const ValueKey('image-camera-shutter-morph'))), + const Size.square(64), + ); + expect( + find.byKey(const ValueKey('image-camera-accept-icon')), + findsOneWidget, + ); + expect(find.bySemanticsLabel('Retake photo'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('image-camera-shutter'))); + expect(accepted, same(bytes)); + }); + + 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 { From 4b1f8a1e042785fb1b4b307bf0c197a6f5577b91 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 24 Aug 2026 08:08:00 +0100 Subject: [PATCH 35/67] fix(mobile): refine profile camera motion Signed-off-by: kenny lopez --- .../ios/Runner/JumpToLatestGlassButton.swift | 22 +- .../profile/image_avatar_capture.dart | 415 ++++++++++++------ .../profile/profile_avatar_editor.dart | 4 +- .../widgets/ios_glass_navigation_button.dart | 52 ++- .../image_selection_tests.dart | 99 ++++- 5 files changed, 413 insertions(+), 179 deletions(-) diff --git a/mobile/ios/Runner/JumpToLatestGlassButton.swift b/mobile/ios/Runner/JumpToLatestGlassButton.swift index 8a95831808a..ba350b0cee3 100644 --- a/mobile/ios/Runner/JumpToLatestGlassButton.swift +++ b/mobile/ios/Runner/JumpToLatestGlassButton.swift @@ -199,9 +199,17 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { let label = arguments?["label"] as? String buttonLabel = label let icon = arguments?["icon"] as? String - let symbolName = icon == "close" ? "xmark" : "chevron.backward" + let symbolName: String + switch icon { + case "close": symbolName = "xmark" + case "rotateCamera": symbolName = "arrow.triangle.2.circlepath.camera" + case "shutter": symbolName = "circle.fill" + default: symbolName = "chevron.backward" + } let controlWidth = (arguments?["controlWidth"] as? NSNumber)?.doubleValue ?? 40 + let controlSize = + (arguments?["controlSize"] as? NSNumber)?.doubleValue ?? 40 var configuration: UIButton.Configuration if #available(iOS 26.0, *) { @@ -225,7 +233,7 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { configuration.image = UIImage( systemName: symbolName, withConfiguration: UIImage.SymbolConfiguration( - pointSize: 17, + pointSize: icon == "shutter" ? controlSize * 0.72 : 17, weight: .semibold ) ) @@ -234,10 +242,10 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { 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 @@ -266,7 +274,7 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { ), button.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), button.widthAnchor.constraint(equalToConstant: controlWidth), - button.heightAnchor.constraint(equalToConstant: 40), + button.heightAnchor.constraint(equalToConstant: controlSize), ]) } diff --git a/mobile/lib/features/profile/image_avatar_capture.dart b/mobile/lib/features/profile/image_avatar_capture.dart index f2248b50804..c898be145bb 100644 --- a/mobile/lib/features/profile/image_avatar_capture.dart +++ b/mobile/lib/features/profile/image_avatar_capture.dart @@ -14,14 +14,16 @@ 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_action.dart'; +import '../../shared/widgets/ios_glass_navigation_button.dart'; const _avatarPreviewSize = 220.0; /// Diameter of the expanded circular viewfinder while taking a profile photo. -const imageAvatarCameraPreviewSize = 245.0; -const _cameraControlSize = 48.0; -const _shutterSize = 88.0; -const _acceptedControlSize = 64.0; +const imageAvatarCameraPreviewSize = _avatarPreviewSize * 1.25; +const _cameraControlSize = 64.0; +const _shutterSize = 100.0; +const _shutterCoreSize = 82.0; const _captureMotionDuration = Duration(milliseconds: 180); /// Builds the inline still-photo camera used by the profile avatar editor. @@ -40,6 +42,7 @@ class ImageAvatarCapture extends HookConsumerWidget { required this.height, required this.onAccepted, required this.onClosed, + this.initialPreview, this.initialCapturedBytes, this.loadCameras = availableCameras, }); @@ -53,6 +56,9 @@ class ImageAvatarCapture extends HookConsumerWidget { /// Leaves camera mode without changing the current avatar draft. final VoidCallback onClosed; + /// The existing avatar shown while the same circular cutout becomes a camera. + final Widget? initialPreview; + /// Seeds the captured-photo review state in focused widget tests. @visibleForTesting final Uint8List? initialCapturedBytes; @@ -72,8 +78,10 @@ class ImageAvatarCapture extends HookConsumerWidget { final cameraGeneration = useState(0); final isInitializing = useState(initialCapturedBytes == null); final isCapturing = useState(false); + final isProcessingCapture = useState(false); final capturedBytes = useState(initialCapturedBytes); final controlsExpanded = useState(false); + final isClosing = useState(false); final error = useState(null); useEffect(() { @@ -159,6 +167,12 @@ class ImageAvatarCapture extends HookConsumerWidget { 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); @@ -167,6 +181,11 @@ class ImageAvatarCapture extends HookConsumerWidget { } catch (_) { if (context.mounted) { error.value = "We couldn't take that photo. Try again."; + try { + await active.resumePreview(); + } on CameraException { + // Reinitialization remains available if this backend cannot resume. + } } } finally { final path = photo?.path; @@ -177,7 +196,10 @@ class ImageAvatarCapture extends HookConsumerWidget { // The camera plugin can remove its temporary file independently. } } - if (context.mounted) isCapturing.value = false; + if (context.mounted) { + isCapturing.value = false; + isProcessingCapture.value = false; + } } } @@ -205,12 +227,27 @@ class ImageAvatarCapture extends HookConsumerWidget { cameraGeneration.value++; } + Future leaveCamera(Uint8List? acceptedBytes) async { + if (isClosing.value) return; + 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 = captured == null && controlsExpanded.value + final previewSize = controlsExpanded.value ? imageAvatarCameraPreviewSize : _avatarPreviewSize; - final captureEnabled = controller.value != null && !isCapturing.value; - final flipEnabled = cameras.value.length > 1 && !isCapturing.value; + final captureEnabled = + controller.value != null && !isCapturing.value && !isClosing.value; + final flipEnabled = + cameras.value.length > 1 && !isCapturing.value && !isClosing.value; return SizedBox( key: const ValueKey('image-avatar-camera'), @@ -229,22 +266,34 @@ class ImageAvatarCapture extends HookConsumerWidget { child: ClipOval( child: ColoredBox( color: Colors.black, - child: captured != null - ? Image.memory(captured, fit: BoxFit.cover) - : controller.value != null - ? _CameraPreview(controller: controller.value!) - : Center( + child: Stack( + fit: StackFit.expand, + children: [ + ?initialPreview, + AnimatedOpacity( + duration: reduceMotion + ? Duration.zero + : _captureMotionDuration, + curve: Curves.easeOutCubic, + opacity: captured != null || controller.value != null + ? 1 + : 0, + child: captured != null + ? Image.memory(captured, fit: BoxFit.cover) + : controller.value != null + ? _CameraPreview(controller: controller.value!) + : const SizedBox.shrink(), + ), + if (controller.value == null && captured == null) + Center( child: isInitializing.value ? const BuzzLoadingIndicator( - color: Colors.white, semanticLabel: 'Starting camera', ) - : const Icon( - LucideIcons.cameraOff, - color: Colors.white, - size: 32, - ), + : const Icon(LucideIcons.cameraOff, size: 32), ), + ], + ), ), ), ), @@ -261,51 +310,68 @@ class ImageAvatarCapture extends HookConsumerWidget { builder: (context, progress, _) => Stack( alignment: Alignment.center, children: [ - Transform.translate( - offset: Offset(-52 - 60 * progress, 0), - child: _CameraIconButton( - key: const ValueKey('image-camera-close'), - icon: LucideIcons.x, - semanticLabel: 'Close camera', - onTap: isCapturing.value ? null : onClosed, - ), - ), - Transform.scale( - scale: 0.73 + 0.27 * progress, - child: Opacity( - opacity: progress, - child: _ShutterButton( - captured: captured != null, - busy: isCapturing.value, - onTap: captured != null - ? () { - unawaited(HapticFeedback.lightImpact()); - onAccepted(captured); - } - : captureEnabled - ? () => unawaited(capture()) - : null, - ), - ), - ), - Transform.translate( - offset: Offset(52 + 60 * progress, 0), - child: _CameraIconButton( - key: ValueKey( - captured == null - ? 'image-camera-flip' - : 'image-camera-retake', - ), - icon: LucideIcons.refreshCcw, - semanticLabel: captured == null - ? 'Flip camera' - : 'Retake photo', - onTap: captured != null - ? retake - : flipEnabled - ? flipCamera - : null, - ), + AnimatedSwitcher( + duration: reduceMotion + ? Duration.zero + : _captureMotionDuration, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeOutCubic, + child: captured != null + ? Opacity( + opacity: progress, + child: Transform.scale( + scale: 0.96 + 0.04 * progress, + child: _CapturedPhotoActions( + key: const ValueKey( + 'image-camera-review-actions', + ), + onRetry: isClosing.value ? null : retake, + onUsePhoto: isClosing.value + ? null + : () => unawaited(leaveCamera(captured)), + ), + ), + ) + : Stack( + key: const ValueKey('image-camera-live-actions'), + alignment: Alignment.center, + children: [ + Transform.translate( + offset: Offset(-52 - 60 * progress, 0), + child: _CameraIconButton( + key: const ValueKey('image-camera-close'), + icon: LucideIcons.x, + iosIcon: IosGlassNavigationIcon.close, + semanticLabel: 'Close camera', + onTap: isCapturing.value || isClosing.value + ? null + : () => unawaited(leaveCamera(null)), + ), + ), + Transform.scale( + scale: 0.73 + 0.27 * progress, + child: Opacity( + opacity: progress, + child: _ShutterButton( + busy: isProcessingCapture.value, + onTap: captureEnabled + ? () => unawaited(capture()) + : null, + ), + ), + ), + Transform.translate( + offset: Offset(52 + 60 * progress, 0), + child: _CameraIconButton( + key: const ValueKey('image-camera-flip'), + icon: LucideIcons.switchCamera, + iosIcon: IosGlassNavigationIcon.rotateCamera, + semanticLabel: 'Flip camera', + onTap: flipEnabled ? flipCamera : null, + ), + ), + ], + ), ), ], ), @@ -363,50 +429,60 @@ class _CameraIconButton extends StatelessWidget { const _CameraIconButton({ super.key, required this.icon, + required this.iosIcon, required this.semanticLabel, required this.onTap, }); final IconData icon; + final IosGlassNavigationIcon iosIcon; final String semanticLabel; final VoidCallback? onTap; @override - Widget build(BuildContext context) => Semantics( - label: semanticLabel, - button: true, - enabled: onTap != null, - child: ExcludeSemantics( - child: Material( - color: context.colors.surfaceContainerHighest, - shape: const CircleBorder(), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onTap, - child: SizedBox.square( - dimension: _cameraControlSize, - child: Icon( - icon, - size: 22, - color: onTap == null - ? context.colors.onSurface.withValues(alpha: 0.38) - : context.colors.onSurface, + Widget build(BuildContext context) { + if (defaultTargetPlatform == TargetPlatform.iOS) { + return IosGlassNavigationButton( + icon: iosIcon, + semanticLabel: semanticLabel, + onPressed: onTap, + width: _cameraControlSize, + height: _cameraControlSize, + controlSize: _cameraControlSize, + foregroundColor: context.colors.onSurface, + ); + } + return Semantics( + label: semanticLabel, + button: true, + enabled: onTap != null, + child: ExcludeSemantics( + child: Material( + color: context.colors.surfaceContainerHighest, + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: SizedBox.square( + dimension: _cameraControlSize, + child: Icon( + icon, + size: 26, + color: onTap == null + ? context.colors.onSurface.withValues(alpha: 0.38) + : context.colors.onSurface, + ), ), ), ), ), - ), - ); + ); + } } class _ShutterButton extends StatelessWidget { - const _ShutterButton({ - required this.captured, - required this.busy, - required this.onTap, - }); + const _ShutterButton({required this.busy, required this.onTap}); - final bool captured; final bool busy; final VoidCallback? onTap; @@ -414,69 +490,122 @@ class _ShutterButton extends StatelessWidget { Widget build(BuildContext context) { final reduceMotion = MediaQuery.disableAnimationsOf(context); return Semantics( - label: captured ? 'Use photo' : 'Take photo', + label: 'Take photo', button: true, enabled: onTap != null, child: ExcludeSemantics( - child: AnimatedContainer( + child: SizedBox( key: const ValueKey('image-camera-shutter-morph'), - duration: reduceMotion ? Duration.zero : _captureMotionDuration, - curve: Curves.easeOutCubic, - width: captured ? _acceptedControlSize : _shutterSize, - height: captured ? _acceptedControlSize : _shutterSize, - child: Material( - color: captured - ? context.colors.onSurface - : 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', - ) - : captured - ? Icon( - LucideIcons.check, - key: const ValueKey('image-camera-accept-icon'), - size: 28, - color: context.colors.surface, - ) - : Container( - key: const ValueKey('image-camera-shutter-icon'), - width: 64, - height: 64, - decoration: BoxDecoration( - color: context.colors.onSurface, - shape: BoxShape.circle, - border: Border.all( - color: context.colors.surface, - width: 3, - ), - ), - ), + width: _shutterSize, + height: _shutterSize, + child: defaultTargetPlatform == TargetPlatform.iOS + ? 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, + ), + ), + ), + ), + ), + ), ), - ), - ), - ), ), ), ); } } +class _CapturedPhotoActions extends StatelessWidget { + const _CapturedPhotoActions({ + super.key, + required this.onRetry, + required this.onUsePhoto, + }); + + final VoidCallback? onRetry; + final VoidCallback? onUsePhoto; + + @override + Widget build(BuildContext context) { + if (defaultTargetPlatform == TargetPlatform.iOS) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + IosGlassNavigationAction(label: 'Retry', onPressed: onRetry), + const SizedBox(width: Grid.gutter), + IosGlassNavigationAction( + label: 'Use Photo', + width: 104, + onPressed: onUsePhoto, + ), + ], + ); + } + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + _ReviewButton(label: 'Retry', onTap: onRetry), + const SizedBox(width: Grid.gutter), + _ReviewButton(label: 'Use Photo', onTap: onUsePhoto), + ], + ); + } +} + +class _ReviewButton extends StatelessWidget { + const _ReviewButton({required this.label, required this.onTap}); + + final String label; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) => FilledButton.tonal( + onPressed: onTap, + style: FilledButton.styleFrom(minimumSize: const Size(104, 52)), + child: Text(label), + ); +} + Uint8List _centerCropCameraImage(Uint8List bytes) { final decoded = image.decodeImage(bytes); if (decoded == null) throw const FormatException('Invalid camera image'); diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart index 0eef3594a5b..c6319999437 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -241,7 +241,6 @@ class ProfileAvatarEditor extends HookConsumerWidget { _ => currentAvatarUrl, }; final fixedPreviewContent = switch (mode) { - ProfileAvatarMode.image when isCapturingImage.value => null, ProfileAvatarMode.image when draft is ProfileImageAvatarDraft => CircleAvatar( key: const ValueKey('avatar-editor-fixed-preview'), @@ -377,6 +376,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { ) ?? ImageAvatarCapture( height: modeHeight, + initialPreview: fixedPreview, onAccepted: acceptCameraImage, onClosed: closeImageCamera, ), @@ -486,7 +486,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { ), ), ), - if (fixedPreview != null) + if (fixedPreview != null && !isCapturingImage.value) AnimatedPositioned( key: const ValueKey('avatar-preview-position'), curve: Curves.easeOutCubic, diff --git a/mobile/lib/shared/widgets/ios_glass_navigation_button.dart b/mobile/lib/shared/widgets/ios_glass_navigation_button.dart index f54c898d7cb..24ea329947d 100644 --- a/mobile/lib/shared/widgets/ios_glass_navigation_button.dart +++ b/mobile/lib/shared/widgets/ios_glass_navigation_button.dart @@ -9,7 +9,7 @@ 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, rotateCamera, shutter } /// Leading width used by iOS channel-style headers. const iosGlassChannelHeaderLeadingWidth = 58.0; @@ -32,8 +32,10 @@ class IosGlassNavigationButton extends HookWidget { required this.onPressed, this.width = 48, this.height = 48, + this.controlSize = 40, this.buttonCenterX, this.foregroundColor, + this.isBusy = false, this.nativeViewSuppressed, }); @@ -44,8 +46,10 @@ class IosGlassNavigationButton extends HookWidget { final VoidCallback? onPressed; final double width; final double height; + final double controlSize; final double? buttonCenterX; final Color? foregroundColor; + final bool isBusy; final ValueListenable? nativeViewSuppressed; @override @@ -77,11 +81,12 @@ class IosGlassNavigationButton extends HookWidget { 'brightness': brightness, 'foregroundColor': foregroundValue, 'enabled': enabled, + 'busy': isBusy, }), ); } return null; - }, [nativeChannel.value, brightness, foregroundValue, enabled]); + }, [nativeChannel.value, brightness, foregroundValue, enabled, isBusy]); Widget buildControl({required bool suppressNativeView}) { if (suppressNativeView) { @@ -97,10 +102,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 +116,31 @@ 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, + ), + ), + ) + : Icon( + switch (icon) { + IosGlassNavigationIcon.back => + Icons.arrow_back_ios_new_rounded, + IosGlassNavigationIcon.close => + Icons.close_rounded, + IosGlassNavigationIcon.rotateCamera => + Icons.cameraswitch_rounded, + IosGlassNavigationIcon.shutter => Icons.circle, + }, + size: icon == IosGlassNavigationIcon.shutter + ? controlSize * 0.72 + : 22, + color: effectiveForeground, + ), ), ), ], @@ -134,6 +157,9 @@ class IosGlassNavigationButton extends HookWidget { 'brightness': brightness, 'foregroundColor': foregroundValue, 'enabled': enabled, + 'busy': isBusy, + 'controlSize': controlSize, + 'controlWidth': controlSize, 'buttonCenterX': buttonCenterX ?? width / 2, 'hitTargetWidth': width, 'hitTargetHeight': height, 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 d36ddea47c3..1f01a32765b 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,7 +1,7 @@ part of '../profile_edit_page_test.dart'; void runProfileEditImageSelectionTests() { - testWidgets('grows the inline camera viewfinder by 25dp', (tester) async { + testWidgets('grows the existing avatar cutout by 25 percent', (tester) async { await tester.pumpWidget( WidgetHelpers.testable( child: Scaffold( @@ -10,6 +10,10 @@ void runProfileEditImageSelectionTests() { child: ImageAvatarCapture( height: 400, onAccepted: (_) {}, + initialPreview: const ColoredBox( + key: ValueKey('existing-avatar-preview'), + color: Colors.pink, + ), onClosed: () {}, loadCameras: () async => const [], ), @@ -22,13 +26,74 @@ void runProfileEditImageSelectionTests() { expect(tester.getSize(preview), const Size.square(220)); await tester.pump(); await tester.pump(const Duration(milliseconds: 180)); - expect(tester.getSize(preview), const Size.square(245)); + expect(tester.getSize(preview), const Size.square(275)); + expect( + find.byKey(const ValueKey('existing-avatar-preview')), + findsOneWidget, + ); + expect( + tester.getSize(find.byKey(const ValueKey('image-camera-close'))), + const Size.square(64), + ); + expect( + tester.getSize(find.byKey(const ValueKey('image-camera-flip'))), + 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('shrinks a captured photo and accepts it with a check', ( + testWidgets('reverses the camera expansion 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)); + + tester + .widget( + find.descendant( + of: find.byKey(const ValueKey('image-camera-close')), + matching: find.byType(InkWell), + ), + ) + .onTap!(); + 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(closed, isFalse); + + await tester.pump(const Duration(milliseconds: 90)); + expect(closed, isTrue); + expect( + tester.getSize(find.byKey(const ValueKey('image-camera-preview-size'))), + const Size.square(220), + ); + }); + + testWidgets('reviews a captured photo before scaling down to accept it', ( tester, ) async { final bytes = Uint8List.fromList( @@ -54,20 +119,26 @@ void runProfileEditImageSelectionTests() { expect( tester.getSize(find.byKey(const ValueKey('image-camera-preview-size'))), - const Size.square(220), + const Size.square(275), ); - expect( - tester.getSize(find.byKey(const ValueKey('image-camera-shutter-morph'))), - const Size.square(64), - ); - expect( - find.byKey(const ValueKey('image-camera-accept-icon')), - findsOneWidget, - ); - expect(find.bySemanticsLabel('Retake photo'), findsOneWidget); + expect(find.text('Retry'), findsOneWidget); + expect(find.text('Use Photo'), findsOneWidget); - await tester.tap(find.byKey(const ValueKey('image-camera-shutter'))); + 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('accepts an inline camera photo before enabling profile Save', ( From 545279206fd3caa7ef143b357745f65c5a3ba927 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 24 Aug 2026 08:43:37 +0100 Subject: [PATCH 36/67] fix(mobile): polish profile camera capture motion Signed-off-by: kenny lopez --- .../ios/Runner/JumpToLatestGlassButton.swift | 94 ++++-- .../profile/image_avatar_capture.dart | 313 +++++++++--------- .../widgets/ios_glass_navigation_button.dart | 55 ++- .../image_selection_tests.dart | 16 +- 4 files changed, 271 insertions(+), 207 deletions(-) diff --git a/mobile/ios/Runner/JumpToLatestGlassButton.swift b/mobile/ios/Runner/JumpToLatestGlassButton.swift index ba350b0cee3..c425d7e455e 100644 --- a/mobile/ios/Runner/JumpToLatestGlassButton.swift +++ b/mobile/ios/Runner/JumpToLatestGlassButton.swift @@ -173,6 +173,7 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { private let channel: FlutterMethodChannel private let button = NavigationGlassButton(type: .system) private var buttonLabel: String? + private var buttonIconName = "chevron.backward" init( frame: CGRect, @@ -196,20 +197,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: String - switch icon { - case "close": symbolName = "xmark" - case "rotateCamera": symbolName = "arrow.triangle.2.circlepath.camera" - case "shutter": symbolName = "circle.fill" - default: symbolName = "chevron.backward" - } let controlWidth = (arguments?["controlWidth"] as? NSNumber)?.doubleValue ?? 40 let controlSize = (arguments?["controlSize"] as? NSNumber)?.doubleValue ?? 40 + let fillWidth = arguments?["fillWidth"] as? Bool ?? false var configuration: UIButton.Configuration if #available(iOS 26.0, *) { @@ -219,26 +211,8 @@ 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: icon == "shutter" ? controlSize * 0.72 : 17, - weight: .semibold - ) - ) - } button.configuration = configuration + applyContent(from: arguments) button.titleLabel?.numberOfLines = 1 button.titleLabel?.lineBreakMode = .byClipping button.hitTargetInsets = UIEdgeInsets( @@ -258,6 +232,11 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { applyAppearance(from: args) channel.setMethodCallHandler { [weak self] call, result in + if call.method == "setContent" { + self?.applyContent(from: call.arguments) + result(nil) + return + } guard call.method == "setAppearance" else { result(FlutterMethodNotImplemented) return @@ -268,14 +247,19 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { containerView.addSubview(button) NSLayoutConstraint.activate([ - button.centerXAnchor.constraint( - equalTo: containerView.leadingAnchor, - constant: buttonCenterX - ), button.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), - button.widthAnchor.constraint(equalToConstant: controlWidth), 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 + ).isActive = true + button.widthAnchor.constraint(equalToConstant: controlWidth).isActive = true + } } func view() -> UIView { @@ -357,6 +341,48 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { button.setNeedsUpdateConfiguration() } + private func applyContent(from value: Any?) { + let arguments = value as? [String: Any] + let icon = arguments?["icon"] as? String + buttonLabel = arguments?["label"] as? String + if let accessibilityLabel = arguments?["accessibilityLabel"] as? String { + button.accessibilityLabel = accessibilityLabel + } + switch icon { + case "close": buttonIconName = "xmark" + case "rotateCamera": buttonIconName = "arrow.triangle.2.circlepath.camera" + case "shutter": buttonIconName = "circle.fill" + default: buttonIconName = "chevron.backward" + } + if let buttonLabel { + button.configuration?.title = buttonLabel + button.configuration?.image = nil + 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?.title = nil + button.configuration?.titleTextAttributesTransformer = nil + let pointSize: CGFloat = icon == "shutter" ? 50 : 17 + button.configuration?.image = UIImage( + systemName: buttonIconName, + withConfiguration: UIImage.SymbolConfiguration( + pointSize: pointSize, + weight: .semibold + ) + ) + } + button.setNeedsUpdateConfiguration() + } + 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/image_avatar_capture.dart b/mobile/lib/features/profile/image_avatar_capture.dart index c898be145bb..875aca4147c 100644 --- a/mobile/lib/features/profile/image_avatar_capture.dart +++ b/mobile/lib/features/profile/image_avatar_capture.dart @@ -14,7 +14,6 @@ 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_action.dart'; import '../../shared/widgets/ios_glass_navigation_button.dart'; const _avatarPreviewSize = 220.0; @@ -22,8 +21,11 @@ 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 _shutterSize = 100.0; -const _shutterCoreSize = 82.0; +const _shutterSize = 115.0; +const _shutterCoreSize = 94.0; +const _reviewControlWidth = 112.0; +const _collapsedControlOffset = 52.0; +const _expandedControlOffset = 119.5; const _captureMotionDuration = Duration(milliseconds: 180); /// Builds the inline still-photo camera used by the profile avatar editor. @@ -81,15 +83,26 @@ class ImageAvatarCapture extends HookConsumerWidget { final isProcessingCapture = useState(false); final capturedBytes = useState(initialCapturedBytes); final controlsExpanded = useState(false); + final previewTransitionComplete = useState(reduceMotion); final isClosing = useState(false); final error = useState(null); useEffect(() { WidgetsBinding.instance.addPostFrameCallback((_) { - if (context.mounted) controlsExpanded.value = true; + if (!context.mounted) return; + controlsExpanded.value = true; + if (reduceMotion) { + previewTransitionComplete.value = true; + } else { + Future.delayed(_captureMotionDuration, () { + if (context.mounted && !isClosing.value) { + previewTransitionComplete.value = true; + } + }); + } }); return null; - }, const []); + }, [reduceMotion]); useEffect(() { var disposed = false; @@ -266,34 +279,19 @@ class ImageAvatarCapture extends HookConsumerWidget { child: ClipOval( child: ColoredBox( color: Colors.black, - child: Stack( - fit: StackFit.expand, - children: [ - ?initialPreview, - AnimatedOpacity( - duration: reduceMotion - ? Duration.zero - : _captureMotionDuration, - curve: Curves.easeOutCubic, - opacity: captured != null || controller.value != null - ? 1 - : 0, - child: captured != null - ? Image.memory(captured, fit: BoxFit.cover) - : controller.value != null - ? _CameraPreview(controller: controller.value!) - : const SizedBox.shrink(), - ), - if (controller.value == null && captured == null) - Center( - child: isInitializing.value - ? const BuzzLoadingIndicator( - semanticLabel: 'Starting camera', - ) - : const Icon(LucideIcons.cameraOff, size: 32), - ), - ], - ), + child: captured != null + ? Image.memory(captured, fit: BoxFit.cover) + : previewTransitionComplete.value && + controller.value != null + ? _CameraPreview(controller: controller.value!) + : initialPreview ?? + Center( + child: isInitializing.value + ? const BuzzLoadingIndicator( + semanticLabel: 'Starting camera', + ) + : const Icon(LucideIcons.cameraOff, size: 32), + ), ), ), ), @@ -307,73 +305,96 @@ class ImageAvatarCapture extends HookConsumerWidget { tween: Tween(end: controlsExpanded.value ? 1 : 0), duration: reduceMotion ? Duration.zero : _captureMotionDuration, curve: Curves.easeOutCubic, - builder: (context, progress, _) => Stack( - alignment: Alignment.center, - children: [ - AnimatedSwitcher( + builder: (context, progress, _) => LayoutBuilder( + builder: (context, constraints) { + final sideOffset = + _collapsedControlOffset + + (_expandedControlOffset - _collapsedControlOffset) * + progress; + return TweenAnimationBuilder( + tween: Tween(end: captured == null ? 0 : 1), duration: reduceMotion ? Duration.zero : _captureMotionDuration, - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeOutCubic, - child: captured != null - ? Opacity( - opacity: progress, - child: Transform.scale( - scale: 0.96 + 0.04 * progress, - child: _CapturedPhotoActions( - key: const ValueKey( - 'image-camera-review-actions', - ), - onRetry: isClosing.value ? null : retake, - onUsePhoto: isClosing.value - ? null - : () => unawaited(leaveCamera(captured)), - ), + curve: Curves.easeInOutCubic, + builder: (context, reviewProgress, _) { + final sideWidth = + _cameraControlSize + + (_reviewControlWidth - _cameraControlSize) * + reviewProgress; + return Stack( + alignment: Alignment.center, + children: [ + Positioned( + left: + constraints.maxWidth / 2 - + sideOffset - + sideWidth / 2, + width: sideWidth, + height: _cameraControlSize, + child: _MorphingCameraAction( + key: const ValueKey('image-camera-left-action'), + width: sideWidth, + icon: LucideIcons.x, + iosIcon: IosGlassNavigationIcon.close, + label: captured == null ? null : 'Retry', + semanticLabel: captured == null + ? 'Close camera' + : 'Retry', + onTap: isCapturing.value || isClosing.value + ? null + : captured == null + ? () => unawaited(leaveCamera(null)) + : retake, ), - ) - : Stack( - key: const ValueKey('image-camera-live-actions'), - alignment: Alignment.center, - children: [ - Transform.translate( - offset: Offset(-52 - 60 * progress, 0), - child: _CameraIconButton( - key: const ValueKey('image-camera-close'), - icon: LucideIcons.x, - iosIcon: IosGlassNavigationIcon.close, - semanticLabel: 'Close camera', - onTap: isCapturing.value || isClosing.value - ? null - : () => unawaited(leaveCamera(null)), - ), - ), - Transform.scale( - scale: 0.73 + 0.27 * progress, - child: Opacity( - opacity: progress, - child: _ShutterButton( - busy: isProcessingCapture.value, - onTap: captureEnabled - ? () => unawaited(capture()) - : null, - ), - ), - ), - Transform.translate( - offset: Offset(52 + 60 * progress, 0), - child: _CameraIconButton( - key: const ValueKey('image-camera-flip'), - icon: LucideIcons.switchCamera, - iosIcon: IosGlassNavigationIcon.rotateCamera, - semanticLabel: 'Flip camera', - onTap: flipEnabled ? flipCamera : null, + ), + Transform.scale( + scale: + (0.73 + 0.27 * progress) * + (1 - 0.28 * reviewProgress), + child: Opacity( + opacity: progress * (1 - reviewProgress), + child: IgnorePointer( + ignoring: captured != null, + child: _ShutterButton( + busy: isProcessingCapture.value, + onTap: captureEnabled + ? () => unawaited(capture()) + : null, ), ), - ], + ), ), - ), - ], + Positioned( + left: + constraints.maxWidth / 2 + + sideOffset - + sideWidth / 2, + width: sideWidth, + height: _cameraControlSize, + child: _MorphingCameraAction( + key: const ValueKey('image-camera-right-action'), + width: sideWidth, + icon: LucideIcons.switchCamera, + iosIcon: IosGlassNavigationIcon.rotateCamera, + label: captured == null ? null : 'Use Photo', + semanticLabel: captured == null + ? 'Flip camera' + : 'Use Photo', + onTap: isClosing.value + ? null + : captured != null + ? () => unawaited(leaveCamera(captured)) + : flipEnabled + ? flipCamera + : null, + ), + ), + ], + ); + }, + ); + }, ), ), ), @@ -425,17 +446,21 @@ class _CameraPreview extends StatelessWidget { } } -class _CameraIconButton extends StatelessWidget { - const _CameraIconButton({ +class _MorphingCameraAction extends StatelessWidget { + const _MorphingCameraAction({ super.key, + required this.width, required this.icon, required this.iosIcon, + required this.label, required this.semanticLabel, required this.onTap, }); + final double width; final IconData icon; final IosGlassNavigationIcon iosIcon; + final String? label; final String semanticLabel; final VoidCallback? onTap; @@ -444,11 +469,13 @@ class _CameraIconButton extends StatelessWidget { if (defaultTargetPlatform == TargetPlatform.iOS) { return IosGlassNavigationButton( icon: iosIcon, + label: label, semanticLabel: semanticLabel, onPressed: onTap, - width: _cameraControlSize, + width: width, height: _cameraControlSize, controlSize: _cameraControlSize, + fillWidth: true, foregroundColor: context.colors.onSurface, ); } @@ -459,18 +486,43 @@ class _CameraIconButton extends StatelessWidget { child: ExcludeSemantics( child: Material( color: context.colors.surfaceContainerHighest, - shape: const CircleBorder(), + shape: const StadiumBorder(), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onTap, - child: SizedBox.square( - dimension: _cameraControlSize, - child: Icon( - icon, - size: 26, - color: onTap == null - ? context.colors.onSurface.withValues(alpha: 0.38) - : context.colors.onSurface, + child: SizedBox( + width: width, + height: _cameraControlSize, + child: Center( + child: AnimatedSwitcher( + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : _captureMotionDuration, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeOutCubic, + child: label == null + ? Icon( + icon, + key: const ValueKey('camera-action-icon'), + size: 26, + color: onTap == null + ? context.colors.onSurface.withValues(alpha: 0.38) + : context.colors.onSurface, + ) + : Text( + label!, + key: ValueKey(label), + maxLines: 1, + style: context.textTheme.labelMedium?.copyWith( + color: onTap == null + ? context.colors.onSurface.withValues( + alpha: 0.38, + ) + : context.colors.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ), ), ), ), @@ -555,57 +607,6 @@ class _ShutterButton extends StatelessWidget { } } -class _CapturedPhotoActions extends StatelessWidget { - const _CapturedPhotoActions({ - super.key, - required this.onRetry, - required this.onUsePhoto, - }); - - final VoidCallback? onRetry; - final VoidCallback? onUsePhoto; - - @override - Widget build(BuildContext context) { - if (defaultTargetPlatform == TargetPlatform.iOS) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - IosGlassNavigationAction(label: 'Retry', onPressed: onRetry), - const SizedBox(width: Grid.gutter), - IosGlassNavigationAction( - label: 'Use Photo', - width: 104, - onPressed: onUsePhoto, - ), - ], - ); - } - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - _ReviewButton(label: 'Retry', onTap: onRetry), - const SizedBox(width: Grid.gutter), - _ReviewButton(label: 'Use Photo', onTap: onUsePhoto), - ], - ); - } -} - -class _ReviewButton extends StatelessWidget { - const _ReviewButton({required this.label, required this.onTap}); - - final String label; - final VoidCallback? onTap; - - @override - Widget build(BuildContext context) => FilledButton.tonal( - onPressed: onTap, - style: FilledButton.styleFrom(minimumSize: const Size(104, 52)), - child: Text(label), - ); -} - Uint8List _centerCropCameraImage(Uint8List bytes) { final decoded = image.decodeImage(bytes); if (decoded == null) throw const FormatException('Invalid camera image'); diff --git a/mobile/lib/shared/widgets/ios_glass_navigation_button.dart b/mobile/lib/shared/widgets/ios_glass_navigation_button.dart index 24ea329947d..52697316536 100644 --- a/mobile/lib/shared/widgets/ios_glass_navigation_button.dart +++ b/mobile/lib/shared/widgets/ios_glass_navigation_button.dart @@ -30,9 +30,11 @@ 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, @@ -42,11 +44,13 @@ class IosGlassNavigationButton extends HookWidget { static const viewType = 'buzz/navigation_glass'; final IosGlassNavigationIcon icon; + final String? label; final String semanticLabel; final VoidCallback? onPressed; final double width; final double height; final double controlSize; + final bool fillWidth; final double? buttonCenterX; final Color? foregroundColor; final bool isBusy; @@ -88,6 +92,19 @@ class IosGlassNavigationButton extends HookWidget { return null; }, [nativeChannel.value, brightness, foregroundValue, enabled, isBusy]); + useEffect(() { + final channel = nativeChannel.value; + if (channel != null) { + final content = { + 'icon': icon.name, + 'accessibilityLabel': semanticLabel, + }; + if (label != null) content['label'] = label!; + unawaited(channel.invokeMethod('setContent', content)); + } + return null; + }, [nativeChannel.value, icon, label, semanticLabel]); + Widget buildControl({required bool suppressNativeView}) { if (suppressNativeView) { final resolvedButtonCenterX = buttonCenterX ?? width / 2; @@ -126,6 +143,15 @@ class IosGlassNavigationButton extends HookWidget { ), ), ) + : label != null + ? Text( + label!, + maxLines: 1, + style: context.textTheme.labelMedium?.copyWith( + color: effectiveForeground, + fontWeight: FontWeight.w600, + ), + ) : Icon( switch (icon) { IosGlassNavigationIcon.back => @@ -148,22 +174,25 @@ class IosGlassNavigationButton extends HookWidget { ), ); } + final creationParams = { + 'icon': icon.name, + 'accessibilityLabel': semanticLabel, + 'brightness': brightness, + 'foregroundColor': foregroundValue, + 'enabled': enabled, + 'busy': isBusy, + '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, - 'busy': isBusy, - 'controlSize': controlSize, - 'controlWidth': controlSize, - 'buttonCenterX': buttonCenterX ?? width / 2, - 'hitTargetWidth': width, - 'hitTargetHeight': height, - }, + creationParams: creationParams, creationParamsCodec: const StandardMessageCodec(), onPlatformViewCreated: (viewId) { nativeChannel.value = MethodChannel('$viewType/$viewId'); 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 1f01a32765b..11a75e7f817 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 @@ -32,16 +32,16 @@ void runProfileEditImageSelectionTests() { findsOneWidget, ); expect( - tester.getSize(find.byKey(const ValueKey('image-camera-close'))), + tester.getSize(find.byKey(const ValueKey('image-camera-left-action'))), const Size.square(64), ); expect( - tester.getSize(find.byKey(const ValueKey('image-camera-flip'))), + 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), + const Size.square(115), ); expect(find.bySemanticsLabel('Close camera'), findsOneWidget); expect(find.bySemanticsLabel('Flip camera'), findsOneWidget); @@ -71,7 +71,7 @@ void runProfileEditImageSelectionTests() { tester .widget( find.descendant( - of: find.byKey(const ValueKey('image-camera-close')), + of: find.byKey(const ValueKey('image-camera-left-action')), matching: find.byType(InkWell), ), ) @@ -123,6 +123,14 @@ void runProfileEditImageSelectionTests() { ); 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), + ); await tester.tap(find.text('Use Photo')); await tester.pump(); From 482d83c33bc69c8d5e78c1675227140a8a353201 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 24 Aug 2026 09:11:42 +0100 Subject: [PATCH 37/67] fix(mobile): smooth camera state handoffs Signed-off-by: kenny lopez --- .../ios/Runner/JumpToLatestGlassButton.swift | 12 ++- .../profile/image_avatar_capture.dart | 69 +++++++++----- .../profile/profile_avatar_editor.dart | 74 ++++++++++++++- .../widgets/ios_glass_navigation_button.dart | 13 ++- .../profile/profile_edit_page_test.dart | 1 + .../image_selection_tests.dart | 94 ++++++++++++++++--- 6 files changed, 221 insertions(+), 42 deletions(-) diff --git a/mobile/ios/Runner/JumpToLatestGlassButton.swift b/mobile/ios/Runner/JumpToLatestGlassButton.swift index c425d7e455e..832a49ccaad 100644 --- a/mobile/ios/Runner/JumpToLatestGlassButton.swift +++ b/mobile/ios/Runner/JumpToLatestGlassButton.swift @@ -350,6 +350,8 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { } switch icon { case "close": buttonIconName = "xmark" + case "camera": buttonIconName = "camera" + case "photoLibrary": buttonIconName = "photo.on.rectangle.angled" case "rotateCamera": buttonIconName = "arrow.triangle.2.circlepath.camera" case "shutter": buttonIconName = "circle.fill" default: buttonIconName = "chevron.backward" @@ -371,7 +373,15 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { } else { button.configuration?.title = nil button.configuration?.titleTextAttributesTransformer = nil - let pointSize: CGFloat = icon == "shutter" ? 50 : 17 + if icon == "shutter" { + button.configuration?.contentInsets = NSDirectionalEdgeInsets( + top: 8, + leading: 8, + bottom: 8, + trailing: 8 + ) + } + let pointSize: CGFloat = icon == "shutter" ? 99 : 17 button.configuration?.image = UIImage( systemName: buttonIconName, withConfiguration: UIImage.SymbolConfiguration( diff --git a/mobile/lib/features/profile/image_avatar_capture.dart b/mobile/lib/features/profile/image_avatar_capture.dart index 875aca4147c..02d89242a53 100644 --- a/mobile/lib/features/profile/image_avatar_capture.dart +++ b/mobile/lib/features/profile/image_avatar_capture.dart @@ -22,10 +22,11 @@ const _avatarPreviewSize = 220.0; const imageAvatarCameraPreviewSize = _avatarPreviewSize * 1.25; const _cameraControlSize = 64.0; const _shutterSize = 115.0; -const _shutterCoreSize = 94.0; +const _shutterCoreSize = _shutterSize - Grid.xxs * 2; const _reviewControlWidth = 112.0; const _collapsedControlOffset = 52.0; const _expandedControlOffset = 119.5; +const _reviewControlGap = Grid.twelve; const _captureMotionDuration = Duration(milliseconds: 180); /// Builds the inline still-photo camera used by the profile avatar editor. @@ -83,7 +84,6 @@ class ImageAvatarCapture extends HookConsumerWidget { final isProcessingCapture = useState(false); final capturedBytes = useState(initialCapturedBytes); final controlsExpanded = useState(false); - final previewTransitionComplete = useState(reduceMotion); final isClosing = useState(false); final error = useState(null); @@ -91,18 +91,18 @@ class ImageAvatarCapture extends HookConsumerWidget { WidgetsBinding.instance.addPostFrameCallback((_) { if (!context.mounted) return; controlsExpanded.value = true; - if (reduceMotion) { - previewTransitionComplete.value = true; - } else { - Future.delayed(_captureMotionDuration, () { - if (context.mounted && !isClosing.value) { - previewTransitionComplete.value = true; - } - }); - } }); return null; - }, [reduceMotion]); + }, const []); + + useEffect( + () => () { + final active = controllerRef.value; + controllerRef.value = null; + unawaited(active?.dispose() ?? Future.value()); + }, + const [], + ); useEffect(() { var disposed = false; @@ -111,16 +111,19 @@ class ImageAvatarCapture extends HookConsumerWidget { if (lifecycle != AppLifecycleState.resumed || capturedBytes.value != null) { isInitializing.value = false; + final active = controllerRef.value; + controllerRef.value = null; controller.value = null; + unawaited(active?.dispose() ?? Future.value()); return null; } isInitializing.value = true; - controller.value = null; error.value = null; Future initialize() async { CameraController? next; + var installed = false; try { final available = await loadCameras(); if (disposed || generation != cameraGeneration.value) return; @@ -146,11 +149,20 @@ class ImageAvatarCapture extends HookConsumerWidget { await next.dispose(); return; } + final previous = controllerRef.value; controllerRef.value = next; controller.value = next; + installed = true; + if (previous != null && previous != next) { + unawaited(previous.dispose()); + } } catch (_) { - await next?.dispose(); + if (!installed) await 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 { @@ -163,9 +175,6 @@ class ImageAvatarCapture extends HookConsumerWidget { unawaited(initialize()); return () { disposed = true; - final active = controllerRef.value; - controllerRef.value = null; - unawaited(active?.dispose() ?? Future.value()); }; }, [lifecycle, capturedBytes.value == null, cameraGeneration.value]); @@ -255,12 +264,20 @@ class ImageAvatarCapture extends HookConsumerWidget { final captured = capturedBytes.value; final previewSize = controlsExpanded.value - ? imageAvatarCameraPreviewSize + ? controller.value != null || captured != null + ? imageAvatarCameraPreviewSize + : _avatarPreviewSize : _avatarPreviewSize; final captureEnabled = - controller.value != null && !isCapturing.value && !isClosing.value; + controller.value != null && + !isInitializing.value && + !isCapturing.value && + !isClosing.value; final flipEnabled = - cameras.value.length > 1 && !isCapturing.value && !isClosing.value; + cameras.value.length > 1 && + !isInitializing.value && + !isCapturing.value && + !isClosing.value; return SizedBox( key: const ValueKey('image-avatar-camera'), @@ -281,8 +298,7 @@ class ImageAvatarCapture extends HookConsumerWidget { color: Colors.black, child: captured != null ? Image.memory(captured, fit: BoxFit.cover) - : previewTransitionComplete.value && - controller.value != null + : controller.value != null ? _CameraPreview(controller: controller.value!) : initialPreview ?? Center( @@ -322,13 +338,18 @@ class ImageAvatarCapture extends HookConsumerWidget { _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 - - sideOffset - + effectiveSideOffset - sideWidth / 2, width: sideWidth, height: _cameraControlSize, @@ -368,7 +389,7 @@ class ImageAvatarCapture extends HookConsumerWidget { Positioned( left: constraints.maxWidth / 2 + - sideOffset - + effectiveSideOffset - sideWidth / 2, width: sideWidth, height: _cameraControlSize, diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart index c6319999437..9fcda34e2fb 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -18,6 +18,7 @@ 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'; @@ -701,22 +702,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, ), @@ -728,3 +729,68 @@ 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) { + if (defaultTargetPlatform != TargetPlatform.iOS) { + return AvatarEditorOptionButton( + icon: icon, + label: label, + selected: false, + onTap: onTap, + labelMaxWidth: labelMaxWidth, + ); + } + final handleTap = onTap == null + ? null + : () { + unawaited(HapticFeedback.selectionClick()); + onTap!(); + }; + return Column( + children: [ + IosGlassNavigationButton( + icon: iosIcon, + semanticLabel: label, + onPressed: handleTap, + width: 64, + height: 64, + controlSize: 64, + foregroundColor: context.colors.onSurface, + ), + const SizedBox(height: Grid.quarter), + SizedBox( + height: 20, + child: OverflowBox( + maxWidth: labelMaxWidth, + maxHeight: 20, + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ), + ), + ], + ); + } +} diff --git a/mobile/lib/shared/widgets/ios_glass_navigation_button.dart b/mobile/lib/shared/widgets/ios_glass_navigation_button.dart index 52697316536..e43417b5f14 100644 --- a/mobile/lib/shared/widgets/ios_glass_navigation_button.dart +++ b/mobile/lib/shared/widgets/ios_glass_navigation_button.dart @@ -9,7 +9,14 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import '../theme/theme.dart'; /// The navigation glyph displayed by [IosGlassNavigationButton]. -enum IosGlassNavigationIcon { back, close, rotateCamera, shutter } +enum IosGlassNavigationIcon { + back, + close, + camera, + photoLibrary, + rotateCamera, + shutter, +} /// Leading width used by iOS channel-style headers. const iosGlassChannelHeaderLeadingWidth = 58.0; @@ -158,6 +165,10 @@ class IosGlassNavigationButton extends HookWidget { Icons.arrow_back_ios_new_rounded, IosGlassNavigationIcon.close => Icons.close_rounded, + IosGlassNavigationIcon.camera => + Icons.camera_alt_rounded, + IosGlassNavigationIcon.photoLibrary => + Icons.photo_library_rounded, IosGlassNavigationIcon.rotateCamera => Icons.cameraswitch_rounded, IosGlassNavigationIcon.shutter => Icons.circle, diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index 5bbce8d70ad..7d85d3f65dc 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -19,6 +19,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'; 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 11a75e7f817..28107d4f1b3 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,7 +1,33 @@ part of '../profile_edit_page_test.dart'; void runProfileEditImageSelectionTests() { - testWidgets('grows the existing avatar cutout by 25 percent', (tester) async { + 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('keeps the avatar compact until the camera is ready', ( + tester, + ) async { await tester.pumpWidget( WidgetHelpers.testable( child: Scaffold( @@ -26,7 +52,7 @@ void runProfileEditImageSelectionTests() { expect(tester.getSize(preview), const Size.square(220)); await tester.pump(); await tester.pump(const Duration(milliseconds: 180)); - expect(tester.getSize(preview), const Size.square(275)); + expect(tester.getSize(preview), const Size.square(220)); expect( find.byKey(const ValueKey('existing-avatar-preview')), findsOneWidget, @@ -48,7 +74,7 @@ void runProfileEditImageSelectionTests() { expect(find.bySemanticsLabel('Take photo'), findsOneWidget); }); - testWidgets('reverses the camera expansion before closing', (tester) async { + testWidgets('reverses the camera controls before closing', (tester) async { var closed = false; await tester.pumpWidget( WidgetHelpers.testable( @@ -67,22 +93,21 @@ void runProfileEditImageSelectionTests() { ); 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: find.byKey(const ValueKey('image-camera-left-action')), - matching: find.byType(InkWell), - ), + find.descendant(of: leftAction, matching: find.byType(InkWell)), ) .onTap!(); 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)); + final midDistance = + tester.getCenter(rightAction).dx - tester.getCenter(leftAction).dx; + expect(midDistance, lessThan(expandedDistance)); expect(closed, isFalse); await tester.pump(const Duration(milliseconds: 90)); @@ -131,6 +156,13 @@ void runProfileEditImageSelectionTests() { 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(); @@ -149,6 +181,44 @@ void runProfileEditImageSelectionTests() { ); }); + 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('accepts an inline camera photo before enabling profile Save', ( tester, ) async { From 4b8219867b7a51d4f8dff9341d72cf153f3fde52 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 24 Aug 2026 09:15:47 +0100 Subject: [PATCH 38/67] fix(mobile): stabilize emoji editor layout Signed-off-by: kenny lopez --- .../features/profile/profile_avatar_editor.dart | 4 +--- .../emoji_avatar_picker.dart | 15 ++++++++++----- .../features/profile/profile_edit_page_test.dart | 10 ++++++++-- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart index 9fcda34e2fb..4ac1b176397 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -329,9 +329,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 = 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..06a64f6e91d 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'), diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index 7d85d3f65dc..4b3f927d046 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -542,13 +542,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')); From b5bfaa0b2187c95313de1407c31341ab4a1063a5 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 24 Aug 2026 09:31:39 +0100 Subject: [PATCH 39/67] fix(mobile): refine profile camera controls Signed-off-by: kenny lopez --- .../ios/Runner/JumpToLatestGlassButton.swift | 27 +++- .../profile/animated_avatar_capture.dart | 1 + .../capture_controls.dart | 82 +++++++----- .../review_controls.dart | 4 + .../profile/avatar_editor_option_button.dart | 94 ++++++++----- .../profile/image_avatar_capture.dart | 88 +++++++----- .../profile/profile_avatar_editor.dart | 56 ++------ .../emoji_avatar_picker.dart | 2 + .../widgets/ios_glass_navigation_button.dart | 55 ++++++-- .../profile/profile_edit_page_test.dart | 1 + .../image_selection_tests.dart | 33 ++++- .../motion_and_accessibility_tests.dart | 125 ++++++++++++++++++ 12 files changed, 405 insertions(+), 163 deletions(-) diff --git a/mobile/ios/Runner/JumpToLatestGlassButton.swift b/mobile/ios/Runner/JumpToLatestGlassButton.swift index 832a49ccaad..45202e65a7e 100644 --- a/mobile/ios/Runner/JumpToLatestGlassButton.swift +++ b/mobile/ios/Runner/JumpToLatestGlassButton.swift @@ -323,10 +323,17 @@ 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.isSelected = selected + if selected { + button.accessibilityTraits.insert(.selected) + } else { + button.accessibilityTraits.remove(.selected) + } button.configuration?.showsActivityIndicator = busy button.configuration?.title = busy ? nil : buttonLabel if let foregroundColor { @@ -352,11 +359,21 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { 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 let buttonLabel { + button.configuration?.contentInsets = NSDirectionalEdgeInsets( + top: 8, + leading: 8, + bottom: 8, + trailing: 8 + ) button.configuration?.title = buttonLabel button.configuration?.image = nil button.configuration?.titleLineBreakMode = .byClipping @@ -375,13 +392,13 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { button.configuration?.titleTextAttributesTransformer = nil if icon == "shutter" { button.configuration?.contentInsets = NSDirectionalEdgeInsets( - top: 8, - leading: 8, - bottom: 8, - trailing: 8 + top: 16, + leading: 16, + bottom: 16, + trailing: 16 ) } - let pointSize: CGFloat = icon == "shutter" ? 99 : 17 + let pointSize: CGFloat = icon == "shutter" ? 83 : 17 button.configuration?.image = UIImage( systemName: buttonIconName, withConfiguration: UIImage.SymbolConfiguration( diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart index e503442d7d3..9e8456bf108 100644 --- a/mobile/lib/features/profile/animated_avatar_capture.dart +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -18,6 +18,7 @@ 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 'avatar_editor_option_button.dart'; import 'animated_avatar_orientation.dart'; 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..b4dcdd9cb5a 100644 --- a/mobile/lib/features/profile/animated_avatar_capture/capture_controls.dart +++ b/mobile/lib/features/profile/animated_avatar_capture/capture_controls.dart @@ -19,39 +19,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, + ), + ), + ), + ), + ), ), - ), - ), - ), ), ), ); 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..576ecbe8f30 100644 --- a/mobile/lib/features/profile/animated_avatar_capture/review_controls.dart +++ b/mobile/lib/features/profile/animated_avatar_capture/review_controls.dart @@ -53,6 +53,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 +63,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 +73,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 +85,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..395ec812d7f 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), + 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/image_avatar_capture.dart b/mobile/lib/features/profile/image_avatar_capture.dart index 02d89242a53..75490e24ded 100644 --- a/mobile/lib/features/profile/image_avatar_capture.dart +++ b/mobile/lib/features/profile/image_avatar_capture.dart @@ -80,6 +80,7 @@ class ImageAvatarCapture extends HookConsumerWidget { final selectedLens = useState(CameraLensDirection.front); final cameraGeneration = useState(0); final isInitializing = useState(initialCapturedBytes == null); + final isFlipping = useState(false); final isCapturing = useState(false); final isProcessingCapture = useState(false); final capturedBytes = useState(initialCapturedBytes); @@ -225,21 +226,33 @@ class ImageAvatarCapture extends HookConsumerWidget { } } - void flipCamera() { + 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; - if (!cameras.value.any((camera) => camera.lensDirection == nextLens)) { - return; - } + final matches = cameras.value.where( + (camera) => camera.lensDirection == nextLens, + ); + if (matches.isEmpty) return; unawaited(HapticFeedback.selectionClick()); - selectedLens.value = nextLens; - cameraGeneration.value++; + isFlipping.value = true; + error.value = null; + try { + await active.setDescription(matches.first); + if (context.mounted) selectedLens.value = nextLens; + } on CameraException { + if (context.mounted) error.value = 'Could not switch cameras.'; + } finally { + if (context.mounted) isFlipping.value = false; + } } void retake() { @@ -271,11 +284,13 @@ class ImageAvatarCapture extends HookConsumerWidget { final captureEnabled = controller.value != null && !isInitializing.value && + !isFlipping.value && !isCapturing.value && !isClosing.value; final flipEnabled = cameras.value.length > 1 && !isInitializing.value && + !isFlipping.value && !isCapturing.value && !isClosing.value; @@ -285,29 +300,37 @@ class ImageAvatarCapture extends HookConsumerWidget { child: Stack( clipBehavior: Clip.none, children: [ - Align( - alignment: Alignment.topCenter, - child: AnimatedContainer( - key: const ValueKey('image-camera-preview-size'), - duration: reduceMotion ? Duration.zero : _captureMotionDuration, - curve: Curves.easeInOutCubic, - 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 ?? - Center( - child: isInitializing.value - ? const BuzzLoadingIndicator( - semanticLabel: 'Starting camera', - ) - : const Icon(LucideIcons.cameraOff, size: 32), - ), + Positioned( + left: 0, + right: 0, + top: 0, + height: imageAvatarCameraPreviewSize, + child: Center( + child: AnimatedContainer( + key: const ValueKey('image-camera-preview-size'), + duration: reduceMotion ? Duration.zero : _captureMotionDuration, + curve: Curves.easeInOutCubic, + 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 ?? + Center( + child: isInitializing.value + ? const BuzzLoadingIndicator( + semanticLabel: 'Starting camera', + ) + : const Icon( + LucideIcons.cameraOff, + size: 32, + ), + ), + ), ), ), ), @@ -362,7 +385,10 @@ class ImageAvatarCapture extends HookConsumerWidget { semanticLabel: captured == null ? 'Close camera' : 'Retry', - onTap: isCapturing.value || isClosing.value + onTap: + isFlipping.value || + isCapturing.value || + isClosing.value ? null : captured == null ? () => unawaited(leaveCamera(null)) @@ -407,7 +433,7 @@ class ImageAvatarCapture extends HookConsumerWidget { : captured != null ? () => unawaited(leaveCamera(captured)) : flipEnabled - ? flipCamera + ? () => unawaited(flipCamera()) : null, ), ), diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart index 4ac1b176397..0d030994e5c 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -522,7 +522,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { ), ), AnimatedPositioned( - duration: reduceMotion + duration: isCapturingImage.value || reduceMotion ? Duration.zero : const Duration(milliseconds: 150), curve: Curves.easeOutCubic, @@ -745,50 +745,12 @@ class _ImageSourceOption extends StatelessWidget { final double labelMaxWidth; @override - Widget build(BuildContext context) { - if (defaultTargetPlatform != TargetPlatform.iOS) { - return AvatarEditorOptionButton( - icon: icon, - label: label, - selected: false, - onTap: onTap, - labelMaxWidth: labelMaxWidth, - ); - } - final handleTap = onTap == null - ? null - : () { - unawaited(HapticFeedback.selectionClick()); - onTap!(); - }; - return Column( - children: [ - IosGlassNavigationButton( - icon: iosIcon, - semanticLabel: label, - onPressed: handleTap, - width: 64, - height: 64, - controlSize: 64, - foregroundColor: context.colors.onSurface, - ), - const SizedBox(height: Grid.quarter), - SizedBox( - height: 20, - child: OverflowBox( - maxWidth: labelMaxWidth, - maxHeight: 20, - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ), - ), - ], - ); - } + 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 06a64f6e91d..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 @@ -228,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, @@ -241,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/shared/widgets/ios_glass_navigation_button.dart b/mobile/lib/shared/widgets/ios_glass_navigation_button.dart index e43417b5f14..7fd898f5ab2 100644 --- a/mobile/lib/shared/widgets/ios_glass_navigation_button.dart +++ b/mobile/lib/shared/widgets/ios_glass_navigation_button.dart @@ -14,6 +14,10 @@ enum IosGlassNavigationIcon { close, camera, photoLibrary, + palette, + emoji, + person, + frame, rotateCamera, shutter, } @@ -45,6 +49,7 @@ class IosGlassNavigationButton extends HookWidget { this.buttonCenterX, this.foregroundColor, this.isBusy = false, + this.isSelected = false, this.nativeViewSuppressed, }); @@ -61,6 +66,7 @@ class IosGlassNavigationButton extends HookWidget { final double? buttonCenterX; final Color? foregroundColor; final bool isBusy; + final bool isSelected; final ValueListenable? nativeViewSuppressed; @override @@ -84,20 +90,31 @@ 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, - }), - ); - } - return null; - }, [nativeChannel.value, brightness, foregroundValue, enabled, isBusy]); + 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; @@ -119,6 +136,7 @@ class IosGlassNavigationButton extends HookWidget { container: true, button: true, enabled: enabled, + selected: isSelected, label: semanticLabel, onTap: onPressed, child: ExcludeSemantics( @@ -169,6 +187,14 @@ class IosGlassNavigationButton extends HookWidget { 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, @@ -192,6 +218,7 @@ class IosGlassNavigationButton extends HookWidget { 'foregroundColor': foregroundValue, 'enabled': enabled, 'busy': isBusy, + 'selected': isSelected, 'controlSize': controlSize, 'controlWidth': controlSize, 'fillWidth': fillWidth, diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index 4b3f927d046..9532a47189d 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -5,6 +5,7 @@ import 'package:buzz/features/profile/profile_edit_page.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'; 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 28107d4f1b3..8447bca6e03 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 @@ -25,6 +25,29 @@ void runProfileEditImageSelectionTests() { 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('keeps the avatar compact until the camera is ready', ( tester, ) async { @@ -50,6 +73,7 @@ void runProfileEditImageSelectionTests() { 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: 180)); expect(tester.getSize(preview), const Size.square(220)); @@ -140,12 +164,13 @@ void runProfileEditImageSelectionTests() { ), ), ); + 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(find.byKey(const ValueKey('image-camera-preview-size'))), - const Size.square(275), - ); + 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( 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 5814e2f6716..6af362f31b5 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 @@ -381,6 +381,131 @@ 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'); + 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 { From a56c074aeec5b7af1804f881732ab7e58f097f80 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 24 Aug 2026 09:50:14 +0100 Subject: [PATCH 40/67] fix(mobile): polish avatar capture feedback Signed-off-by: kenny lopez --- .../ios/Runner/JumpToLatestGlassButton.swift | 58 +++++--- .../profile/image_avatar_capture.dart | 5 + .../profile/profile_avatar_editor.dart | 102 +++++++++----- .../image_selection_tests.dart | 131 ++++++++++++++++++ 4 files changed, 246 insertions(+), 50 deletions(-) diff --git a/mobile/ios/Runner/JumpToLatestGlassButton.swift b/mobile/ios/Runner/JumpToLatestGlassButton.swift index 45202e65a7e..ca2abf05ed8 100644 --- a/mobile/ios/Runner/JumpToLatestGlassButton.swift +++ b/mobile/ios/Runner/JumpToLatestGlassButton.swift @@ -172,8 +172,11 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { 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 buttonImage: UIImage? + private var isBusy = false init( frame: CGRect, @@ -230,6 +233,17 @@ 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" { @@ -329,15 +343,16 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { button.overrideUserInterfaceStyle = interfaceStyle button.isEnabled = enabled button.isSelected = selected + isBusy = busy if selected { button.accessibilityTraits.insert(.selected) } else { button.accessibilityTraits.remove(.selected) } - button.configuration?.showsActivityIndicator = busy - button.configuration?.title = busy ? nil : buttonLabel + button.configuration?.showsActivityIndicator = false if let foregroundColor { button.configuration?.baseForegroundColor = foregroundColor + activityIndicator.color = foregroundColor } if #unavailable(iOS 26.0) { button.configuration?.baseBackgroundColor = Self.fallbackBackgroundColor( @@ -345,6 +360,7 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { interfaceStyle: interfaceStyle ) } + updateDisplayedContent() button.setNeedsUpdateConfiguration() } @@ -367,15 +383,14 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { case "shutter": buttonIconName = "circle.fill" default: buttonIconName = "chevron.backward" } - if let buttonLabel { + if buttonLabel != nil { + buttonImage = nil button.configuration?.contentInsets = NSDirectionalEdgeInsets( top: 8, leading: 8, bottom: 8, trailing: 8 ) - button.configuration?.title = buttonLabel - button.configuration?.image = nil button.configuration?.titleLineBreakMode = .byClipping button.configuration?.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in @@ -388,18 +403,16 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { return outgoing } } else { - button.configuration?.title = nil button.configuration?.titleTextAttributesTransformer = nil - if icon == "shutter" { - button.configuration?.contentInsets = NSDirectionalEdgeInsets( - top: 16, - leading: 16, - bottom: 16, - trailing: 16 - ) - } - let pointSize: CGFloat = icon == "shutter" ? 83 : 17 - button.configuration?.image = UIImage( + let iconInset: CGFloat = icon == "shutter" ? 20 : 8 + button.configuration?.contentInsets = NSDirectionalEdgeInsets( + top: iconInset, + leading: iconInset, + bottom: iconInset, + trailing: iconInset + ) + let pointSize: CGFloat = icon == "shutter" ? 75 : 17 + buttonImage = UIImage( systemName: buttonIconName, withConfiguration: UIImage.SymbolConfiguration( pointSize: pointSize, @@ -407,9 +420,22 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { ) ) } + updateDisplayedContent() button.setNeedsUpdateConfiguration() } + 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/image_avatar_capture.dart b/mobile/lib/features/profile/image_avatar_capture.dart index 75490e24ded..978ea4606dc 100644 --- a/mobile/lib/features/profile/image_avatar_capture.dart +++ b/mobile/lib/features/profile/image_avatar_capture.dart @@ -264,6 +264,11 @@ class ImageAvatarCapture extends HookConsumerWidget { 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); diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart index 0d030994e5c..b97fc2258a5 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -139,6 +139,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { final emojiPreviewKey = useState(0); final isPickingImage = useState(false); final isCapturingImage = useState(false); + final animateImageControlsEntrance = useState(false); final imageSelectionGeneration = useRef(0); final currentMode = useRef(mode)..value = mode; final error = useState(null); @@ -166,6 +167,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { imageSelectionGeneration.value++; isPickingImage.value = false; isCapturingImage.value = false; + animateImageControlsEntrance.value = false; onImageCameraActiveChanged(false); } if (!reduceMotion) modeTransitionController.value = 0; @@ -228,6 +230,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { } void closeImageCamera() { + animateImageControlsEntrance.value = true; isCapturingImage.value = false; onImageCameraActiveChanged(false); } @@ -384,7 +387,9 @@ class ProfileAvatarEditor extends HookConsumerWidget { key: const ValueKey(0), height: modeHeight, isPicking: isPickingImage.value, + animateEntrance: animateImageControlsEntrance.value, onCamera: () { + animateImageControlsEntrance.value = false; isCapturingImage.value = true; onImageCameraActiveChanged(true); }, @@ -676,56 +681,85 @@ class _AvatarModeControl extends StatelessWidget { ); } -class _ImageMode extends StatelessWidget { +class _ImageMode extends HookWidget { const _ImageMode({ super.key, required this.height, required this.isPicking, + required this.animateEntrance, required this.onCamera, required this.onLibrary, }); final double height; final bool isPicking; + final bool animateEntrance; final VoidCallback onCamera; final VoidCallback onLibrary; @override - Widget build(BuildContext context) => SizedBox( - height: height, - child: Column( - children: [ - const Spacer(), - Row( - children: [ - const Spacer(), - Expanded( - child: _ImageSourceOption( - key: const ValueKey('image-source-camera'), - icon: LucideIcons.camera, - iosIcon: IosGlassNavigationIcon.camera, - label: 'Camera', - onTap: isPicking ? null : onCamera, - labelMaxWidth: 96, - ), - ), - const SizedBox(width: Grid.half), - Expanded( - child: _ImageSourceOption( - key: const ValueKey('image-source-library'), - icon: LucideIcons.images, - iosIcon: IosGlassNavigationIcon.photoLibrary, - label: 'Photo Library', - onTap: isPicking ? null : onLibrary, - labelMaxWidth: 104, + Widget build(BuildContext context) { + final reduceMotion = MediaQuery.disableAnimationsOf(context); + final controlsVisible = useState(!animateEntrance || reduceMotion); + useEffect(() { + if (!animateEntrance || reduceMotion) return null; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) controlsVisible.value = true; + }); + return null; + }, const []); + final duration = reduceMotion + ? Duration.zero + : const Duration(milliseconds: 140); + + return SizedBox( + height: height, + child: Column( + children: [ + const Spacer(), + AnimatedOpacity( + key: const ValueKey('image-source-return-opacity'), + opacity: controlsVisible.value ? 1 : 0, + duration: duration, + curve: Curves.easeOutCubic, + child: AnimatedScale( + key: const ValueKey('image-source-return-scale'), + scale: controlsVisible.value ? 1 : 0.96, + duration: duration, + curve: Curves.easeOutCubic, + child: Row( + children: [ + const Spacer(), + Expanded( + child: _ImageSourceOption( + key: const ValueKey('image-source-camera'), + icon: LucideIcons.camera, + iosIcon: IosGlassNavigationIcon.camera, + label: 'Camera', + onTap: isPicking ? null : onCamera, + labelMaxWidth: 96, + ), + ), + const SizedBox(width: Grid.half), + Expanded( + child: _ImageSourceOption( + key: const ValueKey('image-source-library'), + icon: LucideIcons.images, + iosIcon: IosGlassNavigationIcon.photoLibrary, + label: 'Photo Library', + onTap: isPicking ? null : onLibrary, + labelMaxWidth: 104, + ), + ), + const Spacer(), + ], ), ), - const Spacer(), - ], - ), - ], - ), - ); + ), + ], + ), + ); + } } class _ImageSourceOption extends StatelessWidget { 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 8447bca6e03..9f5eaa85986 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 @@ -142,6 +142,137 @@ void runProfileEditImageSelectionTests() { ); }); + testWidgets( + 'fades and scales image source controls back after camera close', + (tester) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: ProfileEditPage( + startInPhotoEditor: true, + imageAvatarCaptureBuilder: + ({required height, required onAccepted, required onClosed}) => + _FakeImageAvatarCapture( + onAccepted: onAccepted, + onClosed: onClosed, + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('image-source-camera'))); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('fake-image-camera-close'))); + await tester.pump(); + + AnimatedOpacity opacity() => tester.widget( + find.byKey(const ValueKey('image-source-return-opacity')), + ); + AnimatedScale scale() => tester.widget( + find.byKey(const ValueKey('image-source-return-scale')), + ); + expect(opacity().opacity, 0); + expect(scale().scale, 0.96); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 70)); + final fadedOpacity = tester + .widget( + find.descendant( + of: find.byKey(const ValueKey('image-source-return-opacity')), + matching: find.byType(FadeTransition), + ), + ) + .opacity + .value; + final animatedScale = tester + .widget( + find.descendant( + of: find.byKey(const ValueKey('image-source-return-scale')), + matching: find.byType(ScaleTransition), + ), + ) + .scale + .value; + expect(fadedOpacity, greaterThan(0)); + expect(fadedOpacity, lessThan(1)); + expect(animatedScale, greaterThan(0.96)); + expect(animatedScale, lessThan(1)); + + await tester.pump(const Duration(milliseconds: 70)); + expect(opacity().opacity, 1); + expect(scale().scale, 1); + }, + ); + + 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 { From 4ca6bdddc6f9cef00baedbf334c45b4e5a69c351 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 24 Aug 2026 10:02:20 +0100 Subject: [PATCH 41/67] fix(mobile): crossfade camera exit controls Signed-off-by: kenny lopez --- .../ios/Runner/JumpToLatestGlassButton.swift | 23 +++- .../profile/image_avatar_capture.dart | 26 +++-- .../profile/profile_avatar_editor.dart | 102 ++++++------------ .../image_selection_tests.dart | 71 ++---------- 4 files changed, 82 insertions(+), 140 deletions(-) diff --git a/mobile/ios/Runner/JumpToLatestGlassButton.swift b/mobile/ios/Runner/JumpToLatestGlassButton.swift index ca2abf05ed8..c17b95c39d1 100644 --- a/mobile/ios/Runner/JumpToLatestGlassButton.swift +++ b/mobile/ios/Runner/JumpToLatestGlassButton.swift @@ -175,6 +175,7 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { 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 @@ -247,7 +248,7 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { applyAppearance(from: args) channel.setMethodCallHandler { [weak self] call, result in if call.method == "setContent" { - self?.applyContent(from: call.arguments) + self?.setContent(from: call.arguments) result(nil) return } @@ -367,6 +368,7 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { 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 @@ -411,7 +413,7 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { bottom: iconInset, trailing: iconInset ) - let pointSize: CGFloat = icon == "shutter" ? 75 : 17 + let pointSize: CGFloat = icon == "shutter" ? 80 : 17 buttonImage = UIImage( systemName: buttonIconName, withConfiguration: UIImage.SymbolConfiguration( @@ -424,6 +426,23 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { 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 diff --git a/mobile/lib/features/profile/image_avatar_capture.dart b/mobile/lib/features/profile/image_avatar_capture.dart index 978ea4606dc..19dd4cfb8ee 100644 --- a/mobile/lib/features/profile/image_avatar_capture.dart +++ b/mobile/lib/features/profile/image_avatar_capture.dart @@ -384,11 +384,17 @@ class ImageAvatarCapture extends HookConsumerWidget { child: _MorphingCameraAction( key: const ValueKey('image-camera-left-action'), width: sideWidth, - icon: LucideIcons.x, - iosIcon: IosGlassNavigationIcon.close, + icon: isClosing.value + ? LucideIcons.camera + : LucideIcons.x, + iosIcon: isClosing.value + ? IosGlassNavigationIcon.camera + : IosGlassNavigationIcon.close, label: captured == null ? null : 'Retry', semanticLabel: captured == null - ? 'Close camera' + ? isClosing.value + ? 'Camera' + : 'Close camera' : 'Retry', onTap: isFlipping.value || @@ -427,11 +433,17 @@ class ImageAvatarCapture extends HookConsumerWidget { child: _MorphingCameraAction( key: const ValueKey('image-camera-right-action'), width: sideWidth, - icon: LucideIcons.switchCamera, - iosIcon: IosGlassNavigationIcon.rotateCamera, + icon: isClosing.value + ? LucideIcons.images + : LucideIcons.switchCamera, + iosIcon: isClosing.value + ? IosGlassNavigationIcon.photoLibrary + : IosGlassNavigationIcon.rotateCamera, label: captured == null ? null : 'Use Photo', semanticLabel: captured == null - ? 'Flip camera' + ? isClosing.value + ? 'Photo Library' + : 'Flip camera' : 'Use Photo', onTap: isClosing.value ? null @@ -555,7 +567,7 @@ class _MorphingCameraAction extends StatelessWidget { child: label == null ? Icon( icon, - key: const ValueKey('camera-action-icon'), + key: ValueKey('camera-action-icon-${iosIcon.name}'), size: 26, color: onTap == null ? context.colors.onSurface.withValues(alpha: 0.38) diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart index b97fc2258a5..0d030994e5c 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -139,7 +139,6 @@ class ProfileAvatarEditor extends HookConsumerWidget { final emojiPreviewKey = useState(0); final isPickingImage = useState(false); final isCapturingImage = useState(false); - final animateImageControlsEntrance = useState(false); final imageSelectionGeneration = useRef(0); final currentMode = useRef(mode)..value = mode; final error = useState(null); @@ -167,7 +166,6 @@ class ProfileAvatarEditor extends HookConsumerWidget { imageSelectionGeneration.value++; isPickingImage.value = false; isCapturingImage.value = false; - animateImageControlsEntrance.value = false; onImageCameraActiveChanged(false); } if (!reduceMotion) modeTransitionController.value = 0; @@ -230,7 +228,6 @@ class ProfileAvatarEditor extends HookConsumerWidget { } void closeImageCamera() { - animateImageControlsEntrance.value = true; isCapturingImage.value = false; onImageCameraActiveChanged(false); } @@ -387,9 +384,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { key: const ValueKey(0), height: modeHeight, isPicking: isPickingImage.value, - animateEntrance: animateImageControlsEntrance.value, onCamera: () { - animateImageControlsEntrance.value = false; isCapturingImage.value = true; onImageCameraActiveChanged(true); }, @@ -681,85 +676,56 @@ class _AvatarModeControl extends StatelessWidget { ); } -class _ImageMode extends HookWidget { +class _ImageMode extends StatelessWidget { const _ImageMode({ super.key, required this.height, required this.isPicking, - required this.animateEntrance, required this.onCamera, required this.onLibrary, }); final double height; final bool isPicking; - final bool animateEntrance; final VoidCallback onCamera; final VoidCallback onLibrary; @override - Widget build(BuildContext context) { - final reduceMotion = MediaQuery.disableAnimationsOf(context); - final controlsVisible = useState(!animateEntrance || reduceMotion); - useEffect(() { - if (!animateEntrance || reduceMotion) return null; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (context.mounted) controlsVisible.value = true; - }); - return null; - }, const []); - final duration = reduceMotion - ? Duration.zero - : const Duration(milliseconds: 140); - - return SizedBox( - height: height, - child: Column( - children: [ - const Spacer(), - AnimatedOpacity( - key: const ValueKey('image-source-return-opacity'), - opacity: controlsVisible.value ? 1 : 0, - duration: duration, - curve: Curves.easeOutCubic, - child: AnimatedScale( - key: const ValueKey('image-source-return-scale'), - scale: controlsVisible.value ? 1 : 0.96, - duration: duration, - curve: Curves.easeOutCubic, - child: Row( - children: [ - const Spacer(), - Expanded( - child: _ImageSourceOption( - key: const ValueKey('image-source-camera'), - icon: LucideIcons.camera, - iosIcon: IosGlassNavigationIcon.camera, - label: 'Camera', - onTap: isPicking ? null : onCamera, - labelMaxWidth: 96, - ), - ), - const SizedBox(width: Grid.half), - Expanded( - child: _ImageSourceOption( - key: const ValueKey('image-source-library'), - icon: LucideIcons.images, - iosIcon: IosGlassNavigationIcon.photoLibrary, - label: 'Photo Library', - onTap: isPicking ? null : onLibrary, - labelMaxWidth: 104, - ), - ), - const Spacer(), - ], + Widget build(BuildContext context) => SizedBox( + height: height, + child: Column( + children: [ + const Spacer(), + Row( + children: [ + const Spacer(), + Expanded( + child: _ImageSourceOption( + key: const ValueKey('image-source-camera'), + icon: LucideIcons.camera, + iosIcon: IosGlassNavigationIcon.camera, + label: 'Camera', + onTap: isPicking ? null : onCamera, + labelMaxWidth: 96, ), ), - ), - ], - ), - ); - } + const SizedBox(width: Grid.half), + Expanded( + child: _ImageSourceOption( + key: const ValueKey('image-source-library'), + icon: LucideIcons.images, + iosIcon: IosGlassNavigationIcon.photoLibrary, + label: 'Photo Library', + onTap: isPicking ? null : onLibrary, + labelMaxWidth: 104, + ), + ), + const Spacer(), + ], + ), + ], + ), + ); } class _ImageSourceOption extends StatelessWidget { 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 9f5eaa85986..1b7704df663 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 @@ -132,6 +132,14 @@ void runProfileEditImageSelectionTests() { 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(closed, isFalse); await tester.pump(const Duration(milliseconds: 90)); @@ -142,69 +150,6 @@ void runProfileEditImageSelectionTests() { ); }); - testWidgets( - 'fades and scales image source controls back after camera close', - (tester) async { - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], - child: ProfileEditPage( - startInPhotoEditor: true, - imageAvatarCaptureBuilder: - ({required height, required onAccepted, required onClosed}) => - _FakeImageAvatarCapture( - onAccepted: onAccepted, - onClosed: onClosed, - ), - ), - ), - ); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const ValueKey('image-source-camera'))); - await tester.pump(); - await tester.tap(find.byKey(const ValueKey('fake-image-camera-close'))); - await tester.pump(); - - AnimatedOpacity opacity() => tester.widget( - find.byKey(const ValueKey('image-source-return-opacity')), - ); - AnimatedScale scale() => tester.widget( - find.byKey(const ValueKey('image-source-return-scale')), - ); - expect(opacity().opacity, 0); - expect(scale().scale, 0.96); - - await tester.pump(); - await tester.pump(const Duration(milliseconds: 70)); - final fadedOpacity = tester - .widget( - find.descendant( - of: find.byKey(const ValueKey('image-source-return-opacity')), - matching: find.byType(FadeTransition), - ), - ) - .opacity - .value; - final animatedScale = tester - .widget( - find.descendant( - of: find.byKey(const ValueKey('image-source-return-scale')), - matching: find.byType(ScaleTransition), - ), - ) - .scale - .value; - expect(fadedOpacity, greaterThan(0)); - expect(fadedOpacity, lessThan(1)); - expect(animatedScale, greaterThan(0.96)); - expect(animatedScale, lessThan(1)); - - await tester.pump(const Duration(milliseconds: 70)); - expect(opacity().opacity, 1); - expect(scale().scale, 1); - }, - ); - testWidgets('provides haptics when closing or accepting a camera photo', ( tester, ) async { From 089dd301000f17d56c6fdd1ba683370d62440692 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 24 Aug 2026 10:14:12 +0100 Subject: [PATCH 42/67] fix(mobile): complete camera exit handoff Signed-off-by: kenny lopez --- .../profile/image_avatar_capture.dart | 289 ++++++++++++------ .../image_selection_tests.dart | 40 ++- 2 files changed, 241 insertions(+), 88 deletions(-) diff --git a/mobile/lib/features/profile/image_avatar_capture.dart b/mobile/lib/features/profile/image_avatar_capture.dart index 19dd4cfb8ee..a88016655bc 100644 --- a/mobile/lib/features/profile/image_avatar_capture.dart +++ b/mobile/lib/features/profile/image_avatar_capture.dart @@ -24,10 +24,10 @@ const _cameraControlSize = 64.0; const _shutterSize = 115.0; const _shutterCoreSize = _shutterSize - Grid.xxs * 2; const _reviewControlWidth = 112.0; -const _collapsedControlOffset = 52.0; const _expandedControlOffset = 119.5; const _reviewControlGap = Grid.twelve; const _captureMotionDuration = Duration(milliseconds: 180); +const _shutterExitDuration = Duration(milliseconds: 150); /// Builds the inline still-photo camera used by the profile avatar editor. typedef ImageAvatarCaptureBuilder = @@ -199,7 +199,10 @@ class ImageAvatarCapture extends HookConsumerWidget { final prepared = await ref .read(mediaUploadServiceProvider) .prepareImageBytes(photo); - final cropped = await compute(_centerCropCameraImage, prepared); + final cropped = await compute(_centerCropCameraImage, ( + bytes: prepared, + mirror: active.description.lensDirection == CameraLensDirection.front, + )); if (context.mounted) capturedBytes.value = cropped; } catch (_) { if (context.mounted) { @@ -351,12 +354,16 @@ class ImageAvatarCapture extends HookConsumerWidget { curve: Curves.easeOutCubic, builder: (context, progress, _) => LayoutBuilder( builder: (context, constraints) { + final collapsedControlOffset = + (constraints.maxWidth + Grid.half * 3) / 8; final sideOffset = - _collapsedControlOffset + - (_expandedControlOffset - _collapsedControlOffset) * + collapsedControlOffset + + (_expandedControlOffset - collapsedControlOffset) * progress; return TweenAnimationBuilder( - tween: Tween(end: captured == null ? 0 : 1), + tween: Tween( + end: captured == null || isClosing.value ? 0 : 1, + ), duration: reduceMotion ? Duration.zero : _captureMotionDuration, @@ -380,9 +387,11 @@ class ImageAvatarCapture extends HookConsumerWidget { effectiveSideOffset - sideWidth / 2, width: sideWidth, - height: _cameraControlSize, + height: _shutterSize, child: _MorphingCameraAction( - key: const ValueKey('image-camera-left-action'), + controlKey: const ValueKey( + 'image-camera-left-action', + ), width: sideWidth, icon: isClosing.value ? LucideIcons.camera @@ -390,11 +399,21 @@ class ImageAvatarCapture extends HookConsumerWidget { iosIcon: isClosing.value ? IosGlassNavigationIcon.camera : IosGlassNavigationIcon.close, - label: captured == null ? null : 'Retry', - semanticLabel: captured == null - ? isClosing.value - ? 'Camera' - : 'Close camera' + 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 || @@ -406,22 +425,36 @@ class ImageAvatarCapture extends HookConsumerWidget { : retake, ), ), - Transform.scale( - scale: - (0.73 + 0.27 * progress) * - (1 - 0.28 * reviewProgress), - child: Opacity( - opacity: progress * (1 - reviewProgress), - child: IgnorePointer( - ignoring: captured != null, - child: _ShutterButton( - busy: isProcessingCapture.value, - onTap: captureEnabled - ? () => unawaited(capture()) - : null, + 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: @@ -429,9 +462,11 @@ class ImageAvatarCapture extends HookConsumerWidget { effectiveSideOffset - sideWidth / 2, width: sideWidth, - height: _cameraControlSize, + height: _shutterSize, child: _MorphingCameraAction( - key: const ValueKey('image-camera-right-action'), + controlKey: const ValueKey( + 'image-camera-right-action', + ), width: sideWidth, icon: isClosing.value ? LucideIcons.images @@ -439,11 +474,21 @@ class ImageAvatarCapture extends HookConsumerWidget { iosIcon: isClosing.value ? IosGlassNavigationIcon.photoLibrary : IosGlassNavigationIcon.rotateCamera, - label: captured == null ? null : 'Use Photo', - semanticLabel: captured == null - ? isClosing.value - ? 'Photo Library' - : 'Flip camera' + 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 @@ -512,86 +557,148 @@ class _CameraPreview extends StatelessWidget { class _MorphingCameraAction extends StatelessWidget { const _MorphingCameraAction({ - super.key, + 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) { - return IosGlassNavigationButton( - icon: iosIcon, - label: label, - semanticLabel: semanticLabel, - onPressed: onTap, - width: width, - height: _cameraControlSize, - controlSize: _cameraControlSize, - fillWidth: true, - foregroundColor: context.colors.onSurface, + 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, + ), ); - } - return 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 - : _captureMotionDuration, - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeOutCubic, - child: label == null - ? Icon( - icon, - key: ValueKey('camera-action-icon-${iosIcon.name}'), - size: 26, - color: onTap == null - ? context.colors.onSurface.withValues(alpha: 0.38) - : context.colors.onSurface, - ) - : Text( - label!, - key: ValueKey(label), - maxLines: 1, - style: context.textTheme.labelMedium?.copyWith( - color: onTap == null + } 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, - fontWeight: FontWeight.w600, + ) + : 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: (_shutterSize - _cameraControlSize) / 2, + height: _cameraControlSize, + child: Transform.translate( + offset: Offset(0, 1.5 * labelProgress), + child: control, + ), + ), + if (transitionLabel != null) + Positioned( + left: 0, + right: 0, + top: _shutterSize - 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, + ), + ), + ), + ), + ), + ), + ], ); } } @@ -671,9 +778,10 @@ class _ShutterButton extends StatelessWidget { } } -Uint8List _centerCropCameraImage(Uint8List bytes) { - final decoded = image.decodeImage(bytes); +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, @@ -692,3 +800,10 @@ Uint8List _centerCropCameraImage(Uint8List bytes) { ); 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/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 1b7704df663..c62fcb7e832 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 @@ -140,9 +140,26 @@ void runProfileEditImageSelectionTests() { 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: 90)); + 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'))), @@ -150,6 +167,27 @@ void runProfileEditImageSelectionTests() { ); }); + 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 { From 668c1d50810432e659c697cf7336373341a51ef6 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 24 Aug 2026 10:19:50 +0100 Subject: [PATCH 43/67] fix(mobile): synchronize camera preview expansion Signed-off-by: kenny lopez --- mobile/lib/features/profile/image_avatar_capture.dart | 6 ++---- .../profile_edit_page_test/image_selection_tests.dart | 11 ++++++++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/mobile/lib/features/profile/image_avatar_capture.dart b/mobile/lib/features/profile/image_avatar_capture.dart index a88016655bc..13ccc4af0f2 100644 --- a/mobile/lib/features/profile/image_avatar_capture.dart +++ b/mobile/lib/features/profile/image_avatar_capture.dart @@ -285,9 +285,7 @@ class ImageAvatarCapture extends HookConsumerWidget { final captured = capturedBytes.value; final previewSize = controlsExpanded.value - ? controller.value != null || captured != null - ? imageAvatarCameraPreviewSize - : _avatarPreviewSize + ? imageAvatarCameraPreviewSize : _avatarPreviewSize; final captureEnabled = controller.value != null && @@ -317,7 +315,7 @@ class ImageAvatarCapture extends HookConsumerWidget { child: AnimatedContainer( key: const ValueKey('image-camera-preview-size'), duration: reduceMotion ? Duration.zero : _captureMotionDuration, - curve: Curves.easeInOutCubic, + curve: Curves.easeOutCubic, width: previewSize, height: previewSize, child: ClipOval( 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 c62fcb7e832..37734cfd270 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 @@ -48,7 +48,7 @@ void runProfileEditImageSelectionTests() { ); }); - testWidgets('keeps the avatar compact until the camera is ready', ( + testWidgets('expands the avatar with the controls while camera loads', ( tester, ) async { await tester.pumpWidget( @@ -75,8 +75,13 @@ void runProfileEditImageSelectionTests() { expect(tester.getSize(preview), const Size.square(220)); expect(tester.getCenter(preview).dy, imageAvatarCameraPreviewSize / 2); await tester.pump(); - await tester.pump(const Duration(milliseconds: 180)); - expect(tester.getSize(preview), const Size.square(220)); + await tester.pump(const Duration(milliseconds: 90)); + final midSize = tester.getSize(preview).width; + expect(midSize, greaterThan(220)); + expect(midSize, lessThan(275)); + expect(tester.getCenter(preview).dy, imageAvatarCameraPreviewSize / 2); + await tester.pump(const Duration(milliseconds: 90)); + expect(tester.getSize(preview), const Size.square(275)); expect( find.byKey(const ValueKey('existing-avatar-preview')), findsOneWidget, From 990275d20ce49d3b9c9048d2677feb4a5c27c762 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 24 Aug 2026 10:27:46 +0100 Subject: [PATCH 44/67] fix(mobile): scale avatar through camera handoff Signed-off-by: kenny lopez --- .../profile/image_avatar_capture.dart | 29 ++++++++++++------- .../image_selection_tests.dart | 21 ++++++++++++-- 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/mobile/lib/features/profile/image_avatar_capture.dart b/mobile/lib/features/profile/image_avatar_capture.dart index 13ccc4af0f2..18542025a08 100644 --- a/mobile/lib/features/profile/image_avatar_capture.dart +++ b/mobile/lib/features/profile/image_avatar_capture.dart @@ -325,17 +325,24 @@ class ImageAvatarCapture extends HookConsumerWidget { ? Image.memory(captured, fit: BoxFit.cover) : controller.value != null ? _CameraPreview(controller: controller.value!) - : initialPreview ?? - Center( - child: isInitializing.value - ? const BuzzLoadingIndicator( - semanticLabel: 'Starting camera', - ) - : const Icon( - LucideIcons.cameraOff, - size: 32, - ), - ), + : 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), + ), ), ), ), 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 37734cfd270..01b9ed12a9e 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,5 +1,12 @@ 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; @@ -59,9 +66,12 @@ void runProfileEditImageSelectionTests() { child: ImageAvatarCapture( height: 400, onAccepted: (_) {}, - initialPreview: const ColoredBox( - key: ValueKey('existing-avatar-preview'), - color: Colors.pink, + initialPreview: const SizedBox.square( + dimension: 220, + child: ColoredBox( + key: ValueKey('existing-avatar-preview'), + color: Colors.pink, + ), ), onClosed: () {}, loadCameras: () async => const [], @@ -79,9 +89,14 @@ void runProfileEditImageSelectionTests() { 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, From 2038112c7ad3ebec5286c736e0db3096bfd53f87 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 24 Aug 2026 10:34:45 +0100 Subject: [PATCH 45/67] refactor(mobile): keep avatar capture within size limit Signed-off-by: kenny lopez --- mobile/lib/features/profile/animated_avatar_capture.dart | 2 -- .../profile/animated_avatar_capture/review_controls.dart | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart index 9e8456bf108..5962f7e35d8 100644 --- a/mobile/lib/features/profile/animated_avatar_capture.dart +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -35,8 +35,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. 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 576ecbe8f30..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, From 4b8f01845ccfb120982d8d2a069ae3ba568fc7b4 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 24 Aug 2026 13:41:39 +0100 Subject: [PATCH 46/67] fix(mobile): resize profile camera shutter Signed-off-by: kenny lopez --- mobile/ios/Runner/JumpToLatestGlassButton.swift | 13 ++++++++++--- .../features/profile/image_avatar_capture.dart | 17 +++++++++-------- .../image_selection_tests.dart | 2 +- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/mobile/ios/Runner/JumpToLatestGlassButton.swift b/mobile/ios/Runner/JumpToLatestGlassButton.swift index c17b95c39d1..e59fc55e8af 100644 --- a/mobile/ios/Runner/JumpToLatestGlassButton.swift +++ b/mobile/ios/Runner/JumpToLatestGlassButton.swift @@ -169,6 +169,8 @@ 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) @@ -178,6 +180,7 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { private var contentIcon = "back" private var buttonImage: UIImage? private var isBusy = false + private var controlSize: CGFloat = 40 init( frame: CGRect, @@ -203,7 +206,7 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { (arguments?["hitTargetHeight"] as? NSNumber)?.doubleValue ?? 48 let controlWidth = (arguments?["controlWidth"] as? NSNumber)?.doubleValue ?? 40 - let controlSize = + controlSize = (arguments?["controlSize"] as? NSNumber)?.doubleValue ?? 40 let fillWidth = arguments?["fillWidth"] as? Bool ?? false @@ -406,14 +409,18 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { } } else { button.configuration?.titleTextAttributesTransformer = nil - let iconInset: CGFloat = icon == "shutter" ? 20 : 8 + 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" ? 80 : 17 + let pointSize: CGFloat = icon == "shutter" + ? controlSize * Self.shutterIconRatio + : 17 buttonImage = UIImage( systemName: buttonIconName, withConfiguration: UIImage.SymbolConfiguration( diff --git a/mobile/lib/features/profile/image_avatar_capture.dart b/mobile/lib/features/profile/image_avatar_capture.dart index 18542025a08..09b1946bd2b 100644 --- a/mobile/lib/features/profile/image_avatar_capture.dart +++ b/mobile/lib/features/profile/image_avatar_capture.dart @@ -21,8 +21,9 @@ 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 _shutterSize = 115.0; -const _shutterCoreSize = _shutterSize - Grid.xxs * 2; +const _cameraControlRailHeight = 115.0; +const _shutterSize = _cameraControlSize * 1.25; +const _shutterCoreSize = _shutterSize * (99 / 115); const _reviewControlWidth = 112.0; const _expandedControlOffset = 119.5; const _reviewControlGap = Grid.twelve; @@ -352,7 +353,7 @@ class ImageAvatarCapture extends HookConsumerWidget { left: 0, right: 0, bottom: 0, - height: _shutterSize, + height: _cameraControlRailHeight, child: TweenAnimationBuilder( tween: Tween(end: controlsExpanded.value ? 1 : 0), duration: reduceMotion ? Duration.zero : _captureMotionDuration, @@ -392,7 +393,7 @@ class ImageAvatarCapture extends HookConsumerWidget { effectiveSideOffset - sideWidth / 2, width: sideWidth, - height: _shutterSize, + height: _cameraControlRailHeight, child: _MorphingCameraAction( controlKey: const ValueKey( 'image-camera-left-action', @@ -467,7 +468,7 @@ class ImageAvatarCapture extends HookConsumerWidget { effectiveSideOffset - sideWidth / 2, width: sideWidth, - height: _shutterSize, + height: _cameraControlRailHeight, child: _MorphingCameraAction( controlKey: const ValueKey( 'image-camera-right-action', @@ -516,7 +517,7 @@ class ImageAvatarCapture extends HookConsumerWidget { Positioned( left: 0, right: 0, - bottom: _shutterSize + Grid.xs, + bottom: _cameraControlRailHeight + Grid.xs, child: Semantics( liveRegion: true, child: Text( @@ -669,7 +670,7 @@ class _MorphingCameraAction extends StatelessWidget { key: controlKey, left: 0, right: 0, - top: (_shutterSize - _cameraControlSize) / 2, + top: (_cameraControlRailHeight - _cameraControlSize) / 2, height: _cameraControlSize, child: Transform.translate( offset: Offset(0, 1.5 * labelProgress), @@ -680,7 +681,7 @@ class _MorphingCameraAction extends StatelessWidget { Positioned( left: 0, right: 0, - top: _shutterSize - 20, + top: _cameraControlRailHeight - 20, height: 20, child: Opacity( key: ValueKey('camera-transition-label-${transitionLabel!}'), 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 01b9ed12a9e..7a79855352f 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 @@ -111,7 +111,7 @@ void runProfileEditImageSelectionTests() { ); expect( tester.getSize(find.byKey(const ValueKey('image-camera-shutter-morph'))), - const Size.square(115), + const Size.square(80), ); expect(find.bySemanticsLabel('Close camera'), findsOneWidget); expect(find.bySemanticsLabel('Flip camera'), findsOneWidget); From 9701142818b1e59ec66f05317887a2ea5188da3c Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 24 Aug 2026 13:46:32 +0100 Subject: [PATCH 47/67] fix(mobile): enlarge profile camera shutter Signed-off-by: kenny lopez --- mobile/lib/features/profile/image_avatar_capture.dart | 2 +- .../profile/profile_edit_page_test/image_selection_tests.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/lib/features/profile/image_avatar_capture.dart b/mobile/lib/features/profile/image_avatar_capture.dart index 09b1946bd2b..3307e75f664 100644 --- a/mobile/lib/features/profile/image_avatar_capture.dart +++ b/mobile/lib/features/profile/image_avatar_capture.dart @@ -22,7 +22,7 @@ const _avatarPreviewSize = 220.0; const imageAvatarCameraPreviewSize = _avatarPreviewSize * 1.25; const _cameraControlSize = 64.0; const _cameraControlRailHeight = 115.0; -const _shutterSize = _cameraControlSize * 1.25; +const _shutterSize = _cameraControlSize * 1.5625; const _shutterCoreSize = _shutterSize * (99 / 115); const _reviewControlWidth = 112.0; const _expandedControlOffset = 119.5; 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 7a79855352f..59573870652 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 @@ -111,7 +111,7 @@ void runProfileEditImageSelectionTests() { ); expect( tester.getSize(find.byKey(const ValueKey('image-camera-shutter-morph'))), - const Size.square(80), + const Size.square(100), ); expect(find.bySemanticsLabel('Close camera'), findsOneWidget); expect(find.bySemanticsLabel('Flip camera'), findsOneWidget); From 09b919ae016b5fad0074f2a31f778274cdb97de4 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 24 Aug 2026 14:03:09 +0100 Subject: [PATCH 48/67] feat(mobile): animate profile camera flips Signed-off-by: kenny lopez --- .../profile/image_avatar_capture.dart | 107 +++++++++++++----- 1 file changed, 76 insertions(+), 31 deletions(-) diff --git a/mobile/lib/features/profile/image_avatar_capture.dart b/mobile/lib/features/profile/image_avatar_capture.dart index 3307e75f664..2c572d83eed 100644 --- a/mobile/lib/features/profile/image_avatar_capture.dart +++ b/mobile/lib/features/profile/image_avatar_capture.dart @@ -29,6 +29,7 @@ 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 = @@ -80,6 +81,10 @@ class ImageAvatarCapture extends HookConsumerWidget { 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); @@ -248,14 +253,35 @@ class ImageAvatarCapture extends HookConsumerWidget { if (matches.isEmpty) return; unawaited(HapticFeedback.selectionClick()); isFlipping.value = true; + flipDirection.value = nextLens == CameraLensDirection.back ? 1 : -1; error.value = null; try { + if (!reduceMotion) { + await flipAnimation.animateTo( + 0.5, + duration: _cameraFlipHalfDuration, + curve: Curves.easeInOutCubic, + ); + } + if (!context.mounted) return; await active.setDescription(matches.first); if (context.mounted) selectedLens.value = nextLens; + } on TickerCanceled { + return; } on CameraException { if (context.mounted) error.value = 'Could not switch cameras.'; } finally { - if (context.mounted) isFlipping.value = false; + if (context.mounted && !reduceMotion) { + await flipAnimation.animateTo( + 1, + duration: _cameraFlipHalfDuration, + curve: Curves.easeInOutCubic, + ); + } + if (context.mounted) { + flipAnimation.value = 0; + isFlipping.value = false; + } } } @@ -313,37 +339,56 @@ class ImageAvatarCapture extends HookConsumerWidget { top: 0, height: imageAvatarCameraPreviewSize, child: Center( - 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, + 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), ), - ) - : Center( - child: isInitializing.value - ? const BuzzLoadingIndicator( - semanticLabel: 'Starting camera', - ) - : const Icon(LucideIcons.cameraOff, size: 32), - ), + ), ), ), ), From 1ae1feb2d921cf18bea7e51cb51536309a7f087e Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 24 Aug 2026 14:16:16 +0100 Subject: [PATCH 49/67] fix(mobile): polish profile camera selection Signed-off-by: kenny lopez --- .../ios/Runner/JumpToLatestGlassButton.swift | 3 ++ .../profile/image_avatar_capture.dart | 29 +++++++++---------- .../motion_and_accessibility_tests.dart | 4 +++ 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/mobile/ios/Runner/JumpToLatestGlassButton.swift b/mobile/ios/Runner/JumpToLatestGlassButton.swift index e59fc55e8af..0dce3fb048b 100644 --- a/mobile/ios/Runner/JumpToLatestGlassButton.swift +++ b/mobile/ios/Runner/JumpToLatestGlassButton.swift @@ -355,6 +355,9 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { } 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 } diff --git a/mobile/lib/features/profile/image_avatar_capture.dart b/mobile/lib/features/profile/image_avatar_capture.dart index 2c572d83eed..cfd528b851c 100644 --- a/mobile/lib/features/profile/image_avatar_capture.dart +++ b/mobile/lib/features/profile/image_avatar_capture.dart @@ -255,28 +255,27 @@ class ImageAvatarCapture extends HookConsumerWidget { 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 flipAnimation.animateTo( - 0.5, - duration: _cameraFlipHalfDuration, - curve: Curves.easeInOutCubic, - ); - } + if (!reduceMotion) await Future.delayed(_cameraFlipHalfDuration); if (!context.mounted) return; await active.setDescription(matches.first); if (context.mounted) selectedLens.value = nextLens; - } on TickerCanceled { - return; } on CameraException { if (context.mounted) error.value = 'Could not switch cameras.'; } finally { - if (context.mounted && !reduceMotion) { - await flipAnimation.animateTo( - 1, - duration: _cameraFlipHalfDuration, - curve: Curves.easeInOutCubic, - ); + 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; 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 6af362f31b5..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 @@ -423,6 +423,10 @@ void runProfileEditMotionAndAccessibilityTests() { .where((params) => params['selected'] == true) .toList(); expect(selectedControls.single['icon'], 'palette'); + expect( + selectedControls.single['foregroundColor'], + AppTheme.light().colorScheme.primary.toARGB32(), + ); debugDefaultTargetPlatformOverride = null; }); From ce9627f0307f27d91fafdbed0ad259cc9bc87d2a Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 24 Aug 2026 14:23:08 +0100 Subject: [PATCH 50/67] fix(mobile): lock profile capture to portrait Signed-off-by: kenny lopez --- .../android/app/src/main/AndroidManifest.xml | 1 + mobile/ios/Runner/Info.plist | 2 - .../profile/animated_avatar_capture.dart | 5 +- .../capture_controls.dart | 8 +-- .../profile/animated_avatar_orientation.dart | 25 ++------- .../profile/image_avatar_capture.dart | 10 ++-- .../profile/animated_avatar_capture_test.dart | 51 ++++--------------- 7 files changed, 21 insertions(+), 81 deletions(-) diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 9121acbc151..b9556560619 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -25,6 +25,7 @@ android:launchMode="singleTop" android:taskAffinity="" android:theme="@style/LaunchTheme" + android:screenOrientation="portrait" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize"> diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 3f93df5b97e..b720417676b 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -85,8 +85,6 @@ UISupportedInterfaceOrientations UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart index 5962f7e35d8..2506a01fc06 100644 --- a/mobile/lib/features/profile/animated_avatar_capture.dart +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -144,6 +144,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { : ImageFormatGroup.yuv420, ); await next.initialize(); + await next.lockCaptureOrientation(DeviceOrientation.portraitUp); if (disposed) { await next.dispose(); return; @@ -267,10 +268,8 @@ class AnimatedAvatarCapture extends HookConsumerWidget { try { final request = _FrameRequest.fromCameraImage( cameraImage, - rotationDegrees: animatedAvatarFrameRotationDegrees( + rotationDegrees: animatedAvatarPortraitFrameRotationDegrees( sensorOrientation: active.description.sensorOrientation, - deviceOrientation: active.value.deviceOrientation, - lensDirection: active.description.lensDirection, ), mirror: active.description.lensDirection == CameraLensDirection.front, 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 b4dcdd9cb5a..a888438c2de 100644 --- a/mobile/lib/features/profile/animated_avatar_capture/capture_controls.dart +++ b/mobile/lib/features/profile/animated_avatar_capture/capture_controls.dart @@ -83,13 +83,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_orientation.dart b/mobile/lib/features/profile/animated_avatar_orientation.dart index 77f2b557c4a..905e4c6599d 100644 --- a/mobile/lib/features/profile/animated_avatar_orientation.dart +++ b/mobile/lib/features/profile/animated_avatar_orientation.dart @@ -1,24 +1,9 @@ -import 'package:camera/camera.dart'; -import 'package:flutter/services.dart'; - -/// Returns the clockwise correction for an Android animated-avatar frame. +/// Returns the portrait-up correction for an Android animated-avatar frame. /// -/// Android image-stream buffers remain sensor-oriented. The correction must -/// account for both the sensor mount and the orientation in which the device -/// is currently held; front-facing frames are mirrored separately after this -/// rotation. -int animatedAvatarFrameRotationDegrees({ +/// Android image-stream buffers remain sensor-oriented, so the fixed hardware +/// sensor mount still needs correction even though capture is portrait-locked. +int animatedAvatarPortraitFrameRotationDegrees({ required int sensorOrientation, - required DeviceOrientation deviceOrientation, - required CameraLensDirection lensDirection, }) { - final deviceOrientationDegrees = switch (deviceOrientation) { - DeviceOrientation.portraitUp => 0, - DeviceOrientation.landscapeRight => 90, - DeviceOrientation.portraitDown => 180, - DeviceOrientation.landscapeLeft => 270, - }; - final facingSign = lensDirection == CameraLensDirection.back ? -1 : 1; - return (sensorOrientation - deviceOrientationDegrees * facingSign + 360) % - 360; + return (sensorOrientation % 360 + 360) % 360; } diff --git a/mobile/lib/features/profile/image_avatar_capture.dart b/mobile/lib/features/profile/image_avatar_capture.dart index cfd528b851c..9ea01f448e3 100644 --- a/mobile/lib/features/profile/image_avatar_capture.dart +++ b/mobile/lib/features/profile/image_avatar_capture.dart @@ -152,6 +152,7 @@ class ImageAvatarCapture extends HookConsumerWidget { enableAudio: false, ); await next.initialize(); + await next.lockCaptureOrientation(DeviceOrientation.portraitUp); if (disposed || generation != cameraGeneration.value) { await next.dispose(); return; @@ -266,6 +267,7 @@ class ImageAvatarCapture extends HookConsumerWidget { if (!reduceMotion) await Future.delayed(_cameraFlipHalfDuration); if (!context.mounted) return; await active.setDescription(matches.first); + await active.lockCaptureOrientation(DeviceOrientation.portraitUp); if (context.mounted) selectedLens.value = nextLens; } on CameraException { if (context.mounted) error.value = 'Could not switch cameras.'; @@ -586,13 +588,7 @@ class _CameraPreview extends StatelessWidget { @override Widget build(BuildContext context) { - final orientation = controller.value.deviceOrientation; - final landscape = - orientation == DeviceOrientation.landscapeLeft || - orientation == DeviceOrientation.landscapeRight; - final aspectRatio = landscape - ? 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/test/features/profile/animated_avatar_capture_test.dart b/mobile/test/features/profile/animated_avatar_capture_test.dart index 63059266321..71ea1f05611 100644 --- a/mobile/test/features/profile/animated_avatar_capture_test.dart +++ b/mobile/test/features/profile/animated_avatar_capture_test.dart @@ -6,9 +6,7 @@ import 'package:buzz/features/profile/animated_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'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image/image.dart' as image; @@ -43,46 +41,15 @@ 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('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, - ), - entry.value, - ); - } - }); + test('normalizes the fixed portrait camera sensor correction', () { + expect( + animatedAvatarPortraitFrameRotationDegrees(sensorOrientation: 270), + 270, + ); + expect( + animatedAvatarPortraitFrameRotationDegrees(sensorOrientation: 450), + 90, + ); }); testWidgets('completed review frames survive lifecycle changes', ( From 5076f704afeafb8858eb9bd362627d8b662273be Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 24 Aug 2026 14:47:40 +0100 Subject: [PATCH 51/67] fix(mobile): preserve animated avatar save handoff Signed-off-by: kenny lopez --- .../profile/animated_avatar_capture.dart | 43 +++++----- .../capture_controls.dart | 34 ++++++++ .../features/profile/profile_edit_page.dart | 80 +++++++++++++------ .../features/profile/profile_provider.dart | 45 +++++++++++ .../profile/settings_profile_header.dart | 20 ++++- .../shared/widgets/playing_avatar_image.dart | 32 ++++++++ .../widgets/progressive_animated_avatar.dart | 29 +++++-- .../profile/profile_edit_page_test.dart | 67 ++++++++++++++++ 8 files changed, 297 insertions(+), 53 deletions(-) diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart index 2506a01fc06..6a8d722dd46 100644 --- a/mobile/lib/features/profile/animated_avatar_capture.dart +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -339,7 +339,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( @@ -354,7 +354,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { left: 0, right: 0, top: previewTop, - height: 220, + height: _animatedAvatarPreviewSize, child: Center( child: _RepositionablePreviewSemantics( offset: offset.value, @@ -382,7 +382,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { .toDouble(); }, child: SizedBox.square( - dimension: 220, + dimension: _animatedAvatarPreviewSize, child: Stack( fit: StackFit.expand, children: [ @@ -393,13 +393,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, @@ -410,7 +414,9 @@ class AnimatedAvatarCapture extends HookConsumerWidget { ), _AnimatedPersonPreview( bytes: selectedFrame, - offset: offset.value * 48, + offset: + offset.value * + _animatedAvatarPersonTranslation, scale: scale.value, outline: personOutline.value, outlineColor: _personOutlineColor( @@ -893,8 +899,8 @@ _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, @@ -916,9 +922,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, @@ -936,16 +942,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); } } 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 a888438c2de..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}); diff --git a/mobile/lib/features/profile/profile_edit_page.dart b/mobile/lib/features/profile/profile_edit_page.dart index 3342f674eb6..40095e60984 100644 --- a/mobile/lib/features/profile/profile_edit_page.dart +++ b/mobile/lib/features/profile/profile_edit_page.dart @@ -229,6 +229,17 @@ 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, + ), + ); + } if (context.mounted) await closeAvatarEditor(whileSaving: true); } on ProfileCommunityChangedException { await discardStaleEditor(); @@ -256,6 +267,7 @@ class ProfileEditPage extends HookConsumerWidget { final activeDraft = avatarDraftMode.value == avatarMode.value ? avatarDraft.value : null; + final avatarHandoff = ref.watch(profileAvatarHandoffProvider); return PopScope( canPop: !isEditingAvatar.value, @@ -420,6 +432,7 @@ class ProfileEditPage extends HookConsumerWidget { children: [ _ProfilePhotoEditor( profile: profile, + handoff: avatarHandoff, onEditPhoto: profileHydrated ? openAvatarEditor : null, ), AppListCard( @@ -498,36 +511,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 { diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index 38231d0f0d3..a8becae7185 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,50 @@ 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; + } +} + +/// 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/settings_profile_header.dart b/mobile/lib/features/profile/settings_profile_header.dart index ac2df53ef42..ca3f4f33ff1 100644 --- a/mobile/lib/features/profile/settings_profile_header.dart +++ b/mobile/lib/features/profile/settings_profile_header.dart @@ -34,6 +34,10 @@ class SettingsProfileHeader extends HookConsumerWidget { final hasStatus = status != null && !status.isEmpty; final presence = ref.watch(presenceProvider).value ?? 'offline'; final animatedAvatar = parseAnimatedAvatarUrl(profile?.avatarUrl); + 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 +75,24 @@ 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 + ? AvatarImageContent( imageUrl: avatarUrl, fallback: _AvatarFallback(initial: profile?.initial), + ) + : Image( + image: MemoryImage(activeHandoff.poster), + fit: BoxFit.cover, + gaplessPlayback: true, ), ), ), diff --git a/mobile/lib/shared/widgets/playing_avatar_image.dart b/mobile/lib/shared/widgets/playing_avatar_image.dart index ef84d4549e7..13ae3600101 100644 --- a/mobile/lib/shared/widgets/playing_avatar_image.dart +++ b/mobile/lib/shared/widgets/playing_avatar_image.dart @@ -13,6 +13,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 +27,39 @@ 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 the persisted animation 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: Image( + image: loadingPosterImage!, + fit: BoxFit.cover, + gaplessPlayback: true, + ), + ), + ), + ); + } if (descriptor == null || MediaQuery.disableAnimationsOf(context)) { return AvatarImage( imageUrl: descriptor?.posterUrl ?? imageUrl, @@ -48,6 +78,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/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index 9532a47189d..e1bf738e5ae 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -2,6 +2,7 @@ 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'; @@ -667,6 +668,58 @@ void main() { 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 { final notifier = _FakeProfileNotifier(); await tester.pumpWidget( @@ -860,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 = []; @@ -908,6 +963,18 @@ class _FakeProfileNotifier extends ProfileNotifier { throw Exception('profile publish failed'); } savedAvatarUrls.add(avatarUrl); + if (updatesProfileState) { + final current = state.requireValue!; + state = AsyncData( + UserProfile( + pubkey: current.pubkey, + displayName: current.displayName, + avatarUrl: avatarUrl, + about: current.about, + nip05Handle: current.nip05Handle, + ), + ); + } } } From 469afa70006d9ec7c6a4502bffdec4d373b57706 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 24 Aug 2026 14:59:12 +0100 Subject: [PATCH 52/67] fix(mobile): hide capture placeholder during processing Signed-off-by: kenny lopez --- mobile/lib/features/profile/animated_avatar_capture.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart index 6a8d722dd46..ad191cdc58b 100644 --- a/mobile/lib/features/profile/animated_avatar_capture.dart +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -287,12 +287,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); From ad6e31c00bd4550462bfadd735937a841aa745b4 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 24 Aug 2026 15:10:12 +0100 Subject: [PATCH 53/67] fix(mobile): save latest animated avatar framing Signed-off-by: kenny lopez --- .../profile/animated_avatar_capture.dart | 4 +- .../profile/animated_avatar_capture_test.dart | 67 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart index ad191cdc58b..a55df04d008 100644 --- a/mobile/lib/features/profile/animated_avatar_capture.dart +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -94,6 +94,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; @@ -169,7 +171,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { }, [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; diff --git a/mobile/test/features/profile/animated_avatar_capture_test.dart b/mobile/test/features/profile/animated_avatar_capture_test.dart index 71ea1f05611..e3f10682eee 100644 --- a/mobile/test/features/profile/animated_avatar_capture_test.dart +++ b/mobile/test/features/profile/animated_avatar_capture_test.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:typed_data'; import 'dart:ui' show SemanticsAction; import 'package:buzz/features/profile/animated_avatar_orientation.dart'; @@ -185,6 +186,72 @@ 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 _TestLifecycleNotifier extends AppLifecycleNotifier { From 7020f1526c1e1c995e7ca9c8e9b9bc48e0953604 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 24 Aug 2026 16:24:49 +0100 Subject: [PATCH 54/67] fix(mobile): preserve animated avatar frame bounds Signed-off-by: kenny lopez --- mobile/lib/shared/widgets/progressive_animated_avatar.dart | 4 ++++ .../features/profile/settings_profile_header_test.dart | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/mobile/lib/shared/widgets/progressive_animated_avatar.dart b/mobile/lib/shared/widgets/progressive_animated_avatar.dart index ed0c54e089e..1d469c93d24 100644 --- a/mobile/lib/shared/widgets/progressive_animated_avatar.dart +++ b/mobile/lib/shared/widgets/progressive_animated_avatar.dart @@ -41,6 +41,10 @@ class ProgressiveAnimatedAvatar extends HookWidget { key: animationKey, url: descriptor.animationUrl, fit: fit, + // Keep APNG frames at their encoded canvas size. On iOS, resizing the + // codec can scale the first frame while later frames retain their + // original bounds, which paints the moving cutout in the upper-left. + boundDecodeToLayout: false, errorBuilder: (_, _, _) => const SizedBox.shrink(), frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { if ((wasSynchronouslyLoaded || frame != null) && diff --git a/mobile/test/features/profile/settings_profile_header_test.dart b/mobile/test/features/profile/settings_profile_header_test.dart index a22c8c58f81..e1b60fc9178 100644 --- a/mobile/test/features/profile/settings_profile_header_test.dart +++ b/mobile/test/features/profile/settings_profile_header_test.dart @@ -124,6 +124,13 @@ void main() { .map((image) => image.url), containsAll([posterUrl, animationUrl]), ); + expect( + tester + .widgetList(find.byType(MediaImage, skipOffstage: false)) + .singleWhere((image) => image.url == animationUrl) + .boundDecodeToLayout, + isFalse, + ); animationResponse.complete(http.Response.bytes(_transparentPng, 200)); await tester.runAsync( From f4351d4e921036e7af56ba644edd30e6dbcf0293 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 24 Aug 2026 16:42:15 +0100 Subject: [PATCH 55/67] fix(mobile): preserve enlarged animated avatar framing Signed-off-by: kenny lopez --- .../profile/animated_avatar_capture.dart | 22 ++----------------- .../frame_processing.dart | 20 +++++++++++++++++ .../widgets/progressive_animated_avatar.dart | 4 ---- .../profile/animated_avatar_capture_test.dart | 12 ++++++++++ .../profile/settings_profile_header_test.dart | 8 ------- 5 files changed, 34 insertions(+), 32 deletions(-) diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart index a55df04d008..e7b384022cf 100644 --- a/mobile/lib/features/profile/animated_avatar_capture.dart +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -909,6 +909,8 @@ _EncodedAvatar _encodeAvatar(_EncodeRequest request) { image.compositeImage( person, scaledPerson, + dstW: scaledSize, + dstH: scaledSize, dstX: ((_outputSize - scaledSize) / 2 + request.offsetX * previewTranslation * translationScale) @@ -970,26 +972,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/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/shared/widgets/progressive_animated_avatar.dart b/mobile/lib/shared/widgets/progressive_animated_avatar.dart index 1d469c93d24..ed0c54e089e 100644 --- a/mobile/lib/shared/widgets/progressive_animated_avatar.dart +++ b/mobile/lib/shared/widgets/progressive_animated_avatar.dart @@ -41,10 +41,6 @@ class ProgressiveAnimatedAvatar extends HookWidget { key: animationKey, url: descriptor.animationUrl, fit: fit, - // Keep APNG frames at their encoded canvas size. On iOS, resizing the - // codec can scale the first frame while later frames retain their - // original bounds, which paints the moving cutout in the upper-left. - boundDecodeToLayout: false, errorBuilder: (_, _, _) => const SizedBox.shrink(), frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { if ((wasSynchronouslyLoaded || frame != null) && diff --git a/mobile/test/features/profile/animated_avatar_capture_test.dart b/mobile/test/features/profile/animated_avatar_capture_test.dart index e3f10682eee..51373396870 100644 --- a/mobile/test/features/profile/animated_avatar_capture_test.dart +++ b/mobile/test/features/profile/animated_avatar_capture_test.dart @@ -27,6 +27,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, diff --git a/mobile/test/features/profile/settings_profile_header_test.dart b/mobile/test/features/profile/settings_profile_header_test.dart index e1b60fc9178..954bd191b9a 100644 --- a/mobile/test/features/profile/settings_profile_header_test.dart +++ b/mobile/test/features/profile/settings_profile_header_test.dart @@ -124,14 +124,6 @@ void main() { .map((image) => image.url), containsAll([posterUrl, animationUrl]), ); - expect( - tester - .widgetList(find.byType(MediaImage, skipOffstage: false)) - .singleWhere((image) => image.url == animationUrl) - .boundDecodeToLayout, - isFalse, - ); - animationResponse.complete(http.Response.bytes(_transparentPng, 200)); await tester.runAsync( () => Future.delayed(const Duration(milliseconds: 50)), From 94c1ca5f7ffa7add0b6a04fc4cd1ffc2ec8f373f Mon Sep 17 00:00:00 2001 From: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> Date: Mon, 24 Aug 2026 20:04:41 +0100 Subject: [PATCH 56/67] fix(mobile): harden profile camera capture Signed-off-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> Co-authored-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> Signed-off-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> --- .../android/app/src/main/AndroidManifest.xml | 1 - mobile/ios/Runner/Info.plist | 2 + .../profile/animated_avatar_capture.dart | 6 +- .../profile/avatar_editor_option_button.dart | 2 +- .../profile/image_avatar_capture.dart | 157 +++++++++++------- .../widgets/ios_glass_navigation_button.dart | 25 +++ .../shared/widgets/playing_avatar_image.dart | 32 +++- 7 files changed, 153 insertions(+), 72 deletions(-) diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index b9556560619..9121acbc151 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -25,7 +25,6 @@ android:launchMode="singleTop" android:taskAffinity="" android:theme="@style/LaunchTheme" - android:screenOrientation="portrait" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize"> diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index b720417676b..3f93df5b97e 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -85,6 +85,8 @@ UISupportedInterfaceOrientations UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart index e7b384022cf..23b66554b33 100644 --- a/mobile/lib/features/profile/animated_avatar_capture.dart +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -130,6 +130,8 @@ class AnimatedAvatarCapture extends HookConsumerWidget { isInitializing.value = true; Future initialize() async { + CameraController? next; + var installed = false; try { final cameras = await availableCameras(); if (disposed || cameras.isEmpty) return; @@ -137,7 +139,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { (camera) => camera.lensDirection == CameraLensDirection.front, orElse: () => cameras.first, ); - final next = CameraController( + next = CameraController( selected, ResolutionPreset.medium, enableAudio: false, @@ -153,7 +155,9 @@ class AnimatedAvatarCapture extends HookConsumerWidget { } controllerRef.value = next; controller.value = next; + installed = true; } catch (_) { + if (!installed) await next?.dispose(); if (!disposed) error.value = 'Could not access the camera.'; } finally { if (!disposed) isInitializing.value = false; diff --git a/mobile/lib/features/profile/avatar_editor_option_button.dart b/mobile/lib/features/profile/avatar_editor_option_button.dart index 395ec812d7f..c7d0d12da2e 100644 --- a/mobile/lib/features/profile/avatar_editor_option_button.dart +++ b/mobile/lib/features/profile/avatar_editor_option_button.dart @@ -98,7 +98,7 @@ class AvatarEditorOptionButton extends StatelessWidget { isSelected: selected, ), const SizedBox(height: avatarEditorOptionLabelGap), - labelWidget(), + ExcludeSemantics(child: labelWidget()), ], ); } diff --git a/mobile/lib/features/profile/image_avatar_capture.dart b/mobile/lib/features/profile/image_avatar_capture.dart index 9ea01f448e3..fad5afef7a1 100644 --- a/mobile/lib/features/profile/image_avatar_capture.dart +++ b/mobile/lib/features/profile/image_avatar_capture.dart @@ -78,6 +78,7 @@ class ImageAvatarCapture extends HookConsumerWidget { final lifecycle = ref.watch(appLifecycleProvider); final controller = useState(null); final controllerRef = useRef(null); + final controllerDisposal = useRef>(Future.value()); final cameras = useState>(const []); final selectedLens = useState(CameraLensDirection.front); final cameraGeneration = useState(0); @@ -94,6 +95,21 @@ class ImageAvatarCapture extends HookConsumerWidget { final isClosing = useState(false); final error = useState(null); + Future releaseController(CameraController? active) { + if (active == null) return controllerDisposal.value; + final previousDisposal = controllerDisposal.value; + final release = () async { + try { + await previousDisposal; + } catch (_) { + // A failed release must not prevent a replacement camera session. + } + await active.dispose(); + }(); + controllerDisposal.value = release; + return release; + } + useEffect(() { WidgetsBinding.instance.addPostFrameCallback((_) { if (!context.mounted) return; @@ -106,7 +122,7 @@ class ImageAvatarCapture extends HookConsumerWidget { () => () { final active = controllerRef.value; controllerRef.value = null; - unawaited(active?.dispose() ?? Future.value()); + unawaited(releaseController(active)); }, const [], ); @@ -121,7 +137,7 @@ class ImageAvatarCapture extends HookConsumerWidget { final active = controllerRef.value; controllerRef.value = null; controller.value = null; - unawaited(active?.dispose() ?? Future.value()); + unawaited(releaseController(active)); return null; } @@ -132,6 +148,8 @@ class ImageAvatarCapture extends HookConsumerWidget { CameraController? next; var installed = false; try { + await controllerDisposal.value; + if (disposed || generation != cameraGeneration.value) return; final available = await loadCameras(); if (disposed || generation != cameraGeneration.value) return; cameras.value = available; @@ -162,7 +180,7 @@ class ImageAvatarCapture extends HookConsumerWidget { controller.value = next; installed = true; if (previous != null && previous != next) { - unawaited(previous.dispose()); + unawaited(releaseController(previous)); } } catch (_) { if (!installed) await next?.dispose(); @@ -217,7 +235,12 @@ class ImageAvatarCapture extends HookConsumerWidget { try { await active.resumePreview(); } on CameraException { - // Reinitialization remains available if this backend cannot resume. + if (identical(controllerRef.value, active)) { + controllerRef.value = null; + controller.value = null; + await releaseController(active); + if (context.mounted) cameraGeneration.value++; + } } } } finally { @@ -267,8 +290,8 @@ class ImageAvatarCapture extends HookConsumerWidget { if (!reduceMotion) await Future.delayed(_cameraFlipHalfDuration); if (!context.mounted) return; await active.setDescription(matches.first); - await active.lockCaptureOrientation(DeviceOrientation.portraitUp); if (context.mounted) selectedLens.value = nextLens; + await active.lockCaptureOrientation(DeviceOrientation.portraitUp); } on CameraException { if (context.mounted) error.value = 'Could not switch cameras.'; } finally { @@ -321,8 +344,15 @@ class ImageAvatarCapture extends HookConsumerWidget { !isFlipping.value && !isCapturing.value && !isClosing.value; + final hasOppositeLens = cameras.value.any( + (camera) => + camera.lensDirection == + (selectedLens.value == CameraLensDirection.front + ? CameraLensDirection.back + : CameraLensDirection.front), + ); final flipEnabled = - cameras.value.length > 1 && + hasOppositeLens && !isInitializing.value && !isFlipping.value && !isCapturing.value && @@ -758,68 +788,67 @@ class _ShutterButton extends StatelessWidget { @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: SizedBox( - key: const ValueKey('image-camera-shutter-morph'), - width: _shutterSize, - height: _shutterSize, - child: defaultTargetPlatform == TargetPlatform.iOS - ? 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, - ), - ), - ), - ), - ), - ), - ), - ), - ), + child: ExcludeSemantics(child: content), ); } } diff --git a/mobile/lib/shared/widgets/ios_glass_navigation_button.dart b/mobile/lib/shared/widgets/ios_glass_navigation_button.dart index 7fd898f5ab2..802b7c4f05d 100644 --- a/mobile/lib/shared/widgets/ios_glass_navigation_button.dart +++ b/mobile/lib/shared/widgets/ios_glass_navigation_button.dart @@ -55,18 +55,43 @@ class IosGlassNavigationButton extends HookWidget { 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 diff --git a/mobile/lib/shared/widgets/playing_avatar_image.dart b/mobile/lib/shared/widgets/playing_avatar_image.dart index 13ae3600101..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'; @@ -33,7 +34,7 @@ class PlayingAvatarImage extends StatelessWidget { /// A local poster shown when motion is disabled while remote media loads. final ImageProvider? loadingPosterImage; - /// Called after the persisted animation produces its first frame. + /// Called after persisted avatar media produces its first frame. final VoidCallback? onAnimationReady; /// Content shown while media is unavailable or still loading. @@ -51,10 +52,31 @@ class PlayingAvatarImage extends StatelessWidget { child: ClipOval( child: SizedBox.square( dimension: radius * 2, - child: Image( - image: loadingPosterImage!, - fit: BoxFit.cover, - gaplessPlayback: true, + 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; + }, + ), + ), + ], ), ), ), From f7cb63cac4f9d0be9a08b5dc965758d6b98147e7 Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:11:07 -0400 Subject: [PATCH 57/67] perf(desktop): persist channel heads, collapse thread reads and reply sends (#6572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands the build-now items from the desktop latency plan (#ui-performance-deep-dive) as one change. Every perceived-latency hot path a user hits on launch, channel open, thread open, and reply send drops one or more round trips. **A1 — persisted channel heads (the big one).** Native WAL SQLite cache (`desktop/src-tauri/src/channel_head_cache.rs`) keyed by `{pubkey, relayUrl}` scope, 32 rows/scope LRU, 1 MiB per-row drop cap, schema-version reset, corrupt-row tolerance, checkpointed on shutdown. Three blocking-pool commands: `channel_head_cache_load` / `_store` / `_clear`. On the renderer side, `CommunityQueryProvider` kicks off hydration of up to 12 heads when it constructs the query client — the app, splash and relay preconnect mount immediately; only `useChannelMessagesQuery` awaits the seed (`channelHeadHydration`), then consumes a one-shot hydrated gate so a hydrated channel pays **zero** `get_channel_window` calls on mount and exactly **one** on the post-subscription refresh, whose response replaces page zero wholesale. That refresh fires whether live-subscription setup succeeds or fails, and is sequenced behind hydration so it is always a distinct authoritative fetch (see Review follow-ups). Bounds-only persisted heads (zero rows) are not hydrated and take the cold loading path. The timeline loading latch recognizes native-hydrated rows as restart-safe so they paint immediately instead of holding a skeleton. The cache is a paint accelerator only — the relay response is always authoritative. Replaces the legacy localStorage `messageSnapshot.ts` (removed, -401 lines). Kill switch: `VITE_BUZZ_CHANNEL_HEAD_CACHE=off` at build time or `localStorage["buzz-channel-head-cache"] = "off"` at runtime. Cache is cleared on community removal and scoped per identity, so a replaced signer never sees the previous identity's rows. **B1 — thread aux in one response.** Relay thread filters accept `include_aux`; the bridge appends the same authorized two-hop reactions/edits/deletions closure a channel window gets (`build_aux_query` shared with the window path). Renderer `useThreadReplies` drops its two follow-up aux fetches. `next_cursor` is computed from reply-kind rows only since aux rows are unpaged. Documented in `docs/bridge-channel-window.md`. Thread queries keep `staleTime: 0` (`bcfe04e2f`): an earlier revision raised it to 30s, which CI's `thread-unread.spec.ts` caught — once the user leaves a channel, the live subscription stops feeding that thread's cache, so a reopen must always take the (now single) authoritative read. **B2 — cached root on reply send.** `send_channel_message` gains `root_event_id`; when the renderer already holds the parent (channel or thread cache) it passes the NIP-10 root, and native signs without the relay round trip that `resolve_thread_ref` used to make. Strict hex parse; `root_event_id` requires `parent_event_id`; absent root falls back to the existing relay resolution. The renderer never sends a guessed root. **B4** general HTTP pool idle 10s→300s, max idle per host 1→2. **B5** relay preconnect fires as soon as identity is ready instead of waiting for `requestIdleCallback`. One e2e test (`relay-reconnect.spec.ts` "service restart close resets accumulated backoff") had been relying on the idle-callback batching to skip past its own seeded dial failures before the channel list painted; `8133d70bb` makes it wait for the connected state instead (test-only, still fails with the 1012 backoff reset disabled). **B6** profile freshness 60s→10 min (both the in-memory entry check and the query `staleTime`). Tradeoff: another user's display-name/avatar edit can take up to 10 min to propagate to a client that already holds their profile (relay reconnect refetches `users-batch` but resolves from the still-fresh per-pubkey entry); your own edits still evict the entry immediately (`evictUsersBatchEntries` in `useUpdateProfileMutation`). Follows #6456/#6457/#6459/#6460 (already merged). #6455 is the measurement instrument and is intentionally not folded in. No duplicate PR found. Addressing Carl's reviews [5001114109](https://github.com/block/buzz/pull/6572#pullrequestreview-5001114109) and [5002596542](https://github.com/block/buzz/pull/6572#pullrequestreview-5002596542), each pushed as new commits (no rebase): - `4f06b7770` fix(desktop): mount app while channel heads hydrate; always revalidate — provider no longer gates children on the cache load; `refreshAfterSubscribe` runs on subscribe failure too; bounds-only heads skipped at seed; seed merges into an existing window store. +3 tests. - `35834cb31` fix(relay): drain aux closure hops across the page clamp — `query_all_pages` walks the `(created_at, id)` keyset via `until`/`before_id` until a short page (`AUX_PAGE_LIMIT` = `DEFAULT_MAX_PAGE_LIMIT`, `AUX_MAX_PAGES` = 64 warn+truncate) so one-shot `limit: 1000` newest-first no longer drops the oldest edits/deletions. +3 tests; `docs/bridge-channel-window.md` updated. - `db21b0531` merge of `origin/main` `e23632941` (#6558, #6312 — no overlap). - `5a5566c0f` fix(desktop): sequence post-subscribe refresh behind channel head hydration — `refreshChannelWindowMessages` awaits `channelHeadHydration()` and, for a hydration-seeded query (`data !== undefined && dataUpdatedAt === 0`), the in-flight snapshot fetch before invalidating. Without this, a subscription that settles before the SQLite load invalidated a data-less in-flight query; TanStack dedupes that onto the existing fetch (`query-core` `fetch()` only cancels when `state.data` exists), which returned the seeded snapshot — 0 authoritative fetches. Regression test reproduces Carl's exact ordering (fails at `35834cb31` with 0 calls), plus a cold-channel guard that the fix does not double-fetch. - `b129231c8` fix(desktop): let concurrent post-hydration refreshes share one window fetch — found independently by Max and Wren reviewing `5a5566c0f`: subscribe settlement + reconnect both wake on the same snapshot promise and both invalidate; the second (default `cancelRefetch: true`) cancelled and replaced the first authoritative fetch (3 queryFn calls, not 2, and the cancelled Tauri invoke still hits the relay). The seeded branch now invalidates with `cancelRefetch: false` so a second waker joins the in-flight fetch; cold/warm keep the default (`test_canceled_stale_fetch_cannot_overwrite_catch_up_window` relies on it). Concurrent regression test fails at `5a5566c0f` with 3. At `b129231c8` (PR head; verified in one shell with `git rev-parse HEAD` = `b129231c8`): `pnpm check`, `tsc --noEmit`, desktop unit 5,393 / 0, Playwright `boot-splash` + `channel-head-restart` + `relay-reconnect` + `relay-reconnect-affordance` + `thread-unread` 34 / 34 on a fresh `build:e2e`, pre-push hooks green. At `5a5566c0f`: `pnpm check`, `tsc --noEmit`, desktop unit 5,392 / 0, Playwright `boot-splash` + `channel-head-restart` + `relay-reconnect` + `relay-reconnect-affordance` + `thread-unread` 34 / 34 on a fresh `build:e2e`, pre-push hooks green. At `35834cb31`: desktop unit 5,390 / 0; `cargo test -p buzz-relay --lib` 910 / 0; fmt + clippy `-D warnings` clean; Playwright 32 / 32 (same specs minus affordance); GitHub CI green on every job except Smoke (3) (unrelated project-review row-count + messaging timing flake, per Carl) and Unit Tests (sherpa cache skeleton, below). Earlier, all at `8133d70bb` (this PR head is `0c492366d` = 8133d70bb + a comments-only commit correcting two `profile/hooks.ts` freshness comments from 60s to 10 min; pre-push desktop check/typecheck/test 5,387/0 re-ran at 0c492366d) in one shell; `origin/main` = `040b203f7` at PR open, since moved to `4baccd539` (#6558, mobile only — zero file overlap, `git merge-tree` clean): - `just desktop-test` — 5,387 passed / 0 failed (includes new hook-level call-count test: cold = 1, stale-prefetched = 1, hydrated = 0 on mount then 1 on invalidate with wholesale replacement) - Playwright smoke `relay-reconnect.spec.ts` + `thread-unread.spec.ts` + `channel-head-restart.spec.ts` — 30/30 (thread-unread was 8/13 at `7acbf951b`; relay-reconnect was 15/16 at `bcfe04e2f`). The restart spec persists a head, reloads into a fresh mock relay with the head fetch held 5s, asserts the persisted row paints within 2s, exactly one `get_channel_window` after open, and the stale row is removed when the authoritative page lands. - `pnpm typecheck`, `pnpm check` — clean At `7acbf951b` (everything except the two-line `useThreadReplies.ts` staleTime revert and the test-only `relay-reconnect.spec.ts` change), also green in one shell: - `just desktop-tauri-test` — 2,859 passed / 0 failed across the workspace (channel_head_cache: wire shape, LRU+caps, schema reset, corrupt-row skip) - `just test-unit` — 632 passed (buzz-core/auth); `cargo test -p buzz-relay --lib` — 908 passed / 0 failed - `just check` components: fmt-check, clippy, desktop-check, desktop-typecheck, desktop-tauri-fmt-check, desktop-tauri-clippy, web-check, mobile-check, file-size-check — all green - `just desktop-build`, `web-build`, `desktop-tauri-check`, `mobile-test` (1,661 passed) — all green CI note: the "Unit Tests" job goes red on this PR and on `main` whenever it hits a poisoned `rust-cache` entry (an empty-directory skeleton of `target/sherpa-onnx-prebuilt` that `sherpa-onnx-sys` build.rs trusts), surfacing as `could not find native static library sherpa-onnx-c-api` in `buzz-voice` — a crate this PR doesn't touch. Deleting the cache entry and rerunning turned the job green at `0c492366d` (28/28); it re-poisons on the next `main` push until the workflow clears that directory after cache restore. Reviewed in-channel by Wren (9 / 9 / 9.5) and Eva (9 / 9 / 9), and line-by-line by me before opening; the staleTime fix re-verified by Wren and me independently; the relay-reconnect test fix bisected and verified by me. --------- Signed-off-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz> Signed-off-by: Max Signed-off-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> Co-authored-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz> Co-authored-by: Max Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> --- .../src-tauri/src/commands/messages_tests.rs | 40 ------------------- 1 file changed, 40 deletions(-) diff --git a/desktop/src-tauri/src/commands/messages_tests.rs b/desktop/src-tauri/src/commands/messages_tests.rs index 627e6326432..dc7c0f4b5a2 100644 --- a/desktop/src-tauri/src/commands/messages_tests.rs +++ b/desktop/src-tauri/src/commands/messages_tests.rs @@ -236,43 +236,3 @@ fn provided_thread_ref_validates_and_preserves_root_and_parent() { assert_eq!(thread_ref.parent_event_id.to_hex(), parent); assert!(thread_ref::provided_thread_ref("not-hex", &parent).is_err()); } - -/// `FeedItem.category` is a wire contract with the desktop frontend -/// (`desktop/src/shared/api/types.ts`). The frontend routes notification -/// sounds, titles, mute-bypass, and inbox labels off these exact strings, so -/// the serialized form must stay singular `mention` — not the plural section -/// name `mentions` used by `FeedSections` and the `--types` filter. -#[test] -fn feed_item_category_serializes_to_frontend_contract() { - let cases = [ - (FeedItemCategory::Mention, "mention"), - (FeedItemCategory::NeedsAction, "needs_action"), - (FeedItemCategory::Activity, "activity"), - (FeedItemCategory::AgentActivity, "agent_activity"), - ]; - for (category, expected) in cases { - let value = serde_json::to_value(category).expect("category should serialize"); - assert_eq!(value, serde_json::Value::String(expected.to_string())); - } -} - -#[test] -fn feed_item_from_event_carries_singular_mention_category() { - let pubkey = Keys::generate().public_key().to_hex(); - let event = build_managed_agent_channel_message( - uuid::Uuid::new_v4(), - "hey @you", - None, - std::slice::from_ref(&pubkey), - &[], - ) - .expect("message should build") - .sign_with_keys(&Keys::generate()) - .expect("message should sign"); - - let item = feed_item_from_event(&event, FeedItemCategory::Mention); - let json = serde_json::to_value(&item).expect("feed item should serialize"); - - assert_eq!(json["category"], "mention"); - assert_eq!(json["id"], event.id.to_hex()); -} From b8d64a4ff3c991daa5558c5f8dad557767d2195e Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:20:53 -0700 Subject: [PATCH 58/67] fix(desktop): emit singular `mention` feed category so alerts route correctly (#6665) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Mentions — and thread replies that @-mention you — played the **Needs action** sound instead of the **@Mentions** sound. Reported by @morgmart: "I set Needs action to a different sound and it's the only one I ever hear." ## Root cause Two vocabularies got conflated in #475: - **Section / filter vocabulary** (plural): `mentions`, `needs_action`, `activity`, `agent_activity` — the shape of `FeedSections`, the `--types` filter, and the agent-facing CLI docs. - **Per-item category vocabulary** (singular for mention): `mention`, `needs_action`, `activity`, `agent_activity` — the `FeedItemCategory` contract in `desktop/src/shared/api/types.ts`, unchanged since #12. The Tauri feed builder reused the filter string `"mentions"` as each mention item's `category`. Only one word differs between the vocabularies, so only mentions broke. Every frontend consumer compares against the singular, so real mentions never matched and fell through to the resolver's `needs_action` fallback. The E2E mock bridge emits the singular form, so tests never saw the drift. ### Symptoms this fixes (all from the one mislabel) - Mentions and mentioning thread replies played the Needs-action sound - Mention notifications used the Needs-action title format - Mentions in muted channels were suppressed (the mute-bypass never fired) - Inbox / Home feed labelled mentions "Channel update" - Channel activity popover's mentions list was always empty ## Fix **Fix the owner, not the symptoms.** `FeedItemInfo.category` becomes a `FeedItemCategory` enum whose serde form is exactly the TS union, so a misspelled category can't compile at the producer. A serialization test pins each variant to its wire string. **Frontend:** `slotForFeedKind` maps every known category explicitly. The `needs_action` fallback for unknown categories is **kept on purpose** — a contract drift should cost the user the wrong sound, not a missed alert — but it now `console.warn`s so the drift is visible to developers instead of masquerading as intended behavior. `e2eBridge.ts` and `tauri.ts` now derive the category type from `types.ts` instead of retyping it. Not touched: the plural `--types` filter and `FeedSections` keys. Those are the section vocabulary and are correct as-is. ## Verification - `just ci` green (file-size ratchet, Rust/Tauri/desktop/mobile tests, desktop + web builds) - New tests: 2 Rust (`feed_item_category_serializes_to_frontend_contract`, `feed_item_from_event_carries_singular_mention_category`), 3 TS in `sound.test.mjs` incl. one that feeds the old `"mentions"` string and asserts fallback + warning - **Runtime, dev build against the production relay:** controlled test from an agent identity into a test channel — - mention in channel → @Mentions sound, inbox shows "Mentioned in" ✅ (was Needs-action) - thread reply with mention → @Mentions sound, once ✅ (was Needs-action) - plain thread reply in the channel being viewed → silent, as designed ✅ ## Reviewers - @tlongwell-block — #475 introduced the plural category; please confirm it wasn't intentional - @wesbillman — owner of the original `FeedItemCategory` contract (#12) and most of the feed builder - @taylorkmho — owner of the sound-slot model and resolver (#968); the fallback-with-warning shape is the part to weigh in on - cc @klopez4212 --------- Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> Co-authored-by: Claude --- .../src-tauri/src/commands/messages_tests.rs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/desktop/src-tauri/src/commands/messages_tests.rs b/desktop/src-tauri/src/commands/messages_tests.rs index dc7c0f4b5a2..627e6326432 100644 --- a/desktop/src-tauri/src/commands/messages_tests.rs +++ b/desktop/src-tauri/src/commands/messages_tests.rs @@ -236,3 +236,43 @@ fn provided_thread_ref_validates_and_preserves_root_and_parent() { assert_eq!(thread_ref.parent_event_id.to_hex(), parent); assert!(thread_ref::provided_thread_ref("not-hex", &parent).is_err()); } + +/// `FeedItem.category` is a wire contract with the desktop frontend +/// (`desktop/src/shared/api/types.ts`). The frontend routes notification +/// sounds, titles, mute-bypass, and inbox labels off these exact strings, so +/// the serialized form must stay singular `mention` — not the plural section +/// name `mentions` used by `FeedSections` and the `--types` filter. +#[test] +fn feed_item_category_serializes_to_frontend_contract() { + let cases = [ + (FeedItemCategory::Mention, "mention"), + (FeedItemCategory::NeedsAction, "needs_action"), + (FeedItemCategory::Activity, "activity"), + (FeedItemCategory::AgentActivity, "agent_activity"), + ]; + for (category, expected) in cases { + let value = serde_json::to_value(category).expect("category should serialize"); + assert_eq!(value, serde_json::Value::String(expected.to_string())); + } +} + +#[test] +fn feed_item_from_event_carries_singular_mention_category() { + let pubkey = Keys::generate().public_key().to_hex(); + let event = build_managed_agent_channel_message( + uuid::Uuid::new_v4(), + "hey @you", + None, + std::slice::from_ref(&pubkey), + &[], + ) + .expect("message should build") + .sign_with_keys(&Keys::generate()) + .expect("message should sign"); + + let item = feed_item_from_event(&event, FeedItemCategory::Mention); + let json = serde_json::to_value(&item).expect("feed item should serialize"); + + assert_eq!(json["category"], "mention"); + assert_eq!(json["id"], event.id.to_hex()); +} From dd9b23225bc06af1954ba446c415e0705c0f94a9 Mon Sep 17 00:00:00 2001 From: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> Date: Mon, 24 Aug 2026 20:29:24 +0100 Subject: [PATCH 59/67] fix(mobile): recover camera after disposal failure Signed-off-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> Co-authored-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> Signed-off-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> --- .../profile/animated_avatar_capture.dart | 17 ++++-- .../profile/camera_disposal_barrier.dart | 31 ++++++++++ .../profile/image_avatar_capture.dart | 21 ++----- .../profile/camera_disposal_barrier_test.dart | 58 +++++++++++++++++++ 4 files changed, 108 insertions(+), 19 deletions(-) create mode 100644 mobile/lib/features/profile/camera_disposal_barrier.dart create mode 100644 mobile/test/features/profile/camera_disposal_barrier_test.dart diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart index 23b66554b33..abc2380c940 100644 --- a/mobile/lib/features/profile/animated_avatar_capture.dart +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -20,6 +20,7 @@ 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'; @@ -58,6 +59,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final controller = useState(null); final controllerRef = useRef(null); + final controllerDisposal = useRef(CameraDisposalBarrier()); final captureEpoch = useRef(0); final cameraGeneration = useState(0); final isInitializing = useState(true); @@ -129,10 +131,17 @@ 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; var installed = false; try { + await controllerDisposal.value.settled; + if (disposed) return; final cameras = await availableCameras(); if (disposed || cameras.isEmpty) return; final selected = cameras.firstWhere( @@ -150,14 +159,14 @@ class AnimatedAvatarCapture extends HookConsumerWidget { await next.initialize(); await next.lockCaptureOrientation(DeviceOrientation.portraitUp); if (disposed) { - await next.dispose(); + await releaseController(next); return; } controllerRef.value = next; controller.value = next; installed = true; } catch (_) { - if (!installed) await next?.dispose(); + if (!installed && next != null) await releaseController(next); if (!disposed) error.value = 'Could not access the camera.'; } finally { if (!disposed) isInitializing.value = false; @@ -170,7 +179,7 @@ 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]); @@ -247,7 +256,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), (_) { 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..6ee49290b2d --- /dev/null +++ b/mobile/lib/features/profile/camera_disposal_barrier.dart @@ -0,0 +1,31 @@ +import 'package:flutter/foundation.dart'; + +/// 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. +@visibleForTesting +class CameraDisposalBarrier { + Future _pending = Future.value(); + + /// 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; +} diff --git a/mobile/lib/features/profile/image_avatar_capture.dart b/mobile/lib/features/profile/image_avatar_capture.dart index fad5afef7a1..9c370816108 100644 --- a/mobile/lib/features/profile/image_avatar_capture.dart +++ b/mobile/lib/features/profile/image_avatar_capture.dart @@ -15,6 +15,7 @@ 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'; const _avatarPreviewSize = 220.0; @@ -78,7 +79,7 @@ class ImageAvatarCapture extends HookConsumerWidget { final lifecycle = ref.watch(appLifecycleProvider); final controller = useState(null); final controllerRef = useRef(null); - final controllerDisposal = useRef>(Future.value()); + final controllerDisposal = useRef(CameraDisposalBarrier()); final cameras = useState>(const []); final selectedLens = useState(CameraLensDirection.front); final cameraGeneration = useState(0); @@ -96,18 +97,8 @@ class ImageAvatarCapture extends HookConsumerWidget { final error = useState(null); Future releaseController(CameraController? active) { - if (active == null) return controllerDisposal.value; - final previousDisposal = controllerDisposal.value; - final release = () async { - try { - await previousDisposal; - } catch (_) { - // A failed release must not prevent a replacement camera session. - } - await active.dispose(); - }(); - controllerDisposal.value = release; - return release; + if (active == null) return controllerDisposal.value.settled; + return controllerDisposal.value.release(active.dispose); } useEffect(() { @@ -148,7 +139,7 @@ class ImageAvatarCapture extends HookConsumerWidget { CameraController? next; var installed = false; try { - await controllerDisposal.value; + await controllerDisposal.value.settled; if (disposed || generation != cameraGeneration.value) return; final available = await loadCameras(); if (disposed || generation != cameraGeneration.value) return; @@ -183,7 +174,7 @@ class ImageAvatarCapture extends HookConsumerWidget { unawaited(releaseController(previous)); } } catch (_) { - if (!installed) await next?.dispose(); + if (!installed && next != null) await releaseController(next); if (!disposed && generation == cameraGeneration.value) { final active = controllerRef.value; if (active != null) { 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); + }, + ); +} From 76643f129424d34cb2d87416920a42c0dfbb76d2 Mon Sep 17 00:00:00 2001 From: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> Date: Mon, 24 Aug 2026 20:31:22 +0100 Subject: [PATCH 60/67] fix(mobile): remove obsolete animated error part Signed-off-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> Co-authored-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> Signed-off-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> --- .../animated_avatar_capture/error_text.dart | 22 ------------------- 1 file changed, 22 deletions(-) delete mode 100644 mobile/lib/features/profile/animated_avatar_capture/error_text.dart diff --git a/mobile/lib/features/profile/animated_avatar_capture/error_text.dart b/mobile/lib/features/profile/animated_avatar_capture/error_text.dart deleted file mode 100644 index 022a730009c..00000000000 --- a/mobile/lib/features/profile/animated_avatar_capture/error_text.dart +++ /dev/null @@ -1,22 +0,0 @@ -part of '../animated_avatar_capture.dart'; - -class _ErrorText extends StatelessWidget { - const _ErrorText(this.message); - - final String message; - - @override - Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.only(top: Grid.xs), - child: Semantics( - liveRegion: true, - child: Text( - message, - textAlign: TextAlign.center, - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.error, - ), - ), - ), - ); -} From f704ca8447f003b1709b211df4d71a2cfb4474f2 Mon Sep 17 00:00:00 2001 From: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> Date: Tue, 25 Aug 2026 07:32:39 +0100 Subject: [PATCH 61/67] test(mobile): cover profile camera recovery Co-authored-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> Signed-off-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> --- mobile/pubspec.lock | 2 +- mobile/pubspec.yaml | 1 + .../profile/animated_avatar_capture_test.dart | 126 ++++++++++++- .../image_avatar_capture_lifecycle_test.dart | 171 ++++++++++++++++++ 4 files changed, 297 insertions(+), 3 deletions(-) create mode 100644 mobile/test/features/profile/image_avatar_capture_lifecycle_test.dart 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 51373396870..b91fb18ee11 100644 --- a/mobile/test/features/profile/animated_avatar_capture_test.dart +++ b/mobile/test/features/profile/animated_avatar_capture_test.dart @@ -1,13 +1,15 @@ import 'dart:io'; -import 'dart:typed_data'; +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/profile_avatar_draft.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.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'; import 'package:image/image.dart' as image; @@ -65,6 +67,43 @@ void main() { ); }); + 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; + }); + + await tester.pumpWidget( + ProviderScope( + 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]); + expect(platform.disposedCameraIds, [1]); + expect(find.text('Could not access the camera.'), findsOneWidget); + + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + }); + testWidgets('completed review frames survive lifecycle changes', ( tester, ) async { @@ -266,6 +305,89 @@ double _greenCenterX(Uint8List bytes) { return matchingX.reduce((left, right) => left + right) / matchingX.length; } +class _TestCameraPlatform extends CameraPlatform { + _TestCameraPlatform({Set? failLockForCameraIds}) + : _failLockForCameraIds = failLockForCameraIds ?? const {}; + + final Set _failLockForCameraIds; + 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 { + _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 + Future dispose(int cameraId) async { + disposedCameraIds.add(cameraId); + } +} + class _TestLifecycleNotifier extends AppLifecycleNotifier { AppLifecycleState _lifecycle = AppLifecycleState.resumed; 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..58080b9f54f --- /dev/null +++ b/mobile/test/features/profile/image_avatar_capture_lifecycle_test.dart @@ -0,0 +1,171 @@ +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(); + }); +} + +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}) + : _blockDisposeForCameraIds = blockDisposeForCameraIds ?? const {}; + + final Set _blockDisposeForCameraIds; + final _disposeCompleters = >{}; + 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 { + _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 failDispose(int cameraId) { + _disposeCompleters[cameraId]!.completeError( + PlatformException(code: 'dispose-failed'), + ); + } +} From a0dd6c367a62e348fca5a46df1ea500e03c01df2 Mon Sep 17 00:00:00 2001 From: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> Date: Tue, 25 Aug 2026 08:24:54 +0100 Subject: [PATCH 62/67] test(mobile): cover animated camera lifecycle recovery Signed-off-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> Co-authored-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4> --- .../profile/animated_avatar_capture_test.dart | 74 ++++++++++++++++++- 1 file changed, 72 insertions(+), 2 deletions(-) diff --git a/mobile/test/features/profile/animated_avatar_capture_test.dart b/mobile/test/features/profile/animated_avatar_capture_test.dart index b91fb18ee11..7318120225f 100644 --- a/mobile/test/features/profile/animated_avatar_capture_test.dart +++ b/mobile/test/features/profile/animated_avatar_capture_test.dart @@ -104,6 +104,59 @@ void main() { 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('completed review frames survive lifecycle changes', ( tester, ) async { @@ -306,10 +359,15 @@ double _greenCenterX(Uint8List bytes) { } class _TestCameraPlatform extends CameraPlatform { - _TestCameraPlatform({Set? failLockForCameraIds}) - : _failLockForCameraIds = failLockForCameraIds ?? const {}; + _TestCameraPlatform({ + Set? failLockForCameraIds, + Set? blockDisposeForCameraIds, + }) : _failLockForCameraIds = failLockForCameraIds ?? const {}, + _blockDisposeForCameraIds = blockDisposeForCameraIds ?? const {}; final Set _failLockForCameraIds; + final Set _blockDisposeForCameraIds; + final _disposeCompleters = >{}; final _initializedControllers = >{}; final _errorControllers = >{}; @@ -382,9 +440,21 @@ class _TestCameraPlatform extends CameraPlatform { } } + @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 failDispose(int cameraId) { + _disposeCompleters[cameraId]!.completeError( + PlatformException(code: 'dispose-failed'), + ); } } From 6caf85b8d099c88d07ee7f4586f1ed08c9a1c693 Mon Sep 17 00:00:00 2001 From: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> Date: Tue, 25 Aug 2026 08:48:05 +0100 Subject: [PATCH 63/67] fix(mobile): clear stale gallery errors in camera Co-authored-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> Signed-off-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> --- .../profile/profile_avatar_editor.dart | 1 + .../profile/profile_edit_page_test.dart | 22 +++++---- .../image_selection_tests.dart | 48 +++++++++++++++++++ 3 files changed, 63 insertions(+), 8 deletions(-) diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart index 0d030994e5c..7fd64387dd8 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -385,6 +385,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { height: modeHeight, isPicking: isPickingImage.value, onCamera: () { + error.value = null; isCapturingImage.value = true; onImageCameraActiveChanged(true); }, diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index e1bf738e5ae..4e9d724cb79 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -979,16 +979,19 @@ class _FakeProfileNotifier extends ProfileNotifier { } 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; @@ -1014,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( 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 59573870652..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 @@ -378,6 +378,54 @@ void runProfileEditImageSelectionTests() { 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 { From b597a936df8b380f984b26f10c0b6868386c27fe Mon Sep 17 00:00:00 2001 From: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> Date: Tue, 25 Aug 2026 10:16:41 +0100 Subject: [PATCH 64/67] fix(mobile): serialize profile camera ownership Signed-off-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> Co-authored-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> --- .../profile/animated_avatar_capture.dart | 55 +++++++------- .../animated_avatar_capture/error_text.dart | 29 ++++++++ .../profile/camera_disposal_barrier.dart | 44 +++++++++++- .../profile/image_avatar_capture.dart | 31 +++++++- .../profile/profile_avatar_editor.dart | 17 +++-- .../features/profile/profile_edit_page.dart | 2 + .../features/profile/profile_provider.dart | 3 + .../profile/animated_avatar_capture_test.dart | 72 +++++++++++++++++++ 8 files changed, 217 insertions(+), 36 deletions(-) create mode 100644 mobile/lib/features/profile/animated_avatar_capture/error_text.dart diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart index abc2380c940..f02852e665e 100644 --- a/mobile/lib/features/profile/animated_avatar_capture.dart +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -28,6 +28,7 @@ import 'profile_avatar_draft.dart'; part 'animated_avatar_capture/review_controls.dart'; part 'animated_avatar_capture/capture_controls.dart'; part 'animated_avatar_capture/frame_processing.dart'; +part 'animated_avatar_capture/error_text.dart'; const _captureDuration = Duration(seconds: 3); const _captureFrameInterval = Duration(milliseconds: 125); @@ -44,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. @@ -55,11 +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(CameraDisposalBarrier()); + final controllerDisposal = useRef( + disposalBarrier ?? CameraDisposalBarrier(), + ); + final candidateRef = useRef(null); final captureEpoch = useRef(0); final cameraGeneration = useState(0); final isInitializing = useState(true); @@ -138,6 +147,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { Future initialize() async { CameraController? next; + CameraDisposalReservation? reservation; var installed = false; try { await controllerDisposal.value.settled; @@ -156,17 +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 releaseController(next); + 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) await releaseController(next); + 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; @@ -654,29 +680,6 @@ List _resampleCapturedFrames( }, growable: false); } -class _ErrorText extends StatelessWidget { - const _ErrorText(this.message); - - final String message; - - @override - Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.only(top: Grid.xs), - child: Text( - message, - textAlign: TextAlign.center, - style: context.textTheme.bodySmall?.copyWith(color: context.colors.error), - ), - ); -} - -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); diff --git a/mobile/lib/features/profile/animated_avatar_capture/error_text.dart b/mobile/lib/features/profile/animated_avatar_capture/error_text.dart new file mode 100644 index 00000000000..88086bfc163 --- /dev/null +++ b/mobile/lib/features/profile/animated_avatar_capture/error_text.dart @@ -0,0 +1,29 @@ +part of '../animated_avatar_capture.dart'; + +class _ErrorText extends StatelessWidget { + const _ErrorText(this.message); + + final String message; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(top: Grid.xs), + child: Semantics( + liveRegion: true, + child: Text( + message, + textAlign: TextAlign.center, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), + ), + ); +} + +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/camera_disposal_barrier.dart b/mobile/lib/features/profile/camera_disposal_barrier.dart index 6ee49290b2d..238d65267df 100644 --- a/mobile/lib/features/profile/camera_disposal_barrier.dart +++ b/mobile/lib/features/profile/camera_disposal_barrier.dart @@ -1,13 +1,19 @@ -import 'package:flutter/foundation.dart'; +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. -@visibleForTesting 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 @@ -29,3 +35,37 @@ class CameraDisposalBarrier { /// 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 index 9c370816108..242e875d8c4 100644 --- a/mobile/lib/features/profile/image_avatar_capture.dart +++ b/mobile/lib/features/profile/image_avatar_capture.dart @@ -51,6 +51,7 @@ class ImageAvatarCapture extends HookConsumerWidget { this.initialPreview, this.initialCapturedBytes, this.loadCameras = availableCameras, + this.disposalBarrier, }); /// The vertical space available to the camera and its controls. @@ -73,13 +74,19 @@ class ImageAvatarCapture extends HookConsumerWidget { @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(CameraDisposalBarrier()); + final controllerDisposal = useRef( + disposalBarrier ?? CameraDisposalBarrier(), + ); + final candidateRef = useRef(null); final cameras = useState>(const []); final selectedLens = useState(CameraLensDirection.front); final cameraGeneration = useState(0); @@ -137,6 +144,7 @@ class ImageAvatarCapture extends HookConsumerWidget { Future initialize() async { CameraController? next; + CameraDisposalReservation? reservation; var installed = false; try { await controllerDisposal.value.settled; @@ -160,12 +168,23 @@ class ImageAvatarCapture extends HookConsumerWidget { 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) { - await next.dispose(); + 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; @@ -174,7 +193,13 @@ class ImageAvatarCapture extends HookConsumerWidget { unawaited(releaseController(previous)); } } catch (_) { - if (!installed && next != null) await releaseController(next); + 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) { diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart index 7fd64387dd8..ec55df81736 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -22,6 +22,7 @@ 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'; @@ -139,6 +140,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { 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); @@ -378,6 +380,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { initialPreview: fixedPreview, onAccepted: acceptCameraImage, onClosed: closeImageCamera, + disposalBarrier: cameraDisposal.value, ), ), ProfileAvatarMode.image => _ImageMode( @@ -420,6 +423,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { AnimatedAvatarCapture( height: modeHeight, onPrepareChanged: onAnimatedPrepareChanged, + disposalBarrier: cameraDisposal.value, ), ), }; @@ -561,11 +565,14 @@ class ProfileAvatarEditor extends HookConsumerWidget { left: Grid.gutter, right: Grid.gutter, bottom: _editorControlsBottom + _editorRailHeight, - child: Text( - error.value!, - textAlign: TextAlign.center, - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.error, + child: Semantics( + liveRegion: true, + child: Text( + error.value!, + textAlign: TextAlign.center, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), ), ), ), diff --git a/mobile/lib/features/profile/profile_edit_page.dart b/mobile/lib/features/profile/profile_edit_page.dart index 40095e60984..3584808bee6 100644 --- a/mobile/lib/features/profile/profile_edit_page.dart +++ b/mobile/lib/features/profile/profile_edit_page.dart @@ -239,6 +239,8 @@ class ProfileEditPage extends HookConsumerWidget { poster: nextDraft.poster, ), ); + } else { + ref.read(profileAvatarHandoffProvider.notifier).clearAny(); } if (context.mounted) await closeAvatarEditor(whileSaving: true); } on ProfileCommunityChangedException { diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index a8becae7185..93621f7608a 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -54,6 +54,9 @@ class ProfileAvatarHandoffNotifier extends Notifier { 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. diff --git a/mobile/test/features/profile/animated_avatar_capture_test.dart b/mobile/test/features/profile/animated_avatar_capture_test.dart index 7318120225f..2e6249a5ed7 100644 --- a/mobile/test/features/profile/animated_avatar_capture_test.dart +++ b/mobile/test/features/profile/animated_avatar_capture_test.dart @@ -5,6 +5,8 @@ import 'dart:ui' show SemanticsAction; 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'; @@ -99,6 +101,10 @@ void main() { 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(); @@ -157,6 +163,68 @@ void main() { 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', ( tester, ) async { @@ -456,6 +524,10 @@ class _TestCameraPlatform extends CameraPlatform { PlatformException(code: 'dispose-failed'), ); } + + void completeDispose(int cameraId) { + _disposeCompleters[cameraId]!.complete(); + } } class _TestLifecycleNotifier extends AppLifecycleNotifier { From a33e1fac554374f4c10b0fb2ab10bbca29b49f42 Mon Sep 17 00:00:00 2001 From: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> Date: Tue, 25 Aug 2026 11:00:04 +0100 Subject: [PATCH 65/67] test(mobile): cover in-flight camera lifecycle ownership Signed-off-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> Co-authored-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> --- .../profile/animated_avatar_capture_test.dart | 64 ++++++++++++++++- .../image_avatar_capture_lifecycle_test.dart | 69 ++++++++++++++++++- 2 files changed, 130 insertions(+), 3 deletions(-) diff --git a/mobile/test/features/profile/animated_avatar_capture_test.dart b/mobile/test/features/profile/animated_avatar_capture_test.dart index 2e6249a5ed7..1ef97e65c02 100644 --- a/mobile/test/features/profile/animated_avatar_capture_test.dart +++ b/mobile/test/features/profile/animated_avatar_capture_test.dart @@ -163,6 +163,57 @@ void main() { await tester.pump(); }); + testWidgets('waits for an in-flight camera before lifecycle replacement', ( + tester, + ) async { + final platform = _TestCameraPlatform(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, 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 { @@ -430,12 +481,16 @@ class _TestCameraPlatform extends CameraPlatform { _TestCameraPlatform({ Set? failLockForCameraIds, Set? blockDisposeForCameraIds, + Set? blockInitializeForCameraIds, }) : _failLockForCameraIds = failLockForCameraIds ?? const {}, - _blockDisposeForCameraIds = blockDisposeForCameraIds ?? const {}; + _blockDisposeForCameraIds = blockDisposeForCameraIds ?? const {}, + _blockInitializeForCameraIds = blockInitializeForCameraIds ?? const {}; final Set _failLockForCameraIds; final Set _blockDisposeForCameraIds; + final Set _blockInitializeForCameraIds; final _disposeCompleters = >{}; + final _initializeCompleters = >{}; final _initializedControllers = >{}; final _errorControllers = >{}; @@ -485,6 +540,9 @@ class _TestCameraPlatform extends CameraPlatform { int cameraId, { ImageFormatGroup imageFormatGroup = ImageFormatGroup.unknown, }) async { + if (_blockInitializeForCameraIds.contains(cameraId)) { + await (_initializeCompleters[cameraId] ??= Completer()).future; + } _initializedControllers[cameraId]!.add( CameraInitializedEvent( cameraId, @@ -519,6 +577,10 @@ class _TestCameraPlatform extends CameraPlatform { } } + void completeInitialize(int cameraId) { + _initializeCompleters[cameraId]!.complete(); + } + void failDispose(int cameraId) { _disposeCompleters[cameraId]!.completeError( PlatformException(code: 'dispose-failed'), diff --git a/mobile/test/features/profile/image_avatar_capture_lifecycle_test.dart b/mobile/test/features/profile/image_avatar_capture_lifecycle_test.dart index 58080b9f54f..f80bd7d877e 100644 --- a/mobile/test/features/profile/image_avatar_capture_lifecycle_test.dart +++ b/mobile/test/features/profile/image_avatar_capture_lifecycle_test.dart @@ -64,6 +64,59 @@ void main() { await tester.pumpWidget(const SizedBox()); await tester.pump(); }); + + testWidgets('waits for an in-flight camera before lifecycle replacement', ( + tester, + ) async { + final platform = _TestCameraPlatform(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, 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 { @@ -79,11 +132,16 @@ class _TestLifecycleNotifier extends AppLifecycleNotifier { } class _TestCameraPlatform extends CameraPlatform { - _TestCameraPlatform({Set? blockDisposeForCameraIds}) - : _blockDisposeForCameraIds = blockDisposeForCameraIds ?? const {}; + _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 = >{}; @@ -133,6 +191,9 @@ class _TestCameraPlatform extends CameraPlatform { int cameraId, { ImageFormatGroup imageFormatGroup = ImageFormatGroup.unknown, }) async { + if (_blockInitializeForCameraIds.contains(cameraId)) { + await (_initializeCompleters[cameraId] ??= Completer()).future; + } _initializedControllers[cameraId]!.add( CameraInitializedEvent( cameraId, @@ -163,6 +224,10 @@ class _TestCameraPlatform extends CameraPlatform { } } + void completeInitialize(int cameraId) { + _initializeCompleters[cameraId]!.complete(); + } + void failDispose(int cameraId) { _disposeCompleters[cameraId]!.completeError( PlatformException(code: 'dispose-failed'), From 6c9f19425c47aabf9eb5d799fcdb966df5d2e1a9 Mon Sep 17 00:00:00 2001 From: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> Date: Tue, 25 Aug 2026 11:29:36 +0100 Subject: [PATCH 66/67] fix(mobile): address camera capture review findings Signed-off-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> Co-authored-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> --- .../profile/animated_avatar_capture.dart | 4 +- .../profile/animated_avatar_orientation.dart | 25 +- .../profile/image_avatar_capture.dart | 246 +----------------- .../image_avatar_capture/camera_preview.dart | 21 ++ .../morphing_camera_action.dart | 149 +++++++++++ .../image_avatar_capture/shutter_button.dart | 75 ++++++ .../profile/settings_profile_header.dart | 50 +++- .../profile/animated_avatar_capture_test.dart | 18 +- .../profile/settings_profile_header_test.dart | 73 ++++++ 9 files changed, 403 insertions(+), 258 deletions(-) create mode 100644 mobile/lib/features/profile/image_avatar_capture/camera_preview.dart create mode 100644 mobile/lib/features/profile/image_avatar_capture/morphing_camera_action.dart create mode 100644 mobile/lib/features/profile/image_avatar_capture/shutter_button.dart diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart index f02852e665e..45264cf8841 100644 --- a/mobile/lib/features/profile/animated_avatar_capture.dart +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -309,8 +309,10 @@ class AnimatedAvatarCapture extends HookConsumerWidget { try { final request = _FrameRequest.fromCameraImage( cameraImage, - rotationDegrees: animatedAvatarPortraitFrameRotationDegrees( + rotationDegrees: animatedAvatarFrameRotationDegrees( sensorOrientation: active.description.sensorOrientation, + deviceOrientation: active.value.deviceOrientation, + lensDirection: active.description.lensDirection, ), mirror: active.description.lensDirection == CameraLensDirection.front, diff --git a/mobile/lib/features/profile/animated_avatar_orientation.dart b/mobile/lib/features/profile/animated_avatar_orientation.dart index 905e4c6599d..77f2b557c4a 100644 --- a/mobile/lib/features/profile/animated_avatar_orientation.dart +++ b/mobile/lib/features/profile/animated_avatar_orientation.dart @@ -1,9 +1,24 @@ -/// Returns the portrait-up correction for an Android animated-avatar frame. +import 'package:camera/camera.dart'; +import 'package:flutter/services.dart'; + +/// Returns the clockwise correction for an Android animated-avatar frame. /// -/// Android image-stream buffers remain sensor-oriented, so the fixed hardware -/// sensor mount still needs correction even though capture is portrait-locked. -int animatedAvatarPortraitFrameRotationDegrees({ +/// Android image-stream buffers remain sensor-oriented. The correction must +/// account for both the sensor mount and the orientation in which the device +/// is currently held; front-facing frames are mirrored separately after this +/// rotation. +int animatedAvatarFrameRotationDegrees({ required int sensorOrientation, + required DeviceOrientation deviceOrientation, + required CameraLensDirection lensDirection, }) { - return (sensorOrientation % 360 + 360) % 360; + final deviceOrientationDegrees = switch (deviceOrientation) { + DeviceOrientation.portraitUp => 0, + DeviceOrientation.landscapeRight => 90, + DeviceOrientation.portraitDown => 180, + DeviceOrientation.landscapeLeft => 270, + }; + final facingSign = lensDirection == CameraLensDirection.back ? -1 : 1; + return (sensorOrientation - deviceOrientationDegrees * facingSign + 360) % + 360; } diff --git a/mobile/lib/features/profile/image_avatar_capture.dart b/mobile/lib/features/profile/image_avatar_capture.dart index 242e875d8c4..e9ffda995d7 100644 --- a/mobile/lib/features/profile/image_avatar_capture.dart +++ b/mobile/lib/features/profile/image_avatar_capture.dart @@ -17,6 +17,10 @@ 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. @@ -627,248 +631,6 @@ class ImageAvatarCapture extends HookConsumerWidget { } } -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), - ), - ); - } -} - -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, - ), - ), - ), - ), - ), - ), - ], - ); - } -} - -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), - ); - } -} - Uint8List _centerCropCameraImage(({Uint8List bytes, bool mirror}) request) { var decoded = image.decodeImage(request.bytes); if (decoded == null) throw const FormatException('Invalid camera image'); 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/settings_profile_header.dart b/mobile/lib/features/profile/settings_profile_header.dart index ca3f4f33ff1..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,7 @@ 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 @@ -84,15 +86,53 @@ class SettingsProfileHeader extends HookConsumerWidget { .read(profileAvatarHandoffProvider.notifier) .clear(activeHandoff.avatarUrl), ) - : activeHandoff == null + : activeHandoff == null || animatedPosterUrl == null ? AvatarImageContent( imageUrl: avatarUrl, fallback: _AvatarFallback(initial: profile?.initial), ) - : Image( - image: MemoryImage(activeHandoff.poster), - fit: BoxFit.cover, - gaplessPlayback: true, + : 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/test/features/profile/animated_avatar_capture_test.dart b/mobile/test/features/profile/animated_avatar_capture_test.dart index 1ef97e65c02..3e606a5a481 100644 --- a/mobile/test/features/profile/animated_avatar_capture_test.dart +++ b/mobile/test/features/profile/animated_avatar_capture_test.dart @@ -58,14 +58,22 @@ void main() { expect(first.path, isNot(second.path)); }); - test('normalizes the fixed portrait camera sensor correction', () { + test('accounts for device orientation and lens direction', () { expect( - animatedAvatarPortraitFrameRotationDegrees(sensorOrientation: 270), - 270, + animatedAvatarFrameRotationDegrees( + sensorOrientation: 270, + deviceOrientation: DeviceOrientation.landscapeRight, + lensDirection: CameraLensDirection.front, + ), + 180, ); expect( - animatedAvatarPortraitFrameRotationDegrees(sensorOrientation: 450), - 90, + animatedAvatarFrameRotationDegrees( + sensorOrientation: 270, + deviceOrientation: DeviceOrientation.landscapeRight, + lensDirection: CameraLensDirection.back, + ), + 0, ); }); diff --git a/mobile/test/features/profile/settings_profile_header_test.dart b/mobile/test/features/profile/settings_profile_header_test.dart index 954bd191b9a..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'; @@ -167,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 { From 431e8a251808a7cd2f3a85a647ce49ad8df1319d Mon Sep 17 00:00:00 2001 From: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> Date: Tue, 25 Aug 2026 13:41:20 +0100 Subject: [PATCH 67/67] test(mobile): cover camera teardown settlement Co-authored-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> Signed-off-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> --- .../profile/animated_avatar_capture_test.dart | 10 +++++++++- .../image_avatar_capture_lifecycle_test.dart | 14 +++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/mobile/test/features/profile/animated_avatar_capture_test.dart b/mobile/test/features/profile/animated_avatar_capture_test.dart index 3e606a5a481..6558d0ac369 100644 --- a/mobile/test/features/profile/animated_avatar_capture_test.dart +++ b/mobile/test/features/profile/animated_avatar_capture_test.dart @@ -174,7 +174,10 @@ void main() { testWidgets('waits for an in-flight camera before lifecycle replacement', ( tester, ) async { - final platform = _TestCameraPlatform(blockInitializeForCameraIds: {1}); + final platform = _TestCameraPlatform( + blockDisposeForCameraIds: {1}, + blockInitializeForCameraIds: {1}, + ); final previousPlatform = CameraPlatform.instance; CameraPlatform.instance = platform; addTearDown(() => CameraPlatform.instance = previousPlatform); @@ -212,6 +215,11 @@ void main() { 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')), diff --git a/mobile/test/features/profile/image_avatar_capture_lifecycle_test.dart b/mobile/test/features/profile/image_avatar_capture_lifecycle_test.dart index f80bd7d877e..790a00cccf9 100644 --- a/mobile/test/features/profile/image_avatar_capture_lifecycle_test.dart +++ b/mobile/test/features/profile/image_avatar_capture_lifecycle_test.dart @@ -68,7 +68,10 @@ void main() { testWidgets('waits for an in-flight camera before lifecycle replacement', ( tester, ) async { - final platform = _TestCameraPlatform(blockInitializeForCameraIds: {1}); + final platform = _TestCameraPlatform( + blockDisposeForCameraIds: {1}, + blockInitializeForCameraIds: {1}, + ); final previousPlatform = CameraPlatform.instance; CameraPlatform.instance = platform; addTearDown(() => CameraPlatform.instance = previousPlatform); @@ -108,6 +111,11 @@ void main() { 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')), @@ -233,4 +241,8 @@ class _TestCameraPlatform extends CameraPlatform { PlatformException(code: 'dispose-failed'), ); } + + void completeDispose(int cameraId) { + _disposeCompleters[cameraId]!.complete(); + } }