Files
buzz/mobile/ios/Runner/NativeStatusEditor.swift

239 lines
10 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import UIKit
/// Status uses the same navigation-controller sheet as the profile text forms.
final class NativeStatusEditorViewController: UITableViewController, UITextFieldDelegate {
let statusField = UITextField()
let emojiField = StatusEmojiTextField()
let datePicker = UIDatePicker()
private let configuration: [String: Any]
private let onChooseEmoji: (@escaping (String?) -> Void) -> Void
private let onCancel: () -> Void
private let onSet: ([String: Any]) -> Void
private var duration: Int
private let durations = [3600, 28800, 86400, 604800]
private let durationLabels = ["1 hour", "8 hours", "1 day", "1 week", "Custom"]
private let presets = [("🗣️", "In a meeting"), ("🚌", "Commuting"),
("🤒", "Out sick"), ("🏖️", "Vacationing"), ("🏠", "Working remotely")]
init(configuration: [String: Any], onChooseEmoji: @escaping (@escaping (String?) -> Void) -> Void = { $0(nil) }, onCancel: @escaping () -> Void,
onSet: @escaping ([String: Any]) -> Void) {
self.configuration = configuration
self.onChooseEmoji = onChooseEmoji
self.onCancel = onCancel
self.onSet = onSet
duration = configuration["duration"] as? Int ?? 86400
super.init(style: .insetGrouped)
title = "Set a status"
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func viewDidLoad() {
super.viewDidLoad()
view.tintColor = .label
navigationController?.view.tintColor = .label
navigationController?.navigationBar.tintColor = .label
let pageColor = color("pageBackgroundArgb", fallback: .systemGroupedBackground)
tableView.backgroundColor = pageColor
// Space the form below the bar without moving UIKit’s native rail actions.
tableView.tableHeaderView = UIView(frame: CGRect(x: 0, y: 0, width: 1, height: 16))
let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = pageColor
navigationItem.standardAppearance = appearance
navigationItem.scrollEdgeAppearance = appearance
navigationItem.compactAppearance = appearance
navigationItem.leftBarButtonItem = UIBarButtonItem(
barButtonSystemItem: .close, target: self, action: #selector(cancel))
navigationItem.leftBarButtonItem?.accessibilityLabel = "Close"
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "Set", style: .done, target: self, action: #selector(save))
if #available(iOS 27.1, *) {
navigationItem.leftBarButtonItem?.axisBehavior = .verticalPreferred
navigationItem.rightBarButtonItem?.axisBehavior = .verticalPreferred
}
statusField.text = configuration["text"] as? String ?? ""
statusField.placeholder = "What’s your status?"
statusField.accessibilityLabel = "Status"
statusField.returnKeyType = .done
statusField.delegate = self
emojiField.text = configuration["emoji"] as? String ?? ""
emojiField.placeholder = "Emoji"
emojiField.accessibilityLabel = "Status emoji"
emojiField.autocorrectionType = .no
emojiField.autocapitalizationType = .none
emojiField.clearButtonMode = .whileEditing
for field in [statusField, emojiField] {
field.font = .preferredFont(forTextStyle: .body)
field.adjustsFontForContentSizeCategory = true
field.addTarget(self, action: #selector(changed), for: .editingChanged)
}
datePicker.tintColor = .label
datePicker.datePickerMode = .dateAndTime
datePicker.preferredDatePickerStyle = .compact
datePicker.minimumDate = Date().addingTimeInterval(300)
let expiration = configuration["expiresAt"] as? Double ?? Date().addingTimeInterval(86400).timeIntervalSince1970
datePicker.date = max(Date(timeIntervalSince1970: expiration), datePicker.minimumDate ?? Date())
datePicker.accessibilityLabel = "Custom expiration"
tableView.keyboardDismissMode = .interactive
changed()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if configuration["saveError"] as? Bool == true, !didShowError {
didShowError = true
let alert = UIAlertController(title: "Couldn’t save status", message: "Your changes are still here. Please try again.", preferredStyle: .alert)
alert.view.tintColor = .label
alert.addAction(UIAlertAction(title: "OK", style: .default))
present(alert, animated: true)
}
}
private var didShowError = false
override func numberOfSections(in tableView: UITableView) -> Int {
configuration["hasExistingStatus"] as? Bool == true ? 4 : 3
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
switch section {
case 0: return 2
case 1: return duration == 0 ? 2 : 1
case 2: return presets.count
default: return 1
}
}
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
section == 1 ? "Duration" : section == 2 ? "Quick statuses" : nil
}
private func color(_ key: String, fallback: UIColor) -> UIColor {
guard let value = configuration[key] as? NSNumber else { return fallback }
let argb = value.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)
}
private func embed(_ content: UIView, in cell: UITableViewCell) {
content.translatesAutoresizingMaskIntoConstraints = false
cell.contentView.addSubview(content)
NSLayoutConstraint.activate([
content.leadingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.leadingAnchor),
content.trailingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.trailingAnchor),
content.topAnchor.constraint(equalTo: cell.contentView.topAnchor, constant: 12),
content.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor, constant: -12),
])
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
cell.backgroundColor = color("containerBackgroundArgb", fallback: .secondarySystemGroupedBackground)
cell.selectionStyle = .none
switch indexPath.section {
case 0:
if indexPath.row == 0 {
embed(statusField, in: cell)
} else {
let choose = UIButton(type: .system)
choose.setTitle("Choose emoji", for: .normal)
choose.addAction(UIAction { [weak self, weak choose] _ in
guard let self else { return }
self.view.endEditing(true)
choose?.isEnabled = false
choose?.setTitle("Loading…", for: .normal)
self.onChooseEmoji { [weak self, weak choose] value in
choose?.isEnabled = true
choose?.setTitle("Choose emoji", for: .normal)
guard let self, let value else { return }
self.emojiField.text = value
self.changed()
}
}, for: .touchUpInside)
let row = UIStackView(arrangedSubviews: [emojiField, choose])
row.spacing = 12
choose.setContentHuggingPriority(.required, for: .horizontal)
embed(row, in: cell)
}
case 1:
if indexPath.row == 1 {
embed(datePicker, in: cell)
} else {
cell.textLabel?.text = "Clear after"
let button = UIButton(type: .system)
let selected = durations.firstIndex(of: duration) ?? 4
button.setTitle(durationLabels[selected], for: .normal)
button.accessibilityLabel = "Duration"
button.showsMenuAsPrimaryAction = true
button.menu = UIMenu(children: durationLabels.enumerated().map { index, label in
UIAction(title: label, state: index == selected ? .on : .off) { [weak self] _ in
guard let self else { return }
self.duration = index < self.durations.count ? self.durations[index] : 0
self.tableView.reloadSections(IndexSet(integer: 1), with: .automatic)
}
})
button.sizeToFit()
cell.accessoryView = button
}
case 2:
cell.textLabel?.text = "\(presets[indexPath.row].0) \(presets[indexPath.row].1)"
cell.accessibilityTraits.insert(.button)
cell.selectionStyle = .default
default:
cell.textLabel?.text = "Clear status"
cell.textLabel?.textColor = .systemRed
cell.accessibilityTraits.insert(.button)
cell.selectionStyle = .default
}
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
if indexPath.section == 2 {
emojiField.text = presets[indexPath.row].0
statusField.text = presets[indexPath.row].1
changed()
} else if indexPath.section == 3 {
submit(clear: true)
}
}
@objc private func changed() {
navigationItem.rightBarButtonItem?.isEnabled =
!(statusField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| !(emojiField.text ?? "").isEmpty
}
@objc private func cancel() { onCancel() }
@objc private func save() {
guard navigationItem.rightBarButtonItem?.isEnabled == true else { return }
submit(clear: false)
}
private func submit(clear: Bool) {
onSet([
"text": statusField.text ?? "", "emoji": emojiField.text ?? "",
"duration": duration, "clear": clear,
"expiresAt": duration == 0 ? datePicker.date.timeIntervalSince1970
: Date().addingTimeInterval(TimeInterval(duration)).timeIntervalSince1970,
])
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
save()
return true
}
}
/// Open the system's complete emoji keyboard when it is enabled.
final class StatusEmojiTextField: UITextField {
override var textInputContextIdentifier: String? { "buzz.status.emoji" }
override var textInputMode: UITextInputMode? {
UITextInputMode.activeInputModes.first { $0.primaryLanguage == "emoji" } ?? super.textInputMode
}
}