将扫码页提升到列表层弹出并补齐 AppDelegate.window,避免扫码时 UIKit 取主窗口崩溃;Toast 改用独立 UIWindow 显示在 sheet 之上;合作获客员支持短信验证修改分成比例,同时优化冷启动根视图创建时机并补充相关测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
313 lines
14 KiB
Swift
313 lines
14 KiB
Swift
//
|
||
// RootView.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/18.
|
||
//
|
||
|
||
import SwiftUI
|
||
|
||
/// 根视图内容展示状态,隔离冷启动判定与登录阶段切换。
|
||
enum RootContentDisplayState: Equatable {
|
||
case bootstrapPlaceholder
|
||
case login
|
||
case restoringPlaceholder
|
||
case mainTabs
|
||
|
||
/// 根据冷启动 bootstrap 状态和登录阶段计算当前应创建的根内容。
|
||
static func resolve(isColdStartBootstrapPending: Bool, appPhase: AuthPhase) -> RootContentDisplayState {
|
||
if isColdStartBootstrapPending {
|
||
return .bootstrapPlaceholder
|
||
}
|
||
|
||
switch appPhase {
|
||
case .loggedOut:
|
||
return .login
|
||
case .restoring:
|
||
return .restoringPlaceholder
|
||
case .loggedIn:
|
||
return .mainTabs
|
||
}
|
||
}
|
||
}
|
||
|
||
/// App 根视图,负责创建全局依赖并根据登录状态切换登录页和主界面。
|
||
struct RootView: View {
|
||
@Environment(\.scenePhase) private var scenePhase
|
||
@StateObject private var appSession = AppSession()
|
||
@StateObject private var accountContext = AccountContext()
|
||
@StateObject private var permissionContext = PermissionContext()
|
||
@StateObject private var scenicSpotContext = ScenicSpotContext()
|
||
@StateObject private var appRouter = AppRouter()
|
||
@StateObject private var toastCenter = ToastCenter()
|
||
@State private var globalLoading = GlobalLoadingCenter()
|
||
@State private var snapshotStore: AccountSnapshotStore
|
||
@State private var apiClient: APIClient
|
||
@State private var authAPI: AuthAPI
|
||
@State private var profileAPI: ProfileAPI
|
||
@State private var uploadAPI: UploadAPI
|
||
@State private var ossUploadService: OSSUploadService
|
||
@State private var accountContextAPI: AccountContextAPI
|
||
@State private var ordersAPI: OrdersAPI
|
||
@State private var statisticsAPI: StatisticsAPI
|
||
@State private var paymentAPI: PaymentAPI
|
||
@State private var walletAPI: WalletAPI
|
||
@State private var pushAPI: PushAPI
|
||
@State private var scenicPermissionAPI: ScenicPermissionAPI
|
||
@State private var scenicSettlementAPI: ScenicSettlementAPI
|
||
@State private var messageCenterAPI: MessageCenterAPI
|
||
@State private var scenicQueueAPI: ScenicQueueAPI
|
||
@StateObject private var scenicQueueRuntime = ScenicQueueRuntime()
|
||
@State private var liveAPI: LiveAPI
|
||
@State private var operatingAreaAPI: OperatingAreaAPI
|
||
@State private var pilotCertificationAPI: PilotCertificationAPI
|
||
@State private var taskAPI: TaskAPI
|
||
@State private var projectAPI: ProjectAPI
|
||
@State private var scheduleAPI: ScheduleAPI
|
||
@State private var inviteAPI: InviteAPI
|
||
@State private var assetsAPI: AssetsAPI
|
||
@State private var travelAlbumAPI: TravelAlbumAPI
|
||
@State private var punchPointAPI: PunchPointAPI
|
||
@State private var locationReportAPI: LocationReportAPI
|
||
@StateObject private var homeLocationViewModel: HomeLocationViewModel
|
||
@State private var cooperationOrderAPI: CooperationOrderAPI
|
||
@State private var authSessionCoordinator: AuthSessionCoordinator
|
||
@State private var sessionBootstrapper: SessionBootstrapper
|
||
@State private var isColdStartBootstrapPending = true
|
||
@State private var appConfigAPI: AppConfigAPI
|
||
@State private var preferencesStore: AppPreferencesStore
|
||
|
||
/// 初始化网络客户端和业务 API,确保它们共享同一个 token provider。
|
||
init() {
|
||
let apiClient = APIClient()
|
||
let tokenStore = SessionTokenStore()
|
||
let snapshotStore = AccountSnapshotStore()
|
||
let preferencesStore = AppPreferencesStore()
|
||
_snapshotStore = State(initialValue: snapshotStore)
|
||
_apiClient = State(initialValue: apiClient)
|
||
_authAPI = State(initialValue: AuthAPI(client: apiClient))
|
||
_appConfigAPI = State(initialValue: AppConfigAPI(client: apiClient))
|
||
_profileAPI = State(initialValue: ProfileAPI(client: apiClient))
|
||
let uploadAPI = UploadAPI(client: apiClient)
|
||
_uploadAPI = State(initialValue: uploadAPI)
|
||
_ossUploadService = State(initialValue: OSSUploadService(configService: uploadAPI))
|
||
_accountContextAPI = State(initialValue: AccountContextAPI(client: apiClient))
|
||
_ordersAPI = State(initialValue: OrdersAPI(client: apiClient))
|
||
_statisticsAPI = State(initialValue: StatisticsAPI(client: apiClient))
|
||
_paymentAPI = State(initialValue: PaymentAPI(client: apiClient))
|
||
_walletAPI = State(initialValue: WalletAPI(client: apiClient))
|
||
_pushAPI = State(initialValue: PushAPI(client: apiClient))
|
||
_scenicPermissionAPI = State(initialValue: ScenicPermissionAPI(client: apiClient))
|
||
_scenicSettlementAPI = State(initialValue: ScenicSettlementAPI(client: apiClient))
|
||
_messageCenterAPI = State(initialValue: MessageCenterAPI(client: apiClient))
|
||
_scenicQueueAPI = State(initialValue: ScenicQueueAPI(client: apiClient))
|
||
_liveAPI = State(initialValue: LiveAPI(client: apiClient))
|
||
_operatingAreaAPI = State(initialValue: OperatingAreaAPI(client: apiClient))
|
||
_pilotCertificationAPI = State(initialValue: PilotCertificationAPI(client: apiClient))
|
||
_taskAPI = State(initialValue: TaskAPI(client: apiClient))
|
||
_projectAPI = State(initialValue: ProjectAPI(client: apiClient))
|
||
_scheduleAPI = State(initialValue: ScheduleAPI(client: apiClient))
|
||
_inviteAPI = State(initialValue: InviteAPI(client: apiClient))
|
||
_assetsAPI = State(initialValue: AssetsAPI(client: apiClient))
|
||
_travelAlbumAPI = State(initialValue: TravelAlbumAPI(client: apiClient))
|
||
_punchPointAPI = State(initialValue: PunchPointAPI(client: apiClient))
|
||
let locationReportAPI = LocationReportAPI(client: apiClient)
|
||
_locationReportAPI = State(initialValue: locationReportAPI)
|
||
_homeLocationViewModel = StateObject(wrappedValue: HomeLocationViewModel(api: locationReportAPI))
|
||
_cooperationOrderAPI = State(initialValue: CooperationOrderAPI(client: apiClient))
|
||
_preferencesStore = State(initialValue: preferencesStore)
|
||
_authSessionCoordinator = State(
|
||
initialValue: AuthSessionCoordinator(
|
||
tokenStore: tokenStore,
|
||
snapshotStore: snapshotStore,
|
||
preferencesStore: preferencesStore,
|
||
onPrivacyAgreementPersisted: { accepted in
|
||
guard accepted else { return }
|
||
AMapBootstrap.configure(apiKey: AMapConfig.apiKey)
|
||
UMengSDKBootstrap.shared.configureIfAllowed(preferencesStore: preferencesStore)
|
||
}
|
||
)
|
||
)
|
||
_sessionBootstrapper = State(
|
||
initialValue: SessionBootstrapper(
|
||
tokenStore: tokenStore,
|
||
snapshotStore: snapshotStore
|
||
)
|
||
)
|
||
}
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
rootContent
|
||
|
||
if isColdStartBootstrapPending {
|
||
SplashView()
|
||
.transition(.opacity)
|
||
.zIndex(1)
|
||
}
|
||
}
|
||
.globalToastOverlay()
|
||
.globalLoadingOverlay(loadingCenter: globalLoading)
|
||
.environmentObject(appSession)
|
||
.environmentObject(accountContext)
|
||
.environmentObject(permissionContext)
|
||
.environmentObject(scenicSpotContext)
|
||
.environmentObject(appRouter)
|
||
.environmentObject(toastCenter)
|
||
.environment(\.globalLoading, globalLoading)
|
||
.environment(\.accountSnapshotStore, snapshotStore)
|
||
.environment(\.apiClient, apiClient)
|
||
.environment(\.authAPI, authAPI)
|
||
.environment(\.appConfigAPI, appConfigAPI)
|
||
.environment(\.profileAPI, profileAPI)
|
||
.environment(\.uploadAPI, uploadAPI)
|
||
.environment(\.ossUploadService, ossUploadService)
|
||
.environment(\.accountContextAPI, accountContextAPI)
|
||
.environment(\.ordersAPI, ordersAPI)
|
||
.environment(\.statisticsAPI, statisticsAPI)
|
||
.environment(\.paymentAPI, paymentAPI)
|
||
.environment(\.walletAPI, walletAPI)
|
||
.environment(\.pushAPI, pushAPI)
|
||
.environment(\.scenicPermissionAPI, scenicPermissionAPI)
|
||
.environment(\.scenicSettlementAPI, scenicSettlementAPI)
|
||
.environment(\.messageCenterAPI, messageCenterAPI)
|
||
.environment(\.scenicQueueAPI, scenicQueueAPI)
|
||
.environmentObject(scenicQueueRuntime)
|
||
.environment(\.liveAPI, liveAPI)
|
||
.environment(\.operatingAreaAPI, operatingAreaAPI)
|
||
.environment(\.pilotCertificationAPI, pilotCertificationAPI)
|
||
.environment(\.taskAPI, taskAPI)
|
||
.environment(\.projectAPI, projectAPI)
|
||
.environment(\.scheduleAPI, scheduleAPI)
|
||
.environment(\.inviteAPI, inviteAPI)
|
||
.environment(\.assetsAPI, assetsAPI)
|
||
.environment(\.travelAlbumAPI, travelAlbumAPI)
|
||
.environment(\.punchPointAPI, punchPointAPI)
|
||
.environment(\.locationReportAPI, locationReportAPI)
|
||
.environmentObject(homeLocationViewModel)
|
||
.environment(\.cooperationOrderAPI, cooperationOrderAPI)
|
||
.environment(\.authSessionCoordinator, authSessionCoordinator)
|
||
.task {
|
||
apiClient.bindAuthTokenProvider { appSession.token }
|
||
configurePrivacyGatedThirdPartySDKsIfAllowed()
|
||
PushNotificationManager.shared.configure(api: pushAPI, session: appSession, router: appRouter)
|
||
await sessionBootstrapper.restore(
|
||
appSession: appSession,
|
||
accountContext: accountContext,
|
||
permissionContext: permissionContext,
|
||
profileAPI: profileAPI,
|
||
accountContextAPI: accountContextAPI,
|
||
toastCenter: toastCenter
|
||
)
|
||
withAnimation {
|
||
isColdStartBootstrapPending = false
|
||
}
|
||
if appSession.isLoggedIn, !AppUITestLaunchState.isRunningUITests {
|
||
PushNotificationManager.shared.requestAuthorizationAndRegister()
|
||
}
|
||
}
|
||
.task(id: scenicSpotTaskID) {
|
||
guard appSession.isLoggedIn else {
|
||
scenicSpotContext.reset()
|
||
return
|
||
}
|
||
await scenicSpotContext.reload(
|
||
scenicId: accountContext.currentScenic?.id,
|
||
api: accountContextAPI
|
||
)
|
||
}
|
||
.onChange(of: scenePhase) { newPhase in
|
||
guard !AppUITestLaunchState.isRunningUITests else { return }
|
||
if appSession.isLoggedIn {
|
||
scenicQueueRuntime.update(
|
||
api: scenicQueueAPI,
|
||
userId: accountContext.profile?.userId,
|
||
scenicId: accountContext.currentScenic?.id,
|
||
scenePhase: newPhase
|
||
)
|
||
} else {
|
||
scenicQueueRuntime.stop()
|
||
}
|
||
}
|
||
.onChange(of: accountContext.currentScenic?.id) { _ in
|
||
guard !AppUITestLaunchState.isRunningUITests else { return }
|
||
if appSession.isLoggedIn {
|
||
scenicQueueRuntime.update(
|
||
api: scenicQueueAPI,
|
||
userId: accountContext.profile?.userId,
|
||
scenicId: accountContext.currentScenic?.id,
|
||
scenePhase: scenePhase
|
||
)
|
||
}
|
||
}
|
||
.onChange(of: appSession.phase) { phase in
|
||
switch phase {
|
||
case .loggedIn:
|
||
if !AppUITestLaunchState.isRunningUITests {
|
||
PushNotificationManager.shared.requestAuthorizationAndRegister()
|
||
}
|
||
case .loggedOut:
|
||
accountContext.reset()
|
||
permissionContext.reset()
|
||
scenicSpotContext.reset()
|
||
appRouter.reset()
|
||
toastCenter.dismiss()
|
||
case .restoring:
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var rootContent: some View {
|
||
switch rootContentDisplayState {
|
||
case .bootstrapPlaceholder, .restoringPlaceholder:
|
||
Color.clear
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
case .login:
|
||
LoginView()
|
||
case .mainTabs:
|
||
MainTabsView()
|
||
.task {
|
||
#if DEBUG
|
||
await AppUITestRouteDriver.applyIfNeeded(appRouter: appRouter)
|
||
#endif
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 计算根视图当前应创建的内容,避免冷启动期间提前创建登录页。
|
||
private var rootContentDisplayState: RootContentDisplayState {
|
||
RootContentDisplayState.resolve(
|
||
isColdStartBootstrapPending: isColdStartBootstrapPending,
|
||
appPhase: appSession.phase
|
||
)
|
||
}
|
||
|
||
/// 景点加载任务的稳定标识,确保登录状态和当前景区变化都会触发任务。
|
||
private var scenicSpotTaskID: String {
|
||
let phaseText: String
|
||
switch appSession.phase {
|
||
case .loggedOut:
|
||
phaseText = "loggedOut"
|
||
case .restoring:
|
||
phaseText = "restoring"
|
||
case .loggedIn:
|
||
phaseText = "loggedIn"
|
||
}
|
||
return "\(phaseText)-\(accountContext.currentScenic?.id ?? 0)"
|
||
}
|
||
|
||
/// 在用户已经同意隐私政策后初始化第三方 SDK。
|
||
private func configurePrivacyGatedThirdPartySDKsIfAllowed() {
|
||
guard preferencesStore.loadPrivacyAgreementAccepted() else { return }
|
||
|
||
AMapBootstrap.configure(apiKey: AMapConfig.apiKey)
|
||
UMengSDKBootstrap.shared.configureIfAllowed(preferencesStore: preferencesStore)
|
||
}
|
||
}
|
||
|
||
#Preview {
|
||
RootView()
|
||
}
|