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