391 lines
16 KiB
Swift
391 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 let mode: TravelAlbumEntryViewModel.CreateMode = .preShoot
|
|
|
|
private let scrollView = UIScrollView()
|
|
private let contentView = UIView()
|
|
private let titleLabel = UILabel()
|
|
private let preShootCard = TravelAlbumModeOptionView()
|
|
private let fieldsStack = UIStackView()
|
|
private let freeCountField = UITextField()
|
|
private let singlePriceField = UITextField()
|
|
private let packagePriceField = UITextField()
|
|
private let autoRetouchSectionView = UIView()
|
|
private let autoRetouchTitleLabel = UILabel()
|
|
private let autoRetouchDetailLabel = UILabel()
|
|
private let noRetouchOption = TravelAlbumModeOptionView()
|
|
private let aiRetouchOption = TravelAlbumModeOptionView()
|
|
private let cancelButton = UIButton(type: .system)
|
|
private let confirmButton = UIButton(type: .system)
|
|
private var autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled
|
|
|
|
init(viewModel: TravelAlbumEntryViewModel, api: any TravelAlbumServing) {
|
|
self.viewModel = viewModel
|
|
self.api = api
|
|
super.init(nibName: nil, bundle: nil)
|
|
modalPresentationStyle = .pageSheet
|
|
if let sheetPresentationController {
|
|
let formDetent = UISheetPresentationController.Detent.Identifier("createTravelAlbumForm")
|
|
sheetPresentationController.detents = [
|
|
.custom(identifier: formDetent) { context in
|
|
min(590, context.maximumDetentValue)
|
|
},
|
|
.large(),
|
|
]
|
|
sheetPresentationController.selectedDetentIdentifier = formDetent
|
|
sheetPresentationController.prefersGrabberVisible = false
|
|
sheetPresentationController.preferredCornerRadius = AppRadius.xl
|
|
}
|
|
}
|
|
|
|
@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
|
|
)
|
|
|
|
fieldsStack.axis = .vertical
|
|
fieldsStack.spacing = 14
|
|
|
|
configureTextField(freeCountField, placeholder: "请输入免费张数", keyboardType: .numberPad)
|
|
configureTextField(singlePriceField, placeholder: "请输入单张照片价格", keyboardType: .decimalPad)
|
|
configureTextField(packagePriceField, placeholder: "请输入打包价格", keyboardType: .decimalPad)
|
|
configureAutoRetouchSection()
|
|
|
|
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, 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)
|
|
}
|
|
fieldsStack.snp.makeConstraints { make in
|
|
make.top.equalTo(preShootCard.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() {
|
|
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)
|
|
}
|
|
noRetouchOption.addTarget(self, action: #selector(noRetouchTapped), for: .touchUpInside)
|
|
aiRetouchOption.addTarget(self, action: #selector(aiRetouchTapped), for: .touchUpInside)
|
|
}
|
|
|
|
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() }
|
|
fieldsStack.addArrangedSubview(makeFieldGroup(title: "免费张数", required: false, field: freeCountField))
|
|
fieldsStack.addArrangedSubview(makeFieldGroup(title: "单张照片价格(元)", required: true, field: singlePriceField))
|
|
fieldsStack.addArrangedSubview(makeFieldGroup(title: "打包价格(元)", required: false, field: packagePriceField))
|
|
fieldsStack.addArrangedSubview(autoRetouchSectionView)
|
|
}
|
|
|
|
private func configureAutoRetouchSection() {
|
|
autoRetouchSectionView.backgroundColor = .white
|
|
autoRetouchSectionView.layer.cornerRadius = AppRadius.sm
|
|
autoRetouchSectionView.layer.borderWidth = 1
|
|
autoRetouchSectionView.layer.borderColor = AppColor.border.cgColor
|
|
autoRetouchSectionView.clipsToBounds = true
|
|
|
|
autoRetouchTitleLabel.text = "修图方式"
|
|
autoRetouchTitleLabel.font = .app(.bodyMedium)
|
|
autoRetouchTitleLabel.textColor = AppColor.textPrimary
|
|
autoRetouchDetailLabel.text = "选择 AI 修图后,需要再选一个效果模板"
|
|
autoRetouchDetailLabel.font = .app(.caption)
|
|
autoRetouchDetailLabel.textColor = AppColor.textSecondary
|
|
|
|
let optionsStack = UIStackView(arrangedSubviews: [noRetouchOption, aiRetouchOption])
|
|
optionsStack.axis = .horizontal
|
|
optionsStack.spacing = AppSpacing.xs
|
|
optionsStack.distribution = .fillEqually
|
|
|
|
autoRetouchSectionView.addSubview(autoRetouchTitleLabel)
|
|
autoRetouchSectionView.addSubview(autoRetouchDetailLabel)
|
|
autoRetouchSectionView.addSubview(optionsStack)
|
|
autoRetouchTitleLabel.snp.makeConstraints { make in
|
|
make.top.equalToSuperview().offset(AppSpacing.sm)
|
|
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
|
}
|
|
autoRetouchDetailLabel.snp.makeConstraints { make in
|
|
make.top.equalTo(autoRetouchTitleLabel.snp.bottom).offset(AppSpacing.xxs)
|
|
make.leading.trailing.equalTo(autoRetouchTitleLabel)
|
|
}
|
|
optionsStack.snp.makeConstraints { make in
|
|
make.top.equalTo(autoRetouchDetailLabel.snp.bottom).offset(AppSpacing.sm)
|
|
make.leading.trailing.bottom.equalToSuperview().inset(AppSpacing.sm)
|
|
make.height.equalTo(72)
|
|
}
|
|
updateAutoRetouchSection()
|
|
}
|
|
|
|
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 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
|
|
}
|
|
|
|
@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 ?? "",
|
|
autoRetouchConfiguration: autoRetouchConfiguration,
|
|
order: nil,
|
|
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)
|
|
}
|
|
}
|
|
|
|
@objc private func noRetouchTapped() {
|
|
autoRetouchConfiguration = .disabled
|
|
updateAutoRetouchSection()
|
|
}
|
|
|
|
@objc private func aiRetouchTapped() {
|
|
presentTemplatePicker()
|
|
}
|
|
|
|
private func presentTemplatePicker() {
|
|
guard presentedViewController == nil else { return }
|
|
let controller = TravelAlbumAutoRetouchSettingSheetViewController(
|
|
configuration: autoRetouchConfiguration,
|
|
startsWithModeSelection: false
|
|
)
|
|
controller.onConfirm = { [weak self] configuration in
|
|
guard let self else { return }
|
|
self.autoRetouchConfiguration = configuration
|
|
self.updateAutoRetouchSection()
|
|
}
|
|
present(controller, animated: true)
|
|
}
|
|
|
|
private func updateAutoRetouchSection() {
|
|
let isEnabled = autoRetouchConfiguration.isEnabled
|
|
noRetouchOption.apply(
|
|
title: TravelAlbumRetouchMode.disabled.title,
|
|
desc: "保留原图",
|
|
selected: !isEnabled
|
|
)
|
|
aiRetouchOption.apply(
|
|
title: TravelAlbumRetouchMode.aiRetouch.title,
|
|
desc: autoRetouchConfiguration.template?.title ?? "点击选模板",
|
|
selected: isEnabled
|
|
)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|