持久化服务端 expired 过期时间戳以正确恢复倒计时,切换在线/离线与上报时展示定位 Loading,移除重复的成功 Alert,并将 Toast 调整为居中半透明样式。 Co-authored-by: Cursor <cursoragent@cursor.com>
447 lines
19 KiB
Swift
447 lines
19 KiB
Swift
//
|
||
// LocationReportViews.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/24.
|
||
//
|
||
|
||
import Combine
|
||
import SwiftUI
|
||
|
||
/// 位置上报页面,支持当前位置上报、标记点上报、在线状态和提醒设置。
|
||
struct LocationReportView: View {
|
||
@EnvironmentObject private var accountContext: AccountContext
|
||
@EnvironmentObject private var homeLocationViewModel: HomeLocationViewModel
|
||
@Environment(\.accountSnapshotStore) private var snapshotStore
|
||
@Environment(\.locationReportAPI) private var locationReportAPI
|
||
@EnvironmentObject private var router: RouterPath
|
||
@EnvironmentObject private var toastCenter: ToastCenter
|
||
@Environment(\.globalLoading) private var globalLoading
|
||
|
||
@StateObject private var viewModel = LocationReportViewModel()
|
||
@State private var locationProvider = ForegroundLocationProvider()
|
||
@State private var showOnlineDialog = false
|
||
@State private var showReminderDialog = false
|
||
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 homeLocationViewModel.tick() }
|
||
.task {
|
||
homeLocationViewModel.restoreState()
|
||
guard viewModel.currentCoordinate == nil else { return }
|
||
await locateCurrent()
|
||
}
|
||
.alert("切换在线状态", isPresented: $showOnlineDialog) {
|
||
Button("取消", role: .cancel) {}
|
||
Button("确定") {
|
||
Task {
|
||
await globalLoading.withLoading(message: "正在定位...") {
|
||
await homeLocationViewModel.confirmOnlineToggle(
|
||
staffId: staffId,
|
||
scenicId: scenicId,
|
||
toast: toastCenter
|
||
)
|
||
}
|
||
}
|
||
}
|
||
} message: {
|
||
Text(homeLocationViewModel.isOnline ? "是否确认切换为离线状态?离线后将暂停位置上报。" : "是否确认切换为在线状态?在线后将开始位置上报和计时。")
|
||
}
|
||
.confirmationDialog("提前提醒时间", isPresented: $showReminderDialog, titleVisibility: .visible) {
|
||
ForEach([0, 5, 10, 15, 30], id: \.self) { minute in
|
||
Button(minute == 0 ? "不提醒" : "\(minute)分钟") {
|
||
homeLocationViewModel.updateReminderMinutes(minute)
|
||
}
|
||
}
|
||
Button("取消", role: .cancel) {}
|
||
}
|
||
}
|
||
|
||
/// 在线状态和倒计时区域。
|
||
private var statusSection: some View {
|
||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||
HStack {
|
||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||
Text(homeLocationViewModel.isOnline ? "当前在线" : "当前离线")
|
||
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
|
||
.foregroundStyle(AppDesign.textPrimary)
|
||
Text(countdownText)
|
||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
}
|
||
Spacer()
|
||
Button {
|
||
showOnlineDialog = true
|
||
} label: {
|
||
Text(homeLocationViewModel.isOnline ? "在线" : "离线")
|
||
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
|
||
.foregroundStyle(homeLocationViewModel.isOnline ? Color(hex: 0xF0FDF4) : Color(hex: 0x7B8EAA))
|
||
.padding(.horizontal, AppMetrics.Spacing.small)
|
||
.padding(.vertical, AppMetrics.Spacing.xxSmall + 2)
|
||
.background(homeLocationViewModel.isOnline ? Color(hex: 0x22C55E) : Color(hex: 0xF4F4F4), in: RoundedRectangle(cornerRadius: 4))
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
if !homeLocationViewModel.reportTimeText.isEmpty {
|
||
Text("上次上报:\(homeLocationViewModel.reportTimeText)")
|
||
.font(.system(size: AppMetrics.FontSize.caption))
|
||
.foregroundStyle(AppDesign.placeholder)
|
||
}
|
||
}
|
||
.padding(AppMetrics.Spacing.medium)
|
||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||
}
|
||
|
||
private var countdownText: String {
|
||
guard homeLocationViewModel.secondsUntilReport > 0 else { return "可立即上报" }
|
||
let hours = homeLocationViewModel.secondsUntilReport / 3600
|
||
let minutes = (homeLocationViewModel.secondsUntilReport % 3600) / 60
|
||
return "\(hours)小时\(minutes)分钟后可再次提醒"
|
||
}
|
||
|
||
/// 当前定位区域。
|
||
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))
|
||
Button {
|
||
showReminderDialog = true
|
||
} label: {
|
||
Text(homeLocationViewModel.reminderMinutes == 0 ? "不提醒" : "提前 \(homeLocationViewModel.reminderMinutes) 分钟提醒")
|
||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||
.foregroundStyle(AppDesign.primary)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
.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 || homeLocationViewModel.isReporting)
|
||
}
|
||
|
||
/// 坐标和地址展示。
|
||
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 submit(type: LocationReportType) async {
|
||
let success = await globalLoading.withLoading {
|
||
await viewModel.submit(
|
||
type: type,
|
||
staffId: staffId,
|
||
scenicId: scenicId,
|
||
homeLocationViewModel: homeLocationViewModel,
|
||
toast: toastCenter
|
||
)
|
||
}
|
||
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) private var snapshotStore
|
||
@Environment(\.locationReportAPI) private var locationReportAPI
|
||
@EnvironmentObject private var toastCenter: ToastCenter
|
||
@Environment(\.globalLoading) private var globalLoading
|
||
|
||
@StateObject 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))
|
||
}
|
||
}
|