Files
suixinkan_ios_new/suixinkan/Features/LocationReport/Views/LocationReportViews.swift
汉秋 63fb0462d6 修复离线路径误弹位置上报超时窗,并优化提醒设置与定位 Loading 展示。
离线或倒计时未进入提醒窗口时不再弹出超时提醒;全局 Loading 支持按需展示定位文案,提前提醒选项抽成共用组件。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 16:22:55 +08:00

436 lines
18 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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
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: "获取定位中", showsMessage: true) {
await homeLocationViewModel.confirmOnlineToggle(
staffId: staffId,
scenicId: scenicId,
toast: toastCenter
)
}
}
}
} message: {
Text(homeLocationViewModel.isOnline ? "是否确认切换为离线状态?离线后将暂停位置上报。" : "是否确认切换为在线状态?在线后将开始位置上报和计时。")
}
}
/// 线
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))
ReminderTimeOptionsMenu(onSelect: { homeLocationViewModel.updateReminderMinutes($0) }) {
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))
}
}