feat: update app workflows and permissions
This commit is contained in:
@ -3,46 +3,651 @@
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 普通订单退款弹窗入口,对齐 Android `OrderRefundDialog`。
|
||||
enum OrderRefundAlertController {
|
||||
static func present(
|
||||
from presenter: UIViewController,
|
||||
order: OrderEntity,
|
||||
onConfirm: @escaping (OrderRefundRequest) -> Void
|
||||
) {
|
||||
let alert = UIAlertController(title: "订单退款", message: order.orderNumber, preferredStyle: .alert)
|
||||
alert.addTextField { $0.placeholder = "退款金额"; $0.text = order.actualPayAmount }
|
||||
alert.addTextField { $0.placeholder = "退款原因" }
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "确认退款", style: .destructive) { _ in
|
||||
let amount = alert.textFields?.first?.text ?? order.actualPayAmount
|
||||
let reason = alert.textFields?.last?.text ?? ""
|
||||
onConfirm(OrderRefundRequest(orderNumber: order.orderNumber, refundType: 1, refundAmount: amount, refundReason: reason))
|
||||
})
|
||||
presenter.present(alert, animated: true)
|
||||
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 (String, String) -> Void
|
||||
onConfirm: @escaping (Int, String, String) -> Void
|
||||
) {
|
||||
let title = refundType == 3 ? "部分退款" : "全额退款"
|
||||
let alert = UIAlertController(title: title, message: orderNumber, preferredStyle: .alert)
|
||||
alert.addTextField { $0.placeholder = "退款金额"; $0.text = amount }
|
||||
alert.addTextField { $0.placeholder = "退款原因" }
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "确认", style: .destructive) { _ in
|
||||
let refundAmount = alert.textFields?.first?.text ?? amount
|
||||
let reason = alert.textFields?.last?.text ?? ""
|
||||
onConfirm(refundAmount, reason)
|
||||
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()
|
||||
})
|
||||
presenter.present(alert, animated: true)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user