Files
buzz/mobile/ios/Runner/NativeProfileTextEditor.swift
Tom Brow e558f15faa Mobile: one New chat screen (#9079)
Mobile had two New chat screens.
[#8884](https://github.com/block/buzz/pull/8884) made the Flutter draft
page (first send creates and names the chat) the New chat screen, but
kept the older create form (a native iOS sheet, with a Flutter bottom
sheet on Android) for one case: resuming a creation that an older build
had saved with a chat name and no first message. Nothing creates that
state anymore, so the old form only appeared for users with an
interrupted creation from before that change.

This removes the old form. Every New chat, including that resume case,
now opens the draft page:

- **New chat**: unchanged.
- **Resuming an interrupted creation**: after the existing Resume /
Discard setup / Later prompt, the draft page shows the saved chat with
its agent and workspace locked and a Retry button, the same choices the
old sheet offered when it locked its fields. A creation saved with a
first message resumes exactly as before. One saved without a message
finishes without one, because a saved creation can't change after its
first relay write.

The iOS editor's chat-only code (`presentChat`, `updateChatAssistants`,
the agent list, and its avatar loader) goes too. The same editor still
handles profile fields, channel details and workspace names. The native
test and CI step that covered only the removed chat form are removed.

### Screenshots

Resuming an interrupted creation saved without a first message:

| Before: old native sheet (removed) | After: draft page (kept) |
| --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/5d0a502e92fc3dfeffbe4fa95d63ba8f73d1e612/pr-9079--01-before-resume-native-sheet.png"
width="300"> | <img
src="https://raw.githubusercontent.com/block/buzz/5d0a502e92fc3dfeffbe4fa95d63ba8f73d1e612/pr-9079--02-after-resume-draft-page.png"
width="300"> |

A brand new chat already opened the draft page and is unchanged:

<img
src="https://raw.githubusercontent.com/block/buzz/5d0a502e92fc3dfeffbe4fa95d63ba8f73d1e612/pr-9079--03-new-chat-draft-page.png"
width="300">

### Validation

- Changed tests: `me_page_test` checks that Resume and Discard setup
both open the draft page and that no native sheet is requested.
`solo_creation_tags_test` checks that recovering an interrupted named
creation through the draft page's Retry continues with member setup,
keeps the saved name and agent, and adds no first message.
- Full mobile Flutter suite (4,431 passed) and `flutter analyze`
(clean). iOS debug simulator build succeeds.
- Automated simulator run (iPhone 17 Pro, iOS 27, signed-in account):
New chat opened the draft page, the agent picker selected an agent, and
the first send created the chat, which then appeared under Solo. New
workspace still opened the native editor, enabled Create once named, and
showed the Discard changes prompt on cancel. The recording isn't
attached here because it shows internal account content.
- Resuming an interrupted
pre-[#8884](https://github.com/block/buzz/pull/8884) creation can't be
produced by the current build. For the screenshots above, a
capture-only, uncommitted patch to a `main` build seeded one; the branch
build installed over it resumed it on the draft page. The widget tests
cover finishing it.
- `solo_creation_tags_test` also covers a conflicting pending Solo
organization edit during that Retry: the draft offers the Conflicting
Solo changes dialog, keeps the saved creation, and the next Retry
finishes it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)


buzz-review-completed

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:34:05 -08:00

574 lines
20 KiB
Swift

import Flutter
import UIKit
final class NativeProfileTextEditorCoordinator: NSObject,
UIAdaptivePresentationControllerDelegate
{
private let channel: FlutterMethodChannel
private weak var parentViewController: UIViewController?
private weak var presentedController: UIViewController?
private var pendingResult: FlutterResult?
init(
messenger: FlutterBinaryMessenger,
parentViewController: UIViewController?
) {
channel = FlutterMethodChannel(
name: "buzz/profile_text_editor",
binaryMessenger: messenger
)
self.parentViewController = parentViewController
super.init()
channel.setMethodCallHandler { [weak self] call, result in
self?.handle(call, result: result)
}
}
private func handle(
_ call: FlutterMethodCall,
result: @escaping FlutterResult
) {
guard call.method == "present" || call.method == "presentChannel" || call.method == "presentProject" else {
result(FlutterMethodNotImplemented)
return
}
guard
let arguments = call.arguments as? [String: Any],
let title = arguments["title"] as? String,
let initialValue = arguments["initialValue"] as? String,
let placeholder = arguments["placeholder"] as? String,
let multiline = arguments["multiline"] as? Bool,
let brightness = arguments["brightness"] as? String,
let pageBackgroundArgb = arguments["pageBackgroundArgb"] as? NSNumber,
let containerBackgroundArgb = arguments["containerBackgroundArgb"] as? NSNumber,
let containerCornerRadius = arguments["containerCornerRadius"] as? NSNumber
else {
result(
FlutterError(
code: "invalid_arguments",
message: "Expected profile text editor configuration.",
details: nil
)
)
return
}
let allowUnchangedSubmission =
arguments["allowUnchangedSubmission"] as? Bool ?? false
DispatchQueue.main.async { [weak self] in
self?.present(
title: title,
initialValue: initialValue,
placeholder: placeholder,
multiline: multiline,
brightness: brightness,
pageBackgroundColor: UIColor(argb: pageBackgroundArgb.uint32Value),
containerBackgroundColor: UIColor(argb: containerBackgroundArgb.uint32Value),
containerCornerRadius: CGFloat(containerCornerRadius.doubleValue),
allowUnchangedSubmission: allowUnchangedSubmission,
channelDescription: call.method == "presentChannel" ? arguments["description"] as? String : nil,
originalName: arguments["originalName"] as? String,
originalDescription: arguments["originalDescription"] as? String,
canEditDetails: arguments["canEditDetails"] as? Bool ?? true,
canEditCanvas: arguments["canEditCanvas"] as? Bool ?? false,
canvasContent: arguments["canvasContent"] as? String ?? "",
canvasLoaded: arguments["canvasLoaded"] as? Bool ?? false,
isProjectEditor: call.method == "presentProject",
result: result
)
}
}
@MainActor
private func present(
title: String,
initialValue: String,
placeholder: String,
multiline: Bool,
brightness: String,
pageBackgroundColor: UIColor,
containerBackgroundColor: UIColor,
containerCornerRadius: CGFloat,
allowUnchangedSubmission: Bool,
channelDescription: String?,
originalName: String?,
originalDescription: String?,
canEditDetails: Bool,
canEditCanvas: Bool,
canvasContent: String,
canvasLoaded: Bool,
isProjectEditor: Bool,
result: @escaping FlutterResult
) {
guard presentedController == nil else {
result(
FlutterError(
code: "already_presented",
message: "A profile editor is already open.",
details: nil
)
)
return
}
guard
let presenter = topViewController(
from: parentViewController ?? activeWindowRootViewController()
)
else {
result(
FlutterError(
code: "presentation_failed",
message: "Unable to find a view controller for the profile editor.",
details: nil
)
)
return
}
let editor = NativeProfileTextEditorViewController(
title: title,
initialValue: initialValue,
placeholder: placeholder,
multiline: multiline,
pageBackgroundColor: pageBackgroundColor,
containerBackgroundColor: containerBackgroundColor,
containerCornerRadius: containerCornerRadius,
allowUnchangedSubmission: allowUnchangedSubmission,
channelDescription: channelDescription,
originalName: originalName,
originalDescription: originalDescription,
canEditDetails: canEditDetails,
canEditCanvas: canEditCanvas,
canvasContent: canvasContent,
canvasLoaded: canvasLoaded,
isProjectEditor: isProjectEditor,
onCancel: { [weak self] in self?.finish(value: nil) },
onSet: { [weak self] value in self?.finish(value: value) }
)
let navigationController = UINavigationController(rootViewController: editor)
navigationController.overrideUserInterfaceStyle = brightness == "dark"
? .dark
: .light
if UIDevice.current.userInterfaceIdiom == .pad {
navigationController.modalPresentationStyle = .formSheet
}
if channelDescription != nil {
navigationController.sheetPresentationController?.detents = [.large()]
}
pendingResult = result
presentedController = navigationController
presenter.present(navigationController, animated: true) { [weak self] in
navigationController.presentationController?.delegate = self
}
}
@MainActor
private func finish(value: Any?) {
guard let controller = presentedController else {
resolve(value: value)
return
}
controller.dismiss(animated: true) { [weak self] in
self?.resolve(value: value)
}
}
func presentationControllerDidDismiss(
_ presentationController: UIPresentationController
) {
resolve(value: nil)
}
@MainActor
private func resolve(value: Any?) {
let result = pendingResult
pendingResult = nil
presentedController = nil
result?(value)
}
@MainActor
private func activeWindowRootViewController() -> UIViewController? {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.filter { $0.activationState == .foregroundActive }
.flatMap(\.windows)
.first(where: \.isKeyWindow)?
.rootViewController
}
@MainActor
private func topViewController(
from viewController: UIViewController?
) -> UIViewController? {
if let navigationController = viewController as? UINavigationController {
return topViewController(from: navigationController.visibleViewController)
}
if let tabBarController = viewController as? UITabBarController {
return topViewController(from: tabBarController.selectedViewController)
}
if let presentedViewController = viewController?.presentedViewController {
return topViewController(from: presentedViewController)
}
return viewController
}
}
private final class NativeProfileTextEditorViewController:
UITableViewController,
UITextFieldDelegate,
UITextViewDelegate
{
private let initialValue: String
private let placeholder: String
private let multiline: Bool
private let pageBackgroundColor: UIColor
private let containerBackgroundColor: UIColor
private let containerCornerRadius: CGFloat
private let allowUnchangedSubmission: Bool
private let onCancel: () -> Void
private let onSet: (Any) -> Void
private let channelDescription: String?
private let originalName: String?
private let originalDescription: String?
private let canEditDetails: Bool
private let canEditCanvas: Bool
private let canvasContent: String
private let canvasLoaded: Bool
private var hasCanvas: Bool {
canvasLoaded && !canvasContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private let isProjectEditor: Bool
private var hasFocusedEditor = false
private var isChannelEditor: Bool { channelDescription != nil }
private lazy var textField: UITextField = {
let field = UITextField()
field.translatesAutoresizingMaskIntoConstraints = false
field.placeholder = placeholder
field.text = initialValue
field.font = .preferredFont(forTextStyle: .body)
field.adjustsFontForContentSizeCategory = true
field.clearButtonMode = .whileEditing
field.returnKeyType = isChannelEditor ? .next : .done
field.isEnabled = canEditDetails
field.accessibilityLabel = placeholder
field.autocapitalizationType = .sentences
field.delegate = self
field.addTarget(self, action: #selector(textDidChange), for: .editingChanged)
return field
}()
private lazy var textView: UITextView = {
let view = UITextView()
view.translatesAutoresizingMaskIntoConstraints = false
view.text = channelDescription ?? initialValue
view.isEditable = canEditDetails
view.accessibilityLabel = isChannelEditor ? "Description" : placeholder
view.font = .preferredFont(forTextStyle: .body)
view.adjustsFontForContentSizeCategory = true
view.backgroundColor = .clear
view.textContainerInset = .zero
view.textContainer.lineFragmentPadding = 0
view.autocapitalizationType = .sentences
view.delegate = self
return view
}()
private lazy var placeholderLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.text = isChannelEditor ? "Description" : placeholder
label.font = .preferredFont(forTextStyle: .body)
label.adjustsFontForContentSizeCategory = true
label.textColor = .placeholderText
label.numberOfLines = 0
return label
}()
init(
title: String,
initialValue: String,
placeholder: String,
multiline: Bool,
pageBackgroundColor: UIColor,
containerBackgroundColor: UIColor,
containerCornerRadius: CGFloat,
allowUnchangedSubmission: Bool,
channelDescription: String?,
originalName: String?,
originalDescription: String?,
canEditDetails: Bool,
canEditCanvas: Bool,
canvasContent: String,
canvasLoaded: Bool,
isProjectEditor: Bool,
onCancel: @escaping () -> Void,
onSet: @escaping (Any) -> Void
) {
self.initialValue = initialValue
self.placeholder = placeholder
self.multiline = multiline
self.pageBackgroundColor = pageBackgroundColor
self.containerBackgroundColor = containerBackgroundColor
self.containerCornerRadius = containerCornerRadius
self.allowUnchangedSubmission = allowUnchangedSubmission
self.channelDescription = channelDescription
self.originalName = originalName
self.originalDescription = originalDescription
self.canEditDetails = canEditDetails
self.canEditCanvas = canEditCanvas
self.canvasContent = canvasContent
self.canvasLoaded = canvasLoaded
self.isProjectEditor = isProjectEditor
self.onCancel = onCancel
self.onSet = onSet
super.init(style: .insetGrouped)
self.title = title
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = pageBackgroundColor
tableView.backgroundColor = pageBackgroundColor
tableView.keyboardDismissMode = .interactive
tableView.alwaysBounceVertical = false
NavigationTopEdge.configure(tableView, item: navigationItem)
navigationItem.leftBarButtonItem = UIBarButtonItem(
barButtonSystemItem: .cancel,
target: self,
action: #selector(cancelTapped)
)
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: isProjectEditor ? "Create" : (isChannelEditor ? "Save" : "Set"),
style: .done,
target: self,
action: #selector(setTapped)
)
updateNavigation()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
guard canEditDetails && !hasFocusedEditor else { return }
hasFocusedEditor = true
if multiline {
textView.becomeFirstResponder()
} else {
textField.becomeFirstResponder()
}
}
private var currentValue: String {
multiline ? textView.text : (textField.text ?? "")
}
private var hasUnsavedChanges: Bool {
if isProjectEditor {
return !currentValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
if isChannelEditor {
return allowUnchangedSubmission
|| currentValue.trimmingCharacters(in: .whitespacesAndNewlines)
!= (originalName ?? initialValue).trimmingCharacters(in: .whitespacesAndNewlines)
|| textView.text.trimmingCharacters(in: .whitespacesAndNewlines)
!= (originalDescription ?? channelDescription ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
}
return allowUnchangedSubmission
|| currentValue.trimmingCharacters(in: .whitespacesAndNewlines)
!= initialValue.trimmingCharacters(in: .whitespacesAndNewlines)
}
override var isModalInPresentation: Bool {
get { hasUnsavedChanges }
set {}
}
@objc private func textDidChange() {
updateNavigation()
}
func textViewDidChange(_ textView: UITextView) {
updateNavigation()
}
private func updateNavigation() {
navigationItem.rightBarButtonItem?.isEnabled = canEditDetails && hasUnsavedChanges
&& (!isChannelEditor || !canonicalChannelName.isEmpty)
&& (!isProjectEditor || (
!currentValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& currentValue.count <= 120
))
navigationController?.isModalInPresentation = hasUnsavedChanges
placeholderLabel.isHidden = !textView.text.isEmpty
}
@objc private func cancelTapped() {
guard hasUnsavedChanges else {
onCancel()
return
}
let alert = UIAlertController(
title: "Discard changes?",
message: nil,
preferredStyle: .actionSheet
)
alert.addAction(UIAlertAction(title: "Keep Editing", style: .cancel))
alert.addAction(
UIAlertAction(title: "Discard", style: .destructive) { [weak self] _ in
self?.onCancel()
}
)
if let popover = alert.popoverPresentationController {
popover.barButtonItem = navigationItem.leftBarButtonItem
}
present(alert, animated: true)
}
private var canonicalChannelName: String {
currentValue.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "^#+", with: "", options: .regularExpression)
.trimmingCharacters(in: .whitespacesAndNewlines)
}
@objc private func setTapped() {
guard canEditDetails && hasUnsavedChanges else { return }
if isProjectEditor {
let name = currentValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty, currentValue.count <= 120 else { return }
onSet(name)
} else if isChannelEditor {
guard !canonicalChannelName.isEmpty else { return }
onSet(["action": "save", "name": canonicalChannelName, "description": textView.text ?? ""])
} else {
onSet(currentValue)
}
}
@objc private func editCanvasTapped() {
guard canEditCanvas else { return }
onSet(["action": "canvas", "name": currentValue, "description": textView.text ?? ""])
}
private func removeCanvasTapped() {
guard canEditCanvas && hasCanvas else { return }
let alert = UIAlertController(
title: "Remove canvas?",
message: "This removes the canvas content for everyone in this channel.",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Remove", style: .destructive) { [weak self] _ in
guard let self else { return }
self.onSet(["action": "removeCanvas", "name": self.currentValue, "description": self.textView.text ?? ""])
})
present(alert, animated: true)
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard isChannelEditor && canEditCanvas else { return }
if indexPath.section == 2 { editCanvasTapped() }
if indexPath.section == 3 { removeCanvasTapped() }
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
if isChannelEditor {
textView.becomeFirstResponder()
} else {
setTapped()
}
return false
}
override func numberOfSections(in tableView: UITableView) -> Int { isChannelEditor ? (hasCanvas && canEditCanvas ? 4 : 3) : 1 }
override func tableView(
_ tableView: UITableView,
numberOfRowsInSection section: Int
) -> Int { 1 }
override func tableView(
_ tableView: UITableView,
heightForRowAt indexPath: IndexPath
) -> CGFloat {
if isChannelEditor && indexPath.section >= 2 { return UITableView.automaticDimension }
return (isChannelEditor ? indexPath.section == 1 : multiline && indexPath.section == 0) ? 132 : 52
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
cell.selectionStyle = .none
cell.backgroundColor = containerBackgroundColor
cell.layer.cornerRadius = containerCornerRadius
cell.layer.cornerCurve = .continuous
cell.clipsToBounds = true
if isChannelEditor && indexPath.section >= 2 {
var content = cell.defaultContentConfiguration()
content.textProperties.font = .preferredFont(forTextStyle: .body)
if indexPath.section == 3 {
content.text = "Remove Canvas"
content.textProperties.color = .systemRed
} else if hasCanvas {
content.text = "Canvas"
content.secondaryText = String(canvasContent.prefix(600))
content.secondaryTextProperties.font = .preferredFont(forTextStyle: .body)
content.secondaryTextProperties.numberOfLines = 4
cell.accessoryType = canEditCanvas ? .disclosureIndicator : .none
cell.accessibilityHint = canEditCanvas ? "Opens the canvas editor" : nil
} else {
content.text = canvasLoaded ? "Add Canvas" : "Retry loading canvas"
content.textProperties.color = canEditCanvas ? .label : .secondaryLabel
cell.accessoryType = canEditCanvas ? .disclosureIndicator : .none
}
cell.contentConfiguration = content
cell.selectionStyle = canEditCanvas ? .default : .none
cell.isUserInteractionEnabled = canEditCanvas
cell.accessibilityTraits = canEditCanvas ? .button : .staticText
} else if isChannelEditor ? indexPath.section == 1 : multiline {
cell.contentView.addSubview(textView)
textView.addSubview(placeholderLabel)
NSLayoutConstraint.activate([
textView.leadingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.leadingAnchor),
textView.trailingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.trailingAnchor),
textView.topAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.topAnchor),
textView.bottomAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.bottomAnchor),
placeholderLabel.leadingAnchor.constraint(equalTo: textView.leadingAnchor),
placeholderLabel.trailingAnchor.constraint(equalTo: textView.trailingAnchor),
placeholderLabel.topAnchor.constraint(equalTo: textView.topAnchor),
])
placeholderLabel.isHidden = !textView.text.isEmpty
} else {
cell.contentView.addSubview(textField)
NSLayoutConstraint.activate([
textField.leadingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.leadingAnchor),
textField.trailingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.trailingAnchor),
textField.topAnchor.constraint(equalTo: cell.contentView.topAnchor),
textField.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor),
])
}
return cell
}
}
private extension UIColor {
convenience init(argb: UInt32) {
self.init(
red: CGFloat((argb >> 16) & 0xff) / 255,
green: CGFloat((argb >> 8) & 0xff) / 255,
blue: CGFloat(argb & 0xff) / 255,
alpha: CGFloat((argb >> 24) & 0xff) / 255
)
}
}