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:
@ -10,6 +10,7 @@
|
||||
11A5FC66EE3769DAC202A397 /* Pods_suixinkanTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EDE192531FA9E2FD908B9EC1 /* Pods_suixinkanTests.framework */; };
|
||||
93DAED0B2FE8E50000B9E2B1 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 93DAED0D2FE8E50000B9E2B1 /* Kingfisher */; };
|
||||
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 */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@ -58,6 +59,7 @@
|
||||
files = (
|
||||
93DAED0B2FE8E50000B9E2B1 /* Kingfisher in Frameworks */,
|
||||
93DAED0C2FE8E50000B9E2B1 /* AlibabaCloudOSS in Frameworks */,
|
||||
93DAED112FE9100000B9E2B1 /* Lottie in Frameworks */,
|
||||
AE69C4E776CD531A1BB67C15 /* Pods_suixinkan.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@ -137,6 +139,7 @@
|
||||
packageProductDependencies = (
|
||||
93DAED0D2FE8E50000B9E2B1 /* Kingfisher */,
|
||||
93DAED0E2FE8E50000B9E2B1 /* AlibabaCloudOSS */,
|
||||
93DAED102FE9100000B9E2B1 /* Lottie */,
|
||||
);
|
||||
productName = suixinkan;
|
||||
productReference = 939AC7962FE3F832004B22E4 /* suixinkan.app */;
|
||||
@ -195,6 +198,7 @@
|
||||
packageReferences = (
|
||||
93DAED092FE8E14D00B9E2B1 /* XCRemoteSwiftPackageReference "Kingfisher" */,
|
||||
93DAED0A2FE8E17E00B9E2B1 /* XCRemoteSwiftPackageReference "alibabacloud-oss-swift-sdk-v2" */,
|
||||
93DAED0F2FE9100000B9E2B1 /* XCRemoteSwiftPackageReference "lottie-ios" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 939AC7972FE3F832004B22E4 /* Products */;
|
||||
@ -644,6 +648,14 @@
|
||||
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 */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
@ -657,6 +669,11 @@
|
||||
package = 93DAED0A2FE8E17E00B9E2B1 /* XCRemoteSwiftPackageReference "alibabacloud-oss-swift-sdk-v2" */;
|
||||
productName = AlibabaCloudOSS;
|
||||
};
|
||||
93DAED102FE9100000B9E2B1 /* Lottie */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 93DAED0F2FE9100000B9E2B1 /* XCRemoteSwiftPackageReference "lottie-ios" */;
|
||||
productName = Lottie;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 939AC78E2FE3F832004B22E4 /* Project object */;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "dca97369973ef74cd9a7eb6d4579ab8af27675808e732def830661fbc4778f6f",
|
||||
"originHash" : "6f02892b99a60872df0561ceead8949f780d607eccd0a776d8f299248fc13a72",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "alibabacloud-oss-swift-sdk-v2",
|
||||
@ -19,6 +19,15 @@
|
||||
"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",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
@ -15,6 +15,7 @@ struct RootView: View {
|
||||
@State private var scenicSpotContext = ScenicSpotContext()
|
||||
@State private var appRouter = AppRouter()
|
||||
@State private var toastCenter = ToastCenter()
|
||||
@State private var globalLoading = GlobalLoadingCenter()
|
||||
@State private var snapshotStore: AccountSnapshotStore
|
||||
@State private var apiClient: APIClient
|
||||
@State private var authAPI: AuthAPI
|
||||
@ -66,12 +67,14 @@ struct RootView: View {
|
||||
var body: some View {
|
||||
rootContent
|
||||
.globalToastOverlay()
|
||||
.globalLoadingOverlay(loadingCenter: globalLoading)
|
||||
.environment(appSession)
|
||||
.environment(accountContext)
|
||||
.environment(permissionContext)
|
||||
.environment(scenicSpotContext)
|
||||
.environment(appRouter)
|
||||
.environment(toastCenter)
|
||||
.environment(\.globalLoading, globalLoading)
|
||||
.environment(snapshotStore)
|
||||
.environment(apiClient)
|
||||
.environment(authAPI)
|
||||
@ -87,14 +90,16 @@ struct RootView: View {
|
||||
.environment(authSessionCoordinator)
|
||||
.task {
|
||||
apiClient.bindAuthTokenProvider { appSession.token }
|
||||
await sessionBootstrapper.restore(
|
||||
appSession: appSession,
|
||||
accountContext: accountContext,
|
||||
permissionContext: permissionContext,
|
||||
profileAPI: profileAPI,
|
||||
accountContextAPI: accountContextAPI,
|
||||
toastCenter: toastCenter
|
||||
)
|
||||
await globalLoading.withLoading {
|
||||
await sessionBootstrapper.restore(
|
||||
appSession: appSession,
|
||||
accountContext: accountContext,
|
||||
permissionContext: permissionContext,
|
||||
profileAPI: profileAPI,
|
||||
accountContextAPI: accountContextAPI,
|
||||
toastCenter: toastCenter
|
||||
)
|
||||
}
|
||||
}
|
||||
.task(id: scenicSpotTaskID) {
|
||||
guard appSession.isLoggedIn else {
|
||||
@ -122,9 +127,8 @@ struct RootView: View {
|
||||
case .loggedOut:
|
||||
LoginView()
|
||||
case .restoring:
|
||||
ProgressView()
|
||||
Color(.systemBackground)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(.systemBackground))
|
||||
case .loggedIn:
|
||||
MainTabsView()
|
||||
}
|
||||
|
||||
@ -65,6 +65,14 @@ Core 模块提供跨业务复用的基础能力,包括网络请求、缓存存
|
||||
|
||||
新增页面时优先使用 `AppMetrics` 和 `AppDesign`。只有明显属于单个页面的特殊尺寸,才保留在页面本地。
|
||||
|
||||
### 全局 Loading
|
||||
|
||||
`GlobalLoadingCenter` 是全局 Loading 的命令中心,只负责展示加载状态,不保存业务数据。业务 View 只能通过 Environment 获取它并调用 `show`、`hide`、`updateMessage`、`withLoading` 或 `withOptionalLoading`,不要在 `body` 中读取 `isVisible`、`message` 等展示状态。
|
||||
|
||||
Loading 的可观察状态只在 `GlobalLoadingOverlayHost` 内部订阅,并由 `RootView` 挂载到应用根部。这样切换 Loading 显隐时,只会刷新根部 Overlay,不会让当前页面、Tab 根视图或业务子视图形成观察依赖。
|
||||
|
||||
全局 Loading 只用于阻塞型等待,例如冷启动恢复、登录、首屏加载、提交表单和核销。列表加载更多、上传进度、按钮内局部反馈继续保留在页面局部状态中。
|
||||
|
||||
## Upload
|
||||
|
||||
`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 }
|
||||
onConfirm(selectedAccount)
|
||||
} label: {
|
||||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
}
|
||||
Text("进入系统")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
Text("进入系统")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
|
||||
.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(AccountContextAPI.self) private var accountContextAPI
|
||||
@Environment(AuthSessionCoordinator.self) private var authSessionCoordinator
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@State private var viewModel = LoginViewModel()
|
||||
@FocusState private var focusedField: LoginField?
|
||||
@ -111,27 +112,13 @@ struct LoginView: View {
|
||||
Spacer().frame(height: AppMetrics.Spacing.mediumLarge)
|
||||
|
||||
Button(action: loginAction) {
|
||||
HStack {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
.frame(width: AppMetrics.ControlSize.progressWidth)
|
||||
} else {
|
||||
Spacer()
|
||||
.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))
|
||||
Text("登录")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.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)
|
||||
.accessibilityIdentifier("login.submit")
|
||||
@ -167,12 +154,14 @@ struct LoginView: View {
|
||||
|
||||
Task {
|
||||
do {
|
||||
let resolution = try await viewModel.login(authAPI: authAPI)
|
||||
switch resolution {
|
||||
case let .completed(response):
|
||||
await completeLogin(with: response)
|
||||
case .needsAccountSelection:
|
||||
break
|
||||
try await globalLoading.withLoading(message: "登录中...") {
|
||||
let resolution = try await viewModel.login(authAPI: authAPI)
|
||||
switch resolution {
|
||||
case let .completed(response):
|
||||
await completeLogin(with: response)
|
||||
case .needsAccountSelection:
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch is CancellationError {
|
||||
// Keep cancellation silent; the current task was abandoned by SwiftUI.
|
||||
@ -186,8 +175,10 @@ struct LoginView: View {
|
||||
private func selectAccount(_ account: AccountSwitchAccount) {
|
||||
Task {
|
||||
do {
|
||||
let response = try await viewModel.selectAccount(account, authAPI: authAPI)
|
||||
await completeLogin(with: response)
|
||||
try await globalLoading.withLoading(message: "账号切换中...") {
|
||||
let response = try await viewModel.selectAccount(account, authAPI: authAPI)
|
||||
await completeLogin(with: response)
|
||||
}
|
||||
} catch is CancellationError {
|
||||
// Keep cancellation silent; the current task was abandoned by SwiftUI.
|
||||
} catch {
|
||||
|
||||
@ -13,6 +13,7 @@ struct StoreOrderDetailView: View {
|
||||
@Environment(OrdersAPI.self) private var ordersAPI
|
||||
@Environment(RouterPath.self) private var router
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
let item: OrderEntity
|
||||
@State private var viewModel: OrderDetailViewModel
|
||||
@ -25,16 +26,6 @@ struct StoreOrderDetailView: View {
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if viewModel.loading {
|
||||
Section {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView("加载详情中...")
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let contextMessage = viewModel.contextMessage {
|
||||
Section {
|
||||
Label(contextMessage, systemImage: "info.circle")
|
||||
@ -117,7 +108,9 @@ struct StoreOrderDetailView: View {
|
||||
.navigationTitle("订单详情")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.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) {
|
||||
Button("知道了", role: .cancel) {}
|
||||
|
||||
@ -15,6 +15,7 @@ struct OrdersView: View {
|
||||
@Environment(RouterPath.self) private var router
|
||||
@Environment(OrdersAPI.self) private var ordersAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
@State private var viewModel = OrdersViewModel()
|
||||
@ -227,10 +228,10 @@ struct OrdersView: View {
|
||||
|
||||
private var storeList: some View {
|
||||
LazyVStack(spacing: AppMetrics.Spacing.small) {
|
||||
if viewModel.loading {
|
||||
ProgressView()
|
||||
if viewModel.loading && viewModel.storeOrders.isEmpty {
|
||||
Color.clear
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, AppMetrics.Spacing.xxLarge)
|
||||
.frame(minHeight: 260)
|
||||
} else if viewModel.storeOrders.isEmpty {
|
||||
ContentUnavailableView("暂无订单", systemImage: "tray", description: Text("可切换筛选条件或下拉刷新。"))
|
||||
.frame(minHeight: 260)
|
||||
@ -300,10 +301,10 @@ struct OrdersView: View {
|
||||
|
||||
private var writeOffList: some View {
|
||||
LazyVStack(spacing: AppMetrics.Spacing.small) {
|
||||
if viewModel.loading {
|
||||
ProgressView()
|
||||
if viewModel.loading && viewModel.writeOffOrders.isEmpty {
|
||||
Color.clear
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, AppMetrics.Spacing.xxLarge)
|
||||
.frame(minHeight: 260)
|
||||
} else if viewModel.writeOffOrders.isEmpty {
|
||||
ContentUnavailableView("暂无核销订单", systemImage: "tray", description: Text("可下拉刷新或切换景区查看。"))
|
||||
.frame(minHeight: 260)
|
||||
@ -516,18 +517,31 @@ struct OrdersView: View {
|
||||
|
||||
private func reload(showLoading: Bool = true) async {
|
||||
do {
|
||||
try await viewModel.reload(
|
||||
api: ordersAPI,
|
||||
scenicId: currentScenicId,
|
||||
storeId: currentStoreId,
|
||||
roleId: currentRoleId,
|
||||
showLoading: showLoading
|
||||
)
|
||||
try await globalLoading.withOptionalLoading(shouldShowGlobalLoading(showLoading: showLoading), message: "加载中...") {
|
||||
try await viewModel.reload(
|
||||
api: ordersAPI,
|
||||
scenicId: currentScenicId,
|
||||
storeId: currentStoreId,
|
||||
roleId: currentRoleId,
|
||||
showLoading: showLoading
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
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 {
|
||||
do {
|
||||
try await viewModel.loadMoreStoreOrders(api: ordersAPI, scenicId: currentScenicId, roleId: currentRoleId)
|
||||
@ -567,7 +581,9 @@ struct OrdersView: View {
|
||||
private func verify(orderNumber: String) async {
|
||||
guard let scenicId = currentScenicId else { return }
|
||||
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 = ""
|
||||
pendingVerifyOrder = nil
|
||||
pendingVerifyOrderNumber = nil
|
||||
|
||||
@ -14,6 +14,7 @@ struct PaymentCollectionView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(PaymentAPI.self) private var paymentAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = PaymentCollectionViewModel()
|
||||
@State private var showingAmountSheet = false
|
||||
@ -44,7 +45,9 @@ struct PaymentCollectionView: View {
|
||||
}
|
||||
}
|
||||
.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) {
|
||||
amountSheet
|
||||
@ -87,10 +90,7 @@ struct PaymentCollectionView: View {
|
||||
/// 二维码展示卡片。
|
||||
private var qrCard: some View {
|
||||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.frame(width: 220, height: 220)
|
||||
} else if let image = viewModel.qrImage {
|
||||
if let image = viewModel.qrImage {
|
||||
Image(uiImage: image)
|
||||
.interpolation(.none)
|
||||
.resizable()
|
||||
@ -129,7 +129,11 @@ struct PaymentCollectionView: View {
|
||||
saveQRCode()
|
||||
}
|
||||
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(PaymentAPI.self) private var paymentAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@State private var viewModel = PaymentCollectionRecordViewModel()
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
ForEach(viewModel.groups) { group in
|
||||
Section {
|
||||
ForEach(group.items) { item in
|
||||
@ -299,7 +300,9 @@ struct PaymentCollectionRecordView: View {
|
||||
await viewModel.load(api: paymentAPI, scenicId: 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
|
||||
if let message {
|
||||
|
||||
@ -18,6 +18,7 @@ struct AccountSwitchView: View {
|
||||
@Environment(AccountContextAPI.self) private var accountContextAPI
|
||||
@Environment(AuthSessionCoordinator.self) private var authSessionCoordinator
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var viewModel = AccountSwitchViewModel()
|
||||
@ -71,15 +72,9 @@ struct AccountSwitchView: View {
|
||||
Button {
|
||||
Task { await confirmSelection() }
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
if viewModel.switching {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
}
|
||||
Text("确认切换")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
Text("确认切换")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
.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 {
|
||||
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 {
|
||||
return
|
||||
} catch {
|
||||
@ -204,18 +201,20 @@ struct AccountSwitchView: View {
|
||||
}
|
||||
|
||||
do {
|
||||
let response = try await viewModel.switchAccount(account, api: authAPI)
|
||||
let username = nonEmpty(accountContext.profile?.phone) ?? nonEmpty(account.phone) ?? ""
|
||||
try await authSessionCoordinator.completeLogin(
|
||||
with: response,
|
||||
username: username,
|
||||
privacyAgreementAccepted: authSessionCoordinator.loginPreferences().privacyAgreementAccepted,
|
||||
appSession: appSession,
|
||||
accountContext: accountContext,
|
||||
permissionContext: permissionContext,
|
||||
profileAPI: profileAPI,
|
||||
accountContextAPI: accountContextAPI
|
||||
)
|
||||
try await globalLoading.withLoading(message: "账号切换中...") {
|
||||
let response = try await viewModel.switchAccount(account, api: authAPI)
|
||||
let username = nonEmpty(accountContext.profile?.phone) ?? nonEmpty(account.phone) ?? ""
|
||||
try await authSessionCoordinator.completeLogin(
|
||||
with: response,
|
||||
username: username,
|
||||
privacyAgreementAccepted: authSessionCoordinator.loginPreferences().privacyAgreementAccepted,
|
||||
appSession: appSession,
|
||||
accountContext: accountContext,
|
||||
permissionContext: permissionContext,
|
||||
profileAPI: profileAPI,
|
||||
accountContextAPI: accountContextAPI
|
||||
)
|
||||
}
|
||||
appRouter.reset()
|
||||
toastCenter.show("账号已切换")
|
||||
dismiss()
|
||||
|
||||
@ -21,6 +21,7 @@ struct ProfileView: View {
|
||||
@Environment(OSSUploadService.self) private var ossUploadService
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(AuthSessionCoordinator.self) private var authSessionCoordinator
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
@State private var viewModel = ProfileViewModel()
|
||||
@ -73,14 +74,6 @@ struct ProfileView: View {
|
||||
}
|
||||
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
|
||||
guard isEditing else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
||||
@ -512,7 +505,9 @@ struct ProfileView: View {
|
||||
/// 重新拉取个人资料,并同步更新全局账号资料。
|
||||
private func reloadProfile(showToast: Bool) async {
|
||||
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 {
|
||||
authSessionCoordinator.refreshCachedProfile(from: userInfo, accountContext: accountContext)
|
||||
} else {
|
||||
@ -531,11 +526,13 @@ struct ProfileView: View {
|
||||
/// 保存昵称编辑内容,并在成功后刷新全局账号展示。
|
||||
private func saveProfileEdits() async {
|
||||
do {
|
||||
try await viewModel.saveProfile(
|
||||
api: profileAPI,
|
||||
uploader: ossUploadService,
|
||||
scenicId: accountContext.currentScenic?.id ?? 0
|
||||
)
|
||||
try await globalLoading.withLoading(message: "保存中...") {
|
||||
try await viewModel.saveProfile(
|
||||
api: profileAPI,
|
||||
uploader: ossUploadService,
|
||||
scenicId: accountContext.currentScenic?.id ?? 0
|
||||
)
|
||||
}
|
||||
if let userInfo = viewModel.userInfo {
|
||||
authSessionCoordinator.refreshCachedProfile(from: userInfo, accountContext: accountContext)
|
||||
} else {
|
||||
|
||||
@ -15,6 +15,7 @@ struct RealNameAuthView: View {
|
||||
@Environment(ProfileAPI.self) private var profileAPI
|
||||
@Environment(OSSUploadService.self) private var ossUploadService
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@State private var viewModel = RealNameAuthViewModel()
|
||||
@State private var pickedFrontItem: PhotosPickerItem?
|
||||
@State private var pickedBackItem: PhotosPickerItem?
|
||||
@ -37,14 +38,6 @@ struct RealNameAuthView: View {
|
||||
.task {
|
||||
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
|
||||
Task { await prepareIdentityImage(from: item, side: .front) }
|
||||
}
|
||||
@ -380,7 +373,9 @@ struct RealNameAuthView: View {
|
||||
/// 拉取实名认证信息。
|
||||
private func loadInfo() async {
|
||||
do {
|
||||
try await viewModel.load(api: profileAPI)
|
||||
try await globalLoading.withOptionalLoading(viewModel.info == nil, message: "加载中...") {
|
||||
try await viewModel.load(api: profileAPI)
|
||||
}
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
@ -400,11 +395,13 @@ struct RealNameAuthView: View {
|
||||
/// 提交实名资料。
|
||||
private func submit() async {
|
||||
do {
|
||||
try await viewModel.submit(
|
||||
api: profileAPI,
|
||||
uploader: ossUploadService,
|
||||
scenicId: accountContext.currentScenic?.id ?? 0
|
||||
)
|
||||
try await globalLoading.withLoading(message: "提交中...") {
|
||||
try await viewModel.submit(
|
||||
api: profileAPI,
|
||||
uploader: ossUploadService,
|
||||
scenicId: accountContext.currentScenic?.id ?? 0
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
viewModel.statusMessage = error.localizedDescription
|
||||
toastCenter.show(error.localizedDescription)
|
||||
|
||||
@ -13,6 +13,7 @@ struct StatisticsView: View {
|
||||
@Environment(PermissionContext.self) private var permissionContext
|
||||
@Environment(StatisticsAPI.self) private var statisticsAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
@State private var viewModel = StatisticsViewModel()
|
||||
@ -165,8 +166,8 @@ struct StatisticsView: View {
|
||||
|
||||
Divider()
|
||||
|
||||
if viewModel.loading {
|
||||
ProgressView()
|
||||
if viewModel.loading && viewModel.dailyItems.isEmpty {
|
||||
Color.clear
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(minHeight: 280)
|
||||
} else if viewModel.dailyItems.isEmpty {
|
||||
@ -287,7 +288,9 @@ struct StatisticsView: View {
|
||||
|
||||
private func selectPeriod(_ period: StatisticsPeriod) async {
|
||||
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 {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
@ -295,7 +298,9 @@ struct StatisticsView: View {
|
||||
|
||||
private func reload(showLoading: Bool = true) async {
|
||||
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 {
|
||||
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