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 }
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

View File

@@ -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)

View File

@@ -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(),
),
);
}
}

View File

@@ -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,

View File

@@ -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(

View File

@@ -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,

View File

@@ -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')))