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:
2026-06-23 11:25:56 +08:00
parent 1d1eb46ed8
commit 7fd964fe19
17 changed files with 514 additions and 139 deletions

View File

@ -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))

View File

@ -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 {

View File

@ -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) {}

View File

@ -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

View File

@ -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 {

View File

@ -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()

View File

@ -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 {

View File

@ -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)

View File

@ -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)
}