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