Files
suixinkan_ios_new/suixinkan/Features/LocationReport/Views/LocationReportViews.swift
汉秋 26f4d0e671 Migrate from iOS 17 Observation to iOS 16-compatible Combine architecture.
Lower deployment target to iOS 16, replace @Observable with ObservableObject, add navigation and UI compatibility shims, and include login smoke UI tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 10:16:35 +08:00

406 lines
17 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
@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()
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) 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))
}
}