Files
suixinkan_uikit/suixinkan/UI/Orders/OrderDialogControllers.swift

653 lines
25 KiB
Swift
Raw Permalink 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.

//
// 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: KeyboardAvoidingDialogView, UITextViewDelegate {
/// 退
private enum Layout {
static let sectionGap: CGFloat = AppSpacing.lg
static let formGap: CGFloat = AppSpacing.md
static let controlHeight: CGFloat = AppSpacing.minTouchTarget
static let amountGroupHeight: CGFloat = 67
static let remarkHeight: CGFloat = 88
static let actionTop: CGFloat = 14
static let bottomInset: CGFloat = AppSpacing.md
}
/// 退/
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 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")
}
func show(in parent: UIView) {
frame = parent.bounds
autoresizingMask = [.flexibleWidth, .flexibleHeight]
alpha = 0
parent.addSubview(self)
UIView.animate(withDuration: 0.2) {
self.alpha = 1
}
}
func dismiss() {
endEditing(true)
UIView.animate(withDuration: 0.2, animations: {
self.alpha = 0
}, completion: { _ in
self.removeFromSuperview()
})
}
private func setupUI() {
keyboardAvoidingContentView = cardView
keyboardAvoidingMinimumSpacing = AppSpacing.sm
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)
keyboardAvoidingCenterYConstraint = 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(Layout.sectionGap)
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(Layout.controlHeight)
}
amountGroupView.snp.makeConstraints { make in
make.top.equalTo(modeButtonStack.snp.bottom).offset(Layout.formGap)
make.leading.trailing.equalToSuperview()
make.height.equalTo(Layout.amountGroupHeight)
}
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(Layout.controlHeight)
}
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(Layout.formGap)
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(Layout.remarkHeight)
}
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(Layout.actionTop)
make.trailing.equalToSuperview().inset(AppSpacing.md)
make.bottom.equalToSuperview().inset(Layout.bottomInset)
}
[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 : Layout.amountGroupHeight)
}
remarkTitleLabel.snp.remakeConstraints { make in
make.top.equalTo((amountHidden ? modeButtonStack : amountGroupView).snp.bottom).offset(Layout.formGap)
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)
}
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)
}
}