新增打卡点与位置上报模块,并接入首页路由

将打卡点管理与位置上报从占位页迁移为完整 MVVM 流程,包含前台定位支持与单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-24 11:08:41 +08:00
parent 403a3eefa6
commit abcac9bfdf
22 changed files with 2698 additions and 12 deletions

View File

@ -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))
}
}