修复离线路径误弹位置上报超时窗,并优化提醒设置与定位 Loading 展示。

离线或倒计时未进入提醒窗口时不再弹出超时提醒;全局 Loading 支持按需展示定位文案,提前提醒选项抽成共用组件。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-29 16:22:55 +08:00
parent 560b52fcc8
commit 63fb0462d6
11 changed files with 188 additions and 108 deletions

View File

@ -71,7 +71,7 @@ Core 模块提供跨业务复用的基础能力,包括网络请求、缓存存
Loading 的可观察状态只在 `GlobalLoadingOverlayHost` 内部订阅,并由 `RootView` 挂载到应用根部。这样切换 Loading 显隐时,只会刷新根部 Overlay不会让当前页面、Tab 根视图或业务子视图形成观察依赖。 Loading 的可观察状态只在 `GlobalLoadingOverlayHost` 内部订阅,并由 `RootView` 挂载到应用根部。这样切换 Loading 显隐时,只会刷新根部 Overlay不会让当前页面、Tab 根视图或业务子视图形成观察依赖。
当前全局 Loading UI 不展示文案;`message` 字段保留为内部展示状态,便于后续需要时恢复文案展示 当前全局 Loading 默认只展示动画;调用方传入 `showsMessage: true` 时,才会在动画下方展示 `message` 文案(如位置上报的「获取定位中」)
全局 Loading 只用于阻塞型等待,例如冷启动恢复、登录、首屏加载、提交表单和核销。列表加载更多、上传进度、按钮内局部反馈继续保留在页面局部状态中。 全局 Loading 只用于阻塞型等待,例如冷启动恢复、登录、首屏加载、提交表单和核销。列表加载更多、上传进度、按钮内局部反馈继续保留在页面局部状态中。

View File

