Add Lottie-based global loading overlay across key flows.
Introduce GlobalLoadingCenter with reference counting, wire it through RootView and major auth/order/profile screens, and add the loading animation asset plus unit tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -51,6 +51,9 @@
|
|||||||
Kingfisher
|
Kingfisher
|
||||||
- 用于显示网络图片
|
- 用于显示网络图片
|
||||||
|
|
||||||
|
Lottie
|
||||||
|
- 用于展示全局 Loading 动画
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
11A5FC66EE3769DAC202A397 /* Pods_suixinkanTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EDE192531FA9E2FD908B9EC1 /* Pods_suixinkanTests.framework */; };
|
11A5FC66EE3769DAC202A397 /* Pods_suixinkanTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EDE192531FA9E2FD908B9EC1 /* Pods_suixinkanTests.framework */; };
|
||||||
93DAED0B2FE8E50000B9E2B1 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 93DAED0D2FE8E50000B9E2B1 /* Kingfisher */; };
|
93DAED0B2FE8E50000B9E2B1 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 93DAED0D2FE8E50000B9E2B1 /* Kingfisher */; };
|
||||||
93DAED0C2FE8E50000B9E2B1 /* AlibabaCloudOSS in Frameworks */ = {isa = PBXBuildFile; productRef = 93DAED0E2FE8E50000B9E2B1 /* AlibabaCloudOSS */; };
|
93DAED0C2FE8E50000B9E2B1 /* AlibabaCloudOSS in Frameworks */ = {isa = PBXBuildFile; productRef = 93DAED0E2FE8E50000B9E2B1 /* AlibabaCloudOSS */; };
|
||||||
|
93DAED112FE9100000B9E2B1 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 93DAED102FE9100000B9E2B1 /* Lottie */; };
|
||||||
AE69C4E776CD531A1BB67C15 /* Pods_suixinkan.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 454D6E9EC1585878877CF5FD /* Pods_suixinkan.framework */; };
|
AE69C4E776CD531A1BB67C15 /* Pods_suixinkan.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 454D6E9EC1585878877CF5FD /* Pods_suixinkan.framework */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
@ -58,6 +59,7 @@
|
|||||||
files = (
|
files = (
|
||||||
93DAED0B2FE8E50000B9E2B1 /* Kingfisher in Frameworks */,
|
93DAED0B2FE8E50000B9E2B1 /* Kingfisher in Frameworks */,
|
||||||
93DAED0C2FE8E50000B9E2B1 /* AlibabaCloudOSS in Frameworks */,
|
93DAED0C2FE8E50000B9E2B1 /* AlibabaCloudOSS in Frameworks */,
|
||||||
|
93DAED112FE9100000B9E2B1 /* Lottie in Frameworks */,
|
||||||
AE69C4E776CD531A1BB67C15 /* Pods_suixinkan.framework in Frameworks */,
|
AE69C4E776CD531A1BB67C15 /* Pods_suixinkan.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@ -137,6 +139,7 @@
|
|||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
93DAED0D2FE8E50000B9E2B1 /* Kingfisher */,
|
93DAED0D2FE8E50000B9E2B1 /* Kingfisher */,
|
||||||
93DAED0E2FE8E50000B9E2B1 /* AlibabaCloudOSS */,
|
93DAED0E2FE8E50000B9E2B1 /* AlibabaCloudOSS */,
|
||||||
|
93DAED102FE9100000B9E2B1 /* Lottie */,
|
||||||
);
|
);
|
||||||
productName = suixinkan;
|
productName = suixinkan;
|
||||||
productReference = 939AC7962FE3F832004B22E4 /* suixinkan.app */;
|
productReference = 939AC7962FE3F832004B22E4 /* suixinkan.app */;
|
||||||
@ -195,6 +198,7 @@
|
|||||||
packageReferences = (
|
packageReferences = (
|
||||||
93DAED092FE8E14D00B9E2B1 /* XCRemoteSwiftPackageReference "Kingfisher" */,
|
93DAED092FE8E14D00B9E2B1 /* XCRemoteSwiftPackageReference "Kingfisher" */,
|
||||||
93DAED0A2FE8E17E00B9E2B1 /* XCRemoteSwiftPackageReference "alibabacloud-oss-swift-sdk-v2" */,
|
93DAED0A2FE8E17E00B9E2B1 /* XCRemoteSwiftPackageReference "alibabacloud-oss-swift-sdk-v2" */,
|
||||||
|
93DAED0F2FE9100000B9E2B1 /* XCRemoteSwiftPackageReference "lottie-ios" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = 939AC7972FE3F832004B22E4 /* Products */;
|
productRefGroup = 939AC7972FE3F832004B22E4 /* Products */;
|
||||||
@ -644,6 +648,14 @@
|
|||||||
minimumVersion = 0.2.0;
|
minimumVersion = 0.2.0;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
93DAED0F2FE9100000B9E2B1 /* XCRemoteSwiftPackageReference "lottie-ios" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/airbnb/lottie-ios.git";
|
||||||
|
requirement = {
|
||||||
|
kind = upToNextMajorVersion;
|
||||||
|
minimumVersion = 4.5.0;
|
||||||
|
};
|
||||||
|
};
|
||||||
/* End XCRemoteSwiftPackageReference section */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
@ -657,6 +669,11 @@
|
|||||||
package = 93DAED0A2FE8E17E00B9E2B1 /* XCRemoteSwiftPackageReference "alibabacloud-oss-swift-sdk-v2" */;
|
package = 93DAED0A2FE8E17E00B9E2B1 /* XCRemoteSwiftPackageReference "alibabacloud-oss-swift-sdk-v2" */;
|
||||||
productName = AlibabaCloudOSS;
|
productName = AlibabaCloudOSS;
|
||||||
};
|
};
|
||||||
|
93DAED102FE9100000B9E2B1 /* Lottie */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = 93DAED0F2FE9100000B9E2B1 /* XCRemoteSwiftPackageReference "lottie-ios" */;
|
||||||
|
productName = Lottie;
|
||||||
|
};
|
||||||
/* End XCSwiftPackageProductDependency section */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = 939AC78E2FE3F832004B22E4 /* Project object */;
|
rootObject = 939AC78E2FE3F832004B22E4 /* Project object */;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "dca97369973ef74cd9a7eb6d4579ab8af27675808e732def830661fbc4778f6f",
|
"originHash" : "6f02892b99a60872df0561ceead8949f780d607eccd0a776d8f299248fc13a72",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "alibabacloud-oss-swift-sdk-v2",
|
"identity" : "alibabacloud-oss-swift-sdk-v2",
|
||||||
@ -19,6 +19,15 @@
|
|||||||
"version" : "8.10.0"
|
"version" : "8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "lottie-ios",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/airbnb/lottie-ios.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "f4db77d7feacba0c2360b84a40c38a6ce8ff399d",
|
||||||
|
"version" : "4.6.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swift-asn1",
|
"identity" : "swift-asn1",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
@ -15,6 +15,7 @@ struct RootView: View {
|
|||||||
@State private var scenicSpotContext = ScenicSpotContext()
|
@State private var scenicSpotContext = ScenicSpotContext()
|
||||||
@State private var appRouter = AppRouter()
|
@State private var appRouter = AppRouter()
|
||||||
@State private var toastCenter = ToastCenter()
|
@State private var toastCenter = ToastCenter()
|
||||||
|
@State private var globalLoading = GlobalLoadingCenter()
|
||||||
@State private var snapshotStore: AccountSnapshotStore
|
@State private var snapshotStore: AccountSnapshotStore
|
||||||
@State private var apiClient: APIClient
|
@State private var apiClient: APIClient
|
||||||
@State private var authAPI: AuthAPI
|
@State private var authAPI: AuthAPI
|
||||||
@ -66,12 +67,14 @@ struct RootView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
rootContent
|
rootContent
|
||||||
.globalToastOverlay()
|
.globalToastOverlay()
|
||||||
|
.globalLoadingOverlay(loadingCenter: globalLoading)
|
||||||
.environment(appSession)
|
.environment(appSession)
|
||||||
.environment(accountContext)
|
.environment(accountContext)
|
||||||
.environment(permissionContext)
|
.environment(permissionContext)
|
||||||
.environment(scenicSpotContext)
|
.environment(scenicSpotContext)
|
||||||
.environment(appRouter)
|
.environment(appRouter)
|
||||||
.environment(toastCenter)
|
.environment(toastCenter)
|
||||||
|
.environment(\.globalLoading, globalLoading)
|
||||||
.environment(snapshotStore)
|
.environment(snapshotStore)
|
||||||
.environment(apiClient)
|
.environment(apiClient)
|
||||||
.environment(authAPI)
|
.environment(authAPI)
|
||||||
@ -87,14 +90,16 @@ struct RootView: View {
|
|||||||
.environment(authSessionCoordinator)
|
.environment(authSessionCoordinator)
|
||||||
.task {
|
.task {
|
||||||
apiClient.bindAuthTokenProvider { appSession.token }
|
apiClient.bindAuthTokenProvider { appSession.token }
|
||||||
await sessionBootstrapper.restore(
|
await globalLoading.withLoading {
|
||||||
appSession: appSession,
|
await sessionBootstrapper.restore(
|
||||||
accountContext: accountContext,
|
appSession: appSession,
|
||||||
permissionContext: permissionContext,
|
accountContext: accountContext,
|
||||||
profileAPI: profileAPI,
|
permissionContext: permissionContext,
|
||||||
accountContextAPI: accountContextAPI,
|
profileAPI: profileAPI,
|
||||||
toastCenter: toastCenter
|
accountContextAPI: accountContextAPI,
|
||||||
)
|
toastCenter: toastCenter
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.task(id: scenicSpotTaskID) {
|
.task(id: scenicSpotTaskID) {
|
||||||
guard appSession.isLoggedIn else {
|
guard appSession.isLoggedIn else {
|
||||||
@ -122,9 +127,8 @@ struct RootView: View {
|
|||||||
case .loggedOut:
|
case .loggedOut:
|
||||||
LoginView()
|
LoginView()
|
||||||
case .restoring:
|
case .restoring:
|
||||||
ProgressView()
|
Color(.systemBackground)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(Color(.systemBackground))
|
|
||||||
case .loggedIn:
|
case .loggedIn:
|
||||||
MainTabsView()
|
MainTabsView()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -65,6 +65,14 @@ Core 模块提供跨业务复用的基础能力,包括网络请求、缓存存
|
|||||||
|
|
||||||
新增页面时优先使用 `AppMetrics` 和 `AppDesign`。只有明显属于单个页面的特殊尺寸,才保留在页面本地。
|
新增页面时优先使用 `AppMetrics` 和 `AppDesign`。只有明显属于单个页面的特殊尺寸,才保留在页面本地。
|
||||||
|
|
||||||
|
### 全局 Loading
|
||||||
|
|
||||||
|
`GlobalLoadingCenter` 是全局 Loading 的命令中心,只负责展示加载状态,不保存业务数据。业务 View 只能通过 Environment 获取它并调用 `show`、`hide`、`updateMessage`、`withLoading` 或 `withOptionalLoading`,不要在 `body` 中读取 `isVisible`、`message` 等展示状态。
|
||||||
|
|
||||||
|
Loading 的可观察状态只在 `GlobalLoadingOverlayHost` 内部订阅,并由 `RootView` 挂载到应用根部。这样切换 Loading 显隐时,只会刷新根部 Overlay,不会让当前页面、Tab 根视图或业务子视图形成观察依赖。
|
||||||
|
|
||||||
|
全局 Loading 只用于阻塞型等待,例如冷启动恢复、登录、首屏加载、提交表单和核销。列表加载更多、上传进度、按钮内局部反馈继续保留在页面局部状态中。
|
||||||
|
|
||||||
## Upload
|
## Upload
|
||||||
|
|
||||||
`UploadAPI` 通过 `/api/app/config/get-sts-token` 获取阿里云 OSS 临时上传配置。`OSSUploadService` 负责校验文件、生成 objectKey、调用 `AlibabaCloudOSS` SDK 并返回最终文件 URL。
|
`UploadAPI` 通过 `/api/app/config/get-sts-token` 获取阿里云 OSS 临时上传配置。`OSSUploadService` 负责校验文件、生成 objectKey、调用 `AlibabaCloudOSS` SDK 并返回最终文件 URL。
|
||||||
|
|||||||
225
suixinkan/Core/Design/GlobalLoadingCenter.swift
Normal file
225
suixinkan/Core/Design/GlobalLoadingCenter.swift
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
//
|
||||||
|
// GlobalLoadingCenter.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import Lottie
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 全局 Loading 命令中心,只负责展示状态控制,不承载业务数据。
|
||||||
|
final class GlobalLoadingCenter {
|
||||||
|
fileprivate let state = GlobalLoadingState()
|
||||||
|
|
||||||
|
/// 显示全局 Loading;多次调用会通过引用计数叠加。
|
||||||
|
func show(message: String = "") {
|
||||||
|
if !message.isEmpty {
|
||||||
|
state.message = message
|
||||||
|
}
|
||||||
|
state.activeCount += 1
|
||||||
|
state.isVisible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 隐藏一次全局 Loading;所有调用方都结束后才真正关闭。
|
||||||
|
func hide() {
|
||||||
|
state.activeCount = max(0, state.activeCount - 1)
|
||||||
|
guard state.activeCount == 0 else { return }
|
||||||
|
state.isVisible = false
|
||||||
|
state.message = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新当前 Loading 文案,仅在 Loading 可见且文案非空时生效。
|
||||||
|
func updateMessage(_ message: String) {
|
||||||
|
guard state.isVisible, !message.isEmpty else { return }
|
||||||
|
state.message = message
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 包裹一个异步操作,操作结束或抛错时自动关闭 Loading。
|
||||||
|
func withLoading<T>(
|
||||||
|
message: String = "",
|
||||||
|
operation: () async throws -> T
|
||||||
|
) async rethrows -> T {
|
||||||
|
show(message: message)
|
||||||
|
defer { hide() }
|
||||||
|
return try await operation()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据开关决定是否展示 Loading,适合下拉刷新等可选 loading 场景。
|
||||||
|
func withOptionalLoading<T>(
|
||||||
|
_ enabled: Bool,
|
||||||
|
message: String = "",
|
||||||
|
operation: () async throws -> T
|
||||||
|
) async rethrows -> T {
|
||||||
|
if enabled {
|
||||||
|
return try await withLoading(message: message, operation: operation)
|
||||||
|
}
|
||||||
|
return try await operation()
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
/// 测试专用快照,业务代码不应读取 Loading 展示状态。
|
||||||
|
var snapshotForTests: GlobalLoadingSnapshot {
|
||||||
|
GlobalLoadingSnapshot(
|
||||||
|
isVisible: state.isVisible,
|
||||||
|
message: state.message,
|
||||||
|
activeCount: state.activeCount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 全局 Loading 的私有可观察状态,只允许根部 Overlay 订阅。
|
||||||
|
fileprivate final class GlobalLoadingState: ObservableObject {
|
||||||
|
@Published fileprivate var isVisible = false
|
||||||
|
@Published fileprivate var message = ""
|
||||||
|
fileprivate var activeCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lottie Loading 动画容器,负责播放主包中的 loading.json。
|
||||||
|
struct LottieLoadingAnimationView: UIViewRepresentable {
|
||||||
|
/// 判断当前包内是否存在可用 Loading 动画资源。
|
||||||
|
static var hasAnimationResource: Bool {
|
||||||
|
loadAnimation() != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建承载 Lottie 动画的 UIKit 视图。
|
||||||
|
func makeUIView(context: Context) -> UIView {
|
||||||
|
let container = UIView()
|
||||||
|
container.backgroundColor = .clear
|
||||||
|
|
||||||
|
let animationView = LottieAnimationView()
|
||||||
|
animationView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
animationView.contentMode = .scaleAspectFit
|
||||||
|
animationView.loopMode = .loop
|
||||||
|
animationView.backgroundBehavior = .pauseAndRestore
|
||||||
|
animationView.animation = Self.loadAnimation()
|
||||||
|
animationView.play()
|
||||||
|
|
||||||
|
container.addSubview(animationView)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
animationView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||||
|
animationView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||||
|
animationView.topAnchor.constraint(equalTo: container.topAnchor),
|
||||||
|
animationView.bottomAnchor.constraint(equalTo: container.bottomAnchor)
|
||||||
|
])
|
||||||
|
return container
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loading 动画无需响应 SwiftUI 状态更新。
|
||||||
|
func updateUIView(_ uiView: UIView, context: Context) {}
|
||||||
|
|
||||||
|
/// 从主包中加载 loading 动画资源,兼容 Resources 子目录和根目录。
|
||||||
|
private static func loadAnimation() -> LottieAnimation? {
|
||||||
|
if let animation = LottieAnimation.named("loading") {
|
||||||
|
return animation
|
||||||
|
}
|
||||||
|
if let url = Bundle.main.url(forResource: "loading", withExtension: "json", subdirectory: "Resources") {
|
||||||
|
return LottieAnimation.filepath(url.path)
|
||||||
|
}
|
||||||
|
if let url = Bundle.main.url(forResource: "loading", withExtension: "json") {
|
||||||
|
return LottieAnimation.filepath(url.path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 全局 Loading Overlay 宿主,只在根部订阅 Loading 状态,避免业务页面重绘。
|
||||||
|
private struct GlobalLoadingOverlayHost: View {
|
||||||
|
@ObservedObject private var state: GlobalLoadingState
|
||||||
|
|
||||||
|
/// 使用全局 Loading 命令中心初始化 Overlay 宿主。
|
||||||
|
init(loadingCenter: GlobalLoadingCenter) {
|
||||||
|
_state = ObservedObject(wrappedValue: loadingCenter.state)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
if state.isVisible {
|
||||||
|
ZStack {
|
||||||
|
Color.black.opacity(0.28)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
loadingAnimation
|
||||||
|
|
||||||
|
if !state.message.isEmpty {
|
||||||
|
Text(state.message)
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundStyle(Color(hex: 0x333333))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.lineLimit(2)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 28)
|
||||||
|
.padding(.vertical, 24)
|
||||||
|
.background(.white, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||||
|
.shadow(color: Color.black.opacity(0.12), radius: 24, x: 0, y: 10)
|
||||||
|
}
|
||||||
|
.transition(.opacity)
|
||||||
|
.zIndex(10_000)
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityLabel(state.message.isEmpty ? "加载中" : state.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: state.isVisible)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var loadingAnimation: some View {
|
||||||
|
if LottieLoadingAnimationView.hasAnimationResource {
|
||||||
|
LottieLoadingAnimationView()
|
||||||
|
.frame(width: 132, height: 132)
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.large)
|
||||||
|
.tint(AppDesign.primary)
|
||||||
|
.frame(width: 132, height: 132)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct GlobalLoadingOverlayModifier: ViewModifier {
|
||||||
|
let loadingCenter: GlobalLoadingCenter
|
||||||
|
|
||||||
|
/// 在页面根部覆盖全局 Loading 展示层。
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
ZStack {
|
||||||
|
content
|
||||||
|
GlobalLoadingOverlayHost(loadingCenter: loadingCenter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
/// 挂载全局 Loading 展示层,通常只应在 RootView 调用一次。
|
||||||
|
func globalLoadingOverlay(loadingCenter: GlobalLoadingCenter) -> some View {
|
||||||
|
modifier(GlobalLoadingOverlayModifier(loadingCenter: loadingCenter))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
/// 全局 Loading 测试快照,用于验证引用计数和展示文案。
|
||||||
|
struct GlobalLoadingSnapshot: Equatable {
|
||||||
|
let isVisible: Bool
|
||||||
|
let message: String
|
||||||
|
let activeCount: Int
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private struct GlobalLoadingCenterKey: EnvironmentKey {
|
||||||
|
static let defaultValue = GlobalLoadingCenter()
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EnvironmentValues {
|
||||||
|
/// 全局 Loading 命令中心,业务页面只能通过它发出 show/hide 指令。
|
||||||
|
var globalLoading: GlobalLoadingCenter {
|
||||||
|
get { self[GlobalLoadingCenterKey.self] }
|
||||||
|
set { self[GlobalLoadingCenterKey.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -71,15 +71,9 @@ struct AccountSelectionView: View {
|
|||||||
guard let selectedAccount else { return }
|
guard let selectedAccount else { return }
|
||||||
onConfirm(selectedAccount)
|
onConfirm(selectedAccount)
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
Text("进入系统")
|
||||||
if isLoading {
|
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||||
ProgressView()
|
.foregroundStyle(.white)
|
||||||
.tint(.white)
|
|
||||||
}
|
|
||||||
Text("进入系统")
|
|
||||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
|
||||||
}
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
|
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
|
||||||
.background(canConfirm ? AppDesign.primary : Color(hex: 0xC9CED6), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
|
.background(canConfirm ? AppDesign.primary : Color(hex: 0xC9CED6), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
|
||||||
|
|||||||
@ -17,6 +17,7 @@ struct LoginView: View {
|
|||||||
@Environment(ProfileAPI.self) private var profileAPI
|
@Environment(ProfileAPI.self) private var profileAPI
|
||||||
@Environment(AccountContextAPI.self) private var accountContextAPI
|
@Environment(AccountContextAPI.self) private var accountContextAPI
|
||||||
@Environment(AuthSessionCoordinator.self) private var authSessionCoordinator
|
@Environment(AuthSessionCoordinator.self) private var authSessionCoordinator
|
||||||
|
@Environment(\.globalLoading) private var globalLoading
|
||||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
@State private var viewModel = LoginViewModel()
|
@State private var viewModel = LoginViewModel()
|
||||||
@FocusState private var focusedField: LoginField?
|
@FocusState private var focusedField: LoginField?
|
||||||
@ -111,27 +112,13 @@ struct LoginView: View {
|
|||||||
Spacer().frame(height: AppMetrics.Spacing.mediumLarge)
|
Spacer().frame(height: AppMetrics.Spacing.mediumLarge)
|
||||||
|
|
||||||
Button(action: loginAction) {
|
Button(action: loginAction) {
|
||||||
HStack {
|
Text("登录")
|
||||||
if viewModel.isLoading {
|
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
|
||||||
ProgressView()
|
.foregroundStyle(.white)
|
||||||
.tint(.white)
|
.frame(maxWidth: .infinity)
|
||||||
.frame(width: AppMetrics.ControlSize.progressWidth)
|
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
|
||||||
} else {
|
.background(viewModel.canSubmit ? AppDesign.primary : Color.gray)
|
||||||
Spacer()
|
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
|
||||||
.frame(width: AppMetrics.ControlSize.progressWidth)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text("登录")
|
|
||||||
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
.frame(width: AppMetrics.ControlSize.progressWidth)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
|
|
||||||
.background(viewModel.canSubmit ? AppDesign.primary : Color.gray)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
|
|
||||||
}
|
}
|
||||||
.disabled(!viewModel.canSubmit || viewModel.isLoading)
|
.disabled(!viewModel.canSubmit || viewModel.isLoading)
|
||||||
.accessibilityIdentifier("login.submit")
|
.accessibilityIdentifier("login.submit")
|
||||||
@ -167,12 +154,14 @@ struct LoginView: View {
|
|||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let resolution = try await viewModel.login(authAPI: authAPI)
|
try await globalLoading.withLoading(message: "登录中...") {
|
||||||
switch resolution {
|
let resolution = try await viewModel.login(authAPI: authAPI)
|
||||||
case let .completed(response):
|
switch resolution {
|
||||||
await completeLogin(with: response)
|
case let .completed(response):
|
||||||
case .needsAccountSelection:
|
await completeLogin(with: response)
|
||||||
break
|
case .needsAccountSelection:
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
// Keep cancellation silent; the current task was abandoned by SwiftUI.
|
// Keep cancellation silent; the current task was abandoned by SwiftUI.
|
||||||
@ -186,8 +175,10 @@ struct LoginView: View {
|
|||||||
private func selectAccount(_ account: AccountSwitchAccount) {
|
private func selectAccount(_ account: AccountSwitchAccount) {
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let response = try await viewModel.selectAccount(account, authAPI: authAPI)
|
try await globalLoading.withLoading(message: "账号切换中...") {
|
||||||
await completeLogin(with: response)
|
let response = try await viewModel.selectAccount(account, authAPI: authAPI)
|
||||||
|
await completeLogin(with: response)
|
||||||
|
}
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
// Keep cancellation silent; the current task was abandoned by SwiftUI.
|
// Keep cancellation silent; the current task was abandoned by SwiftUI.
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@ -13,6 +13,7 @@ struct StoreOrderDetailView: View {
|
|||||||
@Environment(OrdersAPI.self) private var ordersAPI
|
@Environment(OrdersAPI.self) private var ordersAPI
|
||||||
@Environment(RouterPath.self) private var router
|
@Environment(RouterPath.self) private var router
|
||||||
@Environment(ToastCenter.self) private var toastCenter
|
@Environment(ToastCenter.self) private var toastCenter
|
||||||
|
@Environment(\.globalLoading) private var globalLoading
|
||||||
|
|
||||||
let item: OrderEntity
|
let item: OrderEntity
|
||||||
@State private var viewModel: OrderDetailViewModel
|
@State private var viewModel: OrderDetailViewModel
|
||||||
@ -25,16 +26,6 @@ struct StoreOrderDetailView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
if viewModel.loading {
|
|
||||||
Section {
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
ProgressView("加载详情中...")
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let contextMessage = viewModel.contextMessage {
|
if let contextMessage = viewModel.contextMessage {
|
||||||
Section {
|
Section {
|
||||||
Label(contextMessage, systemImage: "info.circle")
|
Label(contextMessage, systemImage: "info.circle")
|
||||||
@ -117,7 +108,9 @@ struct StoreOrderDetailView: View {
|
|||||||
.navigationTitle("订单详情")
|
.navigationTitle("订单详情")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.task {
|
.task {
|
||||||
await viewModel.load(api: ordersAPI, fallbackStoreId: accountContext.currentStore?.id)
|
await globalLoading.withLoading(message: "加载详情中...") {
|
||||||
|
await viewModel.load(api: ordersAPI, fallbackStoreId: accountContext.currentStore?.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.alert("提示", isPresented: errorBinding) {
|
.alert("提示", isPresented: errorBinding) {
|
||||||
Button("知道了", role: .cancel) {}
|
Button("知道了", role: .cancel) {}
|
||||||
|
|||||||
@ -15,6 +15,7 @@ struct OrdersView: View {
|
|||||||
@Environment(RouterPath.self) private var router
|
@Environment(RouterPath.self) private var router
|
||||||
@Environment(OrdersAPI.self) private var ordersAPI
|
@Environment(OrdersAPI.self) private var ordersAPI
|
||||||
@Environment(ToastCenter.self) private var toastCenter
|
@Environment(ToastCenter.self) private var toastCenter
|
||||||
|
@Environment(\.globalLoading) private var globalLoading
|
||||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
|
|
||||||
@State private var viewModel = OrdersViewModel()
|
@State private var viewModel = OrdersViewModel()
|
||||||
@ -227,10 +228,10 @@ struct OrdersView: View {
|
|||||||
|
|
||||||
private var storeList: some View {
|
private var storeList: some View {
|
||||||
LazyVStack(spacing: AppMetrics.Spacing.small) {
|
LazyVStack(spacing: AppMetrics.Spacing.small) {
|
||||||
if viewModel.loading {
|
if viewModel.loading && viewModel.storeOrders.isEmpty {
|
||||||
ProgressView()
|
Color.clear
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, AppMetrics.Spacing.xxLarge)
|
.frame(minHeight: 260)
|
||||||
} else if viewModel.storeOrders.isEmpty {
|
} else if viewModel.storeOrders.isEmpty {
|
||||||
ContentUnavailableView("暂无订单", systemImage: "tray", description: Text("可切换筛选条件或下拉刷新。"))
|
ContentUnavailableView("暂无订单", systemImage: "tray", description: Text("可切换筛选条件或下拉刷新。"))
|
||||||
.frame(minHeight: 260)
|
.frame(minHeight: 260)
|
||||||
@ -300,10 +301,10 @@ struct OrdersView: View {
|
|||||||
|
|
||||||
private var writeOffList: some View {
|
private var writeOffList: some View {
|
||||||
LazyVStack(spacing: AppMetrics.Spacing.small) {
|
LazyVStack(spacing: AppMetrics.Spacing.small) {
|
||||||
if viewModel.loading {
|
if viewModel.loading && viewModel.writeOffOrders.isEmpty {
|
||||||
ProgressView()
|
Color.clear
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, AppMetrics.Spacing.xxLarge)
|
.frame(minHeight: 260)
|
||||||
} else if viewModel.writeOffOrders.isEmpty {
|
} else if viewModel.writeOffOrders.isEmpty {
|
||||||
ContentUnavailableView("暂无核销订单", systemImage: "tray", description: Text("可下拉刷新或切换景区查看。"))
|
ContentUnavailableView("暂无核销订单", systemImage: "tray", description: Text("可下拉刷新或切换景区查看。"))
|
||||||
.frame(minHeight: 260)
|
.frame(minHeight: 260)
|
||||||
@ -516,18 +517,31 @@ struct OrdersView: View {
|
|||||||
|
|
||||||
private func reload(showLoading: Bool = true) async {
|
private func reload(showLoading: Bool = true) async {
|
||||||
do {
|
do {
|
||||||
try await viewModel.reload(
|
try await globalLoading.withOptionalLoading(shouldShowGlobalLoading(showLoading: showLoading), message: "加载中...") {
|
||||||
api: ordersAPI,
|
try await viewModel.reload(
|
||||||
scenicId: currentScenicId,
|
api: ordersAPI,
|
||||||
storeId: currentStoreId,
|
scenicId: currentScenicId,
|
||||||
roleId: currentRoleId,
|
storeId: currentStoreId,
|
||||||
showLoading: showLoading
|
roleId: currentRoleId,
|
||||||
)
|
showLoading: showLoading
|
||||||
|
)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toastCenter.show(error.localizedDescription)
|
toastCenter.show(error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 判断当前订单列表加载是否需要使用全局 Loading。
|
||||||
|
private func shouldShowGlobalLoading(showLoading: Bool) -> Bool {
|
||||||
|
guard showLoading, currentScenicId != nil else { return false }
|
||||||
|
switch viewModel.selectedEntry {
|
||||||
|
case .storeOrders:
|
||||||
|
return viewModel.storeOrders.isEmpty
|
||||||
|
case .verificationOrders:
|
||||||
|
return viewModel.writeOffOrders.isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func loadMoreStoreOrders() async {
|
private func loadMoreStoreOrders() async {
|
||||||
do {
|
do {
|
||||||
try await viewModel.loadMoreStoreOrders(api: ordersAPI, scenicId: currentScenicId, roleId: currentRoleId)
|
try await viewModel.loadMoreStoreOrders(api: ordersAPI, scenicId: currentScenicId, roleId: currentRoleId)
|
||||||
@ -567,7 +581,9 @@ struct OrdersView: View {
|
|||||||
private func verify(orderNumber: String) async {
|
private func verify(orderNumber: String) async {
|
||||||
guard let scenicId = currentScenicId else { return }
|
guard let scenicId = currentScenicId else { return }
|
||||||
do {
|
do {
|
||||||
try await viewModel.verify(api: ordersAPI, scenicId: scenicId, storeId: currentStoreId, orderNumber: orderNumber)
|
try await globalLoading.withLoading(message: "核销中...") {
|
||||||
|
try await viewModel.verify(api: ordersAPI, scenicId: scenicId, storeId: currentStoreId, orderNumber: orderNumber)
|
||||||
|
}
|
||||||
manualOrderNumber = ""
|
manualOrderNumber = ""
|
||||||
pendingVerifyOrder = nil
|
pendingVerifyOrder = nil
|
||||||
pendingVerifyOrderNumber = nil
|
pendingVerifyOrderNumber = nil
|
||||||
|
|||||||
@ -14,6 +14,7 @@ struct PaymentCollectionView: View {
|
|||||||
@Environment(AccountContext.self) private var accountContext
|
@Environment(AccountContext.self) private var accountContext
|
||||||
@Environment(PaymentAPI.self) private var paymentAPI
|
@Environment(PaymentAPI.self) private var paymentAPI
|
||||||
@Environment(ToastCenter.self) private var toastCenter
|
@Environment(ToastCenter.self) private var toastCenter
|
||||||
|
@Environment(\.globalLoading) private var globalLoading
|
||||||
|
|
||||||
@State private var viewModel = PaymentCollectionViewModel()
|
@State private var viewModel = PaymentCollectionViewModel()
|
||||||
@State private var showingAmountSheet = false
|
@State private var showingAmountSheet = false
|
||||||
@ -44,7 +45,9 @@ struct PaymentCollectionView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task(id: accountContext.currentScenic?.id) {
|
.task(id: accountContext.currentScenic?.id) {
|
||||||
await viewModel.loadPayCode(api: paymentAPI, scenicId: accountContext.currentScenic?.id)
|
await globalLoading.withOptionalLoading(viewModel.qrImage == nil, message: "加载中...") {
|
||||||
|
await viewModel.loadPayCode(api: paymentAPI, scenicId: accountContext.currentScenic?.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingAmountSheet) {
|
.sheet(isPresented: $showingAmountSheet) {
|
||||||
amountSheet
|
amountSheet
|
||||||
@ -87,10 +90,7 @@ struct PaymentCollectionView: View {
|
|||||||
/// 二维码展示卡片。
|
/// 二维码展示卡片。
|
||||||
private var qrCard: some View {
|
private var qrCard: some View {
|
||||||
VStack(spacing: AppMetrics.Spacing.medium) {
|
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||||
if viewModel.isLoading {
|
if let image = viewModel.qrImage {
|
||||||
ProgressView()
|
|
||||||
.frame(width: 220, height: 220)
|
|
||||||
} else if let image = viewModel.qrImage {
|
|
||||||
Image(uiImage: image)
|
Image(uiImage: image)
|
||||||
.interpolation(.none)
|
.interpolation(.none)
|
||||||
.resizable()
|
.resizable()
|
||||||
@ -129,7 +129,11 @@ struct PaymentCollectionView: View {
|
|||||||
saveQRCode()
|
saveQRCode()
|
||||||
}
|
}
|
||||||
paymentActionButton(title: "刷新", icon: "arrow.clockwise") {
|
paymentActionButton(title: "刷新", icon: "arrow.clockwise") {
|
||||||
Task { await viewModel.loadPayCode(api: paymentAPI, scenicId: accountContext.currentScenic?.id) }
|
Task {
|
||||||
|
await globalLoading.withLoading(message: "刷新中...") {
|
||||||
|
await viewModel.loadPayCode(api: paymentAPI, scenicId: accountContext.currentScenic?.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -255,14 +259,11 @@ struct PaymentCollectionRecordView: View {
|
|||||||
@Environment(AccountContext.self) private var accountContext
|
@Environment(AccountContext.self) private var accountContext
|
||||||
@Environment(PaymentAPI.self) private var paymentAPI
|
@Environment(PaymentAPI.self) private var paymentAPI
|
||||||
@Environment(ToastCenter.self) private var toastCenter
|
@Environment(ToastCenter.self) private var toastCenter
|
||||||
|
@Environment(\.globalLoading) private var globalLoading
|
||||||
@State private var viewModel = PaymentCollectionRecordViewModel()
|
@State private var viewModel = PaymentCollectionRecordViewModel()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
if viewModel.isLoading {
|
|
||||||
ProgressView()
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
ForEach(viewModel.groups) { group in
|
ForEach(viewModel.groups) { group in
|
||||||
Section {
|
Section {
|
||||||
ForEach(group.items) { item in
|
ForEach(group.items) { item in
|
||||||
@ -299,7 +300,9 @@ struct PaymentCollectionRecordView: View {
|
|||||||
await viewModel.load(api: paymentAPI, scenicId: accountContext.currentScenic?.id)
|
await viewModel.load(api: paymentAPI, scenicId: accountContext.currentScenic?.id)
|
||||||
}
|
}
|
||||||
.task(id: accountContext.currentScenic?.id) {
|
.task(id: accountContext.currentScenic?.id) {
|
||||||
await viewModel.load(api: paymentAPI, scenicId: accountContext.currentScenic?.id)
|
await globalLoading.withOptionalLoading(viewModel.groups.isEmpty, message: "加载中...") {
|
||||||
|
await viewModel.load(api: paymentAPI, scenicId: accountContext.currentScenic?.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: viewModel.errorMessage) { _, message in
|
.onChange(of: viewModel.errorMessage) { _, message in
|
||||||
if let message {
|
if let message {
|
||||||
|
|||||||
@ -18,6 +18,7 @@ struct AccountSwitchView: View {
|
|||||||
@Environment(AccountContextAPI.self) private var accountContextAPI
|
@Environment(AccountContextAPI.self) private var accountContextAPI
|
||||||
@Environment(AuthSessionCoordinator.self) private var authSessionCoordinator
|
@Environment(AuthSessionCoordinator.self) private var authSessionCoordinator
|
||||||
@Environment(ToastCenter.self) private var toastCenter
|
@Environment(ToastCenter.self) private var toastCenter
|
||||||
|
@Environment(\.globalLoading) private var globalLoading
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@State private var viewModel = AccountSwitchViewModel()
|
@State private var viewModel = AccountSwitchViewModel()
|
||||||
@ -71,15 +72,9 @@ struct AccountSwitchView: View {
|
|||||||
Button {
|
Button {
|
||||||
Task { await confirmSelection() }
|
Task { await confirmSelection() }
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 8) {
|
Text("确认切换")
|
||||||
if viewModel.switching {
|
.font(.system(size: 16, weight: .semibold))
|
||||||
ProgressView()
|
.foregroundStyle(.white)
|
||||||
.tint(.white)
|
|
||||||
}
|
|
||||||
Text("确认切换")
|
|
||||||
.font(.system(size: 16, weight: .semibold))
|
|
||||||
}
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.frame(height: 50)
|
.frame(height: 50)
|
||||||
.background(canConfirm ? AppDesign.primary : Color(hex: 0xC9CED6), in: RoundedRectangle(cornerRadius: 12))
|
.background(canConfirm ? AppDesign.primary : Color(hex: 0xC9CED6), in: RoundedRectangle(cornerRadius: 12))
|
||||||
@ -187,7 +182,9 @@ struct AccountSwitchView: View {
|
|||||||
/// 拉取账号列表。
|
/// 拉取账号列表。
|
||||||
private func loadAccounts(force: Bool = false) async {
|
private func loadAccounts(force: Bool = false) async {
|
||||||
do {
|
do {
|
||||||
try await viewModel.load(api: profileAPI, force: force, currentAccountId: currentAccountId)
|
try await globalLoading.withOptionalLoading(!force && viewModel.accounts.isEmpty, message: "加载中...") {
|
||||||
|
try await viewModel.load(api: profileAPI, force: force, currentAccountId: currentAccountId)
|
||||||
|
}
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
return
|
return
|
||||||
} catch {
|
} catch {
|
||||||
@ -204,18 +201,20 @@ struct AccountSwitchView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let response = try await viewModel.switchAccount(account, api: authAPI)
|
try await globalLoading.withLoading(message: "账号切换中...") {
|
||||||
let username = nonEmpty(accountContext.profile?.phone) ?? nonEmpty(account.phone) ?? ""
|
let response = try await viewModel.switchAccount(account, api: authAPI)
|
||||||
try await authSessionCoordinator.completeLogin(
|
let username = nonEmpty(accountContext.profile?.phone) ?? nonEmpty(account.phone) ?? ""
|
||||||
with: response,
|
try await authSessionCoordinator.completeLogin(
|
||||||
username: username,
|
with: response,
|
||||||
privacyAgreementAccepted: authSessionCoordinator.loginPreferences().privacyAgreementAccepted,
|
username: username,
|
||||||
appSession: appSession,
|
privacyAgreementAccepted: authSessionCoordinator.loginPreferences().privacyAgreementAccepted,
|
||||||
accountContext: accountContext,
|
appSession: appSession,
|
||||||
permissionContext: permissionContext,
|
accountContext: accountContext,
|
||||||
profileAPI: profileAPI,
|
permissionContext: permissionContext,
|
||||||
accountContextAPI: accountContextAPI
|
profileAPI: profileAPI,
|
||||||
)
|
accountContextAPI: accountContextAPI
|
||||||
|
)
|
||||||
|
}
|
||||||
appRouter.reset()
|
appRouter.reset()
|
||||||
toastCenter.show("账号已切换")
|
toastCenter.show("账号已切换")
|
||||||
dismiss()
|
dismiss()
|
||||||
|
|||||||
@ -21,6 +21,7 @@ struct ProfileView: View {
|
|||||||
@Environment(OSSUploadService.self) private var ossUploadService
|
@Environment(OSSUploadService.self) private var ossUploadService
|
||||||
@Environment(ToastCenter.self) private var toastCenter
|
@Environment(ToastCenter.self) private var toastCenter
|
||||||
@Environment(AuthSessionCoordinator.self) private var authSessionCoordinator
|
@Environment(AuthSessionCoordinator.self) private var authSessionCoordinator
|
||||||
|
@Environment(\.globalLoading) private var globalLoading
|
||||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
|
|
||||||
@State private var viewModel = ProfileViewModel()
|
@State private var viewModel = ProfileViewModel()
|
||||||
@ -73,14 +74,6 @@ struct ProfileView: View {
|
|||||||
}
|
}
|
||||||
Button("取消", role: .cancel) {}
|
Button("取消", role: .cancel) {}
|
||||||
}
|
}
|
||||||
.overlay {
|
|
||||||
if viewModel.isLoading && viewModel.userInfo == nil {
|
|
||||||
ProgressView()
|
|
||||||
.controlSize(.large)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.background(Color.white.opacity(0.35))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.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) {
|
||||||
@ -512,7 +505,9 @@ struct ProfileView: View {
|
|||||||
/// 重新拉取个人资料,并同步更新全局账号资料。
|
/// 重新拉取个人资料,并同步更新全局账号资料。
|
||||||
private func reloadProfile(showToast: Bool) async {
|
private func reloadProfile(showToast: Bool) async {
|
||||||
do {
|
do {
|
||||||
try await viewModel.reload(api: profileAPI)
|
try await globalLoading.withOptionalLoading(!showToast && viewModel.userInfo == nil, message: "加载中...") {
|
||||||
|
try await viewModel.reload(api: profileAPI)
|
||||||
|
}
|
||||||
if let userInfo = viewModel.userInfo {
|
if let userInfo = viewModel.userInfo {
|
||||||
authSessionCoordinator.refreshCachedProfile(from: userInfo, accountContext: accountContext)
|
authSessionCoordinator.refreshCachedProfile(from: userInfo, accountContext: accountContext)
|
||||||
} else {
|
} else {
|
||||||
@ -531,11 +526,13 @@ struct ProfileView: View {
|
|||||||
/// 保存昵称编辑内容,并在成功后刷新全局账号展示。
|
/// 保存昵称编辑内容,并在成功后刷新全局账号展示。
|
||||||
private func saveProfileEdits() async {
|
private func saveProfileEdits() async {
|
||||||
do {
|
do {
|
||||||
try await viewModel.saveProfile(
|
try await globalLoading.withLoading(message: "保存中...") {
|
||||||
api: profileAPI,
|
try await viewModel.saveProfile(
|
||||||
uploader: ossUploadService,
|
api: profileAPI,
|
||||||
scenicId: accountContext.currentScenic?.id ?? 0
|
uploader: ossUploadService,
|
||||||
)
|
scenicId: accountContext.currentScenic?.id ?? 0
|
||||||
|
)
|
||||||
|
}
|
||||||
if let userInfo = viewModel.userInfo {
|
if let userInfo = viewModel.userInfo {
|
||||||
authSessionCoordinator.refreshCachedProfile(from: userInfo, accountContext: accountContext)
|
authSessionCoordinator.refreshCachedProfile(from: userInfo, accountContext: accountContext)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -15,6 +15,7 @@ struct RealNameAuthView: View {
|
|||||||
@Environment(ProfileAPI.self) private var profileAPI
|
@Environment(ProfileAPI.self) private var profileAPI
|
||||||
@Environment(OSSUploadService.self) private var ossUploadService
|
@Environment(OSSUploadService.self) private var ossUploadService
|
||||||
@Environment(ToastCenter.self) private var toastCenter
|
@Environment(ToastCenter.self) private var toastCenter
|
||||||
|
@Environment(\.globalLoading) private var globalLoading
|
||||||
@State private var viewModel = RealNameAuthViewModel()
|
@State private var viewModel = RealNameAuthViewModel()
|
||||||
@State private var pickedFrontItem: PhotosPickerItem?
|
@State private var pickedFrontItem: PhotosPickerItem?
|
||||||
@State private var pickedBackItem: PhotosPickerItem?
|
@State private var pickedBackItem: PhotosPickerItem?
|
||||||
@ -37,14 +38,6 @@ struct RealNameAuthView: View {
|
|||||||
.task {
|
.task {
|
||||||
await loadInfo()
|
await loadInfo()
|
||||||
}
|
}
|
||||||
.overlay {
|
|
||||||
if viewModel.loading && viewModel.info == nil {
|
|
||||||
ProgressView()
|
|
||||||
.controlSize(.large)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.background(Color.white.opacity(0.35))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: pickedFrontItem) { _, item in
|
.onChange(of: pickedFrontItem) { _, item in
|
||||||
Task { await prepareIdentityImage(from: item, side: .front) }
|
Task { await prepareIdentityImage(from: item, side: .front) }
|
||||||
}
|
}
|
||||||
@ -380,7 +373,9 @@ struct RealNameAuthView: View {
|
|||||||
/// 拉取实名认证信息。
|
/// 拉取实名认证信息。
|
||||||
private func loadInfo() async {
|
private func loadInfo() async {
|
||||||
do {
|
do {
|
||||||
try await viewModel.load(api: profileAPI)
|
try await globalLoading.withOptionalLoading(viewModel.info == nil, message: "加载中...") {
|
||||||
|
try await viewModel.load(api: profileAPI)
|
||||||
|
}
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
return
|
return
|
||||||
} catch {
|
} catch {
|
||||||
@ -400,11 +395,13 @@ struct RealNameAuthView: View {
|
|||||||
/// 提交实名资料。
|
/// 提交实名资料。
|
||||||
private func submit() async {
|
private func submit() async {
|
||||||
do {
|
do {
|
||||||
try await viewModel.submit(
|
try await globalLoading.withLoading(message: "提交中...") {
|
||||||
api: profileAPI,
|
try await viewModel.submit(
|
||||||
uploader: ossUploadService,
|
api: profileAPI,
|
||||||
scenicId: accountContext.currentScenic?.id ?? 0
|
uploader: ossUploadService,
|
||||||
)
|
scenicId: accountContext.currentScenic?.id ?? 0
|
||||||
|
)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
viewModel.statusMessage = error.localizedDescription
|
viewModel.statusMessage = error.localizedDescription
|
||||||
toastCenter.show(error.localizedDescription)
|
toastCenter.show(error.localizedDescription)
|
||||||
|
|||||||
@ -13,6 +13,7 @@ struct StatisticsView: View {
|
|||||||
@Environment(PermissionContext.self) private var permissionContext
|
@Environment(PermissionContext.self) private var permissionContext
|
||||||
@Environment(StatisticsAPI.self) private var statisticsAPI
|
@Environment(StatisticsAPI.self) private var statisticsAPI
|
||||||
@Environment(ToastCenter.self) private var toastCenter
|
@Environment(ToastCenter.self) private var toastCenter
|
||||||
|
@Environment(\.globalLoading) private var globalLoading
|
||||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
|
|
||||||
@State private var viewModel = StatisticsViewModel()
|
@State private var viewModel = StatisticsViewModel()
|
||||||
@ -165,8 +166,8 @@ struct StatisticsView: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
if viewModel.loading {
|
if viewModel.loading && viewModel.dailyItems.isEmpty {
|
||||||
ProgressView()
|
Color.clear
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.frame(minHeight: 280)
|
.frame(minHeight: 280)
|
||||||
} else if viewModel.dailyItems.isEmpty {
|
} else if viewModel.dailyItems.isEmpty {
|
||||||
@ -287,7 +288,9 @@ struct StatisticsView: View {
|
|||||||
|
|
||||||
private func selectPeriod(_ period: StatisticsPeriod) async {
|
private func selectPeriod(_ period: StatisticsPeriod) async {
|
||||||
do {
|
do {
|
||||||
try await viewModel.selectPeriod(period, api: statisticsAPI, scenicId: currentScenicId, roleId: currentRoleId)
|
try await globalLoading.withLoading(message: "加载中...") {
|
||||||
|
try await viewModel.selectPeriod(period, api: statisticsAPI, scenicId: currentScenicId, roleId: currentRoleId)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toastCenter.show(error.localizedDescription)
|
toastCenter.show(error.localizedDescription)
|
||||||
}
|
}
|
||||||
@ -295,7 +298,9 @@ struct StatisticsView: View {
|
|||||||
|
|
||||||
private func reload(showLoading: Bool = true) async {
|
private func reload(showLoading: Bool = true) async {
|
||||||
do {
|
do {
|
||||||
try await viewModel.reload(api: statisticsAPI, scenicId: currentScenicId, roleId: currentRoleId, showLoading: showLoading)
|
try await globalLoading.withOptionalLoading(showLoading && currentScenicId != nil && viewModel.dailyItems.isEmpty, message: "加载中...") {
|
||||||
|
try await viewModel.reload(api: statisticsAPI, scenicId: currentScenicId, roleId: currentRoleId, showLoading: showLoading)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toastCenter.show(error.localizedDescription)
|
toastCenter.show(error.localizedDescription)
|
||||||
}
|
}
|
||||||
|
|||||||
1
suixinkan/Resources/loading.json
Normal file
1
suixinkan/Resources/loading.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"v":"4.8.0","meta":{"g":"LottieFiles AE 1.0.0","a":"","k":"","d":"","tc":"#FFFFFF"},"fr":25,"ip":25,"op":55,"w":800,"h":800,"nm":"Loading #18","ddd":0,"assets":[{"id":"comp_0","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Line","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":0,"s":[20]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":15,"s":[100]},{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":30,"s":[20]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":45,"s":[100]},{"t":60,"s":[20]}],"ix":11},"r":{"a":0,"k":90,"ix":10},"p":{"a":0,"k":[400,400,0],"ix":2},"a":{"a":0,"k":[-175,46,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-2,0]],"o":[[0,0],[2,0]],"v":[[-268,46],[-82,46]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.2745,0.3608,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":39,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":0,"s":[45]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":15,"s":[0]},{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":30,"s":[45]},{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":45,"s":[0]},{"t":60,"s":[45]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":0,"s":[55]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":15,"s":[100]},{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":30,"s":[55]},{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":45,"s":[100]},{"t":60,"s":[55]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":125,"st":0,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Line 5","parent":3,"refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[540,400,0],"ix":2},"a":{"a":0,"k":[400,400,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":800,"h":800,"ip":25,"op":80,"st":25,"bm":0},{"ddd":0,"ind":2,"ty":0,"nm":"Line 4","parent":3,"refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[470,400,0],"ix":2},"a":{"a":0,"k":[400,400,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":800,"h":800,"ip":22,"op":77,"st":22,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"Line 3","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[400,400,0],"ix":2},"a":{"a":0,"k":[400,400,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":800,"h":800,"ip":19,"op":74,"st":19,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"Line 2","parent":3,"refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[330,400,0],"ix":2},"a":{"a":0,"k":[400,400,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":800,"h":800,"ip":16,"op":71,"st":16,"bm":0},{"ddd":0,"ind":5,"ty":0,"nm":"Line 1","parent":3,"refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[260,400,0],"ix":2},"a":{"a":0,"k":[400,400,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":800,"h":800,"ip":13,"op":68,"st":13,"bm":0}],"markers":[]}
|
||||||
113
suixinkanTests/GlobalLoadingCenterTests.swift
Normal file
113
suixinkanTests/GlobalLoadingCenterTests.swift
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
//
|
||||||
|
// GlobalLoadingCenterTests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 全局 Loading 命令中心测试,验证展示状态不会泄漏给业务页面使用。
|
||||||
|
final class GlobalLoadingCenterTests: XCTestCase {
|
||||||
|
/// 测试多次 show/hide 会按引用计数控制最终显隐。
|
||||||
|
func testShowHideUsesReferenceCount() {
|
||||||
|
let center = GlobalLoadingCenter()
|
||||||
|
|
||||||
|
center.show(message: "加载账号")
|
||||||
|
center.show(message: "加载权限")
|
||||||
|
|
||||||
|
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "加载权限", activeCount: 2))
|
||||||
|
|
||||||
|
center.hide()
|
||||||
|
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "加载权限", activeCount: 1))
|
||||||
|
|
||||||
|
center.hide()
|
||||||
|
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试 hide 调用次数超过 show 时不会产生负数计数。
|
||||||
|
func testHideNeverDropsReferenceCountBelowZero() {
|
||||||
|
let center = GlobalLoadingCenter()
|
||||||
|
|
||||||
|
center.hide()
|
||||||
|
|
||||||
|
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试更新文案只影响当前展示内容,不改变引用计数。
|
||||||
|
func testUpdateMessageOnlyChangesVisibleMessage() {
|
||||||
|
let center = GlobalLoadingCenter()
|
||||||
|
|
||||||
|
center.updateMessage("不会展示")
|
||||||
|
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0))
|
||||||
|
|
||||||
|
center.show(message: "加载中")
|
||||||
|
center.updateMessage("即将完成")
|
||||||
|
|
||||||
|
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "即将完成", activeCount: 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试 withLoading 成功完成后自动隐藏 Loading。
|
||||||
|
func testWithLoadingHidesAfterSuccess() async throws {
|
||||||
|
let center = GlobalLoadingCenter()
|
||||||
|
|
||||||
|
let value = try await center.withLoading(message: "提交中") {
|
||||||
|
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "提交中", activeCount: 1))
|
||||||
|
return 42
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertEqual(value, 42)
|
||||||
|
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试 withLoading 抛错时也会自动隐藏 Loading。
|
||||||
|
func testWithLoadingHidesAfterFailure() async {
|
||||||
|
let center = GlobalLoadingCenter()
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await center.withLoading(message: "提交中") {
|
||||||
|
throw TestFailure.expected
|
||||||
|
} as Int
|
||||||
|
XCTFail("withLoading 应该向外抛出业务错误")
|
||||||
|
} catch {
|
||||||
|
XCTAssertEqual(error as? TestFailure, .expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试 withOptionalLoading 关闭时不会改变展示状态。
|
||||||
|
func testWithOptionalLoadingDisabledDoesNotChangeState() async throws {
|
||||||
|
let center = GlobalLoadingCenter()
|
||||||
|
|
||||||
|
let value = try await center.withOptionalLoading(false, message: "不展示") {
|
||||||
|
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0))
|
||||||
|
return "done"
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertEqual(value, "done")
|
||||||
|
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试使用 Loading 不需要读取可观察展示状态,业务侧只能拿到命令中心类型。
|
||||||
|
func testBusinessCodeOnlyDependsOnCommandCenter() {
|
||||||
|
let center = GlobalLoadingCenter()
|
||||||
|
|
||||||
|
issueLoadingCommand(center)
|
||||||
|
|
||||||
|
XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "加载中", activeCount: 1))
|
||||||
|
center.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模拟业务代码只接收命令中心并发出展示指令。
|
||||||
|
private func issueLoadingCommand(_ center: GlobalLoadingCenter) {
|
||||||
|
center.show(message: "加载中")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试用错误类型。
|
||||||
|
private enum TestFailure: Error, Equatable {
|
||||||
|
case expected
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user