mirror of
https://github.com/block/buzz.git
synced 2026-10-28 04:51:40 +00:00
Replace the iOS profile setup sheet with a full-screen name → avatar flow. Require a display name, enable avatar Continue after Done, and let Skip save the name without an avatar. After a failed save, Not now continues without saving the draft. Embed photo, emoji, and animated avatar editing with native Back/Done, matching 220-point previews, a rounded-notch pencil edit badge, an entry haptic, equal emoji-preview spacing, and 200 ms transitions that move the avatar into its editor position without resizing. Apply appearance changes live across setup and editing while preserving the draft. Keep the camera exit rail stationary, use a solid shutter, and present photo positioning full-screen with Back/Done. Preserve the main Flutter view during presentation and hide underlying controls from VoiceOver while editing. Validation: Flutter analysis, 3,796 Flutter tests, and 106 native tests passed. Built, installed, and launched the isolated iPhone preview; inspected the native emoji/badge rendering. The local preview entry point is excluded. Draft pending real login/join → profile-save verification and owner verification of the updated device flow. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
258 lines
13 KiB
Swift
258 lines
13 KiB
Swift
import Flutter
|
|
import UIKit
|
|
import XCTest
|
|
|
|
@testable import Buzz
|
|
|
|
final class ProfileSetupRecoveryTests: XCTestCase {
|
|
@MainActor
|
|
func testSelectedEditorIconsContrastInBothAppearances() throws {
|
|
for dark in [false, true] {
|
|
for icon in ["emoji", "palette"] {
|
|
let control = NavigationGlassButtonPlatformView(
|
|
frame: CGRect(x: 0, y: 0, width: 64, height: 64), viewIdentifier: 911,
|
|
arguments: ["icon": icon, "controlSize": 64, "selected": true,
|
|
"brightness": dark ? "dark" : "light", "foregroundColor": dark ? 0xFFFFFFFF : 0xFF000000],
|
|
messenger: ProfileSetupTestMessenger())
|
|
let button = try XCTUnwrap(descendants(control.view()).compactMap { $0 as? UIButton }.first)
|
|
let image = try XCTUnwrap(button.configuration?.image)
|
|
XCTAssertEqual(image.renderingMode, .alwaysOriginal)
|
|
let rendered = UIGraphicsImageRenderer(size: image.size).image { _ in
|
|
image.draw(at: .zero)
|
|
}
|
|
let cgImage = try XCTUnwrap(rendered.cgImage)
|
|
let width = cgImage.width, height = cgImage.height
|
|
var pixels = [UInt8](repeating: 0, count: width * height * 4)
|
|
try pixels.withUnsafeMutableBytes { bytes in
|
|
let context = try XCTUnwrap(CGContext(data: bytes.baseAddress, width: width, height: height,
|
|
bitsPerComponent: 8, bytesPerRow: width * 4, space: CGColorSpaceCreateDeviceRGB(),
|
|
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue))
|
|
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: CGFloat(width), height: CGFloat(height)))
|
|
}
|
|
let opaque = stride(from: 0, to: pixels.count, by: 4).filter { pixels[$0 + 3] > 250 }
|
|
XCTAssertFalse(opaque.isEmpty)
|
|
XCTAssertTrue(opaque.allSatisfy { pixels[$0] == (dark ? 0 : pixels[$0 + 3]) })
|
|
let attachment = XCTAttachment(image: rendered)
|
|
attachment.name = "Selected \(icon) dark=\(dark)"
|
|
attachment.lifetime = .keepAlways
|
|
add(attachment)
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func testCameraShutterHasAnOpaqueCenter() throws {
|
|
let control = NavigationGlassButtonPlatformView(
|
|
frame: CGRect(x: 0, y: 0, width: 100, height: 100), viewIdentifier: 910,
|
|
arguments: ["icon": "shutter", "controlSize": 100, "accessibilityLabel": "Take photo"],
|
|
messenger: ProfileSetupTestMessenger())
|
|
let button = try XCTUnwrap(descendants(control.view()).compactMap { $0 as? UIButton }.first)
|
|
let image = try XCTUnwrap(button.configuration?.image?.cgImage)
|
|
var pixel = [UInt8](repeating: 0, count: 4)
|
|
let context = try XCTUnwrap(CGContext(data: &pixel, width: 1, height: 1, bitsPerComponent: 8,
|
|
bytesPerRow: 4, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue))
|
|
context.translateBy(x: -CGFloat(image.width / 2), y: -CGFloat(image.height / 2))
|
|
context.draw(image, in: CGRect(x: 0, y: 0, width: CGFloat(image.width), height: CGFloat(image.height)))
|
|
XCTAssertEqual(pixel[3], 255, "The shutter must be filled, not an outlined circle")
|
|
let attachment = XCTAttachment(image: UIImage(cgImage: image))
|
|
attachment.lifetime = .keepAlways
|
|
add(attachment)
|
|
}
|
|
|
|
@MainActor
|
|
func testStartedEditorWithoutReadyRestoresProfileAndAllowsRetry() async throws {
|
|
let messenger = ProfileSetupTestMessenger()
|
|
let parent = UIViewController()
|
|
let window = UIWindow(frame: UIScreen.main.bounds)
|
|
window.windowScene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
|
|
window.rootViewController = parent
|
|
window.makeKeyAndVisible()
|
|
defer { window.isHidden = true }
|
|
var watchdogs: [() -> Void] = []
|
|
weak var engine: FlutterEngine?
|
|
let coordinator = NativeProfileSetupCoordinator(messenger: messenger,
|
|
scheduleEditorWatchdog: { watchdogs.append($0) },
|
|
startEditorEngine: {
|
|
engine = $0
|
|
// Start the normal app entry point: it never sends avatar-editor ready.
|
|
return $0.run()
|
|
}, configureEditorEngine: { _ in })
|
|
messenger.invoke("present", arguments: ["session": 81, "dark": true, "primaryArgb": 0xFFFFFFFF, "onPrimaryArgb": 0xFF000000])
|
|
let nav = try XCTUnwrap(parent.presentedViewController as? UINavigationController)
|
|
if let transition = nav.transitionCoordinator {
|
|
await withCheckedContinuation { continuation in
|
|
transition.animate(alongsideTransition: nil) { _ in continuation.resume() }
|
|
}
|
|
}
|
|
let profile = try XCTUnwrap(nav.viewControllers.first)
|
|
let avatar = try XCTUnwrap(descendants(profile.view).compactMap { $0 as? UIButton }
|
|
.first { $0.accessibilityLabel == "Add profile photo" })
|
|
XCTAssertEqual(nav.modalPresentationStyle, .overFullScreen)
|
|
XCTAssertNil(profile.navigationItem.rightBarButtonItem)
|
|
let name = try XCTUnwrap(descendants(profile.view).compactMap { $0 as? UITextField }.first)
|
|
let submit = try XCTUnwrap(descendants(profile.view).compactMap { $0 as? UIButton }
|
|
.first { $0.configuration?.title == "Continue" })
|
|
let skip = try XCTUnwrap(descendants(profile.view).compactMap { $0 as? UIButton }
|
|
.first { $0.configuration?.title == "Skip" })
|
|
let foreground = try XCTUnwrap(submit.configuration?.baseForegroundColor)
|
|
var red: CGFloat = 1, green: CGFloat = 1, blue: CGFloat = 1, alpha: CGFloat = 0
|
|
XCTAssertTrue(foreground.getRed(&red, green: &green, blue: &blue, alpha: &alpha))
|
|
XCTAssertEqual(red, 0); XCTAssertEqual(green, 0); XCTAssertEqual(blue, 0); XCTAssertEqual(alpha, 1)
|
|
XCTAssertTrue(skip.isHidden)
|
|
XCTAssertTrue(avatar.superview!.isHidden)
|
|
XCTAssertFalse(submit.isEnabled)
|
|
name.text = "Kenny"
|
|
name.sendActions(for: .editingChanged)
|
|
submit.sendActions(for: .touchUpInside)
|
|
XCTAssertNotNil(engine, "Prepare the editor before the avatar is tapped")
|
|
XCTAssertNil(profile.navigationItem.rightBarButtonItem)
|
|
XCTAssertEqual(submit.alpha, 1)
|
|
XCTAssertTrue(name.isHidden)
|
|
XCTAssertFalse(skip.isHidden)
|
|
XCTAssertFalse(submit.isEnabled, "Continue requires an avatar saved from the editor")
|
|
XCTAssertFalse(avatar.superview!.isHidden)
|
|
profile.view.layoutIfNeeded()
|
|
XCTAssertEqual(avatar.bounds.width, 220, accuracy: 0.5)
|
|
XCTAssertEqual(avatar.transform.a, 0.85, accuracy: 0.001)
|
|
XCTAssertTrue(descendants(profile.view).compactMap { $0 as? UILabel }
|
|
.contains { $0.text == "Choose your avatar" && $0.accessibilityTraits.contains(.header) })
|
|
XCTAssertTrue(avatar.point(inside: CGPoint(x: 4, y: 110), with: nil))
|
|
XCTAssertTrue(avatar.point(inside: CGPoint(x: 110, y: 110), with: nil))
|
|
let pan = UIPanGestureRecognizer()
|
|
let disabledPan = UIPanGestureRecognizer()
|
|
disabledPan.isEnabled = false
|
|
nav.view.addGestureRecognizer(pan)
|
|
nav.view.addGestureRecognizer(disabledPan)
|
|
nav.interactivePopGestureRecognizer?.isEnabled = true
|
|
for _ in 0..<2 {
|
|
autoreleasepool {
|
|
avatar.sendActions(for: .touchUpInside)
|
|
XCTAssertNotNil(engine)
|
|
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 })
|
|
XCTAssertFalse(pan.isEnabled)
|
|
XCTAssertFalse(nav.interactivePopGestureRecognizer?.isEnabled ?? true)
|
|
XCTAssertTrue(nav.isModalInPresentation)
|
|
XCTAssertFalse(nav.isNavigationBarHidden)
|
|
if watchdogs.count > 1 {
|
|
// An old attempt's watchdog cannot tear down the successful retry.
|
|
watchdogs[0]()
|
|
XCTAssertTrue(profile.children.contains { $0 is FlutterViewController })
|
|
XCTAssertNotNil(engine)
|
|
}
|
|
watchdogs.last?()
|
|
XCTAssertTrue(nav.topViewController === profile)
|
|
XCTAssertFalse(nav.isNavigationBarHidden)
|
|
XCTAssertEqual(nav.modalPresentationStyle, .overFullScreen)
|
|
XCTAssertTrue(pan.isEnabled)
|
|
XCTAssertFalse(disabledPan.isEnabled)
|
|
XCTAssertTrue(nav.interactivePopGestureRecognizer?.isEnabled ?? false)
|
|
XCTAssertFalse(nav.isModalInPresentation)
|
|
XCTAssertTrue(avatar.isEnabled)
|
|
XCTAssertFalse(avatar.isHidden)
|
|
}
|
|
// UIKit releases popped controllers at the end of its autorelease turn.
|
|
let released = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in engine == nil }, object: nil)
|
|
await fulfillment(of: [released], timeout: 2)
|
|
}
|
|
XCTAssertEqual(watchdogs.count, 2)
|
|
skip.sendActions(for: .touchUpInside)
|
|
XCTAssertEqual(messenger.savedArguments?["name"] as? String, "Kenny")
|
|
XCTAssertEqual(messenger.savedArguments?["skipAvatar"] as? Bool, true)
|
|
XCTAssertTrue(skip.isEnabled, "Failed name-only save must remain retryable through Skip")
|
|
XCTAssertFalse(submit.isEnabled)
|
|
let notNow = try XCTUnwrap(descendants(profile.view).compactMap { $0 as? UIButton }
|
|
.first { $0.configuration?.title == "Not now" })
|
|
XCTAssertFalse(notNow.isHidden)
|
|
XCTAssertTrue(notNow.isEnabled)
|
|
notNow.sendActions(for: .touchUpInside)
|
|
let dismissed = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in parent.presentedViewController == nil }, object: nil)
|
|
await fulfillment(of: [dismissed], timeout: 2)
|
|
XCTAssertEqual(messenger.completionAction, "skip")
|
|
withExtendedLifetime(coordinator) {}
|
|
nav.dismiss(animated: false)
|
|
}
|
|
|
|
@MainActor
|
|
func testSelectedEmojiShowsEditablePreview() async throws {
|
|
let messenger = ProfileSetupTestMessenger()
|
|
let parent = UIViewController()
|
|
let window = UIWindow(frame: UIScreen.main.bounds)
|
|
window.windowScene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
|
|
window.rootViewController = parent
|
|
window.makeKeyAndVisible()
|
|
defer { window.isHidden = true }
|
|
let coordinator = NativeProfileSetupCoordinator(messenger: messenger, startEditorEngine: { _ in false }, configureEditorEngine: { _ in })
|
|
messenger.invoke("present", arguments: ["session": 82, "name": "Kenny", "emoji": "😊", "emojiColor": 0xFFFFCC00, "primaryArgb": 0xFF000000, "onPrimaryArgb": 0xFFFFFFFF])
|
|
let nav = try XCTUnwrap(parent.presentedViewController as? UINavigationController)
|
|
if let transition = nav.transitionCoordinator {
|
|
await withCheckedContinuation { continuation in
|
|
transition.animate(alongsideTransition: nil) { _ in continuation.resume() }
|
|
}
|
|
}
|
|
let profile = try XCTUnwrap(nav.viewControllers.first)
|
|
let avatar = try XCTUnwrap(descendants(profile.view).compactMap { $0 as? UIButton }
|
|
.first { $0.accessibilityLabel == "Edit profile photo" })
|
|
let submit = try XCTUnwrap(descendants(profile.view).compactMap { $0 as? UIButton }
|
|
.first { $0.configuration?.title == "Continue" })
|
|
submit.sendActions(for: .touchUpInside)
|
|
XCTAssertTrue(submit.isEnabled)
|
|
for dark in [true, false] {
|
|
let background: UInt32 = dark ? 0xFF000000 : 0xFFFFFFFF
|
|
messenger.invoke("appearance", arguments: ["session": 82, "dark": dark, "pageBackgroundArgb": background,
|
|
"primaryArgb": dark ? 0xFFFFFFFF : 0xFF000000, "onPrimaryArgb": background])
|
|
XCTAssertTrue(parent.presentedViewController === nav)
|
|
XCTAssertEqual(nav.overrideUserInterfaceStyle, dark ? .dark : .light)
|
|
XCTAssertTrue(submit.isEnabled)
|
|
XCTAssertEqual(avatar.accessibilityLabel, "Edit profile photo")
|
|
let expected = dark ? UIColor.black : UIColor.white
|
|
var actualWhite: CGFloat = -1, alpha: CGFloat = 0
|
|
XCTAssertTrue(profile.view.backgroundColor!.getWhite(&actualWhite, alpha: &alpha))
|
|
var expectedWhite: CGFloat = -1
|
|
expected.getWhite(&expectedWhite, alpha: &alpha)
|
|
XCTAssertEqual(actualWhite, expectedWhite, accuracy: 0.001)
|
|
}
|
|
let image = try XCTUnwrap(avatar.image(for: .normal))
|
|
XCTAssertEqual(image.size, CGSize(width: 220, height: 220))
|
|
let attachment = XCTAttachment(image: image)
|
|
attachment.name = "Selected emoji with edit badge"
|
|
attachment.lifetime = .keepAlways
|
|
add(attachment)
|
|
withExtendedLifetime(coordinator) {}
|
|
nav.dismiss(animated: false)
|
|
}
|
|
|
|
@MainActor
|
|
private func descendants(_ view: UIView) -> [UIView] {
|
|
view.subviews.flatMap { [$0] + descendants($0) }
|
|
}
|
|
}
|
|
|
|
private final class ProfileSetupTestMessenger: NSObject, FlutterBinaryMessenger {
|
|
private var handler: FlutterBinaryMessageHandler?
|
|
var savedArguments: [String: Any]?
|
|
var completionAction: String?
|
|
func send(onChannel channel: String, message: Data?) {}
|
|
func send(onChannel channel: String, message: Data?, binaryReply callback: FlutterBinaryReply?) {
|
|
let codec = FlutterStandardMethodCodec.sharedInstance()
|
|
if let message {
|
|
let call = codec.decodeMethodCall(message)
|
|
if call.method == "save" { savedArguments = call.arguments as? [String: Any] }
|
|
}
|
|
callback?(codec.encodeSuccessEnvelope(["saved": false]))
|
|
}
|
|
func setMessageHandlerOnChannel(_ channel: String, binaryMessageHandler handler: FlutterBinaryMessageHandler?) -> FlutterBinaryMessengerConnection {
|
|
self.handler = handler
|
|
return 1
|
|
}
|
|
func cleanUpConnection(_ connection: FlutterBinaryMessengerConnection) { handler = nil }
|
|
func invoke(_ method: String, arguments: [String: Any]) {
|
|
let codec = FlutterStandardMethodCodec.sharedInstance()
|
|
handler?(codec.encode(FlutterMethodCall(methodName: method, arguments: arguments))) { data in
|
|
if let data { self.completionAction = try? codec.decodeEnvelope(data) as? String }
|
|
}
|
|
}
|
|
}
|