@ -16,10 +16,12 @@ final class GlobalLoadingCenter {
fileprivate let state = GlobalLoadingState() fileprivate let state = GlobalLoadingState()
/// Loading /// Loading
func show(message: String = "") { func show(message: String = "", showsMessage: Bool = false) {
if !message.isEmpty { if !message.isEmpty {
state.message = message state.message = message
} }
state.messageDisplayLevels.append(showsMessage)
state.showsMessage = state.messageDisplayLevels.contains(true)
state.activeCount += 1 state.activeCount += 1
state.isVisible = true state.isVisible = true
} }
@ -27,9 +29,15 @@ final class GlobalLoadingCenter {
/// Loading /// Loading
func hide() { func hide() {
state.activeCount = max(0, state.activeCount - 1) state.activeCount = max(0, state.activeCount - 1)
if !state.messageDisplayLevels.isEmpty {
state.messageDisplayLevels.removeLast()
}
state.showsMessage = state.messageDisplayLevels.contains(true)
guard state.activeCount == 0 else { return } guard state.activeCount == 0 else { return }
state.isVisible = false state.isVisible = false
state.message = "" state.message = ""
state.messageDisplayLevels = []
state.showsMessage = false
} }
/// Loading Loading /// Loading Loading
@ -41,9 +49,10 @@ final class GlobalLoadingCenter {
/// Loading /// Loading
func withLoading<T>( func withLoading<T>(
message: String = "", message: String = "",
showsMessage: Bool = false,
operation: () async throws -> T operation: () async throws -> T
) async rethrows -> T { ) async rethrows -> T {
show(message: message) show(message: message, showsMessage: showsMessage)
defer { hide() } defer { hide() }
return try await operation() return try await operation()
} }
@ -52,10 +61,11 @@ final class GlobalLoadingCenter {
func withOptionalLoading<T>( func withOptionalLoading<T>(
_ enabled: Bool, _ enabled: Bool,
message: String = "", message: String = "",
showsMessage: Bool = false,
operation: () async throws -> T operation: () async throws -> T
) async rethrows -> T { ) async rethrows -> T {
if enabled { if enabled {
return try await withLoading(message: message, operation: operation) return try await withLoading(message: message, showsMessage: showsMessage, operation: operation)
} }
return try await operation() return try await operation()
} }
@ -66,7 +76,8 @@ final class GlobalLoadingCenter {
GlobalLoadingSnapshot( GlobalLoadingSnapshot(
isVisible: state.isVisible, isVisible: state.isVisible,
message: state.message, message: state.message,
activeCount: state.activeCount activeCount: state.activeCount,
showsMessage: state.showsMessage
) )
} }
#endif #endif
@ -77,7 +88,9 @@ final class GlobalLoadingCenter {
fileprivate final class GlobalLoadingState: ObservableObject { fileprivate final class GlobalLoadingState: ObservableObject {
@Published fileprivate var isVisible = false @Published fileprivate var isVisible = false
@Published fileprivate var message = "" @Published fileprivate var message = ""
@Published fileprivate var showsMessage = false
@Published fileprivate var activeCount = 0 @Published fileprivate var activeCount = 0
fileprivate var messageDisplayLevels: [Bool] = []
} }
/// Lottie Loading loading.json /// Lottie Loading loading.json
@ -146,6 +159,14 @@ private struct GlobalLoadingOverlayHost: View {
VStack(spacing: 14) { VStack(spacing: 14) {
loadingAnimation loadingAnimation
if state.showsMessage, !state.message.isEmpty {
Text(state.message)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(AppDesign.textPrimary)
.multilineTextAlignment(.center)
.padding(.horizontal, 8)
.padding(.bottom, 6)
}
} }
.padding(.horizontal, 10) .padding(.horizontal, 10)
.padding(.vertical, 10) .padding(.vertical, 10)
@ -155,10 +176,12 @@ private struct GlobalLoadingOverlayHost: View {
.transition(.opacity) .transition(.opacity)
.zIndex(10_000) .zIndex(10_000)
.accessibilityElement(children: .combine) .accessibilityElement(children: .combine)
.accessibilityLabel("加载中") .accessibilityLabel(state.showsMessage && !state.message.isEmpty ? state.message : "加载中")
} }
} }
.animation(.easeInOut(duration: 0.2), value: state.isVisible) .animation(.easeInOut(duration: 0.2), value: state.isVisible)
.animation(.easeInOut(duration: 0.2), value: state.showsMessage)
.animation(.easeInOut(duration: 0.2), value: state.message)
} }
@ViewBuilder @ViewBuilder
@ -200,6 +223,7 @@ struct GlobalLoadingSnapshot: Equatable {
let isVisible: Bool let isVisible: Bool
let message: String let message: String
let activeCount: Int let activeCount: Int
let showsMessage: Bool
} }
#endif #endif

View File

@ -0,0 +1,29 @@
//
// ReminderTimeOptionsMenu.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import SwiftUI
///
/// 使 `Menu` `confirmationDialog` ScrollView popover
struct ReminderTimeOptionsMenu<Label: View>: View {
let onSelect: (Int) -> Void
@ViewBuilder let label: () -> Label
private let options = [0, 5, 10, 15, 30]
var body: some View {
Menu {
ForEach(options, id: \.self) { minute in
Button(minute == 0 ? "不提醒" : "\(minute)分钟") {
onSelect(minute)
}
}
} label: {
label()
}
}
}

View File

