Centralize dependency wiring with Session/Context/Network/UI/Runtime bundles, unify leaf ViewControllers on appServices, and add a test initializer with AppServicesTests. Co-authored-by: Cursor <cursoragent@cursor.com>
316 lines
11 KiB
Swift
316 lines
11 KiB
Swift
//
|
|
// PunchPointViewControllers.swift
|
|
// suixinkan
|
|
//
|
|
// Created by Codex on 2026/6/26.
|
|
//
|
|
|
|
import CoreLocation
|
|
import SnapKit
|
|
import UIKit
|
|
|
|
private extension PunchPointItem {
|
|
var displayAddress: String {
|
|
region?.address ?? scenicSpotStr
|
|
}
|
|
}
|
|
|
|
/// 打卡点列表页。
|
|
final class PunchPointListViewController: ModuleTableViewController {
|
|
private let viewModel = PunchPointListViewModel()
|
|
|
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
|
override func viewDidLoad() {
|
|
title = "打卡点"
|
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
|
title: "新建",
|
|
style: .plain,
|
|
target: self,
|
|
action: #selector(createPunchPoint)
|
|
)
|
|
super.viewDidLoad()
|
|
wireViewModel(viewModel) { }
|
|
}
|
|
|
|
/// table行Count相关逻辑。
|
|
override func tableRowCount() -> Int { viewModel.items.count }
|
|
|
|
/// 配置Cell展示内容。
|
|
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
|
let item = viewModel.items[indexPath.row]
|
|
cell.configure(title: item.name, subtitle: item.displayAddress, detail: item.statusLabel)
|
|
}
|
|
|
|
/// didSelectTableRow 回调处理。
|
|
override func didSelectTableRow(at indexPath: IndexPath) {
|
|
let item = viewModel.items[indexPath.row]
|
|
navigationController?.pushViewController(
|
|
PunchPointDetailViewController(punchPointId: item.id, summary: item),
|
|
animated: true
|
|
)
|
|
}
|
|
|
|
/// 刷新Content。
|
|
override func reloadContent() async {
|
|
await viewModel.reload(scenicId: services.currentScenicId, api: services.punchPointAPI)
|
|
}
|
|
|
|
/// 创建打卡Point。
|
|
@objc private func createPunchPoint() {
|
|
navigationController?.pushViewController(PunchPointEditorViewController(punchPointId: nil), animated: true)
|
|
}
|
|
}
|
|
|
|
extension PunchPointListViewModel: ViewModelBindable {}
|
|
|
|
/// 打卡点详情页。
|
|
final class PunchPointDetailViewController: ModuleTableViewController {
|
|
private let punchPointId: Int
|
|
private let summary: PunchPointItem?
|
|
private let viewModel = PunchPointListViewModel()
|
|
|
|
/// 初始化实例。
|
|
init(punchPointId: Int, summary: PunchPointItem?) {
|
|
self.punchPointId = punchPointId
|
|
self.summary = summary
|
|
super.init(nibName: nil, bundle: nil)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
|
override func viewDidLoad() {
|
|
title = summary?.name ?? "打卡点详情"
|
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
|
title: "二维码",
|
|
style: .plain,
|
|
target: self,
|
|
action: #selector(showQR)
|
|
)
|
|
super.viewDidLoad()
|
|
viewModel.onChange = { [weak self] in
|
|
self?.title = self?.viewModel.selectedDetail?.name ?? self?.summary?.name
|
|
self?.reloadTable()
|
|
}
|
|
}
|
|
|
|
/// table行Count相关逻辑。
|
|
override func tableRowCount() -> Int {
|
|
guard viewModel.selectedDetail != nil || summary != nil else { return 0 }
|
|
return 4
|
|
}
|
|
|
|
/// 配置Cell展示内容。
|
|
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
|
let detail = viewModel.selectedDetail ?? summary
|
|
guard let detail else { return }
|
|
switch indexPath.row {
|
|
case 0: cell.configure(title: "名称", subtitle: detail.name)
|
|
case 1: cell.configure(title: "地址", subtitle: detail.displayAddress)
|
|
case 2: cell.configure(title: "状态", subtitle: detail.statusLabel)
|
|
default: cell.configure(title: "创建时间", subtitle: detail.createdAt)
|
|
}
|
|
}
|
|
|
|
/// 刷新Content。
|
|
override func reloadContent() async {
|
|
await viewModel.loadDetail(id: punchPointId, api: services.punchPointAPI)
|
|
}
|
|
|
|
/// 展示QR。
|
|
@objc private func showQR() {
|
|
guard let detail = viewModel.selectedDetail ?? summary else { return }
|
|
navigationController?.pushViewController(
|
|
PunchPointQRViewController(title: detail.name, qrURL: detail.mpQrcode),
|
|
animated: true
|
|
)
|
|
}
|
|
}
|
|
|
|
/// 打卡点编辑页。
|
|
final class PunchPointEditorViewController: UIViewController {
|
|
private let viewModel = PunchPointEditorViewModel()
|
|
private let punchPointId: Int?
|
|
private let nameField = UITextField()
|
|
private let addressField = UITextField()
|
|
private let latitudeField = UITextField()
|
|
private let longitudeField = UITextField()
|
|
private let mapPicker = PunchPointMapPickerView()
|
|
private let locationProvider = ForegroundLocationProvider()
|
|
|
|
init(punchPointId: Int?) {
|
|
self.punchPointId = punchPointId
|
|
super.init(nibName: nil, bundle: nil)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
title = punchPointId == nil ? "新建打卡点" : "编辑打卡点"
|
|
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
|
title: "保存",
|
|
style: .done,
|
|
target: self,
|
|
action: #selector(save)
|
|
)
|
|
|
|
configureFields()
|
|
layoutForm()
|
|
wireViewModel()
|
|
|
|
mapPicker.onLocationPicked = { [weak self] lat, lng, address in
|
|
self?.viewModel.applyLocation(latitude: lat, longitude: lng, address: address)
|
|
self?.syncFieldsFromViewModel()
|
|
}
|
|
|
|
Task {
|
|
guard let punchPointId else { return }
|
|
if let detail = try? await appServices.punchPointAPI.punchPointInfo(id: punchPointId) {
|
|
viewModel.apply(detail)
|
|
syncFieldsFromViewModel()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func configureFields() {
|
|
[nameField, addressField, latitudeField, longitudeField].forEach {
|
|
$0.borderStyle = .roundedRect
|
|
$0.font = .systemFont(ofSize: AppMetrics.FontSize.body)
|
|
}
|
|
nameField.placeholder = "打卡点名称"
|
|
addressField.placeholder = "地址"
|
|
latitudeField.placeholder = "纬度"
|
|
longitudeField.placeholder = "经度"
|
|
latitudeField.keyboardType = .decimalPad
|
|
longitudeField.keyboardType = .decimalPad
|
|
}
|
|
|
|
private func layoutForm() {
|
|
let locateButton = UIButton(type: .system)
|
|
locateButton.setTitle("使用当前位置", for: .normal)
|
|
locateButton.addTarget(self, action: #selector(useCurrentLocation), for: .touchUpInside)
|
|
|
|
let stack = UIStackView(arrangedSubviews: [
|
|
nameField,
|
|
mapPicker,
|
|
addressField,
|
|
latitudeField,
|
|
longitudeField,
|
|
locateButton
|
|
])
|
|
stack.axis = .vertical
|
|
stack.spacing = AppMetrics.Spacing.small
|
|
|
|
view.addSubview(stack)
|
|
nameField.snp.makeConstraints { make in make.height.equalTo(44) }
|
|
mapPicker.snp.makeConstraints { make in make.height.equalTo(220) }
|
|
addressField.snp.makeConstraints { make in make.height.equalTo(44) }
|
|
latitudeField.snp.makeConstraints { make in make.height.equalTo(44) }
|
|
longitudeField.snp.makeConstraints { make in make.height.equalTo(44) }
|
|
locateButton.snp.makeConstraints { make in make.height.equalTo(44) }
|
|
|
|
stack.snp.makeConstraints { make in
|
|
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppMetrics.Spacing.medium)
|
|
make.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.pageHorizontal)
|
|
}
|
|
}
|
|
|
|
private func wireViewModel() {
|
|
viewModel.onChange = { [weak self] in
|
|
self?.syncFieldsFromViewModel()
|
|
}
|
|
}
|
|
|
|
private func syncFieldsFromViewModel() {
|
|
if nameField.text?.isEmpty != false { nameField.text = viewModel.name }
|
|
addressField.text = viewModel.address
|
|
latitudeField.text = viewModel.latitudeText
|
|
longitudeField.text = viewModel.longitudeText
|
|
if let lat = Double(viewModel.latitudeText), let lng = Double(viewModel.longitudeText) {
|
|
mapPicker.setCoordinate(latitude: lat, longitude: lng)
|
|
}
|
|
}
|
|
|
|
@objc private func useCurrentLocation() {
|
|
Task {
|
|
do {
|
|
let result = try await locationProvider.requestCurrentLocation()
|
|
viewModel.applyLocation(
|
|
latitude: result.latitude,
|
|
longitude: result.longitude,
|
|
address: result.address
|
|
)
|
|
syncFieldsFromViewModel()
|
|
} catch {
|
|
appServices.toastCenter.show("定位失败:\(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc private func save() {
|
|
viewModel.name = nameField.text ?? ""
|
|
viewModel.address = addressField.text ?? ""
|
|
viewModel.latitudeText = latitudeField.text ?? ""
|
|
viewModel.longitudeText = longitudeField.text ?? ""
|
|
Task {
|
|
let success = await viewModel.submit(
|
|
scenicId: appServices.currentScenicId,
|
|
api: appServices.punchPointAPI,
|
|
uploadService: appServices.ossUploadService
|
|
)
|
|
if success {
|
|
appServices.toastCenter.show("保存成功")
|
|
navigationController?.popViewController(animated: true)
|
|
} else if let message = viewModel.errorMessage {
|
|
appServices.toastCenter.show(message)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
extension PunchPointEditorViewModel: ViewModelBindable {}
|
|
|
|
/// 打卡点二维码页。
|
|
final class PunchPointQRViewController: UIViewController {
|
|
private let pageTitle: String
|
|
private let qrURL: String
|
|
|
|
/// 初始化实例。
|
|
init(title: String, qrURL: String) {
|
|
pageTitle = title
|
|
self.qrURL = qrURL
|
|
super.init(nibName: nil, bundle: nil)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
title = pageTitle
|
|
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
|
|
|
let urlLabel = UILabel()
|
|
urlLabel.text = qrURL
|
|
urlLabel.numberOfLines = 0
|
|
urlLabel.textAlignment = .center
|
|
urlLabel.textColor = AppDesign.textSecondary
|
|
view.addSubview(urlLabel)
|
|
urlLabel.snp.makeConstraints { make in
|
|
make.center.equalToSuperview()
|
|
make.leading.trailing.equalToSuperview().inset(24)
|
|
}
|
|
}
|
|
}
|