Files
suixinkan_ios_new/suixinkan/Features/PunchPoint/Views/PunchPointViews.swift
汉秋 0d4b63d273 Add PunchPoint and LocationReport modules with home routing.
Migrate check-in point management and location reporting from placeholders to full MVVM flows, including foreground location support and unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 11:08:41 +08:00

593 lines
25 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// PunchPointViews.swift
// suixinkan
//
// Created by Codex on 2026/6/24.
//
import PhotosUI
import SwiftUI
import UIKit
///
struct PunchPointListView: View {
@Environment(AccountContext.self) private var accountContext
@Environment(AccountContextAPI.self) private var accountContextAPI
@Environment(PunchPointAPI.self) private var punchPointAPI
@Environment(RouterPath.self) private var router
@Environment(ScenicSpotContext.self) private var scenicSpotContext
@Environment(ToastCenter.self) private var toastCenter
@Environment(\.globalLoading) private var globalLoading
@State private var viewModel = PunchPointListViewModel()
var body: some View {
ScrollView {
LazyVStack(spacing: AppMetrics.Spacing.medium) {
filterSection
contentSection
}
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
.padding(.vertical, AppMetrics.Spacing.medium)
}
.background(Color(hex: 0xF5F7FA))
.navigationTitle("打卡点管理")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
router.navigate(to: .home(.punchPointEditor(id: nil)))
} label: {
Image(systemName: "plus")
}
.accessibilityLabel("新增打卡点")
}
}
.refreshable { await reload(showLoading: false) }
.task {
guard viewModel.items.isEmpty else { return }
await reload(showLoading: true)
}
}
///
private var filterSection: some View {
Picker("打卡点状态", selection: $viewModel.selectedFilter) {
ForEach(PunchPointFilter.allCases) { filter in
Text(filter.title).tag(filter)
}
}
.pickerStyle(.segmented)
.onChange(of: viewModel.selectedFilter) { _, newValue in
Task {
await globalLoading.withOptionalLoading(currentScenicId != nil) {
await viewModel.selectFilter(newValue, scenicId: currentScenicId, api: punchPointAPI)
}
}
}
}
///
@ViewBuilder
private var contentSection: some View {
if currentScenicId == nil {
PunchPointEmptyState(title: "缺少景区上下文", message: "请先在首页选择景区后再管理打卡点。")
} else if viewModel.items.isEmpty {
PunchPointEmptyState(title: "暂无打卡点", message: viewModel.errorMessage ?? "当前筛选条件下没有打卡点。")
} else {
ForEach(viewModel.items) { item in
Button {
router.navigate(to: .home(.punchPointDetail(id: item.id, summary: item)))
} label: {
PunchPointCardView(item: item)
}
.buttonStyle(.plain)
.onAppear {
guard item.id == viewModel.items.last?.id else { return }
Task { await loadMore() }
}
}
if viewModel.isLoadingMore {
ProgressView()
.padding(.vertical, AppMetrics.Spacing.medium)
}
}
}
private var currentScenicId: Int? {
accountContext.currentScenic?.id
}
///
private func reload(showLoading: Bool) async {
await globalLoading.withOptionalLoading(showLoading && currentScenicId != nil) {
await viewModel.reload(scenicId: currentScenicId, api: punchPointAPI)
}
}
///
private func loadMore() async {
await viewModel.loadMore(scenicId: currentScenicId, api: punchPointAPI)
}
}
///
struct PunchPointDetailView: View {
let punchPointId: Int
let summary: PunchPointItem?
@Environment(AccountContext.self) private var accountContext
@Environment(AccountContextAPI.self) private var accountContextAPI
@Environment(PunchPointAPI.self) private var punchPointAPI
@Environment(RouterPath.self) private var router
@Environment(ScenicSpotContext.self) private var scenicSpotContext
@Environment(ToastCenter.self) private var toastCenter
@Environment(\.globalLoading) private var globalLoading
@State private var viewModel = PunchPointListViewModel()
@State private var showDeleteConfirm = false
var item: PunchPointItem? {
viewModel.selectedDetail ?? summary
}
var body: some View {
ScrollView {
VStack(spacing: AppMetrics.Spacing.medium) {
if let item {
detailHeader(item)
imageSection(item)
infoSection(item)
} else {
PunchPointEmptyState(title: "暂无详情", message: viewModel.errorMessage ?? "请稍后重试。")
}
}
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
.padding(.vertical, AppMetrics.Spacing.medium)
}
.background(Color(hex: 0xF5F7FA))
.navigationTitle("打卡点详情")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
if let item {
Button {
router.navigate(to: .home(.punchPointQR(id: item.id, title: item.name, qrURL: item.mpQrcode)))
} label: {
Image(systemName: "qrcode")
}
Button {
router.navigate(to: .home(.punchPointEditor(id: item.id)))
} label: {
Image(systemName: "square.and.pencil")
}
Button(role: .destructive) {
showDeleteConfirm = true
} label: {
Image(systemName: "trash")
}
}
}
}
.confirmationDialog("确认删除该打卡点?", isPresented: $showDeleteConfirm, titleVisibility: .visible) {
Button("删除", role: .destructive) {
Task { await deleteCurrentItem() }
}
}
.task {
await globalLoading.withOptionalLoading(summary == nil) {
await viewModel.loadDetail(id: punchPointId, api: punchPointAPI)
}
}
}
///
private func detailHeader(_ item: PunchPointItem) -> some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
HStack {
Text(item.name)
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
Spacer()
Text(item.statusLabel.isEmpty ? "状态 \(item.status)" : item.statusLabel)
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
.foregroundStyle(AppDesign.primary)
.padding(.horizontal, AppMetrics.Spacing.small)
.padding(.vertical, AppMetrics.Spacing.xSmall)
.background(AppDesign.primarySoft, in: Capsule())
}
Text(item.description.isEmpty ? "暂无描述" : item.description)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
///
private func imageSection(_ item: PunchPointItem) -> some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
Text("打卡点图片")
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
if item.guideImages.isEmpty {
Text("暂无图片")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: AppMetrics.Spacing.small) {
ForEach(item.guideImages, id: \.self) { url in
RemoteImage(urlString: url) {
Image(systemName: "photo")
.foregroundStyle(AppDesign.placeholder)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(hex: 0xEEF2F6))
}
.frame(width: 128, height: 92)
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
}
}
}
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
///
private func infoSection(_ item: PunchPointItem) -> some View {
VStack(spacing: AppMetrics.Spacing.small) {
PunchPointInfoRow(title: "地址", value: item.region?.address ?? "暂无")
PunchPointInfoRow(title: "坐标", value: coordinateText(for: item))
PunchPointInfoRow(title: "创建时间", value: nonEmpty(item.createdAt) ?? "暂无")
PunchPointInfoRow(title: "创建人", value: nonEmpty(item.creator) ?? "暂无")
if !item.auditRemark.isEmpty {
PunchPointInfoRow(title: "审核备注", value: item.auditRemark)
}
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
///
private func coordinateText(for item: PunchPointItem) -> String {
guard let region = item.region else { return "暂无" }
return "\(region.lat), \(region.lot)"
}
///
private func deleteCurrentItem() async {
guard let item else { return }
let success = await globalLoading.withLoading {
await viewModel.delete(item, scenicId: accountContext.currentScenic?.id, api: punchPointAPI)
}
if success {
toastCenter.show("删除成功")
await scenicSpotContext.reload(scenicId: accountContext.currentScenic?.id, api: accountContextAPI)
router.path.removeLast()
} else {
toastCenter.show(viewModel.errorMessage ?? "删除失败")
}
}
}
///
struct PunchPointEditorView: View {
let punchPointId: Int?
@Environment(AccountContext.self) private var accountContext
@Environment(AccountContextAPI.self) private var accountContextAPI
@Environment(PunchPointAPI.self) private var punchPointAPI
@Environment(OSSUploadService.self) private var uploadService
@Environment(ScenicSpotContext.self) private var scenicSpotContext
@Environment(ToastCenter.self) private var toastCenter
@Environment(\.dismiss) private var dismiss
@Environment(\.globalLoading) private var globalLoading
@State private var detailLoader = PunchPointListViewModel()
@State private var viewModel = PunchPointEditorViewModel()
@State private var locationProvider = ForegroundLocationProvider()
@State private var selectedItems: [PhotosPickerItem] = []
var body: some View {
ScrollView {
VStack(spacing: AppMetrics.Spacing.medium) {
formSection
locationSection
imageSection
submitButton
}
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
.padding(.vertical, AppMetrics.Spacing.medium)
}
.background(Color(hex: 0xF5F7FA))
.navigationTitle(punchPointId == nil ? "新增打卡点" : "编辑打卡点")
.navigationBarTitleDisplayMode(.inline)
.task {
guard let punchPointId else { return }
await globalLoading.withOptionalLoading(detailLoader.selectedDetail == nil) {
await detailLoader.loadDetail(id: punchPointId, api: punchPointAPI)
if let detail = detailLoader.selectedDetail {
viewModel = PunchPointEditorViewModel(item: detail)
}
}
}
.onChange(of: selectedItems) { _, items in
Task { await loadPickedImages(items) }
}
}
///
private var formSection: some View {
VStack(spacing: AppMetrics.Spacing.small) {
TextField("打卡点名称", text: $viewModel.name)
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
TextField("打卡点描述", text: $viewModel.description, axis: .vertical)
.lineLimit(3, reservesSpace: true)
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: 96)
TextField("打卡点展示名称", text: $viewModel.scenicSpotText)
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
///
private var locationSection: some View {
VStack(spacing: AppMetrics.Spacing.small) {
TextField("地址", text: $viewModel.address)
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
HStack(spacing: AppMetrics.Spacing.small) {
TextField("纬度", text: $viewModel.latitudeText)
.keyboardType(.decimalPad)
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
TextField("经度", text: $viewModel.longitudeText)
.keyboardType(.decimalPad)
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
}
Button {
Task { await locateForPunchPoint() }
} label: {
Label("使用当前位置", systemImage: "location")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
///
private var imageSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
HStack {
Text("打卡点图片")
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
Spacer()
PhotosPicker(selection: $selectedItems, maxSelectionCount: 9, matching: .images) {
Label("选择图片", systemImage: "photo.on.rectangle")
}
}
LazyVGrid(columns: [GridItem(.adaptive(minimum: 92), spacing: AppMetrics.Spacing.small)], spacing: AppMetrics.Spacing.small) {
ForEach(viewModel.remoteImages, id: \.self) { url in
ZStack(alignment: .topTrailing) {
RemoteImage(urlString: url) {
Image(systemName: "photo")
.foregroundStyle(AppDesign.placeholder)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(hex: 0xEEF2F6))
}
.frame(height: 92)
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
imageRemoveButton { viewModel.removeRemoteImage(url) }
}
}
ForEach(viewModel.localImages) { image in
ZStack(alignment: .topTrailing) {
if let uiImage = UIImage(data: image.data) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.frame(height: 92)
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
if image.progress > 0 && image.progress < 100 {
Text("\(image.progress)%")
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
.foregroundStyle(.white)
.padding(4)
.background(.black.opacity(0.55), in: Capsule())
.padding(4)
}
imageRemoveButton { viewModel.removeLocalImage(id: image.id) }
}
}
}
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
///
private var submitButton: some View {
Button {
Task { await submit() }
} label: {
Text(viewModel.isSubmitting ? "提交中..." : "保存")
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.frame(maxWidth: .infinity)
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
}
.buttonStyle(.borderedProminent)
.disabled(viewModel.isSubmitting)
}
///
private func imageRemoveButton(action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.white, AppDesign.textSecondary)
.padding(4)
}
.buttonStyle(.plain)
}
/// 使
private func locateForPunchPoint() async {
do {
let result = try await locationProvider.requestCurrentLocation()
viewModel.applyLocation(latitude: result.latitude, longitude: result.longitude, address: result.address)
} catch {
toastCenter.show("定位失败:\(error.localizedDescription)")
}
}
/// PhotosPicker
private func loadPickedImages(_ items: [PhotosPickerItem]) async {
var images: [PunchPointLocalImage] = []
for item in items {
guard let data = try? await item.loadTransferable(type: Data.self) else { continue }
images.append(PunchPointLocalImage(data: data, fileName: "punch_\(UUID().uuidString).jpg"))
}
viewModel.addLocalImages(images)
selectedItems = []
}
///
private func submit() async {
let success = await globalLoading.withLoading {
await viewModel.submit(scenicId: accountContext.currentScenic?.id, api: punchPointAPI, uploadService: uploadService)
}
if success {
toastCenter.show("保存成功")
await scenicSpotContext.reload(scenicId: accountContext.currentScenic?.id, api: accountContextAPI)
dismiss()
} else {
toastCenter.show(viewModel.errorMessage ?? "保存失败")
}
}
}
///
struct PunchPointQRView: View {
let title: String
let qrURL: String
var body: some View {
VStack(spacing: AppMetrics.Spacing.large) {
Text(title)
.font(.system(size: AppMetrics.FontSize.title2, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
RemoteImage(urlString: qrURL, contentMode: .fit) {
Image(systemName: "qrcode")
.font(.system(size: 96, weight: .semibold))
.foregroundStyle(AppDesign.placeholder)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(hex: 0xEEF2F6))
}
.frame(width: 240, height: 240)
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
Text(qrURL.isEmpty ? "暂无二维码" : qrURL)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, AppMetrics.Spacing.large)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(hex: 0xF5F7FA))
.navigationTitle("打卡点二维码")
.navigationBarTitleDisplayMode(.inline)
}
}
///
private struct PunchPointCardView: View {
let item: PunchPointItem
var body: some View {
HStack(spacing: AppMetrics.Spacing.medium) {
RemoteImage(urlString: item.guideImages.first) {
Image(systemName: "mappin.and.ellipse")
.foregroundStyle(AppDesign.primary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(AppDesign.primarySoft)
}
.frame(width: 82, height: 72)
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
HStack {
Text(item.name)
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
.lineLimit(1)
Spacer()
Text(item.statusLabel.isEmpty ? "状态 \(item.status)" : item.statusLabel)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.primary)
}
Text(item.region?.address ?? "暂无地址")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
.lineLimit(2)
Text(nonEmpty(item.createdAt) ?? "暂无创建时间")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.placeholder)
}
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
}
///
private func nonEmpty(_ text: String) -> String? {
let value = text.trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
///
private struct PunchPointInfoRow: View {
let title: String
let value: String
var body: some View {
HStack(alignment: .top) {
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
.frame(width: 76, alignment: .leading)
Text(value)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textPrimary)
.multilineTextAlignment(.leading)
Spacer(minLength: 0)
}
}
}
///
private struct PunchPointEmptyState: View {
let title: String
let message: String
var body: some View {
VStack(spacing: AppMetrics.Spacing.small) {
Image(systemName: "mappin.slash")
.font(.system(size: 34, weight: .semibold))
.foregroundStyle(AppDesign.placeholder)
Text(title)
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
Text(message)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(AppMetrics.Spacing.xLarge)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
}