From 7c165c371845792b6752c642c6351a16b025268c Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 29 Oct 2026 08:16:17 +0000 Subject: [PATCH] Smooth profile avatar editor transitions (#8640) Smooths profile avatar editing with concurrent 200 ms entry/return transitions, fixed-size circle movement between tabs, and a crossfade to the dotted placeholder on cancel. New empty avatars open on Emoji, ready to save. Review fixes: keep the suggested emoji separate from the Image fallback, and preserve native editor transparency across appearance changes. Regression journeys cover empty Image previews, retained Emoji selection, and light/dark updates. Validation: focused Flutter journeys pass; restoring the old baseline fails both fresh-editor regressions. At `bffb83f5e`: full Flutter suite and push checks passed; all 107 native tests passed on the iOS simulator. Prior iPhone visual approval covers the transition work; these review fixes still need a device check. Follow-up to #8628. --------- Signed-off-by: kenny lopez Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> --- mobile/ios/Runner/NativeProfileSetup.swift | 13 +- .../ProfileSetupRecoveryTests.swift | 11 +- .../profile/animated_avatar_capture.dart | 172 ++++++++------ .../profile/profile_avatar_editor.dart | 114 ++++++++-- .../emoji_avatar_picker.dart | 6 +- .../profile/profile_setup_avatar_editor.dart | 24 +- .../profile_setup_avatar_editor_test.dart | 215 ++++++++++++++++++ 7 files changed, 452 insertions(+), 103 deletions(-) diff --git a/mobile/ios/Runner/NativeProfileSetup.swift b/mobile/ios/Runner/NativeProfileSetup.swift index e160c2f287..8a4ca2770d 100644 --- a/mobile/ios/Runner/NativeProfileSetup.swift +++ b/mobile/ios/Runner/NativeProfileSetup.swift @@ -40,7 +40,6 @@ final class NativeProfileSetupCoordinator: NSObject, UIAdaptivePresentationContr let controller = self.navigation?.viewControllers.first as? NativeProfileSetupController else { result(nil); return } self.navigation?.overrideUserInterfaceStyle = args["dark"] as? Bool == true ? .dark : .light controller.updateAppearance(args) - self.avatarController?.view.backgroundColor = controller.view.backgroundColor self.updateEditorAppearance(controller) result(nil); return } @@ -136,6 +135,7 @@ final class NativeProfileSetupCoordinator: NSObject, UIAdaptivePresentationContr return } if call.method == "controls", let state = call.arguments as? [String: Any] { + guard !finishing else { result(nil); return } self.editorCanDone = state["canDone"] as? Bool ?? false self.editorBusy = state["busy"] as? Bool ?? false let fullScreen = state["fullScreen"] as? Bool == true @@ -152,6 +152,11 @@ final class NativeProfileSetupCoordinator: NSObject, UIAdaptivePresentationContr let draft = call.arguments as? [String: Any] func returnToProfile() { controller.navigationItem.setRightBarButton(nil, animated: false) + // Restore the native chrome alongside Flutter's exit. The transparent + // editor reveals it during the animation; the final handoff must not + // restart it or allow another navigation action mid-transition. + controller.view.isUserInteractionEnabled = false + controller.navigationItem.leftBarButtonItem?.isEnabled = false controller.setEditorVisible(false) let frame = controller.editorFrame let rect = controller.avatarFrame(in: controller.view).offsetBy(dx: -frame.minX, dy: -frame.minY) @@ -180,7 +185,8 @@ final class NativeProfileSetupCoordinator: NSObject, UIAdaptivePresentationContr let editor = FlutterViewController(engine: engine, nibName: nil, bundle: nil) avatarController = editor engine.registrar(forPlugin: "BuzzIosNavigationBar")?.register(IosNavigationBarFactory(messenger: messenger, parent: editor), withId: "buzz/ios_navigation_bar") - editor.view.backgroundColor = controller.view.backgroundColor + editor.isViewOpaque = false + editor.view.backgroundColor = .clear controller.addChild(editor) editor.view.frame = editorFrame editor.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] @@ -229,6 +235,8 @@ final class NativeProfileSetupCoordinator: NSObject, UIAdaptivePresentationContr if let enabled = previousInteractivePopEnabled { navigation?.interactivePopGestureRecognizer?.isEnabled = enabled } previousInteractivePopEnabled = nil disabledSheetPans.forEach { $0.isEnabled = true }; disabledSheetPans.removeAll() + controller?.view.isUserInteractionEnabled = true + controller?.navigationItem.leftBarButtonItem?.isEnabled = true controller?.setEditorVisible(false) controller?.setAvatarHidden(false) controller?.avatarReady() @@ -422,6 +430,7 @@ private final class NativeProfileSetupController: UIViewController, UITextFieldD return CGRect(x: 0, y: top, width: view.bounds.width, height: max(0, view.safeAreaLayoutGuide.layoutFrame.maxY - top)) } func setEditorVisible(_ visible: Bool) { + guard editingAvatar != visible else { return } editingAvatar = visible heading.accessibilityElementsHidden = visible explanationRow.accessibilityElementsHidden = visible diff --git a/mobile/ios/RunnerTests/ProfileSetupRecoveryTests.swift b/mobile/ios/RunnerTests/ProfileSetupRecoveryTests.swift index 7c9e036ef5..b7065ef274 100644 --- a/mobile/ios/RunnerTests/ProfileSetupRecoveryTests.swift +++ b/mobile/ios/RunnerTests/ProfileSetupRecoveryTests.swift @@ -131,7 +131,16 @@ final class ProfileSetupRecoveryTests: XCTestCase { XCTAssertEqual(profile.navigationItem.rightBarButtonItem?.title, "Done") XCTAssertFalse(profile.navigationItem.rightBarButtonItem?.isEnabled ?? true) XCTAssertTrue(profile.navigationItem.leftBarButtonItem?.isEnabled ?? false) - XCTAssertTrue(profile.children.contains { $0 is FlutterViewController }) + let editor = profile.children.compactMap { $0 as? FlutterViewController }.first + XCTAssertEqual(editor?.isViewOpaque, false, "Native chrome must remain visible through the exiting editor") + XCTAssertEqual(editor?.view.backgroundColor, UIColor.clear) + for dark in [false, true] { + messenger.invoke("appearance", arguments: ["session": 81, "dark": dark, + "pageBackgroundArgb": dark ? 0xFF000000 : 0xFFFFFFFF]) + XCTAssertEqual(editor?.isViewOpaque, false) + XCTAssertEqual(editor?.view.backgroundColor, UIColor.clear) + XCTAssertEqual(nav.overrideUserInterfaceStyle, dark ? .dark : .light) + } XCTAssertFalse(pan.isEnabled) XCTAssertFalse(nav.interactivePopGestureRecognizer?.isEnabled ?? true) XCTAssertTrue(nav.isModalInPresentation) diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart index 168cf86d60..2aec77c320 100644 --- a/mobile/lib/features/profile/animated_avatar_capture.dart +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -48,6 +48,8 @@ class AnimatedAvatarCapture extends HookConsumerWidget { this.disposalBarrier, this.previewKey, this.previewSize, + this.controlsTransitionProgress = 1, + this.previewOffset = 0, }); /// Identifies the visible avatar for the native sheet transition. @@ -56,6 +58,12 @@ class AnimatedAvatarCapture extends HookConsumerWidget { /// Fixed visible preview diameter when hosted in profile setup. final double? previewSize; + /// Entrance progress for the controls, independent of the fixed-size preview. + final double controlsTransitionProgress; + + /// Moves the capture circle independently of its controls during tab changes. + final double previewOffset; + /// The vertical space available to the capture surface. final double height; @@ -590,90 +598,108 @@ class AnimatedAvatarCapture extends HookConsumerWidget { Widget captureContent() => Column( mainAxisSize: MainAxisSize.min, children: [ - SizedBox.square( - key: const ValueKey('animated-avatar-capture-preview'), - dimension: capturePreviewSize, - child: Stack( - key: previewKey, - fit: StackFit.expand, - children: [ - Padding( - padding: previewSize == null - ? const EdgeInsets.all(4) - : EdgeInsets.zero, - child: ClipOval( - child: ColoredBox( - color: Colors.black, - child: isPreparingFrames.value - ? Center( - child: BuzzLoadingIndicator( - size: 44, - color: context.colors.onSurface, - semanticLabel: 'Preparing animated avatar', - ), - ) - : active != null - ? _AspectCorrectCameraPreview(controller: active) - : Center( - child: isInitializing.value - ? const BuzzLoadingIndicator( - color: Colors.white, - semanticLabel: 'Starting camera', - ) - : const Icon( - BuzzIcons.cameraOff, - color: Colors.white, - size: 32, - ), - ), - ), - ), - ), - if (isRecording.value) + Transform.translate( + offset: Offset(0, previewOffset), + child: SizedBox.square( + key: const ValueKey('animated-avatar-capture-preview'), + dimension: capturePreviewSize, + child: Stack( + key: previewKey, + fit: StackFit.expand, + children: [ Padding( - // A progress stroke is centred on its circular path. Inset - // it so the outer half is not clipped by the preview stack. - padding: const EdgeInsets.all(2), - child: CircularProgressIndicator( - key: const ValueKey('animated-avatar-recording-ring'), - value: progress.value, - strokeWidth: 4, - strokeCap: StrokeCap.round, - color: context.colors.onSurface, - backgroundColor: context.colors.outlineVariant, + padding: previewSize == null + ? const EdgeInsets.all(4) + : EdgeInsets.zero, + child: ClipOval( + child: ColoredBox( + color: Colors.black, + child: isPreparingFrames.value + ? Center( + child: BuzzLoadingIndicator( + size: 44, + color: context.colors.onSurface, + semanticLabel: 'Preparing animated avatar', + ), + ) + : active != null + ? _AspectCorrectCameraPreview(controller: active) + : Center( + child: isInitializing.value + ? const BuzzLoadingIndicator( + color: Colors.white, + semanticLabel: 'Starting camera', + ) + : const Icon( + BuzzIcons.cameraOff, + color: Colors.white, + size: 32, + ), + ), + ), ), ), - ], + if (isRecording.value) + Padding( + // A progress stroke is centred on its circular path. Inset + // it so the outer half is not clipped by the preview stack. + padding: const EdgeInsets.all(2), + child: CircularProgressIndicator( + key: const ValueKey('animated-avatar-recording-ring'), + value: progress.value, + strokeWidth: 4, + strokeCap: StrokeCap.round, + color: context.colors.onSurface, + backgroundColor: context.colors.outlineVariant, + ), + ), + ], + ), ), ), - const SizedBox(height: Grid.xs), - Text( - isRecording.value - ? 'Recording…' - : isPreparingFrames.value - ? 'Cutting you out of the background…' - : 'Line up your shot.', - textAlign: TextAlign.center, - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, + Transform.translate( + key: const ValueKey('animated-avatar-controls-transition'), + offset: Offset(0, 16 * (1 - controlsTransitionProgress)), + child: Opacity( + opacity: controlsTransitionProgress, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: Grid.xs), + Text( + isRecording.value + ? 'Recording…' + : isPreparingFrames.value + ? 'Cutting you out of the background…' + : 'Line up your shot.', + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + if (error.value != null) _ErrorText(error.value!), + SizedBox(height: recordGap), + _AnimatedRecordButton( + busy: isRecording.value || isPreparingFrames.value, + onPressed: active == null + ? null + : () { + unawaited(HapticFeedback.mediumImpact()); + unawaited(record()); + }, + ), + ], + ), ), ), - if (error.value != null) _ErrorText(error.value!), - SizedBox(height: recordGap), - _AnimatedRecordButton( - busy: isRecording.value || isPreparingFrames.value, - onPressed: active == null - ? null - : () { - unawaited(HapticFeedback.mediumImpact()); - unawaited(record()); - }, - ), ], ); return SizedBox( height: height, - child: SingleChildScrollView(child: captureContent()), + child: SingleChildScrollView( + clipBehavior: Clip.none, + child: captureContent(), + ), ); } } diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart index 58b4c3bb32..5581cf7442 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -88,6 +88,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { this.previewKey, this.previewOpacity = 1, this.emptyPreview, + this.returningToEmpty = false, this.headerGap = Grid.xs, this.anchoredPreview, }); @@ -98,6 +99,9 @@ class ProfileAvatarEditor extends HookConsumerWidget { final GlobalKey? previewKey; final double previewOpacity; final Widget? emptyPreview; + + /// Crossfades a discarded setup draft to its original empty placeholder. + final bool returningToEmpty; final double headerGap; /// Keeps the onboarding avatar in place while its controls appear. @@ -151,9 +155,14 @@ class ProfileAvatarEditor extends HookConsumerWidget { final previousAnchor = useRef(anchoredPreview); final anchorMoved = previousAnchor.value != anchoredPreview; previousAnchor.value = anchoredPreview; + // Seed Emoji controls from the opening draft without making it the Image baseline. final currentEmojiAvatar = useMemoized( - () => parseEmojiAvatarDataUrl(currentAvatarUrl), - [currentAvatarUrl], + () => parseEmojiAvatarDataUrl( + draft is ProfileUrlAvatarDraft + ? (draft as ProfileUrlAvatarDraft).url + : currentAvatarUrl, + ), + const [], ); final selectedEmoji = useState(currentEmojiAvatar?.emoji ?? '😊'); final initialColor = useMemoized( @@ -176,10 +185,12 @@ class ProfileAvatarEditor extends HookConsumerWidget { final modeTransitionFrom = useRef(mode); final retainedPreview = useRef(null); final retainedPreviewTop = useRef(0.0); + final currentPreviewTop = useRef(null); + final modeStartPreviewTop = useRef(null); final modeTransitionController = useAnimationController( duration: anchoredPreview == null ? _motionDuration - : const Duration(milliseconds: 100), + : const Duration(milliseconds: 200), initialValue: 1, ); final modeTransitionValue = useAnimation(modeTransitionController); @@ -189,6 +200,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { void selectMode(ProfileAvatarMode nextMode) { if (nextMode == mode) return; + modeStartPreviewTop.value = currentPreviewTop.value; modeTransitionFrom.value = mode; modeTransitionDirection.value = nextMode.index > mode.index ? 1 : -1; unawaited(HapticFeedback.selectionClick()); @@ -406,13 +418,20 @@ class ProfileAvatarEditor extends HookConsumerWidget { modeTop - (anchoredPreview?.height ?? _previewBlockSize)) / 2; - final displayedPreviewTop = + final targetPreviewTop = anchoredPreview != null && mode == ProfileAvatarMode.emoji ? centeredEmojiTop : returningToEmoji ? animatedPreviewTop + (previewTop - animatedPreviewTop) * modeTransitionProgress : previewTop; + final displayedPreviewTop = anchoredPreview != null + ? (modeStartPreviewTop.value ?? targetPreviewTop) + + (targetPreviewTop - + (modeStartPreviewTop.value ?? targetPreviewTop)) * + modeTransitionProgress + : targetPreviewTop; + currentPreviewTop.value = displayedPreviewTop; if (mode != ProfileAvatarMode.animated) { retainedPreviewTop.value = displayedPreviewTop; } @@ -458,6 +477,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { selectedColor: selectedColor.value, transitionProgress: modeTransitionProgress, transitionDirection: modeTransitionDirection.value, + controlsFromBelow: anchoredPreview != null, onSectionChanged: (section) => emojiSection.value = section, onEmojiSelected: (emoji) { selectedEmoji.value = emoji; @@ -478,6 +498,12 @@ class ProfileAvatarEditor extends HookConsumerWidget { AnimatedAvatarCapture( previewKey: previewKey, previewSize: anchoredPreview?.width, + previewOffset: anchoredPreview == null + ? 0 + : displayedPreviewTop - modeTop, + controlsTransitionProgress: anchoredPreview == null + ? 1 + : modeTransitionProgress, height: contentHeight, onPrepareChanged: onAnimatedPrepareChanged, disposalBarrier: cameraDisposal.value, @@ -496,10 +522,14 @@ class ProfileAvatarEditor extends HookConsumerWidget { child: Transform.translate( key: const ValueKey('avatar-mode-transition-transform'), offset: Offset( - modeTransitionDirection.value * - _modeTransitionDistance * - (1 - modeTransitionProgress), - 0, + anchoredPreview == null + ? modeTransitionDirection.value * + _modeTransitionDistance * + (1 - modeTransitionProgress) + : 0, + anchoredPreview == null + ? 0 + : 16 * (1 - modeTransitionProgress), ), child: Opacity( key: const ValueKey('avatar-mode-transition-opacity'), @@ -559,7 +589,8 @@ class ProfileAvatarEditor extends HookConsumerWidget { ), ), ), - if (fixedPreview != null && !isCapturingImage.value) + if ((fixedPreview != null || returningToEmpty) && + !isCapturingImage.value) AnimatedPositioned( key: const ValueKey('avatar-preview-position'), curve: Curves.easeOutCubic, @@ -567,7 +598,8 @@ class ProfileAvatarEditor extends HookConsumerWidget { right: Grid.gutter, top: displayedPreviewTop, height: anchoredPreview?.height ?? _previewBlockSize, - duration: anchorMoved || returningToEmoji + duration: + anchoredPreview != null || anchorMoved || returningToEmoji ? Duration.zero : reduceMotion ? Duration.zero @@ -578,12 +610,44 @@ class ProfileAvatarEditor extends HookConsumerWidget { child: Opacity( opacity: previewOpacity, child: KeyedSubtree( - key: previewKey, + key: mode == ProfileAvatarMode.animated + ? null + : previewKey, child: anchoredPreview == null - ? fixedPreview + ? fixedPreview ?? const SizedBox.shrink() : SizedBox.square( dimension: anchoredPreview.width, - child: FittedBox(child: fixedPreview), + child: AnimatedBuilder( + animation: transition, + builder: (context, _) => FittedBox( + child: SizedBox.square( + dimension: _previewSize, + child: Stack( + fit: StackFit.expand, + children: [ + if (returningToEmpty) + Opacity( + key: const ValueKey( + 'avatar-return-empty-opacity', + ), + opacity: 1 - transition.value, + child: emptyPreview, + ), + if (fixedPreview != null) + Opacity( + key: const ValueKey( + 'avatar-return-draft-opacity', + ), + opacity: returningToEmpty + ? transition.value + : 1, + child: fixedPreview, + ), + ], + ), + ), + ), + ), ), ), ), @@ -613,7 +677,10 @@ class ProfileAvatarEditor extends HookConsumerWidget { AnimatedPositioned( // Camera controls already morph into the source buttons before // closing. Keep their bottom rail fixed during the handoff. - duration: mode == ProfileAvatarMode.image || reduceMotion + duration: + anchoredPreview != null || + mode == ProfileAvatarMode.image || + reduceMotion ? Duration.zero : const Duration(milliseconds: 150), curve: Curves.easeOutCubic, @@ -623,7 +690,9 @@ class ProfileAvatarEditor extends HookConsumerWidget { height: modeHeight, child: FadeTransition( opacity: curvedEntrance, - child: anchoredPreview == null + child: + anchoredPreview == null || + mode == ProfileAvatarMode.animated ? transitionedModeContent : ClipRect( child: SingleChildScrollView( @@ -643,12 +712,15 @@ class ProfileAvatarEditor extends HookConsumerWidget { key: const ValueKey('avatar-mode-retained-preview'), left: Grid.gutter, right: Grid.gutter, - top: - retainedPreviewTop.value + - ((anchoredPreview == null ? basePreviewTop : previewTop) - - retainedPreviewTop.value) * - modeTransitionProgress, - height: _previewBlockSize, + top: anchoredPreview != null + ? displayedPreviewTop + : retainedPreviewTop.value + + ((anchoredPreview == null + ? basePreviewTop + : previewTop) - + retainedPreviewTop.value) * + modeTransitionProgress, + height: anchoredPreview?.height ?? _previewBlockSize, child: IgnorePointer( child: Opacity( opacity: 1 - modeTransitionProgress, 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 820d2faba0..33d3fa522a 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 @@ -22,6 +22,7 @@ class _EmojiMode extends HookConsumerWidget { required this.selectedColor, required this.transitionProgress, required this.transitionDirection, + this.controlsFromBelow = false, required this.onSectionChanged, required this.onEmojiSelected, required this.onColorSelected, @@ -34,6 +35,7 @@ class _EmojiMode extends HookConsumerWidget { final int selectedColor; final double transitionProgress; final double transitionDirection; + final bool controlsFromBelow; final ValueChanged<_EmojiEditorSection> onSectionChanged; final ValueChanged onEmojiSelected; final ValueChanged onColorSelected; @@ -213,7 +215,9 @@ class _EmojiMode extends HookConsumerWidget { ClipRect( child: Transform.translate( key: const ValueKey('avatar-mode-transition-transform'), - offset: Offset(actionOffset, 0), + offset: controlsFromBelow + ? Offset(0, trayOffset) + : Offset(actionOffset, 0), child: Opacity( opacity: transitionProgress, child: Row( diff --git a/mobile/lib/features/profile/profile_setup_avatar_editor.dart b/mobile/lib/features/profile/profile_setup_avatar_editor.dart index f77ff3a897..7c274529a2 100644 --- a/mobile/lib/features/profile/profile_setup_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_setup_avatar_editor.dart @@ -1,4 +1,8 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; + +import '../../shared/emoji/emoji_avatar.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -50,14 +54,23 @@ class ProfileSetupAvatarEditor extends HookConsumerWidget { }); return null; }, const []); - final draft = useState(initialDraft); - final mode = useState(switch (initialDraft) { + final openingDraft = useMemoized( + () => + initialDraft ?? + ProfileUrlAvatarDraft( + emojiAvatarDataUrl('😊', emojiAvatarColors[Random().nextInt(18)]), + ), + const [], + ); + final draft = useState(openingDraft); + final mode = useState(switch (openingDraft) { ProfileUrlAvatarDraft() => ProfileAvatarMode.emoji, ProfileAnimatedAvatarDraft() => ProfileAvatarMode.animated, _ => ProfileAvatarMode.image, }); final prepare = useState Function()?>(null); final busy = useState(false); + final returningToEmpty = useState(false); final error = useState(false); final transition = useAnimationController( initialValue: anchoredPreview == null ? 1 : 0, @@ -67,6 +80,7 @@ class ProfileSetupAvatarEditor extends HookConsumerWidget { ? null : () { if (onFinished != null) { + returningToEmpty.value = initialDraft == null; onFinished!(null); } else { Navigator.of(context).pop(); @@ -166,9 +180,8 @@ class ProfileSetupAvatarEditor extends HookConsumerWidget { return Theme( data: utilitySurfaceThemeData(Theme.of(context)), child: Scaffold( - backgroundColor: utilitySurfaceThemeData( - Theme.of(context), - ).colorScheme.surface, + // Native profile chrome returns underneath the exiting controls. + backgroundColor: Colors.transparent, resizeToAvoidBottomInset: false, body: Column( children: [ @@ -186,6 +199,7 @@ class ProfileSetupAvatarEditor extends HookConsumerWidget { : null, fallbackInitial: '+', emptyPreview: const ProfileSetupEmptyAvatar(), + returningToEmpty: returningToEmpty.value, draft: draft.value, mode: mode.value, transition: transition, diff --git a/mobile/test/features/profile/profile_setup_avatar_editor_test.dart b/mobile/test/features/profile/profile_setup_avatar_editor_test.dart index e75c4f124c..05b06969ac 100644 --- a/mobile/test/features/profile/profile_setup_avatar_editor_test.dart +++ b/mobile/test/features/profile/profile_setup_avatar_editor_test.dart @@ -1,4 +1,5 @@ import 'package:buzz/features/profile/profile_setup_avatar_editor.dart'; +import 'package:buzz/features/profile/profile_setup_empty_avatar.dart'; import 'package:buzz/features/profile/profile_avatar_draft.dart'; import 'package:buzz/shared/emoji/emoji_avatar.dart'; import 'package:buzz/features/profile/profile_avatar_editor.dart'; @@ -44,6 +45,43 @@ void main() { } await open(null); + final openingEditor = tester.widget( + find.byType(ProfileAvatarEditor), + ); + expect(openingEditor.mode, ProfileAvatarMode.emoji); + final openingDraft = openingEditor.draft! as ProfileUrlAvatarDraft; + expect(parseEmojiAvatarDataUrl(openingDraft.url)?.emoji, '😊'); + expect(openingEditor.currentAvatarUrl, isNull); + await tester.tap(find.text('Image')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(find.byType(ProfileSetupEmptyAvatar), findsOneWidget); + expect( + tester + .widget(find.widgetWithText(TextButton, 'Done')) + .onPressed, + isNull, + ); + await tester.tap(find.text('Emoji')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + final restoredDraft = + tester + .widget(find.byType(ProfileAvatarEditor)) + .draft! + as ProfileUrlAvatarDraft; + expect(restoredDraft.url, openingDraft.url); + await tester.tap(find.text('Done')); + await tester.pump(); + expect(accepted, same(restoredDraft)); + await open(null); + await tester.tap(find.byTooltip('Back to profile')); + await tester.pump(); + expect( + accepted, + isNull, + reason: 'Back does not accept the suggested emoji', + ); tester .widget(find.byType(ProfileAvatarEditor)) .onModeChanged(ProfileAvatarMode.animated); @@ -79,6 +117,81 @@ void main() { }, ); + testWidgets('discarding a new avatar crossfades to the empty circle', ( + tester, + ) async { + for (final mode in ProfileAvatarMode.values) { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + home: ProfileSetupAvatarEditor( + key: UniqueKey(), + anchoredPreview: const Rect.fromLTWH(306.5, 236.5, 187, 187), + onFinished: (draft) => expect(draft, isNull), + ), + ), + ), + ); + Future send(String method) => + tester.binding.defaultBinaryMessenger.handlePlatformMessage( + 'buzz/profile_avatar_sheet', + const StandardMethodCodec().encodeMethodCall(MethodCall(method)), + (_) {}, + ); + await tester.pump(); + await send('reveal'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + await tester.tap( + find.text(switch (mode) { + ProfileAvatarMode.image => 'Image', + ProfileAvatarMode.emoji => 'Emoji', + ProfileAvatarMode.animated => 'Animated', + }).first, + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + if (mode == ProfileAvatarMode.image) { + expect(find.byType(ProfileSetupEmptyAvatar), findsOneWidget); + expect( + tester + .widget(find.byType(ProfileAvatarEditor)) + .draft, + isNull, + ); + } + await send('back'); + await tester.pump(); + double emptyOpacity() => tester + .widget( + find.byKey(const ValueKey('avatar-return-empty-opacity')), + ) + .opacity; + expect(emptyOpacity(), 0); + final hiding = send('hide'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(emptyOpacity(), closeTo(0.5, 0.01)); + if (mode != ProfileAvatarMode.animated) { + expect( + tester + .widget( + find.byKey(const ValueKey('avatar-return-draft-opacity')), + ) + .opacity, + closeTo(0.5, 0.01), + ); + } + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 16)); + await tester.pump(); + await hiding; + expect(emptyOpacity(), 1); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + } + }); + testWidgets('avatar draft editor lays out within a full height sheet', ( tester, ) async { @@ -111,6 +224,10 @@ void main() { expect(previewRect!.width, greaterThan(0)); expect(previewRect!.height, greaterThan(0)); if (anchor != null) { + expect( + tester.widget(find.byType(Scaffold)).backgroundColor, + Colors.transparent, + ); expect(previewRect, anchor); final circle = tester.getRect( find.byKey(const ValueKey('avatar-preview-position')), @@ -148,6 +265,97 @@ void main() { expect(tester.getSize(visibleAvatar), const Size.square(220)); await tester.pump(const Duration(milliseconds: 100)); expect(tester.getTopLeft(visibleAvatar).dy, closeTo(66, 0.01)); + // Every tab keeps one diameter and moves from the currently painted + // circle position. Controls always enter upward, including Animated. + var previousTop = tester.getTopLeft(visibleAvatar).dy; + for (final label in [ + 'Animated', + 'Emoji', + 'Image', + 'Animated', + 'Image', + 'Emoji', + ]) { + await tester.tap(find.text(label)); + await tester.pump(); + final circleFinder = label == 'Animated' + ? find.byKey(const ValueKey('animated-avatar-capture-preview')) + : visibleAvatar; + expect(tester.getSize(circleFinder), const Size.square(220)); + expect( + tester.getTopLeft(circleFinder).dy, + closeTo(previousTop, 0.01), + ); + final controlsKey = label == 'Animated' + ? 'animated-avatar-controls-transition' + : label == 'Emoji' + ? 'avatar-mode-tray-transition-transform' + : 'avatar-mode-transition-transform'; + final controls = find.byKey(ValueKey(controlsKey)); + final controlsContent = find + .descendant(of: controls, matching: find.byType(Opacity)) + .first; + final startControlsTop = tester.getTopLeft(controlsContent).dy; + final startOffset = tester + .widget(controls) + .transform + .getTranslation(); + expect(startOffset.x, 0); + expect(startOffset.y, greaterThan(0)); + await tester.pump(const Duration(milliseconds: 50)); + final midwayTop = tester.getTopLeft(circleFinder).dy; + final midwayControlsTop = tester.getTopLeft(controlsContent).dy; + expect(midwayControlsTop, lessThan(startControlsTop)); + expect(tester.getSize(circleFinder), const Size.square(220)); + final midwayOffset = tester + .widget(controls) + .transform + .getTranslation(); + expect( + midwayOffset.y, + allOf(greaterThan(0), lessThan(startOffset.y)), + ); + await tester.pump(const Duration(milliseconds: 150)); + final destinationTop = tester.getTopLeft(circleFinder).dy; + expect( + tester.getTopLeft(controlsContent).dy, + lessThan(midwayControlsTop), + ); + expect( + midwayTop, + allOf( + greaterThan( + previousTop < destinationTop ? previousTop : destinationTop, + ), + lessThan( + previousTop > destinationTop ? previousTop : destinationTop, + ), + ), + ); + expect(tester.getSize(circleFinder), const Size.square(220)); + expect( + tester.widget(controls).transform.getTranslation().y, + 0, + ); + previousTop = destinationTop; + } + // Changing direction mid-flight starts from the painted position. + await tester.tap(find.text('Animated')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + final interruptedTop = tester + .getTopLeft( + find.byKey(const ValueKey('animated-avatar-capture-preview')), + ) + .dy; + await tester.tap(find.text('Emoji')); + await tester.pump(); + expect( + tester.getTopLeft(visibleAvatar).dy, + closeTo(interruptedTop, 0.01), + ); + expect(tester.getSize(visibleAvatar), const Size.square(220)); + await tester.pump(const Duration(milliseconds: 200)); // Empty native previews start 15% smaller, then reach editor size. await tester.binding.defaultBinaryMessenger.handlePlatformMessage( 'buzz/profile_avatar_sheet', @@ -216,6 +424,13 @@ void main() { await tester.tap(find.text('Image')); await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); + expect(find.byType(ProfileSetupEmptyAvatar), findsNothing); + expect( + tester + .widget(find.byType(ProfileAvatarEditor)) + .draft, + isNull, + ); expect( tester .getTopLeft(find.byKey(const ValueKey('avatar-preview-position')))