Files
suixinkan_uikit/suixinkan/UI/TravelAlbum/CreateTravelAlbumSheetViewController.swift
T
lujiuyin 396597a160 feat: 优化自动修图设置与模板网格交互
同页切换修图方式并展开三列模板,复用模板卡片与对比预览,保留草稿和滚动位置,统一卡片与固定底栏样式并补充回归测试。
2026-08-27 17:15:50 +08:00

385 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) { min(650, $0.maximumDetentValue) },
.large(),
]
sheetPresentationController.selectedDetentIdentifier = formDetent
sheetPresentationController.prefersGrabberVisible = false
sheetPresentationController.preferredCornerRadius = 22
}
}
@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 = 10
autoRetouchSectionView.layer.borderWidth = 1
autoRetouchSectionView.layer.borderColor = AppColor.border.cgColor
autoRetouchTitleLabel.text = "修图方式"
autoRetouchTitleLabel.font = .systemFont(ofSize: 14, weight: .medium)
autoRetouchTitleLabel.textColor = AppColor.textPrimary
autoRetouchDetailLabel.text = "选择 AI 修图后,需要再选一个效果模板"
autoRetouchDetailLabel.font = .systemFont(ofSize: 12)
autoRetouchDetailLabel.textColor = AppColor.textSecondary
let optionsStack = UIStackView(arrangedSubviews: [noRetouchOption, aiRetouchOption])
optionsStack.axis = .horizontal
optionsStack.spacing = 10
optionsStack.distribution = .fillEqually
autoRetouchSectionView.addSubview(autoRetouchTitleLabel)
autoRetouchSectionView.addSubview(autoRetouchDetailLabel)
autoRetouchSectionView.addSubview(optionsStack)
autoRetouchTitleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(12)
make.leading.trailing.equalToSuperview().inset(14)
}
autoRetouchDetailLabel.snp.makeConstraints { make in
make.top.equalTo(autoRetouchTitleLabel.snp.bottom).offset(4)
make.leading.trailing.equalTo(autoRetouchTitleLabel)
}
optionsStack.snp.makeConstraints { make in
make.top.equalTo(autoRetouchDetailLabel.snp.bottom).offset(12)
make.leading.trailing.bottom.equalToSuperview().inset(12)
make.height.equalTo(76)
}
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 noRetouchTapped() {
autoRetouchConfiguration = .disabled
updateAutoRetouchSection()
}
@objc private func aiRetouchTapped() {
guard presentedViewController == nil else { return }
let settingViewModel = TravelAlbumAutoRetouchSettingViewModel(
scenicId: AppStore.shared.session.currentScenicId,
configuration: autoRetouchConfiguration,
allowsModeSelection: false
)
let controller = TravelAlbumAutoRetouchSettingSheetViewController(
viewModel: settingViewModel,
api: api
)
controller.onConfirm = { [weak self] configuration in
self?.autoRetouchConfiguration = configuration
self?.updateAutoRetouchSection()
}
present(controller, animated: true)
}
private func updateAutoRetouchSection() {
noRetouchOption.apply(title: "不修图", desc: "保留原图", selected: !autoRetouchConfiguration.enabled)
aiRetouchOption.apply(
title: "AI 修图",
desc: autoRetouchConfiguration.enabled ? "已选择真实 AI 模板" : "点击选模板",
selected: autoRetouchConfiguration.enabled
)
noRetouchOption.accessibilityLabel = "不修图,保留原图"
aiRetouchOption.accessibilityLabel = "AI 修图,点击选择模板"
noRetouchOption.accessibilityValue = autoRetouchConfiguration.enabled ? "未选择" : "已选择"
aiRetouchOption.accessibilityValue = autoRetouchConfiguration.enabled ? "已选择" : "未选择"
}
@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
}
}