Files
suixinkan_ios_new/suixinkan/Features/LocationReport/Views/LocationReportViews.swift
汉秋 560b52fcc8 接入首页位置上报与在线倒计时,并优化上报交互体验。
持久化服务端 expired 过期时间戳以正确恢复倒计时,切换在线/离线与上报时展示定位 Loading,移除重复的成功 Alert,并将 Toast 调整为居中半透明样式。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 15:59:59 +08:00

447 lines
19 KiB
Swift
Raw 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
@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))
}
}