394 lines
16 KiB
Swift
394 lines
16 KiB
Swift
//
|
||
// CreateTravelAlbumSheetViewController.swift
|
||
// suixinkan
|
||
//
|
||
|
||
import SnapKit
|
||
import UIKit
|
||
|
||
/// 新建相册任务底部 Sheet,对齐 Android `CreateTravelAlbumModal`。
|
||
final class CreateTravelAlbumSheetViewController: BaseViewController {
|
||
var onDismissed: (() -> Void)?
|
||
|
||
private let viewModel: TravelAlbumEntryViewModel
|
||
private let api: any TravelAlbumServing
|
||
private var mode: TravelAlbumEntryViewModel.CreateMode = .preShoot
|
||
private var selectedOrder: TravelAlbumAvailableOrder?
|
||
|
||
private let scrollView = UIScrollView()
|
||
private let contentView = UIView()
|
||
private let titleLabel = UILabel()
|
||
private let preShootCard = TravelAlbumModeOptionView()
|
||
private let preOrderCard = TravelAlbumModeOptionView()
|
||
private let fieldsStack = UIStackView()
|
||
private let freeCountField = UITextField()
|
||
private let singlePriceField = UITextField()
|
||
private let packagePriceField = UITextField()
|
||
private let orderButton = UIButton(type: .system)
|
||
private let cancelButton = UIButton(type: .system)
|
||
private let confirmButton = UIButton(type: .system)
|
||
|
||
init(viewModel: TravelAlbumEntryViewModel, api: any TravelAlbumServing) {
|
||
self.viewModel = viewModel
|
||
self.api = api
|
||
super.init(nibName: nil, bundle: nil)
|
||
modalPresentationStyle = .pageSheet
|
||
if let sheetPresentationController {
|
||
sheetPresentationController.detents = [.medium(), .large()]
|
||
sheetPresentationController.prefersGrabberVisible = false
|
||
}
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func setupNavigationBar() {}
|
||
|
||
override func setupUI() {
|
||
view.backgroundColor = .white
|
||
titleLabel.text = "新建相册任务"
|
||
titleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
|
||
titleLabel.textColor = AppColor.textPrimary
|
||
titleLabel.textAlignment = .center
|
||
|
||
preShootCard.apply(
|
||
title: "先拍再买",
|
||
desc: "拍完后分享给用户,用户在小程序上选择性购买",
|
||
selected: true
|
||
)
|
||
preOrderCard.apply(
|
||
title: "买了再拍",
|
||
desc: "表示用户已经在小程序上下过单了,绑定对应订单上传后用户可以直接选片",
|
||
selected: false
|
||
)
|
||
|
||
fieldsStack.axis = .vertical
|
||
fieldsStack.spacing = 14
|
||
|
||
configureTextField(freeCountField, placeholder: "请输入免费张数", keyboardType: .numberPad)
|
||
configureTextField(singlePriceField, placeholder: "请输入单张照片价格", keyboardType: .decimalPad)
|
||
configureTextField(packagePriceField, placeholder: "请输入打包价格", keyboardType: .decimalPad)
|
||
|
||
orderButton.contentHorizontalAlignment = .left
|
||
orderButton.setTitle("请选择订单", for: .normal)
|
||
orderButton.setTitleColor(AppColor.textTertiary, for: .normal)
|
||
orderButton.titleLabel?.font = .systemFont(ofSize: 14)
|
||
orderButton.layer.cornerRadius = 8
|
||
orderButton.layer.borderColor = AppColor.border.cgColor
|
||
orderButton.layer.borderWidth = 1
|
||
orderButton.contentEdgeInsets = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
|
||
|
||
configureActionButton(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xF4F4F4), titleColor: AppColor.textSecondary)
|
||
configureActionButton(confirmButton, title: "确定", backgroundColor: AppColor.primary, titleColor: .white)
|
||
|
||
view.addSubview(scrollView)
|
||
scrollView.addSubview(contentView)
|
||
[titleLabel, preShootCard, preOrderCard, fieldsStack, cancelButton, confirmButton].forEach(contentView.addSubview)
|
||
rebuildFields()
|
||
}
|
||
|
||
override func setupConstraints() {
|
||
scrollView.snp.makeConstraints { make in
|
||
make.edges.equalTo(view.safeAreaLayoutGuide)
|
||
}
|
||
contentView.snp.makeConstraints { make in
|
||
make.edges.equalToSuperview()
|
||
make.width.equalToSuperview()
|
||
}
|
||
titleLabel.snp.makeConstraints { make in
|
||
make.top.equalToSuperview().offset(18)
|
||
make.leading.trailing.equalToSuperview().inset(20)
|
||
}
|
||
preShootCard.snp.makeConstraints { make in
|
||
make.top.equalTo(titleLabel.snp.bottom).offset(18)
|
||
make.leading.trailing.equalToSuperview().inset(16)
|
||
}
|
||
preOrderCard.snp.makeConstraints { make in
|
||
make.top.equalTo(preShootCard.snp.bottom).offset(10)
|
||
make.leading.trailing.equalTo(preShootCard)
|
||
}
|
||
fieldsStack.snp.makeConstraints { make in
|
||
make.top.equalTo(preOrderCard.snp.bottom).offset(18)
|
||
make.leading.trailing.equalTo(preShootCard)
|
||
}
|
||
cancelButton.snp.makeConstraints { make in
|
||
make.top.equalTo(fieldsStack.snp.bottom).offset(22)
|
||
make.leading.equalToSuperview().offset(16)
|
||
make.height.equalTo(48)
|
||
make.width.equalTo(confirmButton)
|
||
make.bottom.equalToSuperview().offset(-16)
|
||
}
|
||
confirmButton.snp.makeConstraints { make in
|
||
make.top.height.width.equalTo(cancelButton)
|
||
make.leading.equalTo(cancelButton.snp.trailing).offset(12)
|
||
make.trailing.equalToSuperview().offset(-16)
|
||
}
|
||
}
|
||
|
||
override func bindActions() {
|
||
preShootCard.addTarget(self, action: #selector(preShootTapped), for: .touchUpInside)
|
||
preOrderCard.addTarget(self, action: #selector(preOrderTapped), for: .touchUpInside)
|
||
orderButton.addTarget(self, action: #selector(orderTapped), for: .touchUpInside)
|
||
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||
[freeCountField, singlePriceField, packagePriceField].forEach {
|
||
$0.addTarget(self, action: #selector(textFieldEditingChanged(_:)), for: .editingChanged)
|
||
}
|
||
}
|
||
|
||
override func viewDidDisappear(_ animated: Bool) {
|
||
super.viewDidDisappear(animated)
|
||
if isBeingDismissed {
|
||
onDismissed?()
|
||
}
|
||
}
|
||
|
||
private func configureTextField(_ field: UITextField, placeholder: String, keyboardType: UIKeyboardType) {
|
||
field.placeholder = placeholder
|
||
field.keyboardType = keyboardType
|
||
field.font = .systemFont(ofSize: 14)
|
||
field.layer.cornerRadius = 8
|
||
field.layer.borderWidth = 1
|
||
field.layer.borderColor = AppColor.border.cgColor
|
||
field.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1))
|
||
field.leftViewMode = .always
|
||
field.snp.makeConstraints { make in
|
||
make.height.equalTo(44)
|
||
}
|
||
}
|
||
|
||
private func configureActionButton(_ button: UIButton, title: String, backgroundColor: UIColor, titleColor: UIColor) {
|
||
button.setTitle(title, for: .normal)
|
||
button.setTitleColor(titleColor, for: .normal)
|
||
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
||
button.backgroundColor = backgroundColor
|
||
button.layer.cornerRadius = 10
|
||
}
|
||
|
||
private func rebuildFields() {
|
||
fieldsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||
if mode == .preShoot {
|
||
fieldsStack.addArrangedSubview(makeFieldGroup(title: "免费张数", required: false, field: freeCountField))
|
||
fieldsStack.addArrangedSubview(makeFieldGroup(title: "单张照片价格(元)", required: true, field: singlePriceField))
|
||
fieldsStack.addArrangedSubview(makeFieldGroup(title: "打包价格(元)", required: false, field: packagePriceField))
|
||
} else {
|
||
fieldsStack.addArrangedSubview(makeOrderGroup())
|
||
}
|
||
}
|
||
|
||
private func makeFieldGroup(title: String, required: Bool, field: UITextField) -> UIView {
|
||
let container = UIView()
|
||
let label = UILabel()
|
||
label.attributedText = fieldTitle(title, required: required)
|
||
container.addSubview(label)
|
||
container.addSubview(field)
|
||
label.snp.makeConstraints { make in
|
||
make.top.leading.trailing.equalToSuperview()
|
||
}
|
||
field.snp.makeConstraints { make in
|
||
make.top.equalTo(label.snp.bottom).offset(8)
|
||
make.leading.trailing.bottom.equalToSuperview()
|
||
}
|
||
return container
|
||
}
|
||
|
||
private func makeOrderGroup() -> UIView {
|
||
let container = UIView()
|
||
let label = UILabel()
|
||
label.attributedText = fieldTitle("绑定订单", required: false)
|
||
container.addSubview(label)
|
||
container.addSubview(orderButton)
|
||
label.snp.makeConstraints { make in
|
||
make.top.leading.trailing.equalToSuperview()
|
||
}
|
||
orderButton.snp.makeConstraints { make in
|
||
make.top.equalTo(label.snp.bottom).offset(8)
|
||
make.leading.trailing.bottom.equalToSuperview()
|
||
make.height.greaterThanOrEqualTo(46)
|
||
}
|
||
return container
|
||
}
|
||
|
||
private func fieldTitle(_ text: String, required: Bool) -> NSAttributedString {
|
||
let result = NSMutableAttributedString(
|
||
string: text,
|
||
attributes: [.font: UIFont.systemFont(ofSize: 14, weight: .medium), .foregroundColor: AppColor.textPrimary]
|
||
)
|
||
if required {
|
||
result.append(NSAttributedString(string: " *", attributes: [.foregroundColor: UIColor.red]))
|
||
}
|
||
return result
|
||
}
|
||
|
||
private func updateMode(_ newMode: TravelAlbumEntryViewModel.CreateMode) {
|
||
mode = newMode
|
||
preShootCard.apply(
|
||
title: "先拍再买",
|
||
desc: "拍完后分享给用户,用户在小程序上选择性购买",
|
||
selected: mode == .preShoot
|
||
)
|
||
preOrderCard.apply(
|
||
title: "买了再拍",
|
||
desc: "表示用户已经在小程序上下过单了,绑定对应订单上传后用户可以直接选片",
|
||
selected: mode == .preOrder
|
||
)
|
||
rebuildFields()
|
||
}
|
||
|
||
private func updateOrderButton() {
|
||
guard let order = selectedOrder else {
|
||
orderButton.setTitle("请选择订单", for: .normal)
|
||
orderButton.setTitleColor(AppColor.textTertiary, for: .normal)
|
||
return
|
||
}
|
||
let title = "\(order.projectName.isEmpty ? "未命名项目" : order.projectName)\n手机号:\(order.userPhone)\n订单号:\(order.orderNumber)"
|
||
orderButton.setTitle(title, for: .normal)
|
||
orderButton.setTitleColor(AppColor.textPrimary, for: .normal)
|
||
orderButton.titleLabel?.numberOfLines = 3
|
||
}
|
||
|
||
@objc private func preShootTapped() {
|
||
updateMode(.preShoot)
|
||
}
|
||
|
||
@objc private func preOrderTapped() {
|
||
updateMode(.preOrder)
|
||
}
|
||
|
||
@objc private func orderTapped() {
|
||
let alert = UIAlertController(title: "绑定订单", message: nil, preferredStyle: .actionSheet)
|
||
if viewModel.availableOrders.isEmpty {
|
||
alert.addAction(UIAlertAction(title: "暂无可绑定订单", style: .default))
|
||
} else {
|
||
viewModel.availableOrders.forEach { order in
|
||
let title = "\(order.projectName.isEmpty ? "未命名项目" : order.projectName) \(order.userPhone)"
|
||
alert.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
|
||
self?.selectedOrder = order
|
||
self?.updateOrderButton()
|
||
})
|
||
}
|
||
}
|
||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||
present(alert, animated: true)
|
||
}
|
||
|
||
@objc private func cancelTapped() {
|
||
dismiss(animated: true)
|
||
}
|
||
|
||
@objc private func confirmTapped() {
|
||
Task {
|
||
await viewModel.confirmCreate(
|
||
mode: mode,
|
||
freeCount: freeCountField.text ?? "",
|
||
singlePrice: singlePriceField.text ?? "",
|
||
packagePrice: packagePriceField.text ?? "",
|
||
order: selectedOrder,
|
||
api: api
|
||
)
|
||
await MainActor.run {
|
||
if !viewModel.isCreateSheetVisible {
|
||
dismiss(animated: true)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@objc private func textFieldEditingChanged(_ field: UITextField) {
|
||
let text = field.text ?? ""
|
||
if field === freeCountField {
|
||
field.text = text.filter(\.isNumber)
|
||
} else {
|
||
field.text = sanitizeMoneyInput(text)
|
||
}
|
||
}
|
||
|
||
private func sanitizeMoneyInput(_ value: String) -> String {
|
||
var result = ""
|
||
var hasDot = false
|
||
for char in value {
|
||
if char.isNumber {
|
||
result.append(char)
|
||
} else if char == ".", !hasDot {
|
||
result.append(char)
|
||
hasDot = true
|
||
}
|
||
}
|
||
guard !result.isEmpty else { return "" }
|
||
if result.hasPrefix(".") {
|
||
result = "0" + result
|
||
}
|
||
guard let dotIndex = result.firstIndex(of: ".") else {
|
||
let trimmed = result.drop { $0 == "0" }
|
||
return trimmed.isEmpty ? "0" : String(trimmed)
|
||
}
|
||
let integerPart = result[..<dotIndex].drop { $0 == "0" }
|
||
let decimalStart = result.index(after: dotIndex)
|
||
let decimal = result[decimalStart...].prefix(2)
|
||
return "\(integerPart.isEmpty ? "0" : String(integerPart)).\(decimal)"
|
||
}
|
||
}
|
||
|
||
/// 新建相册模式选择卡片。
|
||
private final class TravelAlbumModeOptionView: UIControl {
|
||
private let dotView = UIView()
|
||
private let innerDotView = UIView()
|
||
private let titleLabel = UILabel()
|
||
private let descLabel = UILabel()
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
layer.cornerRadius = 10
|
||
layer.borderWidth = 1
|
||
dotView.layer.cornerRadius = 10
|
||
innerDotView.backgroundColor = .white
|
||
innerDotView.layer.cornerRadius = 3.5
|
||
titleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
|
||
descLabel.font = .systemFont(ofSize: 12)
|
||
descLabel.textColor = AppColor.textSecondary
|
||
descLabel.numberOfLines = 0
|
||
|
||
addSubview(dotView)
|
||
dotView.addSubview(innerDotView)
|
||
addSubview(titleLabel)
|
||
addSubview(descLabel)
|
||
dotView.snp.makeConstraints { make in
|
||
make.leading.top.equalToSuperview().offset(14)
|
||
make.size.equalTo(20)
|
||
}
|
||
innerDotView.snp.makeConstraints { make in
|
||
make.center.equalToSuperview()
|
||
make.size.equalTo(7)
|
||
}
|
||
titleLabel.snp.makeConstraints { make in
|
||
make.top.equalToSuperview().offset(14)
|
||
make.leading.equalTo(dotView.snp.trailing).offset(10)
|
||
make.trailing.equalToSuperview().offset(-14)
|
||
}
|
||
descLabel.snp.makeConstraints { make in
|
||
make.top.equalTo(titleLabel.snp.bottom).offset(4)
|
||
make.leading.trailing.equalTo(titleLabel)
|
||
make.bottom.equalToSuperview().offset(-14)
|
||
}
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
func apply(title: String, desc: String, selected: Bool) {
|
||
titleLabel.text = title
|
||
descLabel.text = desc
|
||
backgroundColor = selected ? AppColor.primary.withAlphaComponent(0.06) : .white
|
||
layer.borderColor = (selected ? AppColor.primary : AppColor.border).cgColor
|
||
titleLabel.textColor = selected ? AppColor.primary : AppColor.textPrimary
|
||
dotView.backgroundColor = selected ? AppColor.primary : .clear
|
||
dotView.layer.borderWidth = selected ? 0 : 2
|
||
dotView.layer.borderColor = AppColor.border.cgColor
|
||
innerDotView.isHidden = !selected
|
||
}
|
||
}
|