Integrate高德 SDK with simulator-safe build flags, add map views for operating area and punch points, refactor main tabs and key feature screens to UIKit with Diffable lists, and document Swift concurrency defaults in AGENTS.md. Co-authored-by: Cursor <cursoragent@cursor.com>
318 lines
11 KiB
Swift
318 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()
|
|
|
|
private var services: AppServices { AppServices.shared }
|
|
|
|
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 services.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 {
|
|
services.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: services.currentScenicId,
|
|
api: services.punchPointAPI,
|
|
uploadService: services.ossUploadService
|
|
)
|
|
if success {
|
|
services.toastCenter.show("保存成功")
|
|
navigationController?.popViewController(animated: true)
|
|
} else if let message = viewModel.errorMessage {
|
|
services.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)
|
|
}
|
|
}
|
|
}
|