mirror of
https://github.com/block/buzz.git
synced 2026-11-13 04:41:12 +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 }
|
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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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')))
|
||||||
|
|||||||
Reference in New Issue
Block a user