Files
buzz/mobile/ios/Runner/NativeProfileTextEditor.swift
klopez4212 ed11c8d8bf Refresh mobile utility surfaces and theme picker (#6944)
## Summary

- invert mobile utility surfaces so pages and sheets use the softer page
background with raised containers
- rebuild the community theme picker around swipeable Home/Chat
previews, accent and appearance controls, and native iOS glass
interactions
- align shared sheet/profile spacing and radii, and move Theme into the
Community settings card

### Related issue

None found.

### Testing

- `just mobile-check`
- `just mobile-test` (1,871 tests)
- signed iOS Release build installed on a physical iPhone

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
2026-08-28 18:56:42 +01:00

438 lines
14 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" 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,
result: result
)
}
}
@MainActor
private func present(
title: String,
initialValue: String,
placeholder: String,
multiline: Bool,
brightness: String,
pageBackgroundColor: UIColor,
containerBackgroundColor: UIColor,
containerCornerRadius: CGFloat,
allowUnchangedSubmission: 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,
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
}
pendingResult = result
presentedController = navigationController
presenter.present(navigationController, animated: true) { [weak self] in
navigationController.presentationController?.delegate = self
}
}
@MainActor
private func finish(value: String?) {
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: String?) {
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: (String) -> Void
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 = .done
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 = initialValue
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 = 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,
onCancel: @escaping () -> Void,
onSet: @escaping (String) -> Void
) {
self.initialValue = initialValue
self.placeholder = placeholder
self.multiline = multiline
self.pageBackgroundColor = pageBackgroundColor
self.containerBackgroundColor = containerBackgroundColor
self.containerCornerRadius = containerCornerRadius
self.allowUnchangedSubmission = allowUnchangedSubmission
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
let navigationAppearance = UINavigationBarAppearance()
navigationAppearance.configureWithOpaqueBackground()
navigationAppearance.backgroundColor = pageBackgroundColor
navigationAppearance.shadowColor = UIColor.separator.withAlphaComponent(0.2)
let scrollEdgeAppearance = UINavigationBarAppearance()
scrollEdgeAppearance.configureWithOpaqueBackground()
scrollEdgeAppearance.backgroundColor = pageBackgroundColor
scrollEdgeAppearance.shadowColor = .clear
navigationItem.standardAppearance = navigationAppearance
navigationItem.scrollEdgeAppearance = scrollEdgeAppearance
navigationItem.compactAppearance = navigationAppearance
navigationItem.compactScrollEdgeAppearance = scrollEdgeAppearance
navigationItem.leftBarButtonItem = UIBarButtonItem(
barButtonSystemItem: .cancel,
target: self,
action: #selector(cancelTapped)
)
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "Set",
style: .done,
target: self,
action: #selector(setTapped)
)
updateNavigation()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if multiline {
textView.becomeFirstResponder()
} else {
textField.becomeFirstResponder()
}
}
private var currentValue: String {
multiline ? textView.text : (textField.text ?? "")
}
private var hasUnsavedChanges: Bool {
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 = hasUnsavedChanges
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)
}
@objc private func setTapped() {
guard hasUnsavedChanges else { return }
onSet(currentValue)
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
setTapped()
return false
}
override func numberOfSections(in tableView: UITableView) -> Int { 1 }
override func tableView(
_ tableView: UITableView,
numberOfRowsInSection section: Int
) -> Int { 1 }
override func tableView(
_ tableView: UITableView,
heightForRowAt indexPath: IndexPath
) -> CGFloat {
multiline ? 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 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
)
}
}