mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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>
396 lines
16 KiB
Swift
396 lines
16 KiB
Swift
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
|
|
}
|