diff --git a/suixinkan/Features/Home/API/HomeAPI.swift b/suixinkan/Features/Home/API/HomeAPI.swift index 760461f..b12cdc0 100644 --- a/suixinkan/Features/Home/API/HomeAPI.swift +++ b/suixinkan/Features/Home/API/HomeAPI.swift @@ -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( diff --git a/suixinkan/Features/Home/Models/LocationModels.swift b/suixinkan/Features/Home/Models/LocationModels.swift index 07ebbae..61c5a11 100644 --- a/suixinkan/Features/Home/Models/LocationModels.swift +++ b/suixinkan/Features/Home/Models/LocationModels.swift @@ -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 + } +} diff --git a/suixinkan/Features/Home/Services/HomeLocationProvider.swift b/suixinkan/Features/Home/Services/HomeLocationProvider.swift index 69449d2..2b055e8 100644 --- a/suixinkan/Features/Home/Services/HomeLocationProvider.swift +++ b/suixinkan/Features/Home/Services/HomeLocationProvider.swift @@ -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: "需要定位权限才能使用此功能" } } } diff --git a/suixinkan/Features/Home/Services/HomeLocationStateStore.swift b/suixinkan/Features/Home/Services/HomeLocationStateStore.swift index afcda6d..32b69ea 100644 --- a/suixinkan/Features/Home/Services/HomeLocationStateStore.swift +++ b/suixinkan/Features/Home/Services/HomeLocationStateStore.swift @@ -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) } /// 是否到达提前提醒窗口。 diff --git a/suixinkan/Features/Home/Services/HomeRouteHandler.swift b/suixinkan/Features/Home/Services/HomeRouteHandler.swift index 6b741f5..e67b953 100644 --- a/suixinkan/Features/Home/Services/HomeRouteHandler.swift +++ b/suixinkan/Features/Home/Services/HomeRouteHandler.swift @@ -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: diff --git a/suixinkan/Features/Home/ViewModels/HomeViewModel.swift b/suixinkan/Features/Home/ViewModels/HomeViewModel.swift index f3db0e2..90c3678 100644 --- a/suixinkan/Features/Home/ViewModels/HomeViewModel.swift +++ b/suixinkan/Features/Home/ViewModels/HomeViewModel.swift @@ -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() { diff --git a/suixinkan/Features/LocationReport/Services/LocationReportService.swift b/suixinkan/Features/LocationReport/Services/LocationReportService.swift new file mode 100644 index 0000000..edae9eb --- /dev/null +++ b/suixinkan/Features/LocationReport/Services/LocationReportService.swift @@ -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: + "正在定位中,请稍候" + } + } +} diff --git a/suixinkan/Features/LocationReport/ViewModels/LocationReportHistoryViewModel.swift b/suixinkan/Features/LocationReport/ViewModels/LocationReportHistoryViewModel.swift new file mode 100644 index 0000000..2f59867 --- /dev/null +++ b/suixinkan/Features/LocationReport/ViewModels/LocationReportHistoryViewModel.swift @@ -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?() + } +} diff --git a/suixinkan/Features/LocationReport/ViewModels/LocationReportViewModel.swift b/suixinkan/Features/LocationReport/ViewModels/LocationReportViewModel.swift new file mode 100644 index 0000000..5ed5517 --- /dev/null +++ b/suixinkan/Features/LocationReport/ViewModels/LocationReportViewModel.swift @@ -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?() + } +} diff --git a/suixinkan/UI/Home/HomeViewController.swift b/suixinkan/UI/Home/HomeViewController.swift index 5954e0c..4c597ff 100644 --- a/suixinkan/UI/Home/HomeViewController.swift +++ b/suixinkan/UI/Home/HomeViewController.swift @@ -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() { diff --git a/suixinkan/UI/LocationReport/LocationReportDialogPresenter.swift b/suixinkan/UI/LocationReport/LocationReportDialogPresenter.swift new file mode 100644 index 0000000..0c4acb2 --- /dev/null +++ b/suixinkan/UI/LocationReport/LocationReportDialogPresenter.swift @@ -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) + } +} diff --git a/suixinkan/UI/LocationReport/LocationReportHistoryViewController.swift b/suixinkan/UI/LocationReport/LocationReportHistoryViewController.swift new file mode 100644 index 0000000..0bd346f --- /dev/null +++ b/suixinkan/UI/LocationReport/LocationReportHistoryViewController.swift @@ -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) } + } +} diff --git a/suixinkan/UI/LocationReport/LocationReportViewController.swift b/suixinkan/UI/LocationReport/LocationReportViewController.swift new file mode 100644 index 0000000..0bba36f --- /dev/null +++ b/suixinkan/UI/LocationReport/LocationReportViewController.swift @@ -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() + } +} diff --git a/suixinkan/UI/LocationReport/Views/LocationReportHistoryViews.swift b/suixinkan/UI/LocationReport/Views/LocationReportHistoryViews.swift new file mode 100644 index 0000000..88288f5 --- /dev/null +++ b/suixinkan/UI/LocationReport/Views/LocationReportHistoryViews.swift @@ -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 + } +} diff --git a/suixinkan/UI/LocationReport/Views/LocationReportViews.swift b/suixinkan/UI/LocationReport/Views/LocationReportViews.swift new file mode 100644 index 0000000..c4001d5 --- /dev/null +++ b/suixinkan/UI/LocationReport/Views/LocationReportViews.swift @@ -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) + } +} diff --git a/suixinkanTests/LocationReportHistoryViewModelTests.swift b/suixinkanTests/LocationReportHistoryViewModelTests.swift new file mode 100644 index 0000000..1410813 --- /dev/null +++ b/suixinkanTests/LocationReportHistoryViewModelTests.swift @@ -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) + } +} diff --git a/suixinkanTests/LocationReportServiceTests.swift b/suixinkanTests/LocationReportServiceTests.swift new file mode 100644 index 0000000..84fb99f --- /dev/null +++ b/suixinkanTests/LocationReportServiceTests.swift @@ -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)") + } + } +}