feat: add travel album module
This commit is contained in:
@ -0,0 +1,393 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,162 @@
|
||||
//
|
||||
// TravelAlbumCodeDialogViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Kingfisher
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 相册码弹窗,对齐 Android `TravelAlbumCodeDialog`。
|
||||
final class TravelAlbumCodeDialogViewController: UIViewController {
|
||||
var onClose: (() -> Void)?
|
||||
var onDownload: (() -> Void)?
|
||||
|
||||
private let state: TravelAlbumEntryViewModel.AlbumCodeState
|
||||
private let dimView = UIView()
|
||||
private let cardView = UIView()
|
||||
private let titleLabel = UILabel()
|
||||
private let closeButton = UIButton(type: .system)
|
||||
private let albumNameLabel = UILabel()
|
||||
private let qrImageView = UIImageView()
|
||||
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
|
||||
private let hintLabel = UILabel()
|
||||
private let downloadButton = UIButton(type: .system)
|
||||
|
||||
init(state: TravelAlbumEntryViewModel.AlbumCodeState) {
|
||||
self.state = state
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
modalPresentationStyle = .overFullScreen
|
||||
modalTransitionStyle = .crossDissolve
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
setupUI()
|
||||
setupConstraints()
|
||||
applyState()
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
dimView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = 16
|
||||
cardView.clipsToBounds = true
|
||||
|
||||
titleLabel.text = "相册码"
|
||||
titleLabel.font = .systemFont(ofSize: 18, weight: .bold)
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
titleLabel.textAlignment = .center
|
||||
closeButton.setImage(UIImage(systemName: "xmark"), for: .normal)
|
||||
closeButton.tintColor = AppColor.textSecondary
|
||||
albumNameLabel.font = .systemFont(ofSize: 18, weight: .medium)
|
||||
albumNameLabel.textColor = AppColor.textPrimary
|
||||
albumNameLabel.textAlignment = .center
|
||||
qrImageView.contentMode = .scaleAspectFit
|
||||
qrImageView.layer.cornerRadius = 8
|
||||
qrImageView.clipsToBounds = true
|
||||
hintLabel.textAlignment = .center
|
||||
hintLabel.font = .systemFont(ofSize: 13)
|
||||
hintLabel.textColor = AppColor.textSecondary
|
||||
hintLabel.backgroundColor = AppColor.primary.withAlphaComponent(0.06)
|
||||
hintLabel.layer.cornerRadius = 8
|
||||
hintLabel.layer.borderWidth = 1
|
||||
hintLabel.layer.borderColor = AppColor.primary.withAlphaComponent(0.35).cgColor
|
||||
hintLabel.clipsToBounds = true
|
||||
let hint = NSMutableAttributedString(string: "微信", attributes: [.foregroundColor: AppColor.textSecondary])
|
||||
hint.append(NSAttributedString(string: "扫一扫", attributes: [.foregroundColor: AppColor.primary, .font: UIFont.systemFont(ofSize: 13, weight: .medium)]))
|
||||
hint.append(NSAttributedString(string: ",查看下载照片", attributes: [.foregroundColor: AppColor.textSecondary]))
|
||||
hintLabel.attributedText = hint
|
||||
downloadButton.setTitle("下载图片", for: .normal)
|
||||
downloadButton.setTitleColor(.white, for: .normal)
|
||||
downloadButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
||||
downloadButton.backgroundColor = AppColor.primary
|
||||
downloadButton.layer.cornerRadius = 23
|
||||
|
||||
view.addSubview(dimView)
|
||||
view.addSubview(cardView)
|
||||
[titleLabel, closeButton, albumNameLabel, qrImageView, loadingIndicator, hintLabel, downloadButton].forEach(cardView.addSubview)
|
||||
closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
|
||||
downloadButton.addTarget(self, action: #selector(downloadTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
private func setupConstraints() {
|
||||
dimView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
cardView.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.width.equalToSuperview().multipliedBy(0.78)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(18)
|
||||
make.leading.trailing.equalToSuperview().inset(48)
|
||||
}
|
||||
closeButton.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(titleLabel)
|
||||
make.trailing.equalToSuperview().offset(-20)
|
||||
make.size.equalTo(28)
|
||||
}
|
||||
albumNameLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(18)
|
||||
make.leading.trailing.equalToSuperview().inset(20)
|
||||
}
|
||||
qrImageView.snp.makeConstraints { make in
|
||||
make.top.equalTo(albumNameLabel.snp.bottom).offset(18)
|
||||
make.centerX.equalToSuperview()
|
||||
make.size.equalTo(190)
|
||||
}
|
||||
loadingIndicator.snp.makeConstraints { make in
|
||||
make.center.equalTo(qrImageView)
|
||||
}
|
||||
hintLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(qrImageView.snp.bottom).offset(18)
|
||||
make.centerX.equalToSuperview()
|
||||
make.height.equalTo(34)
|
||||
make.leading.greaterThanOrEqualToSuperview().offset(20)
|
||||
}
|
||||
downloadButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(hintLabel.snp.bottom).offset(20)
|
||||
make.leading.trailing.equalToSuperview().inset(20)
|
||||
make.height.equalTo(46)
|
||||
make.bottom.equalToSuperview().offset(-18)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyState() {
|
||||
albumNameLabel.text = state.albumName.isEmpty ? "未命名相册" : state.albumName
|
||||
if state.isLoading {
|
||||
loadingIndicator.startAnimating()
|
||||
qrImageView.image = nil
|
||||
} else {
|
||||
loadingIndicator.stopAnimating()
|
||||
if let url = URL(string: state.qrImageUrl), !state.qrImageUrl.isEmpty {
|
||||
qrImageView.kf.setImage(with: url)
|
||||
} else {
|
||||
qrImageView.image = UIImage(systemName: "qrcode")
|
||||
qrImageView.tintColor = AppColor.textSecondary
|
||||
}
|
||||
}
|
||||
downloadButton.isEnabled = !state.isLoading && !state.qrImageUrl.isEmpty
|
||||
downloadButton.alpha = downloadButton.isEnabled ? 1 : 0.5
|
||||
}
|
||||
|
||||
func updateDownloading(_ isDownloading: Bool) {
|
||||
downloadButton.setTitle(isDownloading ? "保存中..." : "下载图片", for: .normal)
|
||||
downloadButton.isEnabled = !isDownloading
|
||||
}
|
||||
|
||||
@objc private func closeTapped() {
|
||||
onClose?()
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
@objc private func downloadTapped() {
|
||||
onDownload?()
|
||||
}
|
||||
}
|
||||
479
suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift
Normal file
479
suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift
Normal file
@ -0,0 +1,479 @@
|
||||
//
|
||||
// TravelAlbumDetailViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Kingfisher
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 相册详情管理页,对齐 Android `TravelAlbumDetailScreen`。
|
||||
final class TravelAlbumDetailViewController: BaseViewController {
|
||||
private let viewModel: TravelAlbumDetailViewModel
|
||||
private let api: any TravelAlbumServing
|
||||
|
||||
private let scrollContainer = UIView()
|
||||
private let infoCard = TravelAlbumInfoCard()
|
||||
private let manageCard = UIView()
|
||||
private let tabStack = UIStackView()
|
||||
private let allTabButton = UIButton(type: .system)
|
||||
private let purchasedTabButton = UIButton(type: .system)
|
||||
private let sortButton = UIButton(type: .system)
|
||||
private let selectButton = UIButton(type: .system)
|
||||
private var collectionView: UICollectionView!
|
||||
private var dataSource: UICollectionViewDiffableDataSource<Int, TravelAlbumMaterial>!
|
||||
private let bottomBar = UIView()
|
||||
private let deleteSelectedButton = UIButton(type: .system)
|
||||
private let uploadButton = UIButton(type: .system)
|
||||
|
||||
init(albumId: Int, api: any TravelAlbumServing = NetworkServices.shared.travelAlbumAPI) {
|
||||
viewModel = TravelAlbumDetailViewModel(albumId: albumId)
|
||||
self.api = api
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "相册管理"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
image: UIImage(systemName: "ellipsis"),
|
||||
menu: UIMenu(children: [
|
||||
UIAction(title: "删除相册", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in
|
||||
self?.confirmDeleteAlbum()
|
||||
},
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = AppColor.pageBackground
|
||||
|
||||
manageCard.backgroundColor = .white
|
||||
manageCard.layer.cornerRadius = 12
|
||||
manageCard.layer.shadowColor = UIColor.black.withAlphaComponent(0.08).cgColor
|
||||
manageCard.layer.shadowOpacity = 1
|
||||
manageCard.layer.shadowRadius = 6
|
||||
manageCard.layer.shadowOffset = CGSize(width: 0, height: 2)
|
||||
|
||||
tabStack.axis = .horizontal
|
||||
tabStack.spacing = 8
|
||||
configurePillButton(allTabButton)
|
||||
configurePillButton(purchasedTabButton)
|
||||
configureIconButton(sortButton, image: UIImage(systemName: "line.3.horizontal.decrease.circle.fill"))
|
||||
configureIconButton(selectButton, image: UIImage(systemName: "circle"))
|
||||
|
||||
collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
|
||||
collectionView.backgroundColor = .white
|
||||
collectionView.delegate = self
|
||||
collectionView.register(TravelAlbumMaterialCell.self, forCellWithReuseIdentifier: TravelAlbumMaterialCell.reuseIdentifier)
|
||||
dataSource = UICollectionViewDiffableDataSource<Int, TravelAlbumMaterial>(collectionView: collectionView) {
|
||||
[weak self] collectionView, indexPath, material in
|
||||
let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: TravelAlbumMaterialCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as! TravelAlbumMaterialCell
|
||||
cell.apply(
|
||||
material: material,
|
||||
selectionMode: self?.viewModel.isSelectionMode == true,
|
||||
selected: self?.viewModel.selectedMaterialIds.contains(material.id) == true
|
||||
)
|
||||
return cell
|
||||
}
|
||||
|
||||
bottomBar.backgroundColor = AppColor.pageBackground
|
||||
configureBottomButton(deleteSelectedButton, title: "删除选中(0)", color: UIColor(hex: 0xE53935))
|
||||
configureBottomButton(uploadButton, title: "上传照片", color: AppColor.primary)
|
||||
deleteSelectedButton.isHidden = true
|
||||
|
||||
view.addSubview(scrollContainer)
|
||||
scrollContainer.addSubview(infoCard)
|
||||
scrollContainer.addSubview(manageCard)
|
||||
manageCard.addSubview(tabStack)
|
||||
tabStack.addArrangedSubview(allTabButton)
|
||||
tabStack.addArrangedSubview(purchasedTabButton)
|
||||
manageCard.addSubview(sortButton)
|
||||
manageCard.addSubview(selectButton)
|
||||
manageCard.addSubview(collectionView)
|
||||
view.addSubview(bottomBar)
|
||||
bottomBar.addSubview(deleteSelectedButton)
|
||||
bottomBar.addSubview(uploadButton)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
bottomBar.snp.makeConstraints { make in
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
deleteSelectedButton.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(12)
|
||||
make.leading.trailing.equalToSuperview().inset(20)
|
||||
make.height.equalTo(48)
|
||||
}
|
||||
uploadButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(deleteSelectedButton.snp.bottom).offset(8)
|
||||
make.leading.trailing.equalTo(deleteSelectedButton)
|
||||
make.height.equalTo(48)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
|
||||
}
|
||||
scrollContainer.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
make.bottom.equalTo(bottomBar.snp.top).offset(-8)
|
||||
}
|
||||
infoCard.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview()
|
||||
make.height.greaterThanOrEqualTo(76)
|
||||
}
|
||||
manageCard.snp.makeConstraints { make in
|
||||
make.top.equalTo(infoCard.snp.bottom).offset(12)
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
tabStack.snp.makeConstraints { make in
|
||||
make.top.leading.equalToSuperview().offset(12)
|
||||
make.height.equalTo(32)
|
||||
}
|
||||
sortButton.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(tabStack)
|
||||
make.trailing.equalTo(selectButton.snp.leading).offset(-8)
|
||||
make.size.equalTo(32)
|
||||
}
|
||||
selectButton.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(tabStack)
|
||||
make.trailing.equalToSuperview().offset(-12)
|
||||
make.size.equalTo(32)
|
||||
}
|
||||
collectionView.snp.makeConstraints { make in
|
||||
make.top.equalTo(tabStack.snp.bottom).offset(12)
|
||||
make.leading.trailing.bottom.equalToSuperview().inset(12)
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
infoCard.onCall = { [weak self] phone in self?.call(phone) }
|
||||
allTabButton.addTarget(self, action: #selector(allTabTapped), for: .touchUpInside)
|
||||
purchasedTabButton.addTarget(self, action: #selector(purchasedTabTapped), for: .touchUpInside)
|
||||
sortButton.addTarget(self, action: #selector(sortTapped), for: .touchUpInside)
|
||||
selectButton.addTarget(self, action: #selector(selectTapped), for: .touchUpInside)
|
||||
deleteSelectedButton.addTarget(self, action: #selector(deleteSelectedTapped), for: .touchUpInside)
|
||||
uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside)
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.applyViewModel() }
|
||||
}
|
||||
viewModel.onShowMessage = { [weak self] message in
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
viewModel.onDeletedAlbum = { [weak self] _ in
|
||||
Task { @MainActor in self?.navigationController?.popViewController(animated: true) }
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
Task { await viewModel.refreshAll(api: api) }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyViewModel() {
|
||||
if let album = viewModel.album {
|
||||
infoCard.apply(album: album)
|
||||
}
|
||||
configureTabButton(allTabButton, title: "全部照片\(viewModel.allPhotoCount)", selected: viewModel.selectedTab == .all)
|
||||
configureTabButton(purchasedTabButton, title: "已购照片", selected: viewModel.selectedTab == .purchased)
|
||||
selectButton.isHidden = viewModel.selectedTab != .all
|
||||
selectButton.setImage(UIImage(systemName: viewModel.isSelectionMode ? "checkmark.circle.fill" : "circle"), for: .normal)
|
||||
selectButton.tintColor = viewModel.isSelectionMode ? AppColor.primary : AppColor.textTertiary
|
||||
deleteSelectedButton.isHidden = !(viewModel.isSelectionMode && !viewModel.selectedMaterialIds.isEmpty)
|
||||
deleteSelectedButton.setTitle("删除选中(\(viewModel.selectedMaterialIds.count))", for: .normal)
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbumMaterial>()
|
||||
snapshot.appendSections([0])
|
||||
snapshot.appendItems(viewModel.materials)
|
||||
dataSource.apply(snapshot, animatingDifferences: true)
|
||||
|
||||
if viewModel.isLoading && viewModel.album == nil {
|
||||
showLoading()
|
||||
} else {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
private func configurePillButton(_ button: UIButton) {
|
||||
button.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium)
|
||||
button.layer.cornerRadius = 16
|
||||
button.contentEdgeInsets = UIEdgeInsets(top: 7, left: 14, bottom: 7, right: 14)
|
||||
}
|
||||
|
||||
private func configureTabButton(_ button: UIButton, title: String, selected: Bool) {
|
||||
button.setTitle(title, for: .normal)
|
||||
button.backgroundColor = selected ? AppColor.primary : UIColor(hex: 0xEAF4FF)
|
||||
button.setTitleColor(selected ? .white : AppColor.primary, for: .normal)
|
||||
}
|
||||
|
||||
private func configureIconButton(_ button: UIButton, image: UIImage?) {
|
||||
button.setImage(image, for: .normal)
|
||||
button.backgroundColor = UIColor(hex: 0xEAF4FF)
|
||||
button.tintColor = AppColor.primary
|
||||
button.layer.cornerRadius = 16
|
||||
}
|
||||
|
||||
private func configureBottomButton(_ button: UIButton, title: String, color: UIColor) {
|
||||
button.setTitle(title, for: .normal)
|
||||
button.setTitleColor(.white, for: .normal)
|
||||
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
||||
button.backgroundColor = color
|
||||
button.layer.cornerRadius = 10
|
||||
}
|
||||
|
||||
private func makeLayout() -> UICollectionViewCompositionalLayout {
|
||||
UICollectionViewCompositionalLayout { _, environment in
|
||||
let spacing: CGFloat = 8
|
||||
let width = (environment.container.effectiveContentSize.width - spacing * 2) / 3
|
||||
let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(width), heightDimension: .absolute(width + 38))
|
||||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(width + 38))
|
||||
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, repeatingSubitem: item, count: 3)
|
||||
group.interItemSpacing = .fixed(spacing)
|
||||
let section = NSCollectionLayoutSection(group: group)
|
||||
section.interGroupSpacing = 12
|
||||
return section
|
||||
}
|
||||
}
|
||||
|
||||
private func call(_ phone: String) {
|
||||
guard !phone.isEmpty, let url = URL(string: "tel:\(phone)") else { return }
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
|
||||
private func confirmDeleteAlbum() {
|
||||
let alert = UIAlertController(title: "删除相册", message: "确定删除该相册吗?已有购买素材的相册无法删除。", preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { await self.viewModel.deleteAlbum(api: self.api) }
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
@objc private func allTabTapped() {
|
||||
Task { await viewModel.selectTab(.all, api: api) }
|
||||
}
|
||||
|
||||
@objc private func purchasedTabTapped() {
|
||||
Task { await viewModel.selectTab(.purchased, api: api) }
|
||||
}
|
||||
|
||||
@objc private func sortTapped() {
|
||||
let alert = UIAlertController(title: "排序", message: nil, preferredStyle: .actionSheet)
|
||||
TravelAlbumDetailViewModel.SortOption.allCases.forEach { option in
|
||||
alert.addAction(UIAlertAction(title: option.title, style: .default) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { await self.viewModel.setSortOption(option, api: self.api) }
|
||||
})
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
@objc private func selectTapped() {
|
||||
viewModel.toggleSelectionMode()
|
||||
}
|
||||
|
||||
@objc private func deleteSelectedTapped() {
|
||||
let count = viewModel.selectedMaterialIds.count
|
||||
let alert = UIAlertController(title: "删除素材", message: "确定删除选中的 \(count) 张素材吗?", preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { await self.viewModel.deleteSelectedMaterials(api: self.api) }
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
@objc private func uploadTapped() {
|
||||
let album = viewModel.album
|
||||
let controller = WiredCameraTransferViewController(
|
||||
viewModel: WiredCameraTransferViewModel(
|
||||
albumId: album?.id ?? viewModel.albumId,
|
||||
albumTitle: album?.name ?? "",
|
||||
headerPhone: album?.displayPhone ?? ""
|
||||
)
|
||||
)
|
||||
navigationController?.pushViewController(controller, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension TravelAlbumDetailViewController: UICollectionViewDelegate {
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
guard let material = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||
viewModel.toggleMaterialSelection(material)
|
||||
}
|
||||
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
let visible = collectionView.indexPathsForVisibleItems.map(\.item).max() ?? 0
|
||||
Task { await viewModel.loadMoreIfNeeded(lastVisibleIndex: visible, api: api) }
|
||||
}
|
||||
}
|
||||
|
||||
/// 旅拍相册详情信息卡。
|
||||
private final class TravelAlbumInfoCard: UIView {
|
||||
var onCall: ((String) -> Void)?
|
||||
private var phone = ""
|
||||
|
||||
private let iconView = UIImageView(image: UIImage(systemName: "checklist"))
|
||||
private let phoneLabel = UILabel()
|
||||
private let timeLabel = UILabel()
|
||||
private let callButton = UIButton(type: .system)
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white
|
||||
layer.cornerRadius = 12
|
||||
layer.shadowColor = UIColor.black.withAlphaComponent(0.08).cgColor
|
||||
layer.shadowOpacity = 1
|
||||
layer.shadowRadius = 6
|
||||
layer.shadowOffset = CGSize(width: 0, height: 2)
|
||||
|
||||
let iconBox = UIView()
|
||||
iconBox.backgroundColor = AppColor.primary
|
||||
iconBox.layer.cornerRadius = 10
|
||||
iconView.tintColor = .white
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
phoneLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
phoneLabel.textColor = AppColor.textPrimary
|
||||
timeLabel.font = .systemFont(ofSize: 12)
|
||||
timeLabel.textColor = AppColor.textTertiary
|
||||
callButton.setImage(UIImage(systemName: "phone.fill"), for: .normal)
|
||||
callButton.tintColor = AppColor.primary
|
||||
callButton.backgroundColor = UIColor(hex: 0xEAF4FF)
|
||||
callButton.layer.cornerRadius = 20
|
||||
|
||||
addSubview(iconBox)
|
||||
iconBox.addSubview(iconView)
|
||||
addSubview(phoneLabel)
|
||||
addSubview(timeLabel)
|
||||
addSubview(callButton)
|
||||
iconBox.snp.makeConstraints { make in
|
||||
make.leading.top.bottom.equalToSuperview().inset(14)
|
||||
make.size.equalTo(48)
|
||||
}
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.size.equalTo(28)
|
||||
}
|
||||
phoneLabel.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(16)
|
||||
make.leading.equalTo(iconBox.snp.trailing).offset(12)
|
||||
make.trailing.equalTo(callButton.snp.leading).offset(-12)
|
||||
}
|
||||
timeLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(phoneLabel.snp.bottom).offset(6)
|
||||
make.leading.trailing.equalTo(phoneLabel)
|
||||
make.bottom.equalToSuperview().offset(-16)
|
||||
}
|
||||
callButton.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().offset(-14)
|
||||
make.centerY.equalToSuperview()
|
||||
make.size.equalTo(40)
|
||||
}
|
||||
callButton.addTarget(self, action: #selector(callTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(album: TravelAlbum) {
|
||||
phone = album.displayPhone
|
||||
phoneLabel.text = "手机号 \(TravelAlbumDisplayFormatter.maskPhone(album.displayPhone))"
|
||||
timeLabel.text = "创建时间 \(album.createdAt)"
|
||||
}
|
||||
|
||||
@objc private func callTapped() {
|
||||
onCall?(phone)
|
||||
}
|
||||
}
|
||||
|
||||
/// 旅拍相册素材网格 cell。
|
||||
private final class TravelAlbumMaterialCell: UICollectionViewCell {
|
||||
static let reuseIdentifier = "TravelAlbumMaterialCell"
|
||||
|
||||
private let imageView = UIImageView()
|
||||
private let statusLabel = UILabel()
|
||||
private let checkImageView = UIImageView()
|
||||
private let nameLabel = UILabel()
|
||||
private let sizeLabel = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.clipsToBounds = true
|
||||
imageView.layer.cornerRadius = 8
|
||||
statusLabel.text = "已上传"
|
||||
statusLabel.font = .systemFont(ofSize: 10)
|
||||
statusLabel.textColor = .white
|
||||
statusLabel.backgroundColor = UIColor(hex: 0x34C759)
|
||||
statusLabel.layer.cornerRadius = 4
|
||||
statusLabel.clipsToBounds = true
|
||||
statusLabel.textAlignment = .center
|
||||
checkImageView.tintColor = .white
|
||||
checkImageView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
||||
checkImageView.layer.cornerRadius = 10
|
||||
nameLabel.font = .systemFont(ofSize: 11)
|
||||
nameLabel.textColor = AppColor.textPrimary
|
||||
nameLabel.lineBreakMode = .byTruncatingMiddle
|
||||
sizeLabel.font = .systemFont(ofSize: 10)
|
||||
sizeLabel.textColor = AppColor.textTertiary
|
||||
|
||||
contentView.addSubview(imageView)
|
||||
imageView.addSubview(statusLabel)
|
||||
imageView.addSubview(checkImageView)
|
||||
contentView.addSubview(nameLabel)
|
||||
contentView.addSubview(sizeLabel)
|
||||
imageView.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview()
|
||||
make.height.equalTo(imageView.snp.width)
|
||||
}
|
||||
statusLabel.snp.makeConstraints { make in
|
||||
make.top.leading.equalToSuperview().offset(4)
|
||||
make.height.equalTo(18)
|
||||
make.width.equalTo(48)
|
||||
}
|
||||
checkImageView.snp.makeConstraints { make in
|
||||
make.top.trailing.equalToSuperview().inset(4)
|
||||
make.size.equalTo(20)
|
||||
}
|
||||
nameLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(imageView.snp.bottom).offset(4)
|
||||
make.leading.trailing.equalToSuperview()
|
||||
}
|
||||
sizeLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(nameLabel.snp.bottom).offset(2)
|
||||
make.leading.trailing.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(material: TravelAlbumMaterial, selectionMode: Bool, selected: Bool) {
|
||||
let urlString = material.coverUrl.isEmpty ? material.fileUrl : material.coverUrl
|
||||
if let url = URL(string: urlString), !urlString.isEmpty {
|
||||
imageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo"))
|
||||
} else {
|
||||
imageView.image = UIImage(systemName: "photo")
|
||||
imageView.tintColor = AppColor.primary
|
||||
imageView.backgroundColor = UIColor(hex: 0xEAF2FF)
|
||||
}
|
||||
checkImageView.isHidden = !selectionMode
|
||||
checkImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||
checkImageView.tintColor = selected ? AppColor.primary : .white
|
||||
nameLabel.text = material.fileName
|
||||
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize)
|
||||
}
|
||||
}
|
||||
456
suixinkan/UI/TravelAlbum/TravelAlbumEntryViewController.swift
Normal file
456
suixinkan/UI/TravelAlbum/TravelAlbumEntryViewController.swift
Normal file
@ -0,0 +1,456 @@
|
||||
//
|
||||
// TravelAlbumEntryViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Kingfisher
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 新增相册入口页,对齐 Android `TravelAlbumEntryScreen`。
|
||||
final class TravelAlbumEntryViewController: BaseViewController {
|
||||
private let viewModel = TravelAlbumEntryViewModel()
|
||||
private let api: any TravelAlbumServing
|
||||
|
||||
private let heroCard = TravelAlbumHeroCard()
|
||||
private let titleLabel = UILabel()
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
private let emptyView = TravelAlbumEmptyView()
|
||||
private var dataSource: UITableViewDiffableDataSource<Int, TravelAlbum>!
|
||||
|
||||
init(api: any TravelAlbumServing = NetworkServices.shared.travelAlbumAPI) {
|
||||
self.api = api
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "新增相册"
|
||||
navigationController?.setNavigationBarHidden(false, animated: false)
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = .white
|
||||
|
||||
titleLabel.font = .systemFont(ofSize: 18, weight: .bold)
|
||||
titleLabel.textColor = .black
|
||||
|
||||
tableView.backgroundColor = .white
|
||||
tableView.separatorStyle = .none
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.estimatedRowHeight = 132
|
||||
tableView.delegate = self
|
||||
tableView.register(TravelAlbumTaskCell.self, forCellReuseIdentifier: TravelAlbumTaskCell.reuseIdentifier)
|
||||
|
||||
dataSource = UITableViewDiffableDataSource<Int, TravelAlbum>(tableView: tableView) {
|
||||
[weak self] tableView, indexPath, album in
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: TravelAlbumTaskCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as! TravelAlbumTaskCell
|
||||
cell.apply(album: album)
|
||||
cell.onShootUpload = { [weak self] in self?.pushWiredTransfer(album: album) }
|
||||
cell.onAlbumCode = { [weak self] in self?.loadAlbumCode(album) }
|
||||
cell.onShare = { [weak self] in self?.showShareSheet() }
|
||||
return cell
|
||||
}
|
||||
|
||||
view.addSubview(heroCard)
|
||||
view.addSubview(titleLabel)
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(emptyView)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
heroCard.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(20)
|
||||
make.leading.trailing.equalToSuperview().inset(20)
|
||||
make.height.equalTo(108)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(heroCard.snp.bottom).offset(28)
|
||||
make.leading.trailing.equalToSuperview().inset(20)
|
||||
}
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(8)
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
emptyView.snp.makeConstraints { make in
|
||||
make.top.equalTo(heroCard.snp.bottom)
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
heroCard.addTarget(self, action: #selector(createTapped), for: .touchUpInside)
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.applyViewModel() }
|
||||
}
|
||||
viewModel.onShowMessage = { [weak self] message in
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
Task { await viewModel.loadAlbums(api: api) }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyViewModel() {
|
||||
heroCard.isLoading = viewModel.isCreating
|
||||
titleLabel.text = "我的任务(\(viewModel.albumTotal))"
|
||||
titleLabel.isHidden = viewModel.albums.isEmpty
|
||||
tableView.isHidden = viewModel.albums.isEmpty
|
||||
emptyView.isHidden = !viewModel.albums.isEmpty || viewModel.isLoading
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbum>()
|
||||
snapshot.appendSections([0])
|
||||
snapshot.appendItems(viewModel.albums)
|
||||
dataSource.apply(snapshot, animatingDifferences: true)
|
||||
if viewModel.isLoading && viewModel.albums.isEmpty {
|
||||
showLoading()
|
||||
} else {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func createTapped() {
|
||||
Task {
|
||||
await viewModel.openCreateSheet(api: api)
|
||||
await MainActor.run {
|
||||
guard viewModel.isCreateSheetVisible, presentedViewController == nil else { return }
|
||||
let sheet = CreateTravelAlbumSheetViewController(viewModel: viewModel, api: api)
|
||||
sheet.onDismissed = { [weak self] in self?.viewModel.dismissCreateSheet() }
|
||||
present(sheet, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadAlbumCode(_ album: TravelAlbum) {
|
||||
Task {
|
||||
await viewModel.openAlbumCode(album: album, api: api)
|
||||
await MainActor.run { self.presentAlbumCodeIfNeeded() }
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func presentAlbumCodeIfNeeded() {
|
||||
guard let state = viewModel.albumCodeState else { return }
|
||||
let controller = TravelAlbumCodeDialogViewController(state: state)
|
||||
controller.onClose = { [weak self] in self?.viewModel.dismissAlbumCode() }
|
||||
controller.onDownload = { [weak self, weak controller] in
|
||||
guard let self, let url = self.viewModel.albumCodeState?.qrImageUrl, !url.isEmpty else { return }
|
||||
self.viewModel.setDownloadingQRCode(true)
|
||||
controller?.updateDownloading(true)
|
||||
Task {
|
||||
do {
|
||||
try await ScenicQueueQRCodeSaver.saveImage(from: url)
|
||||
await MainActor.run { self.showToast("已保存到相册") }
|
||||
} catch {
|
||||
await MainActor.run { self.showToast(error.localizedDescription) }
|
||||
}
|
||||
await MainActor.run {
|
||||
self.viewModel.setDownloadingQRCode(false)
|
||||
controller?.updateDownloading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
present(controller, animated: true)
|
||||
}
|
||||
|
||||
private func showShareSheet() {
|
||||
let sheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
|
||||
sheet.addAction(UIAlertAction(title: "微信好友", style: .default) { [weak self] _ in
|
||||
self?.showToast("已选择微信好友")
|
||||
})
|
||||
sheet.addAction(UIAlertAction(title: "QQ好友", style: .default) { [weak self] _ in
|
||||
self?.showToast("已选择QQ好友")
|
||||
})
|
||||
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
present(sheet, animated: true)
|
||||
}
|
||||
|
||||
private func pushWiredTransfer(album: TravelAlbum) {
|
||||
let controller = WiredCameraTransferViewController(
|
||||
viewModel: WiredCameraTransferViewModel(
|
||||
albumId: album.id,
|
||||
albumTitle: album.name,
|
||||
headerPhone: album.displayPhone
|
||||
)
|
||||
)
|
||||
navigationController?.pushViewController(controller, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension TravelAlbumEntryViewController: UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard let album = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||
navigationController?.pushViewController(TravelAlbumDetailViewController(albumId: album.id), animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// 新建相册任务蓝色入口卡片。
|
||||
private final class TravelAlbumHeroCard: UIControl {
|
||||
private let gradientLayer = CAGradientLayer()
|
||||
private let plusView = UIImageView(image: UIImage(systemName: "plus"))
|
||||
private let titleLabel = UILabel()
|
||||
private let subtitleLabel = UILabel()
|
||||
private let arrowLabel = UILabel()
|
||||
|
||||
var isLoading = false {
|
||||
didSet { titleLabel.text = isLoading ? "新建中..." : "新建相册任务" }
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
layer.cornerRadius = 10
|
||||
clipsToBounds = true
|
||||
gradientLayer.colors = [UIColor(hex: 0x007BFF).cgColor, UIColor(hex: 0x3A91FF).cgColor]
|
||||
gradientLayer.startPoint = CGPoint(x: 0, y: 0.5)
|
||||
gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
|
||||
layer.insertSublayer(gradientLayer, at: 0)
|
||||
|
||||
plusView.tintColor = AppColor.primary
|
||||
plusView.contentMode = .scaleAspectFit
|
||||
plusView.backgroundColor = .white
|
||||
plusView.layer.cornerRadius = 17
|
||||
|
||||
titleLabel.text = "新建相册任务"
|
||||
titleLabel.font = .systemFont(ofSize: 19, weight: .medium)
|
||||
titleLabel.textColor = .white
|
||||
subtitleLabel.text = "创建一个新的相册拍摄任务"
|
||||
subtitleLabel.font = .systemFont(ofSize: 14)
|
||||
subtitleLabel.textColor = .white
|
||||
arrowLabel.text = "›"
|
||||
arrowLabel.textColor = .white
|
||||
arrowLabel.font = .systemFont(ofSize: 36, weight: .light)
|
||||
|
||||
addSubview(plusView)
|
||||
addSubview(titleLabel)
|
||||
addSubview(subtitleLabel)
|
||||
addSubview(arrowLabel)
|
||||
plusView.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(28)
|
||||
make.centerY.equalToSuperview()
|
||||
make.size.equalTo(34)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(30)
|
||||
make.leading.equalTo(plusView.snp.trailing).offset(16)
|
||||
make.trailing.lessThanOrEqualTo(arrowLabel.snp.leading).offset(-8)
|
||||
}
|
||||
subtitleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(8)
|
||||
make.leading.equalTo(titleLabel)
|
||||
make.trailing.lessThanOrEqualTo(arrowLabel.snp.leading).offset(-8)
|
||||
}
|
||||
arrowLabel.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().offset(-18)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
gradientLayer.frame = bounds
|
||||
}
|
||||
}
|
||||
|
||||
/// 旅拍相册任务列表 cell。
|
||||
private final class TravelAlbumTaskCell: UITableViewCell {
|
||||
static let reuseIdentifier = "TravelAlbumTaskCell"
|
||||
|
||||
var onShootUpload: (() -> Void)?
|
||||
var onAlbumCode: (() -> Void)?
|
||||
var onShare: (() -> Void)?
|
||||
|
||||
private let cardView = UIView()
|
||||
private let coverView = UIImageView()
|
||||
private let iconView = UIImageView(image: UIImage(systemName: "checklist"))
|
||||
private let nameLabel = UILabel()
|
||||
private let timeLabel = UILabel()
|
||||
private let phoneLabel = UILabel()
|
||||
private let shareButton = UIButton(type: .system)
|
||||
private let codeButton = UIButton(type: .system)
|
||||
private let uploadButton = UIButton(type: .system)
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
contentView.backgroundColor = .white
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = 10
|
||||
cardView.layer.shadowColor = UIColor(hex: 0x2F6BFF).withAlphaComponent(0.10).cgColor
|
||||
cardView.layer.shadowOpacity = 1
|
||||
cardView.layer.shadowRadius = 10
|
||||
cardView.layer.shadowOffset = CGSize(width: 0, height: 4)
|
||||
|
||||
coverView.backgroundColor = UIColor(hex: 0xEAF2FF)
|
||||
coverView.layer.cornerRadius = 8
|
||||
coverView.clipsToBounds = true
|
||||
coverView.contentMode = .scaleAspectFill
|
||||
iconView.tintColor = AppColor.primary
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
|
||||
nameLabel.font = .systemFont(ofSize: 16, weight: .bold)
|
||||
nameLabel.textColor = AppColor.textPrimary
|
||||
timeLabel.font = .systemFont(ofSize: 12)
|
||||
timeLabel.textColor = AppColor.textTertiary
|
||||
phoneLabel.font = .systemFont(ofSize: 12)
|
||||
phoneLabel.textColor = AppColor.textTertiary
|
||||
|
||||
shareButton.setImage(UIImage(systemName: "square.and.arrow.up"), for: .normal)
|
||||
shareButton.tintColor = AppColor.primary
|
||||
codeButton.setImage(UIImage(systemName: "qrcode"), for: .normal)
|
||||
codeButton.tintColor = .black
|
||||
uploadButton.setImage(UIImage(systemName: "camera.fill"), for: .normal)
|
||||
uploadButton.setTitle(" 拍摄上传", for: .normal)
|
||||
uploadButton.tintColor = AppColor.primary
|
||||
uploadButton.setTitleColor(AppColor.primary, for: .normal)
|
||||
uploadButton.titleLabel?.font = .systemFont(ofSize: 13, weight: .bold)
|
||||
uploadButton.backgroundColor = UIColor(hex: 0xEAF4FF)
|
||||
uploadButton.layer.cornerRadius = 8
|
||||
|
||||
contentView.addSubview(cardView)
|
||||
cardView.addSubview(coverView)
|
||||
coverView.addSubview(iconView)
|
||||
cardView.addSubview(nameLabel)
|
||||
cardView.addSubview(timeLabel)
|
||||
cardView.addSubview(phoneLabel)
|
||||
cardView.addSubview(shareButton)
|
||||
cardView.addSubview(codeButton)
|
||||
cardView.addSubview(uploadButton)
|
||||
|
||||
cardView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 6, left: 20, bottom: 6, right: 20))
|
||||
}
|
||||
coverView.snp.makeConstraints { make in
|
||||
make.leading.top.bottom.equalToSuperview().inset(10)
|
||||
make.size.equalTo(86)
|
||||
}
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.size.equalTo(42)
|
||||
}
|
||||
shareButton.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(12)
|
||||
make.trailing.equalTo(codeButton.snp.leading).offset(-8)
|
||||
make.size.equalTo(26)
|
||||
}
|
||||
codeButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(shareButton)
|
||||
make.trailing.equalToSuperview().offset(-10)
|
||||
make.size.equalTo(26)
|
||||
}
|
||||
nameLabel.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(14)
|
||||
make.leading.equalTo(coverView.snp.trailing).offset(12)
|
||||
make.trailing.equalTo(shareButton.snp.leading).offset(-8)
|
||||
}
|
||||
timeLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(nameLabel.snp.bottom).offset(7)
|
||||
make.leading.trailing.equalTo(nameLabel)
|
||||
}
|
||||
phoneLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(timeLabel.snp.bottom).offset(5)
|
||||
make.leading.trailing.equalTo(nameLabel)
|
||||
}
|
||||
uploadButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(phoneLabel.snp.bottom).offset(9)
|
||||
make.trailing.equalToSuperview().offset(-10)
|
||||
make.bottom.equalToSuperview().offset(-10)
|
||||
make.height.equalTo(34)
|
||||
make.width.greaterThanOrEqualTo(104)
|
||||
}
|
||||
|
||||
uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside)
|
||||
shareButton.addTarget(self, action: #selector(shareTapped), for: .touchUpInside)
|
||||
codeButton.addTarget(self, action: #selector(codeTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(album: TravelAlbum) {
|
||||
nameLabel.text = album.name.isEmpty ? "未命名任务" : album.name
|
||||
timeLabel.text = "创建时间:\(TravelAlbumDisplayFormatter.shortTaskTime(album.createdAt))"
|
||||
phoneLabel.text = "手机号:\(album.displayPhone.isEmpty ? "--" : album.displayPhone)"
|
||||
if let url = URL(string: album.coverUrl), !album.coverUrl.isEmpty {
|
||||
iconView.isHidden = true
|
||||
coverView.kf.setImage(with: url)
|
||||
} else {
|
||||
coverView.image = nil
|
||||
iconView.isHidden = false
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func uploadTapped() { onShootUpload?() }
|
||||
@objc private func shareTapped() { onShare?() }
|
||||
@objc private func codeTapped() { onAlbumCode?() }
|
||||
}
|
||||
|
||||
/// 旅拍相册空态视图。
|
||||
private final class TravelAlbumEmptyView: UIView {
|
||||
private let imageView = UIImageView(image: UIImage(systemName: "photo.on.rectangle.angled"))
|
||||
private let titleLabel = UILabel()
|
||||
private let subtitleLabel = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
isHidden = true
|
||||
imageView.tintColor = AppColor.primary
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
titleLabel.text = "暂无任务"
|
||||
titleLabel.font = .systemFont(ofSize: 18, weight: .bold)
|
||||
titleLabel.textColor = .black
|
||||
titleLabel.textAlignment = .center
|
||||
subtitleLabel.text = "点击“新建相册任务”开始创建任务"
|
||||
subtitleLabel.font = .systemFont(ofSize: 14)
|
||||
subtitleLabel.textColor = AppColor.textTertiary
|
||||
subtitleLabel.textAlignment = .center
|
||||
|
||||
let backdrop = UIView()
|
||||
backdrop.backgroundColor = UIColor(hex: 0xF2F7FF)
|
||||
backdrop.layer.cornerRadius = 54
|
||||
addSubview(backdrop)
|
||||
addSubview(imageView)
|
||||
addSubview(titleLabel)
|
||||
addSubview(subtitleLabel)
|
||||
backdrop.snp.makeConstraints { make in
|
||||
make.centerX.equalToSuperview()
|
||||
make.centerY.equalToSuperview().offset(-70)
|
||||
make.width.equalToSuperview().multipliedBy(0.58)
|
||||
make.height.equalTo(108)
|
||||
}
|
||||
imageView.snp.makeConstraints { make in
|
||||
make.center.equalTo(backdrop)
|
||||
make.size.equalTo(82)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(backdrop.snp.bottom).offset(22)
|
||||
make.leading.trailing.equalToSuperview().inset(20)
|
||||
}
|
||||
subtitleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(10)
|
||||
make.leading.trailing.equalTo(titleLabel)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
311
suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift
Normal file
311
suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift
Normal file
@ -0,0 +1,311 @@
|
||||
//
|
||||
// WiredCameraTransferViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 有线相机传输页,仅同步 Android 断开空态 UI,不接入 OTG 或上传业务。
|
||||
final class WiredCameraTransferViewController: BaseViewController {
|
||||
private let viewModel: WiredCameraTransferViewModel
|
||||
|
||||
private let headerView = UIView()
|
||||
private let backButton = UIButton(type: .system)
|
||||
private let titleLabel = UILabel()
|
||||
private let phoneLabel = UILabel()
|
||||
private let statusCard = UIView()
|
||||
private let storagePanel = UIView()
|
||||
private let storageTitleLabel = UILabel()
|
||||
private let storageValueLabel = UILabel()
|
||||
private let statusLabel = UILabel()
|
||||
private let refreshButton = UIButton(type: .system)
|
||||
private let helpLabel = UILabel()
|
||||
private let chipsStack = UIStackView()
|
||||
private let statsCard = UIStackView()
|
||||
private let emptyLabel = UILabel()
|
||||
private let bottomBar = UIView()
|
||||
private let batchButton = UIButton(type: .system)
|
||||
private let specifyButton = UIButton(type: .system)
|
||||
|
||||
init(viewModel: WiredCameraTransferViewModel) {
|
||||
self.viewModel = viewModel
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
navigationController?.setNavigationBarHidden(true, animated: false)
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
navigationController?.setNavigationBarHidden(false, animated: animated)
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = AppColor.pageBackground
|
||||
headerView.backgroundColor = AppColor.primary
|
||||
backButton.setImage(UIImage(systemName: "chevron.left"), for: .normal)
|
||||
backButton.tintColor = .white
|
||||
titleLabel.text = viewModel.albumTitle
|
||||
titleLabel.font = .systemFont(ofSize: 20, weight: .bold)
|
||||
titleLabel.textColor = .white
|
||||
phoneLabel.text = viewModel.headerPhone
|
||||
phoneLabel.font = .systemFont(ofSize: 13)
|
||||
phoneLabel.textColor = UIColor.white.withAlphaComponent(0.92)
|
||||
|
||||
statusCard.backgroundColor = .white
|
||||
statusCard.layer.cornerRadius = 12
|
||||
statusCard.layer.shadowColor = UIColor.black.withAlphaComponent(0.10).cgColor
|
||||
statusCard.layer.shadowOpacity = 1
|
||||
statusCard.layer.shadowRadius = 8
|
||||
statusCard.layer.shadowOffset = CGSize(width: 0, height: 3)
|
||||
|
||||
storagePanel.layer.cornerRadius = 12
|
||||
storagePanel.layer.borderColor = AppColor.border.cgColor
|
||||
storagePanel.layer.borderWidth = 3
|
||||
storageTitleLabel.text = viewModel.deviceModelName
|
||||
storageTitleLabel.font = .systemFont(ofSize: 11, weight: .medium)
|
||||
storageTitleLabel.textColor = AppColor.textPrimary
|
||||
storageTitleLabel.textAlignment = .center
|
||||
storageValueLabel.text = viewModel.availableStorageText
|
||||
storageValueLabel.font = .systemFont(ofSize: 10)
|
||||
storageValueLabel.textColor = AppColor.primary
|
||||
storageValueLabel.textAlignment = .center
|
||||
|
||||
statusLabel.text = viewModel.cameraStatusText
|
||||
statusLabel.font = .systemFont(ofSize: 11, weight: .medium)
|
||||
statusLabel.textColor = AppColor.danger
|
||||
statusLabel.backgroundColor = AppColor.danger.withAlphaComponent(0.10)
|
||||
statusLabel.layer.cornerRadius = 4
|
||||
statusLabel.clipsToBounds = true
|
||||
statusLabel.textAlignment = .center
|
||||
refreshButton.setTitle(viewModel.actionButtonText, for: .normal)
|
||||
refreshButton.setTitleColor(.white, for: .normal)
|
||||
refreshButton.titleLabel?.font = .systemFont(ofSize: 12)
|
||||
refreshButton.backgroundColor = AppColor.danger
|
||||
refreshButton.layer.cornerRadius = 8
|
||||
helpLabel.attributedText = helpText()
|
||||
helpLabel.font = .systemFont(ofSize: 10)
|
||||
helpLabel.numberOfLines = 2
|
||||
|
||||
chipsStack.axis = .horizontal
|
||||
chipsStack.spacing = 6
|
||||
[viewModel.retouchOption, viewModel.photoFormatOption, viewModel.transferModeOption].forEach {
|
||||
chipsStack.addArrangedSubview(makeChip($0))
|
||||
}
|
||||
|
||||
statsCard.axis = .horizontal
|
||||
statsCard.distribution = .fillEqually
|
||||
["全部", "已上传", "失败"].enumerated().forEach { index, title in
|
||||
statsCard.addArrangedSubview(makeStat(title: title, count: viewModel.tabCounts[index], selected: index == 0, danger: index == 2))
|
||||
}
|
||||
|
||||
emptyLabel.text = "暂无照片"
|
||||
emptyLabel.font = .systemFont(ofSize: 14)
|
||||
emptyLabel.textColor = AppColor.textTertiary
|
||||
emptyLabel.textAlignment = .center
|
||||
|
||||
bottomBar.backgroundColor = .white
|
||||
configureBottomButton(batchButton, title: "批量上传", filled: true)
|
||||
configureBottomButton(specifyButton, title: "指定上传", filled: false)
|
||||
|
||||
view.addSubview(headerView)
|
||||
headerView.addSubview(backButton)
|
||||
headerView.addSubview(titleLabel)
|
||||
headerView.addSubview(phoneLabel)
|
||||
view.addSubview(statusCard)
|
||||
statusCard.addSubview(storagePanel)
|
||||
storagePanel.addSubview(storageTitleLabel)
|
||||
storagePanel.addSubview(storageValueLabel)
|
||||
statusCard.addSubview(statusLabel)
|
||||
statusCard.addSubview(refreshButton)
|
||||
statusCard.addSubview(helpLabel)
|
||||
statusCard.addSubview(chipsStack)
|
||||
statusCard.addSubview(statsCard)
|
||||
view.addSubview(emptyLabel)
|
||||
view.addSubview(bottomBar)
|
||||
bottomBar.addSubview(batchButton)
|
||||
bottomBar.addSubview(specifyButton)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
headerView.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview()
|
||||
make.height.equalTo(118)
|
||||
}
|
||||
backButton.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(8)
|
||||
make.bottom.equalToSuperview().offset(-28)
|
||||
make.size.equalTo(44)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(backButton.snp.trailing).offset(4)
|
||||
make.trailing.equalToSuperview().offset(-16)
|
||||
make.top.equalTo(backButton).offset(4)
|
||||
}
|
||||
phoneLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(2)
|
||||
make.leading.trailing.equalTo(titleLabel)
|
||||
}
|
||||
statusCard.snp.makeConstraints { make in
|
||||
make.top.equalTo(headerView.snp.bottom).offset(-10)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
storagePanel.snp.makeConstraints { make in
|
||||
make.top.leading.equalToSuperview().offset(14)
|
||||
make.width.greaterThanOrEqualTo(76)
|
||||
make.height.equalTo(76)
|
||||
}
|
||||
storageTitleLabel.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(10)
|
||||
make.leading.trailing.equalToSuperview().inset(8)
|
||||
}
|
||||
storageValueLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(storageTitleLabel.snp.bottom).offset(6)
|
||||
make.leading.trailing.equalTo(storageTitleLabel)
|
||||
}
|
||||
statusLabel.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(16)
|
||||
make.leading.equalTo(storagePanel.snp.trailing).offset(10)
|
||||
make.height.equalTo(22)
|
||||
make.width.greaterThanOrEqualTo(84)
|
||||
}
|
||||
refreshButton.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(statusLabel)
|
||||
make.trailing.equalToSuperview().offset(-14)
|
||||
make.height.equalTo(34)
|
||||
make.width.greaterThanOrEqualTo(72)
|
||||
}
|
||||
helpLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(statusLabel.snp.bottom).offset(4)
|
||||
make.leading.equalTo(statusLabel)
|
||||
make.trailing.equalTo(refreshButton)
|
||||
}
|
||||
chipsStack.snp.makeConstraints { make in
|
||||
make.top.equalTo(storagePanel.snp.bottom).offset(12)
|
||||
make.leading.trailing.equalToSuperview().inset(14)
|
||||
make.height.equalTo(34)
|
||||
}
|
||||
statsCard.snp.makeConstraints { make in
|
||||
make.top.equalTo(chipsStack.snp.bottom).offset(14)
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
make.height.equalTo(68)
|
||||
}
|
||||
bottomBar.snp.makeConstraints { make in
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
batchButton.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(12)
|
||||
make.leading.equalToSuperview().offset(16)
|
||||
make.height.equalTo(44)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
|
||||
}
|
||||
specifyButton.snp.makeConstraints { make in
|
||||
make.top.height.width.equalTo(batchButton)
|
||||
make.leading.equalTo(batchButton.snp.trailing).offset(12)
|
||||
make.trailing.equalToSuperview().offset(-16)
|
||||
}
|
||||
emptyLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(statusCard.snp.bottom)
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalTo(bottomBar.snp.top)
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
viewModel.onShowMessage = { [weak self] message in
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
|
||||
refreshButton.addTarget(self, action: #selector(uiOnlyTapped), for: .touchUpInside)
|
||||
batchButton.addTarget(self, action: #selector(uiOnlyTapped), for: .touchUpInside)
|
||||
specifyButton.addTarget(self, action: #selector(uiOnlyTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
private func makeChip(_ text: String) -> UIView {
|
||||
let label = UILabel()
|
||||
label.text = text
|
||||
label.font = .systemFont(ofSize: 11)
|
||||
label.textColor = AppColor.textTertiary
|
||||
label.backgroundColor = AppColor.pageBackground
|
||||
label.layer.cornerRadius = 8
|
||||
label.layer.borderWidth = 1
|
||||
label.layer.borderColor = AppColor.border.cgColor
|
||||
label.clipsToBounds = true
|
||||
label.textAlignment = .center
|
||||
return label
|
||||
}
|
||||
|
||||
private func makeStat(title: String, count: Int, selected: Bool, danger: Bool) -> UIView {
|
||||
let container = UIView()
|
||||
let countLabel = UILabel()
|
||||
let titleLabel = UILabel()
|
||||
let underline = UIView()
|
||||
countLabel.text = "\(count)"
|
||||
countLabel.font = .systemFont(ofSize: 18, weight: .semibold)
|
||||
countLabel.textColor = selected ? AppColor.primary : (danger ? AppColor.danger : AppColor.textPrimary)
|
||||
countLabel.textAlignment = .center
|
||||
titleLabel.text = title
|
||||
titleLabel.font = .systemFont(ofSize: 13)
|
||||
titleLabel.textColor = selected ? AppColor.primary : AppColor.textSecondary
|
||||
titleLabel.textAlignment = .center
|
||||
underline.backgroundColor = selected ? AppColor.primary : .clear
|
||||
underline.layer.cornerRadius = 1
|
||||
container.addSubview(countLabel)
|
||||
container.addSubview(titleLabel)
|
||||
container.addSubview(underline)
|
||||
countLabel.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(8)
|
||||
make.leading.trailing.equalToSuperview()
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(countLabel.snp.bottom).offset(2)
|
||||
make.leading.trailing.equalToSuperview()
|
||||
}
|
||||
underline.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(4)
|
||||
make.centerX.equalToSuperview()
|
||||
make.width.equalTo(24)
|
||||
make.height.equalTo(2)
|
||||
}
|
||||
return container
|
||||
}
|
||||
|
||||
private func configureBottomButton(_ button: UIButton, title: String, filled: Bool) {
|
||||
button.setTitle(title, for: .normal)
|
||||
button.titleLabel?.font = .systemFont(ofSize: 15)
|
||||
button.layer.cornerRadius = 10
|
||||
if filled {
|
||||
button.backgroundColor = AppColor.primary
|
||||
button.setTitleColor(.white, for: .normal)
|
||||
} else {
|
||||
button.backgroundColor = .white
|
||||
button.setTitleColor(AppColor.primary, for: .normal)
|
||||
button.layer.borderWidth = 1
|
||||
button.layer.borderColor = AppColor.primary.cgColor
|
||||
}
|
||||
}
|
||||
|
||||
private func helpText() -> NSAttributedString {
|
||||
let text = NSMutableAttributedString(
|
||||
string: "未连接,查看",
|
||||
attributes: [.foregroundColor: AppColor.textSecondary]
|
||||
)
|
||||
text.append(NSAttributedString(string: "《帮助文档》", attributes: [.foregroundColor: AppColor.primary]))
|
||||
return text
|
||||
}
|
||||
|
||||
@objc private func backTapped() {
|
||||
navigationController?.popViewController(animated: true)
|
||||
}
|
||||
|
||||
@objc private func uiOnlyTapped() {
|
||||
viewModel.showUIOnlyMessage()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user