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>
This commit is contained in:
592
suixinkan/Features/PunchPoint/Views/PunchPointViews.swift
Normal file
592
suixinkan/Features/PunchPoint/Views/PunchPointViews.swift
Normal file
@ -0,0 +1,592 @@
|
||||
//
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user