Files
buzz/mobile/ios/Runner/NativeCreateChannelSheet.swift
klopez4212 a66cb5f7ab Modernize mobile channel and DM creation sheets (#8942)
Modernizes New Channel and New DM with full-height forms and X/Create or
X/checkmark actions. iOS uses UIKit sheets throughout; Android uses the
Flutter fallback. Channel menus default to Ongoing/Public, and native
menu previews lift only the trailing value while labels stay fixed.

Channel names disable automatic capitalization, autocorrect, and
spellcheck; both platforms use descriptive placeholders.

Preserves recipient search and limits, creation error retries, and
navigation after native dismissal.

Validation: 4,154 mobile tests passed (38 skipped), auxiliary suites and
static checks passed; iOS device/simulator and Android builds passed.
Exercised menus, dismissal, DM search and selection in the simulator and
Android form behavior on Pixel; iPhone UI tested and approved by the
author.

| iOS native form (Simulator) | Android Flutter form (Pixel) |
| --- | --- |
| ![Native iOS channel
form](https://github.com/user-attachments/assets/a3ee78c9-510d-488c-979e-64fa3bded686)
| ![Android channel
form](https://github.com/user-attachments/assets/a2d65f1c-752c-4cef-b659-2b396cbbbcc9)
|

Native menu with the left label fixed:

![Native value-only menu
preview](https://github.com/user-attachments/assets/c4fe8659-e46c-409f-b866-c9568b26ccc1)

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-11-03 18:11:15 +00:00

282 lines
14 KiB
Swift

import Flutter
import UIKit
/// UIKit owns the complete form, including menus and keyboard presentation.
final class NativeCreateChannelSheetCoordinator: NSObject, UIAdaptivePresentationControllerDelegate {
private let channel: FlutterMethodChannel
private weak var parent: UIViewController?
private var navigation: UINavigationController?
private var pending: FlutterResult?
init(messenger: FlutterBinaryMessenger, parentViewController: UIViewController?) {
channel = FlutterMethodChannel(name: "buzz/create_channel_sheet", binaryMessenger: messenger)
parent = parentViewController
super.init()
channel.setMethodCallHandler { [weak self] call, result in
guard let self else { return }
guard call.method == "present" else { result(FlutterMethodNotImplemented); return }
guard self.navigation == nil, let data = call.arguments as? [String: Any],
let session = data["session"] as? String else {
result(FlutterError(code: "unavailable", message: "Cannot open New channel.", details: nil))
return
}
var presenter = self.parent ?? UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }.filter { $0.activationState == .foregroundActive }
.flatMap(\.windows).first(where: \.isKeyWindow)?.rootViewController
while let next = presenter?.presentedViewController { presenter = next }
guard let presenter, presenter.view.window != nil, !presenter.isBeingDismissed else {
result(FlutterError(code: "unavailable", message: "Cannot open New channel.", details: nil))
return
}
let form = NativeCreateChannelViewController(configuration: data)
form.onClose = { [weak self] in self?.close() }
form.onCreate = { [weak self, weak form] values in
self?.channel.invokeMethod("create", arguments: values.merging(["session": session]) { _, new in new }) { response in
guard let self, let form else { return }
if (response as? [String: Any])?["success"] as? Bool == true {
self.close()
} else {
let error = (response as? [String: Any])?["error"] as? String ?? "Couldn't create channel. Please try again."
form.showError(error)
}
}
}
let navigation = UINavigationController(rootViewController: form)
navigation.overrideUserInterfaceStyle = data["dark"] as? Bool == true ? .dark : .light
if UIDevice.current.userInterfaceIdiom == .pad { navigation.modalPresentationStyle = .formSheet }
navigation.sheetPresentationController?.detents = [.large()]
self.navigation = navigation
self.pending = result
presenter.present(navigation, animated: !UIAccessibility.isReduceMotionEnabled) {
navigation.presentationController?.delegate = self
}
}
}
private func close() {
guard let navigation else { return }
navigation.dismiss(animated: !UIAccessibility.isReduceMotionEnabled) { [weak self] in self?.finish() }
}
private func finish() {
navigation = nil
let result = pending
pending = nil
result?(nil)
}
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { finish() }
}
final class NativeCreateChannelViewController: UITableViewController, UITextViewDelegate, UITextFieldDelegate {
var onClose: (() -> Void)?
var onCreate: (([String: Any]) -> Void)?
private let name = UITextField()
private let details = UITextView()
private let detailsPlaceholder = UILabel()
private let pageColor: UIColor
private let rowColor: UIColor
private var temporary = false
private var visibility = "open"
private var ttl = 604800
private var busy = false
private let expiry: [(String, Int)] = [
("30 minutes", 1800), ("1 hour", 3600), ("6 hours", 21600), ("12 hours", 43200),
("1 day", 86400), ("3 days", 259200), ("7 days", 604800), ("14 days", 1209600), ("30 days", 2592000),
]
init(configuration: [String: Any]) {
pageColor = Self.color(configuration["pageColor"]) ?? .systemGroupedBackground
rowColor = Self.color(configuration["rowColor"]) ?? .secondarySystemGroupedBackground
super.init(style: .insetGrouped)
title = "New channel"
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func viewDidLoad() {
super.viewDidLoad()
tableView.backgroundColor = pageColor
tableView.keyboardDismissMode = .interactive
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 56
name.placeholder = "release-notes"
name.accessibilityLabel = "Channel name"
name.font = .preferredFont(forTextStyle: .body)
name.adjustsFontForContentSizeCategory = true
name.autocapitalizationType = .none
name.autocorrectionType = .no
name.spellCheckingType = .no
name.returnKeyType = .next
name.delegate = self
name.addTarget(self, action: #selector(nameChanged), for: .editingChanged)
details.font = .preferredFont(forTextStyle: .body)
details.adjustsFontForContentSizeCategory = true
details.backgroundColor = .clear
details.accessibilityLabel = "Description, optional"
details.delegate = self
details.isScrollEnabled = true
detailsPlaceholder.text = "What this channel is for"
detailsPlaceholder.font = .preferredFont(forTextStyle: .body)
detailsPlaceholder.adjustsFontForContentSizeCategory = true
detailsPlaceholder.textColor = .placeholderText
detailsPlaceholder.numberOfLines = 0
detailsPlaceholder.isAccessibilityElement = false
detailsPlaceholder.isUserInteractionEnabled = false
detailsPlaceholder.translatesAutoresizingMaskIntoConstraints = false
details.addSubview(detailsPlaceholder)
NSLayoutConstraint.activate([
detailsPlaceholder.leadingAnchor.constraint(equalTo: details.leadingAnchor, constant: details.textContainerInset.left + details.textContainer.lineFragmentPadding),
detailsPlaceholder.trailingAnchor.constraint(equalTo: details.frameLayoutGuide.trailingAnchor, constant: -(details.textContainerInset.right + details.textContainer.lineFragmentPadding)),
detailsPlaceholder.topAnchor.constraint(equalTo: details.topAnchor, constant: details.textContainerInset.top),
])
navigationItem.leftBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "xmark"), style: .plain, target: self, action: #selector(cancel))
navigationItem.leftBarButtonItem?.accessibilityLabel = "Close"
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Create", style: .done, target: self, action: #selector(create))
NavigationTopEdge.configure(tableView, item: navigationItem)
updateControls()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
name.becomeFirstResponder()
}
func textViewDidChange(_ textView: UITextView) { detailsPlaceholder.isHidden = !textView.text.isEmpty }
@objc private func nameChanged() { updateControls() }
func textFieldShouldReturn(_ textField: UITextField) -> Bool { details.becomeFirstResponder(); return false }
private func updateControls() {
navigationController?.isModalInPresentation = busy
navigationItem.leftBarButtonItem?.isEnabled = !busy
navigationItem.rightBarButtonItem?.isEnabled = !busy && !(name.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
navigationItem.rightBarButtonItem?.title = busy ? "Creating…" : "Create"
name.isEnabled = !busy
details.isEditable = !busy
for cell in tableView.visibleCells { cell.isUserInteractionEnabled = !busy }
}
@objc private func cancel() { if !busy { onClose?() } }
@objc private func create() {
let value = (name.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !busy, !value.isEmpty else { return }
busy = true
tableView.tableFooterView = nil
updateControls()
var values: [String: Any] = ["name": value, "description": details.text.trimmingCharacters(in: .whitespacesAndNewlines), "visibility": visibility]
if temporary { values["ttlSeconds"] = ttl }
onCreate?(values)
}
func showError(_ error: String) {
busy = false
updateControls()
let label = UILabel()
label.text = error
label.textColor = .systemRed
label.font = .preferredFont(forTextStyle: .footnote)
label.adjustsFontForContentSizeCategory = true
label.numberOfLines = 0
label.textAlignment = .center
label.frame = CGRect(x: 20, y: 0, width: max(1, tableView.bounds.width - 40), height: 0)
label.sizeToFit()
tableView.tableFooterView = label
UIAccessibility.post(notification: .announcement, argument: error)
}
override func numberOfSections(in tableView: UITableView) -> Int { 3 }
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { section == 2 ? (temporary ? 3 : 2) : 1 }
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { section == 0 ? "Name" : section == 1 ? "Description · Optional" : nil }
override func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) {
// The editable field owns the label; its visual heading is decorative.
guard section < 2 else { return }
view.isAccessibilityElement = false
view.accessibilityElementsHidden = true
if let header = view as? UITableViewHeaderFooterView {
header.textLabel?.isAccessibilityElement = false
header.contentView.accessibilityElementsHidden = true
}
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
cell.backgroundColor = rowColor
cell.selectionStyle = .none
cell.isUserInteractionEnabled = !busy
if indexPath.section < 2 {
let field: UIView = indexPath.section == 0 ? name : details
field.removeFromSuperview()
field.translatesAutoresizingMaskIntoConstraints = false
cell.contentView.addSubview(field)
NSLayoutConstraint.activate([
field.leadingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.leadingAnchor),
field.trailingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.trailingAnchor),
field.topAnchor.constraint(equalTo: cell.contentView.topAnchor, constant: 12),
field.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor, constant: -12),
field.heightAnchor.constraint(greaterThanOrEqualToConstant: indexPath.section == 0 ? 28 : 96),
])
} else {
let isType = indexPath.row == 0
let isVisibility = indexPath.row == 1
let label = isType ? "Channel type" : isVisibility ? "Visibility" : "Delete after inactivity"
let value = isType ? (temporary ? "Temporary" : "Ongoing") : isVisibility ? (visibility == "open" ? "Public" : "Private") : expiry.first(where: { $0.1 == ttl })?.0 ?? "7 days"
let button = UIButton(type: .system)
let titleLabel = UILabel()
titleLabel.text = label
titleLabel.font = .preferredFont(forTextStyle: .body)
titleLabel.adjustsFontForContentSizeCategory = true
titleLabel.numberOfLines = 0
titleLabel.isAccessibilityElement = false
var configuration = UIButton.Configuration.plain()
configuration.title = value
configuration.baseForegroundColor = .secondaryLabel
configuration.image = UIImage(systemName: "chevron.up.chevron.down")
configuration.imagePlacement = .trailing
configuration.imagePadding = 12
configuration.contentInsets = NSDirectionalEdgeInsets(top: 14, leading: 8, bottom: 14, trailing: 16)
button.configuration = configuration
button.titleLabel?.adjustsFontForContentSizeCategory = true
button.setContentHuggingPriority(.required, for: .horizontal)
button.setContentCompressionResistancePriority(.required, for: .horizontal)
button.isAccessibilityElement = true
button.accessibilityLabel = label
button.accessibilityValue = value
button.showsMenuAsPrimaryAction = true
let choices: [(String, String)] = isType ? [("Ongoing", "ongoing"), ("Temporary", "temporary")] : isVisibility ? [("Public", "open"), ("Private", "private")] : expiry.map { ($0.0, String($0.1)) }
let selected = isType ? (temporary ? "temporary" : "ongoing") : isVisibility ? visibility : String(ttl)
button.menu = UIMenu(children: choices.map { title, key in
UIAction(title: title, state: key == selected ? .on : .off) { [weak self] _ in
guard let self, !self.busy else { return }
if isType { self.temporary = key == "temporary" }
else if isVisibility { self.visibility = key }
else if let seconds = Int(key) { self.ttl = seconds }
self.tableView.reloadSections(IndexSet(integer: 2), with: .none)
}
})
// Only the trailing value is lifted into UIKit's menu preview.
let row = UIStackView(arrangedSubviews: [titleLabel, UIView(), button])
row.spacing = 12
row.alignment = .center
row.translatesAutoresizingMaskIntoConstraints = false
cell.contentView.addSubview(row)
NSLayoutConstraint.activate([
row.leadingAnchor.constraint(equalTo: cell.contentView.leadingAnchor, constant: 16),
row.trailingAnchor.constraint(equalTo: cell.contentView.trailingAnchor),
row.topAnchor.constraint(equalTo: cell.contentView.topAnchor),
row.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor),
button.heightAnchor.constraint(greaterThanOrEqualToConstant: 48),
])
}
return cell
}
private static func color(_ value: Any?) -> UIColor? {
guard let number = value as? NSNumber else { return nil }
let argb = number.uint32Value
return UIColor(red: CGFloat((argb >> 16) & 255) / 255, green: CGFloat((argb >> 8) & 255) / 255, blue: CGFloat(argb & 255) / 255, alpha: CGFloat((argb >> 24) & 255) / 255)
}
}