Add scenic application flow aligned with Android.

Implement scenic apply models, API, upload, UI, and tests; wire entry from permission apply pages and include related permission/scenic selection UI fixes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-07 11:09:27 +08:00
parent 2bea05b1d9
commit 77fec21563
22 changed files with 2202 additions and 216 deletions

View File

@ -0,0 +1,338 @@
//
// ScenicApplicationViewController.swift
// suixinkan
//
import PhotosUI
import SnapKit
import UIKit
/// Android `ScenicApplicationScreen`
final class ScenicApplicationViewController: BaseViewController {
private let viewModel: ScenicApplicationViewModel
private let homeAPI: HomeAPI
private let profileAPI: ProfileAPI
private let ossUploadService: OSSUploadService
private let scrollView = UIScrollView()
private let outerStack = UIStackView()
private let paddedStack = UIStackView()
private let auditCard = ScenicApplicationAuditStatusView()
private let warmReminder = ScenicApplicationWarmReminderView()
private let formCard = UIView()
private let formStack = UIStackView()
private let scenicNameField = ScenicApplicationFieldStyle.makeTextField(placeholder: "请输入景区名称")
private let scenicNameSection = ScenicApplicationFormFieldView(title: "景区名称")
private let imageSection = ScenicApplicationFormFieldView(title: "景区图片 (已添加 0/20)")
private let imageUploadView = ScenicApplicationImageUploadView()
private let provinceSelector = ScenicApplicationLocationSelector()
private let citySelector = ScenicApplicationLocationSelector()
private let cooperationStack = UIStackView()
private var cooperationOptions: [ScenicApplicationCooperationOptionView] = []
private let remarksView = ScenicApplicationPlaceholderTextView(placeholder: "请输入备注信息")
private let remarksSection = ScenicApplicationFormFieldView(title: "备注信息 (0/50)")
private let bottomBar = UIView()
private let agreementView = ScenicApplicationAgreementView()
private let submitButton = ScenicApplicationSubmitButton()
private let uploadProgressView = ScenicApplicationUploadProgressView()
init(
viewModel: ScenicApplicationViewModel = ScenicApplicationViewModel(),
homeAPI: HomeAPI = NetworkServices.shared.homeAPI,
profileAPI: ProfileAPI = NetworkServices.shared.profileAPI,
ossUploadService: OSSUploadService = NetworkServices.shared.ossUploadService
) {
self.viewModel = viewModel
self.homeAPI = homeAPI
self.profileAPI = profileAPI
self.ossUploadService = ossUploadService
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "景区申请"
}
override func setupUI() {
outerStack.axis = .vertical
outerStack.spacing = 0
paddedStack.axis = .vertical
paddedStack.spacing = 12
formCard.backgroundColor = .white
formCard.layer.cornerRadius = 8
formStack.axis = .vertical
formStack.spacing = 20
formCard.addSubview(formStack)
formStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
}
scenicNameSection.setContent(scenicNameField)
imageSection.setContent(imageUploadView)
let locationRow = UIStackView(arrangedSubviews: [provinceSelector, citySelector])
locationRow.axis = .horizontal
locationRow.spacing = 12
locationRow.distribution = .fillEqually
let locationSection = ScenicApplicationFormFieldView(title: "景区位置")
locationSection.setContent(locationRow)
cooperationStack.axis = .vertical
cooperationStack.spacing = 8
ScenicCooperationType.allCases.forEach { type in
let option = ScenicApplicationCooperationOptionView(title: type.displayName)
option.tag = type.rawValue
option.addTarget(self, action: #selector(cooperationTypeTapped(_:)), for: .touchUpInside)
cooperationOptions.append(option)
cooperationStack.addArrangedSubview(option)
}
let cooperationSection = ScenicApplicationFormFieldView(title: "合作类型")
cooperationSection.setContent(cooperationStack)
remarksView.text = ""
remarksSection.setContent(remarksView)
formStack.addArrangedSubview(scenicNameSection)
formStack.addArrangedSubview(imageSection)
formStack.addArrangedSubview(locationSection)
formStack.addArrangedSubview(cooperationSection)
formStack.addArrangedSubview(remarksSection)
paddedStack.addArrangedSubview(warmReminder)
paddedStack.addArrangedSubview(formCard)
outerStack.addArrangedSubview(auditCard)
outerStack.addArrangedSubview(paddedStack)
auditCard.isHidden = true
scrollView.addSubview(outerStack)
view.addSubview(scrollView)
view.addSubview(bottomBar)
view.addSubview(uploadProgressView)
bottomBar.backgroundColor = .white
let bottomStack = UIStackView(arrangedSubviews: [agreementView, submitButton])
bottomStack.axis = .vertical
bottomStack.spacing = 4
bottomBar.addSubview(bottomStack)
bottomStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 16, bottom: 18, right: 16))
}
uploadProgressView.isHidden = true
uploadProgressView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
override func setupConstraints() {
scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
make.bottom.equalTo(bottomBar.snp.top)
}
outerStack.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.width.equalTo(scrollView)
}
paddedStack.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(16)
}
paddedStack.layoutMargins = UIEdgeInsets(top: 12, left: 0, bottom: 16, right: 0)
paddedStack.isLayoutMarginsRelativeArrangement = true
bottomBar.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(view.safeAreaLayoutGuide)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
viewModel.onSubmitSuccess = { [weak self] in
Task { @MainActor in self?.navigationController?.popViewController(animated: true) }
}
scenicNameField.addTarget(self, action: #selector(scenicNameChanged), for: .editingChanged)
remarksView.onTextChange = { [weak self] text in
self?.viewModel.onRemarksChange(text)
}
provinceSelector.addTarget(self, action: #selector(provinceTapped), for: .touchUpInside)
citySelector.addTarget(self, action: #selector(cityTapped), for: .touchUpInside)
agreementView.onToggle = { [weak self] in self?.viewModel.toggleAgreement() }
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
imageUploadView.onAdd = { [weak self] in self?.pickImages() }
imageUploadView.onDelete = { [weak self] index in self?.viewModel.deleteImage(at: index) }
imageUploadView.onRetry = { [weak self] index in
guard let self else { return }
self.viewModel.retryUpload(at: index)
Task { await self.startUploadIfNeeded() }
}
Task { await loadInitial() }
applyViewModel()
}
private func loadInitial() async {
showLoading()
defer { hideLoading() }
await viewModel.loadInitial(profileAPI: profileAPI, homeAPI: homeAPI)
}
private func applyViewModel() {
if viewModel.shouldShowAuditCard, let pending = viewModel.pendingApplication {
auditCard.apply(pending: pending)
auditCard.isHidden = false
} else {
auditCard.isHidden = true
}
let editable = viewModel.isEditable
let readOnly = viewModel.isReadOnly
scenicNameField.text = viewModel.scenicName
scenicNameField.isEnabled = editable
scenicNameField.isUserInteractionEnabled = editable && !readOnly
imageSection.updateTitle("景区图片 (已添加 \(viewModel.selectedImages.count)/20)")
imageUploadView.apply(images: viewModel.selectedImages, isReadOnly: readOnly)
provinceSelector.setValue(viewModel.selectedProvince, placeholder: "省份")
citySelector.setValue(viewModel.selectedCity, placeholder: "城市")
provinceSelector.isEnabled = editable
citySelector.isEnabled = editable
cooperationOptions.forEach { option in
let type = ScenicCooperationType(rawValue: option.tag) ?? .photographer
option.isSelected = type == viewModel.selectedCooperationType
option.isEnabled = editable
}
if remarksView.text != viewModel.remarks {
remarksView.text = viewModel.remarks
}
remarksView.isEditable = editable && !readOnly
remarksSection.updateTitle("备注信息 (\(viewModel.remarks.count)/50)")
agreementView.isHidden = !editable
agreementView.setChecked(viewModel.isAgreed)
submitButton.apply(
canSubmit: viewModel.canSubmit,
isSubmitting: viewModel.isSubmitting,
isReadOnly: readOnly
)
uploadProgressView.apply(state: viewModel.uploadDialogState)
}
@objc private func scenicNameChanged() {
viewModel.onScenicNameChange(scenicNameField.text ?? "")
}
@objc private func cooperationTypeTapped(_ sender: ScenicApplicationCooperationOptionView) {
guard viewModel.isEditable else { return }
guard let type = ScenicCooperationType(rawValue: sender.tag) else { return }
viewModel.onCooperationTypeSelected(type)
}
@objc private func provinceTapped() {
guard viewModel.isEditable else { return }
presentAreaPicker(title: "选择省份", names: viewModel.provinceList.map(\.name)) { [weak self] name in
self?.viewModel.onProvinceSelected(name)
}
}
@objc private func cityTapped() {
guard viewModel.isEditable else { return }
guard !viewModel.selectedProvince.isEmpty else {
showToast("请先选择省份")
return
}
presentAreaPicker(title: "选择城市", names: viewModel.cityList.map(\.name)) { [weak self] name in
self?.viewModel.onCitySelected(name)
}
}
private func presentAreaPicker(title: String, names: [String], onSelect: @escaping (String) -> Void) {
let alert = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet)
names.forEach { name in
alert.addAction(UIAlertAction(title: name, style: .default) { _ in onSelect(name) })
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
present(alert, animated: true)
}
@objc private func submitTapped() {
Task {
showLoading()
defer { hideLoading() }
await viewModel.submit(homeAPI: homeAPI)
}
}
private func pickImages() {
guard viewModel.isEditable, !viewModel.isReadOnly else { return }
let remaining = 20 - viewModel.selectedImages.count
guard remaining > 0 else {
showToast("最多只能选择20张图片")
return
}
var configuration = PHPickerConfiguration(photoLibrary: .shared())
configuration.filter = .images
configuration.selectionLimit = remaining
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = self
present(picker, animated: true)
}
private func startUploadIfNeeded() async {
let scenicId = AppStore.shared.currentScenicId
await viewModel.processPendingUploads(uploader: ossUploadService, scenicId: scenicId)
}
}
extension ScenicApplicationViewController: PHPickerViewControllerDelegate {
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true)
guard !results.isEmpty else { return }
var loadedItems = [ScenicApplicationImageItem?](repeating: nil, count: results.count)
let group = DispatchGroup()
for (index, result) in results.enumerated() {
let provider = result.itemProvider
guard provider.canLoadObject(ofClass: UIImage.self) else { continue }
group.enter()
provider.loadObject(ofClass: UIImage.self) { object, _ in
defer { group.leave() }
guard let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { return }
let fileName = "scenic_apply_\(Int(Date().timeIntervalSince1970))_\(index).jpg"
loadedItems[index] = ScenicApplicationImageItem.fromLocal(data: data, fileName: fileName)
}
}
group.notify(queue: .main) { [weak self] in
guard let self else { return }
let items = loadedItems.compactMap { $0 }
guard !items.isEmpty else { return }
self.viewModel.addLocalImages(items)
Task { await self.startUploadIfNeeded() }
}
}
}

