Files
suixinkan_uikit/suixinkan/UI/Setting/StoreAccountDeregistrationViewController.swift

548 lines
27 KiB
Swift

import IQKeyboardCore
import IQKeyboardManagerSwift
import SnapKit
import UIKit
/// 简洁两步注销页面;一次确认两项资产,短信和实际申请仍由用户主动操作。
@MainActor
final class StoreAccountDeregistrationViewController: BaseViewController {
private typealias Style = StoreAccountDeregistrationStyle
private let viewModel: StoreAccountDeregistrationViewModel
private let api: any StoreAccountDeregistrationServing
private let identityName: String
private let readOnly: Bool
private let onUnresolvedSubmission: (() -> Void)?
private let onSubmissionAccepted: (String?) -> Void
private var didHandleAcceptedSubmission = false
private let scrollView = UIScrollView()
private let refreshControl = UIRefreshControl()
private let content = UIStackView()
private let bottomBar = UIView()
private let steps = UIStackView()
private let firstStepNumber = Style.label("1", size: 11, weight: .semibold)
private let secondStepNumber = Style.label("2", size: 11, weight: .semibold)
private let firstStep = Style.label("确认资产", size: 13, weight: .semibold)
private let secondStep = Style.label("手机验证", size: 13, weight: .semibold)
private let firstStepView = UIView()
private let secondStepView = UIView()
private let heading = Style.label(size: 24, weight: .semibold)
private let subtitle = Style.label(size: 14, color: Style.textSecondary)
private let conditionStack = UIStackView()
private let verificationStack = UIStackView()
private let walletAmount = Style.label("—", size: 28, weight: .semibold)
private let pointsAmount = Style.label("—", size: 28, weight: .semibold)
private let walletState = Style.label(size: 12, color: Style.textSecondary)
private let pointsState = Style.label(size: 12, color: Style.textSecondary)
private let assetHint = Style.label(size: 13, color: Style.textSecondary)
private let blockersLabel = Style.label(size: 14, color: Style.textSecondary)
private let riskLabel = Style.label(size: 13, color: AppColor.warning)
private let statusLabel = Style.label(size: 13, color: Style.textSecondary)
private let errorLabel = Style.label(size: 14, color: Style.danger)
private let codeField = UITextField()
private let reasonField = UITextView()
private let reasonTitle = Style.label(size: 16, weight: .semibold)
private let reasonPlaceholder = Style.label("请填写注销原因", size: 14, color: Style.textSecondary)
private let reasonCount = Style.label("0/50", size: 12, color: Style.textSecondary)
private let reasonValidationLabel = Style.label("请填写注销原因", size: 12, color: Style.danger)
private let reasonContainer = UIView()
private let smsButton = UIButton(type: .system)
private let continueButton = Style.button("确认资产并继续", id: "deregister.continue")
private let submitButton = Style.button("提交注销申请", id: "deregister.submit")
private var identityCard = UIView()
private var blockersCard = UIView()
private var errorCard = UIView()
private var actionTask: Task<Void, Never>?
private var previousPopGestureEnabled: Bool?
private var previousViewportHeight: CGFloat = 0
private var reasonWasBlurred = false
/// 注入当前身份及旧接口,不读取任意手机号,也不自动确认资产。
init(identityName: String, viewModel: StoreAccountDeregistrationViewModel,
api: any StoreAccountDeregistrationServing, readOnly: Bool = false,
onUnresolvedSubmission: (() -> Void)? = nil,
onSubmissionAccepted: ((String?) -> Void)? = nil) {
let frozenIdentityName = identityName
self.onSubmissionAccepted = onSubmissionAccepted ?? { coolingUntil in
var userInfo = [NotificationUserInfoKey.deregistrationIdentityName: frozenIdentityName]
if let coolingUntil { userInfo[NotificationUserInfoKey.deregistrationCoolingUntil] = coolingUntil }
NotificationCenter.default.post(
name: NotificationName.storeAccountDeregistrationSubmitted,
object: nil,
userInfo: userInfo
)
}
self.identityName = identityName
self.viewModel = viewModel
self.api = api
self.readOnly = readOnly
self.onUnresolvedSubmission = onUnresolvedSubmission
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func setupNavigationBar() {
title = readOnly ? "注销条件" : "注销账号"
}
override func setupUI() {
view.backgroundColor = Style.pageBackground
view.addSubview(scrollView)
refreshControl.accessibilityIdentifier = "deregister.refresh"
refreshControl.accessibilityLabel = "下拉刷新"
refreshControl.tintColor = .clear
refreshControl.addTarget(self, action: #selector(refreshTapped), for: .valueChanged)
scrollView.refreshControl = refreshControl
scrollView.addSubview(content)
content.axis = .vertical
content.spacing = 16
content.addArrangedSubview(steps)
steps.axis = .horizontal
steps.alignment = .fill
steps.distribution = .fillEqually
steps.spacing = 12
configureStep(firstStepView, number: firstStepNumber, title: firstStep,
identifier: "deregister.step.assets", accessibilityLabel: "第 1 步,确认资产")
configureStep(secondStepView, number: secondStepNumber, title: secondStep,
identifier: "deregister.step.verification", accessibilityLabel: "第 2 步,手机验证")
steps.addArrangedSubview(firstStepView)
steps.addArrangedSubview(secondStepView)
steps.snp.makeConstraints { $0.height.equalTo(46) }
steps.isHidden = readOnly
content.addArrangedSubview(Style.stack([heading, subtitle], spacing: 8))
let identityText = Style.stack([
Style.label("当前门店身份", size: 12, color: Style.textSecondary),
Style.label(identityName, size: 17, weight: .semibold)
], spacing: 5)
let identityRow = UIStackView(arrangedSubviews: [Style.icon("person.crop.circle"), identityText])
identityRow.axis = .horizontal
identityRow.alignment = .center
identityRow.spacing = 12
identityCard = Style.card(identityRow)
content.addArrangedSubview(identityCard)
conditionStack.axis = .vertical
conditionStack.spacing = 16
verificationStack.axis = .vertical
verificationStack.spacing = 16
buildConditions()
buildVerification()
content.addArrangedSubview(conditionStack)
content.addArrangedSubview(verificationStack)
errorCard = Style.card(errorLabel)
errorCard.backgroundColor = UIColor(hex: 0xFFF1F1)
content.addArrangedSubview(errorCard)
content.addArrangedSubview(statusLabel)
statusLabel.accessibilityIdentifier = "deregister.status"
errorLabel.accessibilityIdentifier = "deregister.error"
view.addSubview(bottomBar)
bottomBar.backgroundColor = .white
let actions = Style.stack([continueButton, submitButton], spacing: 10)
bottomBar.addSubview(actions)
actions.snp.makeConstraints { $0.edges.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)) }
bottomBar.isHidden = readOnly
continueButton.addTarget(self, action: #selector(continueTapped), for: .touchUpInside)
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
scrollView.keyboardDismissMode = .onDrag
scrollView.alwaysBounceVertical = true
}
private func configureStep(_ container: UIView, number: UILabel, title: UILabel,
identifier: String, accessibilityLabel: String) {
number.numberOfLines = 1
title.numberOfLines = 1
number.textAlignment = .center
number.layer.cornerRadius = 10
number.clipsToBounds = true
number.snp.makeConstraints { $0.width.height.equalTo(20) }
let row = UIStackView(arrangedSubviews: [number, title])
row.axis = .horizontal
row.alignment = .center
row.spacing = 7
title.setContentCompressionResistancePriority(.required, for: .horizontal)
container.layer.cornerRadius = 12
container.accessibilityIdentifier = identifier
container.isAccessibilityElement = true
container.accessibilityLabel = accessibilityLabel
container.addSubview(row)
row.snp.makeConstraints { $0.center.equalToSuperview() }
}
private func buildConditions() {
let columns = UIStackView()
columns.axis = .horizontal
columns.distribution = .fill
columns.spacing = 16
let walletColumn = Style.stack([
Style.label("现金余额", size: 13, color: Style.textSecondary), walletAmount, walletState
], spacing: 8)
columns.addArrangedSubview(walletColumn)
let divider = UIView()
divider.backgroundColor = Style.border
divider.snp.makeConstraints { $0.width.equalTo(1) }
columns.addArrangedSubview(divider)
let pointsColumn = Style.stack([
Style.label("积分", size: 13, color: Style.textSecondary), pointsAmount, pointsState
], spacing: 8)
columns.addArrangedSubview(pointsColumn)
walletColumn.snp.makeConstraints { $0.width.equalTo(pointsColumn) }
walletAmount.adjustsFontSizeToFitWidth = true
walletAmount.minimumScaleFactor = 0.6
walletAmount.numberOfLines = 1
pointsAmount.adjustsFontSizeToFitWidth = true
pointsAmount.minimumScaleFactor = 0.6
pointsAmount.numberOfLines = 1
walletState.accessibilityIdentifier = "deregister.wallet.state"
pointsState.accessibilityIdentifier = "deregister.points.state"
let assets = Style.card(Style.stack([
Style.label("账号资产", size: 16, weight: .semibold), columns, assetHint
], spacing: 16))
assets.accessibilityIdentifier = "deregister.assets"
conditionStack.addArrangedSubview(assets)
blockersLabel.accessibilityIdentifier = "deregister.blockers"
blockersCard = Style.card(Style.stack([
Style.label("待处理事项", size: 16, weight: .semibold), blockersLabel, riskLabel
]))
conditionStack.addArrangedSubview(blockersCard)
let notices = Style.stack([
notice("person.crop.circle", title: "仅注销当前身份"),
notice("clock", title: "7 天冷静期"),
notice("exclamationmark.shield", title: "正式完成后不可恢复")
], spacing: 16)
conditionStack.addArrangedSubview(Style.card(notices, background: Style.infoBackground))
}
private func notice(_ icon: String, title: String) -> UIView {
let image = Style.icon(icon, size: 18)
image.snp.makeConstraints { $0.width.equalTo(22) }
let row = UIStackView(arrangedSubviews: [image, Style.label(title, size: 14, weight: .medium)])
row.axis = .horizontal
row.alignment = .center
row.spacing = 10
return row
}
private func buildVerification() {
codeField.placeholder = "请输入短信验证码"
codeField.keyboardType = .numberPad
codeField.textContentType = .oneTimeCode
codeField.accessibilityIdentifier = "deregister.code"
codeField.accessibilityLabel = "短信验证码"
reasonField.accessibilityIdentifier = "deregister.reason"
reasonField.accessibilityLabel = "注销原因,必填"
codeField.font = .systemFont(ofSize: 16)
codeField.autocorrectionType = .no
codeField.iq.enableMode = .disabled
codeField.addTarget(self, action: #selector(inputChanged), for: .editingChanged)
codeField.addTarget(self, action: #selector(revealFocusedInput), for: .editingDidBegin)
reasonField.font = .systemFont(ofSize: 15)
reasonField.textColor = Style.textPrimary
reasonField.backgroundColor = .clear
reasonField.delegate = self
reasonField.autocorrectionType = .no
reasonField.iq.enableMode = .disabled
reasonField.textContainerInset = .init(top: 12, left: 12, bottom: 24, right: 12)
codeField.snp.makeConstraints { $0.height.equalTo(50) }
reasonField.snp.makeConstraints { $0.height.equalTo(96) }
smsButton.setTitle("获取验证码", for: .normal)
smsButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
smsButton.accessibilityIdentifier = "deregister.sms"
smsButton.setContentHuggingPriority(.required, for: .horizontal)
smsButton.setContentCompressionResistancePriority(.required, for: .horizontal)
smsButton.addTarget(self, action: #selector(smsTapped), for: .touchUpInside)
smsButton.snp.makeConstraints { $0.height.greaterThanOrEqualTo(44) }
let codeRow = UIStackView(arrangedSubviews: [codeField, smsButton])
codeRow.axis = .horizontal
codeRow.alignment = .center
codeRow.spacing = 12
codeRow.layoutMargins = .init(top: 0, left: 14, bottom: 0, right: 8)
codeRow.isLayoutMarginsRelativeArrangement = true
codeRow.layer.cornerRadius = 10
codeRow.layer.borderWidth = 1
codeRow.layer.borderColor = Style.border.cgColor
let smsTitle = Style.label("短信验证码", size: 16, weight: .semibold)
reasonContainer.layer.cornerRadius = 10
reasonContainer.layer.borderWidth = 1
reasonContainer.layer.borderColor = Style.border.cgColor
reasonContainer.addSubview(reasonField)
reasonContainer.addSubview(reasonPlaceholder)
reasonContainer.addSubview(reasonCount)
reasonField.snp.makeConstraints { $0.edges.equalToSuperview() }
reasonPlaceholder.snp.makeConstraints { $0.top.leading.equalToSuperview().inset(16) }
reasonCount.snp.makeConstraints { $0.trailing.bottom.equalToSuperview().inset(12) }
let titleText = NSMutableAttributedString(
string: "注销原因 ",
attributes: [.font: UIFont.systemFont(ofSize: 16, weight: .semibold), .foregroundColor: Style.textPrimary]
)
titleText.append(NSAttributedString(
string: "*",
attributes: [.font: UIFont.systemFont(ofSize: 16, weight: .semibold), .foregroundColor: Style.danger]
))
reasonTitle.attributedText = titleText
reasonTitle.accessibilityLabel = "注销原因,必填"
reasonTitle.accessibilityIdentifier = "deregister.reason.title"
reasonValidationLabel.isHidden = true
reasonValidationLabel.accessibilityIdentifier = "deregister.reason.error"
let form = Style.stack([smsTitle, codeRow, reasonTitle, reasonContainer, reasonValidationLabel], spacing: 12)
form.setCustomSpacing(18, after: codeRow)
verificationStack.addArrangedSubview(Style.card(form))
let helperIcon = Style.icon("shield", size: 16)
helperIcon.tintColor = Style.textSecondary
helperIcon.snp.makeConstraints { $0.width.equalTo(22) }
let helper = UIStackView(arrangedSubviews: [
helperIcon,
Style.label("验证码将发送至当前身份绑定的手机号。", size: 13, color: Style.textSecondary)
])
helper.axis = .horizontal
helper.alignment = .center
helper.spacing = 8
verificationStack.addArrangedSubview(helper)
}
override func setupConstraints() {
bottomBar.snp.makeConstraints {
$0.leading.trailing.equalToSuperview()
$0.bottom.equalTo(view.keyboardLayoutGuide.snp.top)
}
scrollView.snp.makeConstraints {
$0.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
if readOnly { $0.bottom.equalTo(view.safeAreaLayoutGuide) }
else { $0.bottom.equalTo(bottomBar.snp.top) }
}
content.snp.makeConstraints {
$0.edges.equalTo(scrollView.contentLayoutGuide).inset(16)
$0.width.equalTo(scrollView.frameLayoutGuide).offset(-32)
}
}
override func viewDidLoad() {
super.viewDidLoad()
applyViewModel()
refreshTapped()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
previousPopGestureEnabled = navigationController?.interactivePopGestureRecognizer?.isEnabled
applyViewModel()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if let previousPopGestureEnabled {
navigationController?.interactivePopGestureRecognizer?.isEnabled = previousPopGestureEnabled
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
guard scrollView.bounds.height != previousViewportHeight else { return }
previousViewportHeight = scrollView.bounds.height
revealFocusedInput()
}
@objc private func revealFocusedInput() {
guard let field = ([codeField, reasonField] as [UIView]).first(where: \.isFirstResponder) else { return }
let rect = field.convert(field.bounds, to: scrollView).insetBy(dx: 0, dy: -12)
scrollView.scrollRectToVisible(rect, animated: false)
}
private func applyViewModel() {
let busy = actionTask != nil || viewModel.isBusy
let verifying = viewModel.step == .verification && !readOnly
let unresolved = viewModel.step == .unresolvedRequest && !readOnly
heading.text = readOnly ? "当前注销条件" : (verifying ? "验证绑定手机号" : "注销前,请确认")
subtitle.text = verifying ? "完成验证后,即可提交注销申请。" : "仅注销此身份,其他身份不受影响。"
applyStepStyle(firstStepView, number: firstStepNumber, title: firstStep, selected: !verifying)
applyStepStyle(secondStepView, number: secondStepNumber, title: secondStep, selected: verifying)
identityCard.isHidden = verifying || unresolved
conditionStack.isHidden = verifying || unresolved
verificationStack.isHidden = !verifying
continueButton.isHidden = verifying || readOnly
submitButton.isHidden = !verifying
continueButton.configuration?.title = viewModel.requiresAssetConfirmation ? "确认资产并继续" : "下一步"
continueButton.isEnabled = !busy && viewModel.canConfirmAssetsAndContinue
submitButton.isEnabled = !busy && viewModel.canContinue && hasVerificationInput
smsButton.isEnabled = !busy && viewModel.canContinue
codeField.isEnabled = !busy
reasonField.isEditable = !busy
applyReasonInputStyle()
refreshControl.isEnabled = !busy
errorLabel.text = viewModel.errorMessage
errorCard.isHidden = viewModel.errorMessage == nil
if let value = viewModel.eligibility {
walletAmount.text = "¥\(value.walletBalance)"
pointsAmount.text = "\(value.pointsBalance)"
walletState.text = value.walletWaived ? "已确认放弃" : "待确认放弃"
pointsState.text = value.pointsWaived ? "已确认放弃" : "待确认放弃"
walletState.textColor = value.walletWaived ? Style.primary : Style.textSecondary
pointsState.textColor = value.pointsWaived ? Style.primary : Style.textSecondary
let assetIssues = value.blockers.filter(\.isAssetConfirmation)
let needsAssetHint = readOnly || assetIssues.contains { $0.code == "WAIVER_STALE" }
assetHint.isHidden = !needsAssetHint
assetHint.text = readOnly ? "以当前查询结果为准,冷静期内不能再次确认资产。"
: "资产已变化,请按最新金额重新确认。"
blockersCard.isHidden = value.businessBlockers.isEmpty
blockersLabel.text = value.businessBlockers.map { "• \($0.message)\n \($0.guidance)" }.joined(separator: "\n\n")
riskLabel.text = value.eligibleAt.map { "业务风险期预计结束:\($0)" }
riskLabel.isHidden = value.eligibleAt == nil
} else {
walletAmount.text = "—"
pointsAmount.text = "—"
walletState.text = "待查询"
pointsState.text = "待查询"
assetHint.isHidden = false
assetHint.text = busy ? "正在查询资产与注销条件…" : "暂未获取到资产,请刷新重试。"
blockersCard.isHidden = true
}
statusLabel.text = unresolved ? "申请状态待确认,请刷新后重试,暂勿重复提交。"
: (viewModel.status?.isCancelled == true ? "上次申请已撤销,可重新申请。" : nil)
statusLabel.isHidden = statusLabel.text == nil
let preventsBack = busy || unresolved || (!readOnly && viewModel.submissionAttempted)
navigationItem.hidesBackButton = preventsBack
if view.window != nil {
navigationController?.interactivePopGestureRecognizer?.isEnabled = !preventsBack && (previousPopGestureEnabled ?? true)
}
}
private func applyStepStyle(_ container: UIView, number: UILabel, title: UILabel, selected: Bool) {
container.backgroundColor = selected ? Style.primary : UIColor(hex: 0xE9EEF5)
container.accessibilityTraits = selected ? [.selected] : []
number.backgroundColor = selected ? UIColor.white.withAlphaComponent(0.2) : UIColor(hex: 0xDCE3EC)
number.textColor = selected ? .white : Style.textSecondary
title.textColor = selected ? .white : Style.textSecondary
}
private var hasVerificationInput: Bool {
let code = (codeField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let reason = (reasonField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return !code.isEmpty && !reason.isEmpty && reason.count <= 50
}
private func applyReasonInputStyle() {
let missing = (reasonField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
let invalid = reasonWasBlurred && missing
reasonContainer.layer.borderColor = reasonField.isFirstResponder
? Style.primary.cgColor
: (invalid ? Style.danger.cgColor : Style.border.cgColor)
reasonValidationLabel.isHidden = !invalid
}
private func run(_ operation: @escaping @MainActor () async -> Void) {
guard actionTask == nil, !didHandleAcceptedSubmission else {
refreshControl.endRefreshing()
return
}
actionTask = Task { @MainActor [weak self] in
guard let self else { return }
self.showLoading()
await operation()
self.hideLoading()
self.refreshControl.endRefreshing()
self.actionTask = nil
if !self.readOnly, self.viewModel.submissionAccepted {
self.didHandleAcceptedSubmission = true
self.onSubmissionAccepted(self.viewModel.submittedCoolingUntil)
return
}
self.applyViewModel()
if !self.readOnly, self.viewModel.submissionAttempted || self.viewModel.status?.isCooling == true {
self.onUnresolvedSubmission?()
}
}
applyViewModel()
}
@objc private func inputChanged() { applyViewModel() }
@objc private func refreshTapped() { run { [self] in await self.viewModel.refresh(api: self.api) } }
@objc private func continueTapped() {
guard !readOnly, viewModel.canConfirmAssetsAndContinue, let snapshot = viewModel.eligibility else { return }
if !viewModel.requiresAssetConfirmation {
run { [self] in await self.viewModel.confirmAssetsAndContinue(snapshot: snapshot, api: self.api) }
return
}
let sheet = StoreAccountDeregistrationConfirmationSheetViewController(
title: "确认放弃账号资产",
summaries: [
.init(icon: "person.crop.circle", title: "当前身份", value: identityName),
.init(icon: "banknote", title: "现金余额", value: "¥\(snapshot.walletBalance)"),
.init(icon: "star.circle", title: "积分", value: "\(snapshot.pointsBalance)")
],
notices: [],
cancelTitle: "暂不确认", confirmTitle: "确认放弃"
) { [weak self] in
guard let self else { return }
self.run { [self] in await self.viewModel.confirmAssetsAndContinue(snapshot: snapshot, api: self.api) }
}
present(sheet, animated: true)
}
@objc private func smsTapped() {
guard !readOnly else { return }
run { [self] in
if await self.viewModel.sendSMS(api: self.api) { showToast("验证码已发送至当前身份绑定手机号") }
}
}
@objc private func submitTapped() {
guard !readOnly, viewModel.canContinue, hasVerificationInput else { return }
let code = codeField.text ?? ""
let reason = (reasonField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
view.endEditing(true)
let sheet = StoreAccountDeregistrationConfirmationSheetViewController(
title: "确认提交注销申请?",
summaries: [
.init(icon: "person.crop.circle", title: "当前身份", value: identityName),
.init(icon: "clock", title: "冷静期", value: "7 天"),
.init(icon: "text.bubble", title: "注销原因", value: reason)
],
notices: [
("info.circle", "注销后当前身份将无法使用", false),
("checkmark.shield", "冷静期内可撤销,重新登录该身份自动撤销申请", false),
("exclamationmark.circle.fill", "正式完成后不可恢复,历史记录保留", true)
],
cancelTitle: "我再想想", confirmTitle: "确认提交"
) { [weak self] in
self?.submitConfirmedApplication(smsCode: code, reason: reason)
}
present(sheet, animated: true)
}
/// 用户确认最终弹窗后提交;仅明确成功触发一次退出态结果页,未知结果仍进入核验流程。
func submitConfirmedApplication(smsCode: String, reason: String) {
guard !readOnly, viewModel.validateSubmissionInput(smsCode: smsCode, reason: reason) else {
applyViewModel()
return
}
run { [self] in await viewModel.submit(smsCode: smsCode, reason: reason, api: api) }
}
}
extension StoreAccountDeregistrationViewController: UITextViewDelegate {
/// 注销原因必填;限制长度并同步必填状态、占位和计数展示。
func textViewDidChange(_ textView: UITextView) {
if textView.text.count > 50 {
textView.text = String(textView.text.prefix(50))
}
reasonPlaceholder.isHidden = !textView.text.isEmpty
reasonCount.text = "\(textView.text.count)/50"
if !textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
reasonWasBlurred = false
}
applyViewModel()
}
func textViewDidBeginEditing(_ textView: UITextView) {
applyReasonInputStyle()
revealFocusedInput()
}
func textViewDidEndEditing(_ textView: UITextView) {
reasonWasBlurred = true
applyReasonInputStyle()
}
}