修复合作获客扫码绑定崩溃与 Toast 层级,并接入分成比例修改。
将扫码页提升到列表层弹出并补齐 AppDelegate.window,避免扫码时 UIKit 取主窗口崩溃;Toast 改用独立 UIWindow 显示在 sheet 之上;合作获客员支持短信验证修改分成比例,同时优化冷启动根视图创建时机并补充相关测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -85,19 +85,19 @@ SwiftUI 的 `Environment` 在本项目中是**依赖注入(DI)通道**,不
|
||||
1. `suixinkanApp` 处理 UI Test 冷启动状态清理并创建 `RootView`。
|
||||
2. `RootView` 初始化共享依赖,并把它们注入 Environment。
|
||||
3. iOS 系统 Launch Screen 展示品牌页(白底、Logo、文案)。
|
||||
4. `RootView` 挂载后在 bootstrap 完成前以 `SplashView` 覆盖根视图,避免空白或登录页闪屏。
|
||||
4. `RootView` 挂载后在 bootstrap 完成前只创建透明占位,并以 `SplashView` 覆盖根视图,避免空白、登录页闪屏和登录页任务提前启动。
|
||||
5. `APIClient` 绑定 `AppSession.token` 作为默认 token provider。
|
||||
6. 如果本地已记录用户同意隐私政策,则初始化高德与友盟 SDK;否则不触发第三方 SDK 初始化。
|
||||
7. `SessionBootstrapper.restore` 尝试从 UserDefaults 读取正式 token。
|
||||
8. 无 token 时保持 `loggedOut`,展示 `LoginView`。
|
||||
8. 无 token 时完成 bootstrap,再保持 `loggedOut` 并展示 `LoginView`。
|
||||
9. 有 token 时进入 `restoring`,先恢复本地账号快照。
|
||||
10. 阻塞请求 `rolePermissions` 成功后立即 `markLoggedIn` 并展示 `MainTabsView`。
|
||||
11. `userInfo`、`scenicListAll`、`storeAll` 在后台补充刷新,不阻塞首屏。
|
||||
12. `ScenicSpotContext` 按当前景区懒加载景点/打卡点。
|
||||
13. 明确 token 失效时清空 token 和账号快照,回到登录页。
|
||||
13. 明确 token 失效时清空 token 和账号快照,bootstrap 完成后回到登录页。
|
||||
14. 普通网络失败时保留本地登录态,使用账号快照进入主界面。
|
||||
15. 冷启动 bootstrap 不使用 Lottie 全局 Loading;`.restoring` 期间展示 `SplashView`。
|
||||
16. `LoginView` 首屏出现后调用 `/api/app/config` 加载远程配置(如 `enable_register`),失败静默。
|
||||
16. `LoginView` 只有在 bootstrap 判定完成且登录页真实可见后才创建,首屏出现后调用 `/api/app/config` 加载远程配置(如 `enable_register`),失败静默。
|
||||
|
||||
## 初始化分层
|
||||
|
||||
|
||||
@ -9,16 +9,34 @@ import UIKit
|
||||
|
||||
/// AppDelegate 承接 APNs 与微信 SDK 系统回调,SwiftUI App 入口通过 adaptor 注入。
|
||||
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
/// 主窗口引用。SwiftUI 生命周期下系统不会自动注入,但扫码等 UIKit 路径仍会读取 `delegate.window`。
|
||||
@objc var window: UIWindow?
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||
) -> Bool {
|
||||
syncKeyWindow()
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(syncKeyWindow),
|
||||
name: UIWindow.didBecomeKeyNotification,
|
||||
object: nil
|
||||
)
|
||||
Task { @MainActor in
|
||||
WeChatManager.shared.registerIfNeeded()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// 将 `window` 与当前 key window 保持同步,供 UIKit / SDK 通过 AppDelegate 取主窗口。
|
||||
@objc private func syncKeyWindow() {
|
||||
window = UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.flatMap(\.windows)
|
||||
.first(where: \.isKeyWindow)
|
||||
}
|
||||
|
||||
func application(
|
||||
_ app: UIApplication,
|
||||
open url: URL,
|
||||
|
||||
@ -7,6 +7,30 @@
|
||||
|
||||
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
|
||||
@ -236,13 +260,13 @@ struct RootView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var rootContent: some View {
|
||||
switch appSession.phase {
|
||||
case .loggedOut:
|
||||
LoginView()
|
||||
case .restoring:
|
||||
switch rootContentDisplayState {
|
||||
case .bootstrapPlaceholder, .restoringPlaceholder:
|
||||
Color.clear
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
case .loggedIn:
|
||||
case .login:
|
||||
LoginView()
|
||||
case .mainTabs:
|
||||
MainTabsView()
|
||||
.task {
|
||||
#if DEBUG
|
||||
@ -252,6 +276,14 @@ struct RootView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 计算根视图当前应创建的内容,避免冷启动期间提前创建登录页。
|
||||
private var rootContentDisplayState: RootContentDisplayState {
|
||||
RootContentDisplayState.resolve(
|
||||
isColdStartBootstrapPending: isColdStartBootstrapPending,
|
||||
appPhase: appSession.phase
|
||||
)
|
||||
}
|
||||
|
||||
/// 景点加载任务的稳定标识,确保登录状态和当前景区变化都会触发任务。
|
||||
private var scenicSpotTaskID: String {
|
||||
let phaseText: String
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
/// 全局 Toast 状态中心,负责保存和清除当前提示文案。
|
||||
@ -62,26 +63,75 @@ final class ToastCenter: ObservableObject {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 全局 Toast 叠加层修饰器,负责把 Toast 展示到屏幕中央。
|
||||
private struct GlobalToastOverlay: ViewModifier {
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
/// Toast 独立窗口宿主,确保提示浮在 sheet / fullScreenCover 等 present 层之上。
|
||||
@MainActor
|
||||
private final class ToastWindowPresenter {
|
||||
static let shared = ToastWindowPresenter()
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.overlay {
|
||||
Group {
|
||||
if let message = toastCenter.message {
|
||||
toastBanner(message)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.18), value: toastCenter.message)
|
||||
private var toastWindow: UIWindow?
|
||||
|
||||
/// 在独立 UIWindow 中展示 Toast。
|
||||
func show(message: String) {
|
||||
guard let windowScene = resolveWindowScene() else { return }
|
||||
|
||||
let window: UIWindow
|
||||
if let existing = toastWindow, existing.windowScene === windowScene {
|
||||
window = existing
|
||||
} else {
|
||||
let newWindow = PassthroughWindow(windowScene: windowScene)
|
||||
newWindow.windowLevel = .alert + 1
|
||||
newWindow.backgroundColor = .clear
|
||||
toastWindow = newWindow
|
||||
window = newWindow
|
||||
}
|
||||
|
||||
let hosting = UIHostingController(
|
||||
rootView: ToastBannerHostView(message: message)
|
||||
)
|
||||
hosting.view.backgroundColor = .clear
|
||||
window.rootViewController = hosting
|
||||
window.isHidden = false
|
||||
}
|
||||
|
||||
/// 构建屏幕中央的黑色半透明圆角 Toast 卡片。
|
||||
private func toastBanner(_ message: String) -> some View {
|
||||
/// 隐藏 Toast 窗口。
|
||||
func hide() {
|
||||
toastWindow?.isHidden = true
|
||||
toastWindow?.rootViewController = nil
|
||||
}
|
||||
|
||||
/// 取当前前台 window scene,供独立 Toast 窗口挂载。
|
||||
private func resolveWindowScene() -> UIWindowScene? {
|
||||
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
|
||||
return scenes.first { $0.activationState == .foregroundActive } ?? scenes.first
|
||||
}
|
||||
}
|
||||
|
||||
/// 透传触摸事件的 Toast 窗口,避免挡住下层弹窗交互。
|
||||
private final class PassthroughWindow: UIWindow {
|
||||
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||
guard let view = super.hitTest(point, with: event) else { return nil }
|
||||
return view === rootViewController?.view ? nil : view
|
||||
}
|
||||
}
|
||||
|
||||
/// Toast 全屏居中容器。
|
||||
private struct ToastBannerHostView: View {
|
||||
let message: String
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ToastBannerContent(message: message)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// 屏幕中央的黑色半透明圆角 Toast 卡片。
|
||||
private struct ToastBannerContent: View {
|
||||
let message: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
@ -97,6 +147,31 @@ private struct GlobalToastOverlay: ViewModifier {
|
||||
.padding(.vertical, 14)
|
||||
.background(Color.black.opacity(0.78), in: RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal, 40)
|
||||
.accessibilityIdentifier("global.toast")
|
||||
}
|
||||
}
|
||||
|
||||
/// 全局 Toast 展示桥接层,监听 ToastCenter 并驱动独立窗口。
|
||||
private struct GlobalToastOverlay: ViewModifier {
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.onAppear {
|
||||
syncToastWindow(with: toastCenter.message)
|
||||
}
|
||||
.onChange(of: toastCenter.message) { message in
|
||||
syncToastWindow(with: message)
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据当前文案显示或隐藏 Toast 窗口。
|
||||
private func syncToastWindow(with message: String?) {
|
||||
if let message {
|
||||
ToastWindowPresenter.shared.show(message: message)
|
||||
} else {
|
||||
ToastWindowPresenter.shared.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user