@ -44,6 +44,7 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作
首页工作状态区域由 `HomeLocationViewModel` 驱动,与 `LocationReport` 详情页共享同一实例(在 `RootView` 注入)。 首页工作状态区域由 `HomeLocationViewModel` 驱动,与 `LocationReport` 详情页共享同一实例(在 `RootView` 注入)。
- 在线/离线切换、立即上报、2 小时倒计时和提前提醒均走真实 API。 - 在线/离线切换、立即上报、2 小时倒计时和提前提醒均走真实 API。
- 「位置上报超时」弹窗仅在本地在线且倒计时进入提前提醒窗口时弹出;离线状态不弹窗。
- 本地状态通过 `LocationStateStore` 持久化在线标志、过期时间戳、上次上报时间和提醒分钟数。 - 本地状态通过 `LocationStateStore` 持久化在线标志、过期时间戳、上次上报时间和提醒分钟数。
- 首页大按钮直接触发 GPS 上报;菜单 `location_report` 仍进入详情页。 - 首页大按钮直接触发 GPS 上报;菜单 `location_report` 仍进入详情页。
-`homeMinimalTopRoles` 角色才展示工作状态卡片。 -`homeMinimalTopRoles` 角色才展示工作状态卡片。

View File

@ -61,6 +61,8 @@ final class HomeLocationViewModel: ObservableObject {
isOnline = false isOnline = false
secondsUntilReport = 0 secondsUntilReport = 0
expireTimestamp = nil expireTimestamp = nil
showTimeoutDialog = false
timeoutDialogTriggered = false
return return
} }
@ -156,31 +158,13 @@ final class HomeLocationViewModel: ObservableObject {
await reportLocation(type: .immediate, staffId: staffId!, scenicId: scenicId!, toast: toast) await reportLocation(type: .immediate, staffId: staffId!, scenicId: scenicId!, toast: toast)
} }
/// detail /// 线
func checkLocationTimeout(staffId: Int?) async { func checkLocationTimeout(staffId: Int?) async {
guard reminderMinutes > 0 else { guard isOnline, reminderMinutes > 0, secondsUntilReport > 0 else {
showTimeoutDialog = false showTimeoutDialog = false
return return
} }
guard let staffId, staffId > 0 else { evaluateReminderThreshold(remainingSeconds: secondsUntilReport, forceDialog: true)
showTimeoutDialog = false
return
}
do {
let detail = try await api.locationDetail(staffId: staffId)
if detail.expireTime <= 0 {
showTimeoutDialog = true
return
}
let currentMillis = Date().timeIntervalSince1970 * 1000
let expireMillis = Double(detail.expireTime) * 1000
let thresholdMillis = expireMillis - Double(reminderMinutes * 60 * 1000)
showTimeoutDialog = currentMillis >= thresholdMillis
} catch {
showTimeoutDialog = true
}
} }
/// 线 /// 线
@ -313,6 +297,8 @@ final class HomeLocationViewModel: ObservableObject {
secondsUntilReport = 0 secondsUntilReport = 0
lastReportAnchorTime = nil lastReportAnchorTime = nil
expireTimestamp = nil expireTimestamp = nil
showTimeoutDialog = false
timeoutDialogTriggered = false
if clearPersisted { if clearPersisted {
store.saveOnlineStatus(false) store.saveOnlineStatus(false)
store.clearLastReportTime() store.clearLastReportTime()
@ -344,9 +330,9 @@ final class HomeLocationViewModel: ObservableObject {
return false return false
} }
/// /// 线
private func evaluateReminderThreshold(remainingSeconds: Int, forceDialog: Bool) { private func evaluateReminderThreshold(remainingSeconds: Int, forceDialog: Bool) {
guard reminderMinutes > 0 else { return } guard isOnline, reminderMinutes > 0 else { return }
let thresholdSeconds = reminderMinutes * 60 let thresholdSeconds = reminderMinutes * 60
guard remainingSeconds <= thresholdSeconds else { return } guard remainingSeconds <= thresholdSeconds else { return }

View File

@ -24,7 +24,6 @@ struct HomeView: View {
@StateObject private var viewModel = HomeViewModel() @StateObject private var viewModel = HomeViewModel()
@State private var commonUris: [String] = [] @State private var commonUris: [String] = []
@State private var showOnlineDialog = false @State private var showOnlineDialog = false
@State private var showReminderDialog = false
private let commonMenuStore = HomeCommonMenuStore() private let commonMenuStore = HomeCommonMenuStore()
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
@ -134,14 +133,6 @@ struct HomeView: View {
} message: { } message: {
Text(homeLocationViewModel.isOnline ? "是否确认切换为离线状态?离线后将暂停位置上报。" : "是否确认切换为在线状态?在线后将开始位置上报和计时。") Text(homeLocationViewModel.isOnline ? "是否确认切换为离线状态?离线后将暂停位置上报。" : "是否确认切换为在线状态?在线后将开始位置上报和计时。")
} }
.confirmationDialog("提前提醒时间", isPresented: $showReminderDialog, titleVisibility: .visible) {
ForEach([0, 5, 10, 15, 30], id: \.self) { minute in
Button(minute == 0 ? "不提醒" : "\(minute)分钟") {
homeLocationViewModel.updateReminderMinutes(minute)
}
}
Button("取消", role: .cancel) {}
}
.alert("位置上报提醒", isPresented: $homeLocationViewModel.showReminderDialog) { .alert("位置上报提醒", isPresented: $homeLocationViewModel.showReminderDialog) {
Button("立即上报") { Button("立即上报") {
homeLocationViewModel.dismissReminderDialog() homeLocationViewModel.dismissReminderDialog()
@ -232,9 +223,7 @@ struct HomeView: View {
Spacer(minLength: AppMetrics.Spacing.xxSmall) Spacer(minLength: AppMetrics.Spacing.xxSmall)
Button { ReminderTimeOptionsMenu(onSelect: { homeLocationViewModel.updateReminderMinutes($0) }) {
showReminderDialog = true
} label: {
Label { Label {
Text(reminderText) Text(reminderText)
.font(.system(size: AppMetrics.FontSize.body, weight: .medium)) .font(.system(size: AppMetrics.FontSize.body, weight: .medium))
@ -472,7 +461,7 @@ struct HomeView: View {
/// GPS Loading /// GPS Loading
private func runLocationReportTask(_ body: @escaping () async -> Void) { private func runLocationReportTask(_ body: @escaping () async -> Void) {
Task { Task {
await globalLoading.withLoading(message: "正在定位...") { await globalLoading.withLoading(message: "获取定位中", showsMessage: true) {
await body() await body()
} }
} }

View File

@ -21,7 +21,6 @@ struct LocationReportView: View {
@StateObject private var viewModel = LocationReportViewModel() @StateObject private var viewModel = LocationReportViewModel()
@State private var locationProvider = ForegroundLocationProvider() @State private var locationProvider = ForegroundLocationProvider()
@State private var showOnlineDialog = false @State private var showOnlineDialog = false
@State private var showReminderDialog = false
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View { var body: some View {
@ -59,7 +58,7 @@ struct LocationReportView: View {
Button("取消", role: .cancel) {} Button("取消", role: .cancel) {}
Button("确定") { Button("确定") {
Task { Task {
await globalLoading.withLoading(message: "正在定位...") { await globalLoading.withLoading(message: "获取定位中", showsMessage: true) {
await homeLocationViewModel.confirmOnlineToggle( await homeLocationViewModel.confirmOnlineToggle(
staffId: staffId, staffId: staffId,
scenicId: scenicId, scenicId: scenicId,
@ -71,14 +70,6 @@ struct LocationReportView: View {
} message: { } message: {
Text(homeLocationViewModel.isOnline ? "是否确认切换为离线状态?离线后将暂停位置上报。" : "是否确认切换为在线状态?在线后将开始位置上报和计时。") Text(homeLocationViewModel.isOnline ? "是否确认切换为离线状态?离线后将暂停位置上报。" : "是否确认切换为在线状态?在线后将开始位置上报和计时。")
} }
.confirmationDialog("提前提醒时间", isPresented: $showReminderDialog, titleVisibility: .visible) {
ForEach([0, 5, 10, 15, 30], id: \.self) { minute in
Button(minute == 0 ? "不提醒" : "\(minute)分钟") {
homeLocationViewModel.updateReminderMinutes(minute)
}
}
Button("取消", role: .cancel) {}
}
} }
/// 线 /// 线
@ -188,9 +179,7 @@ struct LocationReportView: View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
Text("提醒设置") Text("提醒设置")
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold)) .font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
Button { ReminderTimeOptionsMenu(onSelect: { homeLocationViewModel.updateReminderMinutes($0) }) {
showReminderDialog = true
} label: {
Text(homeLocationViewModel.reminderMinutes == 0 ? "不提醒" : "提前 \(homeLocationViewModel.reminderMinutes) 分钟提醒") Text(homeLocationViewModel.reminderMinutes == 0 ? "不提醒" : "提前 \(homeLocationViewModel.reminderMinutes) 分钟提醒")
.font(.system(size: AppMetrics.FontSize.subheadline)) .font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.primary) .foregroundStyle(AppDesign.primary)

View File

@ -80,6 +80,9 @@ struct SourcePersonRow: View {
} }
struct SourceLeadContent: View { struct SourceLeadContent: View {
///
private static let leadImageColumns = Array(repeating: GridItem(.flexible()), count: 3)
let lead: OrderSourceLead let lead: OrderSourceLead
var compact = false var compact = false
var enablePhoneCall = true var enablePhoneCall = true
@ -123,11 +126,11 @@ struct SourceLeadContent: View {
.background(OrderSourceColors.imageEmptyBg, in: RoundedRectangle(cornerRadius: 6)) .background(OrderSourceColors.imageEmptyBg, in: RoundedRectangle(cornerRadius: 6))
} }
} else { } else {
HStack(spacing: 8) { LazyVGrid(columns: Self.leadImageColumns, spacing: 8) {
ForEach(Array(lead.images.prefix(3).enumerated()), id: \.offset) { index, imageUrl in ForEach(0..<3, id: \.self) { index in
leadThumbnail(imageUrl: imageUrl, index: index) if index < min(lead.images.count, 3) {
} leadThumbnail(imageUrl: lead.images[index], index: index)
ForEach(0..<max(0, 3 - min(lead.images.count, 3)), id: \.self) { _ in } else {
Color.clear Color.clear
.aspectRatio(1, contentMode: .fit) .aspectRatio(1, contentMode: .fit)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@ -136,25 +139,33 @@ struct SourceLeadContent: View {
} }
} }
} }
}
private func leadThumbnail(imageUrl: String, index: Int) -> some View { private func leadThumbnail(imageUrl: String, index: Int) -> some View {
Group { Color.clear
if let url = URL(string: imageUrl), !imageUrl.isEmpty {
KFImage(url)
.resizable()
.scaledToFill()
} else {
OrderSourceColors.imageEmptyBg
}
}
.frame(maxWidth: .infinity)
.aspectRatio(1, contentMode: .fit) .aspectRatio(1, contentMode: .fit)
.frame(maxWidth: .infinity)
.overlay {
leadThumbnailImage(imageUrl: imageUrl)
}
.clipShape(RoundedRectangle(cornerRadius: 6)) .clipShape(RoundedRectangle(cornerRadius: 6))
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { .onTapGesture {
onImageTap(lead.images, index) onImageTap(lead.images, index)
} }
} }
@ViewBuilder
private func leadThumbnailImage(imageUrl: String) -> some View {
if let url = URL(string: imageUrl), !imageUrl.isEmpty {
KFImage(url)
.placeholder { OrderSourceColors.imageEmptyBg }
.resizable()
.scaledToFill()
} else {
OrderSourceColors.imageEmptyBg
}
}
} }
struct SourceLeadCard: View { struct SourceLeadCard: View {

View File

@ -62,19 +62,6 @@ struct ProfileView: View {
await updatePassword(password) await updatePassword(password)
} }
} }
.confirmationDialog("确认退出当前账号?", isPresented: $showsLogoutConfirm, titleVisibility: .visible) {
Button("退出登录", role: .destructive) {
authSessionCoordinator.logout(
appSession: appSession,
accountContext: accountContext,
permissionContext: permissionContext,
scenicSpotContext: scenicSpotContext,
appRouter: appRouter,
toastCenter: toastCenter
)
}
Button("取消", role: .cancel) {}
}
.onChange(of: viewModel.isEditingProfile) { isEditing in .onChange(of: viewModel.isEditingProfile) { isEditing in
guard isEditing else { return } guard isEditing else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
@ -371,6 +358,19 @@ struct ProfileView: View {
.shadow(color: Color(hex: 0xC8D7EA, alpha: 0.24), radius: 14, x: 0, y: 7) .shadow(color: Color(hex: 0xC8D7EA, alpha: 0.24), radius: 14, x: 0, y: 7)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.confirmationDialog("确认退出当前账号?", isPresented: $showsLogoutConfirm, titleVisibility: .visible) {
Button("退出登录", role: .destructive) {
authSessionCoordinator.logout(
appSession: appSession,
accountContext: accountContext,
permissionContext: permissionContext,
scenicSpotContext: scenicSpotContext,
appRouter: appRouter,
toastCenter: toastCenter
)
}
Button("取消", role: .cancel) {}
}
} }
/// ///

View File

@ -18,13 +18,13 @@ final class GlobalLoadingCenterTests: XCTestCase {
center.show(message: "加载账号") center.show(message: "加载账号")
center.show(message: "加载权限") center.show(message: "加载权限")
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "加载权限", activeCount: 2)) XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "加载权限", activeCount: 2, showsMessage: false))
center.hide() center.hide()
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "加载权限", activeCount: 1)) XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "加载权限", activeCount: 1, showsMessage: false))
center.hide() center.hide()
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0)) XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0, showsMessage: false))
} }
/// hide show /// hide show
@ -33,7 +33,7 @@ final class GlobalLoadingCenterTests: XCTestCase {
center.hide() center.hide()
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0)) XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0, showsMessage: false))
} }
/// ///
@ -41,12 +41,12 @@ final class GlobalLoadingCenterTests: XCTestCase {
let center = GlobalLoadingCenter() let center = GlobalLoadingCenter()
center.updateMessage("不会展示") center.updateMessage("不会展示")
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0)) XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0, showsMessage: false))
center.show(message: "加载中") center.show(message: "加载中")
center.updateMessage("即将完成") center.updateMessage("即将完成")
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "即将完成", activeCount: 1)) XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "即将完成", activeCount: 1, showsMessage: false))
} }
/// withLoading Loading /// withLoading Loading
@ -54,12 +54,12 @@ final class GlobalLoadingCenterTests: XCTestCase {
let center = GlobalLoadingCenter() let center = GlobalLoadingCenter()
let value = try await center.withLoading(message: "提交中") { let value = try await center.withLoading(message: "提交中") {
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "提交中", activeCount: 1)) XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "提交中", activeCount: 1, showsMessage: false))
return 42 return 42
} }
XCTAssertEqual(value, 42) XCTAssertEqual(value, 42)
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0)) XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0, showsMessage: false))
} }
/// withLoading Loading /// withLoading Loading
@ -75,7 +75,7 @@ final class GlobalLoadingCenterTests: XCTestCase {
XCTAssertEqual(error as? TestFailure, .expected) XCTAssertEqual(error as? TestFailure, .expected)
} }
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0)) XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0, showsMessage: false))
} }
/// withOptionalLoading /// withOptionalLoading
@ -83,12 +83,28 @@ final class GlobalLoadingCenterTests: XCTestCase {
let center = GlobalLoadingCenter() let center = GlobalLoadingCenter()
let value = try await center.withOptionalLoading(false, message: "不展示") { let value = try await center.withOptionalLoading(false, message: "不展示") {
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0)) XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0, showsMessage: false))
return "done" return "done"
} }
XCTAssertEqual(value, "done") XCTAssertEqual(value, "done")
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0)) XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0, showsMessage: false))
}
///
func testShowsMessageOnlyWhenEnabled() {
let center = GlobalLoadingCenter()
center.show(message: "加载中")
XCTAssertEqual(center.snapshotForTests.message, "加载中")
XCTAssertFalse(center.snapshotForTests.showsMessage)
center.hide()
center.show(message: "获取定位中", showsMessage: true)
XCTAssertTrue(center.snapshotForTests.showsMessage)
center.hide()
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0, showsMessage: false))
} }
/// 使 Loading /// 使 Loading
@ -97,7 +113,7 @@ final class GlobalLoadingCenterTests: XCTestCase {
issueLoadingCommand(center) issueLoadingCommand(center)
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "加载中", activeCount: 1)) XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "加载中", activeCount: 1, showsMessage: false))
center.hide() center.hide()
} }

