diff --git a/mobile/ios/NativeControlTests/CreateChannelAccessibilityTests.swift b/mobile/ios/NativeControlTests/CreateChannelAccessibilityTests.swift new file mode 100644 index 0000000000..1a7afb39f0 --- /dev/null +++ b/mobile/ios/NativeControlTests/CreateChannelAccessibilityTests.swift @@ -0,0 +1,30 @@ +import Flutter +import UIKit + +@main +struct CreateChannelAccessibilityTests { + @MainActor static func main() { + let controller = NativeCreateChannelViewController(configuration: [:]) + controller.loadViewIfNeeded() + controller.tableView.frame = CGRect(x: 0, y: 0, width: 390, height: 844) + controller.tableView.reloadData() + controller.tableView.layoutIfNeeded() + for section in 0..<2 { + let header = controller.tableView.headerView(forSection: section)! + precondition(header.accessibilityElementsHidden, "Field heading must not add a VoiceOver stop") + precondition(!header.isAccessibilityElement) + precondition(header.textLabel?.isAccessibilityElement == false) + let cell = controller.tableView.cellForRow(at: IndexPath(row: 0, section: section))! + let field = cell.contentView.subviews.first { $0 is UITextField || $0 is UITextView }! + precondition(field.accessibilityLabel == (section == 0 ? "Channel name" : "Description, optional")) + precondition(!field.accessibilityElementsHidden) + } + controller.showError("Try again") + controller.tableView.reloadData() + controller.tableView.layoutIfNeeded() + for section in 0..<2 { + precondition(controller.tableView.headerView(forSection: section)!.accessibilityElementsHidden) + } + print("Create-channel accessibility passed: fields own labels before and after retry") + } +} diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index f956862884..d8fe37443e 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ C0AF00000000000000000001 /* NativeConfirmationDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AF00000000000000000002 /* NativeConfirmationDialog.swift */; }; C0AF00000000000000000021 /* NativePresenceMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AF00000000000000000022 /* NativePresenceMenu.swift */; }; C0AF00000000000000000011 /* NativeAddMembersSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AF00000000000000000012 /* NativeAddMembersSheet.swift */; }; + C0AF00000000000000000071 /* NativeCreateChannelSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AF00000000000000000072 /* NativeCreateChannelSheet.swift */; }; BZZ00000000000000000001 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ00000000000000000006 /* NotificationService.swift */; }; BZZ00000000000000000002 /* NotificationService.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = BZZ00000000000000000009 /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; BZZ00000000000000000020 /* BuzzPushKit in Frameworks */ = {isa = PBXBuildFile; productRef = BZZ00000000000000000022 /* BuzzPushKit */; }; @@ -114,6 +115,7 @@ C0AF00000000000000000002 /* NativeConfirmationDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeConfirmationDialog.swift; sourceTree = ""; }; C0AF00000000000000000022 /* NativePresenceMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePresenceMenu.swift; sourceTree = ""; }; C0AF00000000000000000012 /* NativeAddMembersSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAddMembersSheet.swift; sourceTree = ""; }; + C0AF00000000000000000072 /* NativeCreateChannelSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeCreateChannelSheet.swift; sourceTree = ""; }; BZZ00000000000000000006 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; BZZ00000000000000000007 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; BZZ00000000000000000008 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = NotificationService.entitlements; sourceTree = ""; }; @@ -317,6 +319,7 @@ C0AF00000000000000000002 /* NativeConfirmationDialog.swift */, C0AF00000000000000000022 /* NativePresenceMenu.swift */, C0AF00000000000000000012 /* NativeAddMembersSheet.swift */, + C0AF00000000000000000072 /* NativeCreateChannelSheet.swift */, 4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */, 7B81D01A2F40D00100A17E01 /* NativeCommunityEntry.swift */, 7B81D01A2F40D00100A17E91 /* NativeProfileSetup.swift */, @@ -664,6 +667,7 @@ C0AF00000000000000000001 /* NativeConfirmationDialog.swift in Sources */, C0AF00000000000000000021 /* NativePresenceMenu.swift in Sources */, C0AF00000000000000000011 /* NativeAddMembersSheet.swift in Sources */, + C0AF00000000000000000071 /* NativeCreateChannelSheet.swift in Sources */, 4A71C0192F40D00100A17E01 /* NativeProfileTextEditor.swift in Sources */, 7B81D0192F40D00100A17E01 /* NativeCommunityEntry.swift in Sources */, 7B81D0192F40D00100A17E91 /* NativeProfileSetup.swift in Sources */, diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index b50961c44b..baa880fe56 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -44,6 +44,7 @@ import os.log private var concentricSheetSurfaceChannel: FlutterMethodChannel? private var nativeAttachmentPopoverCoordinator: NativeAttachmentPopoverCoordinator? private var nativeEmojiPickerCoordinator: NativeEmojiPickerCoordinator? + private var nativeCreateChannelSheetCoordinator: NativeCreateChannelSheetCoordinator? private var nativeAddMembersSheetCoordinator: NativeAddMembersSheetCoordinator? private var nativeConfirmationDialogCoordinator: NativeConfirmationDialogCoordinator? private var nativeProfileSetupCoordinator: NativeProfileSetupCoordinator? @@ -301,6 +302,13 @@ import os.log NativePresenceMenuFactory(messenger: messenger), withId: "buzz/presence_menu" ) + nativeCreateChannelSheetCoordinator = NativeCreateChannelSheetCoordinator( + messenger: messenger, + parentViewController: engineBridge.pluginRegistry.registrar( + forPlugin: "BuzzNativeCreateChannelSheet" + )?.viewController + ) + nativeAddMembersSheetCoordinator = NativeAddMembersSheetCoordinator( messenger: messenger, parentViewController: engineBridge.pluginRegistry.registrar( diff --git a/mobile/ios/Runner/JumpToLatestGlassButton.swift b/mobile/ios/Runner/JumpToLatestGlassButton.swift index ac3b64e30d..b53295c4a6 100644 --- a/mobile/ios/Runner/JumpToLatestGlassButton.swift +++ b/mobile/ios/Runner/JumpToLatestGlassButton.swift @@ -530,6 +530,7 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { switch icon { case "plus": buttonIconName = "plus" case "close": buttonIconName = "xmark" + case "check": buttonIconName = "checkmark" case "camera": buttonIconName = "camera" case "photoLibrary": buttonIconName = "photo.on.rectangle.angled" case "palette": buttonIconName = "paintpalette" diff --git a/mobile/ios/Runner/NativeAddMembersSheet.swift b/mobile/ios/Runner/NativeAddMembersSheet.swift index 32b17d9e26..6258d8a5d1 100644 --- a/mobile/ios/Runner/NativeAddMembersSheet.swift +++ b/mobile/ios/Runner/NativeAddMembersSheet.swift @@ -66,20 +66,21 @@ final class NativeAddMembersSheetCoordinator: NSObject, UIAdaptivePresentationCo if request == session { picker?.update(data) } result(nil) case "dismiss": - if request == session { close(notify: false) } - result(nil) + if request == session { close(notify: false) { result(nil) } } + else { result(nil) } default: result(FlutterMethodNotImplemented) } } - private func close(notify: Bool) { - guard let request = session, let navigation else { return } + private func close(notify: Bool, completion: (() -> Void)? = nil) { + guard let request = session, let navigation else { completion?(); return } session = nil self.navigation = nil picker = nil navigation.dismiss(animated: !UIAccessibility.isReduceMotionEnabled) { [weak self] in if notify { self?.channel.invokeMethod("closed", arguments: ["session": request]) } + completion?() } } @@ -106,6 +107,8 @@ final class NativeAddMembersViewController: UITableViewController, UISearchResul !selectedKeys.contains($0["pubkey"] as? String ?? "") } } + private var checkmarkAction: Bool { state["confirmWithCheckmark"] as? Bool == true } + private var atLimit: Bool { selected.count >= (state["selectionLimit"] as? Int ?? Int.max) } private var busy: Bool { state["submitting"] as? Bool == true } private var loading: Bool { state["loading"] as? Bool == true } private var loadError: Bool { state["loadError"] as? Bool == true } @@ -117,7 +120,7 @@ final class NativeAddMembersViewController: UITableViewController, UISearchResul pageColor = Self.color(state["pageColor"]) ?? .systemGroupedBackground rowColor = Self.color(state["rowColor"]) ?? .secondarySystemGroupedBackground super.init(style: .insetGrouped) - title = "Add members" + title = state["title"] as? String ?? "Add members" } @available(*, unavailable) @@ -142,6 +145,12 @@ final class NativeAddMembersViewController: UITableViewController, UISearchResul definesPresentationContext = true navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancel)) navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Add", style: .done, target: self, action: #selector(add)) + if checkmarkAction { + navigationItem.leftBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "xmark"), style: .plain, target: self, action: #selector(cancel)) + navigationItem.leftBarButtonItem?.accessibilityLabel = "Close" + navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "checkmark"), style: .done, target: self, action: #selector(add)) + navigationItem.rightBarButtonItem?.accessibilityLabel = "Create message" + } NavigationTopEdge.configure(tableView, item: navigationItem) applyState() } @@ -163,7 +172,11 @@ final class NativeAddMembersViewController: UITableViewController, UISearchResul navigationController?.isModalInPresentation = busy navigationItem.leftBarButtonItem?.isEnabled = !busy navigationItem.rightBarButtonItem?.isEnabled = !busy && !selected.isEmpty - navigationItem.rightBarButtonItem?.title = busy ? "Adding…" : selected.isEmpty ? "Add" : "Add (\(selected.count))" + if checkmarkAction { + navigationItem.rightBarButtonItem?.accessibilityLabel = busy ? "Creating message" : "Create message" + } else { + navigationItem.rightBarButtonItem?.title = busy ? "Adding…" : selected.isEmpty ? "Add" : "Add (\(selected.count))" + } search.searchBar.isUserInteractionEnabled = !busy tableView.reloadData() let error = state["error"] as? String ?? "" @@ -204,7 +217,7 @@ final class NativeAddMembersViewController: UITableViewController, UISearchResul override func numberOfSections(in tableView: UITableView) -> Int { 2 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - section == 0 ? selected.count : (loading || loadError || users.isEmpty ? 1 : users.count) + section == 0 ? selected.count : (atLimit || loading || loadError || users.isEmpty ? 1 : users.count) } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { section == 0 ? (selected.isEmpty ? nil : "Selected") : "People and agents" @@ -215,12 +228,12 @@ final class NativeAddMembersViewController: UITableViewController, UISearchResul var content = cell.defaultContentConfiguration() var avatarRequest: (String, String)? var memberLabel: String? - if indexPath.section == 1 && (loading || loadError || users.isEmpty) { - content.text = loading ? "Searching…" : loadError ? "Couldn't load people or agents. Tap to retry." : "No matching people or agents." + if indexPath.section == 1 && (atLimit || loading || loadError || users.isEmpty) { + content.text = atLimit ? "DMs support up to nine people, including you." : loading ? "Searching…" : loadError ? "Couldn't load people or agents. Tap to retry." : "No matching people or agents." content.textProperties.color = .secondaryLabel content.textProperties.numberOfLines = 0 cell.selectionStyle = loadError && !loading ? .default : .none - if loadError && !loading { cell.accessibilityTraits.insert(.button) } + if loadError && !loading && !atLimit { cell.accessibilityTraits.insert(.button) } } else { let user = indexPath.section == 0 ? selected[indexPath.row] : users[indexPath.row] content.text = user["name"] as? String @@ -264,8 +277,8 @@ final class NativeAddMembersViewController: UITableViewController, UISearchResul override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) guard !busy else { return } - if indexPath.section == 1 && (loading || loadError || users.isEmpty) { - if loadError && !loading { send("retry", [:]) } + if indexPath.section == 1 && (atLimit || loading || loadError || users.isEmpty) { + if loadError && !loading && !atLimit { send("retry", [:]) } return } let user = indexPath.section == 0 ? selected[indexPath.row] : users[indexPath.row] diff --git a/mobile/ios/Runner/NativeCreateChannelSheet.swift b/mobile/ios/Runner/NativeCreateChannelSheet.swift new file mode 100644 index 0000000000..6e25589532 --- /dev/null +++ b/mobile/ios/Runner/NativeCreateChannelSheet.swift @@ -0,0 +1,281 @@ +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) + } +} diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 5c8102148f..6b9bc930ec 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -4,6 +4,7 @@ import '../../shared/widgets/collapsible_section_body.dart'; import '../../shared/me/me_sync.dart'; import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'dart:math' show max, min; import 'dart:ui'; @@ -35,6 +36,9 @@ import '../../shared/widgets/anchored_popover_menu.dart'; import '../../shared/widgets/bee_refresh_indicator.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/buzz_setting_menu.dart'; +import '../../shared/widgets/buzz_titled_sheet_layout.dart'; +import '../../shared/widgets/ios_glass_navigation_action.dart'; +import '../../shared/widgets/ios_glass_navigation_button.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../../shared/widgets/modal_presentation.dart'; @@ -47,6 +51,8 @@ import '../../shared/profile/user_cache_provider.dart'; import '../../onboarding_app_entry.dart'; import '../pairing/pairing_provider.dart'; import 'channel.dart'; +import 'ios_create_channel_sheet.dart'; +import 'ios_add_members_sheet.dart'; import 'channel_actions_sheet.dart'; import 'channel_detail_page.dart'; import 'channel_management_provider.dart'; @@ -480,17 +486,63 @@ class ChannelsPage extends HookConsumerWidget { final topSectionGradient = context.appColors.topSectionGradient; final usesPinnedGradient = topSectionGradient != null; Future openCreateAction(_QuickAction action) async { + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS) { + Channel? channel; + try { + if (action == _QuickAction.createChannel) { + final actions = ref.read(channelActionsProvider); + channel = await IosCreateChannelSheet.present( + context: context, + onCreate: + ({ + required name, + required description, + required visibility, + required ttlSeconds, + }) => actions.createChannel( + name: name, + description: description, + visibility: visibility, + channelType: 'stream', + ttlSeconds: ttlSeconds, + ), + ); + } else { + channel = await showGeneralDialog( + context: context, + barrierColor: Colors.transparent, + transitionDuration: Duration.zero, + pageBuilder: (_, _, _) => _NewDirectMessageSheet( + currentPubkey: currentPubkey, + nativePresentation: true, + ), + ); + } + if (channel != null && context.mounted) await openChannel(channel); + } on PlatformException catch (error) { + if (context.mounted) { + ScaffoldMessenger.of(context).showFeedbackSnackBar( + SnackBar( + content: Text(error.message ?? 'Couldn’t open the form'), + ), + kind: ToastKind.error, + ); + } + } + return; + } final channel = await showBuzzModalBottomSheet( context: context, - title: action == _QuickAction.createChannel - ? 'Create a new channel' - : 'New message', + showCloseButton: false, constraints: _quickActionSheetConstraints(context), isScrollControlled: true, - showDragHandle: true, - builder: (_) => action == _QuickAction.createChannel - ? const _CreateChannelSheet() - : _NewDirectMessageSheet(currentPubkey: currentPubkey), + useSafeArea: true, + builder: (_) => SizedBox( + height: double.infinity, + child: action == _QuickAction.createChannel + ? const _CreateChannelSheet() + : _NewDirectMessageSheet(currentPubkey: currentPubkey), + ), ); if (channel != null && context.mounted) await openChannel(channel); } @@ -791,6 +843,6 @@ class _CommunitySurfaceRoute extends PageRouteBuilder { BoxConstraints _quickActionSheetConstraints(BuildContext context) { final mediaQuery = MediaQuery.of(context); return BoxConstraints( - maxHeight: mediaQuery.size.height - mediaQuery.viewPadding.top - Grid.sm, + maxHeight: mediaQuery.size.height - mediaQuery.viewPadding.top, ); } diff --git a/mobile/lib/features/channels/channels_page/sheets.dart b/mobile/lib/features/channels/channels_page/sheets.dart index 513537aba3..8f40c704cf 100644 --- a/mobile/lib/features/channels/channels_page/sheets.dart +++ b/mobile/lib/features/channels/channels_page/sheets.dart @@ -58,148 +58,184 @@ class _CreateChannelSheet extends HookConsumerWidget { Navigator.of(context).pop(created); } } catch (error) { - errorMessage.value = error.toString(); + if (context.mounted) errorMessage.value = error.toString(); } finally { - isSubmitting.value = false; + if (context.mounted) isSubmitting.value = false; } } - return Padding( - padding: EdgeInsets.fromLTRB( - Grid.gutter, - 0, - Grid.gutter, - MediaQuery.viewInsetsOf(context).bottom + Grid.xs, - ), - child: SafeArea( - top: false, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _CreateChannelFieldLabel(label: 'Name'), - const SizedBox(height: Grid.xxs), - _CreateChannelFieldShell( - child: TextField( - key: const Key('create-channel-name'), - controller: nameController, - enabled: !isSubmitting.value, - autofocus: true, - autocorrect: false, - enableSuggestions: false, - textCapitalization: TextCapitalization.none, - decoration: InputDecoration( - hintText: 'release-notes', - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - contentPadding: const EdgeInsets.symmetric( - horizontal: Grid.twelve, - vertical: Grid.xs, + return _CreationSheetLayout( + title: 'New channel', + actionKey: const Key('create-channel-submit'), + actionLabel: 'Create', + busy: isSubmitting.value, + onSubmit: canSubmit ? submit : null, + child: Padding( + padding: EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + MediaQuery.viewInsetsOf(context).bottom + Grid.xs, + ), + child: SafeArea( + top: false, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ExcludeSemantics( + child: _CreateChannelFieldLabel(label: 'Name'), + ), + const SizedBox(height: Grid.xxs), + _CreateChannelFieldShell( + label: 'Name', + child: TextField( + key: const Key('create-channel-name'), + controller: nameController, + enabled: !isSubmitting.value, + autofocus: true, + autocorrect: false, + enableSuggestions: false, + textCapitalization: TextCapitalization.none, + decoration: InputDecoration( + hintText: 'release-notes', + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: Grid.twelve, + vertical: Grid.xs, + ), + ), + textInputAction: TextInputAction.done, + onSubmitted: (_) { + if (canSubmit) { + unawaited(submit()); + } + }, + ), + ), + const SizedBox(height: Grid.xs), + const ExcludeSemantics( + child: _CreateChannelFieldLabel( + label: 'Description', + optional: true, + ), + ), + const SizedBox(height: Grid.xxs), + _CreateChannelFieldShell( + label: 'Description, optional', + child: TextField( + key: const Key('create-channel-description'), + controller: descriptionController, + enabled: !isSubmitting.value, + decoration: InputDecoration( + hintText: 'What this channel is for', + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: Grid.twelve, + vertical: Grid.xs, + ), + ), + minLines: 2, + maxLines: 3, + textInputAction: TextInputAction.done, + onSubmitted: (_) { + if (canSubmit) { + unawaited(submit()); + } + }, + ), + ), + const SizedBox(height: Grid.sm), + SheetActionSection( + dividerIndent: Grid.xs, + children: [ + BuzzSettingMenu( + controlKey: const Key('create-channel-type'), + grouped: true, + preserveTextInput: true, + trailingIcon: Icons.unfold_more, + valueLabel: temporary.value ? 'Temporary' : 'Ongoing', + enabled: !isSubmitting.value, + label: 'Channel type', + onSelected: (value) => temporary.value = value, + options: const [ + _CreateChannelMenuOption( + key: Key('create-channel-type-ongoing'), + label: 'Ongoing', + value: false, + ), + _CreateChannelMenuOption( + key: Key('create-channel-type-temporary'), + label: 'Temporary', + value: true, + ), + ], + value: temporary.value, + ), + if (temporary.value) ...[ + BuzzSettingMenu( + grouped: true, + preserveTextInput: true, + trailingIcon: Icons.unfold_more, + controlKey: const Key('create-channel-ttl'), + enabled: !isSubmitting.value, + label: 'Expires after', + onSelected: (value) => ttlSeconds.value = value, + options: _createChannelTtlOptions, + value: ttlSeconds.value, + valueLabel: _createChannelTtlOptions + .firstWhere( + (option) => option.value == ttlSeconds.value, + ) + .label, + ), + ], + BuzzSettingMenu( + controlKey: const Key('create-channel-visibility'), + grouped: true, + preserveTextInput: true, + trailingIcon: Icons.unfold_more, + valueLabel: visibility.value == 'open' + ? 'Public' + : 'Private', + enabled: !isSubmitting.value, + label: 'Visibility', + onSelected: (value) => visibility.value = value, + options: const [ + _CreateChannelMenuOption( + key: Key('create-channel-visibility-public'), + label: 'Public', + value: 'open', + ), + _CreateChannelMenuOption( + key: Key('create-channel-visibility-private'), + label: 'Private', + value: 'private', + ), + ], + value: visibility.value, + ), + ], + ), + if (errorMessage.value case final error?) ...[ + const SizedBox(height: Grid.xs), + Semantics( + liveRegion: true, + child: Text( + error, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), ), ), - textInputAction: TextInputAction.done, - onSubmitted: (_) { - if (canSubmit) { - unawaited(submit()); - } - }, - ), - ), - const SizedBox(height: Grid.xs), - const _CreateChannelFieldLabel( - label: 'Description', - optional: true, - ), - const SizedBox(height: Grid.xxs), - _CreateChannelFieldShell( - child: TextField( - key: const Key('create-channel-description'), - controller: descriptionController, - enabled: !isSubmitting.value, - decoration: InputDecoration( - hintText: 'What this channel is for', - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - contentPadding: const EdgeInsets.symmetric( - horizontal: Grid.twelve, - vertical: Grid.xs, - ), - ), - minLines: 2, - maxLines: 3, - textInputAction: TextInputAction.done, - onSubmitted: (_) { - if (canSubmit) { - unawaited(submit()); - } - }, - ), - ), - const SizedBox(height: Grid.sm), - _CreateChannelRadioGroup( - enabled: !isSubmitting.value, - label: 'Channel type', - onSelected: (value) => temporary.value = value, - options: const [ - _CreateChannelMenuOption( - key: Key('create-channel-type-ongoing'), - label: 'Ongoing', - value: false, - ), - _CreateChannelMenuOption( - key: Key('create-channel-type-temporary'), - label: 'Temporary', - value: true, - ), ], - value: temporary.value, - ), - if (temporary.value) ...[ - const SizedBox(height: Grid.xs), - BuzzSettingMenu( - controlKey: const Key('create-channel-ttl'), - enabled: !isSubmitting.value, - label: 'Expires after', - onSelected: (value) => ttlSeconds.value = value, - options: _createChannelTtlOptions, - value: ttlSeconds.value, - valueLabel: _createChannelTtlOptions - .firstWhere((option) => option.value == ttlSeconds.value) - .label, - ), ], - const SizedBox(height: Grid.sm), - _CreateChannelRadioGroup( - enabled: !isSubmitting.value, - label: 'Visibility', - onSelected: (value) => visibility.value = value, - options: const [ - _CreateChannelMenuOption( - key: Key('create-channel-visibility-public'), - label: 'Public', - value: 'open', - ), - _CreateChannelMenuOption( - key: Key('create-channel-visibility-private'), - label: 'Private', - value: 'private', - ), - ], - value: visibility.value, - ), - if (errorMessage.value case final error?) ...[ - const SizedBox(height: Grid.xs), - Text( - error, - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.error, - ), - ), - ], - ], + ), ), ), ), @@ -240,96 +276,18 @@ class _CreateChannelFieldLabel extends StatelessWidget { class _CreateChannelFieldShell extends StatelessWidget { final Widget child; - const _CreateChannelFieldShell({required this.child}); + final String label; + + const _CreateChannelFieldShell({required this.child, required this.label}); @override Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( - color: context.controlSurface.withValues(alpha: 0.55), - border: Border.all(color: context.colors.outlineVariant), + color: context.colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(Radii.container), ), - child: child, - ); - } -} - -class _CreateChannelRadioGroup extends StatelessWidget { - final bool enabled; - final String label; - final ValueChanged onSelected; - final List<_CreateChannelMenuOption> options; - final T value; - - const _CreateChannelRadioGroup({ - required this.enabled, - required this.label, - required this.onSelected, - required this.options, - required this.value, - }); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _CreateChannelFieldLabel(label: label), - const SizedBox(height: Grid.xxs), - Material( - color: context.colors.surface, - shape: RoundedRectangleBorder( - side: BorderSide(color: context.colors.outlineVariant), - borderRadius: BorderRadius.circular(Radii.container), - ), - child: RadioGroup( - groupValue: value, - onChanged: (nextValue) { - if (enabled && nextValue != null) { - onSelected(nextValue); - } - }, - child: Column( - children: [ - for (final (index, option) in options.indexed) ...[ - RadioListTile( - key: option.key, - value: option.value, - enabled: enabled, - selected: option.value == value, - controlAffinity: ListTileControlAffinity.leading, - contentPadding: const EdgeInsets.symmetric( - horizontal: Grid.xs, - vertical: Grid.half, - ), - visualDensity: VisualDensity.standard, - activeColor: context.colors.primary, - title: Text( - option.label, - style: context.textTheme.bodyMedium?.copyWith( - color: enabled - ? context.colors.onSurface - : context.colors.onSurface.withValues(alpha: 0.38), - fontWeight: option.value == value - ? FontWeight.w600 - : FontWeight.w400, - ), - ), - ), - if (index < options.length - 1) - Divider( - height: 1, - indent: Grid.xs, - endIndent: Grid.xs, - color: context.colors.outlineVariant, - ), - ], - ], - ), - ), - ), - ], + child: Semantics(label: label, child: child), ); } } @@ -337,7 +295,11 @@ class _CreateChannelRadioGroup extends StatelessWidget { class _NewDirectMessageSheet extends HookConsumerWidget { final String? currentPubkey; - const _NewDirectMessageSheet({required this.currentPubkey}); + final bool nativePresentation; + const _NewDirectMessageSheet({ + required this.currentPubkey, + this.nativePresentation = false, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -348,6 +310,7 @@ class _NewDirectMessageSheet extends HookConsumerWidget { final selectedUsers = useState>([]); final isSubmitting = useState(false); final submitError = useState(null); + final completedChannel = useState(null); final previewDirectoryActive = ref.watch(dmDirectoryPreviewEnabledProvider); useEffect(() { @@ -419,301 +382,400 @@ class _NewDirectMessageSheet extends HookConsumerWidget { waitForChannelList: false, ); if (context.mounted) { - Navigator.of(context).pop(channel); + if (nativePresentation) { + completedChannel.value = channel; + } else { + Navigator.of(context).pop(channel); + } } } catch (error) { - submitError.value = error.toString(); + if (context.mounted) submitError.value = error.toString(); } finally { - isSubmitting.value = false; + if (context.mounted) isSubmitting.value = false; } } - return Padding( - padding: EdgeInsets.fromLTRB( - Grid.gutter, - 0, - Grid.gutter, - MediaQuery.viewInsetsOf(context).bottom + Grid.xs, - ), - child: SafeArea( - top: false, - // The sheet bounds this viewport even with the keyboard visible. - // Keep rows as slivers so search and chip updates do not lay out the - // entire directory. The field still scrolls with long chip selections. - child: CustomScrollView( - shrinkWrap: true, - slivers: [ - SliverToBoxAdapter( - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: isSubmitting.value ? null : queryFocusNode.requestFocus, - child: SizedBox( - width: double.infinity, - child: ConstrainedBox( - key: const Key('new-dm-recipient-field'), - constraints: const BoxConstraints(minHeight: Grid.xl), - child: DecoratedBox( - decoration: BoxDecoration( - color: context.colors.surface, - border: Border.all( - color: context.colors.outlineVariant, - ), - borderRadius: BorderRadius.circular(Radii.container), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: Grid.twelve, - vertical: Grid.xxs, - ), - child: LayoutBuilder( - builder: (context, constraints) { - final hasSelectedUsers = - selectedUsers.value.isNotEmpty; - final searchFieldWidth = hasSelectedUsers - ? 96.0 - : (constraints.maxWidth - 32).clamp( - 96.0, - constraints.maxWidth, - ); + if (nativePresentation) { + final profiles = ref.watch(userCacheProvider); + final auth = ref.watch(mediaGetAuthServiceProvider); + final client = ref.watch(mediaHttpClientProvider); + final colors = context.colors; + String? avatarUrl(DirectoryUser user) => + profiles[user.pubkey]?.avatarUrl ?? user.avatarUrl; + String avatarKey(DirectoryUser user) => jsonEncode([ + user.pubkey, + avatarUrl(user), + user.initial, + user.isAgent, + colors.primaryContainer.toARGB32(), + colors.onPrimaryContainer.toARGB32(), + ]); + Map row(DirectoryUser user) => { + 'pubkey': user.pubkey, + 'name': names.labelFor(user.pubkey), + 'detail': user.secondaryLabel, + 'agent': user.isAgent, + 'avatarKey': avatarKey(user), + 'initial': user.initial, + 'avatarBackground': colors.primaryContainer.toARGB32(), + 'avatarForeground': colors.onPrimaryContainer.toARGB32(), + }; + return IosAddMembersSheet( + onAvatar: (pubkey, key) async { + final user = recipients + .where((user) => user.pubkey == pubkey) + .firstOrNull; + if (user == null || avatarKey(user) != key) return null; + return nativeAvatarImage( + url: avatarUrl(user), + initial: user.initial, + isAgent: user.isAgent, + background: colors.primaryContainer, + foreground: colors.onPrimaryContainer, + networkImage: (url) => + MediaImageProvider(url: url, auth: auth, client: client), + ); + }, + state: { + 'title': 'New message', + 'confirmWithCheckmark': true, + 'selectionLimit': 8, + 'completed': completedChannel.value != null, + 'query': query.value, + 'loading': directoryAsync.isLoading || isSearchTransitionPending, + 'loadError': directoryAsync.hasError, + 'error': submitError.value, + 'submitting': isSubmitting.value, + 'selected': [for (final user in selectedUsers.value) row(user)], + 'users': [for (final user in availableResults) row(user)], + }, + onQuery: (value) { + if (!isSubmitting.value) query.value = value; + }, + onToggle: (pubkey) { + if (isSubmitting.value) return; + final user = recipients + .where((user) => user.pubkey == pubkey) + .firstOrNull; + if (user == null) return; + if (selectedPubkeys.contains(pubkey.toLowerCase())) { + selectedUsers.value = selectedUsers.value + .where((user) => user.pubkey != pubkey) + .toList(); + } else if (selectedUsers.value.length < 8) { + selectedUsers.value = [...selectedUsers.value, user]; + } + }, + onSubmit: () => unawaited(submit()), + onRetry: () { + if (normalizedQuery.isEmpty) { + ref.invalidate(relayDirectoryUsersProvider); + } else { + ref.invalidate(relayDirectorySearchProvider(normalizedQuery)); + } + }, + onClose: () => Navigator.of(context).pop(completedChannel.value), + ); + } - return Wrap( - key: const Key('new-dm-recipient-wrap'), - spacing: 6, - runSpacing: 6, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.symmetric( - vertical: Grid.half, - ), - child: Text( - 'To:', - style: context.textTheme.bodyLarge - ?.copyWith(fontWeight: FontWeight.w600), - ), - ), - for (final user in selectedUsers.value) - _SelectedDmRecipientChip( - user: user, - label: names.labelFor(user.pubkey), - enabled: !isSubmitting.value, - onDeleted: () { - selectedUsers.value = [ - for (final candidate - in selectedUsers.value) - if (candidate.pubkey != user.pubkey) - candidate, - ]; - queryFocusNode.requestFocus(); - }, - ), - SizedBox( - width: searchFieldWidth, - child: TextField( - key: const Key('new-dm-search'), - controller: queryController, - focusNode: queryFocusNode, - autofocus: true, - autocorrect: false, - enableSuggestions: false, - enabled: !isSubmitting.value, - onChanged: (value) => query.value = value, - onSubmitted: (_) { - if (canSubmit) { - unawaited(submit()); - } - }, - decoration: InputDecoration( - hintText: hasSelectedUsers - ? null - : 'Search for a person', - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - isDense: true, - contentPadding: - const EdgeInsets.symmetric( - vertical: Grid.half, - ), - suffixIcon: isSubmitting.value - ? const Padding( - padding: EdgeInsets.all( - Grid.half, - ), - child: SizedBox.square( - dimension: 16, - child: BuzzLoadingIndicator( - size: 16, - semanticLabel: - 'Creating conversation', - ), - ), - ) - : null, - suffixIconConstraints: - const BoxConstraints( - minHeight: 32, - minWidth: 32, + return _CreationSheetLayout( + title: 'New message', + actionKey: const Key('new-dm-submit'), + actionLabel: 'Done', + busy: isSubmitting.value, + onSubmit: canSubmit ? submit : null, + child: Padding( + padding: EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + MediaQuery.viewInsetsOf(context).bottom + Grid.xs, + ), + child: SafeArea( + top: false, + // The sheet bounds this viewport even with the keyboard visible. + // Keep rows as slivers so search and chip updates do not lay out the + // entire directory. The field still scrolls with long chip selections. + child: CustomScrollView( + shrinkWrap: true, + slivers: [ + SliverToBoxAdapter( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: isSubmitting.value + ? null + : queryFocusNode.requestFocus, + child: SizedBox( + width: double.infinity, + child: ConstrainedBox( + key: const Key('new-dm-recipient-field'), + constraints: const BoxConstraints(minHeight: Grid.xl), + child: DecoratedBox( + decoration: BoxDecoration( + color: context.colors.surface, + border: Border.all( + color: context.colors.outlineVariant, + ), + borderRadius: BorderRadius.circular(Radii.container), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.twelve, + vertical: Grid.xxs, + ), + child: LayoutBuilder( + builder: (context, constraints) { + final hasSelectedUsers = + selectedUsers.value.isNotEmpty; + final searchFieldWidth = hasSelectedUsers + ? 96.0 + : (constraints.maxWidth - 32).clamp( + 96.0, + constraints.maxWidth, + ); + + return Wrap( + key: const Key('new-dm-recipient-wrap'), + spacing: 6, + runSpacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + vertical: Grid.half, + ), + child: Text( + 'To:', + style: context.textTheme.bodyLarge + ?.copyWith( + fontWeight: FontWeight.w600, ), ), - textInputAction: TextInputAction.done, ), - ), - ], - ); - }, + for (final user in selectedUsers.value) + _SelectedDmRecipientChip( + user: user, + label: names.labelFor(user.pubkey), + enabled: !isSubmitting.value, + onDeleted: () { + selectedUsers.value = [ + for (final candidate + in selectedUsers.value) + if (candidate.pubkey != user.pubkey) + candidate, + ]; + queryFocusNode.requestFocus(); + }, + ), + SizedBox( + width: searchFieldWidth, + child: TextField( + key: const Key('new-dm-search'), + controller: queryController, + focusNode: queryFocusNode, + autofocus: true, + autocorrect: false, + enableSuggestions: false, + enabled: !isSubmitting.value, + onChanged: (value) => query.value = value, + onSubmitted: (_) { + if (canSubmit) { + unawaited(submit()); + } + }, + decoration: InputDecoration( + hintText: hasSelectedUsers + ? null + : 'Search for a person', + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + isDense: true, + contentPadding: + const EdgeInsets.symmetric( + vertical: Grid.half, + ), + suffixIcon: isSubmitting.value + ? const Padding( + padding: EdgeInsets.all( + Grid.half, + ), + child: SizedBox.square( + dimension: 16, + child: BuzzLoadingIndicator( + size: 16, + semanticLabel: + 'Creating conversation', + ), + ), + ) + : null, + suffixIconConstraints: + const BoxConstraints( + minHeight: 32, + minWidth: 32, + ), + ), + textInputAction: TextInputAction.done, + ), + ), + ], + ); + }, + ), ), ), ), ), ), ), - ), - const SliverToBoxAdapter(child: SizedBox(height: Grid.xs)), - Builder( - key: const Key('new-dm-results'), - builder: (context) { - if (selectedUsers.value.length >= 8) { - return const SliverToBoxAdapter( - child: SizedBox( - height: 96, - child: Center( - child: Text( - 'DMs support up to nine people, including you.', - ), - ), - ), - ); - } - if (directoryAsync.isLoading && directoryAsync.asData == null || - isSearchTransitionPending) { - return const SliverToBoxAdapter( - child: SizedBox( - height: 280, - child: Center( - child: BuzzLoadingIndicator( - size: 44, - semanticLabel: 'Loading people', - ), - ), - ), - ); - } - if (directoryAsync.hasError) { - return SliverToBoxAdapter( - child: SizedBox( - height: 280, - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - 'Could not load people from this relay.', - ), - const SizedBox(height: Grid.half), - TextButton( - onPressed: () { - if (normalizedQuery.isEmpty) { - ref.invalidate(relayDirectoryUsersProvider); - } else { - ref.invalidate( - relayDirectorySearchProvider( - normalizedQuery, - ), - ); - } - }, - child: const Text('Retry'), - ), - ], - ), - ), - ), - ); - } - if (availableResults.isEmpty) { - return SliverToBoxAdapter( - child: SizedBox( - height: 280, - child: Center( - child: Text( - normalizedQuery.isEmpty - ? 'No people or agents available to message.' - : 'No matching users.', - ), - ), - ), - ); - } - return SliverList.builder( - itemCount: availableResults.length, - itemBuilder: (context, index) { - final user = availableResults[index]; - return AppListCardItem( - index: index, - itemCount: availableResults.length, - dividerIndent: Grid.xs + 40 + Grid.xs, - child: ListTile( - key: Key('new-dm-person-${user.pubkey}'), - leading: AvatarImage( - imageUrl: user.avatarUrl, - radius: 20, - backgroundColor: context.controlSurface, - fallback: Text( - user.initial, - style: context.textTheme.labelLarge?.copyWith( - color: context.colors.onPrimaryContainer, - fontWeight: FontWeight.w600, - ), + const SliverToBoxAdapter(child: SizedBox(height: Grid.xs)), + Builder( + key: const Key('new-dm-results'), + builder: (context) { + if (selectedUsers.value.length >= 8) { + return const SliverToBoxAdapter( + child: SizedBox( + height: 96, + child: Center( + child: Text( + 'DMs support up to nine people, including you.', ), - isAgent: user.isAgent, ), - title: Text( - names.labelFor(user.pubkey), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - user.secondaryLabel, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - trailing: Icon( - BuzzIcons.plus, - size: 18, - color: context.colors.onSurfaceVariant, - ), - onTap: isSubmitting.value - ? null - : () { - selectedUsers.value = [ - ...selectedUsers.value, - user, - ]; - queryController.clear(); - query.value = ''; - debouncedQuery.value = ''; - submitError.value = null; - queryFocusNode.requestFocus(); - }, ), ); - }, - ); - }, - ), - if (submitError.value case final error?) ...[ - const SliverToBoxAdapter(child: SizedBox(height: Grid.xxs)), - SliverToBoxAdapter( - child: Text( - error, - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.error, + } + if (directoryAsync.isLoading && + directoryAsync.asData == null || + isSearchTransitionPending) { + return const SliverToBoxAdapter( + child: SizedBox( + height: 280, + child: Center( + child: BuzzLoadingIndicator( + size: 44, + semanticLabel: 'Loading people', + ), + ), + ), + ); + } + if (directoryAsync.hasError) { + return SliverToBoxAdapter( + child: SizedBox( + height: 280, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Could not load people from this relay.', + ), + const SizedBox(height: Grid.half), + TextButton( + onPressed: () { + if (normalizedQuery.isEmpty) { + ref.invalidate(relayDirectoryUsersProvider); + } else { + ref.invalidate( + relayDirectorySearchProvider( + normalizedQuery, + ), + ); + } + }, + child: const Text('Retry'), + ), + ], + ), + ), + ), + ); + } + if (availableResults.isEmpty) { + return SliverToBoxAdapter( + child: SizedBox( + height: 280, + child: Center( + child: Text( + normalizedQuery.isEmpty + ? 'No people or agents available to message.' + : 'No matching users.', + ), + ), + ), + ); + } + return SliverList.builder( + itemCount: availableResults.length, + itemBuilder: (context, index) { + final user = availableResults[index]; + return AppListCardItem( + index: index, + itemCount: availableResults.length, + dividerIndent: Grid.xs + 40 + Grid.xs, + child: ListTile( + key: Key('new-dm-person-${user.pubkey}'), + leading: AvatarImage( + imageUrl: user.avatarUrl, + radius: 20, + backgroundColor: context.controlSurface, + fallback: Text( + user.initial, + style: context.textTheme.labelLarge?.copyWith( + color: context.colors.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), + isAgent: user.isAgent, + ), + title: Text( + names.labelFor(user.pubkey), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + user.secondaryLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: Icon( + BuzzIcons.plus, + size: 18, + color: context.colors.onSurfaceVariant, + ), + onTap: isSubmitting.value + ? null + : () { + selectedUsers.value = [ + ...selectedUsers.value, + user, + ]; + queryController.clear(); + query.value = ''; + debouncedQuery.value = ''; + submitError.value = null; + queryFocusNode.requestFocus(); + }, + ), + ); + }, + ); + }, + ), + if (submitError.value case final error?) ...[ + const SliverToBoxAdapter(child: SizedBox(height: Grid.xxs)), + SliverToBoxAdapter( + child: Text( + error, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), ), ), - ), + ], ], - ], + ), ), ), ); @@ -797,3 +859,86 @@ class _SelectedDmRecipientChip extends StatelessWidget { ); } } + +class _CreationSheetLayout extends StatelessWidget { + const _CreationSheetLayout({ + required this.title, + required this.actionKey, + required this.actionLabel, + required this.busy, + required this.onSubmit, + required this.child, + }); + + final String title; + final Key actionKey; + final String actionLabel; + final bool busy; + final VoidCallback? onSubmit; + final Widget child; + + @override + Widget build(BuildContext context) { + final close = busy ? null : () => Navigator.of(context).pop(); + final ios = defaultTargetPlatform == TargetPlatform.iOS; + return PopScope( + canPop: !busy, + child: BuzzTitledSheetLayout( + title: title, + leading: ios + ? IosGlassNavigationButton( + icon: IosGlassNavigationIcon.close, + semanticLabel: 'Close', + onPressed: close, + ) + : IconButton( + tooltip: 'Close', + onPressed: close, + icon: const Icon(BuzzIcons.x), + ), + trailing: actionLabel == 'Done' + ? (ios + ? IosGlassNavigationButton( + key: actionKey, + icon: IosGlassNavigationIcon.check, + semanticLabel: 'Done', + onPressed: onSubmit, + isBusy: busy, + ) + : IconButton( + key: actionKey, + tooltip: 'Done', + onPressed: onSubmit, + icon: busy + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(BuzzIcons.check), + )) + : ios + ? IosGlassNavigationAction( + width: 80, + key: actionKey, + label: actionLabel, + onPressed: onSubmit, + isBusy: busy, + ) + : TextButton( + key: actionKey, + onPressed: onSubmit, + child: busy + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + semanticsLabel: 'Creating', + ), + ) + : Text(actionLabel), + ), + child: child, + ), + ); + } +} diff --git a/mobile/lib/features/channels/ios_add_members_sheet.dart b/mobile/lib/features/channels/ios_add_members_sheet.dart index 477a6dd6cc..fe73818a4b 100644 --- a/mobile/lib/features/channels/ios_add_members_sheet.dart +++ b/mobile/lib/features/channels/ios_add_members_sheet.dart @@ -151,7 +151,11 @@ class _IosAddMembersSheetState extends State { if (!mounted || _closed) return; _closed = true; ScaffoldMessenger.of(context).showFeedbackSnackBar( - const SnackBar(content: Text('Couldn’t open Add members')), + SnackBar( + content: Text( + 'Couldn’t open ${widget.state['title'] ?? 'Add members'}', + ), + ), kind: ToastKind.error, ); widget.onClose(); @@ -160,6 +164,12 @@ class _IosAddMembersSheetState extends State { Future _update() async { if (!_presented || !mounted || _closed) return; try { + if (widget.state['completed'] == true) { + _closed = true; + await _channel.invokeMethod('dismiss', {'session': _session}); + if (mounted) widget.onClose(); + return; + } await _channel.invokeMethod('update', _state); } on PlatformException { _failed(); diff --git a/mobile/lib/features/channels/ios_create_channel_sheet.dart b/mobile/lib/features/channels/ios_create_channel_sheet.dart new file mode 100644 index 0000000000..45e040bda2 --- /dev/null +++ b/mobile/lib/features/channels/ios_create_channel_sheet.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../shared/theme/theme.dart'; +import 'channel.dart'; + +/// Presents an entirely UIKit channel form; relay writes stay in Dart. +class IosCreateChannelSheet { + static const _channel = MethodChannel('buzz/create_channel_sheet'); + static bool _open = false; + + static Future present({ + required BuildContext context, + required Future Function({ + required String name, + required String description, + required String visibility, + required int? ttlSeconds, + }) + onCreate, + }) async { + if (_open) return null; + _open = true; + Channel? created; + final session = UniqueKey().toString(); + final theme = utilitySurfaceThemeData(Theme.of(context)); + _channel.setMethodCallHandler((call) async { + final data = Map.from(call.arguments as Map); + if (call.method != 'create' || data['session'] != session) return null; + try { + created = await onCreate( + name: data['name'] as String, + description: data['description'] as String, + visibility: data['visibility'] as String, + ttlSeconds: data['ttlSeconds'] as int?, + ); + return {'success': true}; + } catch (error) { + return {'error': error.toString()}; + } + }); + try { + await _channel.invokeMethod('present', { + 'session': session, + 'dark': theme.brightness == Brightness.dark, + 'pageColor': theme.colorScheme.surface.toARGB32(), + 'rowColor': theme.colorScheme.surfaceContainerHighest.toARGB32(), + }); + return created; + } finally { + _channel.setMethodCallHandler(null); + _open = false; + } + } +} diff --git a/mobile/lib/shared/widgets/buzz_setting_menu.dart b/mobile/lib/shared/widgets/buzz_setting_menu.dart index 9c5c820634..f8b59ea76e 100644 --- a/mobile/lib/shared/widgets/buzz_setting_menu.dart +++ b/mobile/lib/shared/widgets/buzz_setting_menu.dart @@ -34,6 +34,9 @@ class BuzzSettingMenu extends StatelessWidget { /// Use the same borderless row as Settings inside a shared card. final bool grouped; + /// Keep an active form field and its keyboard focused while choosing a value. + final bool preserveTextInput; + /// Affordance for opening the menu. final IconData trailingIcon; @@ -48,11 +51,17 @@ class BuzzSettingMenu extends StatelessWidget { this.valueIcon, required this.valueLabel, this.grouped = false, + this.preserveTextInput = false, this.trailingIcon = BuzzIcons.chevronDown, }); @override Widget build(BuildContext context) { + final menu = _buildMenu(context); + return preserveTextInput ? TextFieldTapRegion(child: menu) : menu; + } + + Widget _buildMenu(BuildContext context) { if (grouped && defaultTargetPlatform == TargetPlatform.iOS) { return Stack( children: [ @@ -104,7 +113,7 @@ class BuzzSettingMenu extends StatelessWidget { void toggleMenu(MenuController menu) { if (menu.isOpen) return menu.close(); - FocusManager.instance.primaryFocus?.unfocus(); + if (!preserveTextInput) FocusManager.instance.primaryFocus?.unfocus(); menu.open(); } @@ -130,32 +139,35 @@ class BuzzSettingMenu extends StatelessWidget { for (final option in options) // The check icon carries no semantics, so mark the selection on // the item's own node. - MergeSemantics( - child: Semantics( - checked: option.value == value, - inMutuallyExclusiveGroup: true, - child: MenuItemButton( - key: option.key, - onPressed: () => onSelected(option.value), - style: MenuItemButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: Grid.twelve, + TextFieldTapRegion( + enabled: preserveTextInput, + child: MergeSemantics( + child: Semantics( + checked: option.value == value, + inMutuallyExclusiveGroup: true, + child: MenuItemButton( + key: option.key, + onPressed: () => onSelected(option.value), + style: MenuItemButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: Grid.twelve, + ), + ), + // A trailing check keeps labels flush with the menu's edge. + trailingIcon: option.value == value + ? Icon( + Icons.check, + size: 20, + color: context.colors.primary, + ) + : const SizedBox(width: 20), + // Root menu panels ignore size styles and shrink-wrap their items, + // so the label sets the width: the row's, less the item's padding, + // the check and the gap before it. + child: SizedBox( + width: constraints.maxWidth - Grid.twelve * 3 - 20, + child: Text(option.label), ), - ), - // A trailing check keeps labels flush with the menu's edge. - trailingIcon: option.value == value - ? Icon( - Icons.check, - size: 20, - color: context.colors.primary, - ) - : const SizedBox(width: 20), - // Root menu panels ignore size styles and shrink-wrap their items, - // so the label sets the width: the row's, less the item's padding, - // the check and the gap before it. - child: SizedBox( - width: constraints.maxWidth - Grid.twelve * 3 - 20, - child: Text(option.label), ), ), ), diff --git a/mobile/lib/shared/widgets/frosted_app_bar.dart b/mobile/lib/shared/widgets/frosted_app_bar.dart index bd7b527c8d..2018441721 100644 --- a/mobile/lib/shared/widgets/frosted_app_bar.dart +++ b/mobile/lib/shared/widgets/frosted_app_bar.dart @@ -542,6 +542,7 @@ IosNavigationAction? _nativeAction(Widget? widget) { IosGlassNavigationIcon.reply => 'arrowshape.turn.up.left', IosGlassNavigationIcon.back => 'chevron.backward', IosGlassNavigationIcon.close => 'xmark', + IosGlassNavigationIcon.check => 'checkmark', IosGlassNavigationIcon.camera => 'camera', IosGlassNavigationIcon.photoLibrary => 'photo.on.rectangle', IosGlassNavigationIcon.palette => 'paintpalette', diff --git a/mobile/lib/shared/widgets/ios_glass_navigation_button.dart b/mobile/lib/shared/widgets/ios_glass_navigation_button.dart index ae0ebb55c0..9fd1236947 100644 --- a/mobile/lib/shared/widgets/ios_glass_navigation_button.dart +++ b/mobile/lib/shared/widgets/ios_glass_navigation_button.dart @@ -13,6 +13,7 @@ import '../theme/theme.dart'; enum IosGlassNavigationIcon { back, close, + check, camera, photoLibrary, palette, @@ -251,6 +252,7 @@ class IosGlassNavigationButton extends HookWidget { IosGlassNavigationIcon.back => BuzzIcons.chevronLeft, IosGlassNavigationIcon.close => BuzzIcons.x, + IosGlassNavigationIcon.check => BuzzIcons.check, IosGlassNavigationIcon.camera => BuzzIcons.camera, IosGlassNavigationIcon.photoLibrary => BuzzIcons.images, diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 231c99b897..9aa9396692 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -68,6 +68,7 @@ import 'package:buzz/shared/widgets/skeleton.dart'; import '../../helpers/thread_ids.dart'; part 'channels_page_test/presence_tests.dart'; +part 'channels_page_test/native_creation_tests.dart'; part 'channels_page_test/dm_recipient_layout_tests.dart'; part 'channels_page_test/unread_readiness_tests.dart'; part 'channels_page_test/home_community_flight.dart'; @@ -75,10 +76,12 @@ part 'channels_page_test/read_seed_journey.dart'; void main() { setUpAll(() async { - if (Platform.environment.containsKey('COMMUNITY_SCREENSHOTS')) { + if (Platform.environment.containsKey('COMMUNITY_SCREENSHOTS') || + Platform.environment.containsKey('CREATE_SHEET_SCREENSHOTS')) { for (final font in { 'Inter': 'assets/fonts/InterVariable.ttf', 'BuzzTabler': 'assets/fonts/TablerIcons.ttf', + 'MaterialIcons': 'fonts/MaterialIcons-Regular.otf', }.entries) { await (FontLoader( font.key, @@ -2756,82 +2759,165 @@ void main() { await tester.pump(); }); - testWidgets('create channel sheet lists type and visibility radio options', ( - tester, - ) async { - await tester.pumpWidget( - buildTestable( - overrides: [ - channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), - ], - ), - ); - await tester.pumpAndSettle(); + registerNativeCreationTests( + (overrides) => buildTestable(overrides: overrides), + ); - await tester.tap(find.byTooltip('Create')); - await tester.pumpAndSettle(); - await tester.tap(find.text('New channel')); - await tester.pumpAndSettle(); - final createSheetRect = tester.getRect(find.byType(BottomSheet).last); - expect(createSheetRect.top, greaterThanOrEqualTo(24)); - tester.testTextInput.hide(); - await tester.pumpAndSettle(); + testWidgets( + 'create channel sheet uses checked menus and a validated header action', + (tester) async { + final semantics = tester.ensureSemantics(); + tester.view.physicalSize = const Size(390, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final actions = _CreateSheetActions(); + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + channelActionsProvider.overrideWith((ref) => actions), + ], + ), + ); + await tester.pumpAndSettle(); - expect(find.text('Create a new channel'), findsOneWidget); - expect(find.text('Name'), findsOneWidget); - expect(find.text('Description Optional'), findsOneWidget); - expect(find.text('Channel type'), findsOneWidget); - expect(find.text('Ongoing'), findsOneWidget); - expect(find.text('Temporary'), findsOneWidget); - expect(find.text('Visibility'), findsOneWidget); - expect(find.text('Public'), findsOneWidget); - expect(find.text('Private'), findsOneWidget); - expect(find.byKey(const Key('create-channel-submit')), findsNothing); - final nameField = tester.widget( - find.byKey(const Key('create-channel-name')), - ); - final descriptionField = tester.widget( - find.byKey(const Key('create-channel-description')), - ); - expect(nameField.textInputAction, TextInputAction.done); - expect(nameField.onSubmitted, isNotNull); - expect(descriptionField.textInputAction, TextInputAction.done); - expect(descriptionField.onSubmitted, isNotNull); - expect( - tester.getSize(find.byKey(const Key('create-channel-name'))).height, - greaterThanOrEqualTo(52), - ); - expect( - tester - .getSize(find.byKey(const Key('create-channel-type-ongoing'))) - .height, - greaterThanOrEqualTo(56), - ); + await tester.tap(find.byTooltip('Create')); + await tester.pumpAndSettle(); + await tester.tap(find.text('New channel')); + await tester.pumpAndSettle(); + final createSheetRect = tester.getRect(find.byType(BottomSheet).last); + expect(createSheetRect.top, 0); + expect(createSheetRect.height, 800); + tester.testTextInput.hide(); + await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('create-channel-type-temporary'))); - await tester.pumpAndSettle(); + if (Platform.environment['CREATE_SHEET_SCREENSHOTS'] + case final directory?) { + final boundary = tester + .element(find.byType(BottomSheet).last) + .findAncestorRenderObjectOfType()!; + await tester.runAsync(() async { + final image = await boundary.toImage(pixelRatio: 2); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + await Directory(directory).create(recursive: true); + await File( + '$directory/new-channel.png', + ).writeAsBytes(bytes!.buffer.asUint8List()); + image.dispose(); + }); + } - expect(find.text('Expires after'), findsOneWidget); - expect(find.text('7 days'), findsOneWidget); - expect( - tester.getSize(find.byKey(const Key('create-channel-ttl'))).height, - greaterThanOrEqualTo(51), - ); + expect(find.text('New channel'), findsOneWidget); + expect(find.byTooltip('Close'), findsOneWidget); + expect(find.text('Name'), findsOneWidget); + expect(find.text('Description Optional'), findsOneWidget); + void expectSingleFieldLabels() { + final nodes = []; + void collect(SemanticsNode node) { + nodes.add(node); + node.visitChildren((child) { + collect(child); + return true; + }); + } - final privateVisibility = find.byKey( - const Key('create-channel-visibility-private'), - ); - await tester.ensureVisible(privateVisibility); - await tester.pumpAndSettle(); - await tester.tap(privateVisibility); - await tester.pumpAndSettle(); - expect( - tester - .widget>(find.byType(RadioGroup)) - .groupValue, - 'private', - ); - }); + collect(tester.getSemantics(find.byType(BottomSheet).last)); + for (final label in ['Name', 'Description']) { + final matches = nodes.where((node) => node.label.contains(label)); + expect(matches, hasLength(1)); + expect(matches.single.flagsCollection.isTextField, isTrue); + } + } + + expectSingleFieldLabels(); + expect(find.text('Channel type'), findsOneWidget); + expect(find.text('Ongoing'), findsOneWidget); + expect(find.text('Temporary'), findsNothing); + expect(find.text('Visibility'), findsOneWidget); + expect(find.text('Public'), findsOneWidget); + expect(find.text('Private'), findsNothing); + final submit = find.byKey(const Key('create-channel-submit')); + expect(tester.widget(submit).onPressed, isNull); + await tester.enterText( + find.byKey(const Key('create-channel-name')), + 'release-notes', + ); + await tester.pump(); + expect(tester.widget(submit).onPressed, isNotNull); + await tester.tap(submit); + await tester.pumpAndSettle(); + expect(actions.creations.single, { + 'name': 'release-notes', + 'type': 'stream', + 'visibility': 'open', + 'description': '', + 'ttl': null, + }); + expect(find.text('Bad state: Try again'), findsOneWidget); + expectSingleFieldLabels(); + + final nameField = tester.widget( + find.byKey(const Key('create-channel-name')), + ); + final descriptionField = tester.widget( + find.byKey(const Key('create-channel-description')), + ); + expect(nameField.textInputAction, TextInputAction.done); + expect(nameField.onSubmitted, isNotNull); + expect(descriptionField.textInputAction, TextInputAction.done); + expect(descriptionField.onSubmitted, isNotNull); + expect( + tester.getSize(find.byKey(const Key('create-channel-name'))).height, + greaterThanOrEqualTo(52), + ); + expect( + tester.getSize(find.byKey(const Key('create-channel-type'))).height, + greaterThanOrEqualTo(48), + ); + + await tester.tap(find.byKey(const Key('create-channel-type'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('create-channel-type-temporary'))); + await tester.pumpAndSettle(); + + expect(find.text('Expires after'), findsOneWidget); + expect(find.text('7 days'), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('create-channel-ttl'))).height, + greaterThanOrEqualTo(51), + ); + + await tester.tap(find.byKey(const Key('create-channel-visibility'))); + await tester.pumpAndSettle(); + final privateVisibility = find.byKey( + const Key('create-channel-visibility-private'), + ); + await tester.ensureVisible(privateVisibility); + await tester.pumpAndSettle(); + await tester.tap(privateVisibility); + await tester.pumpAndSettle(); + expect(find.text('Private'), findsOneWidget); + expect(find.text('Public'), findsNothing); + await tester.enterText( + find.byKey(const Key('create-channel-description')), + 'Release updates', + ); + await tester.tap(submit); + await tester.pumpAndSettle(); + expect(actions.creations.last, { + 'name': 'release-notes', + 'type': 'stream', + 'visibility': 'private', + 'description': 'Release updates', + 'ttl': 7 * 24 * 60 * 60, + }); + await tester.tap(find.byTooltip('Close')); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('create-channel-name')), findsNothing); + semantics.dispose(); + }, + ); dmRecipientLayoutTests( (overrides) => buildTestable( @@ -2891,8 +2977,16 @@ void main() { await tester.pumpAndSettle(); final dmSheetRect = tester.getRect(find.byType(BottomSheet).last); - expect(dmSheetRect.top, greaterThanOrEqualTo(24)); + expect(dmSheetRect.top, 0); + expect(dmSheetRect.height, 800); expect(find.text('New message'), findsOneWidget); + expect(find.byTooltip('Close'), findsOneWidget); + expect( + tester + .widget(find.byKey(const Key('new-dm-submit'))) + .onPressed, + isNull, + ); expect(find.text('To:'), findsOneWidget); expect(find.text('Search for a person'), findsOneWidget); final recipientField = find.byKey(const Key('new-dm-recipient-field')); @@ -3014,10 +3108,26 @@ void main() { expect(searchField.textInputAction, TextInputAction.done); expect(searchField.onSubmitted, isNotNull); - // Done opens the DM at once instead of waiting on the full channel list. - await tester.testTextInput.receiveAction(TextInputAction.done); + // The header action opens the DM without waiting on the full channel list. + await tester.tap(find.byKey(const Key('new-dm-submit'))); await tester.pump(); expect(waits, [false]); + expect( + tester + .widget(find.byKey(const Key('new-dm-submit'))) + .onPressed, + isNull, + ); + expect( + tester + .widget( + find.byWidgetPredicate( + (widget) => widget is IconButton && widget.tooltip == 'Close', + ), + ) + .onPressed, + isNull, + ); }); testWidgets('wraps many recipients without overflowing above the keyboard', ( @@ -3844,3 +3954,31 @@ class _PendingDmActions implements ChannelActions { @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } + +class _CreateSheetActions implements ChannelActions { + final creations = >[]; + + @override + Future createChannel({ + String? channelId, + required String name, + required String channelType, + required String visibility, + List labels = const [], + String? description, + int? ttlSeconds, + bool waitForChannelList = true, + }) async { + creations.add({ + 'name': name, + 'type': channelType, + 'visibility': visibility, + 'description': description, + 'ttl': ttlSeconds, + }); + throw StateError('Try again'); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/mobile/test/features/channels/channels_page_test/dm_recipient_layout_tests.dart b/mobile/test/features/channels/channels_page_test/dm_recipient_layout_tests.dart index 6d93a99920..8983250397 100644 --- a/mobile/test/features/channels/channels_page_test/dm_recipient_layout_tests.dart +++ b/mobile/test/features/channels/channels_page_test/dm_recipient_layout_tests.dart @@ -102,7 +102,7 @@ void dmRecipientLayoutTests(Widget Function(List) harness) { expect(rows.evaluate().length, lessThan(30)); expect(tester.takeException(), isNull); - await tester.tap(find.byTooltip('Close sheet')); + await tester.tap(find.byTooltip('Close')); await tester.pumpAndSettle(); expect(find.text('New message'), findsNothing); }, diff --git a/mobile/test/features/channels/channels_page_test/native_creation_tests.dart b/mobile/test/features/channels/channels_page_test/native_creation_tests.dart new file mode 100644 index 0000000000..fde36ff4ee --- /dev/null +++ b/mobile/test/features/channels/channels_page_test/native_creation_tests.dart @@ -0,0 +1,101 @@ +part of '../channels_page_test.dart'; + +void registerNativeCreationTests(Widget Function(List) buildPage) { + testWidgets( + 'native DM picker searches, limits selection and preserves it on failure', + (tester) async { + const bridge = MethodChannel('buzz/add_members_sheet'); + final states = >[]; + final messenger = tester.binding.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(bridge, (call) async { + if (call.method != 'dismiss') { + states.add(Map.from(call.arguments as Map)); + } + return null; + }); + addTearDown(() { + messenger.setMockMethodCallHandler(bridge, null); + debugDefaultTargetPlatformOverride = null; + }); + final users = [ + for (var i = 0; i < 10; i++) + DirectoryUser(pubkey: 'person$i', displayName: 'Person $i'), + ]; + final actions = _NativeDmActions(); + await tester.pumpWidget( + buildPage([ + channelsProvider.overrideWith(() => _FakeNotifier([])), + relayDirectoryUsersProvider.overrideWith((ref) async => users), + relayDirectorySearchProvider.overrideWith( + (ref, query) async => users + .where((u) => u.label.toLowerCase().contains(query)) + .toList(), + ), + channelActionsProvider.overrideWithValue(actions), + ]), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Create')); + await tester.pumpAndSettle(); + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + await tester.tap(find.text('New DM')); + await tester.pumpAndSettle(); + expect(find.byType(BottomSheet), findsNothing); + expect(states.last['title'], 'New message'); + expect(states.last['confirmWithCheckmark'], true); + final session = states.last['session']; + Future event( + String method, [ + Map data = const {}, + ]) async { + await messenger.handlePlatformMessage( + bridge.name, + const StandardMethodCodec().encodeMethodCall( + MethodCall(method, {'session': session, ...data}), + ), + (_) {}, + ); + await tester.pump(); + if (method == 'query') { + await tester.pump(const Duration(milliseconds: 250)); + } + await tester.pumpAndSettle(); + } + + await event('query', {'value': 'person 2'}); + expect((states.last['users'] as List).single['pubkey'], 'person2'); + await event('toggle', {'pubkey': 'person2'}); + await event('query', {'value': ''}); + for (final user in users) { + await event('toggle', {'pubkey': user.pubkey}); + } + expect(states.last['selected'] as List, hasLength(8)); + await event('submit'); + expect(states.last['error'], contains('Try again')); + expect(states.last['selected'] as List, hasLength(8)); + expect(actions.calls.single, hasLength(8)); + await event('closed'); + expect( + Navigator.of(tester.element(find.byType(ChannelsPage))).canPop(), + isFalse, + ); + await tester.pumpWidget(const SizedBox.shrink()); + debugDefaultTargetPlatformOverride = null; + }, + ); +} + +class _NativeDmActions implements ChannelActions { + final calls = >[]; + @override + Future openDm({ + required List pubkeys, + bool waitForChannelList = true, + }) async { + calls.add(pubkeys); + throw StateError('Try again'); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/mobile/test/features/channels/ios_create_channel_sheet_test.dart b/mobile/test/features/channels/ios_create_channel_sheet_test.dart new file mode 100644 index 0000000000..6beb9ee525 --- /dev/null +++ b/mobile/test/features/channels/ios_create_channel_sheet_test.dart @@ -0,0 +1,118 @@ +import 'dart:async'; + +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/ios_create_channel_sheet.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'native channel creation retains its session through failure and retry', + (tester) async { + const bridge = MethodChannel('buzz/create_channel_sheet'); + final messenger = tester.binding.defaultBinaryMessenger; + var dismissed = Completer(); + Map? configuration; + messenger.setMockMethodCallHandler(bridge, (call) async { + configuration = Map.from(call.arguments as Map); + await dismissed.future; + return null; + }); + addTearDown(() => messenger.setMockMethodCallHandler(bridge, null)); + final payloads = >[]; + final created = Channel( + id: 'channel', + name: 'Test', + channelType: 'stream', + visibility: 'private', + description: 'Details', + createdBy: 'owner', + createdAt: DateTime(2026), + memberCount: 1, + ); + Channel? result; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () async { + result = await IosCreateChannelSheet.present( + context: context, + onCreate: + ({ + required name, + required description, + required visibility, + required ttlSeconds, + }) async { + payloads.add({ + 'name': name, + 'description': description, + 'visibility': visibility, + 'ttl': ttlSeconds, + }); + if (payloads.length == 1) { + throw StateError('Try again'); + } + return created; + }, + ); + }, + child: const Text('Open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pump(); + final session = configuration!['session']; + Future create(String visibility, int? ttl) async { + final response = Completer(); + await messenger.handlePlatformMessage( + bridge.name, + const StandardMethodCodec().encodeMethodCall( + MethodCall('create', { + 'session': session, + 'name': 'Test', + 'description': 'Details', + 'visibility': visibility, + 'ttlSeconds': ttl, + }), + ), + (bytes) => response.complete( + const StandardMethodCodec().decodeEnvelope(bytes!), + ), + ); + return response.future; + } + + expect( + await create('open', null), + containsPair('error', contains('Try again')), + ); + expect(result, isNull); + expect(configuration!['session'], session); + expect(await create('private', 3600), {'success': true}); + expect(payloads.last, { + 'name': 'Test', + 'description': 'Details', + 'visibility': 'private', + 'ttl': 3600, + }); + expect(result, isNull); // Navigation waits until UIKit has dismissed. + dismissed.complete(); + await tester.pumpAndSettle(); + expect(result, same(created)); + dismissed = Completer(); + await tester.tap(find.text('Open')); + await tester.pump(); + dismissed.complete(); // Native X or swipe dismissal returns no channel. + await tester.pumpAndSettle(); + expect(result, isNull); + expect(payloads, hasLength(2)); + }, + ); +} diff --git a/mobile/test/shared/widgets/buzz_setting_menu_test.dart b/mobile/test/shared/widgets/buzz_setting_menu_test.dart index 2616a5d3ae..22f907c3a6 100644 --- a/mobile/test/shared/widgets/buzz_setting_menu_test.dart +++ b/mobile/test/shared/widgets/buzz_setting_menu_test.dart @@ -27,6 +27,55 @@ void main() { ), ); + testWidgets('form menu preserves keyboard focus through selection', ( + tester, + ) async { + final focus = FocusNode(); + addTearDown(focus.dispose); + String? selected; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + TextField(focusNode: focus), + BuzzSettingMenu( + controlKey: const Key('form-menu'), + enabled: true, + grouped: true, + preserveTextInput: true, + label: 'Visibility', + onSelected: (value) => selected = value, + options: const [ + BuzzSettingMenuOption( + key: Key('private'), + label: 'Private', + value: 'private', + ), + ], + value: 'open', + valueLabel: 'Public', + ), + ], + ), + ), + ), + ); + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + expect(focus.hasFocus, isTrue); + expect(tester.testTextInput.isVisible, isTrue); + await tester.tap(find.byKey(const Key('form-menu'))); + await tester.pumpAndSettle(); + expect(focus.hasFocus, isTrue); + expect(tester.testTextInput.isVisible, isTrue); + await tester.tap(find.byKey(const Key('private'))); + await tester.pumpAndSettle(); + expect(selected, 'private'); + expect(focus.hasFocus, isTrue); + expect(tester.testTextInput.isVisible, isTrue); + }); + testWidgets('exposes button, expanded and checked semantics', (tester) async { final handle = tester.ensureSemantics(); await tester.pumpWidget(menu(enabled: true)); @@ -111,6 +160,7 @@ void main() { ((view.creationParams as Map)['menu'] as List)[1], containsPair('checked', true), ); + await tester.pumpAndSettle(); view.onPlatformViewCreated!(987); await tester.pump(); Future choose(int index) async { diff --git a/scripts/test-mobile-create-channel-accessibility.sh b/scripts/test-mobile-create-channel-accessibility.sh new file mode 100755 index 0000000000..6cec492caa --- /dev/null +++ b/scripts/test-mobile-create-channel-accessibility.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Exercise the production UIKit create-channel field labels on a booted iOS simulator. +set -euo pipefail +repo_root=$(cd "$(dirname "$0")/.." && pwd) +device=${1:?Pass a booted iOS simulator UDID} +flutter_root=$(sed -n 's/^FLUTTER_ROOT=//p' "$repo_root/mobile/ios/Flutter/Generated.xcconfig") +frameworks="$flutter_root/bin/cache/artifacts/engine/ios/Flutter.xcframework/ios-arm64_x86_64-simulator" +fixture=$(mktemp -d) +trap 'rm -r "$fixture"' EXIT +# Compile in one file to exercise the private production controller directly. +cat "$repo_root/mobile/ios/Runner/NavigationTopEdge.swift" \ + "$repo_root/mobile/ios/Runner/NativeCreateChannelSheet.swift" \ + "$repo_root/mobile/ios/NativeControlTests/CreateChannelAccessibilityTests.swift" > "$fixture/test.swift" +xcrun --sdk iphonesimulator swiftc -parse-as-library \ + -sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)" \ + -target "$(uname -m)-apple-ios16.0-simulator" \ + -F "$frameworks" -framework Flutter \ + -Xlinker -rpath -Xlinker "$frameworks" \ + "$fixture/test.swift" \ + -o "$fixture/create-channel-tests" +xcrun simctl spawn "$device" "$fixture/create-channel-tests"