将打卡点管理与位置上报从占位页迁移为完整 MVVM 流程,包含前台定位支持与单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
406 lines
17 KiB
Swift
406 lines
17 KiB
Swift
//
|
||
// 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))
|
||
}
|
||
}
|