mirror of
https://github.com/block/buzz.git
synced 2026-11-01 06:51:17 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Widget?>(null);
|
||||
final retainedPreviewTop = useRef(0.0);
|
||||
final currentPreviewTop = useRef<double?>(null);
|
||||
final modeStartPreviewTop = useRef<double?>(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,
|
||||
|
||||
@@ -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<String> onEmojiSelected;
|
||||
final ValueChanged<int> 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(
|
||||
|
||||
@@ -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<ProfileAvatarDraft?>(openingDraft);
|
||||
final mode = useState(switch (openingDraft) {
|
||||
ProfileUrlAvatarDraft() => ProfileAvatarMode.emoji,
|
||||
ProfileAnimatedAvatarDraft() => ProfileAvatarMode.animated,
|
||||
_ => ProfileAvatarMode.image,
|
||||
});
|
||||
final prepare = useState<Future<ProfileAvatarDraft?> 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,
|
||||
|
||||
@@ -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<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
|
||||
.widget<ProfileAvatarEditor>(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<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', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -111,6 +224,10 @@ void main() {
|
||||
expect(previewRect!.width, greaterThan(0));
|
||||
expect(previewRect!.height, greaterThan(0));
|
||||
if (anchor != null) {
|
||||
expect(
|
||||
tester.widget<Scaffold>(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<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.
|
||||
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<ProfileAvatarEditor>(find.byType(ProfileAvatarEditor))
|
||||
.draft,
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.getTopLeft(find.byKey(const ValueKey('avatar-preview-position')))
|
||||
|
||||
Reference in New Issue
Block a user