View File

@ -0,0 +1,684 @@
//
// ScenicApplicationViews.swift
// suixinkan
//
import Kingfisher
import SnapKit
import UIKit
/// Android `WarmReminderCard`
final class ScenicApplicationWarmReminderView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = AppColor.primaryBanner
layer.cornerRadius = 8
let titleLabel = UILabel()
titleLabel.text = "温馨提示"
titleLabel.font = .systemFont(ofSize: 16, weight: .bold)
titleLabel.textColor = .white
let bodyLabel = UILabel()
bodyLabel.text = "如果你想入驻更多的景区,平台可以帮你跟景区官方谈合作,如果已经有的景区,只要你符合入驻的条件,即可入驻。"
bodyLabel.font = .systemFont(ofSize: 14)
bodyLabel.textColor = .white
bodyLabel.numberOfLines = 0
let stack = UIStackView(arrangedSubviews: [titleLabel, bodyLabel])
stack.axis = .vertical
stack.spacing = 8
addSubview(stack)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
/// Android `ScenicApplicationAuditStatusCard`
final class ScenicApplicationAuditStatusView: UIView {
func apply(pending: ScenicApplicationPendingResponse) {
subviews.forEach { $0.removeFromSuperview() }
let statusLabel: String
let textColor: UIColor
let background: UIColor
switch pending.status {
case 1:
statusLabel = "待审核"
textColor = AppColor.warning
background = AppColor.warningBackground
case 2:
statusLabel = "通过"
textColor = AppColor.info
background = AppColor.primaryLight
case 3, 9:
statusLabel = pending.status == 3 ? "拒绝" : "取消"
textColor = AppColor.danger
background = AppColor.dangerBackground
default:
statusLabel = "--"
textColor = AppColor.warning
background = AppColor.warningBackground
}
backgroundColor = background
let stack = UIStackView()
stack.axis = .vertical
stack.spacing = 8
stack.addArrangedSubview(makeRow(label: "审核状态", value: statusLabel, color: textColor))
if pending.status == 2 {
stack.addArrangedSubview(makeRow(label: "审核人", value: pending.auditedBy ?? "--", color: textColor))
stack.addArrangedSubview(makeRow(label: "审核时间", value: pending.auditedAt ?? "--", color: textColor))
}
let note = pending.rejectReason ?? pending.auditNote ?? "--"
stack.addArrangedSubview(makeRow(label: "备注", value: note.isEmpty ? "--" : note, color: textColor))
addSubview(stack)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 16, left: 20, bottom: 16, right: 20))
}
}
private func makeRow(label: String, value: String, color: UIColor) -> UIView {
let row = UIStackView()
row.axis = .horizontal
row.distribution = .equalSpacing
let left = UILabel()
left.text = label
left.font = .systemFont(ofSize: 14)
left.textColor = color
let right = UILabel()
right.text = value
right.font = .systemFont(ofSize: 14)
right.textColor = color
right.textAlignment = .right
right.numberOfLines = 0
row.addArrangedSubview(left)
row.addArrangedSubview(right)
return row
}
}
/// +
final class ScenicApplicationFormFieldView: UIView {
private let titleLabel = UILabel()
private let contentContainer = UIView()
init(title: String) {
super.init(frame: .zero)
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
titleLabel.textColor = AppColor.textPrimary
let stack = UIStackView(arrangedSubviews: [titleLabel, contentContainer])
stack.axis = .vertical
stack.spacing = 8
addSubview(stack)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setContent(_ view: UIView) {
contentContainer.subviews.forEach { $0.removeFromSuperview() }
contentContainer.addSubview(view)
view.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
func updateTitle(_ title: String) {
titleLabel.text = title
}
}
/// /
final class ScenicApplicationLocationSelector: UIControl {
private let valueLabel = UILabel()
private let chevron = UIImageView(image: UIImage(systemName: "chevron.down"))
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
layer.cornerRadius = 8
layer.borderWidth = 1
layer.borderColor = AppColor.border.cgColor
valueLabel.font = .systemFont(ofSize: 14)
valueLabel.textColor = AppColor.textTertiary
valueLabel.text = "请选择"
chevron.tintColor = AppColor.textTertiary
chevron.contentMode = .scaleAspectFit
addSubview(valueLabel)
addSubview(chevron)
valueLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.centerY.equalToSuperview()
make.trailing.lessThanOrEqualTo(chevron.snp.leading).offset(-8)
}
chevron.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-12)
make.centerY.equalToSuperview()
make.size.equalTo(16)
}
snp.makeConstraints { make in
make.height.equalTo(44)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setValue(_ value: String?, placeholder: String) {
if let value, !value.isEmpty {
valueLabel.text = value
valueLabel.textColor = AppColor.textPrimary
} else {
valueLabel.text = placeholder
valueLabel.textColor = AppColor.textTertiary
}
}
override var isEnabled: Bool {
didSet {
alpha = isEnabled ? 1 : 0.5
}
}
}
///
final class ScenicApplicationCooperationOptionView: UIControl {
private let titleLabel = UILabel()
override var isSelected: Bool {
didSet { applyStyle() }
}
init(title: String) {
super.init(frame: .zero)
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 14)
titleLabel.numberOfLines = 0
layer.cornerRadius = 8
layer.borderWidth = 2
addSubview(titleLabel)
titleLabel.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
}
applyStyle()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func applyStyle() {
if isSelected {
backgroundColor = AppColor.primaryBanner
titleLabel.textColor = .white
layer.borderColor = UIColor.clear.cgColor
} else {
backgroundColor = .white
titleLabel.textColor = AppColor.textSecondary
layer.borderColor = AppColor.inputBackground.cgColor
}
}
}
///
final class ScenicApplicationImageUploadView: UIView {
var onAdd: (() -> Void)?
var onDelete: ((Int) -> Void)?
var onRetry: ((Int) -> Void)?
private let scrollView = UIScrollView()
private let stackView = UIStackView()
override init(frame: CGRect) {
super.init(frame: frame)
stackView.axis = .horizontal
stackView.spacing = 8
stackView.alignment = .center
scrollView.showsHorizontalScrollIndicator = false
scrollView.addSubview(stackView)
addSubview(scrollView)
scrollView.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.height.equalTo(80)
}
stackView.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.height.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(images: [ScenicApplicationImageItem], isReadOnly: Bool) {
stackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
for (index, item) in images.enumerated() {
let cell = ScenicApplicationImageCell()
cell.apply(item: item, showDelete: !isReadOnly)
cell.onDelete = { [weak self] in self?.onDelete?(index) }
cell.onRetry = { [weak self] in self?.onRetry?(index) }
stackView.addArrangedSubview(cell)
cell.snp.makeConstraints { make in
make.size.equalTo(80)
}
}
if images.count < 20, !isReadOnly {
let addButton = ScenicApplicationAddImageButton()
addButton.addTarget(self, action: #selector(addTapped), for: .touchUpInside)
stackView.addArrangedSubview(addButton)
addButton.snp.makeConstraints { make in
make.size.equalTo(80)
}
}
}
@objc private func addTapped() {
onAdd?()
}
}
private final class ScenicApplicationAddImageButton: UIControl {
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = AppColor.inputBackground
layer.cornerRadius = 8
let icon = UIImageView(image: UIImage(systemName: "plus"))
icon.tintColor = UIColor(hex: 0xB6BECA)
let label = UILabel()
label.text = "点击上传"
label.font = .systemFont(ofSize: 12)
label.textColor = UIColor(hex: 0xB6BECA)
let stack = UIStackView(arrangedSubviews: [icon, label])
stack.axis = .vertical
stack.spacing = 4
stack.alignment = .center
stack.isUserInteractionEnabled = false
addSubview(stack)
stack.snp.makeConstraints { make in
make.center.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
private final class ScenicApplicationImageCell: UIView {
var onDelete: (() -> Void)?
var onRetry: (() -> Void)?
private let imageView = UIImageView()
private let progressView = UIProgressView(progressViewStyle: .default)
private let deleteButton = UIButton(type: .system)
private let retryButton = UIButton(type: .system)
private let errorLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
layer.cornerRadius = 8
clipsToBounds = true
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
deleteButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal)
deleteButton.tintColor = .white
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
retryButton.setTitle("重试", for: .normal)
retryButton.titleLabel?.font = .systemFont(ofSize: 12)
retryButton.setTitleColor(.white, for: .normal)
retryButton.backgroundColor = UIColor.black.withAlphaComponent(0.5)
retryButton.layer.cornerRadius = 4
retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
retryButton.isHidden = true
errorLabel.font = .systemFont(ofSize: 10)
errorLabel.textColor = .white
errorLabel.textAlignment = .center
errorLabel.backgroundColor = UIColor.black.withAlphaComponent(0.5)
errorLabel.isHidden = true
addSubview(imageView)
addSubview(progressView)
addSubview(errorLabel)
addSubview(retryButton)
addSubview(deleteButton)
imageView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
progressView.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview().inset(4)
}
deleteButton.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(2)
make.size.equalTo(22)
}
retryButton.snp.makeConstraints { make in
make.center.equalToSuperview()
make.width.equalTo(44)
make.height.equalTo(24)
}
errorLabel.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
make.height.equalTo(18)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(item: ScenicApplicationImageItem, showDelete: Bool) {
deleteButton.isHidden = !showDelete
progressView.isHidden = !item.isUploading
progressView.progress = Float(item.uploadProgress) / 100
if let data = item.localData, let image = UIImage(data: data) {
imageView.image = image
} else if !item.remoteURL.isEmpty, let url = URL(string: item.remoteURL) {
imageView.kf.setImage(with: url)
} else {
imageView.image = nil
}
if let error = item.errorMessage, !error.isEmpty {
errorLabel.isHidden = false
errorLabel.text = "失败"
retryButton.isHidden = false
} else {
errorLabel.isHidden = true
retryButton.isHidden = true
}
}
@objc private func deleteTapped() {
onDelete?()
}
@objc private func retryTapped() {
onRetry?()
}
}
/// Android `AgreementCheckbox`
final class ScenicApplicationAgreementView: UIView {
var onToggle: (() -> Void)?
private let checkbox = UIButton(type: .custom)
private var isChecked = false
override init(frame: CGRect) {
super.init(frame: frame)
checkbox.setImage(UIImage(systemName: "square"), for: .normal)
checkbox.setImage(UIImage(systemName: "checkmark.square.fill"), for: .selected)
checkbox.tintColor = AppColor.primaryBanner
checkbox.addTarget(self, action: #selector(toggleTapped), for: .touchUpInside)
let prefix = UILabel()
prefix.text = "已阅读并同意"
prefix.font = .systemFont(ofSize: 14)
prefix.textColor = AppColor.textSecondary
let userNotice = UILabel()
userNotice.text = "《用户须知》"
userNotice.font = .systemFont(ofSize: 14)
userNotice.textColor = AppColor.primaryBanner
let middle = UILabel()
middle.text = ""
middle.font = .systemFont(ofSize: 14)
middle.textColor = AppColor.textSecondary
let privacy = UILabel()
privacy.text = "《隐私政策》"
privacy.font = .systemFont(ofSize: 14)
privacy.textColor = AppColor.primaryBanner
let textStack = UIStackView(arrangedSubviews: [prefix, userNotice, middle, privacy])
textStack.axis = .horizontal
textStack.spacing = 2
textStack.alignment = .center
let row = UIStackView(arrangedSubviews: [checkbox, textStack])
row.axis = .horizontal
row.spacing = 2
row.alignment = .center
let tap = UITapGestureRecognizer(target: self, action: #selector(toggleTapped))
addGestureRecognizer(tap)
addSubview(row)
row.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
checkbox.snp.makeConstraints { make in
make.size.equalTo(24)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setChecked(_ checked: Bool) {
isChecked = checked
checkbox.isSelected = checked
}
@objc private func toggleTapped() {
onToggle?()
}
}
///
final class ScenicApplicationSubmitButton: UIButton {
override init(frame: CGRect) {
super.init(frame: frame)
setTitle("确认提交", for: .normal)
setTitleColor(.white, for: .normal)
titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
backgroundColor = AppColor.primaryBanner
layer.cornerRadius = 8
snp.makeConstraints { make in
make.height.equalTo(46)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(canSubmit: Bool, isSubmitting: Bool, isReadOnly: Bool) {
let enabled = !isReadOnly && canSubmit && !isSubmitting
isEnabled = enabled
alpha = enabled ? 1 : 1
backgroundColor = enabled ? AppColor.primaryBanner : UIColor(hex: 0xE0E0E0)
setTitle(isSubmitting ? "提交中..." : "确认提交", for: .normal)
}
}
///
final class ScenicApplicationUploadProgressView: UIView {
private let titleLabel = UILabel()
private let progressView = UIProgressView(progressViewStyle: .default)
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor.black.withAlphaComponent(0.35)
let card = UIView()
card.backgroundColor = .white
card.layer.cornerRadius = 12
titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
titleLabel.textColor = AppColor.textPrimary
titleLabel.textAlignment = .center
progressView.progressTintColor = AppColor.primaryBanner
let stack = UIStackView(arrangedSubviews: [titleLabel, progressView])
stack.axis = .vertical
stack.spacing = 16
card.addSubview(stack)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(20)
}
addSubview(card)
card.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(40)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(state: ScenicApplicationUploadDialogState?) {
isHidden = state == nil
guard let state else { return }
titleLabel.text = state.title
progressView.progress = Float(state.progress) / 100
}
}
/// placeholder
final class ScenicApplicationPlaceholderTextView: UITextView, UITextViewDelegate {
private let placeholderLabel = UILabel()
var placeholder: String {
get { placeholderLabel.text ?? "" }
set {
placeholderLabel.text = newValue
refreshPlaceholderVisibility()
}
}
var onTextChange: ((String) -> Void)?
override var text: String! {
didSet { refreshPlaceholderVisibility() }
}
override var delegate: UITextViewDelegate? {
get { super.delegate }
set {
// placeholder delegate onTextChange
}
}
init(placeholder: String) {
super.init(frame: .zero, textContainer: nil)
font = .systemFont(ofSize: 14)
textColor = AppColor.textPrimary
backgroundColor = .white
layer.cornerRadius = 8
layer.borderWidth = 1
layer.borderColor = AppColor.border.cgColor
textContainerInset = UIEdgeInsets(top: 10, left: 8, bottom: 10, right: 8)
super.delegate = self
placeholderLabel.text = placeholder
placeholderLabel.font = font
placeholderLabel.textColor = AppColor.textTertiary
placeholderLabel.numberOfLines = 0
addSubview(placeholderLabel)
placeholderLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(10)
make.leading.equalToSuperview().offset(12)
make.trailing.equalToSuperview().offset(-12)
}
snp.makeConstraints { make in
make.height.equalTo(100)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func textViewDidChange(_ textView: UITextView) {
refreshPlaceholderVisibility()
onTextChange?(textView.text ?? "")
}
private func refreshPlaceholderVisibility() {
placeholderLabel.isHidden = !(text ?? "").isEmpty
}
}
///
enum ScenicApplicationFieldStyle {
static func makeTextField(placeholder: String) -> UITextField {
let field = UITextField()
field.placeholder = placeholder
field.font = .systemFont(ofSize: 14)
field.borderStyle = .none
field.backgroundColor = .white
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)
}
return field
}
}