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 <cursoragent@cursor.com>
This commit is contained in:
7
Podfile
7
Podfile
@ -5,6 +5,7 @@ target 'suixinkan_ios' do
|
|||||||
|
|
||||||
pod 'SnapKit', '~> 5.7'
|
pod 'SnapKit', '~> 5.7'
|
||||||
pod 'Kingfisher', '~> 8.0'
|
pod 'Kingfisher', '~> 8.0'
|
||||||
|
pod 'CYLTabBarController', '~> 1.99.38'
|
||||||
|
|
||||||
# 高德地图 SDK:仅真机构建时链接;模拟器通过 post_install 剥离
|
# 高德地图 SDK:仅真机构建时链接;模拟器通过 post_install 剥离
|
||||||
pod 'AMap3DMap-NO-IDFA', '~> 11.1'
|
pod 'AMap3DMap-NO-IDFA', '~> 11.1'
|
||||||
@ -17,11 +18,13 @@ target 'suixinkan_ios' do
|
|||||||
end
|
end
|
||||||
|
|
||||||
SIMULATOR_OTHER_LDFLAGS = <<~FLAGS.squish
|
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 "CoreGraphics" -framework "CoreLocation" -framework "CoreTelephony"
|
||||||
-framework "CoreText" -framework "ExternalAccessory" -framework "GLKit"
|
-framework "CoreText" -framework "ExternalAccessory" -framework "GLKit"
|
||||||
-framework "OpenGLES" -framework "QuartzCore" -framework "Security"
|
-framework "OpenGLES" -framework "QuartzCore" -framework "Security"
|
||||||
-framework "SystemConfiguration"
|
-framework "SystemConfiguration" -weak_framework "Combine" -weak_framework "SwiftUI"
|
||||||
FLAGS
|
FLAGS
|
||||||
|
|
||||||
post_install do |installer|
|
post_install do |installer|
|
||||||
|
|||||||
@ -6,6 +6,9 @@ PODS:
|
|||||||
- AMapFoundation-NO-IDFA (>= 1.9.0)
|
- AMapFoundation-NO-IDFA (>= 1.9.0)
|
||||||
- AMapSearch-NO-IDFA (9.8.0):
|
- AMapSearch-NO-IDFA (9.8.0):
|
||||||
- AMapFoundation-NO-IDFA (>= 1.9.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)
|
- Kingfisher (8.10.0)
|
||||||
- SnapKit (5.7.1)
|
- SnapKit (5.7.1)
|
||||||
|
|
||||||
@ -13,6 +16,7 @@ DEPENDENCIES:
|
|||||||
- AMap3DMap-NO-IDFA (~> 11.1)
|
- AMap3DMap-NO-IDFA (~> 11.1)
|
||||||
- AMapLocation-NO-IDFA (~> 2.11)
|
- AMapLocation-NO-IDFA (~> 2.11)
|
||||||
- AMapSearch-NO-IDFA (~> 9.7)
|
- AMapSearch-NO-IDFA (~> 9.7)
|
||||||
|
- CYLTabBarController (~> 1.99.38)
|
||||||
- Kingfisher (~> 8.0)
|
- Kingfisher (~> 8.0)
|
||||||
- SnapKit (~> 5.7)
|
- SnapKit (~> 5.7)
|
||||||
|
|
||||||
@ -22,6 +26,7 @@ SPEC REPOS:
|
|||||||
- AMapFoundation-NO-IDFA
|
- AMapFoundation-NO-IDFA
|
||||||
- AMapLocation-NO-IDFA
|
- AMapLocation-NO-IDFA
|
||||||
- AMapSearch-NO-IDFA
|
- AMapSearch-NO-IDFA
|
||||||
|
- CYLTabBarController
|
||||||
- Kingfisher
|
- Kingfisher
|
||||||
- SnapKit
|
- SnapKit
|
||||||
|
|
||||||
@ -30,9 +35,10 @@ SPEC CHECKSUMS:
|
|||||||
AMapFoundation-NO-IDFA: a2e3c895398d7ee757278e1a0a8f9359da4b146e
|
AMapFoundation-NO-IDFA: a2e3c895398d7ee757278e1a0a8f9359da4b146e
|
||||||
AMapLocation-NO-IDFA: 7cd8fc837ea41edfbf4d937cd20572e277b77d18
|
AMapLocation-NO-IDFA: 7cd8fc837ea41edfbf4d937cd20572e277b77d18
|
||||||
AMapSearch-NO-IDFA: c0afd2a69a076d4228becda4401dbe4a279a03ef
|
AMapSearch-NO-IDFA: c0afd2a69a076d4228becda4401dbe4a279a03ef
|
||||||
|
CYLTabBarController: 8d46d501757c00e36499ea6b95e86e492fab3a61
|
||||||
Kingfisher: db468f911dd666c9134dcbeec4db8bed52e4132d
|
Kingfisher: db468f911dd666c9134dcbeec4db8bed52e4132d
|
||||||
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
|
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
|
||||||
|
|
||||||
PODFILE CHECKSUM: 8f89212788a9d27c878d93b811a6eaa818955f8c
|
PODFILE CHECKSUM: 4068d11e2fdbc0a6514a9ec709302300b8e54da8
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
@ -7,19 +7,19 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航
|
|||||||
该模块不直接处理具体页面业务,主要承担 App 级基础设施编排:
|
该模块不直接处理具体页面业务,主要承担 App 级基础设施编排:
|
||||||
- 根据登录状态展示登录页、恢复态或主 Tab。
|
- 根据登录状态展示登录页、恢复态或主 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 分组,对外仍保留扁平属性访问。
|
- `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,只负责登录态,不承载业务资料。
|
- `AppSession`:保存认证阶段和正式 token,只负责登录态,不承载业务资料。
|
||||||
- `AccountContext`:保存当前账号资料、景区作用域和门店作用域。
|
- `AccountContext`:保存当前账号资料、景区作用域和门店作用域。
|
||||||
- `PermissionContext`:保存角色权限、当前角色和扁平化权限 URI。
|
- `PermissionContext`:保存角色权限、当前角色和扁平化权限 URI。
|
||||||
- `ScenicSpotContext`:保存当前景区下的景点/打卡点列表和加载状态。
|
- `ScenicSpotContext`:保存当前景区下的景点/打卡点列表和加载状态。
|
||||||
- `AppRouter`:保存当前 Tab 和每个 Tab 自己的导航路径。
|
- `AppNavigator`:应用级导航器,持有主 Tab 宿主弱引用,并以 UIKit 导航栈作为唯一导航事实源。
|
||||||
- `ToastCenter`:管理当前全局 Toast 文案和自动隐藏任务。
|
- `ToastCenter`:管理当前全局 Toast 文案和自动隐藏任务。
|
||||||
- `AuthSessionCoordinator`:统一处理登录完成、退出登录、偏好读取和账号快照刷新。
|
- `AuthSessionCoordinator`:统一处理登录完成、退出登录、偏好读取和账号快照刷新。
|
||||||
- `SessionBootstrapper`:冷启动时读取本地 token 和账号快照,并向服务端校验登录态。
|
- `SessionBootstrapper`:冷启动时读取本地 token 和账号快照,并向服务端校验登录态。
|
||||||
@ -44,7 +44,7 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航
|
|||||||
|
|
||||||
普通业务 `ViewController`、模块基类(`ModuleTableViewController` / `ModuleCollectionViewController`)通过 `UIViewController.appServices` 扩展访问,**不必**再声明 `private let services` 或新增 `init(services:)`。
|
普通业务 `ViewController`、模块基类(`ModuleTableViewController` / `ModuleCollectionViewController`)通过 `UIViewController.appServices` 扩展访问,**不必**再声明 `private let services` 或新增 `init(services:)`。
|
||||||
|
|
||||||
导航辅助(`UIKitAppNavigation`、`HomeMenuRouting`)在全局 push 场景可直接使用 `AppServices.shared`。
|
导航辅助(如 `HomeMenuRouting`)在全局 push 场景通过 `AppServices.shared.appNavigator` 执行跳转。
|
||||||
|
|
||||||
### ViewModel 层边界
|
### ViewModel 层边界
|
||||||
|
|
||||||
@ -69,7 +69,7 @@ ViewModel **不持有** `AppServices`。View 从容器取出具体依赖,在 `
|
|||||||
5. 无 token 时保持 `loggedOut`,展示 `LoginViewController`。
|
5. 无 token 时保持 `loggedOut`,展示 `LoginViewController`。
|
||||||
6. 有 token 时进入 `restoring`,先恢复本地账号快照。
|
6. 有 token 时进入 `restoring`,先恢复本地账号快照。
|
||||||
7. `AccountContextLoader` 并行请求用户资料和角色权限,再补全景区与门店。
|
7. `AccountContextLoader` 并行请求用户资料和角色权限,再补全景区与门店。
|
||||||
8. 校验成功后进入 `loggedIn`,展示 `MainTabBarController`。
|
8. 校验成功后进入 `loggedIn`,将 `MainTabBarController` 设为 window 根控制器。
|
||||||
9. `ScenicSpotContext` 按当前景区懒加载景点/打卡点。
|
9. `ScenicSpotContext` 按当前景区懒加载景点/打卡点。
|
||||||
10. 明确 token 失效时清空 token 和账号快照,回到登录页。
|
10. 明确 token 失效时清空 token 和账号快照,回到登录页。
|
||||||
11. 普通网络失败时保留本地登录态,使用账号快照进入主界面。
|
11. 普通网络失败时保留本地登录态,使用账号快照进入主界面。
|
||||||
@ -96,20 +96,20 @@ ViewModel **不持有** `AppServices`。View 从容器取出具体依赖,在 `
|
|||||||
退出登录由 `AuthSessionCoordinator.logout` 统一处理:
|
退出登录由 `AuthSessionCoordinator.logout` 统一处理:
|
||||||
- 清空 Keychain token。
|
- 清空 Keychain token。
|
||||||
- 清空账号快照。
|
- 清空账号快照。
|
||||||
- 重置账号上下文、权限上下文、景点上下文、导航路径和 Toast。
|
- 重置账号上下文、权限上下文、景点上下文、全部 UIKit 导航栈和 Toast。
|
||||||
- `AppSession` 切换为 `loggedOut`。
|
- `AppSession` 切换为 `loggedOut`。
|
||||||
- 保留上次手机号、协议状态等非敏感偏好。
|
- 保留上次手机号、协议状态等非敏感偏好。
|
||||||
|
|
||||||
## Toast
|
## Toast
|
||||||
|
|
||||||
全局 Toast 由 `RootViewController` 在 `viewDidAppear` 时挂载到 window,业务页面通过 `showToast(...)` 或 `appServices.toastCenter.show(...)` 发出提示命令。
|
全局 Toast 由 `RootViewController` 在切换 window 根控制器后挂载到 window,业务页面通过 `showToast(...)` 或 `appServices.toastCenter.show(...)` 发出提示命令。
|
||||||
|
|
||||||
Toast 展示为顶部全宽横幅,背景使用不透明主色并延伸到屏幕顶部、左边和右边;文案居中展示,不提供关闭按钮,默认 2.2 秒后自动消失。连续展示新 Toast 时会覆盖旧文案并重新计时,旧的自动隐藏任务不会影响新的 Toast。
|
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 根页面显示底部 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。
|
||||||
|
|||||||
@ -17,7 +17,7 @@ final class AppServices {
|
|||||||
let network: NetworkBundle
|
let network: NetworkBundle
|
||||||
let ui: UIFeedbackBundle
|
let ui: UIFeedbackBundle
|
||||||
let runtime: AppRuntimeBundle
|
let runtime: AppRuntimeBundle
|
||||||
let appRouter = AppRouter()
|
let appNavigator = AppNavigator()
|
||||||
|
|
||||||
private let tokenStore: SessionTokenStore
|
private let tokenStore: SessionTokenStore
|
||||||
private let snapshotStore: AccountSnapshotStore
|
private let snapshotStore: AccountSnapshotStore
|
||||||
@ -151,7 +151,7 @@ final class AppServices {
|
|||||||
PushNotificationManager.shared.configure(
|
PushNotificationManager.shared.configure(
|
||||||
api: pushAPI,
|
api: pushAPI,
|
||||||
session: appSession,
|
session: appSession,
|
||||||
router: appRouter
|
navigator: appNavigator
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -39,19 +39,19 @@ enum AppUITestRouteDriver {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
services.appRouter.reset()
|
services.appNavigator.resetAllStacks()
|
||||||
let resolved = HomeMenuRouter.resolve(uri: item.uri, title: item.title)
|
let resolved = HomeMenuRouter.resolve(uri: item.uri, title: item.title)
|
||||||
switch resolved {
|
switch resolved {
|
||||||
case .tab(let tab):
|
case .tab(let tab):
|
||||||
services.appRouter.select(tab)
|
services.appNavigator.selectTab(tab)
|
||||||
case .orders(let entry):
|
case .orders(let entry):
|
||||||
services.appRouter.selectOrders(entry: entry)
|
services.appNavigator.openOrdersEntry(entry)
|
||||||
case .destination(let homeRoute):
|
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, _):
|
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):
|
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 }
|
guard let route else { return }
|
||||||
services.appRouter.reset()
|
services.appNavigator.resetAllStacks()
|
||||||
services.appRouter.navigateProfile(route, animated: false)
|
services.appNavigator.openProfile(route, policy: .tabRoot(.profile), animated: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 将 UI Test 用例菜单名映射为调试目录标题。
|
/// 将 UI Test 用例菜单名映射为调试目录标题。
|
||||||
|
|||||||
@ -172,12 +172,3 @@ enum AppRouteViewControllerFactory {
|
|||||||
return String(describing: route)
|
return String(describing: route)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
/// 首页路由 ViewController 工厂,供 `UIKitAppNavigation` 复用。
|
|
||||||
enum HomeRouteViewControllerFactory {
|
|
||||||
/// 创建实例。
|
|
||||||
static func make(for route: HomeRoute) -> UIViewController {
|
|
||||||
AppRouteViewControllerFactory.makeViewController(for: route, services: AppServices.shared)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
/// 应用内可导航目标,承载各 Tab 内部的路由目的地。
|
/// 应用内可导航目标,承载各 Tab 内部的路由目的地。
|
||||||
enum AppRoute: Hashable {
|
enum AppRoute: Hashable {
|
||||||
@ -21,90 +22,140 @@ enum AppRoute: Hashable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
/// 单个导航栈的路径容器,用于保存某个 Tab 的导航历史。
|
/// UIKit 路由执行策略,描述全局入口应如何选择导航栈。
|
||||||
final class RouterPath {
|
enum AppNavigationPolicy: Equatable {
|
||||||
var onChange: (() -> Void)?
|
case currentStack
|
||||||
var path: [AppRoute] = [] { didSet { onChange?() } }
|
case tabRoot(AppTab)
|
||||||
|
case preserveTab(AppTab)
|
||||||
/// 将指定路由压入当前 Tab 的导航栈。
|
|
||||||
func navigate(to route: AppRoute) {
|
|
||||||
path.append(route)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从当前 Tab 的导航栈弹出最后一级路由。
|
|
||||||
func navigateBack() {
|
|
||||||
guard !path.isEmpty else { return }
|
|
||||||
path.removeLast()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 清空当前 Tab 的导航栈。
|
|
||||||
func reset() {
|
|
||||||
path = []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
/// 主导航状态中心,管理当前 Tab 和每个 Tab 独立的导航路径。
|
/// 主 Tab 导航宿主协议,由 `MainTabBarController` 提供 UIKit 栈能力。
|
||||||
final class AppRouter {
|
protocol AppNavigationHost: AnyObject {
|
||||||
var onChange: (() -> Void)?
|
/// 选中指定主 Tab。
|
||||||
var selectedTab: AppTab = .home { didSet { onChange?() } }
|
func selectTab(_ tab: AppTab)
|
||||||
var selectedOrdersEntry: OrdersEntry = .storeOrders { didSet { onChange?() } }
|
|
||||||
private(set) var pendingOrderScanCode: String? { didSet { onChange?() } }
|
|
||||||
|
|
||||||
private let routers: [AppTab: RouterPath]
|
/// 返回指定 Tab 的导航控制器。
|
||||||
|
func navigationController(for tab: AppTab) -> TabNavigationController?
|
||||||
|
|
||||||
/// 初始化实例。
|
/// 返回当前选中 Tab 的导航控制器。
|
||||||
init() {
|
func selectedNavigationController() -> TabNavigationController?
|
||||||
var builtRouters: [AppTab: RouterPath] = [:]
|
|
||||||
for tab in AppTab.allCases {
|
/// 重置全部 Tab 的导航栈。
|
||||||
builtRouters[tab] = RouterPath()
|
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
|
self.host = nil
|
||||||
routers.values.forEach { router in
|
pendingActions.removeAll()
|
||||||
router.onChange = { [weak self] in
|
}
|
||||||
self?.onChange?()
|
|
||||||
|
/// 切换到指定主 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 对应的路由路径容器。
|
/// 主 Tab 已挂载时立即执行,否则暂存至登录后执行。
|
||||||
func router(for tab: AppTab) -> RouterPath {
|
private func performOrEnqueue(_ action: @escaping @MainActor (AppNavigationHost) -> Void) {
|
||||||
guard let router = routers[tab] else {
|
if let host {
|
||||||
assertionFailure("Missing router path for tab: \(tab)")
|
action(host)
|
||||||
return RouterPath()
|
} 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() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,25 +6,19 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
/// 单个 Tab 的导航栈,与 `AppRouter` 路径同步并在 push 时隐藏底部 TabBar。
|
/// 单个 Tab 的 UIKit 导航栈,负责承载该 Tab 内部页面层级。
|
||||||
final class TabNavigationController: UINavigationController, UINavigationControllerDelegate {
|
final class TabNavigationController: UINavigationController {
|
||||||
|
|
||||||
let appTab: AppTab
|
let appTab: AppTab
|
||||||
private let services: AppServices
|
private let services: AppServices
|
||||||
private var isSyncingStack = false
|
|
||||||
|
|
||||||
/// 初始化实例。
|
/// 初始化实例。
|
||||||
init(tab: AppTab, services: AppServices) {
|
init(tab: AppTab, services: AppServices) {
|
||||||
self.appTab = tab
|
self.appTab = tab
|
||||||
self.services = services
|
self.services = services
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
delegate = self
|
|
||||||
navigationBar.prefersLargeTitles = false
|
navigationBar.prefersLargeTitles = false
|
||||||
setViewControllers([tab.makeRootViewController(services: services)], animated: false)
|
setViewControllers([tab.makeRootViewController(services: services)], animated: false)
|
||||||
registerNavigationBridge()
|
|
||||||
services.appRouter.router(for: appTab).onChange = { [weak self] in
|
|
||||||
self?.syncFromRouterPath()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
@ -32,76 +26,10 @@ final class TabNavigationController: UINavigationController, UINavigationControl
|
|||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Push 指定应用路由,并同步写入 RouterPath。
|
/// Push 指定应用路由,并统一设置底部 TabBar 隐藏策略。
|
||||||
func pushRoute(_ route: AppRoute, animated: Bool = true) {
|
func push(route: AppRoute, animated: Bool = true) {
|
||||||
let viewController = AppRouteViewControllerFactory.makeViewController(for: route, services: services)
|
let viewController = AppRouteViewControllerFactory.makeViewController(for: route, services: services)
|
||||||
viewController.hidesBottomBarWhenPushed = route.hidesTabBarWhenPushed
|
viewController.hidesBottomBarWhenPushed = route.hidesTabBarWhenPushed
|
||||||
isSyncingStack = true
|
|
||||||
services.appRouter.router(for: appTab).navigate(to: route)
|
|
||||||
isSyncingStack = false
|
|
||||||
pushViewController(viewController, animated: animated)
|
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..<targetDepth {
|
|
||||||
let route = routerPath.path[index]
|
|
||||||
let viewController = AppRouteViewControllerFactory.makeViewController(for: route, services: services)
|
|
||||||
viewController.hidesBottomBarWhenPushed = route.hidesTabBarWhenPushed
|
|
||||||
pushViewController(viewController, animated: true)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if targetDepth < currentDepth {
|
|
||||||
// Router 路径变浅:pop 至目标深度
|
|
||||||
if targetDepth == 0 {
|
|
||||||
popToRootViewController(animated: true)
|
|
||||||
} else if targetDepth < viewControllers.count {
|
|
||||||
popToViewController(viewControllers[targetDepth], animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 同步RouterPathFromStack状态。
|
|
||||||
private func syncRouterPathFromStack() {
|
|
||||||
guard !isSyncingStack else { return }
|
|
||||||
|
|
||||||
let routerPath = services.appRouter.router(for: appTab)
|
|
||||||
let depth = max(viewControllers.count - 1, 0)
|
|
||||||
if depth < routerPath.path.count {
|
|
||||||
isSyncingStack = true
|
|
||||||
routerPath.path = Array(routerPath.path.prefix(depth))
|
|
||||||
isSyncingStack = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,46 +0,0 @@
|
|||||||
//
|
|
||||||
// UIKitAppNavigation.swift
|
|
||||||
// suixinkan
|
|
||||||
//
|
|
||||||
// Created by Codex on 2026/6/26.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
/// UIKit 导航协调器,供 AppRouter、推送和 UI Test 复用。
|
|
||||||
enum UIKitAppNavigation {
|
|
||||||
weak static var homeNavigationController: UINavigationController?
|
|
||||||
weak static var profileNavigationController: UINavigationController?
|
|
||||||
|
|
||||||
/// Push 首页子模块页面。
|
|
||||||
static func pushHomeRoute(_ route: HomeRoute, animated: Bool = true) {
|
|
||||||
let viewController = AppRouteViewControllerFactory.makeViewController(for: route, services: AppServices.shared)
|
|
||||||
homeNavigationController?.pushViewController(viewController, animated: animated)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Push 个人中心子页面。
|
|
||||||
static func pushProfileRoute(_ route: ProfileRoute, animated: Bool = true) {
|
|
||||||
let viewController = AppRouteViewControllerFactory.makeViewController(
|
|
||||||
for: .profile(route),
|
|
||||||
services: AppServices.shared
|
|
||||||
)
|
|
||||||
profileNavigationController?.pushViewController(viewController, animated: animated)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension AppRouter {
|
|
||||||
/// 同步路由栈并执行 UIKit push。
|
|
||||||
func navigateHome(_ route: HomeRoute, animated: Bool = true) {
|
|
||||||
select(.home)
|
|
||||||
router(for: .home).navigate(to: .home(route))
|
|
||||||
UIKitAppNavigation.pushHomeRoute(route, animated: animated)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 同步路由栈并执行 UIKit push。
|
|
||||||
func navigateProfile(_ route: ProfileRoute, animated: Bool = true) {
|
|
||||||
select(.profile)
|
|
||||||
router(for: .profile).navigate(to: .profile(route))
|
|
||||||
UIKitAppNavigation.pushProfileRoute(route, animated: animated)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -124,7 +124,7 @@ final class AuthSessionCoordinator {
|
|||||||
accountContext: AccountContext,
|
accountContext: AccountContext,
|
||||||
permissionContext: PermissionContext,
|
permissionContext: PermissionContext,
|
||||||
scenicSpotContext: ScenicSpotContext,
|
scenicSpotContext: ScenicSpotContext,
|
||||||
appRouter: AppRouter,
|
appNavigator: AppNavigator,
|
||||||
toastCenter: ToastCenter
|
toastCenter: ToastCenter
|
||||||
) {
|
) {
|
||||||
try? tokenStore.clear()
|
try? tokenStore.clear()
|
||||||
@ -132,7 +132,8 @@ final class AuthSessionCoordinator {
|
|||||||
accountContext.reset()
|
accountContext.reset()
|
||||||
permissionContext.reset()
|
permissionContext.reset()
|
||||||
scenicSpotContext.reset()
|
scenicSpotContext.reset()
|
||||||
appRouter.reset()
|
appNavigator.resetAllStacks()
|
||||||
|
appNavigator.detach()
|
||||||
toastCenter.dismiss()
|
toastCenter.dismiss()
|
||||||
appSession.logout()
|
appSession.logout()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,12 +7,11 @@ import SnapKit
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
/// 应用根 ViewController,根据登录阶段切换子页面并挂载全局 Overlay。
|
/// 应用根协调器,监听登录阶段并在 window 根层级切换登录页与 `MainTabBarController`。
|
||||||
final class RootViewController: UIViewController {
|
final class RootViewController: UIViewController {
|
||||||
|
|
||||||
private let services: AppServices
|
private let services: AppServices
|
||||||
private var currentChild: UIViewController?
|
private var currentAuthChild: UIViewController?
|
||||||
private let restoringView = UIView()
|
|
||||||
|
|
||||||
/// 初始化实例。
|
/// 初始化实例。
|
||||||
init(services: AppServices = .shared) {
|
init(services: AppServices = .shared) {
|
||||||
@ -29,21 +28,14 @@ final class RootViewController: UIViewController {
|
|||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
view.backgroundColor = .systemBackground
|
view.backgroundColor = .systemBackground
|
||||||
restoringView.backgroundColor = .systemBackground
|
|
||||||
|
|
||||||
services.appSession.onChange = { [weak self] in
|
services.appSession.onChange = { [weak self] in
|
||||||
self?.handleSessionPhaseChange()
|
self?.handleSessionPhaseChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
mountChild(for: services.appSession.phase)
|
applyRoot(for: services.appSession.phase)
|
||||||
bootstrapSession()
|
bootstrapSession()
|
||||||
configurePushNotifications()
|
services.configurePushNotifications()
|
||||||
}
|
|
||||||
|
|
||||||
/// 窗口展示后将 Toast 与全局 Loading 挂载到 window 层级。
|
|
||||||
override func viewDidAppear(_ animated: Bool) {
|
|
||||||
super.viewDidAppear(animated)
|
|
||||||
attachGlobalOverlays()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 冷启动时恢复本地会话,并在已登录时申请推送权限。
|
/// 冷启动时恢复本地会话,并在已登录时申请推送权限。
|
||||||
@ -66,21 +58,9 @@ final class RootViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 配置推送管理器与路由跳转回调。
|
/// 登录阶段变化时切换 window 根控制器,并在登出时清理各上下文状态。
|
||||||
private func configurePushNotifications() {
|
|
||||||
services.configurePushNotifications()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 将全局 Toast 与 Loading 视图附加到当前 window。
|
|
||||||
private func attachGlobalOverlays() {
|
|
||||||
guard let window = view.window else { return }
|
|
||||||
services.toastCenter.attachToWindow(window)
|
|
||||||
services.globalLoading.attachToWindow(window)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 登录阶段变化时切换子页面,并在登出时清理各上下文状态。
|
|
||||||
private func handleSessionPhaseChange() {
|
private func handleSessionPhaseChange() {
|
||||||
mountChild(for: services.appSession.phase)
|
applyRoot(for: services.appSession.phase)
|
||||||
|
|
||||||
switch services.appSession.phase {
|
switch services.appSession.phase {
|
||||||
case .loggedIn:
|
case .loggedIn:
|
||||||
@ -93,7 +73,8 @@ final class RootViewController: UIViewController {
|
|||||||
services.accountContext.reset()
|
services.accountContext.reset()
|
||||||
services.permissionContext.reset()
|
services.permissionContext.reset()
|
||||||
services.scenicSpotContext.reset()
|
services.scenicSpotContext.reset()
|
||||||
services.appRouter.reset()
|
services.appNavigator.resetAllStacks()
|
||||||
|
services.appNavigator.detach()
|
||||||
services.toastCenter.dismiss()
|
services.toastCenter.dismiss()
|
||||||
services.scenicQueueRuntime.stop()
|
services.scenicQueueRuntime.stop()
|
||||||
case .restoring:
|
case .restoring:
|
||||||
@ -113,31 +94,67 @@ final class RootViewController: UIViewController {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 按登录阶段挂载对应子控制器:登录页、恢复占位或主 Tab。
|
/// 按登录阶段切换 window 根控制器。
|
||||||
private func mountChild(for phase: AuthPhase) {
|
private func applyRoot(for phase: AuthPhase) {
|
||||||
let nextChild: UIViewController
|
|
||||||
switch phase {
|
switch phase {
|
||||||
case .loggedOut:
|
|
||||||
nextChild = LoginViewController(services: services)
|
|
||||||
case .restoring:
|
|
||||||
nextChild = restoringViewController()
|
|
||||||
case .loggedIn:
|
case .loggedIn:
|
||||||
nextChild = MainTabBarController(services: services)
|
showMainTabBarAsWindowRoot()
|
||||||
|
case .loggedOut:
|
||||||
|
showAuthRoot(with: LoginViewController(services: services))
|
||||||
|
case .restoring:
|
||||||
|
showAuthRoot(with: restoringViewController())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登录后将 `MainTabBarController` 设为 window 根控制器,满足 CYLTabBarController 挂载要求。
|
||||||
|
private func showMainTabBarAsWindowRoot() {
|
||||||
|
removeCurrentAuthChild()
|
||||||
|
|
||||||
|
guard let window = activeWindow else { return }
|
||||||
|
|
||||||
|
let mainTabBarController = MainTabBarController(services: services)
|
||||||
|
window.rootViewController = mainTabBarController
|
||||||
|
attachGlobalOverlays(to: window)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 未登录或恢复阶段展示登录/占位页,由当前协调器承载。
|
||||||
|
private func showAuthRoot(with child: UIViewController) {
|
||||||
|
guard let window = activeWindow else {
|
||||||
|
mountAuthChild(child)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard currentChild !== nextChild else { return }
|
if window.rootViewController !== self {
|
||||||
|
window.rootViewController = self
|
||||||
|
}
|
||||||
|
|
||||||
currentChild?.willMove(toParent: nil)
|
mountAuthChild(child)
|
||||||
currentChild?.view.removeFromSuperview()
|
attachGlobalOverlays(to: window)
|
||||||
currentChild?.removeFromParent()
|
}
|
||||||
|
|
||||||
addChild(nextChild)
|
/// 将登录页或恢复占位页挂载为当前协调器的子控制器。
|
||||||
view.addSubview(nextChild.view)
|
private func mountAuthChild(_ child: UIViewController) {
|
||||||
nextChild.view.snp.makeConstraints { make in
|
guard currentAuthChild !== child else { return }
|
||||||
|
|
||||||
|
currentAuthChild?.willMove(toParent: nil)
|
||||||
|
currentAuthChild?.view.removeFromSuperview()
|
||||||
|
currentAuthChild?.removeFromParent()
|
||||||
|
|
||||||
|
addChild(child)
|
||||||
|
view.addSubview(child.view)
|
||||||
|
child.view.snp.makeConstraints { make in
|
||||||
make.edges.equalToSuperview()
|
make.edges.equalToSuperview()
|
||||||
}
|
}
|
||||||
nextChild.didMove(toParent: self)
|
child.didMove(toParent: self)
|
||||||
currentChild = nextChild
|
currentAuthChild = child
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 移除当前认证阶段子控制器。
|
||||||
|
private func removeCurrentAuthChild() {
|
||||||
|
currentAuthChild?.willMove(toParent: nil)
|
||||||
|
currentAuthChild?.view.removeFromSuperview()
|
||||||
|
currentAuthChild?.removeFromParent()
|
||||||
|
currentAuthChild = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 会话恢复阶段的空白占位页,避免闪烁登录页。
|
/// 会话恢复阶段的空白占位页,避免闪烁登录页。
|
||||||
@ -146,4 +163,18 @@ final class RootViewController: UIViewController {
|
|||||||
controller.view.backgroundColor = .systemBackground
|
controller.view.backgroundColor = .systemBackground
|
||||||
return controller
|
return controller
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 将全局 Toast 与 Loading 视图附加到指定 window。
|
||||||
|
private func attachGlobalOverlays(to window: UIWindow) {
|
||||||
|
services.toastCenter.attachToWindow(window)
|
||||||
|
services.globalLoading.attachToWindow(window)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取当前 key window。
|
||||||
|
private var activeWindow: UIWindow? {
|
||||||
|
view.window ?? UIApplication.shared.connectedScenes
|
||||||
|
.compactMap { $0 as? UIWindowScene }
|
||||||
|
.flatMap(\.windows)
|
||||||
|
.first(where: \.isKeyWindow)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,7 +15,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate
|
|||||||
|
|
||||||
private var api: PushAPI?
|
private var api: PushAPI?
|
||||||
private weak var session: AppSession?
|
private weak var session: AppSession?
|
||||||
private weak var router: AppRouter?
|
private weak var navigator: AppNavigator?
|
||||||
private var latestDeviceToken: String?
|
private var latestDeviceToken: String?
|
||||||
private var latestAuthorizationStatus: UNAuthorizationStatus = .notDetermined
|
private var latestAuthorizationStatus: UNAuthorizationStatus = .notDetermined
|
||||||
private let tokenDefaultsKey = "apns_device_token"
|
private let tokenDefaultsKey = "apns_device_token"
|
||||||
@ -27,10 +27,10 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 绑定推送所需的网络、登录和导航依赖。
|
/// 绑定推送所需的网络、登录和导航依赖。
|
||||||
func configure(api: PushAPI, session: AppSession, router: AppRouter) {
|
func configure(api: PushAPI, session: AppSession, navigator: AppNavigator) {
|
||||||
self.api = api
|
self.api = api
|
||||||
self.session = session
|
self.session = session
|
||||||
self.router = router
|
self.navigator = navigator
|
||||||
UNUserNotificationCenter.current().delegate = self
|
UNUserNotificationCenter.current().delegate = self
|
||||||
latestDeviceToken = UserDefaults.standard.string(forKey: tokenDefaultsKey)
|
latestDeviceToken = UserDefaults.standard.string(forKey: tokenDefaultsKey)
|
||||||
}
|
}
|
||||||
@ -128,29 +128,21 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate
|
|||||||
|
|
||||||
/// 根据执行路由跳转。
|
/// 根据执行路由跳转。
|
||||||
private func route(from payload: PushPayload) {
|
private func route(from payload: PushPayload) {
|
||||||
guard let router else { return }
|
guard let navigator else { return }
|
||||||
|
|
||||||
switch payload.route {
|
switch payload.route {
|
||||||
case .payment:
|
case .payment:
|
||||||
navigateHomeRoute(.paymentCollection)
|
navigator.openHome(.paymentCollection, policy: .tabRoot(.home))
|
||||||
case .order:
|
case .order:
|
||||||
router.selectOrders(entry: .storeOrders)
|
navigator.openOrdersEntry(.storeOrders)
|
||||||
case .verificationOrder:
|
case .verificationOrder:
|
||||||
router.selectOrders(entry: .verificationOrders)
|
navigator.openOrdersEntry(.verificationOrders)
|
||||||
case .task:
|
case .task:
|
||||||
navigateHomeRoute(.taskManagement)
|
navigator.openHome(.taskManagement, policy: .tabRoot(.home))
|
||||||
case .queue:
|
case .queue:
|
||||||
navigateHomeRoute(.queueManagement)
|
navigator.openHome(.queueManagement, policy: .tabRoot(.home))
|
||||||
case .messageCenter:
|
case .messageCenter:
|
||||||
navigateHomeRoute(.messageCenter)
|
navigator.openHome(.messageCenter, policy: .tabRoot(.home))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 导航至HomeRoute页面。
|
|
||||||
private func navigateHomeRoute(_ route: HomeRoute) {
|
|
||||||
guard let router else { return }
|
|
||||||
router.select(.home)
|
|
||||||
router.router(for: .home).navigate(to: .home(route))
|
|
||||||
UIKitAppNavigation.pushHomeRoute(route, animated: true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,7 +29,7 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作
|
|||||||
- `unsupported`:已知 iOS 不支持的入口,进入占位说明。
|
- `unsupported`:已知 iOS 不支持的入口,进入占位说明。
|
||||||
- `placeholder`:未知或未迁移入口,记录诊断并进入占位说明。
|
- `placeholder`:未知或未迁移入口,记录诊断并进入占位说明。
|
||||||
|
|
||||||
`HomeView` 不创建自己的 `NavigationStack`,而是使用 Main Tab 注入的 `RouterPath` 进行页面跳转。
|
`HomeMenuRouting` 只作为首页菜单到 `AppNavigator` 的适配层:本地业务页面沿当前 UIKit 导航栈 push,订单和数据等一级入口交给导航器切换 Tab,未迁移入口进入占位页。
|
||||||
|
|
||||||
## 后续迁移
|
## 后续迁移
|
||||||
|
|
||||||
|
|||||||
@ -19,42 +19,16 @@ enum HomeMenuRouting {
|
|||||||
let services = AppServices.shared
|
let services = AppServices.shared
|
||||||
switch route {
|
switch route {
|
||||||
case .tab(let tab):
|
case .tab(let tab):
|
||||||
services.appRouter.select(tab)
|
services.appNavigator.selectTab(tab)
|
||||||
case .orders(let entry):
|
case .orders(let entry):
|
||||||
services.appRouter.selectOrders(entry: entry)
|
services.appNavigator.openOrdersEntry(entry)
|
||||||
case .destination(let homeRoute):
|
case .destination(let homeRoute):
|
||||||
push(homeRoute, from: viewController)
|
services.appNavigator.push(.home(homeRoute), from: viewController)
|
||||||
case .unsupported(let uri, let title, _):
|
case .unsupported(let uri, let title, _):
|
||||||
pushPlaceholder(title: title, uri: uri, from: viewController)
|
services.appNavigator.push(.home(.modulePlaceholder(uri: uri, title: title)), from: viewController)
|
||||||
case .placeholder(let uri, let title):
|
case .placeholder(let uri, let title):
|
||||||
HomeRouteDiagnostics.recordUnknown(uri: uri, title: title)
|
HomeRouteDiagnostics.recordUnknown(uri: uri, title: title)
|
||||||
pushPlaceholder(title: title, uri: uri, from: viewController)
|
services.appNavigator.push(.home(.modulePlaceholder(uri: uri, title: title)), from: viewController)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Push 首页二级路由页面。
|
|
||||||
static func push(_ route: HomeRoute, from viewController: UIViewController) {
|
|
||||||
let target = AppRouteViewControllerFactory.makeViewController(for: route, services: AppServices.shared)
|
|
||||||
viewController.navigationController?.pushViewController(target, animated: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Push 订单模块路由页面。
|
|
||||||
static func pushOrders(_ route: OrdersRoute, from viewController: UIViewController) {
|
|
||||||
let target = AppRouteViewControllerFactory.makeViewController(for: .orders(route), services: AppServices.shared)
|
|
||||||
viewController.navigationController?.pushViewController(target, animated: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Push 个人中心二级路由页面。
|
|
||||||
static func pushProfile(_ route: ProfileRoute, from viewController: UIViewController) {
|
|
||||||
let target = AppRouteViewControllerFactory.makeViewController(for: .profile(route), services: AppServices.shared)
|
|
||||||
viewController.navigationController?.pushViewController(target, animated: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Push Placeholder页面。
|
|
||||||
private static func pushPlaceholder(title: String, uri: String, from viewController: UIViewController) {
|
|
||||||
viewController.navigationController?.pushViewController(
|
|
||||||
FeaturePlaceholderViewController(title: title, uri: uri),
|
|
||||||
animated: true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -368,7 +368,7 @@ final class HomeViewController: UIViewController {
|
|||||||
|
|
||||||
/// 点击景区名称,跳转景区选择页。
|
/// 点击景区名称,跳转景区选择页。
|
||||||
@objc private func scenicTapped() {
|
@objc private func scenicTapped() {
|
||||||
HomeMenuRouting.push(.scenicSelection, from: self)
|
appServices.appNavigator.push(.home(.scenicSelection), from: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 切换在线 / 离线状态。
|
/// 切换在线 / 离线状态。
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Main 模块负责登录后的主界面 Tab 容器。
|
Main 模块负责登录后的主界面 Tab 容器。
|
||||||
|
|
||||||
主界面由 `MainTabBarController` 承载,使用系统 `UITabBarController` 展示首页、订单、数据、我的四个 Tab。每个 Tab 内部包裹独立的 `TabNavigationController`,导航路径由 `AppRouter` 单独保存。
|
主界面由 `MainTabBarController` 承载,使用 **CYLTabBarController** 展示首页、订单、数据、我的四个 Tab,并在中间提供扫码核销按钮。每个 Tab 内部包裹独立的 `TabNavigationController`,页面路径以 UIKit 导航栈为唯一事实源。
|
||||||
|
|
||||||
## Tab 结构
|
## Tab 结构
|
||||||
|
|
||||||
@ -18,11 +18,11 @@ Main 模块负责登录后的主界面 Tab 容器。
|
|||||||
|
|
||||||
## 导航流程
|
## 导航流程
|
||||||
|
|
||||||
1. `MainTabBarController` 读取 `AppServices.appRouter` 作为选中 Tab 状态源。
|
1. `MainTabBarController` 在登录后注册为 `AppNavigator` 的导航宿主。
|
||||||
2. 用户点击 TabBar 或通过 `AppRouter.select` 切换 Tab 时,两边状态保持同步。
|
2. 用户点击 TabBar 只改变 UIKit 选中态,并保留各 Tab 原有导航栈。
|
||||||
3. 每个 Tab 由 `TabNavigationController` 创建独立 `UINavigationController` 栈。
|
3. 每个 Tab 由 `TabNavigationController` 创建独立 `UINavigationController` 栈。
|
||||||
4. Tab 内 push 子页面时,通过 `AppRoute.hidesTabBarWhenPushed` 控制是否隐藏系统 TabBar,默认隐藏。
|
4. Tab 内 push 子页面时,通过 `AppRoute.hidesTabBarWhenPushed` 控制是否隐藏系统 TabBar,默认隐藏。
|
||||||
5. 跨 Tab 跳转通过 `AppRouter.select` 或 `AppRouter.selectOrders(entry:)` 处理。
|
5. 跨 Tab、推送通知、UI Test 直达和全局扫码统一通过 `AppNavigator` 执行。
|
||||||
|
|
||||||
## 订单角标
|
## 订单角标
|
||||||
|
|
||||||
@ -32,15 +32,21 @@ Main 模块负责登录后的主界面 Tab 容器。
|
|||||||
|
|
||||||
## 扫码核销
|
## 扫码核销
|
||||||
|
|
||||||
系统 TabBar 不展示中间扫码按钮;订单页内部的扫码核销入口仍然可用。如需全局扫码入口,可通过首页或订单页进入。
|
TabBar 中间通过 `MainScanPlusButton`(CYLTabBarController PlusButton)展示全局扫码入口,点击后全屏打开 `OrderCodeScannerViewController`:
|
||||||
|
|
||||||
|
1. 扫码成功 → `AppNavigator.openOrdersEntry(.verificationOrders, scannedCode:)` 切换至订单核销入口并传入扫码结果。
|
||||||
|
2. `OrdersViewController` 本地保存并消费扫码结果后进入确认核销流程。
|
||||||
|
3. 扫码失败 → 通过 `ToastCenter` 提示错误。
|
||||||
|
|
||||||
|
订单页内部的扫码核销入口仍然可用,与 TabBar 中间按钮为双入口。
|
||||||
|
|
||||||
## 后续迁移规则
|
## 后续迁移规则
|
||||||
|
|
||||||
迁移新页面时:
|
迁移新页面时:
|
||||||
- 优先替换对应 Tab 的根 ViewController。
|
- 优先替换对应 Tab 的根 ViewController。
|
||||||
- 保持每个 Tab 自己的 `TabNavigationController` 导航栈。
|
- 保持每个 Tab 自己的 `TabNavigationController` 导航栈。
|
||||||
- 跨 Tab 跳转通过 `AppRouter.select` 切换 Tab。
|
- 跨 Tab 跳转通过 `AppNavigator.selectTab` 或对应业务入口方法执行。
|
||||||
- 需要从首页或全局入口进入订单核销时,优先使用 `AppRouter.selectOrders(entry:)` 或 `AppRouter.routeToOrderVerification(scannedCode:)`。
|
- 需要从首页或全局入口进入订单核销时,优先使用 `AppNavigator.openOrdersEntry(_:scannedCode:)`。
|
||||||
- Tab 内跳转通过当前 Tab 的 `RouterPath.navigate` 或扩展后的 `AppRoute` 处理。
|
- Tab 内跳转通过 `AppNavigator.push(_:from:)` 或扩展后的 `AppRoute` 处理。
|
||||||
- 普通业务子页面默认隐藏 TabBar;只有确有产品需求时,才在 `AppRoute` 策略中单独放开。
|
- 普通业务子页面默认隐藏 TabBar;只有确有产品需求时,才在 `AppRoute` 策略中单独放开。
|
||||||
- 真实业务页面接入后,应同步补充该模块文档和单元测试。
|
- 真实业务页面接入后,应同步补充该模块文档和单元测试。
|
||||||
|
|||||||
@ -3,11 +3,12 @@
|
|||||||
// suixinkan
|
// suixinkan
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import CYLTabBarController
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
/// 主 Tab 容器,使用系统 `UITabBarController` 展示四个一级入口。
|
/// 主 Tab 容器,使用 CYLTabBarController 展示四个一级入口及中间扫码按钮。
|
||||||
final class MainTabBarController: UITabBarController, UITabBarControllerDelegate {
|
final class MainTabBarController: CYLTabBarController {
|
||||||
|
|
||||||
private let services: AppServices
|
private let services: AppServices
|
||||||
private let badgeViewModel = MainTabBadgeViewModel()
|
private let badgeViewModel = MainTabBadgeViewModel()
|
||||||
@ -17,7 +18,11 @@ final class MainTabBarController: UITabBarController, UITabBarControllerDelegate
|
|||||||
/// 初始化实例。
|
/// 初始化实例。
|
||||||
init(services: AppServices) {
|
init(services: AppServices) {
|
||||||
self.services = services
|
self.services = services
|
||||||
|
MainScanPlusButton.register()
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
MainScanPlusButton.onScanTap = { [weak self] in
|
||||||
|
self?.presentGlobalScanner()
|
||||||
|
}
|
||||||
delegate = self
|
delegate = self
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,36 +36,72 @@ final class MainTabBarController: UITabBarController, UITabBarControllerDelegate
|
|||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
configureTabBarAppearance()
|
configureTabBarAppearance()
|
||||||
configureTabs()
|
configureTabs()
|
||||||
bindRouter()
|
services.appNavigator.attach(host: self)
|
||||||
bindBadgeViewModel()
|
bindBadgeViewModel()
|
||||||
bindAccountContext()
|
bindAccountContext()
|
||||||
syncSelectedTabFromRouter(animated: false)
|
|
||||||
Task { await refreshOrderBadge() }
|
Task { await refreshOrderBadge() }
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
Task { await AppUITestRouteDriver.applyIfNeeded(services: services) }
|
Task { await AppUITestRouteDriver.applyIfNeeded(services: services) }
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 配置系统 TabBar 外观。
|
/// 控制器释放前清理全局扫码按钮回调。
|
||||||
private func configureTabBarAppearance() {
|
deinit {
|
||||||
tabBar.tintColor = AppDesign.primary
|
Task { @MainActor in
|
||||||
tabBar.backgroundColor = .white
|
MainScanPlusButton.onScanTap = nil
|
||||||
}
|
|
||||||
|
|
||||||
/// 为每个 Tab 创建独立导航栈并绑定 TabBarItem。
|
|
||||||
private func configureTabs() {
|
|
||||||
viewControllers = AppTab.allCases.map { tab in
|
|
||||||
let navigationController = TabNavigationController(tab: tab, services: services)
|
|
||||||
navigationController.tabBarItem = tab.makeTabBarItem()
|
|
||||||
tabNavigationControllers[tab] = navigationController
|
|
||||||
return navigationController
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 绑定 Router 回调,同步程序化 Tab 切换。
|
/// 配置 TabBar 外观,对齐参考工程 CustomMainTabBar 样式。
|
||||||
private func bindRouter() {
|
private func configureTabBarAppearance() {
|
||||||
services.appRouter.onChange = { [weak self] in
|
tabBar.tintColor = AppDesign.primary
|
||||||
self?.handleRouterChange()
|
tabBar.backgroundColor = .white
|
||||||
|
tabBar.shadowImage = UIImage()
|
||||||
|
|
||||||
|
let normalAttributes: [NSAttributedString.Key: Any] = [
|
||||||
|
.foregroundColor: UIColor(hex: 0x7D8DA3),
|
||||||
|
.font: UIFont.systemFont(ofSize: 12, weight: .regular)
|
||||||
|
]
|
||||||
|
let selectedAttributes: [NSAttributedString.Key: Any] = [
|
||||||
|
.foregroundColor: AppDesign.primary,
|
||||||
|
.font: UIFont.systemFont(ofSize: 12, weight: .medium)
|
||||||
|
]
|
||||||
|
|
||||||
|
let appearance = UITabBarAppearance()
|
||||||
|
appearance.configureWithOpaqueBackground()
|
||||||
|
appearance.backgroundColor = .white
|
||||||
|
appearance.shadowColor = .clear
|
||||||
|
appearance.stackedLayoutAppearance.normal.titleTextAttributes = normalAttributes
|
||||||
|
appearance.stackedLayoutAppearance.selected.titleTextAttributes = selectedAttributes
|
||||||
|
tabBar.standardAppearance = appearance
|
||||||
|
tabBar.scrollEdgeAppearance = appearance
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 为每个 Tab 创建独立导航栈,并按 CYLTabBarController 要求先配置 Tab 属性再设置控制器。
|
||||||
|
private func configureTabs() {
|
||||||
|
let tabs = AppTab.allCases
|
||||||
|
tabBarItemsAttributes = tabs.map { makeTabBarItemAttributes(for: $0) }
|
||||||
|
viewControllers = tabs.map { tab in
|
||||||
|
let navigationController = TabNavigationController(tab: tab, services: services)
|
||||||
|
tabNavigationControllers[tab] = navigationController
|
||||||
|
return navigationController
|
||||||
|
}
|
||||||
|
applyTabAccessibilityIdentifiers()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构建 CYLTabBarController 识别的 TabBarItem 属性字典。
|
||||||
|
private func makeTabBarItemAttributes(for tab: AppTab) -> [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 待核销角标。
|
/// 更新订单 Tab 待核销角标。
|
||||||
private func updateOrderBadge() {
|
private func updateOrderBadge() {
|
||||||
let badgeValue: String?
|
let badgeValue: String?
|
||||||
@ -116,14 +139,71 @@ final class MainTabBarController: UITabBarController, UITabBarControllerDelegate
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 用户点击 TabBar 时同步选中状态到 `AppRouter`。
|
/// 从 TabBar 中间按钮打开全局扫码页,成功后路由至订单核销入口。
|
||||||
func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
|
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,
|
guard !isSyncingTabSelection,
|
||||||
let navigationController = viewController as? TabNavigationController else { return }
|
let navigationController = viewController as? TabNavigationController else { return }
|
||||||
|
|
||||||
services.appRouter.select(navigationController.appTab)
|
|
||||||
if navigationController.appTab == .orders {
|
if navigationController.appTab == .orders {
|
||||||
Task { await refreshOrderBadge() }
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
77
suixinkan_ios/Features/Main/Views/MainScanPlusButton.swift
Normal file
77
suixinkan_ios/Features/Main/Views/MainScanPlusButton.swift
Normal file
@ -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?()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,7 +8,7 @@ Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理、核销
|
|||||||
|
|
||||||
## 核心对象
|
## 核心对象
|
||||||
|
|
||||||
- `OrdersView`:订单 Tab 根视图,读取 `AccountContext`、`PermissionContext`、`AppRouter` 和 `OrdersAPI`。
|
- `OrdersView`:订单 Tab 根视图,读取 `AccountContext`、`PermissionContext` 和 `OrdersAPI`,并接收 `AppNavigator` 传入的订单入口与扫码结果。
|
||||||
- `OrdersViewModel`:管理订单子入口、筛选条件、分页状态、加载状态和手动核销状态。
|
- `OrdersViewModel`:管理订单子入口、筛选条件、分页状态、加载状态和手动核销状态。
|
||||||
- `OrderDetailViewModel`:管理订单详情接口加载、错误提示和列表摘要兜底展示。
|
- `OrderDetailViewModel`:管理订单详情接口加载、错误提示和列表摘要兜底展示。
|
||||||
- `DepositOrderListViewModel`:管理押金订单分页、核销、退款和操作后刷新。
|
- `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。
|
||||||
|
|
||||||
未明确接口的订单后续能力继续进入占位页,避免入口崩溃。
|
未明确接口的订单后续能力继续进入占位页,避免入口崩溃。
|
||||||
|
|||||||
@ -96,7 +96,7 @@ final class DepositOrderListViewController: UIViewController, UITableViewDelegat
|
|||||||
let cell = tableView.dequeueReusableCell(withIdentifier: DepositOrderCell.reuseID, for: indexPath) as! DepositOrderCell
|
let cell = tableView.dequeueReusableCell(withIdentifier: DepositOrderCell.reuseID, for: indexPath) as! DepositOrderCell
|
||||||
cell.configure(item: orderItem, isOperating: self.viewModel.operatingOrderNumber == orderItem.orderNumber)
|
cell.configure(item: orderItem, isOperating: self.viewModel.operatingOrderNumber == orderItem.orderNumber)
|
||||||
cell.onDetail = { [weak self] in
|
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.onWriteOff = { [weak self] in self?.writeOff(orderItem) }
|
||||||
cell.onRefund = { [weak self] in self?.refund(orderItem) }
|
cell.onRefund = { [weak self] in self?.refund(orderItem) }
|
||||||
@ -154,7 +154,7 @@ final class DepositOrderListViewController: UIViewController, UITableViewDelegat
|
|||||||
@objc private func openDetail() {
|
@objc private func openDetail() {
|
||||||
let text = orderNumberField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let text = orderNumberField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
guard !text.isEmpty else { return }
|
guard !text.isEmpty else { return }
|
||||||
HomeMenuRouting.pushOrders(.depositDetail(orderNumber: text), from: self)
|
appServices.appNavigator.push(.orders(.depositDetail(orderNumber: text)), from: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 刷新。
|
/// 刷新。
|
||||||
|
|||||||
@ -289,13 +289,13 @@ final class StoreOrderDetailViewController: UIViewController, UITableViewDelegat
|
|||||||
let orderNumber = viewModel.display.orderNumber
|
let orderNumber = viewModel.display.orderNumber
|
||||||
switch index {
|
switch index {
|
||||||
case 0:
|
case 0:
|
||||||
HomeMenuRouting.pushOrders(.historicalShooting(orderNumber: orderNumber), from: self)
|
appServices.appNavigator.push(.orders(.historicalShooting(orderNumber: orderNumber)), from: self)
|
||||||
case 1 where item.orderType == 19:
|
case 1 where item.orderType == 19:
|
||||||
HomeMenuRouting.pushOrders(.multiTravelTaskUpload(orderNumber: orderNumber), from: self)
|
appServices.appNavigator.push(.orders(.multiTravelTaskUpload(orderNumber: orderNumber)), from: self)
|
||||||
case 2:
|
case 2:
|
||||||
HomeMenuRouting.pushOrders(.orderTrailer(orderNumber: orderNumber, title: "视频预告"), from: self)
|
appServices.appNavigator.push(.orders(.orderTrailer(orderNumber: orderNumber, title: "视频预告")), from: self)
|
||||||
case 3:
|
case 3:
|
||||||
HomeMenuRouting.pushOrders(.orderTrailer(orderNumber: orderNumber, title: "尾片上传"), from: self)
|
appServices.appNavigator.push(.orders(.orderTrailer(orderNumber: orderNumber, title: "尾片上传")), from: self)
|
||||||
case 4:
|
case 4:
|
||||||
presentRefund()
|
presentRefund()
|
||||||
default:
|
default:
|
||||||
|
|||||||
@ -37,6 +37,7 @@ final class OrdersViewController: UIViewController {
|
|||||||
private let viewModel = OrdersViewModel()
|
private let viewModel = OrdersViewModel()
|
||||||
private var manualOrderNumber = ""
|
private var manualOrderNumber = ""
|
||||||
private var scanHintMessage: String?
|
private var scanHintMessage: String?
|
||||||
|
private var pendingScanCode: String?
|
||||||
|
|
||||||
private lazy var collectionView: UICollectionView = {
|
private lazy var collectionView: UICollectionView = {
|
||||||
let layout = makeCollectionLayout()
|
let layout = makeCollectionLayout()
|
||||||
@ -77,25 +78,34 @@ final class OrdersViewController: UIViewController {
|
|||||||
self?.applySnapshot()
|
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
|
appServices.accountContext.onChange = { [weak self] in
|
||||||
Task { await self?.reload(showLoading: true) }
|
Task { await self?.reload(showLoading: true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.selectedEntry = appServices.appRouter.selectedOrdersEntry
|
|
||||||
Task {
|
Task {
|
||||||
await reload(showLoading: true)
|
await reload(showLoading: true)
|
||||||
await consumePendingScanCodeIfNeeded()
|
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 类型选用全宽布局。
|
/// 构建 Compositional Layout,按 section 类型选用全宽布局。
|
||||||
private func makeCollectionLayout() -> UICollectionViewCompositionalLayout {
|
private func makeCollectionLayout() -> UICollectionViewCompositionalLayout {
|
||||||
UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in
|
UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in
|
||||||
@ -345,7 +355,6 @@ final class OrdersViewController: UIViewController {
|
|||||||
private func switchEntry(_ entry: OrdersEntry) {
|
private func switchEntry(_ entry: OrdersEntry) {
|
||||||
guard viewModel.selectedEntry != entry else { return }
|
guard viewModel.selectedEntry != entry else { return }
|
||||||
viewModel.selectedEntry = entry
|
viewModel.selectedEntry = entry
|
||||||
appServices.appRouter.selectedOrdersEntry = entry
|
|
||||||
Task { await reload(showLoading: true) }
|
Task { await reload(showLoading: true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -370,7 +379,8 @@ final class OrdersViewController: UIViewController {
|
|||||||
/// 消费路由中暂存的扫码结果(核销入口)。
|
/// 消费路由中暂存的扫码结果(核销入口)。
|
||||||
private func consumePendingScanCodeIfNeeded() async {
|
private func consumePendingScanCodeIfNeeded() async {
|
||||||
guard viewModel.selectedEntry == .verificationOrders,
|
guard viewModel.selectedEntry == .verificationOrders,
|
||||||
let code = appServices.appRouter.consumePendingOrderScanCode() else { return }
|
let code = pendingScanCode else { return }
|
||||||
|
pendingScanCode = nil
|
||||||
handleScanResult(code)
|
handleScanResult(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -459,11 +469,11 @@ extension OrdersViewController: UICollectionViewDelegate {
|
|||||||
if item.hasPrefix("orders:store:") {
|
if item.hasPrefix("orders:store:") {
|
||||||
let orderNumber = String(item.dropFirst("orders:store:".count))
|
let orderNumber = String(item.dropFirst("orders:store:".count))
|
||||||
guard let order = viewModel.storeOrders.first(where: { $0.orderNumber == orderNumber }) else { return }
|
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:") {
|
} else if item.hasPrefix("orders:writeoff:") {
|
||||||
let orderNumber = String(item.dropFirst("orders:writeoff:".count))
|
let orderNumber = String(item.dropFirst("orders:writeoff:".count))
|
||||||
guard let order = viewModel.writeOffOrders.first(where: { $0.orderNumber == orderNumber }) else { return }
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -76,7 +76,7 @@ DEBUG 构建下,“我的”列表会额外展示“首页调试”入口,
|
|||||||
3. 页面展示景区账号和门店账号,优先选中后端标记的当前账号,否则选中第一项。
|
3. 页面展示景区账号和门店账号,优先选中后端标记的当前账号,否则选中第一项。
|
||||||
4. 用户确认后调用 `AuthAPI.setUser`,使用 `AccountSwitchAccount.toSetUserRequest()` 生成请求体。
|
4. 用户确认后调用 `AuthAPI.setUser`,使用 `AccountSwitchAccount.toSetUserRequest()` 生成请求体。
|
||||||
5. 切换成功后调用 `AuthSessionCoordinator.completeLogin`,重新写入正式 token、账号快照、权限、景区和门店上下文。
|
5. 切换成功后调用 `AuthSessionCoordinator.completeLogin`,重新写入正式 token、账号快照、权限、景区和门店上下文。
|
||||||
6. `AppRouter.reset()` 清空旧路由栈并回到首页。
|
6. `AppNavigator.resetAllStacks()` 清空旧 UIKit 导航栈并回到首页。
|
||||||
|
|
||||||
## 实名认证流程
|
## 实名认证流程
|
||||||
|
|
||||||
@ -105,7 +105,7 @@ DEBUG 构建下,“我的”列表会额外展示“首页调试”入口,
|
|||||||
1. 用户点击退出登录。
|
1. 用户点击退出登录。
|
||||||
2. `ProfileView` 展示确认弹窗。
|
2. `ProfileView` 展示确认弹窗。
|
||||||
3. 用户确认后调用 `AuthSessionCoordinator.logout`。
|
3. 用户确认后调用 `AuthSessionCoordinator.logout`。
|
||||||
4. 协调器清空正式 token、账号快照、账号上下文、路由栈和 Toast。
|
4. 协调器清空正式 token、账号快照、账号上下文、全部 UIKit 导航栈和 Toast。
|
||||||
5. `AppSession` 切换为 `loggedOut`,根视图回到登录页。
|
5. `AppSession` 切换为 `loggedOut`,根视图回到登录页。
|
||||||
6. 上次手机号和协议状态等非敏感偏好保留。
|
6. 上次手机号和协议状态等非敏感偏好保留。
|
||||||
|
|
||||||
|
|||||||
@ -187,7 +187,7 @@ final class AccountSwitchViewController: UIViewController, UITableViewDelegate {
|
|||||||
profileAPI: appServices.profileAPI,
|
profileAPI: appServices.profileAPI,
|
||||||
accountContextAPI: appServices.accountContextAPI
|
accountContextAPI: appServices.accountContextAPI
|
||||||
)
|
)
|
||||||
appServices.appRouter.reset()
|
appServices.appNavigator.resetAllStacks()
|
||||||
showToast("账号已切换")
|
showToast("账号已切换")
|
||||||
navigationController?.popToRootViewController(animated: true)
|
navigationController?.popToRootViewController(animated: true)
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@ -28,6 +28,24 @@ private enum ProfileRowID {
|
|||||||
static let logout = "profile:logout"
|
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 {
|
final class ProfileViewController: UIViewController, UITableViewDelegate {
|
||||||
|
|
||||||
@ -76,43 +94,57 @@ final class ProfileViewController: UIViewController, UITableViewDelegate {
|
|||||||
|
|
||||||
/// 配置 Diffable 数据源。
|
/// 配置 Diffable 数据源。
|
||||||
private func configureTableDataSource() {
|
private func configureTableDataSource() {
|
||||||
|
tableView.register(ProfileValueTableViewCell.self, forCellReuseIdentifier: ProfileCellReuseID.value)
|
||||||
|
tableView.register(UITableViewCell.self, forCellReuseIdentifier: ProfileCellReuseID.logout)
|
||||||
|
|
||||||
tableDataSource = UITableViewDiffableDataSource<ProfileTableSection, ProfileTableRow>(
|
tableDataSource = UITableViewDiffableDataSource<ProfileTableSection, ProfileTableRow>(
|
||||||
tableView: tableView
|
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() }
|
guard let self else { return UITableViewCell() }
|
||||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
|
||||||
switch row {
|
switch row {
|
||||||
case ProfileRowID.logout:
|
case ProfileRowID.logout:
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: ProfileCellReuseID.logout, for: indexPath)
|
||||||
cell.textLabel?.text = "退出登录"
|
cell.textLabel?.text = "退出登录"
|
||||||
cell.textLabel?.textColor = UIColor(hex: 0xEF4444)
|
cell.textLabel?.textColor = UIColor(hex: 0xEF4444)
|
||||||
cell.textLabel?.textAlignment = .center
|
cell.textLabel?.textAlignment = .center
|
||||||
cell.selectionStyle = .default
|
cell.selectionStyle = .default
|
||||||
case ProfileRowID.name:
|
cell.accessoryType = .none
|
||||||
cell.textLabel?.text = "姓名"
|
return cell
|
||||||
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
|
|
||||||
default:
|
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)
|
applyTableSnapshot(animated: false)
|
||||||
}
|
}
|
||||||
@ -296,7 +328,7 @@ final class ProfileViewController: UIViewController, UITableViewDelegate {
|
|||||||
accountContext: self.appServices.accountContext,
|
accountContext: self.appServices.accountContext,
|
||||||
permissionContext: self.appServices.permissionContext,
|
permissionContext: self.appServices.permissionContext,
|
||||||
scenicSpotContext: self.appServices.scenicSpotContext,
|
scenicSpotContext: self.appServices.scenicSpotContext,
|
||||||
appRouter: self.appServices.appRouter,
|
appNavigator: self.appServices.appNavigator,
|
||||||
toastCenter: self.appServices.toastCenter
|
toastCenter: self.appServices.toastCenter
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@ -311,11 +343,11 @@ final class ProfileViewController: UIViewController, UITableViewDelegate {
|
|||||||
case ProfileRowID.logout:
|
case ProfileRowID.logout:
|
||||||
logoutTapped()
|
logoutTapped()
|
||||||
case ProfileRowID.account:
|
case ProfileRowID.account:
|
||||||
HomeMenuRouting.pushProfile(.accountSwitch, from: self)
|
appServices.appNavigator.push(.profile(.accountSwitch), from: self)
|
||||||
case ProfileRowID.realName:
|
case ProfileRowID.realName:
|
||||||
HomeMenuRouting.pushProfile(.realNameAuth, from: self)
|
appServices.appNavigator.push(.profile(.realNameAuth), from: self)
|
||||||
case ProfileRowID.settings:
|
case ProfileRowID.settings:
|
||||||
HomeMenuRouting.pushProfile(.settings, from: self)
|
appServices.appNavigator.push(.profile(.settings), from: self)
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@ -98,7 +98,7 @@ final class SettingsViewController: SimpleTableDiffableViewController {
|
|||||||
guard let action = rowActions[row] else { return }
|
guard let action = rowActions[row] else { return }
|
||||||
switch action {
|
switch action {
|
||||||
case .agreement(let page):
|
case .agreement(let page):
|
||||||
HomeMenuRouting.pushProfile(.agreement(page), from: self)
|
appServices.appNavigator.push(.profile(.agreement(page)), from: self)
|
||||||
case .download:
|
case .download:
|
||||||
copyDownloadLink()
|
copyDownloadLink()
|
||||||
case .version:
|
case .version:
|
||||||
|
|||||||
@ -7,11 +7,14 @@
|
|||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// 窗口 Scene 代理,负责创建根窗口并挂载 `RootViewController`。
|
/// 窗口 Scene 代理,负责创建根窗口并挂载会话协调器。
|
||||||
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
|
|
||||||
var window: UIWindow?
|
var window: UIWindow?
|
||||||
|
|
||||||
|
/// 持有会话协调器,确保登录后切换 window 根控制器时仍能响应登出等阶段变化。
|
||||||
|
private var sessionRootController: RootViewController?
|
||||||
|
|
||||||
/// Scene 连接时初始化窗口、重置 UI 测试状态并展示根控制器。
|
/// Scene 连接时初始化窗口、重置 UI 测试状态并展示根控制器。
|
||||||
func scene(
|
func scene(
|
||||||
_ scene: UIScene,
|
_ scene: UIScene,
|
||||||
@ -22,8 +25,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||||||
|
|
||||||
AppUITestLaunchState.resetIfNeeded()
|
AppUITestLaunchState.resetIfNeeded()
|
||||||
|
|
||||||
|
let rootViewController = RootViewController()
|
||||||
|
sessionRootController = rootViewController
|
||||||
|
|
||||||
let window = UIWindow(windowScene: windowScene)
|
let window = UIWindow(windowScene: windowScene)
|
||||||
window.rootViewController = RootViewController()
|
window.rootViewController = rootViewController
|
||||||
window.makeKeyAndVisible()
|
window.makeKeyAndVisible()
|
||||||
self.window = window
|
self.window = window
|
||||||
}
|
}
|
||||||
|
|||||||
58
suixinkan_iosTests/MainScanPlusButtonTests.swift
Normal file
58
suixinkan_iosTests/MainScanPlusButtonTests.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,93 +5,167 @@
|
|||||||
// Created by Codex on 2026/6/22.
|
// Created by Codex on 2026/6/22.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
import XCTest
|
import XCTest
|
||||||
@testable import suixinkan_ios
|
@testable import suixinkan_ios
|
||||||
|
|
||||||
/// 主导航路由测试,覆盖 AppRoute 的跨页面导航策略。
|
/// UIKit 主导航器测试,覆盖 Tab 宿主、全局入口和当前栈 push 行为。
|
||||||
@MainActor
|
@MainActor
|
||||||
final class NavigationRouterTests: XCTestCase {
|
final class AppNavigatorTests: XCTestCase {
|
||||||
/// 测试通过 NavigationStack push 的子页面默认隐藏底部 TabBar。
|
/// 测试当前栈 push 会增加选中导航栈层级,并隐藏底部 TabBar。
|
||||||
func testPushedRoutesHideTabBarByDefault() {
|
func testPushUsesCurrentNavigationStack() {
|
||||||
XCTAssertTrue(AppRoute.placeholder(title: "详情").hidesTabBarWhenPushed)
|
let services = AppServices(apiClient: APIClient(session: AppNavigatorRecordingURLSession()))
|
||||||
XCTAssertTrue(AppRoute.home(.moreFunctions).hidesTabBarWhenPushed)
|
let host = AppNavigatorTestHost(services: services)
|
||||||
XCTAssertTrue(AppRoute.home(.paymentCollection).hidesTabBarWhenPushed)
|
let navigator = AppNavigator()
|
||||||
XCTAssertTrue(AppRoute.home(.wallet).hidesTabBarWhenPushed)
|
navigator.attach(host: host)
|
||||||
XCTAssertTrue(AppRoute.orders(.storeDetail(try! Self.firstOrder())).hidesTabBarWhenPushed)
|
|
||||||
XCTAssertTrue(AppRoute.orders(.writeOffDetail(try! Self.firstWriteOffOrder())).hidesTabBarWhenPushed)
|
navigator.push(.home(.paymentCollection), from: nil, animated: false)
|
||||||
XCTAssertTrue(AppRoute.orders(.multiTravelTaskUpload(orderNumber: "MT001")).hidesTabBarWhenPushed)
|
|
||||||
XCTAssertTrue(AppRoute.orders(.orderTrailer(orderNumber: "MT001", title: "尾片上传")).hidesTabBarWhenPushed)
|
let homeStack = host.navigationController(for: .home)!
|
||||||
|
XCTAssertEqual(homeStack.viewControllers.count, 2)
|
||||||
|
XCTAssertTrue(homeStack.viewControllers.last?.hidesBottomBarWhenPushed == true)
|
||||||
|
XCTAssertTrue(homeStack.viewControllers.last is PaymentCollectionViewController)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试切换订单 Tab 时会保存订单内部子入口。
|
/// 测试 tabRoot 会切换到指定 Tab、清空旧栈并打开目标页。
|
||||||
func testSelectOrdersUpdatesTabAndEntry() {
|
func testTabRootSelectsTabResetsStackAndPushesRoute() {
|
||||||
let router = AppRouter()
|
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)
|
let homeStack = host.navigationController(for: .home)!
|
||||||
XCTAssertEqual(router.selectedOrdersEntry, .verificationOrders)
|
XCTAssertEqual(host.selectedTab, .home)
|
||||||
|
XCTAssertEqual(homeStack.viewControllers.count, 2)
|
||||||
|
XCTAssertTrue(homeStack.viewControllers.last is TaskManagementViewController)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试全局扫码核销路由会切到订单核销入口并保存扫码结果。
|
/// 测试 preserveTab 只切换 Tab 并保留旧栈。
|
||||||
func testRouteToOrderVerificationStoresPendingScanCode() {
|
func testPreserveTabKeepsExistingStack() {
|
||||||
let router = AppRouter()
|
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)
|
let profileStack = host.navigationController(for: .profile)!
|
||||||
XCTAssertEqual(router.selectedOrdersEntry, .verificationOrders)
|
XCTAssertEqual(host.selectedTab, .profile)
|
||||||
XCTAssertEqual(router.pendingOrderScanCode, "order_number=VERIFY001")
|
XCTAssertEqual(profileStack.viewControllers.count, 3)
|
||||||
|
XCTAssertTrue(profileStack.viewControllers[1] is SettingsViewController)
|
||||||
|
XCTAssertTrue(profileStack.viewControllers.last is RealNameAuthViewController)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试全局扫码结果只能被消费一次。
|
/// 测试宿主未挂载时全局请求会排队,并在 attach 后按顺序执行。
|
||||||
func testConsumePendingOrderScanCodeClearsValueAfterFirstRead() {
|
func testPendingActionsRunAfterAttachInFIFOOrder() {
|
||||||
let router = AppRouter()
|
let services = AppServices(apiClient: APIClient(session: AppNavigatorRecordingURLSession()))
|
||||||
router.routeToOrderVerification(scannedCode: "VERIFY001")
|
let host = AppNavigatorTestHost(services: services)
|
||||||
|
let navigator = AppNavigator()
|
||||||
|
|
||||||
XCTAssertEqual(router.consumePendingOrderScanCode(), "VERIFY001")
|
navigator.openHome(.paymentCollection, policy: .tabRoot(.home), animated: false)
|
||||||
XCTAssertNil(router.consumePendingOrderScanCode())
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试重置主路由时会清理未消费的扫码结果。
|
/// 测试 resetAllStacks 会清空暂存请求并重置已挂载宿主。
|
||||||
func testResetClearsPendingOrderScanCode() {
|
func testResetAllStacksClearsPendingAndMountedStacks() {
|
||||||
let router = AppRouter()
|
let services = AppServices(apiClient: APIClient(session: AppNavigatorRecordingURLSession()))
|
||||||
router.routeToOrderVerification(scannedCode: "VERIFY001")
|
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)
|
navigator.openHome(.wallet, policy: .tabRoot(.home), animated: false)
|
||||||
XCTAssertEqual(router.selectedOrdersEntry, .storeOrders)
|
navigator.resetAllStacks()
|
||||||
XCTAssertNil(router.pendingOrderScanCode)
|
XCTAssertEqual(host.navigationController(for: .home)?.viewControllers.count, 1)
|
||||||
|
XCTAssertEqual(host.selectedTab, .home)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试订单模块真实路由可被压入导航栈。
|
/// 测试打开订单入口会切到订单 Tab、回到根页面并传递扫码结果。
|
||||||
func testOrderRoutesCanBePushedIntoRouterPath() throws {
|
func testOpenOrdersEntryAppliesRootStateAndScanCode() {
|
||||||
let router = RouterPath()
|
let services = AppServices(apiClient: APIClient(session: AppNavigatorRecordingURLSession()))
|
||||||
let order = try Self.firstOrder()
|
let host = AppNavigatorTestHost(services: services)
|
||||||
let writeOff = try Self.firstWriteOffOrder()
|
let navigator = AppNavigator()
|
||||||
|
navigator.attach(host: host)
|
||||||
|
|
||||||
router.navigate(to: .orders(.storeDetail(order)))
|
navigator.openOrdersEntry(.verificationOrders, scannedCode: "VERIFY001")
|
||||||
router.navigate(to: .orders(.writeOffDetail(writeOff)))
|
|
||||||
router.navigate(to: .orders(.multiTravelTaskUpload(orderNumber: "MT001")))
|
|
||||||
router.navigate(to: .orders(.orderTrailer(orderNumber: "MT001", title: "视频预告")))
|
|
||||||
|
|
||||||
XCTAssertEqual(router.path.count, 4)
|
XCTAssertEqual(host.selectedTab, .orders)
|
||||||
XCTAssertEqual(router.path[0], .orders(.storeDetail(order)))
|
XCTAssertEqual(host.appliedOrdersEntry, .verificationOrders)
|
||||||
XCTAssertEqual(router.path[1], .orders(.writeOffDetail(writeOff)))
|
XCTAssertEqual(host.appliedScanCode, "VERIFY001")
|
||||||
XCTAssertEqual(router.path[2], .orders(.multiTravelTaskUpload(orderNumber: "MT001")))
|
XCTAssertEqual(host.navigationController(for: .orders)?.viewControllers.count, 1)
|
||||||
XCTAssertEqual(router.path[3], .orders(.orderTrailer(orderNumber: "MT001", title: "视频预告")))
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 返回测试用订单管理列表项。
|
/// AppNavigator 单测专用宿主,模拟主 Tab 容器能力。
|
||||||
private static func firstOrder() throws -> OrderEntity {
|
@MainActor
|
||||||
let payload = try TestFixture.payload(ListPayload<OrderEntity>.self, named: "order_v2_success")
|
private final class AppNavigatorTestHost: AppNavigationHost {
|
||||||
return try XCTUnwrap(payload.list.first)
|
private let services: AppServices
|
||||||
}
|
private var controllers: [AppTab: TabNavigationController] = [:]
|
||||||
|
var selectedTab: AppTab = .home
|
||||||
/// 返回测试用核销订单列表项。
|
var appliedOrdersEntry: OrdersEntry?
|
||||||
private static func firstWriteOffOrder() throws -> WriteOffOrderItem {
|
var appliedScanCode: String?
|
||||||
let payload = try TestFixture.payload(ListPayload<WriteOffOrderItem>.self, named: "writeoff_list_success")
|
|
||||||
return try XCTUnwrap(payload.list.first)
|
/// 初始化所有 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -65,51 +65,98 @@ final class PushNotificationTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testPushManagerRoutesHomeDestinationsThroughHomeNavigationStack() {
|
func testPushManagerRoutesHomeDestinationsThroughHomeNavigationStack() {
|
||||||
let router = AppRouter()
|
let services = AppServices(apiClient: APIClient(session: PushRecordingURLSession()))
|
||||||
let manager = pushManager(router: router)
|
let host = PushNavigationHost(services: services)
|
||||||
|
let manager = pushManager(navigator: services.appNavigator)
|
||||||
|
services.appNavigator.attach(host: host)
|
||||||
|
|
||||||
manager.handleRemoteNotification(PushPayload(userInfo: ["route": "payment_qr"]))
|
manager.handleRemoteNotification(PushPayload(userInfo: ["route": "payment_qr"]))
|
||||||
XCTAssertEqual(router.selectedTab, .home)
|
XCTAssertEqual(host.selectedTab, .home)
|
||||||
XCTAssertEqual(router.router(for: .home).path, [.home(.paymentCollection)])
|
XCTAssertTrue(host.navigationController(for: .home)?.viewControllers.last is PaymentCollectionViewController)
|
||||||
|
|
||||||
router.router(for: .home).reset()
|
|
||||||
manager.handleRemoteNotification(PushPayload(userInfo: ["route": "task_management"]))
|
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"]))
|
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": "未知通知"]))
|
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() {
|
func testPushManagerRoutesOrdersEntry() {
|
||||||
let router = AppRouter()
|
let services = AppServices(apiClient: APIClient(session: PushRecordingURLSession()))
|
||||||
let manager = pushManager(router: router)
|
let host = PushNavigationHost(services: services)
|
||||||
|
let manager = pushManager(navigator: services.appNavigator)
|
||||||
|
services.appNavigator.attach(host: host)
|
||||||
|
|
||||||
manager.handleRemoteNotification(PushPayload(userInfo: ["route": "photographer_orders"]))
|
manager.handleRemoteNotification(PushPayload(userInfo: ["route": "photographer_orders"]))
|
||||||
XCTAssertEqual(router.selectedTab, .orders)
|
XCTAssertEqual(host.selectedTab, .orders)
|
||||||
XCTAssertEqual(router.selectedOrdersEntry, .storeOrders)
|
XCTAssertEqual(host.appliedOrdersEntry, .storeOrders)
|
||||||
|
|
||||||
manager.handleRemoteNotification(PushPayload(userInfo: ["route": "verification_order"]))
|
manager.handleRemoteNotification(PushPayload(userInfo: ["route": "verification_order"]))
|
||||||
XCTAssertEqual(router.selectedTab, .orders)
|
XCTAssertEqual(host.selectedTab, .orders)
|
||||||
XCTAssertEqual(router.selectedOrdersEntry, .verificationOrders)
|
XCTAssertEqual(host.appliedOrdersEntry, .verificationOrders)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func pushManager(router: AppRouter) -> PushNotificationManager {
|
private func pushManager(navigator: AppNavigator) -> PushNotificationManager {
|
||||||
let session = AppSession()
|
let session = AppSession()
|
||||||
let manager = PushNotificationManager()
|
let manager = PushNotificationManager()
|
||||||
manager.configure(
|
manager.configure(
|
||||||
api: PushAPI(client: APIClient(session: PushRecordingURLSession())),
|
api: PushAPI(client: APIClient(session: PushRecordingURLSession())),
|
||||||
session: session,
|
session: session,
|
||||||
router: router
|
navigator: navigator
|
||||||
)
|
)
|
||||||
return manager
|
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 final class PushRecordingURLSession: URLSessionProtocol {
|
||||||
private(set) var requests: [URLRequest] = []
|
private(set) var requests: [URLRequest] = []
|
||||||
|
|
||||||
|
|||||||
@ -28,8 +28,8 @@
|
|||||||
|
|
||||||
| 状态 | 模块 | UIKit 实现 | 剩余事项 |
|
| 状态 | 模块 | UIKit 实现 | 剩余事项 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 完成 | App 根结构 | `RootViewController` 按 `AppSession.phase` 切换登录 / 恢复 / 主 Tab | 持续随路由扩展 |
|
| 完成 | App 根结构 | `RootViewController` 监听登录阶段;登录后 `MainTabBarController` 作为 window 根控制器 | 持续随路由扩展 |
|
||||||
| 完成 | Tab 导航 | 系统 `UITabBarController` 四 Tab + 独立 `TabNavigationController`;push 子页隐藏 TabBar。 | 后续新增真实子页面时继续复用 `AppRoute`。 |
|
| 完成 | Tab 导航 | CYLTabBarController 四 Tab + 中间扫码按钮 + 独立 `TabNavigationController`;push 子页隐藏 TabBar。 | 后续新增真实子页面时继续复用 `AppRoute`。 |
|
||||||
| 完成 | 全局状态 | `AppServices.shared` 注入 Session / Context / Router / Toast / Loading | 避免新增 monolith 状态 |
|
| 完成 | 全局状态 | `AppServices.shared` 注入 Session / Context / Router / Toast / Loading | 避免新增 monolith 状态 |
|
||||||
| 完成 | 登录态缓存 | Keychain token + UserDefaults 快照 / 偏好 + 冷启动恢复 | — |
|
| 完成 | 登录态缓存 | Keychain token + UserDefaults 快照 / 偏好 + 冷启动恢复 | — |
|
||||||
| 完成 | 网络框架 | `APIClient` / `APIEnvelope` / 统一错误与 token | — |
|
| 完成 | 网络框架 | `APIClient` / `APIEnvelope` / 统一错误与 token | — |
|
||||||
|
|||||||
Reference in New Issue
Block a user