701 lines
27 KiB
Swift
701 lines
27 KiB
Swift
//
|
||
// OrderDialogControllers.swift
|
||
// suixinkan
|
||
//
|
||
|
||
import SnapKit
|
||
import UIKit
|
||
|
||
/// 普通订单退款弹窗入口,对齐 Android `OrderRefundDialog`。
|
||
enum OrderRefundAlertController {
|
||
static func present(
|
||
from presenter: UIViewController,
|
||
order: OrderEntity,
|
||
onConfirm: @escaping (OrderRefundRequest) -> Void
|
||
) {
|
||
let fullAmount = order.orderAmount.isEmpty ? order.actualPayAmount : order.orderAmount
|
||
let dialog = OrderRefundDialogView(
|
||
configuration: .photographerOrder(
|
||
orderNumber: order.orderNumber,
|
||
fullRefundAmount: fullAmount
|
||
)
|
||
)
|
||
dialog.onMessage = { [weak presenter] message in
|
||
(presenter as? BaseViewController)?.showToast(message)
|
||
}
|
||
dialog.onConfirm = { payload in
|
||
onConfirm(
|
||
OrderRefundRequest(
|
||
orderNumber: order.orderNumber,
|
||
refundType: payload.refundType,
|
||
refundAmount: payload.refundAmount,
|
||
refundReason: payload.refundReason
|
||
)
|
||
)
|
||
}
|
||
dialog.show(in: presenter.view)
|
||
}
|
||
}
|
||
|
||
/// 店铺/定金订单退款弹窗入口,对齐 Android `DepositOrderRefundDialog`。
|
||
enum DepositOrderRefundAlertController {
|
||
static func present(
|
||
from presenter: UIViewController,
|
||
orderNumber: String,
|
||
amount: String,
|
||
refundType: Int,
|
||
onConfirm: @escaping (Int, String, String) -> Void
|
||
) {
|
||
let dialog = OrderRefundDialogView(
|
||
configuration: .storeOrder(
|
||
orderNumber: orderNumber,
|
||
actualPayAmount: amount,
|
||
initialMode: OrderRefundMode(rawValue: refundType) ?? .full
|
||
)
|
||
)
|
||
dialog.onMessage = { [weak presenter] message in
|
||
(presenter as? BaseViewController)?.showToast(message)
|
||
}
|
||
dialog.onConfirm = { payload in
|
||
onConfirm(payload.refundType, payload.refundAmount, payload.refundReason)
|
||
}
|
||
dialog.show(in: presenter.view)
|
||
}
|
||
}
|
||
|
||
/// 退款方式,和 Android 常量 `1=全额退款、3=部分退款` 保持一致。
|
||
enum OrderRefundMode: Int {
|
||
case full = 1
|
||
case partial = 3
|
||
}
|
||
|
||
/// 退款弹窗校验场景。
|
||
enum OrderRefundDialogKind {
|
||
case photographerOrder
|
||
case storeOrder
|
||
}
|
||
|
||
/// 退款弹窗确认后的结构化结果。
|
||
struct OrderRefundDialogPayload: Equatable {
|
||
let refundType: Int
|
||
let refundAmount: String
|
||
let refundReason: String
|
||
}
|
||
|
||
/// 退款弹窗校验失败信息。
|
||
struct OrderRefundDialogValidationError: Error, Equatable {
|
||
let message: String
|
||
}
|
||
|
||
/// 退款弹窗输入校验与金额格式化逻辑,对齐 Android 两个退款弹窗。
|
||
enum OrderRefundDialogValidation {
|
||
|
||
static func filterMoneyInput(_ text: String, maxLength: Int) -> String {
|
||
var result = ""
|
||
var hasDot = false
|
||
var decimalCount = 0
|
||
|
||
for character in text {
|
||
guard result.count < maxLength else { break }
|
||
if character.isNumber {
|
||
if hasDot {
|
||
guard decimalCount < 2 else { continue }
|
||
decimalCount += 1
|
||
}
|
||
result.append(character)
|
||
} else if character == ".", !hasDot {
|
||
hasDot = true
|
||
result.append(character)
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
static func validate(
|
||
kind: OrderRefundDialogKind,
|
||
mode: OrderRefundMode,
|
||
amountText: String,
|
||
reason: String,
|
||
fullRefundAmount: String,
|
||
maxRefundAmount: String
|
||
) -> Result<OrderRefundDialogPayload, OrderRefundDialogValidationError> {
|
||
let trimmedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
switch kind {
|
||
case .photographerOrder:
|
||
return validatePhotographerOrder(
|
||
mode: mode,
|
||
amountText: amountText,
|
||
reason: trimmedReason,
|
||
fullRefundAmount: fullRefundAmount
|
||
)
|
||
case .storeOrder:
|
||
return validateStoreOrder(
|
||
mode: mode,
|
||
amountText: amountText,
|
||
reason: trimmedReason,
|
||
maxRefundAmount: maxRefundAmount
|
||
)
|
||
}
|
||
}
|
||
|
||
private static func validatePhotographerOrder(
|
||
mode: OrderRefundMode,
|
||
amountText: String,
|
||
reason: String,
|
||
fullRefundAmount: String
|
||
) -> Result<OrderRefundDialogPayload, OrderRefundDialogValidationError> {
|
||
guard !reason.isEmpty else {
|
||
return .failure(OrderRefundDialogValidationError(message: "请输入退款原因"))
|
||
}
|
||
guard reason.count <= 255 else {
|
||
return .failure(OrderRefundDialogValidationError(message: "退款原因最多255个字符"))
|
||
}
|
||
if mode == .partial, amountText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||
return .failure(OrderRefundDialogValidationError(message: "请输入退款金额"))
|
||
}
|
||
let refundAmount = mode == .full ? fullRefundAmount : amountText
|
||
return .success(
|
||
OrderRefundDialogPayload(
|
||
refundType: mode.rawValue,
|
||
refundAmount: refundAmount,
|
||
refundReason: reason
|
||
)
|
||
)
|
||
}
|
||
|
||
private static func validateStoreOrder(
|
||
mode: OrderRefundMode,
|
||
amountText: String,
|
||
reason: String,
|
||
maxRefundAmount: String
|
||
) -> Result<OrderRefundDialogPayload, OrderRefundDialogValidationError> {
|
||
guard reason.count >= 5 else {
|
||
return .failure(OrderRefundDialogValidationError(message: "退款原因至少5个字符"))
|
||
}
|
||
guard reason.count <= 255 else {
|
||
return .failure(OrderRefundDialogValidationError(message: "退款原因最多255个字符"))
|
||
}
|
||
guard mode == .partial else {
|
||
return .success(
|
||
OrderRefundDialogPayload(
|
||
refundType: mode.rawValue,
|
||
refundAmount: "0",
|
||
refundReason: reason
|
||
)
|
||
)
|
||
}
|
||
let trimmedAmount = amountText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !trimmedAmount.isEmpty else {
|
||
return .failure(OrderRefundDialogValidationError(message: "请输入退款金额"))
|
||
}
|
||
guard let amount = Double(trimmedAmount), amount > 0 else {
|
||
return .failure(OrderRefundDialogValidationError(message: "请输入有效的退款金额"))
|
||
}
|
||
let maxAmount = parseAmount(maxRefundAmount)
|
||
guard maxAmount <= 0 || amount <= maxAmount else {
|
||
return .failure(OrderRefundDialogValidationError(message: "退款金额不能大于实际支付金额"))
|
||
}
|
||
return .success(
|
||
OrderRefundDialogPayload(
|
||
refundType: mode.rawValue,
|
||
refundAmount: String(format: "%.2f", amount),
|
||
refundReason: reason
|
||
)
|
||
)
|
||
}
|
||
|
||
private static func parseAmount(_ text: String) -> Double {
|
||
text
|
||
.replacingOccurrences(of: ",", with: "")
|
||
.replacingOccurrences(of: "¥", with: "")
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
.toDoubleOrZero
|
||
}
|
||
}
|
||
|
||
private extension String {
|
||
var toDoubleOrZero: Double {
|
||
Double(self) ?? 0
|
||
}
|
||
}
|
||
|
||
/// 自定义订单退款弹窗视图,对齐 Android `OrderRefundDialog` / `DepositOrderRefundDialog`。
|
||
final class OrderRefundDialogView: UIView, UITextViewDelegate {
|
||
|
||
/// 退款弹窗展示与校验配置,用于区分普通订单和店铺/定金订单。
|
||
struct Configuration {
|
||
let kind: OrderRefundDialogKind
|
||
let orderNumber: String
|
||
let fullRefundAmount: String
|
||
let maxRefundAmount: String
|
||
let initialMode: OrderRefundMode
|
||
let remarkPlaceholder: String
|
||
let moneyMaxLength: Int
|
||
|
||
static func photographerOrder(orderNumber: String, fullRefundAmount: String) -> Configuration {
|
||
Configuration(
|
||
kind: .photographerOrder,
|
||
orderNumber: orderNumber,
|
||
fullRefundAmount: fullRefundAmount,
|
||
maxRefundAmount: fullRefundAmount,
|
||
initialMode: .full,
|
||
remarkPlaceholder: "请输入退款原因(必填)",
|
||
moneyMaxLength: 10
|
||
)
|
||
}
|
||
|
||
static func storeOrder(
|
||
orderNumber: String,
|
||
actualPayAmount: String,
|
||
initialMode: OrderRefundMode
|
||
) -> Configuration {
|
||
Configuration(
|
||
kind: .storeOrder,
|
||
orderNumber: orderNumber,
|
||
fullRefundAmount: "0",
|
||
maxRefundAmount: actualPayAmount,
|
||
initialMode: initialMode,
|
||
remarkPlaceholder: "请输入退款原因(必填,5-255字)",
|
||
moneyMaxLength: 15
|
||
)
|
||
}
|
||
}
|
||
|
||
var onConfirm: ((OrderRefundDialogPayload) -> Void)?
|
||
var onMessage: ((String) -> Void)?
|
||
|
||
private let configuration: Configuration
|
||
private var selectedMode: OrderRefundMode
|
||
private var keyboardObservers: [NSObjectProtocol] = []
|
||
private var cardCenterYConstraint: Constraint?
|
||
|
||
private let dimView = UIView()
|
||
private let cardView = UIView()
|
||
private let titleLabel = UILabel()
|
||
private let separatorView = UIView()
|
||
private let orderTitleLabel = UILabel()
|
||
private let orderNumberLabel = UILabel()
|
||
private let refundModeTitleLabel = UILabel()
|
||
private let modeButtonStack = UIStackView()
|
||
private let fullRefundButton = UIButton(type: .system)
|
||
private let partialRefundButton = UIButton(type: .system)
|
||
private let amountGroupView = UIView()
|
||
private let amountTitleLabel = UILabel()
|
||
private let amountContainerView = UIView()
|
||
private let amountField = UITextField()
|
||
private let remarkTitleLabel = UILabel()
|
||
private let remarkContainerView = UIView()
|
||
private let remarkTextView = UITextView()
|
||
private let remarkPlaceholderLabel = UILabel()
|
||
private let actionRow = UIStackView()
|
||
private let cancelButton = UIButton(type: .system)
|
||
private let confirmButton = UIButton(type: .system)
|
||
|
||
init(configuration: Configuration) {
|
||
self.configuration = configuration
|
||
self.selectedMode = configuration.initialMode
|
||
super.init(frame: .zero)
|
||
setupUI()
|
||
applyMode(animated: false)
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
deinit {
|
||
unregisterKeyboardObservers()
|
||
}
|
||
|
||
func show(in parent: UIView) {
|
||
frame = parent.bounds
|
||
autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||
alpha = 0
|
||
parent.addSubview(self)
|
||
registerKeyboardObservers()
|
||
UIView.animate(withDuration: 0.2) {
|
||
self.alpha = 1
|
||
}
|
||
}
|
||
|
||
func dismiss() {
|
||
endEditing(true)
|
||
unregisterKeyboardObservers()
|
||
UIView.animate(withDuration: 0.2, animations: {
|
||
self.alpha = 0
|
||
}, completion: { _ in
|
||
self.removeFromSuperview()
|
||
})
|
||
}
|
||
|
||
private func setupUI() {
|
||
dimView.backgroundColor = UIColor.black.withAlphaComponent(0.2)
|
||
dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cancelTapped)))
|
||
|
||
cardView.backgroundColor = .white
|
||
cardView.layer.cornerRadius = AppRadius.md
|
||
cardView.clipsToBounds = true
|
||
|
||
titleLabel.text = "订单退款"
|
||
titleLabel.font = .systemFont(ofSize: 18, weight: .bold)
|
||
titleLabel.textColor = .black
|
||
|
||
separatorView.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||
|
||
configureSectionLabel(orderTitleLabel, text: "订单号")
|
||
orderNumberLabel.text = configuration.orderNumber
|
||
orderNumberLabel.font = .systemFont(ofSize: 16)
|
||
orderNumberLabel.textColor = .black
|
||
orderNumberLabel.numberOfLines = 0
|
||
|
||
configureSectionLabel(refundModeTitleLabel, text: "退款方式")
|
||
configureModeButton(fullRefundButton, title: "全额退款")
|
||
configureModeButton(partialRefundButton, title: "部分退款")
|
||
fullRefundButton.addTarget(self, action: #selector(fullRefundTapped), for: .touchUpInside)
|
||
partialRefundButton.addTarget(self, action: #selector(partialRefundTapped), for: .touchUpInside)
|
||
|
||
modeButtonStack.axis = .horizontal
|
||
modeButtonStack.spacing = AppSpacing.md
|
||
modeButtonStack.distribution = .fillEqually
|
||
|
||
configureSectionLabel(amountTitleLabel, text: "退款金额")
|
||
amountContainerView.layer.borderWidth = 1
|
||
amountContainerView.layer.borderColor = UIColor(hex: 0xB6BECA).cgColor
|
||
amountContainerView.layer.cornerRadius = AppRadius.sm
|
||
|
||
amountField.placeholder = "请输入退款金额"
|
||
amountField.font = .systemFont(ofSize: 14)
|
||
amountField.textColor = AppColor.textPrimary
|
||
amountField.keyboardType = .decimalPad
|
||
amountField.borderStyle = .none
|
||
amountField.addTarget(self, action: #selector(amountChanged), for: .editingChanged)
|
||
|
||
configureSectionLabel(remarkTitleLabel, text: "退款备注")
|
||
remarkContainerView.layer.borderWidth = 1
|
||
remarkContainerView.layer.borderColor = UIColor(hex: 0xB6BECA).cgColor
|
||
remarkContainerView.layer.cornerRadius = AppRadius.sm
|
||
|
||
remarkTextView.font = .systemFont(ofSize: 14)
|
||
remarkTextView.textColor = AppColor.textPrimary
|
||
remarkTextView.backgroundColor = .clear
|
||
remarkTextView.textContainerInset = UIEdgeInsets(top: 10, left: 8, bottom: 10, right: 8)
|
||
remarkTextView.delegate = self
|
||
|
||
remarkPlaceholderLabel.text = configuration.remarkPlaceholder
|
||
remarkPlaceholderLabel.font = .systemFont(ofSize: 14)
|
||
remarkPlaceholderLabel.textColor = AppColor.textTertiary
|
||
|
||
actionRow.axis = .horizontal
|
||
actionRow.alignment = .center
|
||
actionRow.spacing = AppSpacing.xs
|
||
|
||
configureActionButton(cancelButton, title: "取消", foreground: AppColor.primary, background: AppColor.primaryLight)
|
||
configureActionButton(confirmButton, title: "确认", foreground: .white, background: AppColor.primary)
|
||
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||
|
||
addSubview(dimView)
|
||
addSubview(cardView)
|
||
cardView.addSubview(titleLabel)
|
||
cardView.addSubview(separatorView)
|
||
cardView.addSubview(orderTitleLabel)
|
||
cardView.addSubview(orderNumberLabel)
|
||
cardView.addSubview(refundModeTitleLabel)
|
||
cardView.addSubview(modeButtonStack)
|
||
modeButtonStack.addArrangedSubview(fullRefundButton)
|
||
modeButtonStack.addArrangedSubview(partialRefundButton)
|
||
cardView.addSubview(amountGroupView)
|
||
amountGroupView.addSubview(amountTitleLabel)
|
||
amountGroupView.addSubview(amountContainerView)
|
||
amountContainerView.addSubview(amountField)
|
||
cardView.addSubview(remarkTitleLabel)
|
||
cardView.addSubview(remarkContainerView)
|
||
remarkContainerView.addSubview(remarkTextView)
|
||
remarkContainerView.addSubview(remarkPlaceholderLabel)
|
||
cardView.addSubview(actionRow)
|
||
actionRow.addArrangedSubview(cancelButton)
|
||
actionRow.addArrangedSubview(confirmButton)
|
||
|
||
dimView.snp.makeConstraints { make in
|
||
make.edges.equalToSuperview()
|
||
}
|
||
cardView.snp.makeConstraints { make in
|
||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||
cardCenterYConstraint = make.centerY.equalToSuperview().constraint
|
||
}
|
||
titleLabel.snp.makeConstraints { make in
|
||
make.top.equalToSuperview().offset(AppSpacing.sm)
|
||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||
}
|
||
separatorView.snp.makeConstraints { make in
|
||
make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm)
|
||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||
make.height.equalTo(1)
|
||
}
|
||
orderTitleLabel.snp.makeConstraints { make in
|
||
make.top.equalTo(separatorView.snp.bottom).offset(AppSpacing.md)
|
||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||
}
|
||
orderNumberLabel.snp.makeConstraints { make in
|
||
make.top.equalTo(orderTitleLabel.snp.bottom).offset(AppSpacing.xs)
|
||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||
}
|
||
refundModeTitleLabel.snp.makeConstraints { make in
|
||
make.top.equalTo(orderNumberLabel.snp.bottom).offset(AppSpacing.xl)
|
||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||
}
|
||
modeButtonStack.snp.makeConstraints { make in
|
||
make.top.equalTo(refundModeTitleLabel.snp.bottom).offset(AppSpacing.xs)
|
||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||
make.height.equalTo(46)
|
||
}
|
||
amountGroupView.snp.makeConstraints { make in
|
||
make.top.equalTo(modeButtonStack.snp.bottom).offset(AppSpacing.xl)
|
||
make.leading.trailing.equalToSuperview()
|
||
make.height.equalTo(73)
|
||
}
|
||
amountTitleLabel.snp.makeConstraints { make in
|
||
make.top.leading.trailing.equalToSuperview().inset(UIEdgeInsets(top: 0, left: AppSpacing.md, bottom: 0, right: AppSpacing.md))
|
||
}
|
||
amountContainerView.snp.makeConstraints { make in
|
||
make.top.equalTo(amountTitleLabel.snp.bottom).offset(AppSpacing.xs)
|
||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||
make.height.equalTo(46)
|
||
}
|
||
amountField.snp.makeConstraints { make in
|
||
make.leading.trailing.equalToSuperview().inset(AppSpacing.sm)
|
||
make.centerY.equalToSuperview()
|
||
}
|
||
remarkTitleLabel.snp.makeConstraints { make in
|
||
make.top.equalTo(amountGroupView.snp.bottom).offset(AppSpacing.xl)
|
||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||
}
|
||
remarkContainerView.snp.makeConstraints { make in
|
||
make.top.equalTo(remarkTitleLabel.snp.bottom).offset(AppSpacing.xs)
|
||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||
make.height.equalTo(104)
|
||
}
|
||
remarkTextView.snp.makeConstraints { make in
|
||
make.edges.equalToSuperview()
|
||
}
|
||
remarkPlaceholderLabel.snp.makeConstraints { make in
|
||
make.top.equalToSuperview().offset(16)
|
||
make.leading.trailing.equalToSuperview().inset(AppSpacing.sm)
|
||
}
|
||
actionRow.snp.makeConstraints { make in
|
||
make.top.equalTo(remarkContainerView.snp.bottom).offset(20)
|
||
make.trailing.equalToSuperview().inset(AppSpacing.md)
|
||
make.bottom.equalToSuperview().inset(AppSpacing.lg)
|
||
}
|
||
[cancelButton, confirmButton].forEach { button in
|
||
button.snp.makeConstraints { make in
|
||
make.width.equalTo(84)
|
||
make.height.equalTo(38)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func configureSectionLabel(_ label: UILabel, text: String) {
|
||
label.text = text
|
||
label.font = .systemFont(ofSize: 16)
|
||
label.textColor = UIColor(hex: 0x4B5563)
|
||
}
|
||
|
||
private func configureModeButton(_ button: UIButton, title: String) {
|
||
button.setTitle(title, for: .normal)
|
||
button.titleLabel?.font = .systemFont(ofSize: 14)
|
||
button.layer.cornerRadius = AppRadius.sm
|
||
button.layer.borderWidth = 1
|
||
}
|
||
|
||
private func configureActionButton(_ button: UIButton, title: String, foreground: UIColor, background: UIColor) {
|
||
button.setTitle(title, for: .normal)
|
||
button.setTitleColor(foreground, for: .normal)
|
||
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
||
button.backgroundColor = background
|
||
button.layer.cornerRadius = AppRadius.sm
|
||
}
|
||
|
||
private func applyMode(animated: Bool) {
|
||
let amountHidden = selectedMode == .full
|
||
amountGroupView.isHidden = amountHidden
|
||
amountGroupView.snp.updateConstraints { make in
|
||
make.height.equalTo(amountHidden ? 0 : 73)
|
||
}
|
||
remarkTitleLabel.snp.remakeConstraints { make in
|
||
make.top.equalTo((amountHidden ? modeButtonStack : amountGroupView).snp.bottom).offset(AppSpacing.xl)
|
||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||
}
|
||
|
||
updateModeButton(fullRefundButton, selected: selectedMode == .full)
|
||
updateModeButton(partialRefundButton, selected: selectedMode == .partial)
|
||
let changes = { self.layoutIfNeeded() }
|
||
if animated {
|
||
UIView.animate(withDuration: 0.2, animations: changes)
|
||
} else {
|
||
changes()
|
||
}
|
||
}
|
||
|
||
private func updateModeButton(_ button: UIButton, selected: Bool) {
|
||
button.backgroundColor = selected ? AppColor.primary : .white
|
||
button.layer.borderColor = selected ? UIColor.clear.cgColor : UIColor(hex: 0xB6BECA).cgColor
|
||
button.setTitleColor(selected ? .white : UIColor(hex: 0x4B5563), for: .normal)
|
||
}
|
||
|
||
private func registerKeyboardObservers() {
|
||
unregisterKeyboardObservers()
|
||
let center = NotificationCenter.default
|
||
keyboardObservers = [
|
||
center.addObserver(
|
||
forName: UIResponder.keyboardWillChangeFrameNotification,
|
||
object: nil,
|
||
queue: .main
|
||
) { [weak self] notification in
|
||
self?.handleKeyboardNotification(notification)
|
||
},
|
||
center.addObserver(
|
||
forName: UIResponder.keyboardWillHideNotification,
|
||
object: nil,
|
||
queue: .main
|
||
) { [weak self] notification in
|
||
self?.handleKeyboardNotification(notification)
|
||
}
|
||
]
|
||
}
|
||
|
||
private func unregisterKeyboardObservers() {
|
||
keyboardObservers.forEach(NotificationCenter.default.removeObserver)
|
||
keyboardObservers.removeAll()
|
||
}
|
||
|
||
private func handleKeyboardNotification(_ notification: Notification) {
|
||
layoutIfNeeded()
|
||
guard let userInfo = notification.userInfo else { return }
|
||
let duration = (userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue ?? 0.25
|
||
let curveRaw = (userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue
|
||
?? UInt(UIView.AnimationCurve.easeInOut.rawValue)
|
||
let options = UIView.AnimationOptions(rawValue: curveRaw << 16)
|
||
let offset = keyboardAvoidanceOffset(from: userInfo)
|
||
cardCenterYConstraint?.update(offset: offset)
|
||
UIView.animate(withDuration: duration, delay: 0, options: options) {
|
||
self.layoutIfNeeded()
|
||
}
|
||
}
|
||
|
||
private func keyboardAvoidanceOffset(from userInfo: [AnyHashable: Any]) -> CGFloat {
|
||
guard
|
||
let keyboardFrameValue = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue
|
||
else {
|
||
return 0
|
||
}
|
||
let keyboardFrame = convert(keyboardFrameValue.cgRectValue, from: nil)
|
||
guard keyboardFrame.minY < bounds.height else { return 0 }
|
||
|
||
let desiredBottom = keyboardFrame.minY - AppSpacing.sm
|
||
let overlap = cardView.frame.maxY - desiredBottom
|
||
return overlap > 0 ? -overlap : 0
|
||
}
|
||
|
||
func textViewDidChange(_ textView: UITextView) {
|
||
if textView.text.count > 255 {
|
||
textView.text = String(textView.text.prefix(255))
|
||
}
|
||
remarkPlaceholderLabel.isHidden = !textView.text.isEmpty
|
||
}
|
||
|
||
@objc private func amountChanged() {
|
||
let filtered = OrderRefundDialogValidation.filterMoneyInput(
|
||
amountField.text ?? "",
|
||
maxLength: configuration.moneyMaxLength
|
||
)
|
||
if amountField.text != filtered {
|
||
amountField.text = filtered
|
||
}
|
||
}
|
||
|
||
@objc private func fullRefundTapped() {
|
||
selectedMode = .full
|
||
amountField.text = configuration.kind == .storeOrder ? configuration.maxRefundAmount : configuration.fullRefundAmount
|
||
applyMode(animated: true)
|
||
}
|
||
|
||
@objc private func partialRefundTapped() {
|
||
selectedMode = .partial
|
||
amountField.text = ""
|
||
applyMode(animated: true)
|
||
amountField.becomeFirstResponder()
|
||
}
|
||
|
||
@objc private func cancelTapped() {
|
||
dismiss()
|
||
}
|
||
|
||
@objc private func confirmTapped() {
|
||
let result = OrderRefundDialogValidation.validate(
|
||
kind: configuration.kind,
|
||
mode: selectedMode,
|
||
amountText: amountField.text ?? "",
|
||
reason: remarkTextView.text ?? "",
|
||
fullRefundAmount: configuration.fullRefundAmount,
|
||
maxRefundAmount: configuration.maxRefundAmount
|
||
)
|
||
switch result {
|
||
case .success(let payload):
|
||
onConfirm?(payload)
|
||
dismiss()
|
||
case .failure(let error):
|
||
onMessage?(error.message)
|
||
}
|
||
}
|
||
}
|
||
|
||
enum OrderSignInAlertController {
|
||
static func present(from presenter: UIViewController, response: OrderSignInResponse) {
|
||
let message = """
|
||
项目:\(response.projectName)
|
||
景区:\(response.scenicName)
|
||
金额:¥\(response.orderAmount)
|
||
手机号:\(response.userPhone)
|
||
"""
|
||
let alert = UIAlertController(title: response.checkIn ? "签到成功" : "核销结果", message: message, preferredStyle: .alert)
|
||
alert.addAction(UIAlertAction(title: "确定", style: .default))
|
||
presenter.present(alert, animated: true)
|
||
}
|
||
}
|
||
|
||
enum WriteOffResultAlertController {
|
||
static func present(from presenter: UIViewController, response: WriteOffOrderResponse) {
|
||
let message = """
|
||
项目:\(response.projectName)
|
||
景区:\(response.scenicName)
|
||
金额:¥\(response.orderAmount)
|
||
手机号:\(response.userPhone)
|
||
"""
|
||
let alert = UIAlertController(title: response.orderVerify ? "核销成功" : "核销结果", message: message, preferredStyle: .alert)
|
||
alert.addAction(UIAlertAction(title: "确定", style: .default))
|
||
presenter.present(alert, animated: true)
|
||
}
|
||
}
|
||
|
||
enum SendGiftAlertController {
|
||
static func present(
|
||
from presenter: UIViewController,
|
||
photoNum: Int,
|
||
videoNum: Int,
|
||
onConfirm: @escaping (Int, Int) -> Void
|
||
) {
|
||
let alert = UIAlertController(title: "赠送修图", message: nil, preferredStyle: .alert)
|
||
alert.addTextField { $0.placeholder = "赠送照片数"; $0.keyboardType = .numberPad; $0.text = "\(photoNum)" }
|
||
alert.addTextField { $0.placeholder = "赠送视频数"; $0.keyboardType = .numberPad; $0.text = "\(videoNum)" }
|
||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||
alert.addAction(UIAlertAction(title: "确认", style: .default) { _ in
|
||
let photo = Int(alert.textFields?.first?.text ?? "0") ?? 0
|
||
let video = Int(alert.textFields?.last?.text ?? "0") ?? 0
|
||
onConfirm(photo, video)
|
||
})
|
||
presenter.present(alert, animated: true)
|
||
}
|
||
}
|