Add location report map page and history list aligned with Android.
Extract shared LocationReportService, wire location_report menu routing, and add unit tests for reporting cooldown and history pagination. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -64,6 +64,36 @@ final class HomeAPI {
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取位置上报历史列表。
|
||||
func locationReportList(
|
||||
staffId: String,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
type: Int,
|
||||
startDate: String?,
|
||||
endDate: String?
|
||||
) async throws -> LocationReportHistoryListResponse {
|
||||
var queryItems = [
|
||||
URLQueryItem(name: "staff_id", value: staffId),
|
||||
URLQueryItem(name: "page", value: String(page)),
|
||||
URLQueryItem(name: "page_size", value: String(pageSize)),
|
||||
URLQueryItem(name: "type", value: String(type)),
|
||||
]
|
||||
if let startDate, !startDate.isEmpty {
|
||||
queryItems.append(URLQueryItem(name: "start_date", value: startDate))
|
||||
}
|
||||
if let endDate, !endDate.isEmpty {
|
||||
queryItems.append(URLQueryItem(name: "end_date", value: endDate))
|
||||
}
|
||||
return try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/loacation/list",
|
||||
queryItems: queryItems
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取待审核权限申请列表。
|
||||
func roleApplyPending() async throws -> [RoleApplyPendingResponse] {
|
||||
try await client.send(
|
||||
|
||||
@ -36,3 +36,130 @@ struct LocationReportResponse: Decodable, Sendable {
|
||||
case status
|
||||
}
|
||||
}
|
||||
|
||||
/// 历史上报类型筛选,对齐 Android `LocationReportFilterType`。
|
||||
enum LocationReportFilterType: CaseIterable, Equatable {
|
||||
case all
|
||||
case immediate
|
||||
case marked
|
||||
case offline
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .all: "全部"
|
||||
case .immediate: "立即上报"
|
||||
case .marked: "标记上报"
|
||||
case .offline: "离线上报"
|
||||
}
|
||||
}
|
||||
|
||||
var typeCode: Int {
|
||||
switch self {
|
||||
case .all: 0
|
||||
case .immediate: 1
|
||||
case .marked: 2
|
||||
case .offline: 3
|
||||
}
|
||||
}
|
||||
|
||||
var tagColorHex: UInt {
|
||||
switch self {
|
||||
case .all, .immediate: 0x0073FF
|
||||
case .marked: 0x22C55E
|
||||
case .offline: 0x999999
|
||||
}
|
||||
}
|
||||
|
||||
static func from(typeCode: Int) -> LocationReportFilterType {
|
||||
switch typeCode {
|
||||
case 1: .immediate
|
||||
case 2: .marked
|
||||
case 3: .offline
|
||||
default: .all
|
||||
}
|
||||
}
|
||||
|
||||
static func fromReportType(_ type: Int) -> LocationReportFilterType {
|
||||
switch type {
|
||||
case 1: .immediate
|
||||
case 2: .marked
|
||||
case 3: .offline
|
||||
default: .all
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 历史上报单条记录,对齐 Android `LocationReportHistoryItem`。
|
||||
struct LocationReportHistoryItem: Decodable, Equatable, Identifiable {
|
||||
let id: Int64
|
||||
let staffId: Int64
|
||||
let type: Int
|
||||
let latitude: String
|
||||
let longitude: String
|
||||
let address: String
|
||||
let ip: String?
|
||||
let remark: String
|
||||
let createdAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case staffId = "staff_id"
|
||||
case type
|
||||
case latitude
|
||||
case longitude
|
||||
case address
|
||||
case ip
|
||||
case remark
|
||||
case createdAt = "created_at"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeIfPresent(Int64.self, forKey: .id) ?? 0
|
||||
staffId = try container.decodeIfPresent(Int64.self, forKey: .staffId) ?? 0
|
||||
type = try container.decodeIfPresent(Int.self, forKey: .type) ?? 0
|
||||
latitude = try container.decodeIfPresent(String.self, forKey: .latitude) ?? ""
|
||||
longitude = try container.decodeIfPresent(String.self, forKey: .longitude) ?? ""
|
||||
address = try container.decodeIfPresent(String.self, forKey: .address) ?? ""
|
||||
ip = try container.decodeIfPresent(String.self, forKey: .ip)
|
||||
remark = try container.decodeIfPresent(String.self, forKey: .remark) ?? ""
|
||||
createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) ?? ""
|
||||
}
|
||||
|
||||
init(
|
||||
id: Int64 = 0,
|
||||
staffId: Int64 = 0,
|
||||
type: Int = 0,
|
||||
latitude: String = "",
|
||||
longitude: String = "",
|
||||
address: String = "",
|
||||
ip: String? = nil,
|
||||
remark: String = "",
|
||||
createdAt: String = ""
|
||||
) {
|
||||
self.id = id
|
||||
self.staffId = staffId
|
||||
self.type = type
|
||||
self.latitude = latitude
|
||||
self.longitude = longitude
|
||||
self.address = address
|
||||
self.ip = ip
|
||||
self.remark = remark
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
var filterType: LocationReportFilterType {
|
||||
LocationReportFilterType.fromReportType(type)
|
||||
}
|
||||
}
|
||||
|
||||
/// 历史上报列表响应。
|
||||
struct LocationReportHistoryListResponse: Decodable, Equatable {
|
||||
let list: [LocationReportHistoryItem]
|
||||
let total: Int
|
||||
|
||||
init(list: [LocationReportHistoryItem] = [], total: Int = 0) {
|
||||
self.list = list
|
||||
self.total = total
|
||||
}
|
||||
}
|
||||
|
||||
@ -46,14 +46,8 @@ final class HomeLocationProvider: NSObject, CLLocationManagerDelegate {
|
||||
)
|
||||
}
|
||||
|
||||
private func requestCoordinate() async throws -> CLLocationCoordinate2D {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
self.coordinateContinuation = continuation
|
||||
manager.requestLocation()
|
||||
}
|
||||
}
|
||||
|
||||
private func reverseGeocode(latitude: Double, longitude: Double) async -> String {
|
||||
/// 逆地理编码,供地图标记使用。
|
||||
func reverseGeocode(latitude: Double, longitude: Double) async -> String {
|
||||
let location = CLLocation(latitude: latitude, longitude: longitude)
|
||||
let geocoder = CLGeocoder()
|
||||
do {
|
||||
@ -64,6 +58,13 @@ final class HomeLocationProvider: NSObject, CLLocationManagerDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
private func requestCoordinate() async throws -> CLLocationCoordinate2D {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
self.coordinateContinuation = continuation
|
||||
manager.requestLocation()
|
||||
}
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
guard let location = locations.last else { return }
|
||||
coordinateContinuation?.resume(returning: location.coordinate)
|
||||
@ -82,7 +83,7 @@ enum HomeLocationProviderError: LocalizedError {
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .permissionDenied: "需要位置权限才能上线"
|
||||
case .permissionDenied: "需要定位权限才能使用此功能"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -80,6 +80,15 @@ final class HomeLocationStateStore {
|
||||
notifyChange()
|
||||
}
|
||||
|
||||
var countdownDisplayText: String {
|
||||
formattedCountdown(from: nextReportCountdownSeconds)
|
||||
}
|
||||
|
||||
/// Android 独立页倒计时格式 `H:MM:SS`。
|
||||
var countdownDisplayTextSingleHour: String {
|
||||
formattedCountdownSingleHour(from: nextReportCountdownSeconds)
|
||||
}
|
||||
|
||||
/// 格式化倒计时文本 `HH:MM:SS`。
|
||||
func formattedCountdown(from seconds: Int64) -> String {
|
||||
let hours = seconds / 3600
|
||||
@ -88,8 +97,12 @@ final class HomeLocationStateStore {
|
||||
return String(format: "%02d:%02d:%02d", hours, minutes, secs)
|
||||
}
|
||||
|
||||
var countdownDisplayText: String {
|
||||
formattedCountdown(from: nextReportCountdownSeconds)
|
||||
/// 格式化倒计时文本 `H:MM:SS`。
|
||||
func formattedCountdownSingleHour(from seconds: Int64) -> String {
|
||||
let hours = seconds / 3600
|
||||
let minutes = (seconds % 3600) / 60
|
||||
let secs = seconds % 60
|
||||
return String(format: "%d:%02d:%02d", hours, minutes, secs)
|
||||
}
|
||||
|
||||
/// 是否到达提前提醒窗口。
|
||||
|
||||
@ -48,6 +48,8 @@ enum HomeRouteHandler {
|
||||
return
|
||||
}
|
||||
viewController.navigationController?.pushViewController(CooperationOrderListViewController(), animated: true)
|
||||
case "location_report":
|
||||
viewController.navigationController?.pushViewController(LocationReportViewController(), animated: true)
|
||||
case "task_management", "task_management_editor":
|
||||
viewController.navigationController?.pushViewController(TaskAddViewController(), animated: true)
|
||||
default:
|
||||
|
||||
@ -27,15 +27,13 @@ final class HomeViewModel {
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
|
||||
let locationStateStore: HomeLocationStateStore
|
||||
let locationReportService: LocationReportService
|
||||
private let appStore: AppStore
|
||||
private let commonMenuStore: HomeCommonMenuStore
|
||||
private let locationProvider: HomeLocationProvider
|
||||
|
||||
private var lastOnlineStatusSwitchTime: Int64 = 0
|
||||
private var lastLocationReportTime: Int64 = 0
|
||||
private var timeoutDialogTriggered = false
|
||||
private var dismissedLocationTimeoutDialog = false
|
||||
private let operationIntervalMillis: Int64 = 30_000
|
||||
|
||||
init(
|
||||
appStore: AppStore = .shared,
|
||||
@ -45,8 +43,13 @@ final class HomeViewModel {
|
||||
) {
|
||||
self.appStore = appStore
|
||||
self.locationStateStore = locationStateStore
|
||||
self.commonMenuStore = commonMenuStore
|
||||
self.locationProvider = locationProvider
|
||||
self.locationReportService = LocationReportService(
|
||||
appStore: appStore,
|
||||
locationStateStore: locationStateStore,
|
||||
locationProvider: locationProvider
|
||||
)
|
||||
self.commonMenuStore = commonMenuStore
|
||||
self.locationStateStore.onStateChange = { [weak self] in
|
||||
self?.notifyStateChange()
|
||||
}
|
||||
@ -288,60 +291,92 @@ final class HomeViewModel {
|
||||
|
||||
/// 切换在线/离线并触发位置上报。
|
||||
func switchOnlineStatus(api: HomeAPI) async {
|
||||
let now = currentTimestampMillis()
|
||||
if now - lastOnlineStatusSwitchTime < operationIntervalMillis {
|
||||
let remaining = Int((operationIntervalMillis - (now - lastOnlineStatusSwitchTime)) / 1000)
|
||||
onShowMessage?("操作过于频繁,请\(remaining)秒后再试")
|
||||
hideOnlineStatusSwitchDialog()
|
||||
return
|
||||
}
|
||||
guard appStore.currentScenicId > 0 else {
|
||||
onShowMessage?("请先选择景区")
|
||||
hideOnlineStatusSwitchDialog()
|
||||
return
|
||||
hideOnlineStatusSwitchDialog()
|
||||
isLocationReporting = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isLocationReporting = locationReportService.isReporting
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
let goingOnline = !isOnline
|
||||
if goingOnline {
|
||||
do {
|
||||
_ = try await locationProvider.requestSnapshot()
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
hideOnlineStatusSwitchDialog()
|
||||
return
|
||||
do {
|
||||
let goingOnline = !isOnline
|
||||
if let result = try await locationReportService.switchOnlineStatus(api: api) {
|
||||
applyReportSuccess(result)
|
||||
onShowMessage?(goingOnline ? "已切换到在线状态,正在上报位置" : "已切换到离线状态")
|
||||
} else {
|
||||
onShowMessage?("已切换到离线状态")
|
||||
}
|
||||
locationStateStore.updateOnlineStatus(true)
|
||||
timeoutDialogTriggered = false
|
||||
dismissedLocationTimeoutDialog = false
|
||||
lastOnlineStatusSwitchTime = now
|
||||
onShowMessage?("已切换到在线状态,正在上报位置")
|
||||
hideOnlineStatusSwitchDialog()
|
||||
await reportLocation(api: api, type: 1)
|
||||
} else {
|
||||
locationStateStore.updateOnlineStatus(false)
|
||||
lastOnlineStatusSwitchTime = now
|
||||
onShowMessage?("已切换到离线状态")
|
||||
hideOnlineStatusSwitchDialog()
|
||||
await reportLocation(api: api, type: 3)
|
||||
} catch let error as LocationReportServiceError {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 手动上报位置。
|
||||
func manualReportLocation(api: HomeAPI) async {
|
||||
let now = currentTimestampMillis()
|
||||
if now - lastLocationReportTime < operationIntervalMillis {
|
||||
let remaining = Int((operationIntervalMillis - (now - lastLocationReportTime)) / 1000)
|
||||
onShowMessage?("操作过于频繁,请\(remaining)秒后再试")
|
||||
return
|
||||
isLocationReporting = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isLocationReporting = locationReportService.isReporting
|
||||
notifyStateChange()
|
||||
}
|
||||
do {
|
||||
let result = try await locationReportService.manualReport(api: api)
|
||||
applyReportSuccess(result)
|
||||
onShowMessage?("位置上报成功")
|
||||
} catch let error as LocationReportServiceError {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 指定坐标上报(标记上报 type=2)。
|
||||
func reportLocation(
|
||||
api: HomeAPI,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
address: String?,
|
||||
type: Int
|
||||
) async {
|
||||
isLocationReporting = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isLocationReporting = locationReportService.isReporting
|
||||
notifyStateChange()
|
||||
}
|
||||
do {
|
||||
let result = try await locationReportService.reportWithCoordinates(
|
||||
api: api,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
address: address,
|
||||
type: type
|
||||
)
|
||||
applyReportSuccess(result)
|
||||
onShowMessage?("位置上报成功")
|
||||
} catch let error as LocationReportServiceError {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
await reportLocation(api: api, type: 1)
|
||||
}
|
||||
|
||||
/// 位置超时弹窗确认上报。
|
||||
func confirmLocationTimeoutReport(api: HomeAPI) async {
|
||||
showLocationTimeoutDialog = false
|
||||
dismissedLocationTimeoutDialog = false
|
||||
await reportLocation(api: api, type: 1)
|
||||
await manualReportLocation(api: api)
|
||||
}
|
||||
|
||||
/// 触发本地倒计时提醒。
|
||||
@ -371,62 +406,15 @@ final class HomeViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func reportLocation(api: HomeAPI, type: Int) async {
|
||||
guard appStore.currentScenicId > 0 else {
|
||||
onShowMessage?("请先选择景区")
|
||||
return
|
||||
private func applyReportSuccess(_ result: LocationReportSuccessResult) {
|
||||
locationInfoText = result.locationInfoText
|
||||
if result.shouldShowSuccessDialog {
|
||||
reportTimeText = result.reportTimeText
|
||||
showLocationReportSuccessDialog = true
|
||||
timeoutDialogTriggered = false
|
||||
dismissedLocationTimeoutDialog = false
|
||||
}
|
||||
let staffId = appStore.userId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !staffId.isEmpty else {
|
||||
onShowMessage?("获取用户ID失败")
|
||||
return
|
||||
}
|
||||
|
||||
isLocationReporting = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isLocationReporting = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
do {
|
||||
let snapshot = try await locationProvider.requestSnapshot()
|
||||
locationInfoText = snapshot.address.isEmpty
|
||||
? String(format: "%.5f, %.5f", snapshot.latitude, snapshot.longitude)
|
||||
: snapshot.address
|
||||
_ = try await api.reportLocation(
|
||||
staffId: staffId,
|
||||
latitude: snapshot.latitude,
|
||||
longitude: snapshot.longitude,
|
||||
address: snapshot.address,
|
||||
type: type,
|
||||
scenicId: String(appStore.currentScenicId)
|
||||
)
|
||||
lastLocationReportTime = currentTimestampMillis()
|
||||
if type != 3 {
|
||||
locationStateStore.startLocationReport()
|
||||
reportTimeText = formattedNow()
|
||||
showLocationReportSuccessDialog = true
|
||||
timeoutDialogTriggered = false
|
||||
dismissedLocationTimeoutDialog = false
|
||||
}
|
||||
notifyStateChange()
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func formattedNow() -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||
return formatter.string(from: Date())
|
||||
}
|
||||
|
||||
private func currentTimestampMillis() -> Int64 {
|
||||
Int64(Date().timeIntervalSince1970 * 1000)
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
|
||||
@ -0,0 +1,207 @@
|
||||
//
|
||||
// LocationReportService.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 位置上报成功结果。
|
||||
struct LocationReportSuccessResult: Equatable {
|
||||
let reportTimeText: String
|
||||
let locationInfoText: String
|
||||
let shouldShowSuccessDialog: Bool
|
||||
}
|
||||
|
||||
/// 位置上报共享业务逻辑,对齐 Android `HomeViewModel.reportLocation`。
|
||||
final class LocationReportService {
|
||||
|
||||
static let operationIntervalMillis: Int64 = 30_000
|
||||
|
||||
private(set) var lastOnlineStatusSwitchTime: Int64 = 0
|
||||
private(set) var lastLocationReportTime: Int64 = 0
|
||||
private(set) var isReporting = false
|
||||
|
||||
private let appStore: AppStore
|
||||
private let locationStateStore: HomeLocationStateStore
|
||||
private let locationProvider: HomeLocationProvider
|
||||
|
||||
init(
|
||||
appStore: AppStore = .shared,
|
||||
locationStateStore: HomeLocationStateStore,
|
||||
locationProvider: HomeLocationProvider = HomeLocationProvider()
|
||||
) {
|
||||
self.appStore = appStore
|
||||
self.locationStateStore = locationStateStore
|
||||
self.locationProvider = locationProvider
|
||||
}
|
||||
|
||||
/// 切换在线/离线并触发位置上报。
|
||||
func switchOnlineStatus(api: HomeAPI) async throws -> LocationReportSuccessResult? {
|
||||
let now = currentTimestampMillis()
|
||||
if now - lastOnlineStatusSwitchTime < Self.operationIntervalMillis {
|
||||
let remaining = Int((Self.operationIntervalMillis - (now - lastOnlineStatusSwitchTime)) / 1000)
|
||||
throw LocationReportServiceError.tooFrequent(remainingSeconds: remaining)
|
||||
}
|
||||
guard appStore.currentScenicId > 0 else {
|
||||
throw LocationReportServiceError.missingScenic
|
||||
}
|
||||
|
||||
let goingOnline = !locationStateStore.isOnline
|
||||
if goingOnline {
|
||||
_ = try await locationProvider.requestSnapshot()
|
||||
locationStateStore.updateOnlineStatus(true)
|
||||
lastOnlineStatusSwitchTime = now
|
||||
return try await reportWithCoordinates(
|
||||
api: api,
|
||||
latitude: nil,
|
||||
longitude: nil,
|
||||
address: nil,
|
||||
type: 1,
|
||||
showSuccessToast: true
|
||||
)
|
||||
}
|
||||
|
||||
locationStateStore.updateOnlineStatus(false)
|
||||
lastOnlineStatusSwitchTime = now
|
||||
_ = try await reportWithCoordinates(
|
||||
api: api,
|
||||
latitude: nil,
|
||||
longitude: nil,
|
||||
address: nil,
|
||||
type: 3,
|
||||
showSuccessToast: false
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 手动 GPS 上报。
|
||||
func manualReport(api: HomeAPI) async throws -> LocationReportSuccessResult {
|
||||
let now = currentTimestampMillis()
|
||||
if now - lastLocationReportTime < Self.operationIntervalMillis {
|
||||
let remaining = Int((Self.operationIntervalMillis - (now - lastLocationReportTime)) / 1000)
|
||||
throw LocationReportServiceError.tooFrequent(remainingSeconds: remaining)
|
||||
}
|
||||
return try await reportWithCoordinates(
|
||||
api: api,
|
||||
latitude: nil,
|
||||
longitude: nil,
|
||||
address: nil,
|
||||
type: 1,
|
||||
showSuccessToast: true
|
||||
)
|
||||
}
|
||||
|
||||
/// 指定坐标上报(含标记上报 type=2)。
|
||||
func reportWithCoordinates(
|
||||
api: HomeAPI,
|
||||
latitude: Double?,
|
||||
longitude: Double?,
|
||||
address: String?,
|
||||
type: Int,
|
||||
showSuccessToast: Bool = true
|
||||
) async throws -> LocationReportSuccessResult {
|
||||
if type != 3 {
|
||||
let now = currentTimestampMillis()
|
||||
if now - lastLocationReportTime < Self.operationIntervalMillis {
|
||||
let remaining = Int((Self.operationIntervalMillis - (now - lastLocationReportTime)) / 1000)
|
||||
throw LocationReportServiceError.tooFrequent(remainingSeconds: remaining)
|
||||
}
|
||||
}
|
||||
|
||||
guard appStore.currentScenicId > 0 else {
|
||||
throw LocationReportServiceError.missingScenic
|
||||
}
|
||||
let staffId = appStore.userId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !staffId.isEmpty else {
|
||||
throw LocationReportServiceError.missingStaffId
|
||||
}
|
||||
|
||||
isReporting = true
|
||||
defer { isReporting = false }
|
||||
|
||||
let snapshot: HomeLocationSnapshot
|
||||
if let latitude, let longitude {
|
||||
let resolvedAddress: String
|
||||
if let address, !address.isEmpty {
|
||||
resolvedAddress = address
|
||||
} else {
|
||||
resolvedAddress = await locationProvider.reverseGeocode(
|
||||
latitude: latitude,
|
||||
longitude: longitude
|
||||
)
|
||||
}
|
||||
snapshot = HomeLocationSnapshot(
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
address: resolvedAddress
|
||||
)
|
||||
} else {
|
||||
snapshot = try await locationProvider.requestSnapshot()
|
||||
}
|
||||
|
||||
let locationText = snapshot.address.isEmpty
|
||||
? String(format: "%.5f, %.5f", snapshot.latitude, snapshot.longitude)
|
||||
: snapshot.address
|
||||
|
||||
_ = try await api.reportLocation(
|
||||
staffId: staffId,
|
||||
latitude: snapshot.latitude,
|
||||
longitude: snapshot.longitude,
|
||||
address: snapshot.address,
|
||||
type: type,
|
||||
scenicId: String(appStore.currentScenicId)
|
||||
)
|
||||
|
||||
lastLocationReportTime = currentTimestampMillis()
|
||||
if type != 3 {
|
||||
locationStateStore.startLocationReport()
|
||||
let reportTime = formattedReportTime()
|
||||
if showSuccessToast {
|
||||
// Toast handled by caller via onShowMessage if needed
|
||||
}
|
||||
return LocationReportSuccessResult(
|
||||
reportTimeText: reportTime,
|
||||
locationInfoText: locationText,
|
||||
shouldShowSuccessDialog: true
|
||||
)
|
||||
}
|
||||
|
||||
return LocationReportSuccessResult(
|
||||
reportTimeText: "",
|
||||
locationInfoText: locationText,
|
||||
shouldShowSuccessDialog: false
|
||||
)
|
||||
}
|
||||
|
||||
private func formattedReportTime() -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = "HH:mm"
|
||||
return formatter.string(from: Date())
|
||||
}
|
||||
|
||||
private func currentTimestampMillis() -> Int64 {
|
||||
Int64(Date().timeIntervalSince1970 * 1000)
|
||||
}
|
||||
}
|
||||
|
||||
/// 位置上报业务错误。
|
||||
enum LocationReportServiceError: LocalizedError, Equatable {
|
||||
case tooFrequent(remainingSeconds: Int)
|
||||
case missingScenic
|
||||
case missingStaffId
|
||||
case locating
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .tooFrequent(let remaining):
|
||||
"操作过于频繁,请\(remaining)秒后再试"
|
||||
case .missingScenic:
|
||||
"请先选择景区"
|
||||
case .missingStaffId:
|
||||
"获取用户ID失败"
|
||||
case .locating:
|
||||
"正在定位中,请稍候"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,124 @@
|
||||
//
|
||||
// LocationReportHistoryViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 位置上报历史 ViewModel,对齐 Android `LocationReportHistoryViewModel`。
|
||||
final class LocationReportHistoryViewModel {
|
||||
|
||||
private(set) var reportList: [LocationReportHistoryItem] = []
|
||||
private(set) var selectedFilter: LocationReportFilterType = .all
|
||||
private(set) var isRefreshing = false
|
||||
private(set) var canLoadMore = false
|
||||
private(set) var startDate: Date?
|
||||
private(set) var endDate: Date?
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
|
||||
private let appStore: AppStore
|
||||
private var currentPage = 1
|
||||
private let pageSize = 10
|
||||
private var isLoading = false
|
||||
|
||||
init(appStore: AppStore = .shared) {
|
||||
self.appStore = appStore
|
||||
}
|
||||
|
||||
var startDateText: String { formattedDate(startDate) }
|
||||
var endDateText: String { formattedDate(endDate) }
|
||||
var hasDateFilter: Bool { startDate != nil || endDate != nil }
|
||||
|
||||
/// 加载历史上报列表。
|
||||
func loadReportList(api: HomeAPI, refresh: Bool = false) async {
|
||||
guard !isLoading || refresh else { return }
|
||||
let staffId = appStore.userId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !staffId.isEmpty else {
|
||||
onShowMessage?("获取用户ID失败")
|
||||
return
|
||||
}
|
||||
|
||||
if refresh {
|
||||
currentPage = 1
|
||||
isRefreshing = true
|
||||
notifyStateChange()
|
||||
}
|
||||
isLoading = true
|
||||
defer {
|
||||
isLoading = false
|
||||
isRefreshing = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
do {
|
||||
let response = try await api.locationReportList(
|
||||
staffId: staffId,
|
||||
page: currentPage,
|
||||
pageSize: pageSize,
|
||||
type: selectedFilter.typeCode,
|
||||
startDate: apiDateString(startDate),
|
||||
endDate: apiDateString(endDate)
|
||||
)
|
||||
if refresh || currentPage == 1 {
|
||||
reportList = response.list
|
||||
} else {
|
||||
reportList.append(contentsOf: response.list)
|
||||
}
|
||||
currentPage += 1
|
||||
canLoadMore = reportList.count < response.total
|
||||
notifyStateChange()
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func selectFilter(_ filter: LocationReportFilterType, api: HomeAPI) async {
|
||||
selectedFilter = filter
|
||||
currentPage = 1
|
||||
await loadReportList(api: api, refresh: true)
|
||||
}
|
||||
|
||||
func setDateRange(start: Date?, end: Date?, api: HomeAPI) async {
|
||||
startDate = start
|
||||
endDate = end
|
||||
currentPage = 1
|
||||
await loadReportList(api: api, refresh: true)
|
||||
}
|
||||
|
||||
func clearDateRange(api: HomeAPI) async {
|
||||
startDate = nil
|
||||
endDate = nil
|
||||
currentPage = 1
|
||||
await loadReportList(api: api, refresh: true)
|
||||
}
|
||||
|
||||
func loadMoreIfNeeded(currentIndex: Int, api: HomeAPI) async {
|
||||
guard canLoadMore, !isLoading else { return }
|
||||
guard currentIndex >= reportList.count - 2 else { return }
|
||||
await loadReportList(api: api, refresh: false)
|
||||
}
|
||||
|
||||
private func apiDateString(_ date: Date?) -> String? {
|
||||
guard let date else { return nil }
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private func formattedDate(_ date: Date?) -> String {
|
||||
guard let date else { return "请选择" }
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,197 @@
|
||||
//
|
||||
// LocationReportViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 独立位置上报页 ViewModel,对齐 Android `LocationReportViewModel` + 共享上报逻辑。
|
||||
final class LocationReportViewModel {
|
||||
|
||||
private(set) var currentLatitude: Double?
|
||||
private(set) var currentLongitude: Double?
|
||||
private(set) var currentAddress: String?
|
||||
private(set) var isLocating = false
|
||||
private(set) var isMarking = false
|
||||
private(set) var markerLatitude: Double?
|
||||
private(set) var markerLongitude: Double?
|
||||
private(set) var markerAddress: String?
|
||||
private(set) var showOnlineStatusDialog = false
|
||||
private(set) var showLocationReportSuccessDialog = false
|
||||
private(set) var reportTimeText = ""
|
||||
|
||||
let locationStateStore: HomeLocationStateStore
|
||||
let locationReportService: LocationReportService
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
|
||||
private let locationProvider: HomeLocationProvider
|
||||
|
||||
init(
|
||||
locationStateStore: HomeLocationStateStore = HomeLocationStateStore(),
|
||||
locationProvider: HomeLocationProvider = HomeLocationProvider()
|
||||
) {
|
||||
self.locationStateStore = locationStateStore
|
||||
self.locationProvider = locationProvider
|
||||
self.locationReportService = LocationReportService(
|
||||
locationStateStore: locationStateStore,
|
||||
locationProvider: locationProvider
|
||||
)
|
||||
self.locationStateStore.onStateChange = { [weak self] in
|
||||
self?.notifyStateChange()
|
||||
}
|
||||
}
|
||||
|
||||
var isOnline: Bool { locationStateStore.isOnline }
|
||||
var countdownDisplayText: String { locationStateStore.countdownDisplayTextSingleHour }
|
||||
var reminderMinutes: Int { locationStateStore.reminderMinutes }
|
||||
var isReporting: Bool { locationReportService.isReporting }
|
||||
|
||||
/// 进入页面时恢复在线倒计时。
|
||||
func restoreStateIfNeeded() {
|
||||
locationStateStore.restoreStateIfNeeded()
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 开始定位并更新地图中心。
|
||||
func startLocation() {
|
||||
guard !isLocating else { return }
|
||||
isLocating = true
|
||||
notifyStateChange()
|
||||
Task {
|
||||
defer {
|
||||
isLocating = false
|
||||
notifyStateChange()
|
||||
}
|
||||
do {
|
||||
let snapshot = try await locationProvider.requestSnapshot()
|
||||
currentLatitude = snapshot.latitude
|
||||
currentLongitude = snapshot.longitude
|
||||
currentAddress = snapshot.address.isEmpty
|
||||
? String(format: "%.5f, %.5f", snapshot.latitude, snapshot.longitude)
|
||||
: snapshot.address
|
||||
notifyStateChange()
|
||||
} catch {
|
||||
onShowMessage?((error as? LocalizedError)?.errorDescription ?? error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toggleMarking() {
|
||||
isMarking.toggle()
|
||||
if !isMarking {
|
||||
markerLatitude = nil
|
||||
markerLongitude = nil
|
||||
markerAddress = nil
|
||||
}
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 地图点击设置标记点并逆地理。
|
||||
func setMarkerLocation(latitude: Double, longitude: Double) {
|
||||
guard isMarking else { return }
|
||||
markerLatitude = latitude
|
||||
markerLongitude = longitude
|
||||
markerAddress = nil
|
||||
notifyStateChange()
|
||||
Task {
|
||||
let address = await locationProvider.reverseGeocode(latitude: latitude, longitude: longitude)
|
||||
markerAddress = address.isEmpty
|
||||
? String(format: "%.5f, %.5f", latitude, longitude)
|
||||
: address
|
||||
notifyStateChange()
|
||||
}
|
||||
}
|
||||
|
||||
func showOnlineStatusSwitchDialog() {
|
||||
showOnlineStatusDialog = true
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func hideOnlineStatusSwitchDialog() {
|
||||
showOnlineStatusDialog = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func hideLocationReportSuccessDialog() {
|
||||
showLocationReportSuccessDialog = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func updateReminderMinutes(_ minutes: Int) {
|
||||
locationStateStore.updateReminderMinutes(minutes)
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func switchOnlineStatus(api: HomeAPI) async {
|
||||
hideOnlineStatusSwitchDialog()
|
||||
do {
|
||||
let goingOnline = !isOnline
|
||||
if let result = try await locationReportService.switchOnlineStatus(api: api) {
|
||||
applyReportSuccess(result)
|
||||
onShowMessage?(goingOnline ? "已切换到在线状态,正在上报位置" : "已切换到离线状态")
|
||||
} else {
|
||||
onShowMessage?("已切换到离线状态")
|
||||
}
|
||||
} catch let error as LocationReportServiceError {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 立即上报:标记模式 type=2,否则 type=1。
|
||||
func reportLocation(api: HomeAPI) async {
|
||||
if isMarking {
|
||||
guard let lat = markerLatitude, let lng = markerLongitude else {
|
||||
onShowMessage?("请先在地图上选择标记点")
|
||||
return
|
||||
}
|
||||
await reportCoordinates(api: api, latitude: lat, longitude: lng, address: markerAddress, type: 2)
|
||||
return
|
||||
}
|
||||
|
||||
guard let lat = currentLatitude, let lng = currentLongitude else {
|
||||
onShowMessage?("正在定位中,请稍候")
|
||||
return
|
||||
}
|
||||
await reportCoordinates(api: api, latitude: lat, longitude: lng, address: currentAddress, type: 1)
|
||||
}
|
||||
|
||||
private func reportCoordinates(
|
||||
api: HomeAPI,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
address: String?,
|
||||
type: Int
|
||||
) async {
|
||||
do {
|
||||
let result = try await locationReportService.reportWithCoordinates(
|
||||
api: api,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
address: address,
|
||||
type: type
|
||||
)
|
||||
applyReportSuccess(result)
|
||||
onShowMessage?("位置上报成功")
|
||||
} catch let error as LocationReportServiceError {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyReportSuccess(_ result: LocationReportSuccessResult) {
|
||||
if result.shouldShowSuccessDialog {
|
||||
reportTimeText = result.reportTimeText
|
||||
showLocationReportSuccessDialog = true
|
||||
}
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
@ -382,65 +382,56 @@ final class HomeViewController: BaseViewController {
|
||||
}
|
||||
|
||||
private func presentOnlineStatusDialog() {
|
||||
let goingOnline = !viewModel.isOnline
|
||||
let alert = UIAlertController(
|
||||
title: goingOnline ? "切换在线" : "切换离线",
|
||||
message: goingOnline ? "确认切换到在线状态并上报位置?" : "确认切换到离线状态?",
|
||||
preferredStyle: .alert
|
||||
viewModel.hideOnlineStatusSwitchDialog()
|
||||
LocationReportDialogPresenter.presentOnlineStatusSwitch(
|
||||
from: self,
|
||||
isOnline: viewModel.isOnline,
|
||||
onConfirm: { [weak self] in
|
||||
guard let self else { return }
|
||||
self.activeDialog = nil
|
||||
Task { await self.viewModel.switchOnlineStatus(api: self.homeAPI) }
|
||||
},
|
||||
onCancel: { [weak self] in
|
||||
self?.viewModel.hideOnlineStatusSwitchDialog()
|
||||
self?.activeDialog = nil
|
||||
}
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel) { [weak self] _ in
|
||||
self?.viewModel.hideOnlineStatusSwitchDialog()
|
||||
self?.activeDialog = nil
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.activeDialog = nil
|
||||
Task { await self.viewModel.switchOnlineStatus(api: self.homeAPI) }
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func presentLocationTimeoutDialog() {
|
||||
let alert = UIAlertController(
|
||||
title: "位置上报提醒",
|
||||
message: "您的位置上报即将超时,请立即上报",
|
||||
preferredStyle: .alert
|
||||
viewModel.hideLocationTimeoutDialog()
|
||||
LocationReportDialogPresenter.presentLocationTimeout(
|
||||
from: self,
|
||||
onReport: { [weak self] in
|
||||
guard let self else { return }
|
||||
self.activeDialog = nil
|
||||
Task { await self.viewModel.confirmLocationTimeoutReport(api: self.homeAPI) }
|
||||
},
|
||||
onLater: { [weak self] in
|
||||
self?.viewModel.hideLocationTimeoutDialog()
|
||||
self?.activeDialog = nil
|
||||
}
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "稍后", style: .cancel) { [weak self] _ in
|
||||
self?.viewModel.hideLocationTimeoutDialog()
|
||||
self?.activeDialog = nil
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "立即上报", style: .default) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.activeDialog = nil
|
||||
Task { await self.viewModel.confirmLocationTimeoutReport(api: self.homeAPI) }
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func presentLocationSuccessDialog() {
|
||||
let alert = UIAlertController(
|
||||
title: "上报成功",
|
||||
message: "上报时间:\(viewModel.reportTimeText)",
|
||||
preferredStyle: .alert
|
||||
let reportTime = viewModel.reportTimeText
|
||||
let countdown = viewModel.countdownDisplayText
|
||||
viewModel.hideLocationReportSuccessDialog()
|
||||
LocationReportDialogPresenter.presentReportSuccess(
|
||||
from: self,
|
||||
reportTime: reportTime,
|
||||
countdown: countdown,
|
||||
onDismiss: { [weak self] in
|
||||
self?.activeDialog = nil
|
||||
}
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self] _ in
|
||||
self?.viewModel.hideLocationReportSuccessDialog()
|
||||
self?.activeDialog = nil
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func presentReminderPicker() {
|
||||
let alert = UIAlertController(title: "提前提醒", message: "选择位置超时提前提醒时间", preferredStyle: .actionSheet)
|
||||
[0, 5, 10, 15, 30].forEach { minutes in
|
||||
let title = minutes == 0 ? "不提醒" : "提前\(minutes)分钟"
|
||||
alert.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
|
||||
self?.viewModel.updateReminderMinutes(minutes)
|
||||
})
|
||||
LocationReportDialogPresenter.presentReminderPicker(from: self) { [weak self] minutes in
|
||||
self?.viewModel.updateReminderMinutes(minutes)
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func presentScenicSelection() {
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
//
|
||||
// LocationReportDialogPresenter.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 位置上报相关弹窗,对齐 Android 文案。
|
||||
enum LocationReportDialogPresenter {
|
||||
|
||||
/// 展示切换在线状态确认弹窗。
|
||||
@MainActor
|
||||
static func presentOnlineStatusSwitch(
|
||||
from viewController: UIViewController,
|
||||
isOnline: Bool,
|
||||
onConfirm: @escaping () -> Void,
|
||||
onCancel: (() -> Void)? = nil
|
||||
) {
|
||||
let message = isOnline
|
||||
? "是否确认切换为离线状态?\n离线后将暂停位置上报,直到下次手动切换为在线状态"
|
||||
: "是否确认切换为在线状态?\n在线后将开始位置上报和计时"
|
||||
let alert = UIAlertController(title: "切换在线状态", message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel) { _ in onCancel?() })
|
||||
alert.addAction(UIAlertAction(title: "确定", style: .default) { _ in onConfirm() })
|
||||
viewController.present(alert, animated: true)
|
||||
}
|
||||
|
||||
/// 展示上报成功弹窗。
|
||||
@MainActor
|
||||
static func presentReportSuccess(
|
||||
from viewController: UIViewController,
|
||||
reportTime: String,
|
||||
countdown: String,
|
||||
onDismiss: (() -> Void)? = nil
|
||||
) {
|
||||
let message = "上报已成功,上报时间为\(reportTime)\n距离下次上报时间\(countdown)"
|
||||
let alert = UIAlertController(title: "上报成功", message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "确定", style: .default) { _ in onDismiss?() })
|
||||
viewController.present(alert, animated: true)
|
||||
}
|
||||
|
||||
/// 展示位置超时提醒弹窗。
|
||||
@MainActor
|
||||
static func presentLocationTimeout(
|
||||
from viewController: UIViewController,
|
||||
onReport: @escaping () -> Void,
|
||||
onLater: (() -> Void)? = nil
|
||||
) {
|
||||
let alert = UIAlertController(
|
||||
title: "位置上报提醒",
|
||||
message: "倒计时即将结束,请立即上报位置",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "稍后", style: .cancel) { _ in onLater?() })
|
||||
alert.addAction(UIAlertAction(title: "立即上报", style: .default) { _ in onReport() })
|
||||
viewController.present(alert, animated: true)
|
||||
}
|
||||
|
||||
/// 展示提醒设置选择器。
|
||||
@MainActor
|
||||
static func presentReminderPicker(
|
||||
from viewController: UIViewController,
|
||||
onSelect: @escaping (Int) -> Void
|
||||
) {
|
||||
let alert = UIAlertController(title: "提醒设置", message: "提前提醒时间", preferredStyle: .actionSheet)
|
||||
[0, 5, 10, 15, 30].forEach { minutes in
|
||||
let title = minutes == 0 ? "不提醒" : "提前\(minutes)分钟"
|
||||
alert.addAction(UIAlertAction(title: title, style: .default) { _ in onSelect(minutes) })
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
viewController.present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,184 @@
|
||||
//
|
||||
// LocationReportHistoryViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 位置上报历史页,对齐 Android `LocationReportHistoryScreen`。
|
||||
final class LocationReportHistoryViewController: BaseViewController, UITableViewDelegate, UITableViewDataSource {
|
||||
|
||||
private let viewModel: LocationReportHistoryViewModel
|
||||
private let homeAPI: HomeAPI
|
||||
|
||||
private let filterBar = LocationReportHistoryFilterBar()
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
private let emptyLabel = UILabel()
|
||||
private let footerLabel = UILabel()
|
||||
private let refreshControl = UIRefreshControl()
|
||||
|
||||
init(
|
||||
viewModel: LocationReportHistoryViewModel = LocationReportHistoryViewModel(),
|
||||
homeAPI: HomeAPI = NetworkServices.shared.homeAPI
|
||||
) {
|
||||
self.viewModel = viewModel
|
||||
self.homeAPI = homeAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "位置上报历史"
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
tableView.delegate = self
|
||||
tableView.dataSource = self
|
||||
tableView.separatorStyle = .none
|
||||
tableView.backgroundColor = AppColor.pageBackground
|
||||
tableView.register(LocationReportHistoryCell.self, forCellReuseIdentifier: LocationReportHistoryCell.reuseIdentifier)
|
||||
tableView.refreshControl = refreshControl
|
||||
|
||||
emptyLabel.text = "暂无位置上报记录"
|
||||
emptyLabel.textColor = AppColor.textTertiary
|
||||
emptyLabel.textAlignment = .center
|
||||
emptyLabel.isHidden = true
|
||||
|
||||
footerLabel.text = "没有更多"
|
||||
footerLabel.font = .systemFont(ofSize: 13)
|
||||
footerLabel.textColor = AppColor.textTertiary
|
||||
footerLabel.textAlignment = .center
|
||||
footerLabel.isHidden = true
|
||||
|
||||
view.addSubview(filterBar)
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(emptyLabel)
|
||||
view.addSubview(footerLabel)
|
||||
|
||||
filterBar.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
||||
}
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.top.equalTo(filterBar.snp.bottom)
|
||||
make.leading.trailing.bottom.equalTo(view.safeAreaLayoutGuide)
|
||||
}
|
||||
emptyLabel.snp.makeConstraints { make in
|
||||
make.center.equalTo(tableView)
|
||||
}
|
||||
footerLabel.snp.makeConstraints { make in
|
||||
make.centerX.equalToSuperview()
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.applyViewModel() }
|
||||
}
|
||||
viewModel.onShowMessage = { [weak self] message in
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
|
||||
refreshControl.addTarget(self, action: #selector(refreshTapped), for: .valueChanged)
|
||||
filterBar.onFilterTap = { [weak self] in self?.presentFilterPicker() }
|
||||
filterBar.onDateFilterTap = { [weak self] in self?.presentDateFilter() }
|
||||
filterBar.onClearDates = { [weak self] in
|
||||
guard let self else { return }
|
||||
Task { await self.viewModel.clearDateRange(api: self.homeAPI) }
|
||||
}
|
||||
|
||||
Task { await viewModel.loadReportList(api: homeAPI, refresh: true) }
|
||||
}
|
||||
|
||||
private func applyViewModel() {
|
||||
filterBar.apply(
|
||||
filterTitle: viewModel.selectedFilter.displayName,
|
||||
startText: viewModel.startDateText,
|
||||
endText: viewModel.endDateText,
|
||||
showsDateRange: viewModel.hasDateFilter
|
||||
)
|
||||
tableView.reloadData()
|
||||
emptyLabel.isHidden = !viewModel.reportList.isEmpty
|
||||
footerLabel.isHidden = viewModel.reportList.isEmpty || viewModel.canLoadMore
|
||||
if !viewModel.isRefreshing {
|
||||
refreshControl.endRefreshing()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func refreshTapped() {
|
||||
Task { await viewModel.loadReportList(api: homeAPI, refresh: true) }
|
||||
}
|
||||
|
||||
private func presentFilterPicker() {
|
||||
let alert = UIAlertController(title: "筛选类型", message: nil, preferredStyle: .actionSheet)
|
||||
LocationReportFilterType.allCases.forEach { filter in
|
||||
alert.addAction(UIAlertAction(title: filter.displayName, style: .default) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { await self.viewModel.selectFilter(filter, api: self.homeAPI) }
|
||||
})
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func presentDateFilter() {
|
||||
let alert = UIAlertController(title: "时间筛选", message: nil, preferredStyle: .actionSheet)
|
||||
alert.addAction(UIAlertAction(title: "选择开始日期", style: .default) { [weak self] _ in
|
||||
self?.presentDatePicker(isStart: true)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "选择结束日期", style: .default) { [weak self] _ in
|
||||
self?.presentDatePicker(isStart: false)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func presentDatePicker(isStart: Bool) {
|
||||
let alert = UIAlertController(title: isStart ? "开始日期" : "结束日期", message: "\n\n\n\n\n\n\n\n", preferredStyle: .actionSheet)
|
||||
let picker = UIDatePicker()
|
||||
picker.datePickerMode = .date
|
||||
if #available(iOS 13.4, *) {
|
||||
picker.preferredDatePickerStyle = .wheels
|
||||
}
|
||||
picker.date = isStart ? (viewModel.startDate ?? Date()) : (viewModel.endDate ?? Date())
|
||||
alert.view.addSubview(picker)
|
||||
picker.snp.makeConstraints { make in
|
||||
make.centerX.equalToSuperview()
|
||||
make.top.equalToSuperview().offset(24)
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task {
|
||||
if isStart {
|
||||
await self.viewModel.setDateRange(start: picker.date, end: self.viewModel.endDate, api: self.homeAPI)
|
||||
} else {
|
||||
await self.viewModel.setDateRange(start: self.viewModel.startDate, end: picker.date, api: self.homeAPI)
|
||||
}
|
||||
}
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
viewModel.reportList.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: LocationReportHistoryCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as! LocationReportHistoryCell
|
||||
cell.apply(item: viewModel.reportList[indexPath.row])
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
Task { await viewModel.loadMoreIfNeeded(currentIndex: indexPath.row, api: homeAPI) }
|
||||
}
|
||||
}
|
||||
177
suixinkan/UI/LocationReport/LocationReportViewController.swift
Normal file
177
suixinkan/UI/LocationReport/LocationReportViewController.swift
Normal file
@ -0,0 +1,177 @@
|
||||
//
|
||||
// LocationReportViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 位置上报独立页,对齐 Android `LocationReportScreen`。
|
||||
final class LocationReportViewController: BaseViewController {
|
||||
|
||||
private let viewModel: LocationReportViewModel
|
||||
private let homeAPI: HomeAPI
|
||||
|
||||
private let mapContainer = LocationReportMapView()
|
||||
private let mapControls = LocationReportMapControlStack()
|
||||
private let statusCard = LocationReportStatusCardView()
|
||||
private let actionCard = LocationReportActionCardView()
|
||||
private let markButton = LocationReportBottomActionView(title: "标记上报", symbol: "mappin.and.ellipse")
|
||||
private let historyButton = LocationReportBottomActionView(title: "历史记录", symbol: "clock")
|
||||
private let onlineButton = LocationReportBottomActionView(title: "离线", symbol: "power")
|
||||
private var hasCenteredMap = false
|
||||
|
||||
init(
|
||||
viewModel: LocationReportViewModel = LocationReportViewModel(),
|
||||
homeAPI: HomeAPI = NetworkServices.shared.homeAPI
|
||||
) {
|
||||
self.viewModel = viewModel
|
||||
self.homeAPI = homeAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "位置上报"
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = AppColor.pageBackground
|
||||
|
||||
let bottomStack = UIStackView(arrangedSubviews: [markButton, historyButton, onlineButton])
|
||||
bottomStack.axis = .horizontal
|
||||
bottomStack.spacing = 12
|
||||
bottomStack.distribution = .fillEqually
|
||||
|
||||
let panelStack = UIStackView(arrangedSubviews: [statusCard, actionCard, bottomStack])
|
||||
panelStack.axis = .vertical
|
||||
panelStack.spacing = 12
|
||||
|
||||
view.addSubview(mapContainer)
|
||||
view.addSubview(mapControls)
|
||||
view.addSubview(panelStack)
|
||||
|
||||
mapContainer.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
||||
make.height.equalTo(view.snp.height).multipliedBy(0.45)
|
||||
}
|
||||
mapControls.snp.makeConstraints { make in
|
||||
make.trailing.equalTo(mapContainer).offset(-16)
|
||||
make.centerY.equalTo(mapContainer)
|
||||
}
|
||||
panelStack.snp.makeConstraints { make in
|
||||
make.top.equalTo(mapContainer.snp.bottom).offset(-20)
|
||||
make.leading.trailing.equalToSuperview().inset(15)
|
||||
make.bottom.lessThanOrEqualTo(view.safeAreaLayoutGuide).offset(-12)
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.applyViewModel() }
|
||||
}
|
||||
viewModel.onShowMessage = { [weak self] message in
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
|
||||
mapControls.onZoomIn = { [weak self] in self?.mapContainer.zoomIn() }
|
||||
mapControls.onZoomOut = { [weak self] in self?.mapContainer.zoomOut() }
|
||||
mapControls.onRelocate = { [weak self] in
|
||||
self?.viewModel.startLocation()
|
||||
self?.mapContainer.centerOnUserLocation()
|
||||
}
|
||||
mapContainer.onMapTap = { [weak self] coordinate in
|
||||
self?.viewModel.setMarkerLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
|
||||
}
|
||||
|
||||
statusCard.onOnlineStatusTap = { [weak self] in self?.viewModel.showOnlineStatusSwitchDialog() }
|
||||
statusCard.onReminderTap = { [weak self] in
|
||||
guard let self else { return }
|
||||
LocationReportDialogPresenter.presentReminderPicker(from: self) { minutes in
|
||||
self.viewModel.updateReminderMinutes(minutes)
|
||||
}
|
||||
}
|
||||
actionCard.onReportTap = { [weak self] in
|
||||
guard let self else { return }
|
||||
Task { await self.viewModel.reportLocation(api: self.homeAPI) }
|
||||
}
|
||||
markButton.addTarget(self, action: #selector(markTapped), for: .touchUpInside)
|
||||
historyButton.addTarget(self, action: #selector(historyTapped), for: .touchUpInside)
|
||||
onlineButton.addTarget(self, action: #selector(onlineTapped), for: .touchUpInside)
|
||||
|
||||
viewModel.restoreStateIfNeeded()
|
||||
requestLocationIfNeeded()
|
||||
applyViewModel()
|
||||
}
|
||||
|
||||
private func requestLocationIfNeeded() {
|
||||
let status = CLLocationManager.authorizationStatus()
|
||||
switch status {
|
||||
case .authorizedAlways, .authorizedWhenInUse:
|
||||
viewModel.startLocation()
|
||||
case .notDetermined:
|
||||
CLLocationManager().requestWhenInUseAuthorization()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
self?.viewModel.startLocation()
|
||||
}
|
||||
default:
|
||||
showToast("需要定位权限才能使用此功能")
|
||||
}
|
||||
}
|
||||
|
||||
private func applyViewModel() {
|
||||
statusCard.apply(
|
||||
isOnline: viewModel.isOnline,
|
||||
countdown: viewModel.countdownDisplayText,
|
||||
reminderMinutes: viewModel.reminderMinutes
|
||||
)
|
||||
actionCard.setReporting(viewModel.isReporting)
|
||||
markButton.isSelected = viewModel.isMarking
|
||||
onlineButton.applyOnlineStyle(isOnline: viewModel.isOnline)
|
||||
|
||||
mapContainer.updateMarker(latitude: viewModel.markerLatitude, longitude: viewModel.markerLongitude)
|
||||
if !hasCenteredMap, let lat = viewModel.currentLatitude, let lng = viewModel.currentLongitude {
|
||||
mapContainer.setCenter(CLLocationCoordinate2D(latitude: lat, longitude: lng), animated: false)
|
||||
hasCenteredMap = true
|
||||
}
|
||||
|
||||
if viewModel.showOnlineStatusDialog {
|
||||
viewModel.hideOnlineStatusSwitchDialog()
|
||||
LocationReportDialogPresenter.presentOnlineStatusSwitch(
|
||||
from: self,
|
||||
isOnline: viewModel.isOnline
|
||||
) { [weak self] in
|
||||
guard let self else { return }
|
||||
Task { await self.viewModel.switchOnlineStatus(api: self.homeAPI) }
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.showLocationReportSuccessDialog {
|
||||
let reportTime = viewModel.reportTimeText
|
||||
let countdown = viewModel.countdownDisplayText
|
||||
viewModel.hideLocationReportSuccessDialog()
|
||||
LocationReportDialogPresenter.presentReportSuccess(
|
||||
from: self,
|
||||
reportTime: reportTime,
|
||||
countdown: countdown
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func markTapped() {
|
||||
viewModel.toggleMarking()
|
||||
}
|
||||
|
||||
@objc private func historyTapped() {
|
||||
navigationController?.pushViewController(LocationReportHistoryViewController(), animated: true)
|
||||
}
|
||||
|
||||
@objc private func onlineTapped() {
|
||||
viewModel.showOnlineStatusSwitchDialog()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,177 @@
|
||||
//
|
||||
// LocationReportHistoryViews.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 历史上报列表卡片。
|
||||
final class LocationReportHistoryCell: UITableViewCell {
|
||||
|
||||
static let reuseIdentifier = "LocationReportHistoryCell"
|
||||
|
||||
private let typeBadge = UILabel()
|
||||
private let timeLabel = UILabel()
|
||||
private let coordinateLabel = UILabel()
|
||||
private let addressLabel = UILabel()
|
||||
private let remarkLabel = UILabel()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
|
||||
let card = UIView()
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = 10
|
||||
|
||||
typeBadge.font = .systemFont(ofSize: 12, weight: .medium)
|
||||
typeBadge.textAlignment = .center
|
||||
typeBadge.layer.cornerRadius = 4
|
||||
typeBadge.clipsToBounds = true
|
||||
|
||||
timeLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
timeLabel.textColor = AppColor.textPrimary
|
||||
|
||||
coordinateLabel.font = .systemFont(ofSize: 12)
|
||||
coordinateLabel.textColor = AppColor.textSecondary
|
||||
|
||||
addressLabel.font = .systemFont(ofSize: 13)
|
||||
addressLabel.textColor = AppColor.textPrimary
|
||||
addressLabel.numberOfLines = 2
|
||||
|
||||
remarkLabel.font = .systemFont(ofSize: 12)
|
||||
remarkLabel.textColor = AppColor.textTertiary
|
||||
remarkLabel.numberOfLines = 2
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [timeLabel, coordinateLabel, addressLabel, remarkLabel])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 6
|
||||
|
||||
card.addSubview(typeBadge)
|
||||
card.addSubview(stack)
|
||||
contentView.addSubview(card)
|
||||
|
||||
card.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 6, left: 16, bottom: 6, right: 16))
|
||||
}
|
||||
typeBadge.snp.makeConstraints { make in
|
||||
make.top.leading.equalToSuperview().offset(12)
|
||||
make.height.equalTo(22)
|
||||
make.width.greaterThanOrEqualTo(64)
|
||||
}
|
||||
stack.snp.makeConstraints { make in
|
||||
make.top.equalTo(typeBadge.snp.bottom).offset(8)
|
||||
make.leading.trailing.bottom.equalToSuperview().inset(12)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(item: LocationReportHistoryItem) {
|
||||
let filterType = item.filterType
|
||||
typeBadge.text = " \(filterType.displayName) "
|
||||
typeBadge.textColor = UIColor(hex: filterType.tagColorHex)
|
||||
typeBadge.backgroundColor = UIColor(hex: filterType.tagColorHex).withAlphaComponent(0.12)
|
||||
timeLabel.text = item.createdAt.isEmpty ? "--" : item.createdAt
|
||||
coordinateLabel.text = "经纬度:\(item.latitude), \(item.longitude)"
|
||||
addressLabel.text = item.address.isEmpty ? "--" : item.address
|
||||
if item.remark.isEmpty {
|
||||
remarkLabel.isHidden = true
|
||||
} else {
|
||||
remarkLabel.isHidden = false
|
||||
remarkLabel.text = "备注:\(item.remark)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 筛选与日期工具栏。
|
||||
final class LocationReportHistoryFilterBar: UIView {
|
||||
|
||||
var onFilterTap: (() -> Void)?
|
||||
var onDateFilterTap: (() -> Void)?
|
||||
var onClearDates: (() -> Void)?
|
||||
|
||||
private let filterButton = UIButton(type: .system)
|
||||
private let dateButton = UIButton(type: .system)
|
||||
private let startLabel = UILabel()
|
||||
private let endLabel = UILabel()
|
||||
private let clearButton = UIButton(type: .system)
|
||||
private let dateRangeStack = UIStackView()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white
|
||||
|
||||
filterButton.setTitle("全部", for: .normal)
|
||||
filterButton.setTitleColor(AppColor.textPrimary, for: .normal)
|
||||
filterButton.titleLabel?.font = .systemFont(ofSize: 14)
|
||||
filterButton.setImage(UIImage(systemName: "chevron.down"), for: .normal)
|
||||
filterButton.semanticContentAttribute = .forceRightToLeft
|
||||
filterButton.addTarget(self, action: #selector(filterTapped), for: .touchUpInside)
|
||||
|
||||
dateButton.setTitle("时间筛选", for: .normal)
|
||||
dateButton.setTitleColor(AppColor.primary, for: .normal)
|
||||
dateButton.titleLabel?.font = .systemFont(ofSize: 14)
|
||||
dateButton.addTarget(self, action: #selector(dateTapped), for: .touchUpInside)
|
||||
|
||||
startLabel.font = .systemFont(ofSize: 13)
|
||||
endLabel.font = .systemFont(ofSize: 13)
|
||||
startLabel.textColor = AppColor.textSecondary
|
||||
endLabel.textColor = AppColor.textSecondary
|
||||
|
||||
clearButton.setTitle("清除", for: .normal)
|
||||
clearButton.setTitleColor(AppColor.danger, for: .normal)
|
||||
clearButton.titleLabel?.font = .systemFont(ofSize: 13)
|
||||
clearButton.addTarget(self, action: #selector(clearTapped), for: .touchUpInside)
|
||||
|
||||
dateRangeStack.axis = .horizontal
|
||||
dateRangeStack.spacing = 8
|
||||
dateRangeStack.alignment = .center
|
||||
dateRangeStack.addArrangedSubview(startLabel)
|
||||
dateRangeStack.addArrangedSubview(UILabel(text: "至"))
|
||||
dateRangeStack.addArrangedSubview(endLabel)
|
||||
dateRangeStack.addArrangedSubview(clearButton)
|
||||
dateRangeStack.isHidden = true
|
||||
|
||||
let topRow = UIStackView(arrangedSubviews: [filterButton, UIView(), dateButton])
|
||||
topRow.axis = .horizontal
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [topRow, dateRangeStack])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 8
|
||||
addSubview(stack)
|
||||
stack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(12)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(filterTitle: String, startText: String, endText: String, showsDateRange: Bool) {
|
||||
filterButton.setTitle(filterTitle, for: .normal)
|
||||
startLabel.text = startText
|
||||
endLabel.text = endText
|
||||
dateRangeStack.isHidden = !showsDateRange
|
||||
}
|
||||
|
||||
@objc private func filterTapped() { onFilterTap?() }
|
||||
@objc private func dateTapped() { onDateFilterTap?() }
|
||||
@objc private func clearTapped() { onClearDates?() }
|
||||
}
|
||||
|
||||
private extension UILabel {
|
||||
convenience init(text: String) {
|
||||
self.init()
|
||||
self.text = text
|
||||
font = .systemFont(ofSize: 13)
|
||||
textColor = AppColor.textSecondary
|
||||
}
|
||||
}
|
||||
357
suixinkan/UI/LocationReport/Views/LocationReportViews.swift
Normal file
357
suixinkan/UI/LocationReport/Views/LocationReportViews.swift
Normal file
@ -0,0 +1,357 @@
|
||||
//
|
||||
// LocationReportViews.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import MapKit
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 地图缩放/定位控件。
|
||||
final class LocationReportMapControlStack: UIView {
|
||||
|
||||
var onZoomIn: (() -> Void)?
|
||||
var onZoomOut: (() -> Void)?
|
||||
var onRelocate: (() -> Void)?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
let zoomIn = makeButton(symbol: "plus", action: #selector(zoomInTapped))
|
||||
let zoomOut = makeButton(symbol: "minus", action: #selector(zoomOutTapped))
|
||||
let relocate = makeButton(symbol: "location.fill", action: #selector(relocateTapped))
|
||||
|
||||
let zoomStack = UIStackView(arrangedSubviews: [zoomIn, zoomOut])
|
||||
zoomStack.axis = .vertical
|
||||
zoomStack.spacing = 0
|
||||
zoomStack.backgroundColor = .white
|
||||
zoomStack.layer.cornerRadius = 12
|
||||
zoomStack.clipsToBounds = true
|
||||
|
||||
relocate.backgroundColor = .white
|
||||
relocate.layer.cornerRadius = 12
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [zoomStack, relocate])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 12
|
||||
addSubview(stack)
|
||||
stack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
[zoomIn, zoomOut, relocate].forEach { button in
|
||||
button.snp.makeConstraints { make in
|
||||
make.size.equalTo(44)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private func makeButton(symbol: String, action: Selector) -> UIButton {
|
||||
let button = UIButton(type: .system)
|
||||
button.setImage(UIImage(systemName: symbol), for: .normal)
|
||||
button.tintColor = AppColor.primary
|
||||
button.addTarget(self, action: action, for: .touchUpInside)
|
||||
return button
|
||||
}
|
||||
|
||||
@objc private func zoomInTapped() { onZoomIn?() }
|
||||
@objc private func zoomOutTapped() { onZoomOut?() }
|
||||
@objc private func relocateTapped() { onRelocate?() }
|
||||
}
|
||||
|
||||
/// 状态信息卡片。
|
||||
final class LocationReportStatusCardView: UIView {
|
||||
|
||||
var onOnlineStatusTap: (() -> Void)?
|
||||
var onReminderTap: (() -> Void)?
|
||||
|
||||
private let onlineBadge = UILabel()
|
||||
private let countdownLabel = UILabel()
|
||||
private let reminderLabel = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white
|
||||
layer.cornerRadius = 10
|
||||
|
||||
let statusTitle = makeTitle("当前状态")
|
||||
onlineBadge.font = .systemFont(ofSize: 12, weight: .medium)
|
||||
onlineBadge.textAlignment = .center
|
||||
onlineBadge.layer.cornerRadius = 4
|
||||
onlineBadge.clipsToBounds = true
|
||||
onlineBadge.isUserInteractionEnabled = true
|
||||
onlineBadge.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(onlineTapped)))
|
||||
|
||||
let countdownTitle = makeTitle("距离下次上报时间")
|
||||
countdownLabel.font = .systemFont(ofSize: 16, weight: .medium)
|
||||
countdownLabel.textColor = AppColor.primary
|
||||
|
||||
let reminderTitle = makeTitle("提醒设置")
|
||||
reminderLabel.font = .systemFont(ofSize: 16, weight: .medium)
|
||||
reminderLabel.textColor = AppColor.primary
|
||||
reminderLabel.isUserInteractionEnabled = true
|
||||
reminderLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(reminderTapped)))
|
||||
|
||||
let stack = UIStackView()
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 12
|
||||
stack.addArrangedSubview(makeRow(left: statusTitle, right: onlineBadge))
|
||||
stack.addArrangedSubview(makeRow(left: countdownTitle, right: countdownLabel))
|
||||
stack.addArrangedSubview(makeRow(left: reminderTitle, right: reminderLabel))
|
||||
addSubview(stack)
|
||||
stack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(15)
|
||||
}
|
||||
onlineBadge.snp.makeConstraints { make in
|
||||
make.width.greaterThanOrEqualTo(52)
|
||||
make.height.equalTo(28)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(isOnline: Bool, countdown: String, reminderMinutes: Int) {
|
||||
onlineBadge.text = isOnline ? "在线" : "离线"
|
||||
onlineBadge.textColor = isOnline ? AppColor.online : UIColor(hex: 0x7B8EAA)
|
||||
onlineBadge.backgroundColor = isOnline ? UIColor(hex: 0xF0FDF4) : AppColor.inputBackground
|
||||
countdownLabel.text = countdown
|
||||
reminderLabel.text = reminderMinutes == 0 ? "不提醒" : "提前\(reminderMinutes)分钟"
|
||||
}
|
||||
|
||||
@objc private func onlineTapped() { onOnlineStatusTap?() }
|
||||
@objc private func reminderTapped() { onReminderTap?() }
|
||||
|
||||
private func makeTitle(_ text: String) -> UILabel {
|
||||
let label = UILabel()
|
||||
label.text = text
|
||||
label.font = .systemFont(ofSize: 14)
|
||||
label.textColor = AppColor.textPrimary
|
||||
return label
|
||||
}
|
||||
|
||||
private func makeRow(left: UIView, right: UIView) -> UIView {
|
||||
let row = UIStackView(arrangedSubviews: [left, right])
|
||||
row.axis = .horizontal
|
||||
row.distribution = .equalSpacing
|
||||
row.alignment = .center
|
||||
return row
|
||||
}
|
||||
}
|
||||
|
||||
/// 立即上报卡片。
|
||||
final class LocationReportActionCardView: UIView {
|
||||
|
||||
var onReportTap: (() -> Void)?
|
||||
|
||||
private let reportButton = UIButton(type: .system)
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white
|
||||
layer.cornerRadius = 10
|
||||
|
||||
let icon = UIImageView(image: UIImage(systemName: "arrow.up"))
|
||||
icon.tintColor = AppColor.primary
|
||||
let title = UILabel()
|
||||
title.text = "立即上报"
|
||||
title.font = .systemFont(ofSize: 16, weight: .bold)
|
||||
title.textColor = AppColor.textPrimary
|
||||
let subtitle = UILabel()
|
||||
subtitle.text = "您已进入打卡范围"
|
||||
subtitle.font = .systemFont(ofSize: 12)
|
||||
subtitle.textColor = AppColor.textTertiary
|
||||
|
||||
let textStack = UIStackView(arrangedSubviews: [title, subtitle])
|
||||
textStack.axis = .vertical
|
||||
textStack.spacing = 4
|
||||
|
||||
reportButton.backgroundColor = AppColor.primary
|
||||
reportButton.tintColor = .white
|
||||
reportButton.setImage(UIImage(systemName: "location.fill"), for: .normal)
|
||||
reportButton.layer.cornerRadius = 36
|
||||
reportButton.addTarget(self, action: #selector(reportTapped), for: .touchUpInside)
|
||||
|
||||
addSubview(icon)
|
||||
addSubview(textStack)
|
||||
addSubview(reportButton)
|
||||
|
||||
icon.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(15)
|
||||
make.centerY.equalToSuperview()
|
||||
make.size.equalTo(24)
|
||||
}
|
||||
textStack.snp.makeConstraints { make in
|
||||
make.leading.equalTo(icon.snp.trailing).offset(8)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
reportButton.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().offset(-15)
|
||||
make.centerY.equalToSuperview()
|
||||
make.size.equalTo(72)
|
||||
}
|
||||
snp.makeConstraints { make in
|
||||
make.height.equalTo(96)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func setReporting(_ reporting: Bool) {
|
||||
reportButton.isEnabled = !reporting
|
||||
reportButton.alpha = reporting ? 0.6 : 1
|
||||
}
|
||||
|
||||
@objc private func reportTapped() { onReportTap?() }
|
||||
}
|
||||
|
||||
/// 底部快捷操作按钮。
|
||||
final class LocationReportBottomActionView: UIControl {
|
||||
|
||||
private let iconView = UIImageView()
|
||||
private let titleLabel = UILabel()
|
||||
|
||||
init(title: String, symbol: String) {
|
||||
super.init(frame: .zero)
|
||||
backgroundColor = .white
|
||||
layer.cornerRadius = 10
|
||||
|
||||
iconView.image = UIImage(systemName: symbol)
|
||||
iconView.tintColor = AppColor.primary
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
|
||||
titleLabel.text = title
|
||||
titleLabel.font = .systemFont(ofSize: 12)
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [iconView, titleLabel])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 4
|
||||
stack.alignment = .center
|
||||
stack.isUserInteractionEnabled = false
|
||||
addSubview(stack)
|
||||
stack.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
}
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.size.equalTo(24)
|
||||
}
|
||||
snp.makeConstraints { make in
|
||||
make.height.equalTo(80)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override var isSelected: Bool {
|
||||
didSet { applySelectedStyle() }
|
||||
}
|
||||
|
||||
func applyOnlineStyle(isOnline: Bool) {
|
||||
if isOnline {
|
||||
backgroundColor = AppColor.online
|
||||
iconView.tintColor = .white
|
||||
titleLabel.text = "在线"
|
||||
titleLabel.textColor = .white
|
||||
} else {
|
||||
backgroundColor = .white
|
||||
iconView.tintColor = UIColor(hex: 0x7B8EAA)
|
||||
titleLabel.text = "离线"
|
||||
titleLabel.textColor = UIColor(hex: 0x7B8EAA)
|
||||
}
|
||||
}
|
||||
|
||||
private func applySelectedStyle() {
|
||||
guard titleLabel.text == "标记上报" else { return }
|
||||
if isSelected {
|
||||
backgroundColor = AppColor.online
|
||||
iconView.tintColor = UIColor(hex: 0xF0FDF4)
|
||||
titleLabel.textColor = UIColor(hex: 0xF0FDF4)
|
||||
} else {
|
||||
backgroundColor = .white
|
||||
iconView.tintColor = AppColor.primary
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 地图容器,封装 MKMapView。
|
||||
final class LocationReportMapView: UIView, MKMapViewDelegate {
|
||||
|
||||
var onMapTap: ((CLLocationCoordinate2D) -> Void)?
|
||||
|
||||
let mapView = MKMapView()
|
||||
private var markerAnnotation: MKPointAnnotation?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
mapView.delegate = self
|
||||
mapView.showsUserLocation = true
|
||||
mapView.showsCompass = false
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
|
||||
mapView.addGestureRecognizer(tap)
|
||||
addSubview(mapView)
|
||||
mapView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func centerOnUserLocation() {
|
||||
guard let coordinate = mapView.userLocation.location?.coordinate else { return }
|
||||
setCenter(coordinate)
|
||||
}
|
||||
|
||||
func setCenter(_ coordinate: CLLocationCoordinate2D, animated: Bool = true) {
|
||||
let region = MKCoordinateRegion(center: coordinate, latitudinalMeters: 800, longitudinalMeters: 800)
|
||||
mapView.setRegion(region, animated: animated)
|
||||
}
|
||||
|
||||
func zoomIn() {
|
||||
var region = mapView.region
|
||||
region.span.latitudeDelta *= 0.5
|
||||
region.span.longitudeDelta *= 0.5
|
||||
mapView.setRegion(region, animated: true)
|
||||
}
|
||||
|
||||
func zoomOut() {
|
||||
var region = mapView.region
|
||||
region.span.latitudeDelta = min(region.span.latitudeDelta * 2, 180)
|
||||
region.span.longitudeDelta = min(region.span.longitudeDelta * 2, 180)
|
||||
mapView.setRegion(region, animated: true)
|
||||
}
|
||||
|
||||
func updateMarker(latitude: Double?, longitude: Double?) {
|
||||
if let annotation = markerAnnotation {
|
||||
mapView.removeAnnotation(annotation)
|
||||
markerAnnotation = nil
|
||||
}
|
||||
guard let latitude, let longitude else { return }
|
||||
let annotation = MKPointAnnotation()
|
||||
annotation.coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
|
||||
markerAnnotation = annotation
|
||||
mapView.addAnnotation(annotation)
|
||||
}
|
||||
|
||||
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
|
||||
let point = gesture.location(in: mapView)
|
||||
let coordinate = mapView.convert(point, toCoordinateFrom: mapView)
|
||||
onMapTap?(coordinate)
|
||||
}
|
||||
}
|
||||
77
suixinkanTests/LocationReportHistoryViewModelTests.swift
Normal file
77
suixinkanTests/LocationReportHistoryViewModelTests.swift
Normal file
@ -0,0 +1,77 @@
|
||||
//
|
||||
// LocationReportHistoryViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 位置上报历史 ViewModel 测试。
|
||||
final class LocationReportHistoryViewModelTests: XCTestCase {
|
||||
|
||||
private var appStore: AppStore!
|
||||
private var defaults: UserDefaults!
|
||||
|
||||
override func setUp() {
|
||||
defaults = UserDefaults(suiteName: "LocationReportHistoryViewModelTests")!
|
||||
defaults.removePersistentDomain(forName: "LocationReportHistoryViewModelTests")
|
||||
appStore = AppStore(defaults: defaults)
|
||||
appStore.userId = "staff-99"
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
appStore.logout()
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testLoadReportListPopulatesItems() async throws {
|
||||
let listJSON = """
|
||||
{"code":100000,"msg":"success","data":{"list":[{"id":1,"staff_id":99,"type":1,"latitude":"30.1","longitude":"120.2","address":"测试地址","remark":"","created_at":"2026-01-01 10:00:00"}],"total":1}}
|
||||
""".data(using: .utf8)!
|
||||
let api = HomeAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [listJSON])))
|
||||
let viewModel = LocationReportHistoryViewModel(appStore: appStore)
|
||||
|
||||
await viewModel.loadReportList(api: api, refresh: true)
|
||||
|
||||
XCTAssertEqual(viewModel.reportList.count, 1)
|
||||
XCTAssertEqual(viewModel.reportList.first?.address, "测试地址")
|
||||
XCTAssertFalse(viewModel.canLoadMore)
|
||||
}
|
||||
|
||||
func testSelectFilterReloadsWithTypeCode() async throws {
|
||||
let allJSON = """
|
||||
{"code":100000,"msg":"success","data":{"list":[{"id":1,"staff_id":99,"type":1,"latitude":"30","longitude":"120","address":"A","remark":"","created_at":"2026-01-01 10:00:00"}],"total":1}}
|
||||
""".data(using: .utf8)!
|
||||
let markedJSON = """
|
||||
{"code":100000,"msg":"success","data":{"list":[{"id":2,"staff_id":99,"type":2,"latitude":"31","longitude":"121","address":"B","remark":"","created_at":"2026-01-02 10:00:00"}],"total":1}}
|
||||
""".data(using: .utf8)!
|
||||
let api = HomeAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [allJSON, markedJSON])))
|
||||
let viewModel = LocationReportHistoryViewModel(appStore: appStore)
|
||||
|
||||
await viewModel.loadReportList(api: api, refresh: true)
|
||||
await viewModel.selectFilter(.marked, api: api)
|
||||
|
||||
XCTAssertEqual(viewModel.selectedFilter, .marked)
|
||||
XCTAssertEqual(viewModel.reportList.first?.type, 2)
|
||||
XCTAssertEqual(viewModel.reportList.first?.address, "B")
|
||||
}
|
||||
|
||||
func testPaginationAppendsItems() async throws {
|
||||
let page1JSON = """
|
||||
{"code":100000,"msg":"success","data":{"list":[{"id":1,"staff_id":99,"type":1,"latitude":"30","longitude":"120","address":"第一页","remark":"","created_at":"2026-01-01 10:00:00"}],"total":2}}
|
||||
""".data(using: .utf8)!
|
||||
let page2JSON = """
|
||||
{"code":100000,"msg":"success","data":{"list":[{"id":2,"staff_id":99,"type":1,"latitude":"31","longitude":"121","address":"第二页","remark":"","created_at":"2026-01-02 10:00:00"}],"total":2}}
|
||||
""".data(using: .utf8)!
|
||||
let api = HomeAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [page1JSON, page2JSON])))
|
||||
let viewModel = LocationReportHistoryViewModel(appStore: appStore)
|
||||
|
||||
await viewModel.loadReportList(api: api, refresh: true)
|
||||
await viewModel.loadMoreIfNeeded(currentIndex: 0, api: api)
|
||||
|
||||
XCTAssertEqual(viewModel.reportList.count, 2)
|
||||
XCTAssertEqual(viewModel.reportList.last?.address, "第二页")
|
||||
XCTAssertFalse(viewModel.canLoadMore)
|
||||
}
|
||||
}
|
||||
98
suixinkanTests/LocationReportServiceTests.swift
Normal file
98
suixinkanTests/LocationReportServiceTests.swift
Normal file
@ -0,0 +1,98 @@
|
||||
//
|
||||
// LocationReportServiceTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 位置上报共享服务测试。
|
||||
final class LocationReportServiceTests: XCTestCase {
|
||||
|
||||
private var appStore: AppStore!
|
||||
private var defaults: UserDefaults!
|
||||
|
||||
override func setUp() {
|
||||
defaults = UserDefaults(suiteName: "LocationReportServiceTests")!
|
||||
defaults.removePersistentDomain(forName: "LocationReportServiceTests")
|
||||
appStore = AppStore(defaults: defaults)
|
||||
appStore.userId = "staff-1"
|
||||
appStore.currentScenicId = 100
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
appStore.logout()
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testReportWithCoordinatesType2UsesProvidedLocation() async throws {
|
||||
let reportJSON = """
|
||||
{"code":100000,"msg":"success","data":{"staff_id":"staff-1","expired":0,"status":1}}
|
||||
""".data(using: .utf8)!
|
||||
let api = HomeAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [reportJSON])))
|
||||
let stateStore = HomeLocationStateStore(store: appStore)
|
||||
let service = LocationReportService(appStore: appStore, locationStateStore: stateStore)
|
||||
|
||||
let result = try await service.reportWithCoordinates(
|
||||
api: api,
|
||||
latitude: 30.1,
|
||||
longitude: 120.2,
|
||||
address: "标记地址",
|
||||
type: 2
|
||||
)
|
||||
|
||||
XCTAssertTrue(result.shouldShowSuccessDialog)
|
||||
XCTAssertEqual(result.locationInfoText, "标记地址")
|
||||
XCTAssertTrue(stateStore.isOnline)
|
||||
}
|
||||
|
||||
func testTooFrequentManualReportThrows() async throws {
|
||||
let reportJSON = """
|
||||
{"code":100000,"msg":"success","data":{"staff_id":"staff-1","expired":0,"status":1}}
|
||||
""".data(using: .utf8)!
|
||||
let api = HomeAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [reportJSON, reportJSON])))
|
||||
let stateStore = HomeLocationStateStore(store: appStore)
|
||||
let service = LocationReportService(appStore: appStore, locationStateStore: stateStore)
|
||||
|
||||
_ = try await service.reportWithCoordinates(
|
||||
api: api,
|
||||
latitude: 30.0,
|
||||
longitude: 120.0,
|
||||
address: "测试地址",
|
||||
type: 1
|
||||
)
|
||||
do {
|
||||
_ = try await service.reportWithCoordinates(
|
||||
api: api,
|
||||
latitude: 30.0,
|
||||
longitude: 120.0,
|
||||
address: "测试地址",
|
||||
type: 1
|
||||
)
|
||||
XCTFail("Expected too frequent error")
|
||||
} catch let error as LocationReportServiceError {
|
||||
if case .tooFrequent = error {
|
||||
XCTAssertTrue(true)
|
||||
} else {
|
||||
XCTFail("Unexpected error \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testMissingScenicThrows() async {
|
||||
appStore.currentScenicId = 0
|
||||
let api = HomeAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [])))
|
||||
let stateStore = HomeLocationStateStore(store: appStore)
|
||||
let service = LocationReportService(appStore: appStore, locationStateStore: stateStore)
|
||||
|
||||
do {
|
||||
_ = try await service.manualReport(api: api)
|
||||
XCTFail("Expected missing scenic")
|
||||
} catch let error as LocationReportServiceError {
|
||||
XCTAssertEqual(error, .missingScenic)
|
||||
} catch {
|
||||
XCTFail("Unexpected error \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user