Skip to content
Merged
13 changes: 11 additions & 2 deletions mobile/ios/Runner/NativeProfileSetup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand All @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore the placeholder during the cancel animation

When the user opens a fresh avatar editor and presses Back, revealEditor has hidden the UIKit avatar, and this return path does not unhide it until restoreProfilePage runs after Flutter's 200 ms hide completes. The Flutter preview also remains fully opaque, so the suggested emoji collapses and then abruptly swaps to the dotted placeholder at teardown instead of performing the intended crossfade. The cancellation path needs to reveal/fade the native placeholder while the Flutter preview exits.

Useful? React with 👍 / 👎.

let frame = controller.editorFrame
let rect = controller.avatarFrame(in: controller.view).offsetBy(dx: -frame.minX, dy: -frame.minY)
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion mobile/ios/RunnerTests/ProfileSetupRecoveryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
170 changes: 98 additions & 72 deletions mobile/lib/features/profile/animated_avatar_capture.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;

Expand Down Expand Up @@ -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',
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(
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,
),
),
)
: 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,
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(),
),
);
}
}
Expand Down
Loading
Loading