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>
593 lines
25 KiB
Swift
593 lines
25 KiB
Swift
//
|
||
// 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))
|
||
}
|
||
}
|