mirror of
https://github.com/block/buzz.git
synced 2026-11-12 04:12:21 +00:00
Polish mobile onboarding and profile setup (#8438)
Originally stacked on #8375, now merged. Rebased onto main. Polishes the onboarding landing page, community-link entry, and recovery sheets. Profile setup uses a consistent sheet with a larger avatar, inline display name, and draft photo/emoji/animated editing; iOS uses native controls and coordinated avatar/sheet transitions. Validation: mobile analysis, full tests, and normal pre-push checks passed after rebase. Prior iOS build and device walkthrough passed; native validation was not repeated after this rebase. Snapshots omitted as requested. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Signed-off-by: Tom Brow <tomb@block.xyz> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> Co-authored-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
F84380010000000000000001 /* ProfileSetupRecoveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F84380010000000000000002 /* ProfileSetupRecoveryTests.swift */; };
|
||||
BBAA00000000000000000011 /* IdentityFilePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBAA00000000000000000012 /* IdentityFilePlugin.swift */; };
|
||||
BBAA00000000000000000001 /* HostedHpkeBackup.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBAA00000000000000000002 /* HostedHpkeBackup.swift */; };
|
||||
DAAB00000000000000000001 /* NativeTabler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAAB00000000000000000002; };
|
||||
@@ -35,6 +36,8 @@
|
||||
4A71C0152F40B00100A17E01 /* HuddleMediaPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0162F40B00100A17E01 /* HuddleMediaPlugin.swift */; };
|
||||
4A71C0172F40C00100A17E01 /* HuddleAudioEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0182F40C00100A17E01 /* HuddleAudioEngine.swift */; };
|
||||
4A71C0192F40D00100A17E01 /* NativeProfileTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */; };
|
||||
7B81D0192F40D00100A17E01 /* NativeCommunityEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B81D01A2F40D00100A17E01 /* NativeCommunityEntry.swift */; };
|
||||
7B81D0192F40D00100A17E91 /* NativeProfileSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B81D01A2F40D00100A17E91 /* NativeProfileSetup.swift */; };
|
||||
4A71C01B2F40E00100A17E01 /* NativeSkinToneControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */; };
|
||||
4A71C01D2F40F00100A17E01 /* ThemePaginationGlassControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01E2F40F00100A17E01 /* ThemePaginationGlassControl.swift */; };
|
||||
CA8300012F50000100A17E01 /* IosMediaCarousel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA8300022F50000100A17E01 /* IosMediaCarousel.swift */; };
|
||||
@@ -98,6 +101,7 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
F84380010000000000000002 /* ProfileSetupRecoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSetupRecoveryTests.swift; sourceTree = "<group>"; };
|
||||
BBAA00000000000000000012 /* IdentityFilePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityFilePlugin.swift; sourceTree = "<group>"; };
|
||||
BBAA00000000000000000002 /* HostedHpkeBackup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostedHpkeBackup.swift; sourceTree = "<group>"; };
|
||||
DAAB00000000000000000002 /* NativeTabler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTabler.swift; sourceTree = "<group>"; };
|
||||
@@ -129,6 +133,8 @@
|
||||
4A71C0162F40B00100A17E01 /* HuddleMediaPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HuddleMediaPlugin.swift; sourceTree = "<group>"; };
|
||||
4A71C0182F40C00100A17E01 /* HuddleAudioEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HuddleAudioEngine.swift; sourceTree = "<group>"; };
|
||||
4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeProfileTextEditor.swift; sourceTree = "<group>"; };
|
||||
7B81D01A2F40D00100A17E01 /* NativeCommunityEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeCommunityEntry.swift; sourceTree = "<group>"; };
|
||||
7B81D01A2F40D00100A17E91 /* NativeProfileSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeProfileSetup.swift; sourceTree = "<group>"; };
|
||||
4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSkinToneControl.swift; sourceTree = "<group>"; };
|
||||
4A71C01E2F40F00100A17E01 /* ThemePaginationGlassControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePaginationGlassControl.swift; sourceTree = "<group>"; };
|
||||
CA8300022F50000100A17E01 /* IosMediaCarousel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IosMediaCarousel.swift; sourceTree = "<group>"; };
|
||||
@@ -202,6 +208,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */,
|
||||
F84380010000000000000002 /* ProfileSetupRecoveryTests.swift */,
|
||||
AB1200132F40400100A17E01 /* NativeMessageImageLoaderTests.swift */,
|
||||
AB1200132F40400100A17E09 /* IdentityFilePluginTests.swift */,
|
||||
BZZ0000000000000000002C /* BuzzCommunicationNotificationTests.swift */,
|
||||
@@ -305,6 +312,8 @@
|
||||
C0AF00000000000000000022 /* NativePresenceMenu.swift */,
|
||||
C0AF00000000000000000012 /* NativeAddMembersSheet.swift */,
|
||||
4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */,
|
||||
7B81D01A2F40D00100A17E01 /* NativeCommunityEntry.swift */,
|
||||
7B81D01A2F40D00100A17E91 /* NativeProfileSetup.swift */,
|
||||
4A71C01A2F40D00100A17E02 /* MeAvatarLoader.swift */,
|
||||
4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */,
|
||||
4A71C01E2F40F00100A17E01 /* ThemePaginationGlassControl.swift */,
|
||||
@@ -609,6 +618,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
|
||||
F84380010000000000000001 /* ProfileSetupRecoveryTests.swift in Sources */,
|
||||
AB1200032F40400100A17E01 /* NativeMessageImageLoaderTests.swift in Sources */,
|
||||
AB1200032F40400100A17E09 /* IdentityFilePluginTests.swift in Sources */,
|
||||
BZZ0000000000000000002B /* BuzzCommunicationNotificationTests.swift in Sources */,
|
||||
@@ -647,6 +657,8 @@
|
||||
C0AF00000000000000000021 /* NativePresenceMenu.swift in Sources */,
|
||||
C0AF00000000000000000011 /* NativeAddMembersSheet.swift in Sources */,
|
||||
4A71C0192F40D00100A17E01 /* NativeProfileTextEditor.swift in Sources */,
|
||||
7B81D0192F40D00100A17E01 /* NativeCommunityEntry.swift in Sources */,
|
||||
7B81D0192F40D00100A17E91 /* NativeProfileSetup.swift in Sources */,
|
||||
4A71C0192F40D00100A17E02 /* MeAvatarLoader.swift in Sources */,
|
||||
4A71C01B2F40E00100A17E01 /* NativeSkinToneControl.swift in Sources */,
|
||||
4A71C01D2F40F00100A17E01 /* ThemePaginationGlassControl.swift in Sources */,
|
||||
|
||||
@@ -30,6 +30,8 @@ import os.log
|
||||
endpointGrantStore: endpointGrantStore,
|
||||
keychainAccessGroup: pushKeychainAccessGroup
|
||||
)
|
||||
private var communityKeyboardChannel: FlutterMethodChannel?
|
||||
private var communityKeyboardObserver: NSObjectProtocol?
|
||||
private var hapticsChannel: FlutterMethodChannel?
|
||||
private var qrScannerChannel: FlutterMethodChannel?
|
||||
private var inlinePhotoPickerSupportChannel: FlutterMethodChannel?
|
||||
@@ -44,6 +46,8 @@ import os.log
|
||||
private var nativeEmojiPickerCoordinator: NativeEmojiPickerCoordinator?
|
||||
private var nativeAddMembersSheetCoordinator: NativeAddMembersSheetCoordinator?
|
||||
private var nativeConfirmationDialogCoordinator: NativeConfirmationDialogCoordinator?
|
||||
private var nativeProfileSetupCoordinator: NativeProfileSetupCoordinator?
|
||||
private var nativeCommunityEntryCoordinator: NativeCommunityEntryCoordinator?
|
||||
private var nativeProfileTextEditorCoordinator: NativeProfileTextEditorCoordinator?
|
||||
private var nativeMessageActionSurfaceSupportChannel: FlutterMethodChannel?
|
||||
private var identityFilePlugin: IdentityFilePlugin?
|
||||
@@ -68,6 +72,20 @@ import os.log
|
||||
}
|
||||
|
||||
let messenger = engineBridge.applicationRegistrar.messenger()
|
||||
let keyboardChannel = FlutterMethodChannel(
|
||||
name: "buzz/community_keyboard", binaryMessenger: messenger)
|
||||
communityKeyboardChannel = keyboardChannel
|
||||
if let observer = communityKeyboardObserver {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
communityKeyboardObserver = NotificationCenter.default.addObserver(
|
||||
forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main
|
||||
) { _ in
|
||||
// Deliver after UIKit finishes preparing the keyboard in this run-loop turn.
|
||||
DispatchQueue.main.async {
|
||||
keyboardChannel.invokeMethod("willShow", arguments: nil)
|
||||
}
|
||||
}
|
||||
identityFilePlugin = IdentityFilePlugin(messenger: messenger)
|
||||
browserAuthBridge = BuzzBrowserAuthBridge(messenger: messenger)
|
||||
hostedHpkeChannel = FlutterMethodChannel(name: "buzz/hosted_hpke", binaryMessenger: messenger)
|
||||
@@ -292,6 +310,12 @@ import os.log
|
||||
)?.viewController
|
||||
)
|
||||
|
||||
nativeProfileSetupCoordinator = NativeProfileSetupCoordinator(messenger: messenger) { [weak self] engine in
|
||||
let channel = FlutterMethodChannel(name: "buzz/media_upload", binaryMessenger: engine.binaryMessenger)
|
||||
channel.setMethodCallHandler { [weak self] call, result in self?.handleMediaUploadMethodCall(call, result: result) }
|
||||
}
|
||||
nativeCommunityEntryCoordinator = NativeCommunityEntryCoordinator(messenger: messenger)
|
||||
|
||||
let nativeProfileTextEditorRegistrar = engineBridge.pluginRegistry.registrar(
|
||||
forPlugin: "BuzzNativeProfileTextEditor"
|
||||
)
|
||||
|
||||
395
mobile/ios/Runner/NativeCommunityEntry.swift
Normal file
395
mobile/ios/Runner/NativeCommunityEntry.swift
Normal file
@@ -0,0 +1,395 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import CoreText
|
||||
|
||||
/// One system sheet owns both the identity choices and the import detent.
|
||||
final class NativeCommunityEntryCoordinator: NSObject, UIAdaptivePresentationControllerDelegate {
|
||||
private let channel: FlutterMethodChannel
|
||||
private var pending: FlutterResult?
|
||||
private var navigation: UINavigationController?
|
||||
private var session: Int?
|
||||
|
||||
init(messenger: FlutterBinaryMessenger) {
|
||||
channel = FlutterMethodChannel(name: "buzz/community_entry", binaryMessenger: messenger)
|
||||
super.init()
|
||||
channel.setMethodCallHandler { [weak self] call, result in
|
||||
guard let self else { return }
|
||||
guard call.method == "present" else { result(FlutterMethodNotImplemented); return }
|
||||
guard self.pending == nil else {
|
||||
result(FlutterError(code: "already_presented", message: nil, details: nil)); return
|
||||
}
|
||||
guard let args = call.arguments as? [String: Any], let session = args["session"] as? Int else {
|
||||
result(FlutterError(code: "invalid_arguments", message: nil, details: nil)); return
|
||||
}
|
||||
let root = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
|
||||
.filter { $0.activationState == .foregroundActive }.flatMap(\.windows)
|
||||
.first(where: \.isKeyWindow)?.rootViewController
|
||||
guard var presenter = root else {
|
||||
result(FlutterError(code: "presentation_failed", message: nil, details: nil)); return
|
||||
}
|
||||
while let presented = presenter.presentedViewController { presenter = presented }
|
||||
let controller = NativeCommunityEntryController(configuration: args)
|
||||
controller.action = { [weak self] method, payload, completion in
|
||||
guard let self, self.session == session else { return }
|
||||
var arguments = payload
|
||||
arguments["session"] = session
|
||||
self.channel.invokeMethod(method, arguments: arguments) { value in
|
||||
guard self.session == session else { return }
|
||||
completion(value as? [String: Any] ?? ["error": "Could not finish. Try again."])
|
||||
}
|
||||
}
|
||||
controller.finish = { [weak self] joined in self?.finish(joined) }
|
||||
let navigation = UINavigationController(rootViewController: controller)
|
||||
navigation.modalPresentationStyle = .pageSheet
|
||||
navigation.overrideUserInterfaceStyle = args["dark"] as? Bool == true ? .dark : .light
|
||||
self.pending = result
|
||||
self.session = session
|
||||
self.navigation = navigation
|
||||
controller.configureDetents()
|
||||
presenter.present(navigation, animated: !UIAccessibility.isReduceMotionEnabled)
|
||||
navigation.presentationController?.delegate = self
|
||||
}
|
||||
}
|
||||
|
||||
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
|
||||
cancel()
|
||||
}
|
||||
|
||||
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
|
||||
complete(false)
|
||||
}
|
||||
|
||||
private func cancel() {
|
||||
guard let session else { return }
|
||||
channel.invokeMethod("cancel", arguments: ["session": session])
|
||||
}
|
||||
|
||||
private func finish(_ joined: Bool) {
|
||||
if !joined { cancel() }
|
||||
navigation?.dismiss(animated: !UIAccessibility.isReduceMotionEnabled) { [weak self] in
|
||||
self?.complete(joined)
|
||||
}
|
||||
}
|
||||
|
||||
private func complete(_ joined: Bool) {
|
||||
let result = pending
|
||||
pending = nil
|
||||
navigation = nil
|
||||
session = nil
|
||||
result?(joined)
|
||||
}
|
||||
}
|
||||
|
||||
private final class NativeCommunityEntryController: UIViewController, UITextFieldDelegate {
|
||||
var action: ((String, [String: Any], @escaping ([String: Any]) -> Void) -> Void)?
|
||||
var finish: ((Bool) -> Void)?
|
||||
private let configuration: [String: Any]
|
||||
private let stack = UIStackView()
|
||||
private let scroll = UIScrollView()
|
||||
private let input = UITextField()
|
||||
private let status = UILabel()
|
||||
private let preview = UILabel()
|
||||
private var submit: UIButton?
|
||||
private var buttons: [UIButton] = []
|
||||
private var importing = false
|
||||
private var busy = false
|
||||
private var generation = 0
|
||||
private var confirmed = false
|
||||
private var measuredHeader: CGFloat = 56
|
||||
private let compact = UISheetPresentationController.Detent.Identifier("join")
|
||||
private var invite: Bool { configuration["invite"] as? Bool == true }
|
||||
private var dark: Bool { configuration["dark"] as? Bool == true }
|
||||
private var fieldBackground: UIColor { UIColor(white: dark ? 0.16 : 0.95, alpha: 1) }
|
||||
private var choices: [[String: String]] { configuration["choices"] as? [[String: String]] ?? [] }
|
||||
|
||||
init(configuration: [String: Any]) {
|
||||
self.configuration = configuration
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
required init?(coder: NSCoder) { nil }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = dark ? UIColor(white: 0.11, alpha: 1) : .white
|
||||
navigationController?.view.backgroundColor = view.backgroundColor
|
||||
let argb = (configuration["primaryArgb"] as? NSNumber)?.uint32Value ?? 0xFF007AFF
|
||||
view.tintColor = UIColor(red: CGFloat((argb >> 16) & 255) / 255,
|
||||
green: CGFloat((argb >> 8) & 255) / 255, blue: CGFloat(argb & 255) / 255, alpha: 1)
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(systemItem: .close,
|
||||
primaryAction: UIAction { [weak self] _ in self?.close() })
|
||||
if #available(iOS 17.0, *) { view.keyboardLayoutGuide.usesBottomSafeArea = false }
|
||||
scroll.contentInsetAdjustmentBehavior = .never
|
||||
scroll.translatesAutoresizingMaskIntoConstraints = false
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 12
|
||||
view.addSubview(scroll)
|
||||
scroll.addSubview(stack)
|
||||
NSLayoutConstraint.activate([
|
||||
scroll.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||
scroll.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
scroll.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
scroll.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
|
||||
stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor, constant: 20),
|
||||
stack.leadingAnchor.constraint(equalTo: scroll.frameLayoutGuide.leadingAnchor, constant: 20),
|
||||
stack.trailingAnchor.constraint(equalTo: scroll.frameLayoutGuide.trailingAnchor, constant: -20),
|
||||
stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor, constant: -20),
|
||||
])
|
||||
status.numberOfLines = 0
|
||||
status.font = .preferredFont(forTextStyle: .footnote)
|
||||
status.adjustsFontForContentSizeCategory = true
|
||||
status.textColor = .secondaryLabel
|
||||
preview.numberOfLines = 0
|
||||
preview.font = .preferredFont(forTextStyle: .footnote)
|
||||
preview.adjustsFontForContentSizeCategory = true
|
||||
input.placeholder = "Private key (nsec)"
|
||||
input.accessibilityLabel = "Private key (nsec)"
|
||||
input.isSecureTextEntry = true
|
||||
input.autocorrectionType = .no
|
||||
input.autocapitalizationType = .none
|
||||
input.spellCheckingType = .no
|
||||
input.smartQuotesType = .no
|
||||
input.smartDashesType = .no
|
||||
input.smartInsertDeleteType = .no
|
||||
input.font = .preferredFont(forTextStyle: .body)
|
||||
input.adjustsFontForContentSizeCategory = true
|
||||
input.backgroundColor = fieldBackground
|
||||
input.textColor = dark ? .white : .black
|
||||
input.layer.cornerRadius = 12
|
||||
input.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 16, height: 1))
|
||||
input.leftViewMode = .always
|
||||
input.rightView = UIView(frame: CGRect(x: 0, y: 0, width: 16, height: 1))
|
||||
input.rightViewMode = .always
|
||||
input.heightAnchor.constraint(greaterThanOrEqualToConstant: 56).isActive = true
|
||||
input.delegate = self
|
||||
input.addTarget(self, action: #selector(edited), for: .editingChanged)
|
||||
render()
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
let header = max(56, view.safeAreaInsets.top,
|
||||
navigationController?.navigationBar.bounds.height ?? 0)
|
||||
if abs(header - measuredHeader) > 0.5 {
|
||||
measuredHeader = header
|
||||
navigationController?.sheetPresentationController?.invalidateDetents()
|
||||
}
|
||||
}
|
||||
|
||||
func configureDetents() {
|
||||
guard let sheet = navigationController?.sheetPresentationController else { return }
|
||||
loadViewIfNeeded()
|
||||
sheet.detents = [.custom(identifier: compact) { [weak self] context in
|
||||
guard let self else { return min(320, context.maximumDetentValue) }
|
||||
let width = max(280, self.view.bounds.width - 40)
|
||||
let content = self.stack.systemLayoutSizeFitting(
|
||||
CGSize(width: width, height: UIView.layoutFittingCompressedSize.height),
|
||||
withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel
|
||||
).height
|
||||
// Include the actual navigation inset. Subtracting the window safe area
|
||||
// made the compact detent shorter than its visible content viewport.
|
||||
return min(content + 40 + self.measuredHeader, context.maximumDetentValue)
|
||||
}, .large()]
|
||||
sheet.selectedDetentIdentifier = importing ? .large : compact
|
||||
sheet.prefersScrollingExpandsWhenScrolledToEdge = false
|
||||
// UIKit owns the corners, drag physics, and transition between detents.
|
||||
}
|
||||
|
||||
private func render() {
|
||||
title = importing ? "Import identity" : "Join community"
|
||||
stack.arrangedSubviews.forEach { stack.removeArrangedSubview($0); $0.removeFromSuperview() }
|
||||
buttons = []
|
||||
submit = nil
|
||||
status.text = nil
|
||||
status.isHidden = true
|
||||
preview.isHidden = !confirmed
|
||||
if importing {
|
||||
stack.addArrangedSubview(input)
|
||||
addButton("Choose nsec file") { [weak self] in self?.chooseFile() }
|
||||
stack.addArrangedSubview(preview)
|
||||
submit = addButton(confirmed ? "Import this identity" : "Preview identity", primary: true) {
|
||||
[weak self] in self?.submitKey()
|
||||
}
|
||||
} else {
|
||||
let name = UILabel()
|
||||
name.text = configuration["name"] as? String
|
||||
name.font = .preferredFont(forTextStyle: .headline)
|
||||
name.adjustsFontForContentSizeCategory = true
|
||||
name.numberOfLines = 0
|
||||
name.textAlignment = .center
|
||||
name.textColor = dark ? .white : .black
|
||||
let card = UIView()
|
||||
card.backgroundColor = fieldBackground
|
||||
card.layer.cornerRadius = 12
|
||||
name.translatesAutoresizingMaskIntoConstraints = false
|
||||
card.addSubview(name)
|
||||
NSLayoutConstraint.activate([
|
||||
name.topAnchor.constraint(equalTo: card.topAnchor, constant: 32),
|
||||
name.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -32),
|
||||
name.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 20),
|
||||
name.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -20),
|
||||
])
|
||||
stack.addArrangedSubview(card)
|
||||
if invite {
|
||||
addButton("Join community", primary: true) { [weak self] in
|
||||
self?.request("join", [:])
|
||||
}
|
||||
} else {
|
||||
for choice in choices {
|
||||
addButton("\(choice["label"] ?? "Saved identity")\n\(choice["npub"] ?? "")") { [weak self] in
|
||||
self?.request("join", ["pubkey": choice["pubkey"] ?? ""])
|
||||
}
|
||||
}
|
||||
addButton("Import a Nostr key") { [weak self] in self?.showImport() }
|
||||
if choices.isEmpty {
|
||||
addButton("Create an identity and join", primary: true) { [weak self] in
|
||||
self?.request("join", ["create": true])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
stack.addArrangedSubview(status)
|
||||
setBusy(false)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func addButton(_ title: String, primary: Bool = false, action: @escaping () -> Void) -> UIButton {
|
||||
let config = onboardingButtonConfiguration(title, primary: primary)
|
||||
let button = UIButton(configuration: config, primaryAction: UIAction { _ in action() })
|
||||
button.titleLabel?.numberOfLines = 0
|
||||
button.titleLabel?.adjustsFontForContentSizeCategory = true
|
||||
button.heightAnchor.constraint(greaterThanOrEqualToConstant: 52).isActive = true
|
||||
buttons.append(button)
|
||||
stack.addArrangedSubview(button)
|
||||
return button
|
||||
}
|
||||
|
||||
private func showImport() {
|
||||
importing = true
|
||||
UIView.performWithoutAnimation {
|
||||
self.render()
|
||||
self.view.layoutIfNeeded()
|
||||
}
|
||||
navigationController?.sheetPresentationController?.animateChanges {
|
||||
self.navigationController?.sheetPresentationController?.selectedDetentIdentifier = .large
|
||||
}
|
||||
// The field stays ready for a tap; keyboard appearance never competes with expansion.
|
||||
}
|
||||
|
||||
private func close() {
|
||||
if invite && busy { return }
|
||||
generation += 1
|
||||
view.endEditing(true)
|
||||
input.text = nil
|
||||
preview.text = nil
|
||||
confirmed = false
|
||||
action?("cancel", [:]) { _ in }
|
||||
if importing {
|
||||
importing = false
|
||||
UIView.performWithoutAnimation {
|
||||
self.render()
|
||||
self.view.layoutIfNeeded()
|
||||
}
|
||||
navigationController?.sheetPresentationController?.animateChanges {
|
||||
self.navigationController?.sheetPresentationController?.selectedDetentIdentifier = self.compact
|
||||
}
|
||||
} else { finish?(false) }
|
||||
}
|
||||
|
||||
private func setBusy(_ value: Bool) {
|
||||
busy = value
|
||||
if invite {
|
||||
navigationController?.isModalInPresentation = value
|
||||
navigationItem.rightBarButtonItem?.isEnabled = !value
|
||||
}
|
||||
buttons.forEach { $0.isEnabled = !value }
|
||||
input.isEnabled = !value
|
||||
if value { status.text = "Working…"; status.isHidden = false }
|
||||
}
|
||||
|
||||
private func request(_ method: String, _ args: [String: Any], completion: (([String: Any]) -> Void)? = nil) {
|
||||
guard !busy else { return }
|
||||
generation += 1
|
||||
let attempt = generation
|
||||
setBusy(true)
|
||||
action?(method, args) { [weak self] result in
|
||||
guard let self, self.generation == attempt else { return }
|
||||
self.setBusy(false)
|
||||
self.status.text = result["error"] as? String
|
||||
self.status.isHidden = self.status.text == nil
|
||||
if let error = self.status.text {
|
||||
UIAccessibility.post(notification: .announcement, argument: error)
|
||||
}
|
||||
self.navigationController?.sheetPresentationController?.invalidateDetents()
|
||||
if result["joined"] as? Bool == true {
|
||||
self.input.text = nil
|
||||
self.finish?(true)
|
||||
} else { completion?(result) }
|
||||
}
|
||||
}
|
||||
|
||||
private func showPreview(_ result: [String: Any]) {
|
||||
guard let npub = result["npub"] as? String else { return }
|
||||
confirmed = true
|
||||
preview.isHidden = false
|
||||
preview.text = "Confirm this is the public identity you want to use:\n\n\(npub)"
|
||||
submit?.configuration?.title = "Import this identity"
|
||||
view.endEditing(true)
|
||||
}
|
||||
|
||||
private func submitKey() {
|
||||
request(confirmed ? "join" : "preview", ["nsec": input.text ?? ""]) { [weak self] result in
|
||||
self?.showPreview(result)
|
||||
}
|
||||
}
|
||||
|
||||
private func chooseFile() {
|
||||
view.endEditing(true)
|
||||
request("file", [:]) { [weak self] result in
|
||||
guard let self, let text = result["nsec"] as? String else { return }
|
||||
self.input.text = text
|
||||
self.showPreview(result)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func edited() {
|
||||
confirmed = false
|
||||
preview.text = nil
|
||||
preview.isHidden = true
|
||||
status.text = nil
|
||||
status.isHidden = true
|
||||
submit?.configuration?.title = "Preview identity"
|
||||
}
|
||||
|
||||
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
|
||||
guard let text = textField.text, let range = Range(range, in: text) else { return false }
|
||||
return text.replacingCharacters(in: range, with: string).utf8.count <= 1024
|
||||
}
|
||||
}
|
||||
|
||||
func onboardingButtonConfiguration(_ title: String, primary: Bool) -> UIButton.Configuration {
|
||||
var config = primary ? UIButton.Configuration.filled() : .plain()
|
||||
config.title = title
|
||||
config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
|
||||
var updated = attributes
|
||||
// Match Flutter labelMedium: bundled Inter, 14 points, weight 500.
|
||||
let inter = NativeAttachmentMenuTypography.font(forTextStyle: .subheadline)
|
||||
let descriptor = inter.fontDescriptor.addingAttributes([
|
||||
UIFontDescriptor.AttributeName(rawValue: kCTFontVariationAttribute as String):
|
||||
[NSNumber(value: 0x77676874): NSNumber(value: 500)]
|
||||
])
|
||||
let font = UIFont(descriptor: descriptor, size: 14)
|
||||
updated.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: font)
|
||||
return updated
|
||||
}
|
||||
// Match the circular StadiumBorder used by the Flutter Continue button.
|
||||
config.cornerStyle = .fixed
|
||||
config.background.cornerRadius = 26
|
||||
config.contentInsets = NSDirectionalEdgeInsets(top: 14, leading: 20, bottom: 14, trailing: 20)
|
||||
if !primary {
|
||||
config.baseForegroundColor = .label
|
||||
config.background.strokeColor = .separator
|
||||
config.background.strokeWidth = 1
|
||||
}
|
||||
return config
|
||||
}
|
||||
362
mobile/ios/Runner/NativeProfileSetup.swift
Normal file
362
mobile/ios/Runner/NativeProfileSetup.swift
Normal file
@@ -0,0 +1,362 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import CoreText
|
||||
|
||||
/// Profile draft presentation; persistence and the existing avatar editor stay in Dart.
|
||||
final class NativeProfileSetupCoordinator: NSObject, UIAdaptivePresentationControllerDelegate {
|
||||
private let channel: FlutterMethodChannel
|
||||
private var navigation: UINavigationController?
|
||||
private var pending: FlutterResult?
|
||||
private var session: Int?
|
||||
private var avatarController: FlutterViewController?
|
||||
private var avatarEngine: FlutterEngine?
|
||||
private var avatarChannel: FlutterMethodChannel?
|
||||
private var transitionCover: UIView?
|
||||
private var transitionAvatar: UIView?
|
||||
private var disabledSheetPans: [UIGestureRecognizer] = []
|
||||
private var previousInteractivePopEnabled: Bool?
|
||||
private var previousDetent: UISheetPresentationController.Detent.Identifier?
|
||||
private var editorAvatarRect: CGRect?
|
||||
private var avatarDraft: [String: Any]?
|
||||
private let scheduleEditorWatchdog: (@escaping () -> Void) -> Void
|
||||
private let startEditorEngine: (FlutterEngine) -> Bool
|
||||
private let configureEditorEngine: (FlutterEngine) -> Void
|
||||
|
||||
|
||||
init(messenger: FlutterBinaryMessenger, scheduleEditorWatchdog: @escaping (@escaping () -> Void) -> Void = {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: $0)
|
||||
}, startEditorEngine: @escaping (FlutterEngine) -> Bool = {
|
||||
$0.run(withEntrypoint: "profileAvatarEditorMain", libraryURI: "package:buzz/features/profile/profile_avatar_sheet_app.dart")
|
||||
}, configureEditorEngine: @escaping (FlutterEngine) -> Void) {
|
||||
self.scheduleEditorWatchdog = scheduleEditorWatchdog
|
||||
self.startEditorEngine = startEditorEngine
|
||||
self.configureEditorEngine = configureEditorEngine
|
||||
channel = FlutterMethodChannel(name: "buzz/profile_setup", binaryMessenger: messenger)
|
||||
super.init()
|
||||
channel.setMethodCallHandler { [weak self] call, result in
|
||||
guard let self else { return }
|
||||
if call.method == "dismiss" {
|
||||
guard let args = call.arguments as? [String: Any], args["session"] as? Int == self.session else { result(nil); return }
|
||||
self.finish("cancel"); result(nil); return
|
||||
}
|
||||
guard call.method == "present", let args = call.arguments as? [String: Any],
|
||||
let session = args["session"] as? Int else { result(FlutterMethodNotImplemented); return }
|
||||
guard self.pending == nil else { result(FlutterError(code: "busy", message: nil, details: nil)); return }
|
||||
let root = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
|
||||
.filter { $0.activationState == .foregroundActive || $0.activationState == .foregroundInactive }.flatMap(\.windows)
|
||||
.first(where: \.isKeyWindow)?.rootViewController
|
||||
guard var presenter = root else { result(FlutterError(code: "unavailable", message: nil, details: nil)); return }
|
||||
while let next = presenter.presentedViewController { presenter = next }
|
||||
let controller = NativeProfileSetupController(args: args)
|
||||
controller.finish = { [weak self] action in self?.finish(action) }
|
||||
controller.openAvatar = { [weak self, weak controller] in
|
||||
guard let self, let controller else { return }; self.openAvatar(from: controller, args: args, session: session)
|
||||
}
|
||||
controller.action = { [weak self] action, payload, completion in
|
||||
guard let self, self.session == session else { return }
|
||||
self.channel.invokeMethod(action, arguments: payload.merging(["session": session]) { _, new in new }) { value in
|
||||
guard self.session == session else { return }
|
||||
completion(value as? [String: Any] ?? [:])
|
||||
}
|
||||
}
|
||||
let nav = UINavigationController(rootViewController: controller)
|
||||
nav.modalPresentationStyle = .pageSheet
|
||||
nav.overrideUserInterfaceStyle = args["dark"] as? Bool == true ? .dark : .light
|
||||
self.pending = result; self.session = session; self.navigation = nav
|
||||
controller.loadViewIfNeeded()
|
||||
nav.sheetPresentationController?.detents = [.custom(identifier: .init("profile")) { _ in 430 }, .large()]
|
||||
nav.sheetPresentationController?.selectedDetentIdentifier = args["expanded"] as? Bool == true ? .large : .init("profile")
|
||||
nav.sheetPresentationController?.prefersScrollingExpandsWhenScrolledToEdge = false
|
||||
presenter.present(nav, animated: !UIAccessibility.isReduceMotionEnabled)
|
||||
nav.presentationController?.delegate = self
|
||||
}
|
||||
}
|
||||
private func openAvatar(from controller: NativeProfileSetupController, args: [String: Any], session: Int) {
|
||||
guard self.session == session, avatarEngine == nil, let navigation else { return }
|
||||
previousInteractivePopEnabled = navigation.interactivePopGestureRecognizer?.isEnabled
|
||||
previousDetent = navigation.sheetPresentationController?.selectedDetentIdentifier
|
||||
let sourceAvatar = controller.avatarSnapshot(in: navigation.view)
|
||||
controller.setAvatarHidden(true)
|
||||
let cover = navigation.view.snapshotView(afterScreenUpdates: true)
|
||||
controller.setAvatarHidden(false)
|
||||
controller.view.endEditing(true)
|
||||
navigation.isModalInPresentation = true
|
||||
var sheetView: UIView? = navigation.view
|
||||
while let current = sheetView {
|
||||
for gesture in current.gestureRecognizers ?? [] where gesture is UIPanGestureRecognizer && gesture.isEnabled {
|
||||
gesture.isEnabled = false; disabledSheetPans.append(gesture)
|
||||
}
|
||||
if current === navigation.presentationController?.containerView { break }
|
||||
sheetView = current.superview
|
||||
}
|
||||
navigation.sheetPresentationController?.animateChanges {
|
||||
navigation.sheetPresentationController?.detents = [.large()]
|
||||
navigation.sheetPresentationController?.selectedDetentIdentifier = .large
|
||||
}
|
||||
let engine = FlutterEngine(name: "profile-avatar-\(session)", project: nil, allowHeadlessExecution: false)
|
||||
avatarEngine = engine
|
||||
guard startEditorEngine(engine) else {
|
||||
restoreProfileSheet(controller); return
|
||||
}
|
||||
let editorChannel = FlutterMethodChannel(name: "buzz/profile_avatar_sheet", binaryMessenger: engine.binaryMessenger)
|
||||
avatarChannel = editorChannel
|
||||
var ready = false
|
||||
var finishing = false
|
||||
editorChannel.setMethodCallHandler { [weak self, weak controller, weak engine] call, result in
|
||||
guard let self, let engine, self.session == session, self.avatarEngine === engine, let controller else { result(nil); return }
|
||||
if call.method == "ready", let rect = call.arguments as? [String: NSNumber], let editor = self.avatarController {
|
||||
ready = true
|
||||
let target = CGRect(x: rect["x"]?.doubleValue ?? 0, y: rect["y"]?.doubleValue ?? 0, width: rect["width"]?.doubleValue ?? 220, height: rect["height"]?.doubleValue ?? 220)
|
||||
self.editorAvatarRect = target
|
||||
let destination = editor.view.convert(target, to: navigation.view)
|
||||
self.animateAvatarMotion {
|
||||
self.transitionCover?.alpha = 0
|
||||
self.transitionAvatar?.frame = destination
|
||||
} completion: {
|
||||
result(nil)
|
||||
UIView.animate(withDuration: UIAccessibility.isReduceMotionEnabled ? 0 : 0.08) {
|
||||
self.transitionAvatar?.alpha = 0
|
||||
} completion: { _ in self.clearTransition() }
|
||||
}
|
||||
return
|
||||
}
|
||||
if call.method == "initial" { result(["dark": args["dark"] ?? false, "draft": self.avatarDraft ?? [:]]); return }
|
||||
guard call.method == "finish", !finishing else { result(FlutterMethodNotImplemented); return }
|
||||
finishing = true
|
||||
let draft = call.arguments as? [String: Any]
|
||||
func returnToProfile() {
|
||||
self.clearTransition()
|
||||
let editor = self.avatarController
|
||||
let rect = self.editorAvatarRect
|
||||
let avatarSnapshot = rect.flatMap { editor?.view.resizableSnapshotView(from: $0, afterScreenUpdates: false, withCapInsets: .zero) }
|
||||
let source = rect.flatMap { editor?.view.convert($0, to: navigation.view) }
|
||||
let cover = navigation.view.snapshotView(afterScreenUpdates: false)
|
||||
if let cover, let source {
|
||||
// Cut the moving avatar out of the outgoing page snapshot.
|
||||
let mask = CAShapeLayer()
|
||||
let path = UIBezierPath(rect: navigation.view.bounds)
|
||||
path.append(UIBezierPath(rect: source))
|
||||
mask.path = path.cgPath; mask.fillRule = .evenOdd
|
||||
cover.layer.mask = mask
|
||||
}
|
||||
controller.setAvatarHidden(true)
|
||||
navigation.setNavigationBarHidden(false, animated: false)
|
||||
navigation.popViewController(animated: false)
|
||||
navigation.view.layoutIfNeeded()
|
||||
let destination = controller.avatarFrame(in: navigation.view)
|
||||
if let cover { cover.frame = navigation.view.bounds; navigation.view.addSubview(cover); self.transitionCover = cover }
|
||||
if let avatarSnapshot, let source {
|
||||
avatarSnapshot.frame = source; navigation.view.addSubview(avatarSnapshot); self.transitionAvatar = avatarSnapshot
|
||||
}
|
||||
// Start the sheet resize alongside the shared-avatar return.
|
||||
navigation.sheetPresentationController?.animateChanges {
|
||||
self.restoreProfileDetents()
|
||||
}
|
||||
self.animateAvatarMotion(returning: true) {
|
||||
self.transitionCover?.alpha = 0
|
||||
self.transitionAvatar?.frame = destination
|
||||
} completion: { [weak self, weak controller] in
|
||||
guard let self, self.session == session else { return }
|
||||
self.restoreProfileSheet(controller)
|
||||
}
|
||||
result(nil)
|
||||
}
|
||||
if let draft {
|
||||
self.avatarDraft = draft
|
||||
self.channel.invokeMethod("avatarDraft", arguments: ["session": session, "draft": draft]) { value in
|
||||
guard self.session == session else { result(nil); return }
|
||||
controller.updateAvatar(value as? [String: Any] ?? [:]); returnToProfile()
|
||||
}
|
||||
} else { returnToProfile() }
|
||||
}
|
||||
GeneratedPluginRegistrant.register(with: engine)
|
||||
configureEditorEngine(engine)
|
||||
let messenger = engine.binaryMessenger
|
||||
engine.registrar(forPlugin: "BuzzNavigationGlassButton")?.register(NavigationGlassButtonFactory(messenger: messenger), withId: "buzz/navigation_glass")
|
||||
engine.registrar(forPlugin: "BuzzNativeSegmentedControl")?.register(NativeSegmentedControlFactory(messenger: messenger), withId: "buzz/native_segmented_control")
|
||||
engine.registrar(forPlugin: "BuzzNativeSkinToneControl")?.register(NativeSkinToneControlFactory(messenger: messenger), withId: "buzz/native_skin_tone_control")
|
||||
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
|
||||
navigation.setNavigationBarHidden(true, animated: false)
|
||||
navigation.pushViewController(editor, animated: false)
|
||||
if let cover { cover.frame = navigation.view.bounds; navigation.view.addSubview(cover); transitionCover = cover }
|
||||
if let (snapshot, frame) = sourceAvatar { snapshot.frame = frame; navigation.view.addSubview(snapshot); transitionAvatar = snapshot }
|
||||
// Recover the whole sheet when Dart never delivers the editor's first frame.
|
||||
scheduleEditorWatchdog { [weak self, weak engine, weak controller] in
|
||||
guard let self, let engine, self.session == session,
|
||||
self.avatarEngine === engine, !ready, !finishing else { return }
|
||||
navigation.setNavigationBarHidden(false, animated: false)
|
||||
navigation.popViewController(animated: false)
|
||||
self.restoreProfileSheet(controller)
|
||||
}
|
||||
navigation.interactivePopGestureRecognizer?.isEnabled = false
|
||||
|
||||
}
|
||||
|
||||
// Profile editing uses 200ms easeOutCubic on entrance. Its reversed
|
||||
// easeInCubic value curve is mirrored below for the visible return motion.
|
||||
private func animateAvatarMotion(returning: Bool = false, _ animations: @escaping () -> Void, completion: @escaping () -> Void) {
|
||||
if UIAccessibility.isReduceMotionEnabled { animations(); completion(); return }
|
||||
let timing = returning
|
||||
? UICubicTimingParameters(controlPoint1: CGPoint(x: 0.325, y: 0.81), controlPoint2: CGPoint(x: 0.45, y: 0.945))
|
||||
: UICubicTimingParameters(controlPoint1: CGPoint(x: 0.215, y: 0.61), controlPoint2: CGPoint(x: 0.355, y: 1))
|
||||
let animator = UIViewPropertyAnimator(duration: 0.2, timingParameters: timing)
|
||||
animator.addAnimations(animations)
|
||||
animator.addCompletion { _ in completion() }
|
||||
animator.startAnimation()
|
||||
}
|
||||
|
||||
private func restoreProfileDetents() {
|
||||
navigation?.sheetPresentationController?.detents = [.custom(identifier: .init("profile")) { _ in 430 }, .large()]
|
||||
navigation?.sheetPresentationController?.selectedDetentIdentifier = previousDetent ?? .init("profile")
|
||||
}
|
||||
|
||||
/// Both failed startup and normal editor return must leave a retryable sheet.
|
||||
private func restoreProfileSheet(_ controller: NativeProfileSetupController?) {
|
||||
restoreProfileDetents()
|
||||
previousDetent = nil
|
||||
clearTransition()
|
||||
avatarChannel?.setMethodCallHandler(nil); avatarChannel = nil
|
||||
avatarEngine?.destroyContext(); avatarEngine = nil
|
||||
avatarController = nil; editorAvatarRect = nil
|
||||
navigation?.isModalInPresentation = false
|
||||
if let enabled = previousInteractivePopEnabled { navigation?.interactivePopGestureRecognizer?.isEnabled = enabled }
|
||||
previousInteractivePopEnabled = nil
|
||||
disabledSheetPans.forEach { $0.isEnabled = true }; disabledSheetPans.removeAll()
|
||||
controller?.setAvatarHidden(false)
|
||||
controller?.avatarReady()
|
||||
}
|
||||
|
||||
private func clearTransition() {
|
||||
transitionCover?.removeFromSuperview(); transitionCover = nil
|
||||
transitionAvatar?.removeFromSuperview(); transitionAvatar = nil
|
||||
}
|
||||
private func finish(_ action: String) {
|
||||
navigation?.dismiss(animated: !UIAccessibility.isReduceMotionEnabled) { [weak self] in self?.complete(action) }
|
||||
}
|
||||
private func complete(_ action: String) {
|
||||
clearTransition()
|
||||
disabledSheetPans.forEach { $0.isEnabled = true }; disabledSheetPans.removeAll()
|
||||
let result = pending; pending = nil; navigation = nil; session = nil
|
||||
avatarChannel?.setMethodCallHandler(nil); avatarChannel = nil; avatarEngine?.destroyContext(); avatarEngine = nil; avatarController = nil; avatarDraft = nil
|
||||
result?(action)
|
||||
}
|
||||
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { complete("skip") }
|
||||
}
|
||||
|
||||
private final class NativeProfileSetupController: UIViewController, UITextFieldDelegate {
|
||||
var finish: ((String) -> Void)?
|
||||
var action: ((String, [String: Any], @escaping ([String: Any]) -> Void) -> Void)?
|
||||
var openAvatar: (() -> Void)?
|
||||
private let args: [String: Any]
|
||||
private let name = UITextField()
|
||||
private let avatar = UIButton(type: .system)
|
||||
private let status = UILabel()
|
||||
private var controls: [UIButton] = []
|
||||
private var submit: UIButton!
|
||||
private var draftName: String
|
||||
init(args: [String: Any]) { self.args = args; draftName = args["name"] as? String ?? ""; super.init(nibName: nil, bundle: nil) }
|
||||
required init?(coder: NSCoder) { nil }
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "Set up your profile"
|
||||
view.backgroundColor = color("pageBackgroundArgb", fallback: .systemBackground)
|
||||
let argb = (args["primaryArgb"] as? NSNumber)?.uint32Value ?? 0xFF007AFF
|
||||
view.tintColor = UIColor(red: CGFloat((argb >> 16) & 255) / 255, green: CGFloat((argb >> 8) & 255) / 255, blue: CGFloat(argb & 255) / 255, alpha: 1)
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(systemItem: .close, primaryAction: UIAction { [weak self] _ in self?.finish?("skip") })
|
||||
let stack = UIStackView(); stack.axis = .vertical; stack.spacing = 16; stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
let scroll = UIScrollView(); scroll.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(scroll); scroll.addSubview(stack)
|
||||
NSLayoutConstraint.activate([
|
||||
scroll.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), scroll.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
|
||||
scroll.leadingAnchor.constraint(equalTo: view.leadingAnchor), scroll.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
stack.heightAnchor.constraint(greaterThanOrEqualTo: scroll.frameLayoutGuide.heightAnchor, constant: -40),
|
||||
stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor, constant: 20), stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor, constant: -20),
|
||||
stack.leadingAnchor.constraint(equalTo: scroll.frameLayoutGuide.leadingAnchor, constant: 20), stack.trailingAnchor.constraint(equalTo: scroll.frameLayoutGuide.trailingAnchor, constant: -20)
|
||||
])
|
||||
let avatarRow = UIView(); avatarRow.heightAnchor.constraint(equalToConstant: 112).isActive = true
|
||||
avatar.translatesAutoresizingMaskIntoConstraints = false
|
||||
avatar.accessibilityLabel = "Add profile photo"
|
||||
updateAvatar(args)
|
||||
avatar.addAction(UIAction { [weak self] _ in
|
||||
guard let self else { return }; self.avatar.isEnabled = false; self.openAvatar?()
|
||||
}, for: .touchUpInside)
|
||||
avatarRow.addSubview(avatar)
|
||||
NSLayoutConstraint.activate([avatar.centerXAnchor.constraint(equalTo: avatarRow.centerXAnchor), avatar.topAnchor.constraint(equalTo: avatarRow.topAnchor), avatar.widthAnchor.constraint(equalToConstant: 112), avatar.heightAnchor.constraint(equalToConstant: 112)])
|
||||
stack.addArrangedSubview(avatarRow); controls.append(avatar)
|
||||
name.placeholder = "Display name"; name.accessibilityLabel = "Display name"; name.text = draftName
|
||||
name.font = .preferredFont(forTextStyle: .body); name.adjustsFontForContentSizeCategory = true
|
||||
name.backgroundColor = color("containerBackgroundArgb", fallback: .secondarySystemBackground); name.layer.cornerRadius = CGFloat((args["containerCornerRadius"] as? NSNumber)?.doubleValue ?? 22)
|
||||
name.clearButtonMode = .whileEditing; name.autocapitalizationType = .words; name.returnKeyType = .done
|
||||
name.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 20, height: 1)); name.leftViewMode = .always
|
||||
name.heightAnchor.constraint(greaterThanOrEqualToConstant: 56).isActive = true
|
||||
name.delegate = self; name.addTarget(self, action: #selector(nameChanged), for: .editingChanged)
|
||||
stack.addArrangedSubview(name)
|
||||
status.numberOfLines = 0; status.textColor = .systemRed; status.font = .preferredFont(forTextStyle: .footnote); status.isHidden = true; stack.addArrangedSubview(status)
|
||||
let spacer = UIView(); spacer.setContentHuggingPriority(.init(1), for: .vertical)
|
||||
spacer.heightAnchor.constraint(greaterThanOrEqualToConstant: 12).isActive = true; stack.addArrangedSubview(spacer)
|
||||
submit = UIButton(configuration: onboardingButtonConfiguration("Continue", primary: true), primaryAction: UIAction { [weak self] _ in self?.save() })
|
||||
let skip = UIButton(configuration: onboardingButtonConfiguration("Skip", primary: false), primaryAction: UIAction { [weak self] _ in self?.finish?("skip") })
|
||||
for button in [submit!, skip] { button.heightAnchor.constraint(greaterThanOrEqualToConstant: 52).isActive = true; stack.addArrangedSubview(button); controls.append(button) }
|
||||
updateName()
|
||||
}
|
||||
@objc private func nameChanged() { draftName = name.text ?? ""; updateName() }
|
||||
func textFieldShouldReturn(_ textField: UITextField) -> Bool { textField.resignFirstResponder(); return true }
|
||||
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
|
||||
guard let current = textField.text, let range = Range(range, in: current) else { return false }
|
||||
return current.replacingCharacters(in: range, with: string).count <= 100
|
||||
}
|
||||
func setAvatarHidden(_ hidden: Bool) { avatar.isHidden = hidden }
|
||||
func avatarFrame(in container: UIView) -> CGRect { avatar.convert(avatar.bounds, to: container) }
|
||||
func avatarSnapshot(in container: UIView) -> (UIView, CGRect)? {
|
||||
guard let snapshot = avatar.snapshotView(afterScreenUpdates: false) else { return nil }
|
||||
return (snapshot, avatar.convert(avatar.bounds, to: container))
|
||||
}
|
||||
func avatarReady() { avatar.isEnabled = true }
|
||||
func updateAvatar(_ args: [String: Any]) {
|
||||
avatar.layer.sublayers?.filter { $0 is CAShapeLayer }.forEach { $0.removeFromSuperlayer() }
|
||||
avatar.setImage(nil, for: .normal); avatar.setTitle(nil, for: .normal); avatar.backgroundColor = .clear
|
||||
if let bytes = args["avatar"] as? FlutterStandardTypedData, let image = UIImage(data: bytes.data) {
|
||||
avatar.setImage(image.withRenderingMode(.alwaysOriginal), for: .normal); avatar.imageView?.contentMode = .scaleAspectFill
|
||||
avatar.clipsToBounds = true; avatar.layer.cornerRadius = 56
|
||||
} else if let emoji = args["emoji"] as? String {
|
||||
avatar.setTitle(emoji, for: .normal); avatar.titleLabel?.font = .systemFont(ofSize: 56)
|
||||
let color = (args["emojiColor"] as? NSNumber)?.uint32Value ?? 0xFFFFFFFF
|
||||
avatar.backgroundColor = UIColor(red: CGFloat((color >> 16) & 255) / 255, green: CGFloat((color >> 8) & 255) / 255, blue: CGFloat(color & 255) / 255, alpha: 1); avatar.layer.cornerRadius = 56
|
||||
} else {
|
||||
avatar.setImage(tablerPlusImage(), for: .normal)
|
||||
let ring = CAShapeLayer(); ring.path = UIBezierPath(ovalIn: CGRect(x: 2, y: 2, width: 108, height: 108)).cgPath
|
||||
ring.strokeColor = UIColor.tertiaryLabel.cgColor; ring.fillColor = UIColor.clear.cgColor; ring.lineWidth = 2; let step = 2 * CGFloat.pi * 54 / 32; ring.lineDashPattern = [NSNumber(value: Double(step * 4 / 9)), NSNumber(value: Double(step * 5 / 9))]; avatar.layer.addSublayer(ring)
|
||||
}
|
||||
}
|
||||
private func tablerPlusImage() -> UIImage? {
|
||||
guard let frameworks = Bundle.main.privateFrameworksURL,
|
||||
let provider = CGDataProvider(url: frameworks.appendingPathComponent("App.framework/flutter_assets/assets/fonts/TablerIcons.ttf") as CFURL),
|
||||
let font = CGFont(provider), let fontName = font.postScriptName else { return nil }
|
||||
CTFontManagerRegisterGraphicsFont(font, nil)
|
||||
guard let uiFont = UIFont(name: fontName as String, size: 28), let scalar = UnicodeScalar(0xeb0b) else { return nil }
|
||||
let glyph = NSAttributedString(string: String(scalar), attributes: [.font: uiFont])
|
||||
let size = glyph.size()
|
||||
return UIGraphicsImageRenderer(size: CGSize(width: 32, height: 32)).image { _ in
|
||||
glyph.draw(at: CGPoint(x: (32 - size.width) / 2, y: (32 - size.height) / 2))
|
||||
}.withRenderingMode(.alwaysTemplate)
|
||||
}
|
||||
private func color(_ key: String, fallback: UIColor) -> UIColor {
|
||||
guard let value = (args[key] as? NSNumber)?.uint32Value else { return fallback }
|
||||
return UIColor(red: CGFloat((value >> 16) & 255) / 255, green: CGFloat((value >> 8) & 255) / 255, blue: CGFloat(value & 255) / 255, alpha: CGFloat((value >> 24) & 255) / 255)
|
||||
}
|
||||
private func updateName() { submit.isEnabled = !draftName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
private func save() {
|
||||
name.isEnabled = false; controls.forEach { $0.isEnabled = false }; navigationController?.isModalInPresentation = true; navigationItem.rightBarButtonItem?.isEnabled = false
|
||||
submit.configuration?.showsActivityIndicator = true
|
||||
action?("save", ["name": draftName]) { [weak self] response in
|
||||
guard let self else { return }
|
||||
if response["saved"] as? Bool == true { self.finish?("saved"); return }
|
||||
self.status.text = "Could not save your profile. Try again or skip."; self.status.isHidden = false
|
||||
UIAccessibility.post(notification: .announcement, argument: self.status.text)
|
||||
self.name.isEnabled = true; self.controls.forEach { $0.isEnabled = true }; self.updateName(); self.navigationController?.isModalInPresentation = false; self.navigationItem.rightBarButtonItem?.isEnabled = true; self.submit.configuration?.showsActivityIndicator = false
|
||||
}
|
||||
}
|
||||
}
|
||||
98
mobile/ios/RunnerTests/ProfileSetupRecoveryTests.swift
Normal file
98
mobile/ios/RunnerTests/ProfileSetupRecoveryTests.swift
Normal file
@@ -0,0 +1,98 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import XCTest
|
||||
|
||||
@testable import Buzz
|
||||
|
||||
final class ProfileSetupRecoveryTests: XCTestCase {
|
||||
@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])
|
||||
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" })
|
||||
let pan = UIPanGestureRecognizer()
|
||||
let disabledPan = UIPanGestureRecognizer()
|
||||
disabledPan.isEnabled = false
|
||||
nav.view.addGestureRecognizer(pan)
|
||||
nav.view.addGestureRecognizer(disabledPan)
|
||||
nav.interactivePopGestureRecognizer?.isEnabled = true
|
||||
for detent in [UISheetPresentationController.Detent.Identifier("profile"), .large] {
|
||||
autoreleasepool {
|
||||
nav.sheetPresentationController?.selectedDetentIdentifier = detent
|
||||
avatar.sendActions(for: .touchUpInside)
|
||||
XCTAssertNotNil(engine)
|
||||
XCTAssertTrue(nav.topViewController is FlutterViewController)
|
||||
XCTAssertFalse(pan.isEnabled)
|
||||
XCTAssertFalse(nav.interactivePopGestureRecognizer?.isEnabled ?? true)
|
||||
XCTAssertTrue(nav.isModalInPresentation)
|
||||
XCTAssertTrue(nav.isNavigationBarHidden)
|
||||
if watchdogs.count > 1 {
|
||||
// An old attempt's watchdog cannot tear down the successful retry.
|
||||
watchdogs[0]()
|
||||
XCTAssertTrue(nav.topViewController is FlutterViewController)
|
||||
XCTAssertNotNil(engine)
|
||||
}
|
||||
watchdogs.last?()
|
||||
XCTAssertTrue(nav.topViewController === profile)
|
||||
XCTAssertFalse(nav.isNavigationBarHidden)
|
||||
XCTAssertEqual(nav.sheetPresentationController?.detents.map(\.identifier), [.init("profile"), .large])
|
||||
XCTAssertEqual(nav.sheetPresentationController?.selectedDetentIdentifier, detent)
|
||||
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)
|
||||
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?
|
||||
func send(onChannel channel: String, message: Data?) {}
|
||||
func send(onChannel channel: String, message: Data?, binaryReply callback: FlutterBinaryReply?) { callback?(nil) }
|
||||
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))) { _ in }
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,59 @@ import XCTest
|
||||
|
||||
class RunnerTests: XCTestCase {
|
||||
|
||||
@MainActor
|
||||
func testProfileAvatarStartupFailureRestoresSheetAndAllowsRetry() async throws {
|
||||
let messenger = NavigationTestMessenger()
|
||||
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 starts = 0
|
||||
var navigation: UINavigationController?
|
||||
let pan = UIPanGestureRecognizer()
|
||||
let alreadyDisabled = UIPanGestureRecognizer()
|
||||
alreadyDisabled.isEnabled = false
|
||||
let coordinator = NativeProfileSetupCoordinator(messenger: messenger, startEditorEngine: { _ in
|
||||
starts += 1
|
||||
XCTAssertEqual(navigation?.sheetPresentationController?.selectedDetentIdentifier, .large)
|
||||
XCTAssertTrue(navigation?.isModalInPresentation == true)
|
||||
XCTAssertFalse(pan.isEnabled)
|
||||
return false
|
||||
}, configureEditorEngine: { _ in
|
||||
XCTFail("Failed startup must not configure plugins")
|
||||
})
|
||||
messenger.invoke("present", arguments: ["session": 71]) { _ in }
|
||||
let nav = try XCTUnwrap(parent.presentedViewController as? UINavigationController)
|
||||
navigation = nav
|
||||
if let transition = nav.transitionCoordinator {
|
||||
await withCheckedContinuation { continuation in
|
||||
transition.animate(alongsideTransition: nil) { _ in continuation.resume() }
|
||||
}
|
||||
}
|
||||
nav.view.addGestureRecognizer(pan)
|
||||
nav.view.addGestureRecognizer(alreadyDisabled)
|
||||
let profile = try XCTUnwrap(nav.viewControllers.first)
|
||||
let avatar = try XCTUnwrap(descendants(of: profile.view).compactMap { $0 as? UIButton }
|
||||
.first { $0.accessibilityLabel == "Add profile photo" })
|
||||
for detent in [UISheetPresentationController.Detent.Identifier("profile"), .large] {
|
||||
nav.sheetPresentationController?.selectedDetentIdentifier = detent
|
||||
avatar.sendActions(for: .touchUpInside)
|
||||
XCTAssertEqual(nav.sheetPresentationController?.detents.map(\.identifier), [.init("profile"), .large])
|
||||
XCTAssertEqual(nav.sheetPresentationController?.selectedDetentIdentifier, detent)
|
||||
XCTAssertTrue(pan.isEnabled)
|
||||
XCTAssertFalse(alreadyDisabled.isEnabled)
|
||||
XCTAssertFalse(nav.isModalInPresentation)
|
||||
XCTAssertTrue(avatar.isEnabled)
|
||||
XCTAssertFalse(avatar.isHidden)
|
||||
XCTAssertTrue(nav.topViewController === profile)
|
||||
}
|
||||
XCTAssertEqual(starts, 2)
|
||||
withExtendedLifetime(coordinator) {}
|
||||
nav.dismiss(animated: false)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func descendants(of view: UIView) -> [UIView] {
|
||||
view.subviews.flatMap { [$0] + descendants(of: $0) }
|
||||
|
||||
Reference in New Issue
Block a user