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:
@ -0,0 +1,405 @@
|
||||
//
|
||||
// LocationReportViews.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
/// 位置上报页面,支持当前位置上报、标记点上报、在线状态和提醒设置。
|
||||
struct LocationReportView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(AccountSnapshotStore.self) private var snapshotStore
|
||||
@Environment(LocationReportAPI.self) private var locationReportAPI
|
||||
@Environment(RouterPath.self) private var router
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = LocationReportViewModel()
|
||||
@State private var locationProvider = ForegroundLocationProvider()
|
||||
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||
statusSection
|
||||
currentLocationSection
|
||||
markedLocationSection
|
||||
reminderSection
|
||||
actionSection
|
||||
}
|
||||
.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(.locationReportHistory))
|
||||
} label: {
|
||||
Image(systemName: "clock.arrow.circlepath")
|
||||
}
|
||||
.accessibilityLabel("上报历史")
|
||||
}
|
||||
}
|
||||
.onReceive(timer) { _ in viewModel.tick() }
|
||||
.task {
|
||||
guard viewModel.currentCoordinate == nil else { return }
|
||||
await locateCurrent()
|
||||
}
|
||||
}
|
||||
|
||||
/// 在线状态和倒计时区域。
|
||||
private var statusSection: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||
Text(viewModel.isOnline ? "当前在线" : "当前离线")
|
||||
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
Text(viewModel.countdownText)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
Spacer()
|
||||
Toggle("", isOn: Binding(
|
||||
get: { viewModel.isOnline },
|
||||
set: { value in
|
||||
Task { await toggleOnline(value) }
|
||||
}
|
||||
))
|
||||
.labelsHidden()
|
||||
}
|
||||
if !viewModel.lastReportText.isEmpty {
|
||||
Text("上次上报:\(viewModel.lastReportText)")
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.placeholder)
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
|
||||
/// 当前定位区域。
|
||||
private var currentLocationSection: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
HStack {
|
||||
Text("当前位置")
|
||||
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
|
||||
Spacer()
|
||||
Button("重新定位") {
|
||||
Task { await locateCurrent() }
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
}
|
||||
locationText(coordinate: viewModel.currentCoordinate, address: viewModel.currentAddress)
|
||||
TextField("当前位置地址", text: $viewModel.currentAddress)
|
||||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
|
||||
/// 标记点区域。
|
||||
private var markedLocationSection: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
HStack {
|
||||
Text("标记点")
|
||||
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
|
||||
Spacer()
|
||||
Button("使用当前位置") {
|
||||
if let coordinate = viewModel.currentCoordinate {
|
||||
viewModel.applyMarkedLocation(
|
||||
latitude: coordinate.latitude,
|
||||
longitude: coordinate.longitude,
|
||||
address: viewModel.currentAddress
|
||||
)
|
||||
}
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
}
|
||||
locationText(coordinate: viewModel.markedCoordinate, address: viewModel.markedAddress)
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
TextField("纬度", text: Binding(
|
||||
get: { viewModel.markedCoordinate.map { String($0.latitude) } ?? "" },
|
||||
set: { text in updateMarkedCoordinate(latitudeText: text, longitudeText: nil) }
|
||||
))
|
||||
.keyboardType(.decimalPad)
|
||||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||||
TextField("经度", text: Binding(
|
||||
get: { viewModel.markedCoordinate.map { String($0.longitude) } ?? "" },
|
||||
set: { text in updateMarkedCoordinate(latitudeText: nil, longitudeText: text) }
|
||||
))
|
||||
.keyboardType(.decimalPad)
|
||||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||||
}
|
||||
TextField("标记点地址", text: $viewModel.markedAddress)
|
||||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
|
||||
/// 提醒设置区域。
|
||||
private var reminderSection: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
Text("提醒设置")
|
||||
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
|
||||
Stepper("提前 \(viewModel.reminderMinutes) 分钟提醒", value: $viewModel.reminderMinutes, in: 5...120, step: 5)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
|
||||
/// 上报按钮区域。
|
||||
private var actionSection: some View {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
Button {
|
||||
Task { await submit(type: .immediate) }
|
||||
} label: {
|
||||
Label("立即上报当前位置", systemImage: "location.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button {
|
||||
Task { await submit(type: .marked) }
|
||||
} label: {
|
||||
Label("上报标记点", systemImage: "mappin.and.ellipse")
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.disabled(viewModel.isSubmitting)
|
||||
}
|
||||
|
||||
/// 坐标和地址展示。
|
||||
private func locationText(coordinate: LocationCoordinate?, address: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||
Text(address.isEmpty ? "暂无地址" : address)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
Text(coordinate.map { "\($0.latitude), \($0.longitude)" } ?? "暂无坐标")
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var staffId: Int? {
|
||||
snapshotStore.load()?.businessUserId
|
||||
}
|
||||
|
||||
private var scenicId: Int? {
|
||||
accountContext.currentScenic?.id
|
||||
}
|
||||
|
||||
/// 请求当前位置。
|
||||
private func locateCurrent() async {
|
||||
do {
|
||||
let result = try await locationProvider.requestCurrentLocation()
|
||||
viewModel.applyCurrentLocation(latitude: result.latitude, longitude: result.longitude, address: result.address)
|
||||
} catch {
|
||||
toastCenter.show("定位失败:\(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换在线状态。
|
||||
private func toggleOnline(_ value: Bool) async {
|
||||
let success = await globalLoading.withOptionalLoading(value) {
|
||||
await viewModel.setOnline(value, staffId: staffId, scenicId: scenicId, api: locationReportAPI)
|
||||
}
|
||||
if !success {
|
||||
toastCenter.show(viewModel.errorMessage ?? "状态上报失败")
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交指定类型的位置上报。
|
||||
private func submit(type: LocationReportType) async {
|
||||
let success = await globalLoading.withLoading {
|
||||
await viewModel.submit(type: type, staffId: staffId, scenicId: scenicId, api: locationReportAPI)
|
||||
}
|
||||
toastCenter.show(success ? "上报成功" : (viewModel.errorMessage ?? "上报失败"))
|
||||
}
|
||||
|
||||
/// 手动更新标记点经纬度。
|
||||
private func updateMarkedCoordinate(latitudeText: String?, longitudeText: String?) {
|
||||
let current = viewModel.markedCoordinate
|
||||
let latitude = Double(latitudeText ?? current.map { String($0.latitude) } ?? "")
|
||||
let longitude = Double(longitudeText ?? current.map { String($0.longitude) } ?? "")
|
||||
if let latitude, let longitude {
|
||||
viewModel.applyMarkedLocation(latitude: latitude, longitude: longitude, address: viewModel.markedAddress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 位置上报历史页面,展示历史记录筛选和分页。
|
||||
struct LocationReportHistoryView: View {
|
||||
@Environment(AccountSnapshotStore.self) private var snapshotStore
|
||||
@Environment(LocationReportAPI.self) private var locationReportAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = LocationReportHistoryViewModel()
|
||||
|
||||
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)
|
||||
.refreshable { await reload(showLoading: false) }
|
||||
.task {
|
||||
guard viewModel.items.isEmpty else { return }
|
||||
await reload(showLoading: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// 筛选区域。
|
||||
private var filterSection: some View {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
Picker("上报类型", selection: $viewModel.selectedType) {
|
||||
ForEach(LocationReportType.allCases) { type in
|
||||
Text(type.title).tag(type)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: viewModel.selectedType) { _, _ in
|
||||
Task { await reload(showLoading: true) }
|
||||
}
|
||||
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
optionalDatePicker(title: "开始", date: $viewModel.startDate)
|
||||
optionalDatePicker(title: "结束", date: $viewModel.endDate)
|
||||
}
|
||||
HStack {
|
||||
Button("清除日期") {
|
||||
viewModel.startDate = nil
|
||||
viewModel.endDate = nil
|
||||
Task { await reload(showLoading: true) }
|
||||
}
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Spacer()
|
||||
Button("应用日期") {
|
||||
Task { await reload(showLoading: true) }
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
|
||||
/// 内容区域。
|
||||
@ViewBuilder
|
||||
private var contentSection: some View {
|
||||
if viewModel.items.isEmpty {
|
||||
LocationReportEmptyState(title: "暂无历史记录", message: viewModel.errorMessage ?? "当前筛选条件下没有位置上报记录。")
|
||||
} else {
|
||||
ForEach(viewModel.items) { item in
|
||||
LocationReportHistoryCard(item: item)
|
||||
.onAppear {
|
||||
guard item.id == viewModel.items.last?.id else { return }
|
||||
Task { await viewModel.loadMore(staffId: staffId, api: locationReportAPI) }
|
||||
}
|
||||
}
|
||||
if viewModel.isLoadingMore {
|
||||
ProgressView()
|
||||
.padding(.vertical, AppMetrics.Spacing.medium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var staffId: Int? {
|
||||
snapshotStore.load()?.businessUserId
|
||||
}
|
||||
|
||||
/// 重新加载历史。
|
||||
private func reload(showLoading: Bool) async {
|
||||
await globalLoading.withOptionalLoading(showLoading) {
|
||||
await viewModel.reload(staffId: staffId, api: locationReportAPI)
|
||||
}
|
||||
if let error = viewModel.errorMessage {
|
||||
toastCenter.show(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// 可选日期选择器。
|
||||
private func optionalDatePicker(title: String, date: Binding<Date?>) -> some View {
|
||||
DatePicker(
|
||||
title,
|
||||
selection: Binding(
|
||||
get: { date.wrappedValue ?? Date() },
|
||||
set: { date.wrappedValue = $0 }
|
||||
),
|
||||
displayedComponents: .date
|
||||
)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
}
|
||||
}
|
||||
|
||||
/// 位置上报历史卡片。
|
||||
private struct LocationReportHistoryCard: View {
|
||||
let item: LocationReportHistoryItem
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
HStack {
|
||||
Text(item.typeTitle)
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
Spacer()
|
||||
Text(item.createdAt)
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.placeholder)
|
||||
}
|
||||
Text(item.address.isEmpty ? "暂无地址" : item.address)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Text("\(item.latitude), \(item.longitude)")
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.placeholder)
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
}
|
||||
|
||||
/// 位置上报空状态组件。
|
||||
private struct LocationReportEmptyState: View {
|
||||
let title: String
|
||||
let message: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
Image(systemName: "location.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