From a1c031c9b73e4d88792db936ab7826a0031eeb9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Fri, 26 Jun 2026 16:36:18 +0800 Subject: [PATCH] Integrate CYLTabBar center scan button and unify UIKit navigation. Add CYLTabBarController with a global scan entry, promote MainTabBarController to the window root after login, replace RouterPath-based navigation with AppNavigator, and fix Profile diffable cell reconfiguration crashes. Co-authored-by: Cursor --- Podfile | 7 +- Podfile.lock | 8 +- suixinkan_ios/App/App.md | 20 +- suixinkan_ios/App/AppServices.swift | 4 +- suixinkan_ios/App/AppUITestRouteDriver.swift | 16 +- .../AppRouteViewControllerFactory.swift | 9 - .../App/Navigation/NavigationRouter.swift | 199 ++++++++++------- .../Navigation/TabNavigationController.swift | 80 +------ .../App/Navigation/UIKitAppNavigation.swift | 46 ---- .../App/State/AuthSessionCoordinator.swift | 5 +- .../ViewControllers/RootViewController.swift | 119 ++++++---- .../Core/Push/PushNotificationManager.swift | 28 +-- suixinkan_ios/Features/Home/Home.md | 2 +- .../ViewControllers/HomeMenuRouting.swift | 36 +--- .../ViewControllers/HomeViewController.swift | 2 +- suixinkan_ios/Features/Main/Main.md | 22 +- .../MainTabBarController.swift | 164 ++++++++++---- .../Main/Views/MainScanPlusButton.swift | 77 +++++++ suixinkan_ios/Features/Orders/Orders.md | 4 +- .../DepositOrderViewControllers.swift | 4 +- .../OrderDetailViewControllers.swift | 8 +- .../OrdersViewController.swift | 36 ++-- suixinkan_ios/Features/Profile/Profile.md | 4 +- .../AccountSwitchViewController.swift | 2 +- .../ProfileViewController.swift | 92 +++++--- .../SettingsViewControllers.swift | 2 +- suixinkan_ios/SceneDelegate.swift | 10 +- .../MainScanPlusButtonTests.swift | 58 +++++ .../NavigationRouterTests.swift | 204 ++++++++++++------ .../PushNotificationTests.swift | 83 +++++-- 功能同步Checklist.md | 4 +- 31 files changed, 838 insertions(+), 517 deletions(-) delete mode 100644 suixinkan_ios/App/Navigation/UIKitAppNavigation.swift create mode 100644 suixinkan_ios/Features/Main/Views/MainScanPlusButton.swift create mode 100644 suixinkan_iosTests/MainScanPlusButtonTests.swift diff --git a/Podfile b/Podfile index 8d9923c..71ef345 100644 --- a/Podfile +++ b/Podfile @@ -5,6 +5,7 @@ target 'suixinkan_ios' do pod 'SnapKit', '~> 5.7' pod 'Kingfisher', '~> 8.0' + pod 'CYLTabBarController', '~> 1.99.38' # 高德地图 SDK:仅真机构建时链接;模拟器通过 post_install 剥离 pod 'AMap3DMap-NO-IDFA', '~> 11.1' @@ -17,11 +18,13 @@ target 'suixinkan_ios' do end SIMULATOR_OTHER_LDFLAGS = <<~FLAGS.squish - -ObjC -l"c++" -l"z" + -ObjC -l"c++" -l"swiftCoreGraphics" -l"z" + -framework "Accelerate" -framework "CFNetwork" -framework "CYLTabBarController" + -framework "Kingfisher" -framework "SnapKit" -framework "CoreGraphics" -framework "CoreLocation" -framework "CoreTelephony" -framework "CoreText" -framework "ExternalAccessory" -framework "GLKit" -framework "OpenGLES" -framework "QuartzCore" -framework "Security" - -framework "SystemConfiguration" + -framework "SystemConfiguration" -weak_framework "Combine" -weak_framework "SwiftUI" FLAGS post_install do |installer| diff --git a/Podfile.lock b/Podfile.lock index 2711869..d07b091 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -6,6 +6,9 @@ PODS: - AMapFoundation-NO-IDFA (>= 1.9.0) - AMapSearch-NO-IDFA (9.8.0): - AMapFoundation-NO-IDFA (>= 1.9.0) + - CYLTabBarController (1.99.38): + - CYLTabBarController/Core (= 1.99.38) + - CYLTabBarController/Core (1.99.38) - Kingfisher (8.10.0) - SnapKit (5.7.1) @@ -13,6 +16,7 @@ DEPENDENCIES: - AMap3DMap-NO-IDFA (~> 11.1) - AMapLocation-NO-IDFA (~> 2.11) - AMapSearch-NO-IDFA (~> 9.7) + - CYLTabBarController (~> 1.99.38) - Kingfisher (~> 8.0) - SnapKit (~> 5.7) @@ -22,6 +26,7 @@ SPEC REPOS: - AMapFoundation-NO-IDFA - AMapLocation-NO-IDFA - AMapSearch-NO-IDFA + - CYLTabBarController - Kingfisher - SnapKit @@ -30,9 +35,10 @@ SPEC CHECKSUMS: AMapFoundation-NO-IDFA: a2e3c895398d7ee757278e1a0a8f9359da4b146e AMapLocation-NO-IDFA: 7cd8fc837ea41edfbf4d937cd20572e277b77d18 AMapSearch-NO-IDFA: c0afd2a69a076d4228becda4401dbe4a279a03ef + CYLTabBarController: 8d46d501757c00e36499ea6b95e86e492fab3a61 Kingfisher: db468f911dd666c9134dcbeec4db8bed52e4132d SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a -PODFILE CHECKSUM: 8f89212788a9d27c878d93b811a6eaa818955f8c +PODFILE CHECKSUM: 4068d11e2fdbc0a6514a9ec709302300b8e54da8 COCOAPODS: 1.16.2 diff --git a/suixinkan_ios/App/App.md b/suixinkan_ios/App/App.md index f55127f..f63ba37 100644 --- a/suixinkan_ios/App/App.md +++ b/suixinkan_ios/App/App.md @@ -7,19 +7,19 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航 该模块不直接处理具体页面业务,主要承担 App 级基础设施编排: - 根据登录状态展示登录页、恢复态或主 Tab。 - 创建并注入全局状态和共享服务。 -- 管理每个 Tab 独立的导航栈与 `AppRouter` 路径。 +- 管理每个 Tab 独立的 UIKit 导航栈,并通过 `AppNavigator` 统一执行全局跳转。 - 协调登录成功、退出登录、冷启动恢复和本地缓存同步。 ## 核心对象 -- `AppDelegate` / `SceneDelegate`:UIKit 应用入口,挂载 `RootViewController`。 +- `AppDelegate` / `SceneDelegate`:UIKit 应用入口;`SceneDelegate` 挂载 `RootViewController` 并持有其强引用。 - `AppServices`:全局依赖容器(Composition Root),等价于参考工程 SwiftUI `RootView` 中的 Environment 注入;内部按 Session / Context / Network / UI / Runtime 分组,对外仍保留扁平属性访问。 -- `RootViewController`:根据 `AppSession.phase` 切换登录页、恢复占位或 `MainTabBarController`,并挂载全局 Toast / Loading。 +- `RootViewController`:监听 `AppSession.phase`,未登录时在自身上展示登录/恢复页,登录后将 `MainTabBarController` 设为 window 根控制器,并挂载全局 Toast / Loading。 - `AppSession`:保存认证阶段和正式 token,只负责登录态,不承载业务资料。 - `AccountContext`:保存当前账号资料、景区作用域和门店作用域。 - `PermissionContext`:保存角色权限、当前角色和扁平化权限 URI。 - `ScenicSpotContext`:保存当前景区下的景点/打卡点列表和加载状态。 -- `AppRouter`:保存当前 Tab 和每个 Tab 自己的导航路径。 +- `AppNavigator`:应用级导航器,持有主 Tab 宿主弱引用,并以 UIKit 导航栈作为唯一导航事实源。 - `ToastCenter`:管理当前全局 Toast 文案和自动隐藏任务。 - `AuthSessionCoordinator`:统一处理登录完成、退出登录、偏好读取和账号快照刷新。 - `SessionBootstrapper`:冷启动时读取本地 token 和账号快照,并向服务端校验登录态。 @@ -44,7 +44,7 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航 普通业务 `ViewController`、模块基类(`ModuleTableViewController` / `ModuleCollectionViewController`)通过 `UIViewController.appServices` 扩展访问,**不必**再声明 `private let services` 或新增 `init(services:)`。 -导航辅助(`UIKitAppNavigation`、`HomeMenuRouting`)在全局 push 场景可直接使用 `AppServices.shared`。 +导航辅助(如 `HomeMenuRouting`)在全局 push 场景通过 `AppServices.shared.appNavigator` 执行跳转。 ### ViewModel 层边界 @@ -69,7 +69,7 @@ ViewModel **不持有** `AppServices`。View 从容器取出具体依赖,在 ` 5. 无 token 时保持 `loggedOut`,展示 `LoginViewController`。 6. 有 token 时进入 `restoring`,先恢复本地账号快照。 7. `AccountContextLoader` 并行请求用户资料和角色权限,再补全景区与门店。 -8. 校验成功后进入 `loggedIn`,展示 `MainTabBarController`。 +8. 校验成功后进入 `loggedIn`,将 `MainTabBarController` 设为 window 根控制器。 9. `ScenicSpotContext` 按当前景区懒加载景点/打卡点。 10. 明确 token 失效时清空 token 和账号快照,回到登录页。 11. 普通网络失败时保留本地登录态,使用账号快照进入主界面。 @@ -96,20 +96,20 @@ ViewModel **不持有** `AppServices`。View 从容器取出具体依赖,在 ` 退出登录由 `AuthSessionCoordinator.logout` 统一处理: - 清空 Keychain token。 - 清空账号快照。 -- 重置账号上下文、权限上下文、景点上下文、导航路径和 Toast。 +- 重置账号上下文、权限上下文、景点上下文、全部 UIKit 导航栈和 Toast。 - `AppSession` 切换为 `loggedOut`。 - 保留上次手机号、协议状态等非敏感偏好。 ## Toast -全局 Toast 由 `RootViewController` 在 `viewDidAppear` 时挂载到 window,业务页面通过 `showToast(...)` 或 `appServices.toastCenter.show(...)` 发出提示命令。 +全局 Toast 由 `RootViewController` 在切换 window 根控制器后挂载到 window,业务页面通过 `showToast(...)` 或 `appServices.toastCenter.show(...)` 发出提示命令。 Toast 展示为顶部全宽横幅,背景使用不透明主色并延伸到屏幕顶部、左边和右边;文案居中展示,不提供关闭按钮,默认 2.2 秒后自动消失。连续展示新 Toast 时会覆盖旧文案并重新计时,旧的自动隐藏任务不会影响新的 Toast。 ## 导航规则 -主界面使用 `MainTabBarController`,每个 Tab 内部由独立的 `TabNavigationController`(`UINavigationController`)包裹。`AppRouter` 为每个 `AppTab` 持有独立 `RouterPath`,切换 Tab 不会丢失该 Tab 的内部导航路径。 +主界面使用 `MainTabBarController` 作为 window 根控制器(登录后),每个 Tab 内部由独立的 `TabNavigationController`(`UINavigationController`)包裹。`UINavigationController.viewControllers` 是页面路径的唯一事实源,普通 Tab 切换保留该 Tab 的原有栈。 Tab 根页面显示底部 TabBar。通过 `AppRoute` push 到子页面时,会根据 `AppRoute.hidesTabBarWhenPushed` 统一隐藏 TabBar。 -跨 Tab 跳转通过 `AppRouter.select` / `navigateHome` / `navigateProfile` 处理;Tab 内 push 通过 `UIKitAppNavigation` 或当前 Nav 栈完成。新增页面时优先扩展 `AppRoute` / `HomeRoute`,在 `AppRouteViewControllerFactory` 注册对应 ViewController。 +跨 Tab、推送通知、UI Test 直达和全局扫码都通过 `AppNavigator` 处理。`AppNavigationPolicy.currentStack` 沿当前栈 push,`.tabRoot` 切到目标 Tab 并先回到 root,`.preserveTab` 只切换 Tab 并保留原栈。新增页面时优先扩展 `AppRoute` / `HomeRoute` / `ProfileRoute` / `OrdersRoute`,并在 `AppRouteViewControllerFactory` 注册对应 ViewController。 diff --git a/suixinkan_ios/App/AppServices.swift b/suixinkan_ios/App/AppServices.swift index 0f45a17..d575c68 100644 --- a/suixinkan_ios/App/AppServices.swift +++ b/suixinkan_ios/App/AppServices.swift @@ -17,7 +17,7 @@ final class AppServices { let network: NetworkBundle let ui: UIFeedbackBundle let runtime: AppRuntimeBundle - let appRouter = AppRouter() + let appNavigator = AppNavigator() private let tokenStore: SessionTokenStore private let snapshotStore: AccountSnapshotStore @@ -151,7 +151,7 @@ final class AppServices { PushNotificationManager.shared.configure( api: pushAPI, session: appSession, - router: appRouter + navigator: appNavigator ) } diff --git a/suixinkan_ios/App/AppUITestRouteDriver.swift b/suixinkan_ios/App/AppUITestRouteDriver.swift index 913d965..3873189 100644 --- a/suixinkan_ios/App/AppUITestRouteDriver.swift +++ b/suixinkan_ios/App/AppUITestRouteDriver.swift @@ -39,19 +39,19 @@ enum AppUITestRouteDriver { return } - services.appRouter.reset() + services.appNavigator.resetAllStacks() let resolved = HomeMenuRouter.resolve(uri: item.uri, title: item.title) switch resolved { case .tab(let tab): - services.appRouter.select(tab) + services.appNavigator.selectTab(tab) case .orders(let entry): - services.appRouter.selectOrders(entry: entry) + services.appNavigator.openOrdersEntry(entry) case .destination(let homeRoute): - services.appRouter.navigateHome(homeRoute, animated: false) + services.appNavigator.openHome(homeRoute, policy: .tabRoot(.home), animated: false) case .unsupported(let uri, let title, _): - services.appRouter.navigateHome(.modulePlaceholder(uri: uri, title: title), animated: false) + services.appNavigator.openHome(.modulePlaceholder(uri: uri, title: title), policy: .tabRoot(.home), animated: false) case .placeholder(let uri, let title): - services.appRouter.navigateHome(.modulePlaceholder(uri: uri, title: title), animated: false) + services.appNavigator.openHome(.modulePlaceholder(uri: uri, title: title), policy: .tabRoot(.home), animated: false) } } @@ -78,8 +78,8 @@ enum AppUITestRouteDriver { } guard let route else { return } - services.appRouter.reset() - services.appRouter.navigateProfile(route, animated: false) + services.appNavigator.resetAllStacks() + services.appNavigator.openProfile(route, policy: .tabRoot(.profile), animated: false) } /// 将 UI Test 用例菜单名映射为调试目录标题。 diff --git a/suixinkan_ios/App/Navigation/AppRouteViewControllerFactory.swift b/suixinkan_ios/App/Navigation/AppRouteViewControllerFactory.swift index 3c91fd6..3bf5645 100644 --- a/suixinkan_ios/App/Navigation/AppRouteViewControllerFactory.swift +++ b/suixinkan_ios/App/Navigation/AppRouteViewControllerFactory.swift @@ -172,12 +172,3 @@ enum AppRouteViewControllerFactory { return String(describing: route) } } - -@MainActor -/// 首页路由 ViewController 工厂,供 `UIKitAppNavigation` 复用。 -enum HomeRouteViewControllerFactory { - /// 创建实例。 - static func make(for route: HomeRoute) -> UIViewController { - AppRouteViewControllerFactory.makeViewController(for: route, services: AppServices.shared) - } -} diff --git a/suixinkan_ios/App/Navigation/NavigationRouter.swift b/suixinkan_ios/App/Navigation/NavigationRouter.swift index e4aa5ee..0df692d 100644 --- a/suixinkan_ios/App/Navigation/NavigationRouter.swift +++ b/suixinkan_ios/App/Navigation/NavigationRouter.swift @@ -6,6 +6,7 @@ // import Foundation +import UIKit /// 应用内可导航目标,承载各 Tab 内部的路由目的地。 enum AppRoute: Hashable { @@ -21,90 +22,140 @@ enum AppRoute: Hashable { } @MainActor -/// 单个导航栈的路径容器,用于保存某个 Tab 的导航历史。 -final class RouterPath { - var onChange: (() -> Void)? - var path: [AppRoute] = [] { didSet { onChange?() } } - - /// 将指定路由压入当前 Tab 的导航栈。 - func navigate(to route: AppRoute) { - path.append(route) - } - - /// 从当前 Tab 的导航栈弹出最后一级路由。 - func navigateBack() { - guard !path.isEmpty else { return } - path.removeLast() - } - - /// 清空当前 Tab 的导航栈。 - func reset() { - path = [] - } +/// UIKit 路由执行策略,描述全局入口应如何选择导航栈。 +enum AppNavigationPolicy: Equatable { + case currentStack + case tabRoot(AppTab) + case preserveTab(AppTab) } @MainActor -/// 主导航状态中心,管理当前 Tab 和每个 Tab 独立的导航路径。 -final class AppRouter { - var onChange: (() -> Void)? - var selectedTab: AppTab = .home { didSet { onChange?() } } - var selectedOrdersEntry: OrdersEntry = .storeOrders { didSet { onChange?() } } - private(set) var pendingOrderScanCode: String? { didSet { onChange?() } } +/// 主 Tab 导航宿主协议,由 `MainTabBarController` 提供 UIKit 栈能力。 +protocol AppNavigationHost: AnyObject { + /// 选中指定主 Tab。 + func selectTab(_ tab: AppTab) - private let routers: [AppTab: RouterPath] + /// 返回指定 Tab 的导航控制器。 + func navigationController(for tab: AppTab) -> TabNavigationController? - /// 初始化实例。 - init() { - var builtRouters: [AppTab: RouterPath] = [:] - for tab in AppTab.allCases { - builtRouters[tab] = RouterPath() + /// 返回当前选中 Tab 的导航控制器。 + func selectedNavigationController() -> TabNavigationController? + + /// 重置全部 Tab 的导航栈。 + func resetAllStacks() + + /// 将指定订单入口应用到订单根页面。 + func applyOrdersEntry(_ entry: OrdersEntry, scannedCode: String?) +} + +@MainActor +/// UIKit 主导航器,以 `UINavigationController.viewControllers` 作为唯一导航事实源。 +final class AppNavigator { + private weak var host: AppNavigationHost? + private var pendingActions: [(@MainActor (AppNavigationHost) -> Void)] = [] + + /// 绑定主 Tab 宿主,并按 FIFO 执行挂载前暂存的全局导航请求。 + func attach(host: AppNavigationHost) { + self.host = host + let actions = pendingActions + pendingActions.removeAll() + actions.forEach { $0(host) } + } + + /// 解绑主 Tab 宿主并清理尚未执行的全局导航请求。 + func detach(host: AppNavigationHost? = nil) { + if let host, self.host !== host { + return } - routers = builtRouters - routers.values.forEach { router in - router.onChange = { [weak self] in - self?.onChange?() + self.host = nil + pendingActions.removeAll() + } + + /// 切换到指定主 Tab,保留该 Tab 已有导航栈。 + func selectTab(_ tab: AppTab) { + performOrEnqueue { host in + host.selectTab(tab) + } + } + + /// 在当前或指定来源的导航栈中 push 应用路由。 + func push(_ route: AppRoute, from source: UIViewController?, animated: Bool = true) { + if let navigationController = source?.navigationController as? TabNavigationController { + navigationController.push(route: route, animated: animated) + return + } + + performOrEnqueue { host in + guard let navigationController = source?.navigationController as? TabNavigationController + ?? host.selectedNavigationController() + else { return } + navigationController.push(route: route, animated: animated) + } + } + + /// 打开首页路由。 + func openHome(_ route: HomeRoute, policy: AppNavigationPolicy = .currentStack, animated: Bool = true) { + open(.home(route), defaultTab: .home, policy: policy, animated: animated) + } + + /// 打开个人中心路由。 + func openProfile(_ route: ProfileRoute, policy: AppNavigationPolicy = .currentStack, animated: Bool = true) { + open(.profile(route), defaultTab: .profile, policy: policy, animated: animated) + } + + /// 切换到订单 Tab 并应用订单内部入口,可携带全局扫码结果。 + func openOrdersEntry(_ entry: OrdersEntry, scannedCode: String? = nil) { + performOrEnqueue { host in + host.selectTab(.orders) + host.navigationController(for: .orders)?.popToRootViewController(animated: false) + host.applyOrdersEntry(entry, scannedCode: scannedCode) + } + } + + /// 重置全部 Tab 导航栈并清空暂存请求。 + func resetAllStacks() { + pendingActions.removeAll() + host?.resetAllStacks() + } + + /// 执行通用路由打开逻辑。 + private func open( + _ route: AppRoute, + defaultTab: AppTab, + policy: AppNavigationPolicy, + animated: Bool + ) { + performOrEnqueue { host in + let targetTab: AppTab + switch policy { + case .currentStack: + guard let navigationController = host.selectedNavigationController() else { return } + navigationController.push(route: route, animated: animated) + return + case .tabRoot(let tab): + targetTab = tab + host.selectTab(tab) + guard let navigationController = host.navigationController(for: tab) else { return } + navigationController.popToRootViewController(animated: false) + navigationController.push(route: route, animated: animated) + case .preserveTab(let tab): + targetTab = tab + host.selectTab(tab) + host.navigationController(for: tab)?.push(route: route, animated: animated) + } + + if targetTab != defaultTab { + assertionFailure("Route \(route) opened on unexpected tab \(targetTab)") } } } - /// 获取指定 Tab 对应的路由路径容器。 - func router(for tab: AppTab) -> RouterPath { - guard let router = routers[tab] else { - assertionFailure("Missing router path for tab: \(tab)") - return RouterPath() + /// 主 Tab 已挂载时立即执行,否则暂存至登录后执行。 + private func performOrEnqueue(_ action: @escaping @MainActor (AppNavigationHost) -> Void) { + if let host { + action(host) + } else { + pendingActions.append(action) } - return router - } - - /// 切换到指定主 Tab。 - func select(_ tab: AppTab) { - selectedTab = tab - } - - /// 切换到订单 Tab,并指定订单内部子入口。 - func selectOrders(entry: OrdersEntry) { - selectedOrdersEntry = entry - selectedTab = .orders - } - - /// 切换到核销订单入口,并暂存全局扫码结果等待订单页消费。 - func routeToOrderVerification(scannedCode: String) { - pendingOrderScanCode = scannedCode - selectOrders(entry: .verificationOrders) - } - - /// 读取并清空待处理的全局扫码结果,确保同一次扫码只被处理一次。 - func consumePendingOrderScanCode() -> String? { - let code = pendingOrderScanCode - pendingOrderScanCode = nil - return code - } - - /// 重置主 Tab 选择和所有 Tab 的导航历史。 - func reset() { - selectedTab = .home - selectedOrdersEntry = .storeOrders - pendingOrderScanCode = nil - routers.values.forEach { $0.reset() } } } diff --git a/suixinkan_ios/App/Navigation/TabNavigationController.swift b/suixinkan_ios/App/Navigation/TabNavigationController.swift index 35ca5c0..96772e6 100644 --- a/suixinkan_ios/App/Navigation/TabNavigationController.swift +++ b/suixinkan_ios/App/Navigation/TabNavigationController.swift @@ -6,25 +6,19 @@ import UIKit @MainActor -/// 单个 Tab 的导航栈,与 `AppRouter` 路径同步并在 push 时隐藏底部 TabBar。 -final class TabNavigationController: UINavigationController, UINavigationControllerDelegate { +/// 单个 Tab 的 UIKit 导航栈,负责承载该 Tab 内部页面层级。 +final class TabNavigationController: UINavigationController { let appTab: AppTab private let services: AppServices - private var isSyncingStack = false /// 初始化实例。 init(tab: AppTab, services: AppServices) { self.appTab = tab self.services = services super.init(nibName: nil, bundle: nil) - delegate = self navigationBar.prefersLargeTitles = false setViewControllers([tab.makeRootViewController(services: services)], animated: false) - registerNavigationBridge() - services.appRouter.router(for: appTab).onChange = { [weak self] in - self?.syncFromRouterPath() - } } @available(*, unavailable) @@ -32,76 +26,10 @@ final class TabNavigationController: UINavigationController, UINavigationControl fatalError("init(coder:) has not been implemented") } - /// Push 指定应用路由,并同步写入 RouterPath。 - func pushRoute(_ route: AppRoute, animated: Bool = true) { + /// Push 指定应用路由,并统一设置底部 TabBar 隐藏策略。 + func push(route: AppRoute, animated: Bool = true) { let viewController = AppRouteViewControllerFactory.makeViewController(for: route, services: services) viewController.hidesBottomBarWhenPushed = route.hidesTabBarWhenPushed - isSyncingStack = true - services.appRouter.router(for: appTab).navigate(to: route) - isSyncingStack = false pushViewController(viewController, animated: animated) } - - /// navigationController相关逻辑。 - func navigationController( - _ navigationController: UINavigationController, - didShow viewController: UIViewController, - animated: Bool - ) { - syncRouterPathFromStack() - } - - /// 注册NavigationBridge。 - private func registerNavigationBridge() { - switch appTab { - case .home: - UIKitAppNavigation.homeNavigationController = self - case .profile: - UIKitAppNavigation.profileNavigationController = self - default: - break - } - } - - /// 同步FromRouterPath状态。 - private func syncFromRouterPath() { - guard !isSyncingStack else { return } - - let routerPath = services.appRouter.router(for: appTab) - let targetDepth = routerPath.path.count - let currentDepth = max(viewControllers.count - 1, 0) - - if targetDepth > currentDepth { - // Router 路径变深:按路径依次 push 缺失页面 - for index in currentDepth.. [String: Any] { + [ + CYLTabBarItemTitle: tab.title, + CYLTabBarItemImage: tab.unselectedImageName, + CYLTabBarItemSelectedImage: tab.selectedImageName + ] + } + + /// CYL 会根据属性字典重建 TabBarItem,因此在设置控制器后补充自动化标识。 + private func applyTabAccessibilityIdentifiers() { + for tab in AppTab.allCases { + tabNavigationControllers[tab]?.tabBarItem.accessibilityIdentifier = "main.tab.\(tab.rawValue)" } } @@ -78,24 +119,6 @@ final class MainTabBarController: UITabBarController, UITabBarControllerDelegate } } - /// 处理 Router 变更,保持选中 Tab 与 `AppRouter` 一致。 - private func handleRouterChange() { - syncSelectedTabFromRouter(animated: false) - if services.appRouter.selectedTab == .orders { - Task { await refreshOrderBadge() } - } - } - - /// 将 `AppRouter.selectedTab` 同步到系统 TabBar 选中项。 - private func syncSelectedTabFromRouter(animated: Bool) { - guard let index = AppTab.allCases.firstIndex(of: services.appRouter.selectedTab) else { return } - guard selectedIndex != index else { return } - - isSyncingTabSelection = true - selectedIndex = index - isSyncingTabSelection = false - } - /// 更新订单 Tab 待核销角标。 private func updateOrderBadge() { let badgeValue: String? @@ -116,14 +139,71 @@ final class MainTabBarController: UITabBarController, UITabBarControllerDelegate ) } - /// 用户点击 TabBar 时同步选中状态到 `AppRouter`。 - func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) { + /// 从 TabBar 中间按钮打开全局扫码页,成功后路由至订单核销入口。 + private func presentGlobalScanner() { + let scanner = OrderCodeScannerViewController() + scanner.onScanResult = { [weak self, weak scanner] result in + scanner?.dismiss(animated: true) + guard let self else { return } + switch result { + case .success(let code): + self.services.appNavigator.openOrdersEntry(.verificationOrders, scannedCode: code) + case .failure(let error): + self.services.toastCenter.show(error.localizedDescription) + } + } + let navigationController = UINavigationController(rootViewController: scanner) + navigationController.modalPresentationStyle = .fullScreen + present(navigationController, animated: true) + } + + /// 用户点击 TabBar 时刷新订单角标。 + override func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) { guard !isSyncingTabSelection, let navigationController = viewController as? TabNavigationController else { return } - services.appRouter.select(navigationController.appTab) if navigationController.appTab == .orders { Task { await refreshOrderBadge() } } } } + +extension MainTabBarController: AppNavigationHost { + /// 选中指定主 Tab。 + func selectTab(_ tab: AppTab) { + guard let index = AppTab.allCases.firstIndex(of: tab) else { return } + guard selectedIndex != index else { return } + isSyncingTabSelection = true + selectedIndex = index + isSyncingTabSelection = false + if tab == .orders { + Task { await refreshOrderBadge() } + } + } + + /// 返回指定 Tab 的导航控制器。 + func navigationController(for tab: AppTab) -> TabNavigationController? { + tabNavigationControllers[tab] + } + + /// 返回当前选中 Tab 的导航控制器。 + func selectedNavigationController() -> TabNavigationController? { + selectedViewController as? TabNavigationController + } + + /// 重置全部 Tab 的导航栈。 + func resetAllStacks() { + for navigationController in tabNavigationControllers.values { + navigationController.popToRootViewController(animated: false) + } + selectTab(.home) + } + + /// 将订单入口和可选扫码结果应用到订单根页面。 + func applyOrdersEntry(_ entry: OrdersEntry, scannedCode: String?) { + guard let ordersController = tabNavigationControllers[.orders]?.viewControllers.first as? OrdersViewController else { + return + } + ordersController.applyOrdersEntry(entry, scannedCode: scannedCode) + } +} diff --git a/suixinkan_ios/Features/Main/Views/MainScanPlusButton.swift b/suixinkan_ios/Features/Main/Views/MainScanPlusButton.swift new file mode 100644 index 0000000..9e1ffbb --- /dev/null +++ b/suixinkan_ios/Features/Main/Views/MainScanPlusButton.swift @@ -0,0 +1,77 @@ +// +// MainScanPlusButton.swift +// suixinkan +// + +import CYLTabBarController +import UIKit + +@MainActor +@objcMembers +/// 主 TabBar 中间扫码核销按钮,基于 CYLTabBarController 的 PlusButton 机制实现。 +final class MainScanPlusButton: CYLPlusButton, CYLPlusButtonSubclassing { + + /// 点击扫码按钮时的回调,由 `MainTabBarController` 注入。 + static var onScanTap: (() -> Void)? + + /// 构建中间扫码按钮实例,供 CYLTabBarController 注册使用。 + static func plusButton() -> Any { + let button = MainScanPlusButton(type: .custom) + button.frame = CGRect(x: 0, y: 0, width: 70, height: 64) + button.configureAppearance() + button.addTarget(button, action: #selector(handleScanTap), for: .touchUpInside) + return button + } + + /// 指定扫码按钮在 TabBar 中的视觉槽位(位于订单与数据之间)。 + static func indexOfPlusButtonInTabBar() -> UInt { + 2 + } + + /// 调整扫码按钮垂直位置,使其略微凸出 TabBar 顶部。 + static func constantOfPlusButtonCenterYOffset(forTabBarHeight tabBarHeight: CGFloat) -> CGFloat { + -6 + } + + /// 配置圆形背景与扫码图标,对齐参考工程 CustomMainTabBarCenterAction。 + private func configureAppearance() { + backgroundColor = .clear + + let circleSize: CGFloat = 58 + let circleView = UIView() + circleView.isUserInteractionEnabled = false + circleView.backgroundColor = AppDesign.primary + circleView.layer.cornerRadius = circleSize / 2 + addSubview(circleView) + + let symbolConfig = UIImage.SymbolConfiguration(pointSize: 30, weight: .bold) + let iconView = UIImageView( + image: UIImage(systemName: "qrcode.viewfinder", withConfiguration: symbolConfig) + ) + iconView.tintColor = .white + iconView.contentMode = .scaleAspectFit + iconView.isUserInteractionEnabled = false + addSubview(iconView) + + circleView.translatesAutoresizingMaskIntoConstraints = false + iconView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + circleView.centerXAnchor.constraint(equalTo: centerXAnchor), + circleView.centerYAnchor.constraint(equalTo: centerYAnchor), + circleView.widthAnchor.constraint(equalToConstant: circleSize), + circleView.heightAnchor.constraint(equalToConstant: circleSize), + iconView.centerXAnchor.constraint(equalTo: circleView.centerXAnchor), + iconView.centerYAnchor.constraint(equalTo: circleView.centerYAnchor), + iconView.widthAnchor.constraint(equalToConstant: 30), + iconView.heightAnchor.constraint(equalToConstant: 30) + ]) + + accessibilityLabel = "扫码核销" + accessibilityIdentifier = "main.scan" + } + + /// 响应用户点击,触发全局扫码流程。 + @objc private func handleScanTap() { + Self.onScanTap?() + } +} diff --git a/suixinkan_ios/Features/Orders/Orders.md b/suixinkan_ios/Features/Orders/Orders.md index 63c70bf..1dd56a1 100644 --- a/suixinkan_ios/Features/Orders/Orders.md +++ b/suixinkan_ios/Features/Orders/Orders.md @@ -8,7 +8,7 @@ Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理、核销 ## 核心对象 -- `OrdersView`:订单 Tab 根视图,读取 `AccountContext`、`PermissionContext`、`AppRouter` 和 `OrdersAPI`。 +- `OrdersView`:订单 Tab 根视图,读取 `AccountContext`、`PermissionContext` 和 `OrdersAPI`,并接收 `AppNavigator` 传入的订单入口与扫码结果。 - `OrdersViewModel`:管理订单子入口、筛选条件、分页状态、加载状态和手动核销状态。 - `OrderDetailViewModel`:管理订单详情接口加载、错误提示和列表摘要兜底展示。 - `DepositOrderListViewModel`:管理押金订单分页、核销、退款和操作后刷新。 @@ -44,6 +44,6 @@ Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理、核销 ## 路由边界 -首页 `photographer_orders` 和 `/scenic-order-manage` 会进入订单管理,`verification_order` 会进入核销订单,`deposit_order_detail`、`deposit_order`、`deposit_order_shooting_info` 会进入押金订单页。订单详情、核销订单详情、押金详情、押金拍摄信息、历史拍摄、任务上传和订单尾片已接入 `AppRoute.orders` 真实路由,push 后继续隐藏 TabBar。 +首页 `photographer_orders` 和 `/scenic-order-manage` 会进入订单管理,`verification_order` 会进入核销订单,`deposit_order_detail`、`deposit_order`、`deposit_order_shooting_info` 会进入押金订单页。全局扫码和推送通知通过 `AppNavigator.openOrdersEntry` 切到订单 Tab root,再由 `OrdersViewController` 应用入口状态;订单卡片、核销卡片、押金详情、押金拍摄信息、历史拍摄、任务上传和订单尾片已接入 `AppRoute.orders` 真实路由,push 后继续隐藏 TabBar。 未明确接口的订单后续能力继续进入占位页,避免入口崩溃。 diff --git a/suixinkan_ios/Features/Orders/ViewControllers/DepositOrderViewControllers.swift b/suixinkan_ios/Features/Orders/ViewControllers/DepositOrderViewControllers.swift index 92ebbe6..c2e0da9 100644 --- a/suixinkan_ios/Features/Orders/ViewControllers/DepositOrderViewControllers.swift +++ b/suixinkan_ios/Features/Orders/ViewControllers/DepositOrderViewControllers.swift @@ -96,7 +96,7 @@ final class DepositOrderListViewController: UIViewController, UITableViewDelegat let cell = tableView.dequeueReusableCell(withIdentifier: DepositOrderCell.reuseID, for: indexPath) as! DepositOrderCell cell.configure(item: orderItem, isOperating: self.viewModel.operatingOrderNumber == orderItem.orderNumber) cell.onDetail = { [weak self] in - self.flatMap { HomeMenuRouting.pushOrders(.depositDetail(orderNumber: orderItem.orderNumber), from: $0) } + self.flatMap { $0.appServices.appNavigator.push(.orders(.depositDetail(orderNumber: orderItem.orderNumber)), from: $0) } } cell.onWriteOff = { [weak self] in self?.writeOff(orderItem) } cell.onRefund = { [weak self] in self?.refund(orderItem) } @@ -154,7 +154,7 @@ final class DepositOrderListViewController: UIViewController, UITableViewDelegat @objc private func openDetail() { let text = orderNumberField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !text.isEmpty else { return } - HomeMenuRouting.pushOrders(.depositDetail(orderNumber: text), from: self) + appServices.appNavigator.push(.orders(.depositDetail(orderNumber: text)), from: self) } /// 刷新。 diff --git a/suixinkan_ios/Features/Orders/ViewControllers/OrderDetailViewControllers.swift b/suixinkan_ios/Features/Orders/ViewControllers/OrderDetailViewControllers.swift index c18c3ce..a85d61e 100644 --- a/suixinkan_ios/Features/Orders/ViewControllers/OrderDetailViewControllers.swift +++ b/suixinkan_ios/Features/Orders/ViewControllers/OrderDetailViewControllers.swift @@ -289,13 +289,13 @@ final class StoreOrderDetailViewController: UIViewController, UITableViewDelegat let orderNumber = viewModel.display.orderNumber switch index { case 0: - HomeMenuRouting.pushOrders(.historicalShooting(orderNumber: orderNumber), from: self) + appServices.appNavigator.push(.orders(.historicalShooting(orderNumber: orderNumber)), from: self) case 1 where item.orderType == 19: - HomeMenuRouting.pushOrders(.multiTravelTaskUpload(orderNumber: orderNumber), from: self) + appServices.appNavigator.push(.orders(.multiTravelTaskUpload(orderNumber: orderNumber)), from: self) case 2: - HomeMenuRouting.pushOrders(.orderTrailer(orderNumber: orderNumber, title: "视频预告"), from: self) + appServices.appNavigator.push(.orders(.orderTrailer(orderNumber: orderNumber, title: "视频预告")), from: self) case 3: - HomeMenuRouting.pushOrders(.orderTrailer(orderNumber: orderNumber, title: "尾片上传"), from: self) + appServices.appNavigator.push(.orders(.orderTrailer(orderNumber: orderNumber, title: "尾片上传")), from: self) case 4: presentRefund() default: diff --git a/suixinkan_ios/Features/Orders/ViewControllers/OrdersViewController.swift b/suixinkan_ios/Features/Orders/ViewControllers/OrdersViewController.swift index b7de248..d484513 100644 --- a/suixinkan_ios/Features/Orders/ViewControllers/OrdersViewController.swift +++ b/suixinkan_ios/Features/Orders/ViewControllers/OrdersViewController.swift @@ -37,6 +37,7 @@ final class OrdersViewController: UIViewController { private let viewModel = OrdersViewModel() private var manualOrderNumber = "" private var scanHintMessage: String? + private var pendingScanCode: String? private lazy var collectionView: UICollectionView = { let layout = makeCollectionLayout() @@ -77,25 +78,34 @@ final class OrdersViewController: UIViewController { self?.applySnapshot() } - appServices.appRouter.onChange = { [weak self] in - guard let self else { return } - if self.viewModel.selectedEntry != self.appServices.appRouter.selectedOrdersEntry { - self.viewModel.selectedEntry = self.appServices.appRouter.selectedOrdersEntry - Task { await self.reload(showLoading: true) } - } - Task { await self.consumePendingScanCodeIfNeeded() } - } appServices.accountContext.onChange = { [weak self] in Task { await self?.reload(showLoading: true) } } - viewModel.selectedEntry = appServices.appRouter.selectedOrdersEntry Task { await reload(showLoading: true) await consumePendingScanCodeIfNeeded() } } + /// 应用来自首页、推送或全局扫码入口的订单子入口状态。 + func applyOrdersEntry(_ entry: OrdersEntry, scannedCode: String?) { + let didChangeEntry = viewModel.selectedEntry != entry + viewModel.selectedEntry = entry + if let scannedCode { + pendingScanCode = scannedCode + } + + guard isViewLoaded else { return } + + Task { + if didChangeEntry { + await reload(showLoading: true) + } + await consumePendingScanCodeIfNeeded() + } + } + /// 构建 Compositional Layout,按 section 类型选用全宽布局。 private func makeCollectionLayout() -> UICollectionViewCompositionalLayout { UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in @@ -345,7 +355,6 @@ final class OrdersViewController: UIViewController { private func switchEntry(_ entry: OrdersEntry) { guard viewModel.selectedEntry != entry else { return } viewModel.selectedEntry = entry - appServices.appRouter.selectedOrdersEntry = entry Task { await reload(showLoading: true) } } @@ -370,7 +379,8 @@ final class OrdersViewController: UIViewController { /// 消费路由中暂存的扫码结果(核销入口)。 private func consumePendingScanCodeIfNeeded() async { guard viewModel.selectedEntry == .verificationOrders, - let code = appServices.appRouter.consumePendingOrderScanCode() else { return } + let code = pendingScanCode else { return } + pendingScanCode = nil handleScanResult(code) } @@ -459,11 +469,11 @@ extension OrdersViewController: UICollectionViewDelegate { if item.hasPrefix("orders:store:") { let orderNumber = String(item.dropFirst("orders:store:".count)) guard let order = viewModel.storeOrders.first(where: { $0.orderNumber == orderNumber }) else { return } - HomeMenuRouting.pushOrders(.storeDetail(order), from: self) + appServices.appNavigator.push(.orders(.storeDetail(order)), from: self) } else if item.hasPrefix("orders:writeoff:") { let orderNumber = String(item.dropFirst("orders:writeoff:".count)) guard let order = viewModel.writeOffOrders.first(where: { $0.orderNumber == orderNumber }) else { return } - HomeMenuRouting.pushOrders(.writeOffDetail(order), from: self) + appServices.appNavigator.push(.orders(.writeOffDetail(order)), from: self) } } diff --git a/suixinkan_ios/Features/Profile/Profile.md b/suixinkan_ios/Features/Profile/Profile.md index f26b287..829cf69 100644 --- a/suixinkan_ios/Features/Profile/Profile.md +++ b/suixinkan_ios/Features/Profile/Profile.md @@ -76,7 +76,7 @@ DEBUG 构建下,“我的”列表会额外展示“首页调试”入口, 3. 页面展示景区账号和门店账号,优先选中后端标记的当前账号,否则选中第一项。 4. 用户确认后调用 `AuthAPI.setUser`,使用 `AccountSwitchAccount.toSetUserRequest()` 生成请求体。 5. 切换成功后调用 `AuthSessionCoordinator.completeLogin`,重新写入正式 token、账号快照、权限、景区和门店上下文。 -6. `AppRouter.reset()` 清空旧路由栈并回到首页。 +6. `AppNavigator.resetAllStacks()` 清空旧 UIKit 导航栈并回到首页。 ## 实名认证流程 @@ -105,7 +105,7 @@ DEBUG 构建下,“我的”列表会额外展示“首页调试”入口, 1. 用户点击退出登录。 2. `ProfileView` 展示确认弹窗。 3. 用户确认后调用 `AuthSessionCoordinator.logout`。 -4. 协调器清空正式 token、账号快照、账号上下文、路由栈和 Toast。 +4. 协调器清空正式 token、账号快照、账号上下文、全部 UIKit 导航栈和 Toast。 5. `AppSession` 切换为 `loggedOut`,根视图回到登录页。 6. 上次手机号和协议状态等非敏感偏好保留。 diff --git a/suixinkan_ios/Features/Profile/ViewControllers/AccountSwitchViewController.swift b/suixinkan_ios/Features/Profile/ViewControllers/AccountSwitchViewController.swift index 64c9e0d..47c2580 100644 --- a/suixinkan_ios/Features/Profile/ViewControllers/AccountSwitchViewController.swift +++ b/suixinkan_ios/Features/Profile/ViewControllers/AccountSwitchViewController.swift @@ -187,7 +187,7 @@ final class AccountSwitchViewController: UIViewController, UITableViewDelegate { profileAPI: appServices.profileAPI, accountContextAPI: appServices.accountContextAPI ) - appServices.appRouter.reset() + appServices.appNavigator.resetAllStacks() showToast("账号已切换") navigationController?.popToRootViewController(animated: true) } catch { diff --git a/suixinkan_ios/Features/Profile/ViewControllers/ProfileViewController.swift b/suixinkan_ios/Features/Profile/ViewControllers/ProfileViewController.swift index aca90d2..61c5306 100644 --- a/suixinkan_ios/Features/Profile/ViewControllers/ProfileViewController.swift +++ b/suixinkan_ios/Features/Profile/ViewControllers/ProfileViewController.swift @@ -28,6 +28,24 @@ private enum ProfileRowID { static let logout = "profile:logout" } +/// 个人信息页 value1 样式 Cell,供 Diffable reconfigure 正确复用。 +private final class ProfileValueTableViewCell: UITableViewCell { + /// 初始化 value1 样式 Cell。 + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: .value1, reuseIdentifier: reuseIdentifier) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private enum ProfileCellReuseID { + static let value = "profile.value" + static let logout = "profile.logout" +} + /// 个人信息页,展示用户资料、账号状态和设置入口。 final class ProfileViewController: UIViewController, UITableViewDelegate { @@ -76,43 +94,57 @@ final class ProfileViewController: UIViewController, UITableViewDelegate { /// 配置 Diffable 数据源。 private func configureTableDataSource() { + tableView.register(ProfileValueTableViewCell.self, forCellReuseIdentifier: ProfileCellReuseID.value) + tableView.register(UITableViewCell.self, forCellReuseIdentifier: ProfileCellReuseID.logout) + tableDataSource = UITableViewDiffableDataSource( tableView: tableView - ) { [weak self] (_: UITableView, indexPath: IndexPath, row: ProfileTableRow) -> UITableViewCell? in + ) { [weak self] (tableView: UITableView, indexPath: IndexPath, row: ProfileTableRow) -> UITableViewCell? in guard let self else { return UITableViewCell() } - let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) switch row { case ProfileRowID.logout: + let cell = tableView.dequeueReusableCell(withIdentifier: ProfileCellReuseID.logout, for: indexPath) cell.textLabel?.text = "退出登录" cell.textLabel?.textColor = UIColor(hex: 0xEF4444) cell.textLabel?.textAlignment = .center cell.selectionStyle = .default - case ProfileRowID.name: - cell.textLabel?.text = "姓名" - cell.detailTextLabel?.text = self.viewModel.displayRealName - cell.selectionStyle = .none - case ProfileRowID.account: - cell.textLabel?.text = "当前账号" - cell.detailTextLabel?.text = self.appServices.accountContext.profile?.displayName ?? "--" - cell.accessoryType = .disclosureIndicator - cell.selectionStyle = .default - case ProfileRowID.phone: - cell.textLabel?.text = "手机号" - cell.detailTextLabel?.text = self.viewModel.displayPhone - cell.selectionStyle = .none - case ProfileRowID.realName: - cell.textLabel?.text = "实名认证" - cell.detailTextLabel?.text = self.viewModel.realNameStatusText - cell.accessoryType = .disclosureIndicator - cell.selectionStyle = .default - case ProfileRowID.settings: - cell.textLabel?.text = "设置" - cell.accessoryType = .disclosureIndicator - cell.selectionStyle = .default + cell.accessoryType = .none + return cell default: - break + let cell = tableView.dequeueReusableCell( + withIdentifier: ProfileCellReuseID.value, + for: indexPath + ) + cell.textLabel?.textAlignment = .natural + cell.textLabel?.textColor = .label + cell.accessoryType = .none + cell.selectionStyle = .none + switch row { + case ProfileRowID.name: + cell.textLabel?.text = "姓名" + cell.detailTextLabel?.text = self.viewModel.displayRealName + case ProfileRowID.account: + cell.textLabel?.text = "当前账号" + cell.detailTextLabel?.text = self.appServices.accountContext.profile?.displayName ?? "--" + cell.accessoryType = .disclosureIndicator + cell.selectionStyle = .default + case ProfileRowID.phone: + cell.textLabel?.text = "手机号" + cell.detailTextLabel?.text = self.viewModel.displayPhone + case ProfileRowID.realName: + cell.textLabel?.text = "实名认证" + cell.detailTextLabel?.text = self.viewModel.realNameStatusText + cell.accessoryType = .disclosureIndicator + cell.selectionStyle = .default + case ProfileRowID.settings: + cell.textLabel?.text = "设置" + cell.accessoryType = .disclosureIndicator + cell.selectionStyle = .default + default: + break + } + return cell } - return cell } applyTableSnapshot(animated: false) } @@ -296,7 +328,7 @@ final class ProfileViewController: UIViewController, UITableViewDelegate { accountContext: self.appServices.accountContext, permissionContext: self.appServices.permissionContext, scenicSpotContext: self.appServices.scenicSpotContext, - appRouter: self.appServices.appRouter, + appNavigator: self.appServices.appNavigator, toastCenter: self.appServices.toastCenter ) }) @@ -311,11 +343,11 @@ final class ProfileViewController: UIViewController, UITableViewDelegate { case ProfileRowID.logout: logoutTapped() case ProfileRowID.account: - HomeMenuRouting.pushProfile(.accountSwitch, from: self) + appServices.appNavigator.push(.profile(.accountSwitch), from: self) case ProfileRowID.realName: - HomeMenuRouting.pushProfile(.realNameAuth, from: self) + appServices.appNavigator.push(.profile(.realNameAuth), from: self) case ProfileRowID.settings: - HomeMenuRouting.pushProfile(.settings, from: self) + appServices.appNavigator.push(.profile(.settings), from: self) default: break } diff --git a/suixinkan_ios/Features/Profile/ViewControllers/SettingsViewControllers.swift b/suixinkan_ios/Features/Profile/ViewControllers/SettingsViewControllers.swift index 4074096..6406bbb 100644 --- a/suixinkan_ios/Features/Profile/ViewControllers/SettingsViewControllers.swift +++ b/suixinkan_ios/Features/Profile/ViewControllers/SettingsViewControllers.swift @@ -98,7 +98,7 @@ final class SettingsViewController: SimpleTableDiffableViewController { guard let action = rowActions[row] else { return } switch action { case .agreement(let page): - HomeMenuRouting.pushProfile(.agreement(page), from: self) + appServices.appNavigator.push(.profile(.agreement(page)), from: self) case .download: copyDownloadLink() case .version: diff --git a/suixinkan_ios/SceneDelegate.swift b/suixinkan_ios/SceneDelegate.swift index 161ecf4..f3edfe3 100644 --- a/suixinkan_ios/SceneDelegate.swift +++ b/suixinkan_ios/SceneDelegate.swift @@ -7,11 +7,14 @@ import UIKit -/// 窗口 Scene 代理,负责创建根窗口并挂载 `RootViewController`。 +/// 窗口 Scene 代理,负责创建根窗口并挂载会话协调器。 class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? + /// 持有会话协调器,确保登录后切换 window 根控制器时仍能响应登出等阶段变化。 + private var sessionRootController: RootViewController? + /// Scene 连接时初始化窗口、重置 UI 测试状态并展示根控制器。 func scene( _ scene: UIScene, @@ -22,8 +25,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { AppUITestLaunchState.resetIfNeeded() + let rootViewController = RootViewController() + sessionRootController = rootViewController + let window = UIWindow(windowScene: windowScene) - window.rootViewController = RootViewController() + window.rootViewController = rootViewController window.makeKeyAndVisible() self.window = window } diff --git a/suixinkan_iosTests/MainScanPlusButtonTests.swift b/suixinkan_iosTests/MainScanPlusButtonTests.swift new file mode 100644 index 0000000..4db61dc --- /dev/null +++ b/suixinkan_iosTests/MainScanPlusButtonTests.swift @@ -0,0 +1,58 @@ +// +// MainScanPlusButtonTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan_ios + +/// 主 TabBar 中间扫码按钮测试,覆盖 CYL PlusButton 配置。 +@MainActor +final class MainScanPlusButtonTests: XCTestCase { + + /// 测试扫码按钮位于订单与数据 Tab 之间的视觉槽位。 + func testIndexOfPlusButtonInTabBarIsBetweenOrdersAndStatistics() { + XCTAssertEqual(MainScanPlusButton.indexOfPlusButtonInTabBar(), 2) + } + + /// 测试扫码按钮 accessibility 标识与参考工程一致。 + func testPlusButtonAccessibilityIdentifier() { + guard let button = MainScanPlusButton.plusButton() as? UIButton else { + XCTFail("plusButton 应返回 UIButton 实例") + return + } + + XCTAssertEqual(button.accessibilityIdentifier, "main.scan") + XCTAssertEqual(button.accessibilityLabel, "扫码核销") + } + + /// 测试主 Tab 容器加载时,CYL 控制器数组与 Tab 属性数组保持一致。 + func testMainTabBarControllerLoadsWithMatchingCYLAttributes() { + let services = AppServices(apiClient: APIClient(session: MainTabRecordingURLSession())) + let controller = MainTabBarController(services: services) + + controller.loadViewIfNeeded() + + for tab in AppTab.allCases { + XCTAssertNotNil(controller.navigationController(for: tab)) + XCTAssertEqual( + controller.navigationController(for: tab)?.tabBarItem.accessibilityIdentifier, + "main.tab.\(tab.rawValue)" + ) + } + } +} + +/// 主 Tab 测试使用的空网络会话,确保容器加载不触发真实请求。 +private final class MainTabRecordingURLSession: URLSessionProtocol { + /// 返回空成功响应。 + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + let response = HTTPURLResponse( + url: request.url ?? URL(string: "https://example.com")!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + return (Data(), response) + } +} diff --git a/suixinkan_iosTests/NavigationRouterTests.swift b/suixinkan_iosTests/NavigationRouterTests.swift index f4d37d5..a237ad9 100644 --- a/suixinkan_iosTests/NavigationRouterTests.swift +++ b/suixinkan_iosTests/NavigationRouterTests.swift @@ -5,93 +5,167 @@ // Created by Codex on 2026/6/22. // +import UIKit import XCTest @testable import suixinkan_ios -/// 主导航路由测试,覆盖 AppRoute 的跨页面导航策略。 +/// UIKit 主导航器测试,覆盖 Tab 宿主、全局入口和当前栈 push 行为。 @MainActor -final class NavigationRouterTests: XCTestCase { - /// 测试通过 NavigationStack push 的子页面默认隐藏底部 TabBar。 - func testPushedRoutesHideTabBarByDefault() { - XCTAssertTrue(AppRoute.placeholder(title: "详情").hidesTabBarWhenPushed) - XCTAssertTrue(AppRoute.home(.moreFunctions).hidesTabBarWhenPushed) - XCTAssertTrue(AppRoute.home(.paymentCollection).hidesTabBarWhenPushed) - XCTAssertTrue(AppRoute.home(.wallet).hidesTabBarWhenPushed) - XCTAssertTrue(AppRoute.orders(.storeDetail(try! Self.firstOrder())).hidesTabBarWhenPushed) - XCTAssertTrue(AppRoute.orders(.writeOffDetail(try! Self.firstWriteOffOrder())).hidesTabBarWhenPushed) - XCTAssertTrue(AppRoute.orders(.multiTravelTaskUpload(orderNumber: "MT001")).hidesTabBarWhenPushed) - XCTAssertTrue(AppRoute.orders(.orderTrailer(orderNumber: "MT001", title: "尾片上传")).hidesTabBarWhenPushed) +final class AppNavigatorTests: XCTestCase { + /// 测试当前栈 push 会增加选中导航栈层级,并隐藏底部 TabBar。 + func testPushUsesCurrentNavigationStack() { + let services = AppServices(apiClient: APIClient(session: AppNavigatorRecordingURLSession())) + let host = AppNavigatorTestHost(services: services) + let navigator = AppNavigator() + navigator.attach(host: host) + + navigator.push(.home(.paymentCollection), from: nil, animated: false) + + let homeStack = host.navigationController(for: .home)! + XCTAssertEqual(homeStack.viewControllers.count, 2) + XCTAssertTrue(homeStack.viewControllers.last?.hidesBottomBarWhenPushed == true) + XCTAssertTrue(homeStack.viewControllers.last is PaymentCollectionViewController) } - /// 测试切换订单 Tab 时会保存订单内部子入口。 - func testSelectOrdersUpdatesTabAndEntry() { - let router = AppRouter() + /// 测试 tabRoot 会切换到指定 Tab、清空旧栈并打开目标页。 + func testTabRootSelectsTabResetsStackAndPushesRoute() { + let services = AppServices(apiClient: APIClient(session: AppNavigatorRecordingURLSession())) + let host = AppNavigatorTestHost(services: services) + let navigator = AppNavigator() + navigator.attach(host: host) + host.navigationController(for: .home)?.push(route: .home(.wallet), animated: false) - router.selectOrders(entry: .verificationOrders) + navigator.openHome(.taskManagement, policy: .tabRoot(.home), animated: false) - XCTAssertEqual(router.selectedTab, .orders) - XCTAssertEqual(router.selectedOrdersEntry, .verificationOrders) + let homeStack = host.navigationController(for: .home)! + XCTAssertEqual(host.selectedTab, .home) + XCTAssertEqual(homeStack.viewControllers.count, 2) + XCTAssertTrue(homeStack.viewControllers.last is TaskManagementViewController) } - /// 测试全局扫码核销路由会切到订单核销入口并保存扫码结果。 - func testRouteToOrderVerificationStoresPendingScanCode() { - let router = AppRouter() + /// 测试 preserveTab 只切换 Tab 并保留旧栈。 + func testPreserveTabKeepsExistingStack() { + let services = AppServices(apiClient: APIClient(session: AppNavigatorRecordingURLSession())) + let host = AppNavigatorTestHost(services: services) + let navigator = AppNavigator() + navigator.attach(host: host) + host.navigationController(for: .profile)?.push(route: .profile(.settings), animated: false) - router.routeToOrderVerification(scannedCode: "order_number=VERIFY001") + navigator.openProfile(.realNameAuth, policy: .preserveTab(.profile), animated: false) - XCTAssertEqual(router.selectedTab, .orders) - XCTAssertEqual(router.selectedOrdersEntry, .verificationOrders) - XCTAssertEqual(router.pendingOrderScanCode, "order_number=VERIFY001") + let profileStack = host.navigationController(for: .profile)! + XCTAssertEqual(host.selectedTab, .profile) + XCTAssertEqual(profileStack.viewControllers.count, 3) + XCTAssertTrue(profileStack.viewControllers[1] is SettingsViewController) + XCTAssertTrue(profileStack.viewControllers.last is RealNameAuthViewController) } - /// 测试全局扫码结果只能被消费一次。 - func testConsumePendingOrderScanCodeClearsValueAfterFirstRead() { - let router = AppRouter() - router.routeToOrderVerification(scannedCode: "VERIFY001") + /// 测试宿主未挂载时全局请求会排队,并在 attach 后按顺序执行。 + func testPendingActionsRunAfterAttachInFIFOOrder() { + let services = AppServices(apiClient: APIClient(session: AppNavigatorRecordingURLSession())) + let host = AppNavigatorTestHost(services: services) + let navigator = AppNavigator() - XCTAssertEqual(router.consumePendingOrderScanCode(), "VERIFY001") - XCTAssertNil(router.consumePendingOrderScanCode()) + navigator.openHome(.paymentCollection, policy: .tabRoot(.home), animated: false) + navigator.openHome(.messageCenter, policy: .preserveTab(.home), animated: false) + navigator.attach(host: host) + + let homeStack = host.navigationController(for: .home)! + XCTAssertEqual(homeStack.viewControllers.count, 3) + XCTAssertTrue(homeStack.viewControllers[1] is PaymentCollectionViewController) + XCTAssertTrue(homeStack.viewControllers.last is MessageCenterViewController) } - /// 测试重置主路由时会清理未消费的扫码结果。 - func testResetClearsPendingOrderScanCode() { - let router = AppRouter() - router.routeToOrderVerification(scannedCode: "VERIFY001") + /// 测试 resetAllStacks 会清空暂存请求并重置已挂载宿主。 + func testResetAllStacksClearsPendingAndMountedStacks() { + let services = AppServices(apiClient: APIClient(session: AppNavigatorRecordingURLSession())) + let host = AppNavigatorTestHost(services: services) + let navigator = AppNavigator() - router.reset() + navigator.openHome(.paymentCollection, policy: .tabRoot(.home), animated: false) + navigator.resetAllStacks() + navigator.attach(host: host) + XCTAssertEqual(host.navigationController(for: .home)?.viewControllers.count, 1) - XCTAssertEqual(router.selectedTab, .home) - XCTAssertEqual(router.selectedOrdersEntry, .storeOrders) - XCTAssertNil(router.pendingOrderScanCode) + navigator.openHome(.wallet, policy: .tabRoot(.home), animated: false) + navigator.resetAllStacks() + XCTAssertEqual(host.navigationController(for: .home)?.viewControllers.count, 1) + XCTAssertEqual(host.selectedTab, .home) } - /// 测试订单模块真实路由可被压入导航栈。 - func testOrderRoutesCanBePushedIntoRouterPath() throws { - let router = RouterPath() - let order = try Self.firstOrder() - let writeOff = try Self.firstWriteOffOrder() + /// 测试打开订单入口会切到订单 Tab、回到根页面并传递扫码结果。 + func testOpenOrdersEntryAppliesRootStateAndScanCode() { + let services = AppServices(apiClient: APIClient(session: AppNavigatorRecordingURLSession())) + let host = AppNavigatorTestHost(services: services) + let navigator = AppNavigator() + navigator.attach(host: host) - router.navigate(to: .orders(.storeDetail(order))) - router.navigate(to: .orders(.writeOffDetail(writeOff))) - router.navigate(to: .orders(.multiTravelTaskUpload(orderNumber: "MT001"))) - router.navigate(to: .orders(.orderTrailer(orderNumber: "MT001", title: "视频预告"))) + navigator.openOrdersEntry(.verificationOrders, scannedCode: "VERIFY001") - XCTAssertEqual(router.path.count, 4) - XCTAssertEqual(router.path[0], .orders(.storeDetail(order))) - XCTAssertEqual(router.path[1], .orders(.writeOffDetail(writeOff))) - XCTAssertEqual(router.path[2], .orders(.multiTravelTaskUpload(orderNumber: "MT001"))) - XCTAssertEqual(router.path[3], .orders(.orderTrailer(orderNumber: "MT001", title: "视频预告"))) - } - - /// 返回测试用订单管理列表项。 - private static func firstOrder() throws -> OrderEntity { - let payload = try TestFixture.payload(ListPayload.self, named: "order_v2_success") - return try XCTUnwrap(payload.list.first) - } - - /// 返回测试用核销订单列表项。 - private static func firstWriteOffOrder() throws -> WriteOffOrderItem { - let payload = try TestFixture.payload(ListPayload.self, named: "writeoff_list_success") - return try XCTUnwrap(payload.list.first) + XCTAssertEqual(host.selectedTab, .orders) + XCTAssertEqual(host.appliedOrdersEntry, .verificationOrders) + XCTAssertEqual(host.appliedScanCode, "VERIFY001") + XCTAssertEqual(host.navigationController(for: .orders)?.viewControllers.count, 1) + } +} + +/// AppNavigator 单测专用宿主,模拟主 Tab 容器能力。 +@MainActor +private final class AppNavigatorTestHost: AppNavigationHost { + private let services: AppServices + private var controllers: [AppTab: TabNavigationController] = [:] + var selectedTab: AppTab = .home + var appliedOrdersEntry: OrdersEntry? + var appliedScanCode: String? + + /// 初始化所有 Tab 导航栈。 + init(services: AppServices) { + self.services = services + for tab in AppTab.allCases { + controllers[tab] = TabNavigationController(tab: tab, services: services) + } + } + + /// 选中指定主 Tab。 + func selectTab(_ tab: AppTab) { + selectedTab = tab + } + + /// 返回指定 Tab 的导航控制器。 + func navigationController(for tab: AppTab) -> TabNavigationController? { + controllers[tab] + } + + /// 返回当前选中 Tab 的导航控制器。 + func selectedNavigationController() -> TabNavigationController? { + controllers[selectedTab] + } + + /// 重置全部 Tab 导航栈。 + func resetAllStacks() { + for controller in controllers.values { + controller.popToRootViewController(animated: false) + } + selectedTab = .home + } + + /// 记录订单根页面接收到的入口状态。 + func applyOrdersEntry(_ entry: OrdersEntry, scannedCode: String?) { + appliedOrdersEntry = entry + appliedScanCode = scannedCode + } +} + +/// 空网络会话,避免路由测试发起真实请求。 +private final class AppNavigatorRecordingURLSession: URLSessionProtocol { + /// 返回空成功响应。 + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + let response = HTTPURLResponse( + url: request.url ?? URL(string: "https://example.com")!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + return (Data(), response) } } diff --git a/suixinkan_iosTests/PushNotificationTests.swift b/suixinkan_iosTests/PushNotificationTests.swift index 14c170a..a05a566 100644 --- a/suixinkan_iosTests/PushNotificationTests.swift +++ b/suixinkan_iosTests/PushNotificationTests.swift @@ -65,51 +65,98 @@ final class PushNotificationTests: XCTestCase { } func testPushManagerRoutesHomeDestinationsThroughHomeNavigationStack() { - let router = AppRouter() - let manager = pushManager(router: router) + let services = AppServices(apiClient: APIClient(session: PushRecordingURLSession())) + let host = PushNavigationHost(services: services) + let manager = pushManager(navigator: services.appNavigator) + services.appNavigator.attach(host: host) manager.handleRemoteNotification(PushPayload(userInfo: ["route": "payment_qr"])) - XCTAssertEqual(router.selectedTab, .home) - XCTAssertEqual(router.router(for: .home).path, [.home(.paymentCollection)]) + XCTAssertEqual(host.selectedTab, .home) + XCTAssertTrue(host.navigationController(for: .home)?.viewControllers.last is PaymentCollectionViewController) - router.router(for: .home).reset() manager.handleRemoteNotification(PushPayload(userInfo: ["route": "task_management"])) - XCTAssertEqual(router.router(for: .home).path, [.home(.taskManagement)]) + XCTAssertTrue(host.navigationController(for: .home)?.viewControllers.last is TaskManagementViewController) + XCTAssertEqual(host.navigationController(for: .home)?.viewControllers.count, 2) - router.router(for: .home).reset() manager.handleRemoteNotification(PushPayload(userInfo: ["uri": "/scenic-queue"])) - XCTAssertEqual(router.router(for: .home).path, [.home(.queueManagement)]) + XCTAssertTrue(host.navigationController(for: .home)?.viewControllers.last is QueueManagementViewController) + XCTAssertEqual(host.navigationController(for: .home)?.viewControllers.count, 2) - router.router(for: .home).reset() manager.handleRemoteNotification(PushPayload(userInfo: ["title": "未知通知"])) - XCTAssertEqual(router.router(for: .home).path, [.home(.messageCenter)]) + XCTAssertTrue(host.navigationController(for: .home)?.viewControllers.last is MessageCenterViewController) + XCTAssertEqual(host.navigationController(for: .home)?.viewControllers.count, 2) } func testPushManagerRoutesOrdersEntry() { - let router = AppRouter() - let manager = pushManager(router: router) + let services = AppServices(apiClient: APIClient(session: PushRecordingURLSession())) + let host = PushNavigationHost(services: services) + let manager = pushManager(navigator: services.appNavigator) + services.appNavigator.attach(host: host) manager.handleRemoteNotification(PushPayload(userInfo: ["route": "photographer_orders"])) - XCTAssertEqual(router.selectedTab, .orders) - XCTAssertEqual(router.selectedOrdersEntry, .storeOrders) + XCTAssertEqual(host.selectedTab, .orders) + XCTAssertEqual(host.appliedOrdersEntry, .storeOrders) manager.handleRemoteNotification(PushPayload(userInfo: ["route": "verification_order"])) - XCTAssertEqual(router.selectedTab, .orders) - XCTAssertEqual(router.selectedOrdersEntry, .verificationOrders) + XCTAssertEqual(host.selectedTab, .orders) + XCTAssertEqual(host.appliedOrdersEntry, .verificationOrders) } - private func pushManager(router: AppRouter) -> PushNotificationManager { + private func pushManager(navigator: AppNavigator) -> PushNotificationManager { let session = AppSession() let manager = PushNotificationManager() manager.configure( api: PushAPI(client: APIClient(session: PushRecordingURLSession())), session: session, - router: router + navigator: navigator ) return manager } } +/// 推送路由单测专用导航宿主。 +@MainActor +private final class PushNavigationHost: AppNavigationHost { + private var controllers: [AppTab: TabNavigationController] = [:] + var selectedTab: AppTab = .home + var appliedOrdersEntry: OrdersEntry? + + /// 初始化所有 Tab 导航栈。 + init(services: AppServices) { + for tab in AppTab.allCases { + controllers[tab] = TabNavigationController(tab: tab, services: services) + } + } + + /// 选中指定主 Tab。 + func selectTab(_ tab: AppTab) { + selectedTab = tab + } + + /// 返回指定 Tab 的导航控制器。 + func navigationController(for tab: AppTab) -> TabNavigationController? { + controllers[tab] + } + + /// 返回当前选中 Tab 的导航控制器。 + func selectedNavigationController() -> TabNavigationController? { + controllers[selectedTab] + } + + /// 重置全部 Tab 导航栈。 + func resetAllStacks() { + for controller in controllers.values { + controller.popToRootViewController(animated: false) + } + selectedTab = .home + } + + /// 记录订单入口状态。 + func applyOrdersEntry(_ entry: OrdersEntry, scannedCode: String?) { + appliedOrdersEntry = entry + } +} + private final class PushRecordingURLSession: URLSessionProtocol { private(set) var requests: [URLRequest] = [] diff --git a/功能同步Checklist.md b/功能同步Checklist.md index 9200ae0..6429f1e 100644 --- a/功能同步Checklist.md +++ b/功能同步Checklist.md @@ -28,8 +28,8 @@ | 状态 | 模块 | UIKit 实现 | 剩余事项 | | --- | --- | --- | --- | -| 完成 | App 根结构 | `RootViewController` 按 `AppSession.phase` 切换登录 / 恢复 / 主 Tab | 持续随路由扩展 | -| 完成 | Tab 导航 | 系统 `UITabBarController` 四 Tab + 独立 `TabNavigationController`;push 子页隐藏 TabBar。 | 后续新增真实子页面时继续复用 `AppRoute`。 | +| 完成 | App 根结构 | `RootViewController` 监听登录阶段;登录后 `MainTabBarController` 作为 window 根控制器 | 持续随路由扩展 | +| 完成 | Tab 导航 | CYLTabBarController 四 Tab + 中间扫码按钮 + 独立 `TabNavigationController`;push 子页隐藏 TabBar。 | 后续新增真实子页面时继续复用 `AppRoute`。 | | 完成 | 全局状态 | `AppServices.shared` 注入 Session / Context / Router / Toast / Loading | 避免新增 monolith 状态 | | 完成 | 登录态缓存 | Keychain token + UserDefaults 快照 / 偏好 + 冷启动恢复 | — | | 完成 | 网络框架 | `APIClient` / `APIEnvelope` / 统一错误与 token | — |