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 <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-10-29 08:16:17 +00:00
committed by GitHub
parent 6e332f46c7
commit 7c165c3718
7 changed files with 452 additions and 103 deletions

View File

@@ -40,7 +40,6 @@ final class NativeProfileSetupCoordinator: NSObject, UIAdaptivePresentationContr
let controller = self.navigation?.viewControllers.first as? NativeProfileSetupController else { result(nil); return } let controller = self.navigation?.viewControllers.first as? NativeProfileSetupController else { result(nil); return }
self.navigation?.overrideUserInterfaceStyle = args["dark"] as? Bool == true ? .dark : .light self.navigation?.overrideUserInterfaceStyle = args["dark"] as? Bool == true ? .dark : .light
controller.updateAppearance(args) controller.updateAppearance(args)
self.avatarController?.view.backgroundColor = controller.view.backgroundColor
self.updateEditorAppearance(controller) self.updateEditorAppearance(controller)
result(nil); return result(nil); return
} }
@@ -136,6 +135,7 @@ final class NativeProfileSetupCoordinator: NSObject, UIAdaptivePresentationContr
return return
} }
if call.method == "controls", let state = call.arguments as? [String: Any] { 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.editorCanDone = state["canDone"] as? Bool ?? false
self.editorBusy = state["busy"] as? Bool ?? false self.editorBusy = state["busy"] as? Bool ?? false
let fullScreen = state["fullScreen"] as? Bool == true let fullScreen = state["fullScreen"] as? Bool == true
@@ -152,6 +152,11 @@ final class NativeProfileSetupCoordinator: NSObject, UIAdaptivePresentationContr
let draft = call.arguments as? [String: Any] let draft = call.arguments as? [String: Any]
func returnToProfile() { func returnToProfile() {
controller.navigationItem.setRightBarButton(nil, animated: false) 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) controller.setEditorVisible(false)
let frame = controller.editorFrame let frame = controller.editorFrame
let rect = controller.avatarFrame(in: controller.view).offsetBy(dx: -frame.minX, dy: -frame.minY) 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) let editor = FlutterViewController(engine: engine, nibName: nil, bundle: nil)
avatarController = editor avatarController = editor
engine.registrar(forPlugin: "BuzzIosNavigationBar")?.register(IosNavigationBarFactory(messenger: messenger, parent: editor), withId: "buzz/ios_navigation_bar") 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) controller.addChild(editor)
editor.view.frame = editorFrame editor.view.frame = editorFrame
editor.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] editor.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
@@ -229,6 +235,8 @@ final class NativeProfileSetupCoordinator: NSObject, UIAdaptivePresentationContr
if let enabled = previousInteractivePopEnabled { navigation?.interactivePopGestureRecognizer?.isEnabled = enabled } if let enabled = previousInteractivePopEnabled { navigation?.interactivePopGestureRecognizer?.isEnabled = enabled }
previousInteractivePopEnabled = nil previousInteractivePopEnabled = nil
disabledSheetPans.forEach { $0.isEnabled = true }; disabledSheetPans.removeAll() disabledSheetPans.forEach { $0.isEnabled = true }; disabledSheetPans.removeAll()
controller?.view.isUserInteractionEnabled = true
controller?.navigationItem.leftBarButtonItem?.isEnabled = true
controller?.setEditorVisible(false) controller?.setEditorVisible(false)
controller?.setAvatarHidden(false) controller?.setAvatarHidden(false)
controller?.avatarReady() 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)) return CGRect(x: 0, y: top, width: view.bounds.width, height: max(0, view.safeAreaLayoutGuide.layoutFrame.maxY - top))
} }
func setEditorVisible(_ visible: Bool) { func setEditorVisible(_ visible: Bool) {
guard editingAvatar != visible else { return }
editingAvatar = visible editingAvatar = visible
heading.accessibilityElementsHidden = visible heading.accessibilityElementsHidden = visible
explanationRow.accessibilityElementsHidden = visible explanationRow.accessibilityElementsHidden = visible

View File

@@ -131,7 +131,16 @@ final class ProfileSetupRecoveryTests: XCTestCase {
XCTAssertEqual(profile.navigationItem.rightBarButtonItem?.title, "Done") XCTAssertEqual(profile.navigationItem.rightBarButtonItem?.title, "Done")
XCTAssertFalse(profile.navigationItem.rightBarButtonItem?.isEnabled ?? true) XCTAssertFalse(profile.navigationItem.rightBarButtonItem?.isEnabled ?? true)
XCTAssertTrue(profile.navigationItem.leftBarButtonItem?.isEnabled ?? false) 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(pan.isEnabled)
XCTAssertFalse(nav.interactivePopGestureRecognizer?.isEnabled ?? true) XCTAssertFalse(nav.interactivePopGestureRecognizer?.isEnabled ?? true)
XCTAssertTrue(nav.isModalInPresentation) XCTAssertTrue(nav.isModalInPresentation)

View File

@@ -48,6 +48,8 @@ class AnimatedAvatarCapture extends HookConsumerWidget {
this.disposalBarrier, this.disposalBarrier,
this.previewKey, this.previewKey,
this.previewSize, this.previewSize,
this.controlsTransitionProgress = 1,
this.previewOffset = 0,
}); });
/// Identifies the visible avatar for the native sheet transition. /// 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. /// Fixed visible preview diameter when hosted in profile setup.
final double? previewSize; 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. /// The vertical space available to the capture surface.
final double height; final double height;
@@ -590,90 +598,108 @@ class AnimatedAvatarCapture extends HookConsumerWidget {
Widget captureContent() => Column( Widget captureContent() => Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
SizedBox.square( Transform.translate(
key: const ValueKey('animated-avatar-capture-preview'), offset: Offset(0, previewOffset),
dimension: capturePreviewSize, child: SizedBox.square(
child: Stack( key: const ValueKey('animated-avatar-capture-preview'),
key: previewKey, dimension: capturePreviewSize,
fit: StackFit.expand, child: Stack(
children: [ key: previewKey,
Padding( fit: StackFit.expand,
padding: previewSize == null children: [
? 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( Padding(
// A progress stroke is centred on its circular path. Inset padding: previewSize == null
// it so the outer half is not clipped by the preview stack. ? const EdgeInsets.all(4)
padding: const EdgeInsets.all(2), : EdgeInsets.zero,
child: CircularProgressIndicator( child: ClipOval(
key: const ValueKey('animated-avatar-recording-ring'), child: ColoredBox(
value: progress.value, color: Colors.black,
strokeWidth: 4, child: isPreparingFrames.value
strokeCap: StrokeCap.round, ? Center(
color: context.colors.onSurface, child: BuzzLoadingIndicator(
backgroundColor: context.colors.outlineVariant, 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), Transform.translate(
Text( key: const ValueKey('animated-avatar-controls-transition'),
isRecording.value offset: Offset(0, 16 * (1 - controlsTransitionProgress)),
? 'Recording…' child: Opacity(
: isPreparingFrames.value opacity: controlsTransitionProgress,
? 'Cutting you out of the background…' child: Column(
: 'Line up your shot.', mainAxisSize: MainAxisSize.min,
textAlign: TextAlign.center, children: [
style: context.textTheme.bodyMedium?.copyWith( const SizedBox(height: Grid.xs),
color: context.colors.onSurfaceVariant, 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( return SizedBox(
height: height, height: height,
child: SingleChildScrollView(child: captureContent()), child: SingleChildScrollView(
clipBehavior: Clip.none,
child: captureContent(),
),
); );
} }
} }

View File

@@ -88,6 +88,7 @@ class ProfileAvatarEditor extends HookConsumerWidget {
this.previewKey, this.previewKey,
this.previewOpacity = 1, this.previewOpacity = 1,
this.emptyPreview, this.emptyPreview,
this.returningToEmpty = false,
this.headerGap = Grid.xs, this.headerGap = Grid.xs,
this.anchoredPreview, this.anchoredPreview,
}); });
@@ -98,6 +99,9 @@ class ProfileAvatarEditor extends HookConsumerWidget {
final GlobalKey? previewKey; final GlobalKey? previewKey;
final double previewOpacity; final double previewOpacity;
final Widget? emptyPreview; final Widget? emptyPreview;
/// Crossfades a discarded setup draft to its original empty placeholder.
final bool returningToEmpty;
final double headerGap; final double headerGap;
/// Keeps the onboarding avatar in place while its controls appear. /// Keeps the onboarding avatar in place while its controls appear.
@@ -151,9 +155,14 @@ class ProfileAvatarEditor extends HookConsumerWidget {
final previousAnchor = useRef(anchoredPreview); final previousAnchor = useRef(anchoredPreview);
final anchorMoved = previousAnchor.value != anchoredPreview; final anchorMoved = previousAnchor.value != anchoredPreview;
previousAnchor.value = anchoredPreview; previousAnchor.value = anchoredPreview;
// Seed Emoji controls from the opening draft without making it the Image baseline.
final currentEmojiAvatar = useMemoized( final currentEmojiAvatar = useMemoized(
() => parseEmojiAvatarDataUrl(currentAvatarUrl), () => parseEmojiAvatarDataUrl(
[currentAvatarUrl], draft is ProfileUrlAvatarDraft
? (draft as ProfileUrlAvatarDraft).url
: currentAvatarUrl,
),
const [],
); );
final selectedEmoji = useState(currentEmojiAvatar?.emoji ?? '😊'); final selectedEmoji = useState(currentEmojiAvatar?.emoji ?? '😊');
final initialColor = useMemoized( final initialColor = useMemoized(
@@ -176,10 +185,12 @@ class ProfileAvatarEditor extends HookConsumerWidget {
final modeTransitionFrom = useRef(mode); final modeTransitionFrom = useRef(mode);
final retainedPreview = useRef<Widget?>(null); final retainedPreview = useRef<Widget?>(null);
final retainedPreviewTop = useRef(0.0); final retainedPreviewTop = useRef(0.0);
final currentPreviewTop = useRef<double?>(null);
final modeStartPreviewTop = useRef<double?>(null);
final modeTransitionController = useAnimationController( final modeTransitionController = useAnimationController(
duration: anchoredPreview == null duration: anchoredPreview == null
? _motionDuration ? _motionDuration
: const Duration(milliseconds: 100), : const Duration(milliseconds: 200),
initialValue: 1, initialValue: 1,
); );
final modeTransitionValue = useAnimation(modeTransitionController); final modeTransitionValue = useAnimation(modeTransitionController);
@@ -189,6 +200,7 @@ class ProfileAvatarEditor extends HookConsumerWidget {
void selectMode(ProfileAvatarMode nextMode) { void selectMode(ProfileAvatarMode nextMode) {
if (nextMode == mode) return; if (nextMode == mode) return;
modeStartPreviewTop.value = currentPreviewTop.value;
modeTransitionFrom.value = mode; modeTransitionFrom.value = mode;
modeTransitionDirection.value = nextMode.index > mode.index ? 1 : -1; modeTransitionDirection.value = nextMode.index > mode.index ? 1 : -1;
unawaited(HapticFeedback.selectionClick()); unawaited(HapticFeedback.selectionClick());
@@ -406,13 +418,20 @@ class ProfileAvatarEditor extends HookConsumerWidget {
modeTop - modeTop -
(anchoredPreview?.height ?? _previewBlockSize)) / (anchoredPreview?.height ?? _previewBlockSize)) /
2; 2;
final displayedPreviewTop = final targetPreviewTop =
anchoredPreview != null && mode == ProfileAvatarMode.emoji anchoredPreview != null && mode == ProfileAvatarMode.emoji
? centeredEmojiTop ? centeredEmojiTop
: returningToEmoji : returningToEmoji
? animatedPreviewTop + ? animatedPreviewTop +
(previewTop - animatedPreviewTop) * modeTransitionProgress (previewTop - animatedPreviewTop) * modeTransitionProgress
: previewTop; : previewTop;
final displayedPreviewTop = anchoredPreview != null
? (modeStartPreviewTop.value ?? targetPreviewTop) +
(targetPreviewTop -
(modeStartPreviewTop.value ?? targetPreviewTop)) *
modeTransitionProgress
: targetPreviewTop;
currentPreviewTop.value = displayedPreviewTop;
if (mode != ProfileAvatarMode.animated) { if (mode != ProfileAvatarMode.animated) {
retainedPreviewTop.value = displayedPreviewTop; retainedPreviewTop.value = displayedPreviewTop;
} }
@@ -458,6 +477,7 @@ class ProfileAvatarEditor extends HookConsumerWidget {
selectedColor: selectedColor.value, selectedColor: selectedColor.value,
transitionProgress: modeTransitionProgress, transitionProgress: modeTransitionProgress,
transitionDirection: modeTransitionDirection.value, transitionDirection: modeTransitionDirection.value,
controlsFromBelow: anchoredPreview != null,
onSectionChanged: (section) => emojiSection.value = section, onSectionChanged: (section) => emojiSection.value = section,
onEmojiSelected: (emoji) { onEmojiSelected: (emoji) {
selectedEmoji.value = emoji; selectedEmoji.value = emoji;
@@ -478,6 +498,12 @@ class ProfileAvatarEditor extends HookConsumerWidget {
AnimatedAvatarCapture( AnimatedAvatarCapture(
previewKey: previewKey, previewKey: previewKey,
previewSize: anchoredPreview?.width, previewSize: anchoredPreview?.width,
previewOffset: anchoredPreview == null
? 0
: displayedPreviewTop - modeTop,
controlsTransitionProgress: anchoredPreview == null
? 1
: modeTransitionProgress,
height: contentHeight, height: contentHeight,
onPrepareChanged: onAnimatedPrepareChanged, onPrepareChanged: onAnimatedPrepareChanged,
disposalBarrier: cameraDisposal.value, disposalBarrier: cameraDisposal.value,
@@ -496,10 +522,14 @@ class ProfileAvatarEditor extends HookConsumerWidget {
child: Transform.translate( child: Transform.translate(
key: const ValueKey('avatar-mode-transition-transform'), key: const ValueKey('avatar-mode-transition-transform'),
offset: Offset( offset: Offset(
modeTransitionDirection.value * anchoredPreview == null
_modeTransitionDistance * ? modeTransitionDirection.value *
(1 - modeTransitionProgress), _modeTransitionDistance *
0, (1 - modeTransitionProgress)
: 0,
anchoredPreview == null
? 0
: 16 * (1 - modeTransitionProgress),
), ),
child: Opacity( child: Opacity(
key: const ValueKey('avatar-mode-transition-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( AnimatedPositioned(
key: const ValueKey('avatar-preview-position'), key: const ValueKey('avatar-preview-position'),
curve: Curves.easeOutCubic, curve: Curves.easeOutCubic,
@@ -567,7 +598,8 @@ class ProfileAvatarEditor extends HookConsumerWidget {
right: Grid.gutter, right: Grid.gutter,
top: displayedPreviewTop, top: displayedPreviewTop,
height: anchoredPreview?.height ?? _previewBlockSize, height: anchoredPreview?.height ?? _previewBlockSize,
duration: anchorMoved || returningToEmoji duration:
anchoredPreview != null || anchorMoved || returningToEmoji
? Duration.zero ? Duration.zero
: reduceMotion : reduceMotion
? Duration.zero ? Duration.zero
@@ -578,12 +610,44 @@ class ProfileAvatarEditor extends HookConsumerWidget {
child: Opacity( child: Opacity(
opacity: previewOpacity, opacity: previewOpacity,
child: KeyedSubtree( child: KeyedSubtree(
key: previewKey, key: mode == ProfileAvatarMode.animated
? null
: previewKey,
child: anchoredPreview == null child: anchoredPreview == null
? fixedPreview ? fixedPreview ?? const SizedBox.shrink()
: SizedBox.square( : SizedBox.square(
dimension: anchoredPreview.width, 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( AnimatedPositioned(
// Camera controls already morph into the source buttons before // Camera controls already morph into the source buttons before
// closing. Keep their bottom rail fixed during the handoff. // closing. Keep their bottom rail fixed during the handoff.
duration: mode == ProfileAvatarMode.image || reduceMotion duration:
anchoredPreview != null ||
mode == ProfileAvatarMode.image ||
reduceMotion
? Duration.zero ? Duration.zero
: const Duration(milliseconds: 150), : const Duration(milliseconds: 150),
curve: Curves.easeOutCubic, curve: Curves.easeOutCubic,
@@ -623,7 +690,9 @@ class ProfileAvatarEditor extends HookConsumerWidget {
height: modeHeight, height: modeHeight,
child: FadeTransition( child: FadeTransition(
opacity: curvedEntrance, opacity: curvedEntrance,
child: anchoredPreview == null child:
anchoredPreview == null ||
mode == ProfileAvatarMode.animated
? transitionedModeContent ? transitionedModeContent
: ClipRect( : ClipRect(
child: SingleChildScrollView( child: SingleChildScrollView(
@@ -643,12 +712,15 @@ class ProfileAvatarEditor extends HookConsumerWidget {
key: const ValueKey('avatar-mode-retained-preview'), key: const ValueKey('avatar-mode-retained-preview'),
left: Grid.gutter, left: Grid.gutter,
right: Grid.gutter, right: Grid.gutter,
top: top: anchoredPreview != null
retainedPreviewTop.value + ? displayedPreviewTop
((anchoredPreview == null ? basePreviewTop : previewTop) - : retainedPreviewTop.value +
retainedPreviewTop.value) * ((anchoredPreview == null
modeTransitionProgress, ? basePreviewTop
height: _previewBlockSize, : previewTop) -
retainedPreviewTop.value) *
modeTransitionProgress,
height: anchoredPreview?.height ?? _previewBlockSize,
child: IgnorePointer( child: IgnorePointer(
child: Opacity( child: Opacity(
opacity: 1 - modeTransitionProgress, opacity: 1 - modeTransitionProgress,

View File

@@ -22,6 +22,7 @@ class _EmojiMode extends HookConsumerWidget {
required this.selectedColor, required this.selectedColor,
required this.transitionProgress, required this.transitionProgress,
required this.transitionDirection, required this.transitionDirection,
this.controlsFromBelow = false,
required this.onSectionChanged, required this.onSectionChanged,
required this.onEmojiSelected, required this.onEmojiSelected,
required this.onColorSelected, required this.onColorSelected,
@@ -34,6 +35,7 @@ class _EmojiMode extends HookConsumerWidget {
final int selectedColor; final int selectedColor;
final double transitionProgress; final double transitionProgress;
final double transitionDirection; final double transitionDirection;
final bool controlsFromBelow;
final ValueChanged<_EmojiEditorSection> onSectionChanged; final ValueChanged<_EmojiEditorSection> onSectionChanged;
final ValueChanged<String> onEmojiSelected; final ValueChanged<String> onEmojiSelected;
final ValueChanged<int> onColorSelected; final ValueChanged<int> onColorSelected;
@@ -213,7 +215,9 @@ class _EmojiMode extends HookConsumerWidget {
ClipRect( ClipRect(
child: Transform.translate( child: Transform.translate(
key: const ValueKey('avatar-mode-transition-transform'), key: const ValueKey('avatar-mode-transition-transform'),
offset: Offset(actionOffset, 0), offset: controlsFromBelow
? Offset(0, trayOffset)
: Offset(actionOffset, 0),
child: Opacity( child: Opacity(
opacity: transitionProgress, opacity: transitionProgress,
child: Row( child: Row(

View File

@@ -1,4 +1,8 @@
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../shared/emoji/emoji_avatar.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -50,14 +54,23 @@ class ProfileSetupAvatarEditor extends HookConsumerWidget {
}); });
return null; return null;
}, const []); }, const []);
final draft = useState(initialDraft); final openingDraft = useMemoized(
final mode = useState(switch (initialDraft) { () =>
initialDraft ??
ProfileUrlAvatarDraft(
emojiAvatarDataUrl('😊', emojiAvatarColors[Random().nextInt(18)]),
),
const [],
);
final draft = useState<ProfileAvatarDraft?>(openingDraft);
final mode = useState(switch (openingDraft) {
ProfileUrlAvatarDraft() => ProfileAvatarMode.emoji, ProfileUrlAvatarDraft() => ProfileAvatarMode.emoji,
ProfileAnimatedAvatarDraft() => ProfileAvatarMode.animated, ProfileAnimatedAvatarDraft() => ProfileAvatarMode.animated,
_ => ProfileAvatarMode.image, _ => ProfileAvatarMode.image,
}); });
final prepare = useState<Future<ProfileAvatarDraft?> Function()?>(null); final prepare = useState<Future<ProfileAvatarDraft?> Function()?>(null);
final busy = useState(false); final busy = useState(false);
final returningToEmpty = useState(false);
final error = useState(false); final error = useState(false);
final transition = useAnimationController( final transition = useAnimationController(
initialValue: anchoredPreview == null ? 1 : 0, initialValue: anchoredPreview == null ? 1 : 0,
@@ -67,6 +80,7 @@ class ProfileSetupAvatarEditor extends HookConsumerWidget {
? null ? null
: () { : () {
if (onFinished != null) { if (onFinished != null) {
returningToEmpty.value = initialDraft == null;
onFinished!(null); onFinished!(null);
} else { } else {
Navigator.of(context).pop(); Navigator.of(context).pop();
@@ -166,9 +180,8 @@ class ProfileSetupAvatarEditor extends HookConsumerWidget {
return Theme( return Theme(
data: utilitySurfaceThemeData(Theme.of(context)), data: utilitySurfaceThemeData(Theme.of(context)),
child: Scaffold( child: Scaffold(
backgroundColor: utilitySurfaceThemeData( // Native profile chrome returns underneath the exiting controls.
Theme.of(context), backgroundColor: Colors.transparent,
).colorScheme.surface,
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
body: Column( body: Column(
children: [ children: [
@@ -186,6 +199,7 @@ class ProfileSetupAvatarEditor extends HookConsumerWidget {
: null, : null,
fallbackInitial: '+', fallbackInitial: '+',
emptyPreview: const ProfileSetupEmptyAvatar(), emptyPreview: const ProfileSetupEmptyAvatar(),
returningToEmpty: returningToEmpty.value,
draft: draft.value, draft: draft.value,
mode: mode.value, mode: mode.value,
transition: transition, transition: transition,

View File

@@ -1,4 +1,5 @@
import 'package:buzz/features/profile/profile_setup_avatar_editor.dart'; 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/features/profile/profile_avatar_draft.dart';
import 'package:buzz/shared/emoji/emoji_avatar.dart'; import 'package:buzz/shared/emoji/emoji_avatar.dart';
import 'package:buzz/features/profile/profile_avatar_editor.dart'; import 'package:buzz/features/profile/profile_avatar_editor.dart';
@@ -44,6 +45,43 @@ void main() {
} }
await open(null); await open(null);
final openingEditor = tester.widget<ProfileAvatarEditor>(
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<TextButton>(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<ProfileAvatarEditor>(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 tester
.widget<ProfileAvatarEditor>(find.byType(ProfileAvatarEditor)) .widget<ProfileAvatarEditor>(find.byType(ProfileAvatarEditor))
.onModeChanged(ProfileAvatarMode.animated); .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<void> 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<ProfileAvatarEditor>(find.byType(ProfileAvatarEditor))
.draft,
isNull,
);
}
await send('back');
await tester.pump();
double emptyOpacity() => tester
.widget<Opacity>(
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<Opacity>(
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', ( testWidgets('avatar draft editor lays out within a full height sheet', (
tester, tester,
) async { ) async {
@@ -111,6 +224,10 @@ void main() {
expect(previewRect!.width, greaterThan(0)); expect(previewRect!.width, greaterThan(0));
expect(previewRect!.height, greaterThan(0)); expect(previewRect!.height, greaterThan(0));
if (anchor != null) { if (anchor != null) {
expect(
tester.widget<Scaffold>(find.byType(Scaffold)).backgroundColor,
Colors.transparent,
);
expect(previewRect, anchor); expect(previewRect, anchor);
final circle = tester.getRect( final circle = tester.getRect(
find.byKey(const ValueKey('avatar-preview-position')), find.byKey(const ValueKey('avatar-preview-position')),
@@ -148,6 +265,97 @@ void main() {
expect(tester.getSize(visibleAvatar), const Size.square(220)); expect(tester.getSize(visibleAvatar), const Size.square(220));
await tester.pump(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100));
expect(tester.getTopLeft(visibleAvatar).dy, closeTo(66, 0.01)); 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<Transform>(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<Transform>(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<Transform>(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. // Empty native previews start 15% smaller, then reach editor size.
await tester.binding.defaultBinaryMessenger.handlePlatformMessage( await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
'buzz/profile_avatar_sheet', 'buzz/profile_avatar_sheet',
@@ -216,6 +424,13 @@ void main() {
await tester.tap(find.text('Image')); await tester.tap(find.text('Image'));
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 200)); await tester.pump(const Duration(milliseconds: 200));
expect(find.byType(ProfileSetupEmptyAvatar), findsNothing);
expect(
tester
.widget<ProfileAvatarEditor>(find.byType(ProfileAvatarEditor))
.draft,
isNull,
);
expect( expect(
tester tester
.getTopLeft(find.byKey(const ValueKey('avatar-preview-position'))) .getTopLeft(find.byKey(const ValueKey('avatar-preview-position')))