View File

@ -331,19 +331,54 @@ final class HomeLocationViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.reminderMinutes, 10) XCTAssertEqual(viewModel.reminderMinutes, 10)
} }
/// detail /// 线
func testCheckLocationTimeoutShowsDialogWhenNearExpiry() async { func testCheckLocationTimeoutShowsDialogWhenOnlineAndWithinReminderWindow() async {
let api = MockLocationReportService() let store = makeIsolatedStore()
let expireTime = Int(Date().timeIntervalSince1970) + 120 let expireTime = Int(Date().timeIntervalSince1970) + 120
api.detailResponse = LocationDetailResponse(status: 1, needReport: true, expireTime: expireTime) store.saveOnlineStatus(true)
let viewModel = HomeLocationViewModel(api: api, store: makeIsolatedStore()) store.saveExpireTime(expireTime)
viewModel.updateReminderMinutes(5) store.saveReminderMinutes(5)
let viewModel = HomeLocationViewModel(api: MockLocationReportService(), store: store)
viewModel.restoreState()
await viewModel.checkLocationTimeout(staffId: 77) await viewModel.checkLocationTimeout(staffId: 77)
XCTAssertTrue(viewModel.isOnline)
XCTAssertTrue(viewModel.showTimeoutDialog) XCTAssertTrue(viewModel.showTimeoutDialog)
} }
/// 线
func testCheckLocationTimeoutDoesNotShowDialogWhenOffline() async {
let store = makeIsolatedStore()
store.saveOnlineStatus(false)
store.saveReminderMinutes(5)
let viewModel = HomeLocationViewModel(api: MockLocationReportService(), store: store)
viewModel.restoreState()
await viewModel.checkLocationTimeout(staffId: 77)
XCTAssertFalse(viewModel.showTimeoutDialog)
}
/// 线
func testCheckLocationTimeoutDoesNotShowDialogWhenOutsideReminderWindow() async {
let store = makeIsolatedStore()
let expireTime = Int(Date().timeIntervalSince1970) + 3_600
store.saveOnlineStatus(true)
store.saveExpireTime(expireTime)
store.saveReminderMinutes(5)
let viewModel = HomeLocationViewModel(api: MockLocationReportService(), store: store)
viewModel.restoreState()
await viewModel.checkLocationTimeout(staffId: 77)
XCTAssertTrue(viewModel.isOnline)
XCTAssertFalse(viewModel.showTimeoutDialog)
}
private func makeIsolatedStore() -> LocationStateStore { private func makeIsolatedStore() -> LocationStateStore {
let suiteName = "HomeLocationViewModelTests.\(UUID().uuidString)" let suiteName = "HomeLocationViewModelTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)! let defaults = UserDefaults(suiteName: suiteName)!