Initial commit: suixinkan_ios UIKit rewrite project.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 14:33:31 +08:00
commit 9edf993432
297 changed files with 47151 additions and 0 deletions

View File

@ -0,0 +1,36 @@
//
// AMapBootstrap.swift
// suixinkan
//
// SDK AMAP_ENABLED AMap
//
import Foundation
#if AMAP_ENABLED
import AMapFoundationKit
import AMapLocationKit
import AMapSearchKit
import MAMapKit
enum AMapBootstrap {
/// App `apiKey` Key
static func configure(apiKey: String) {
guard !apiKey.isEmpty else { return }
AMapServices.shared().apiKey = apiKey
AMapServices.shared().enableHTTPS = true
AMapSearchAPI.updatePrivacyShow(.didShow, privacyInfo: .didContain)
AMapSearchAPI.updatePrivacyAgree(.didAgree)
MAMapView.updatePrivacyShow(.didShow, privacyInfo: .didContain)
MAMapView.updatePrivacyAgree(.didAgree)
AMapLocationManager.updatePrivacyShow(.didShow, privacyInfo: .didContain)
AMapLocationManager.updatePrivacyAgree(.didAgree)
}
}
#else
enum AMapBootstrap {
static func configure(apiKey: String) {}
}
#endif

79
suixinkan_ios/App/App.md Normal file
View File

@ -0,0 +1,79 @@
# App 模块业务逻辑
## 模块职责
App 模块负责应用入口、根视图切换、全局状态注入、主导航状态、登录态恢复和全局 Toast。
该模块不直接处理具体页面业务,主要承担 App 级基础设施编排:
- 根据登录状态展示登录页、恢复态或主 Tab。
- 创建并注入全局状态和共享服务。
- 管理每个 Tab 独立的 `NavigationStack` 路径。
- 协调登录成功、退出登录、冷启动恢复和本地缓存同步。
## 核心对象
- `suixinkanApp`SwiftUI 应用入口,挂载 `RootView`
- `RootView`:创建 `AppSession``AccountContext``PermissionContext``ScenicSpotContext``AppRouter``ToastCenter``APIClient` 和业务 API并注入 SwiftUI Environment。
- `AppSession`:保存认证阶段和正式 token只负责登录态不承载业务资料。
- `AccountContext`:保存当前账号资料、景区作用域和门店作用域。
- `PermissionContext`:保存角色权限、当前角色和扁平化权限 URI。
- `ScenicSpotContext`:保存当前景区下的景点/打卡点列表和加载状态。
- `AppRouter`:保存当前 Tab 和每个 Tab 自己的导航路径。
- `ToastCenter`:管理当前全局 Toast 文案和自动隐藏任务。
- `AuthSessionCoordinator`:统一处理登录完成、退出登录、偏好读取和账号快照刷新。
- `SessionBootstrapper`:冷启动时读取本地 token 和账号快照,并向服务端校验登录态。
- `AccountContextLoader`:统一同步用户资料、角色权限、景区和门店。
## 启动流程
1. `suixinkanApp` 创建 `RootView`
2. `RootView` 初始化共享依赖,并把它们注入 Environment。
3. `APIClient` 绑定 `AppSession.token` 作为默认 token provider。
4. `SessionBootstrapper.restore` 尝试从 Keychain 读取正式 token。
5. 无 token 时保持 `loggedOut`,展示 `LoginView`
6. 有 token 时进入 `restoring`,先恢复本地账号快照。
7. `AccountContextLoader` 并行请求用户资料和角色权限,再补全景区与门店。
8. 校验成功后进入 `loggedIn`,展示 `MainTabsView`
9. `ScenicSpotContext` 按当前景区懒加载景点/打卡点。
10. 明确 token 失效时清空 token 和账号快照,回到登录页。
11. 普通网络失败时保留本地登录态,使用账号快照进入主界面。
## UI Test 启动约定
- `AppUITestLaunchState` 在收到 `-suixinkan-ui-tests` 时跳过推送注册和排队 WebSocket避免系统弹窗干扰自动化。
- `-suixinkan-ui-tests-reset-state` 用于冷启动清理 Keychain 与 UserDefaults。
- `-suixinkan-ui-tests-open-menu <菜单标题>` 登录后直达首页调试目录中的目标页。
- `-suixinkan-ui-tests-open-profile <路由名>` 登录后直达个人中心二级页(如 `settings``realNameAuth`)。
- `AppUITestRouteDriver` 仅在 DEBUG 构建下解析上述直达参数,供 XCUITest 逐页验证。
- 详细运行方式见 `suixinkanUITests/README.md`
## 登录和退出
登录完成由 `AuthSessionCoordinator.completeLogin` 统一处理:
- 正式 token 写入 `SessionTokenStore`
- 上次手机号和协议状态写入 `AppPreferencesStore`
- 账号资料、景区列表、门店列表写入 `AccountContext`
- 角色权限和当前角色写入 `PermissionContext`
- 非敏感账号快照写入 `AccountSnapshotStore`
- `AppSession` 切换为 `loggedIn`
退出登录由 `AuthSessionCoordinator.logout` 统一处理:
- 清空 Keychain token。
- 清空账号快照。
- 重置账号上下文、权限上下文、景点上下文、导航路径和 Toast。
- `AppSession` 切换为 `loggedOut`
- 保留上次手机号、协议状态等非敏感偏好。
## Toast
全局 Toast 由 `RootView` 挂载在页面最上层,业务页面只调用 `toastCenter.show(...)` 发出提示命令。
Toast 展示为顶部全宽横幅,背景使用不透明主色并延伸到屏幕顶部、左边和右边;文案居中展示,不提供关闭按钮,默认 2.2 秒后自动消失。连续展示新 Toast 时会覆盖旧文案并重新计时,旧的自动隐藏任务不会影响新的 Toast。
## 导航规则
主界面使用 `TabView`,每个 Tab 内部由单独的 `NavigationStack` 包裹。`AppRouter` 为每个 `AppTab` 持有独立 `RouterPath`,切换 Tab 不会丢失该 Tab 的内部导航路径。
Tab 根页面显示底部 TabBar。通过 `AppRoute` push 到子页面时,`MainTabsView` 会根据 `AppRoute.hidesTabBarWhenPushed` 统一隐藏 TabBar避免每个业务页面重复处理。
当前路由枚举 `AppRoute` 仍以占位详情页为主,后续新增真实页面时应优先扩展 `AppRoute`,再由对应 Tab 的 `NavigationStack` 处理跳转。

View File

@ -0,0 +1,123 @@
//
// AppServices.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import Foundation
@MainActor
/// ViewModel ViewController API
final class AppServices {
static let shared = AppServices()
let appSession = AppSession()
let accountContext = AccountContext()
let permissionContext = PermissionContext()
let scenicSpotContext = ScenicSpotContext()
let appRouter = AppRouter()
let toastCenter = ToastCenter()
let globalLoading = GlobalLoadingCenter()
let scenicQueueRuntime = ScenicQueueRuntime()
let apiClient: APIClient
let authAPI: AuthAPI
let profileAPI: ProfileAPI
let uploadAPI: UploadAPI
let ossUploadService: OSSUploadService
let accountContextAPI: AccountContextAPI
let ordersAPI: OrdersAPI
let statisticsAPI: StatisticsAPI
let paymentAPI: PaymentAPI
let walletAPI: WalletAPI
let pushAPI: PushAPI
let scenicPermissionAPI: ScenicPermissionAPI
let scenicSettlementAPI: ScenicSettlementAPI
let messageCenterAPI: MessageCenterAPI
let scenicQueueAPI: ScenicQueueAPI
let liveAPI: LiveAPI
let operatingAreaAPI: OperatingAreaAPI
let pilotCertificationAPI: PilotCertificationAPI
let taskAPI: TaskAPI
let projectAPI: ProjectAPI
let scheduleAPI: ScheduleAPI
let inviteAPI: InviteAPI
let assetsAPI: AssetsAPI
let punchPointAPI: PunchPointAPI
let locationReportAPI: LocationReportAPI
let authSessionCoordinator: AuthSessionCoordinator
let sessionBootstrapper: SessionBootstrapper
private let tokenStore: SessionTokenStore
private let snapshotStore: AccountSnapshotStore
/// ID
var currentScenicId: Int? {
accountContext.currentScenic?.id
}
/// staffId
var staffId: Int? {
Int(accountContext.profile?.userId ?? "")
}
/// ID
var userId: String? {
accountContext.profile?.userId
}
private init() {
let client = APIClient()
let tokenStore = SessionTokenStore()
let snapshotStore = AccountSnapshotStore()
self.tokenStore = tokenStore
self.snapshotStore = snapshotStore
apiClient = client
authAPI = AuthAPI(client: client)
profileAPI = ProfileAPI(client: client)
uploadAPI = UploadAPI(client: client)
ossUploadService = OSSUploadService(configService: uploadAPI)
accountContextAPI = AccountContextAPI(client: client)
ordersAPI = OrdersAPI(client: client)
statisticsAPI = StatisticsAPI(client: client)
paymentAPI = PaymentAPI(client: client)
walletAPI = WalletAPI(client: client)
pushAPI = PushAPI(client: client)
scenicPermissionAPI = ScenicPermissionAPI(client: client)
scenicSettlementAPI = ScenicSettlementAPI(client: client)
messageCenterAPI = MessageCenterAPI(client: client)
scenicQueueAPI = ScenicQueueAPI(client: client)
liveAPI = LiveAPI(client: client)
operatingAreaAPI = OperatingAreaAPI(client: client)
pilotCertificationAPI = PilotCertificationAPI(client: client)
taskAPI = TaskAPI(client: client)
projectAPI = ProjectAPI(client: client)
scheduleAPI = ScheduleAPI(client: client)
inviteAPI = InviteAPI(client: client)
assetsAPI = AssetsAPI(client: client)
punchPointAPI = PunchPointAPI(client: client)
locationReportAPI = LocationReportAPI(client: client)
authSessionCoordinator = AuthSessionCoordinator(
tokenStore: tokenStore,
snapshotStore: snapshotStore,
preferencesStore: AppPreferencesStore()
)
sessionBootstrapper = SessionBootstrapper(
tokenStore: tokenStore,
snapshotStore: snapshotStore
)
apiClient.bindAuthTokenProvider { [unowned self] in
appSession.token
}
}
/// UIKit
func configurePushNotifications() {
PushNotificationManager.shared.configure(
api: pushAPI,
session: appSession,
router: appRouter
)
}
}

View File

@ -0,0 +1,55 @@
//
// AppUITestLaunchState.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import Foundation
/// UI
enum AppUITestLaunchState {
/// UI Test
static let uiTestsArgument = "-suixinkan-ui-tests"
///
static let resetArgument = "-suixinkan-ui-tests-reset-state"
///
static let openMenuArgument = "-suixinkan-ui-tests-open-menu"
///
static let openProfileArgument = "-suixinkan-ui-tests-open-profile"
/// XCUITest
static var isRunningUITests: Bool {
ProcessInfo.processInfo.arguments.contains(uiTestsArgument)
}
///
static var pendingMenuTitle: String? {
argumentValue(following: openMenuArgument)
}
///
static var pendingProfileRouteRawValue: String? {
argumentValue(following: openProfileArgument)
}
/// flag
private static func argumentValue(following flag: String) -> String? {
let arguments = ProcessInfo.processInfo.arguments
guard let index = arguments.firstIndex(of: flag), index + 1 < arguments.count else {
return nil
}
let value = arguments[index + 1].trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
/// token
static func resetIfNeeded(arguments: [String] = ProcessInfo.processInfo.arguments) {
guard arguments.contains(resetArgument) else { return }
try? SessionTokenStore().clear()
AccountSnapshotStore().clear()
let preferences = AppPreferencesStore()
preferences.clear()
}
}

View File

@ -0,0 +1,95 @@
//
// AppUITestRouteDriver.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
#if DEBUG
import UIKit
/// UI Test TabBar XCUITest push
@MainActor
enum AppUITestRouteDriver {
private static var didApply = false
///
static func applyIfNeeded(services: AppServices = .shared) async {
guard AppUITestLaunchState.isRunningUITests, !didApply else { return }
guard AppUITestLaunchState.pendingMenuTitle != nil
|| AppUITestLaunchState.pendingProfileRouteRawValue != nil else { return }
didApply = true
try? await Task.sleep(nanoseconds: 1_200_000_000)
if let menuTitle = AppUITestLaunchState.pendingMenuTitle {
openHomeMenu(title: menuTitle, services: services)
return
}
if let rawValue = AppUITestLaunchState.pendingProfileRouteRawValue {
openProfileRoute(rawValue: rawValue, services: services)
}
}
///
private static func openHomeMenu(title: String, services: AppServices) {
let resolvedTitle = resolveMenuTitle(title)
guard let item = HomeMenuRouter.debugAllMenuItems().first(where: { $0.title == resolvedTitle }) else {
return
}
services.appRouter.reset()
let resolved = HomeMenuRouter.resolve(uri: item.uri, title: item.title)
switch resolved {
case .tab(let tab):
services.appRouter.select(tab)
case .orders(let entry):
services.appRouter.selectOrders(entry: entry)
case .destination(let homeRoute):
services.appRouter.navigateHome(homeRoute, animated: false)
case .unsupported(let uri, let title, _):
services.appRouter.navigateHome(.modulePlaceholder(uri: uri, title: title), animated: false)
case .placeholder(let uri, let title):
services.appRouter.navigateHome(.modulePlaceholder(uri: uri, title: title), animated: false)
}
}
///
private static func openProfileRoute(rawValue: String, services: AppServices) {
let route: ProfileRoute?
switch rawValue {
case "settings":
route = .settings
case "realNameAuth":
route = .realNameAuth
case "accountSwitch":
route = .accountSwitch
case "debugHomeMenus":
route = .debugHomeMenus
case "agreement.about":
route = .agreement(.about)
case "agreement.userAgreement":
route = .agreement(.userAgreement)
case "agreement.privacyPolicy":
route = .agreement(.privacyPolicy)
default:
route = nil
}
guard let route else { return }
services.appRouter.reset()
services.appRouter.navigateProfile(route, animated: false)
}
/// UI Test
private static func resolveMenuTitle(_ title: String) -> String {
switch title {
case "系统设置":
return "设置中心"
default:
return title
}
}
}
#endif

View File

@ -0,0 +1,181 @@
//
// AppRouteViewControllerFactory.swift
// suixinkan
//
import UIKit
@MainActor
/// ViewController `AppRoute` UIKit
enum AppRouteViewControllerFactory {
/// ViewController使
static func makeViewController(for route: AppRoute, services: AppServices) -> UIViewController {
switch route {
case .placeholder(let title):
return PlaceholderViewController(title: title)
case .home(let homeRoute):
return makeViewController(for: homeRoute, services: services)
case .profile(let profileRoute):
return makeViewController(for: profileRoute, services: services)
case .orders(let ordersRoute):
return makeViewController(for: ordersRoute, services: services)
}
}
/// ViewController
static func makeViewController(for route: HomeRoute, services: AppServices) -> UIViewController {
switch route {
case .profileSpace:
return ProfileViewController()
case .scenicSelection:
return ScenicSelectionViewController()
case .permissionApply:
return PermissionApplyViewController()
case .permissionApplyStatus:
return PermissionApplyStatusViewController()
case .scenicApplication:
return ScenicApplicationViewController()
case .moreFunctions:
return HomeMoreFunctionsViewController()
case .settings:
return SettingsViewController()
case .paymentCollection:
return PaymentCollectionViewController()
case .wallet:
return WalletViewController()
case .taskManagement:
return TaskManagementViewController()
case .taskCreate:
return TaskCreateViewController()
case .taskDetail(let id, let summary):
return TaskDetailViewController(taskId: id, summary: summary)
case .projectManagement:
return ProjectManagementViewController()
case .pmProjectManagement:
return StoreProjectManagementViewController()
case .projectDetail(let id, let storeMode):
return ProjectDetailViewController(projectId: id, storeMode: storeMode)
case .projectEditor(let id, let storeMode):
if storeMode {
return StoreProjectEditorViewController(projectId: id)
}
return ProjectEditorViewController(projectId: id)
case .scheduleManagement:
return ScheduleManagementViewController()
case .scheduleAdd:
return ScheduleAddViewController()
case .photographerInvite:
return PhotographerInviteViewController()
case .inviteRecord:
return InviteRecordViewController()
case .cloudStorage:
return CloudStorageViewController()
case .cloudStorageTransit:
return CloudStorageTransitViewController()
case .materialLibrary:
return MediaLibraryViewController(kind: .material)
case .materialUpload:
return MediaLibraryUploadViewController(kind: .material)
case .sampleLibrary:
return MediaLibraryViewController(kind: .sample)
case .sampleUpload:
return MediaLibraryUploadViewController(kind: .sample)
case .albumList:
return AlbumListViewController()
case .albumTrailer:
return AlbumTrailerViewController()
case .punchPointList:
return PunchPointListViewController()
case .punchPointDetail(let id, let summary):
return PunchPointDetailViewController(punchPointId: id, summary: summary)
case .punchPointEditor(let id):
return PunchPointEditorViewController(punchPointId: id)
case .punchPointQR(_, let title, let qrURL):
return PunchPointQRViewController(title: title, qrURL: qrURL)
case .locationReport:
return LocationReportViewController()
case .locationReportHistory:
return LocationReportHistoryViewController()
case .depositOrders:
return DepositOrderListViewController()
case .withdrawalAudit:
return WithdrawalAuditViewController()
case .scenicSettlement:
return ScenicSettlementViewController()
case .scenicSettlementReview:
return ScenicSettlementReviewViewController()
case .messageCenter:
return MessageCenterViewController()
case .queueManagement:
return QueueManagementViewController()
case .liveManagement:
return LiveManagementViewController()
case .liveAlbum:
return LiveAlbumViewController()
case .operatingArea:
return OperatingAreaViewController()
case .pilotCertification:
return PilotCertificationViewController()
case .modulePlaceholder(_, let title):
return FeaturePlaceholderViewController(title: title, uri: homeRouteURI(route))
}
}
/// ViewController
static func makeViewController(for route: ProfileRoute, services: AppServices) -> UIViewController {
switch route {
case .accountSwitch:
return AccountSwitchViewController()
case .realNameAuth:
return RealNameAuthViewController()
case .settings:
return SettingsViewController()
case .agreement(let page):
return AgreementViewController(page: page)
#if DEBUG
case .debugHomeMenus:
return HomeMoreFunctionsViewController()
#endif
}
}
/// ViewController
static func makeViewController(for route: OrdersRoute, services: AppServices) -> UIViewController {
switch route {
case .storeDetail(let item):
return StoreOrderDetailViewController(item: item)
case .writeOffDetail(let item):
return WriteOffOrderDetailViewController(item: item)
case .depositDetail(let orderNumber):
return DepositOrderDetailViewController(orderNumber: orderNumber)
case .depositShootingInfo(let orderNumber, let scenicSpotId, let photogUid):
return DepositOrderShootingInfoViewController(
orderNumber: orderNumber,
scenicSpotId: scenicSpotId,
photogUid: photogUid
)
case .historicalShooting(let orderNumber):
return HistoricalShootingInfoViewController(orderNumber: orderNumber)
case .multiTravelTaskUpload(let orderNumber):
return MultiTravelTaskUploadViewController(initialOrderNumber: orderNumber)
case .orderTrailer(let orderNumber, let title):
return FeaturePlaceholderViewController(title: title, uri: "order_trailer:\(orderNumber)")
}
}
private static func homeRouteURI(_ route: HomeRoute) -> String {
if case let .modulePlaceholder(uri, _) = route {
return uri
}
return String(describing: route)
}
}
@MainActor
/// ViewController `UIKitAppNavigation`
enum HomeRouteViewControllerFactory {
static func make(for route: HomeRoute) -> UIViewController {
AppRouteViewControllerFactory.makeViewController(for: route, services: AppServices.shared)
}
}

View File

@ -0,0 +1,58 @@
//
// AppTab.swift
// suixinkan
//
// Created by Codex on 2026/6/18.
//
import UIKit
/// Tab
enum AppTab: String, CaseIterable, Identifiable, Hashable {
case home
case orders
case statistics
case profile
var id: Self { self }
var title: String {
switch self {
case .home:
"首页"
case .orders:
"订单"
case .statistics:
"数据"
case .profile:
"我的"
}
}
var systemImage: String {
switch self {
case .home:
"house"
case .orders:
"doc.text"
case .statistics:
"chart.bar"
case .profile:
"person"
}
}
/// Tab ViewController
func makeRootViewController(services: AppServices) -> UIViewController {
switch self {
case .home:
return HomeViewController()
case .orders:
return OrdersViewController()
case .statistics:
return StatisticsViewController()
case .profile:
return ProfileViewController()
}
}
}

View File

@ -0,0 +1,109 @@
//
// NavigationRouter.swift
// suixinkan
//
// Created by Codex on 2026/6/18.
//
import Foundation
/// Tab
enum AppRoute: Hashable {
case placeholder(title: String)
case home(HomeRoute)
case profile(ProfileRoute)
case orders(OrdersRoute)
/// push TabBar
var hidesTabBarWhenPushed: Bool {
true
}
}
@MainActor
/// Tab
final class RouterPath {
var onChange: (() -> Void)?
var path: [AppRoute] = [] { didSet { onChange?() } }
/// Tab
func navigate(to route: AppRoute) {
path.append(route)
}
/// Tab
func navigateBack() {
guard !path.isEmpty else { return }
path.removeLast()
}
/// Tab
func reset() {
path = []
}
}
@MainActor
/// Tab Tab
final class AppRouter {
var onChange: (() -> Void)?
var selectedTab: AppTab = .home { didSet { onChange?() } }
var selectedOrdersEntry: OrdersEntry = .storeOrders { didSet { onChange?() } }
private(set) var pendingOrderScanCode: String? { didSet { onChange?() } }
private let routers: [AppTab: RouterPath]
init() {
var builtRouters: [AppTab: RouterPath] = [:]
for tab in AppTab.allCases {
builtRouters[tab] = RouterPath()
}
routers = builtRouters
routers.values.forEach { router in
router.onChange = { [weak self] in
self?.onChange?()
}
}
}
/// Tab
func router(for tab: AppTab) -> RouterPath {
guard let router = routers[tab] else {
assertionFailure("Missing router path for tab: \(tab)")
return RouterPath()
}
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() }
}
}

View File

@ -0,0 +1,111 @@
//
// TabNavigationController.swift
// suixinkan
//
import UIKit
@MainActor
/// Tab `AppRouter` push TabBar
final class TabNavigationController: UINavigationController, UINavigationControllerDelegate {
let appTab: AppTab
private let services: AppServices
private var isSyncingStack = false
weak var tabBarHost: MainTabBarController?
init(tab: AppTab, services: AppServices) {
self.appTab = tab
self.services = services
super.init(nibName: nil, bundle: nil)
delegate = self
navigationBar.prefersLargeTitles = false
setViewControllers([tab.makeRootViewController(services: services)], animated: false)
registerNavigationBridge()
services.appRouter.router(for: appTab).onChange = { [weak self] in
self?.syncFromRouterPath()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// Push RouterPath
func pushRoute(_ route: AppRoute, animated: Bool = true) {
let viewController = AppRouteViewControllerFactory.makeViewController(for: route, services: services)
viewController.hidesBottomBarWhenPushed = route.hidesTabBarWhenPushed
isSyncingStack = true
services.appRouter.router(for: appTab).navigate(to: route)
isSyncingStack = false
pushViewController(viewController, animated: animated)
}
func navigationController(
_ navigationController: UINavigationController,
willShow viewController: UIViewController,
animated: Bool
) {
let isRoot = viewController === viewControllers.first
tabBarHost?.setCustomTabBarHidden(!isRoot, animated: animated)
}
func navigationController(
_ navigationController: UINavigationController,
didShow viewController: UIViewController,
animated: Bool
) {
syncRouterPathFromStack()
}
private func registerNavigationBridge() {
switch appTab {
case .home:
UIKitAppNavigation.homeNavigationController = self
case .profile:
UIKitAppNavigation.profileNavigationController = self
default:
break
}
}
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 {
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 {
if targetDepth == 0 {
popToRootViewController(animated: true)
} else if targetDepth < viewControllers.count {
popToViewController(viewControllers[targetDepth], animated: true)
}
}
}
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
}
}
}

View File

@ -0,0 +1,46 @@
//
// 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)
}
}

View File

@ -0,0 +1,150 @@
//
// AccountContext.swift
// suixinkan
//
// Created by Codex on 2026/6/18.
//
import Foundation
///
struct AccountProfile: Codable, Equatable {
var userId: String
var displayName: String
var phone: String?
var avatarURL: String?
}
///
struct BusinessScope: Codable, Equatable, Identifiable {
///
enum Kind: Codable, Equatable {
case scenic
case store
}
var id: Int
var name: String
var kind: Kind
var parentScenicId: Int?
var status: Int?
var address: String?
var latitude: Double?
var longitude: Double?
var coverURLString: String?
/// parentScenicId
init(
id: Int,
name: String,
kind: Kind,
parentScenicId: Int? = nil,
status: Int? = nil,
address: String? = nil,
latitude: Double? = nil,
longitude: Double? = nil,
coverURLString: String? = nil
) {
self.id = id
self.name = name
self.kind = kind
self.parentScenicId = parentScenicId
self.status = status
self.address = address
self.latitude = latitude
self.longitude = longitude
self.coverURLString = coverURLString
}
}
@MainActor
/// /
final class AccountContext {
var onChange: (() -> Void)?
private(set) var profile: AccountProfile? { didSet { onChange?() } }
private(set) var scenicScopes: [BusinessScope] = [] { didSet { onChange?() } }
private(set) var storeScopes: [BusinessScope] = [] { didSet { onChange?() } }
var currentScenic: BusinessScope? { didSet { onChange?() } }
var currentStore: BusinessScope? { didSet { onChange?() } }
///
func applyLogin(profile: AccountProfile? = nil) {
self.profile = profile
}
///
func replaceProfile(_ profile: AccountProfile?) {
self.profile = profile
}
///
func replaceScopes(
scenic: [BusinessScope],
stores: [BusinessScope],
currentScenicId: Int? = nil,
currentStoreId: Int? = nil
) {
scenicScopes = scenic
storeScopes = stores
currentScenic = currentScenicId.flatMap { id in
scenic.first { $0.id == id }
} ?? currentScenic.flatMap { current in
scenic.first { $0.id == current.id }
} ?? scenic.first
currentStore = resolvedStore(
in: stores,
currentScenic: currentScenic,
preferredStoreId: currentStoreId ?? currentStore?.id
)
}
///
func selectScenic(id scenicId: Int) {
guard let scenic = scenicScopes.first(where: { $0.id == scenicId }) else { return }
currentScenic = scenic
currentStore = resolvedStore(
in: storeScopes,
currentScenic: scenic,
preferredStoreId: currentStore?.id
)
}
///
func selectStore(id storeId: Int) {
guard let store = storeScopes.first(where: { $0.id == storeId }) else { return }
currentStore = store
if let scenicId = store.parentScenicId,
currentScenic?.id != scenicId,
let scenic = scenicScopes.first(where: { $0.id == scenicId }) {
currentScenic = scenic
}
}
/// 退
func reset() {
profile = nil
scenicScopes = []
storeScopes = []
currentScenic = nil
currentStore = nil
}
/// ID
private func resolvedStore(
in stores: [BusinessScope],
currentScenic: BusinessScope?,
preferredStoreId: Int?
) -> BusinessScope? {
let scenicId = currentScenic?.id
let storesForScenic = stores.filter { store in
guard let scenicId else { return true }
return store.parentScenicId == nil || store.parentScenicId == scenicId
}
if let preferredStoreId,
let store = storesForScenic.first(where: { $0.id == preferredStoreId }) {
return store
}
return storesForScenic.first
}
}

View File

@ -0,0 +1,115 @@
//
// AccountContextLoader.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
@MainActor
/// 便
protocol UserProfileServing {
///
func userInfo() async throws -> UserInfoResponse
}
@MainActor
///
struct AccountContextLoader {
///
func refresh(
accountContext: AccountContext,
permissionContext: PermissionContext,
profileAPI: UserProfileServing,
accountContextAPI: AccountContextServing,
cachedCurrentRoleId: Int? = nil,
cachedCurrentScenicId: Int? = nil,
cachedCurrentStoreId: Int? = nil
) async throws {
async let userData = profileAPI.userInfo()
async let roleData = accountContextAPI.rolePermissions()
let (userInfo, rolePermissions) = try await (userData, roleData)
permissionContext.replaceRolePermissions(rolePermissions, currentRoleId: cachedCurrentRoleId)
let scenicResponse = await loadScenicList(accountContextAPI: accountContextAPI, rolePermissions: rolePermissions)
let storesResponse = await loadStores(accountContextAPI: accountContextAPI)
let profile = makeProfile(from: userInfo, fallback: accountContext.profile)
let scenicScopes = scopedScenics(
permissionContext: permissionContext,
scenicResponse: scenicResponse
)
let storeScopes = storesResponse?.list.map { store in
BusinessScope(id: store.id, name: store.name, kind: .store, parentScenicId: store.scenicId)
} ?? []
accountContext.replaceProfile(profile)
accountContext.replaceScopes(
scenic: scenicScopes,
stores: storeScopes,
currentScenicId: cachedCurrentScenicId,
currentStoreId: cachedCurrentStoreId
)
}
///
private func loadScenicList(
accountContextAPI: AccountContextServing,
rolePermissions: [RolePermissionResponse]
) async -> ScenicListAllResponse {
do {
return try await accountContextAPI.scenicListAll()
} catch {
let fallback = uniqueScenics(from: rolePermissions)
return ScenicListAllResponse(total: fallback.count, list: fallback)
}
}
/// nil
private func loadStores(accountContextAPI: AccountContextServing) async -> ListPayload<StoreItem>? {
try? await accountContextAPI.storeAll()
}
/// 访使
private func scopedScenics(
permissionContext: PermissionContext,
scenicResponse: ScenicListAllResponse
) -> [BusinessScope] {
let roleScenics = permissionContext.currentRoleScenicScopes()
if !roleScenics.isEmpty {
return roleScenics
}
return scenicResponse.list.map {
BusinessScope(id: $0.id, name: $0.name, kind: .scenic)
}
}
///
private func uniqueScenics(from rolePermissions: [RolePermissionResponse]) -> [ScenicListItem] {
var seen = Set<Int>()
var result: [ScenicListItem] = []
for permission in rolePermissions {
for scenic in permission.scenic where seen.insert(scenic.id).inserted {
result.append(ScenicListItem(id: scenic.id, name: scenic.name))
}
}
return result
}
///
private func makeProfile(from userInfo: UserInfoResponse, fallback: AccountProfile?) -> AccountProfile {
AccountProfile(
userId: fallback?.userId ?? "",
displayName: nonEmpty(userInfo.nickname) ?? nonEmpty(userInfo.realName) ?? fallback?.displayName ?? "未设置昵称",
phone: nonEmpty(userInfo.phone) ?? fallback?.phone,
avatarURL: nonEmpty(userInfo.avatar) ?? fallback?.avatarURL
)
}
///
private func nonEmpty(_ value: String?) -> String? {
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return text.isEmpty ? nil : text
}
}

View File

@ -0,0 +1,46 @@
//
// AppSession.swift
// suixinkan
//
// Created by Codex on 2026/6/18.
//
import Foundation
///
enum AuthPhase: Equatable {
case loggedOut
case restoring
case loggedIn
}
@MainActor
/// token
final class AppSession {
var onChange: (() -> Void)?
private(set) var phase: AuthPhase = .loggedOut { didSet { onChange?() } }
private(set) var token: String? { didSet { onChange?() } }
var isLoggedIn: Bool {
phase == .loggedIn
}
///
func beginRestoring(token: String? = nil) {
self.token = token?.trimmingCharacters(in: .whitespacesAndNewlines)
phase = .restoring
}
/// token
func markLoggedIn(token: String? = nil) {
self.token = token?.trimmingCharacters(in: .whitespacesAndNewlines)
phase = .loggedIn
}
/// token
func logout() {
token = nil
phase = .loggedOut
}
}

View File

@ -0,0 +1,209 @@
//
// AuthSessionCoordinator.swift
// suixinkan
//
// Created by Codex on 2026/6/20.
//
import Foundation
@MainActor
/// 退
final class AuthSessionCoordinator {
private let tokenStore: SessionTokenStore
private let snapshotStore: AccountSnapshotStore
private let preferencesStore: AppPreferencesStore
/// token
init(
tokenStore: SessionTokenStore,
snapshotStore: AccountSnapshotStore,
preferencesStore: AppPreferencesStore
) {
self.tokenStore = tokenStore
self.snapshotStore = snapshotStore
self.preferencesStore = preferencesStore
}
/// 使
convenience init() {
self.init(
tokenStore: SessionTokenStore(),
snapshotStore: AccountSnapshotStore(),
preferencesStore: AppPreferencesStore()
)
}
///
func loginPreferences() -> LoginPreferences {
LoginPreferences(
lastUsername: preferencesStore.loadLastLoginUsername(),
privacyAgreementAccepted: preferencesStore.loadPrivacyAgreementAccepted()
)
}
/// Keychain token
func completeLogin(
with response: V9AuthResponse,
username: String,
privacyAgreementAccepted: Bool,
appSession: AppSession,
accountContext: AccountContext,
permissionContext: PermissionContext,
profileAPI: UserProfileServing,
accountContextAPI: AccountContextServing
) async throws {
let token = response.token.trimmingCharacters(in: .whitespacesAndNewlines)
try tokenStore.save(token)
preferencesStore.saveLastLoginUsername(username)
preferencesStore.savePrivacyAgreementAccepted(privacyAgreementAccepted)
let profile = response.primaryProfile
let scenicScopes = response.scenicScopes
let storeScopes = response.storeScopes
let identity = response.currentAccountIdentity
accountContext.applyLogin(profile: profile)
accountContext.replaceScopes(scenic: scenicScopes, stores: storeScopes)
appSession.beginRestoring(token: token)
do {
try await AccountContextLoader().refresh(
accountContext: accountContext,
permissionContext: permissionContext,
profileAPI: profileAPI,
accountContextAPI: accountContextAPI,
cachedCurrentScenicId: accountContext.currentScenic?.id,
cachedCurrentStoreId: accountContext.currentStore?.id
)
} catch {
if APIError.isAuthenticationExpired(error) {
try? tokenStore.clear()
snapshotStore.clear()
accountContext.reset()
permissionContext.reset()
appSession.logout()
throw error
}
appSession.markLoggedIn(token: token)
saveSnapshot(
profile: accountContext.profile ?? profile,
accountType: identity?.accountType,
businessUserId: identity?.businessUserId,
permissionContext: permissionContext,
accountContext: accountContext
)
throw error
}
saveSnapshot(
profile: accountContext.profile ?? profile,
accountType: identity?.accountType,
businessUserId: identity?.businessUserId,
permissionContext: permissionContext,
accountContext: accountContext
)
appSession.markLoggedIn(token: token)
}
/// 使
func refreshCachedProfile(from userInfo: UserInfoResponse, accountContext: AccountContext) {
let profile = makeProfile(from: userInfo, fallback: accountContext.profile)
accountContext.replaceProfile(profile)
saveSnapshot(
profile: profile,
accountType: snapshotStore.load()?.accountType,
businessUserId: snapshotStore.load()?.businessUserId,
currentRoleId: snapshotStore.load()?.currentRoleId,
accountContext: accountContext
)
}
/// 退
func logout(
appSession: AppSession,
accountContext: AccountContext,
permissionContext: PermissionContext,
scenicSpotContext: ScenicSpotContext,
appRouter: AppRouter,
toastCenter: ToastCenter
) {
try? tokenStore.clear()
snapshotStore.clear()
accountContext.reset()
permissionContext.reset()
scenicSpotContext.reset()
appRouter.reset()
toastCenter.dismiss()
appSession.logout()
}
///
private func saveSnapshot(
profile: AccountProfile?,
accountType: String?,
businessUserId: Int?,
permissionContext: PermissionContext? = nil,
currentRoleId: Int? = nil,
accountContext: AccountContext
) {
snapshotStore.save(
AccountSnapshot(
profile: profile,
accountType: accountType,
businessUserId: businessUserId,
currentRoleId: permissionContext?.currentRole?.id ?? currentRoleId,
scenicScopes: accountContext.scenicScopes,
storeScopes: accountContext.storeScopes,
currentScenicId: accountContext.currentScenic?.id,
currentStoreId: accountContext.currentStore?.id
)
)
}
///
private func makeProfile(from userInfo: UserInfoResponse, fallback: AccountProfile?) -> AccountProfile {
AccountProfile(
userId: fallback?.userId ?? "",
displayName: nonEmpty(userInfo.nickname) ?? nonEmpty(userInfo.realName) ?? fallback?.displayName ?? "未设置昵称",
phone: nonEmpty(userInfo.phone) ?? fallback?.phone,
avatarURL: nonEmpty(userInfo.avatar) ?? fallback?.avatarURL
)
}
///
private func nonEmpty(_ value: String?) -> String? {
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return text.isEmpty ? nil : text
}
}
///
struct LoginPreferences: Equatable {
let lastUsername: String?
let privacyAgreementAccepted: Bool
}
private extension V9AuthResponse {
///
struct CurrentAccountIdentity {
let accountType: String
let businessUserId: Int
}
/// 使 isCurrent
var currentAccountIdentity: CurrentAccountIdentity? {
if let storeUser = storeUsers.first(where: \.isCurrent) ?? storeUsers.first {
return CurrentAccountIdentity(
accountType: storeUser.accountType,
businessUserId: storeUser.businessUserId
)
}
if let scenicUser = scenicUsers.first(where: \.isCurrent) ?? scenicUsers.first {
return CurrentAccountIdentity(
accountType: scenicUser.accountType,
businessUserId: scenicUser.businessUserId
)
}
return nil
}
}

View File

@ -0,0 +1,92 @@
//
// PermissionContext.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
@MainActor
/// URI
final class PermissionContext {
var onChange: (() -> Void)?
private(set) var rolePermissions: [RolePermissionResponse] = [] { didSet { onChange?() } }
private(set) var permissionURIs: Set<String> = [] { didSet { onChange?() } }
var currentRole: RoleInfo? { didSet { onChange?() } }
/// ID
func replaceRolePermissions(_ rolePermissions: [RolePermissionResponse], currentRoleId: Int? = nil) {
self.rolePermissions = rolePermissions
currentRole = currentRoleId.flatMap { id in
rolePermissions.first { $0.role.id == id }?.role
} ?? currentRole.flatMap { role in
rolePermissions.first { $0.role.id == role.id }?.role
} ?? rolePermissions.first?.role
permissionURIs = Set(Self.flattenPermissions(currentRole?.permission ?? []))
}
///
func selectRole(id roleId: Int, accountContext: AccountContext) {
guard let rolePermission = rolePermissions.first(where: { $0.role.id == roleId }) else { return }
currentRole = rolePermission.role
permissionURIs = Set(Self.flattenPermissions(rolePermission.role.permission))
let scenicScopes = Self.uniqueScenicScopes(from: rolePermission.scenic)
accountContext.replaceScopes(
scenic: scenicScopes,
stores: accountContext.storeScopes,
currentScenicId: accountContext.currentScenic?.id,
currentStoreId: accountContext.currentStore?.id
)
}
/// URI
func canAccess(_ uri: String) -> Bool {
permissionURIs.contains(uri.trimmingCharacters(in: .whitespacesAndNewlines))
}
///
func currentRoleScenicScopes() -> [BusinessScope] {
guard let currentRole else { return [] }
guard let rolePermission = rolePermissions.first(where: { $0.role.id == currentRole.id }) else {
return []
}
return Self.uniqueScenicScopes(from: rolePermission.scenic)
}
/// 退
func reset() {
rolePermissions = []
permissionURIs = []
currentRole = nil
}
/// URI
private static func flattenPermissions(_ permissions: [PermissionItem]) -> [String] {
permissions.flatMap { item -> [String] in
let current = item.uri.trimmingCharacters(in: .whitespacesAndNewlines)
let children = flattenPermissions(item.children)
return current.isEmpty ? children : [current] + children
}
}
///
private static func uniqueScenicScopes(from scenics: [ScenicInfo]) -> [BusinessScope] {
var seen = Set<Int>()
return scenics.compactMap { scenic in
guard seen.insert(scenic.id).inserted else { return nil }
return BusinessScope(
id: scenic.id,
name: scenic.name,
kind: .scenic,
status: scenic.status,
address: scenic.location?.address,
latitude: scenic.location?.lat,
longitude: scenic.location?.lng,
coverURLString: scenic.coverImg
)
}
}
}

View File

@ -0,0 +1,60 @@
//
// ScenicSpotContext.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
///
enum ScenicSpotLoadState: Equatable {
case idle
case loading
case loaded
case failed(String)
}
@MainActor
///
final class ScenicSpotContext {
var onChange: (() -> Void)?
private(set) var scenicId: Int? { didSet { onChange?() } }
private(set) var spots: [ScenicSpotItem] = [] { didSet { onChange?() } }
private(set) var loadState: ScenicSpotLoadState = .idle { didSet { onChange?() } }
/// ID
func reload(scenicId: Int?, api: AccountContextServing) async {
guard let scenicId else {
reset()
return
}
if self.scenicId != scenicId {
spots = []
}
self.scenicId = scenicId
loadState = .loading
do {
let response = try await api.scenicSpotListAll(scenicId: scenicId)
guard !Task.isCancelled else { return }
spots = response.list
loadState = .loaded
} catch is CancellationError {
loadState = .idle
} catch {
guard !Task.isCancelled else { return }
spots = []
loadState = .failed(error.localizedDescription)
}
}
/// 退
func reset() {
scenicId = nil
spots = []
loadState = .idle
}
}

View File

@ -0,0 +1,111 @@
//
// SessionBootstrapper.swift
// suixinkan
//
// Created by Codex on 2026/6/20.
//
import Foundation
@MainActor
/// token
final class SessionBootstrapper {
private let tokenStore: SessionTokenStore
private let snapshotStore: AccountSnapshotStore
private var didAttemptRestore = false
/// token
init(
tokenStore: SessionTokenStore,
snapshotStore: AccountSnapshotStore
) {
self.tokenStore = tokenStore
self.snapshotStore = snapshotStore
}
/// 使
convenience init() {
self.init(
tokenStore: SessionTokenStore(),
snapshotStore: AccountSnapshotStore()
)
}
///
func restore(
appSession: AppSession,
accountContext: AccountContext,
permissionContext: PermissionContext,
profileAPI: UserProfileServing,
accountContextAPI: AccountContextServing,
toastCenter: ToastCenter
) async {
guard !didAttemptRestore, !appSession.isLoggedIn else { return }
didAttemptRestore = true
guard let token = tokenStore.load() else {
appSession.logout()
return
}
appSession.beginRestoring(token: token)
let cachedSnapshot = restoreCachedSnapshot(to: accountContext)
do {
try await AccountContextLoader().refresh(
accountContext: accountContext,
permissionContext: permissionContext,
profileAPI: profileAPI,
accountContextAPI: accountContextAPI,
cachedCurrentRoleId: cachedSnapshot?.currentRoleId,
cachedCurrentScenicId: cachedSnapshot?.currentScenicId,
cachedCurrentStoreId: cachedSnapshot?.currentStoreId
)
saveLatestSnapshot(from: accountContext, permissionContext: permissionContext)
appSession.markLoggedIn(token: token)
} catch {
if APIError.isAuthenticationExpired(error) {
try? tokenStore.clear()
snapshotStore.clear()
accountContext.reset()
permissionContext.reset()
appSession.logout()
toastCenter.show("登录状态已失效,请重新登录")
} else {
appSession.markLoggedIn(token: token)
toastCenter.show("网络异常,已使用本地登录状态")
}
}
}
///
private func restoreCachedSnapshot(to accountContext: AccountContext) -> AccountSnapshot? {
guard let snapshot = snapshotStore.load() else { return nil }
accountContext.applyLogin(profile: snapshot.profile)
accountContext.replaceScopes(
scenic: snapshot.scenicScopes,
stores: snapshot.storeScopes,
currentScenicId: snapshot.currentScenicId,
currentStoreId: snapshot.currentStoreId
)
return snapshot
}
///
private func saveLatestSnapshot(from accountContext: AccountContext, permissionContext: PermissionContext) {
let existing = snapshotStore.load()
snapshotStore.save(
AccountSnapshot(
profile: accountContext.profile,
accountType: existing?.accountType,
businessUserId: existing?.businessUserId,
currentRoleId: permissionContext.currentRole?.id ?? existing?.currentRoleId,
scenicScopes: accountContext.scenicScopes,
storeScopes: accountContext.storeScopes,
currentScenicId: accountContext.currentScenic?.id,
currentStoreId: accountContext.currentStore?.id
)
)
}
}

View File

@ -0,0 +1,182 @@
//
// ToastCenter.swift
// suixinkan
//
// Created by Codex on 2026/6/18.
//
import UIKit
@MainActor
/// Toast
final class ToastCenter {
var onChange: (() -> Void)?
private(set) var message: String? { didSet { onChange?() } }
private let autoDismissNanoseconds: UInt64
private var dismissTask: Task<Void, Never>?
private var displayToken: UInt64 = 0
private weak var overlayView: ToastOverlayView?
/// Toast 2.2
init(autoDismissNanoseconds: UInt64 = 2_200_000_000) {
self.autoDismissNanoseconds = autoDismissNanoseconds
}
/// Toast
func show(_ message: String) {
displayToken &+= 1
self.message = message
scheduleAutoDismiss(token: displayToken)
}
/// Toast
func dismiss() {
displayToken &+= 1
dismissTask?.cancel()
dismissTask = nil
message = nil
}
/// Toast
func attachToWindow(_ window: UIWindow) {
if let overlayView, overlayView.superview === window {
return
}
let overlay = ToastOverlayView(center: self)
overlay.frame = window.bounds
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
window.addSubview(overlay)
overlayView = overlay
}
/// Toast Toast
private func scheduleAutoDismiss(token: UInt64) {
dismissTask?.cancel()
let delay = autoDismissNanoseconds
dismissTask = Task { [weak self] in
do {
try await Task.sleep(nanoseconds: delay)
} catch {
return
}
await MainActor.run {
guard let self, self.displayToken == token else { return }
self.message = nil
self.dismissTask = nil
}
}
}
#if DEBUG
/// Toast
var snapshotForTests: ToastSnapshot {
ToastSnapshot(message: message)
}
#endif
}
#if DEBUG
/// Toast
struct ToastSnapshot: Equatable {
let message: String?
}
#endif
/// Toast Toast
final class ToastOverlayView: UIView {
private weak var toastCenter: ToastCenter?
private let bannerView = UIView()
private let messageLabel = UILabel()
/// 使 Toast
init(center: ToastCenter) {
self.toastCenter = center
super.init(frame: .zero)
isUserInteractionEnabled = false
configureViews()
center.onChange = { [weak self] in
self?.refreshPresentation(animated: true)
}
refreshPresentation(animated: false)
}
required init?(coder: NSCoder) {
nil
}
private func configureViews() {
bannerView.backgroundColor = AppDesign.primary
bannerView.isHidden = true
bannerView.alpha = 0
messageLabel.font = .systemFont(ofSize: 14, weight: .medium)
messageLabel.textColor = .white
messageLabel.textAlignment = .center
messageLabel.numberOfLines = 2
bannerView.addSubview(messageLabel)
addSubview(bannerView)
bannerView.translatesAutoresizingMaskIntoConstraints = false
messageLabel.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
bannerView.leadingAnchor.constraint(equalTo: leadingAnchor),
bannerView.trailingAnchor.constraint(equalTo: trailingAnchor),
bannerView.topAnchor.constraint(equalTo: topAnchor),
messageLabel.leadingAnchor.constraint(equalTo: bannerView.leadingAnchor, constant: AppMetrics.Spacing.pageHorizontal),
messageLabel.trailingAnchor.constraint(equalTo: bannerView.trailingAnchor, constant: -AppMetrics.Spacing.pageHorizontal),
messageLabel.topAnchor.constraint(equalTo: bannerView.safeAreaLayoutGuide.topAnchor, constant: AppMetrics.Spacing.small),
messageLabel.bottomAnchor.constraint(equalTo: bannerView.bottomAnchor, constant: -AppMetrics.Spacing.small)
])
}
private func refreshPresentation(animated: Bool) {
guard let message = toastCenter?.message, !message.isEmpty else {
hideBanner(animated: animated)
return
}
messageLabel.text = message
showBanner(animated: animated)
}
private func showBanner(animated: Bool) {
guard bannerView.isHidden || bannerView.alpha < 1 else { return }
bannerView.isHidden = false
guard animated else {
bannerView.alpha = 1
bannerView.transform = .identity
return
}
bannerView.alpha = 0
bannerView.transform = CGAffineTransform(translationX: 0, y: -12)
UIView.animate(withDuration: 0.18, delay: 0, options: [.curveEaseInOut]) {
self.bannerView.alpha = 1
self.bannerView.transform = .identity
}
}
private func hideBanner(animated: Bool) {
guard !bannerView.isHidden else { return }
guard animated else {
bannerView.isHidden = true
bannerView.alpha = 0
return
}
UIView.animate(withDuration: 0.18, delay: 0, options: [.curveEaseInOut], animations: {
self.bannerView.alpha = 0
self.bannerView.transform = CGAffineTransform(translationX: 0, y: -12)
}, completion: { _ in
self.bannerView.isHidden = true
self.bannerView.transform = .identity
})
}
}

View File

@ -0,0 +1,138 @@
//
// RootViewController.swift
// suixinkan
//
import SnapKit
import UIKit
@MainActor
/// ViewController Overlay
final class RootViewController: UIViewController {
private let services: AppServices
private var currentChild: UIViewController?
private let restoringView = UIView()
init(services: AppServices = .shared) {
self.services = services
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
restoringView.backgroundColor = .systemBackground
services.appSession.onChange = { [weak self] in
self?.handleSessionPhaseChange()
}
mountChild(for: services.appSession.phase)
bootstrapSession()
configurePushNotifications()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
attachGlobalOverlays()
}
private func bootstrapSession() {
Task {
await services.globalLoading.withLoading {
await services.sessionBootstrapper.restore(
appSession: services.appSession,
accountContext: services.accountContext,
permissionContext: services.permissionContext,
profileAPI: services.profileAPI,
accountContextAPI: services.accountContextAPI,
toastCenter: services.toastCenter
)
}
if services.appSession.isLoggedIn, !AppUITestLaunchState.isRunningUITests {
PushNotificationManager.shared.requestAuthorizationAndRegister()
}
}
}
private func configurePushNotifications() {
services.configurePushNotifications()
}
private func attachGlobalOverlays() {
guard let window = view.window else { return }
services.toastCenter.attachToWindow(window)
services.globalLoading.attachToWindow(window)
}
private func handleSessionPhaseChange() {
mountChild(for: services.appSession.phase)
switch services.appSession.phase {
case .loggedIn:
if !AppUITestLaunchState.isRunningUITests {
PushNotificationManager.shared.requestAuthorizationAndRegister()
}
Task { await reloadScenicSpotContextIfNeeded() }
case .loggedOut:
services.accountContext.reset()
services.permissionContext.reset()
services.scenicSpotContext.reset()
services.appRouter.reset()
services.toastCenter.dismiss()
services.scenicQueueRuntime.stop()
case .restoring:
break
}
}
private func reloadScenicSpotContextIfNeeded() async {
guard services.appSession.isLoggedIn else {
services.scenicSpotContext.reset()
return
}
await services.scenicSpotContext.reload(
scenicId: services.accountContext.currentScenic?.id,
api: services.accountContextAPI
)
}
private func mountChild(for phase: AuthPhase) {
let nextChild: UIViewController
switch phase {
case .loggedOut:
nextChild = LoginViewController(services: services)
case .restoring:
nextChild = restoringViewController()
case .loggedIn:
nextChild = MainTabBarController(services: services)
}
guard currentChild !== nextChild else { return }
currentChild?.willMove(toParent: nil)
currentChild?.view.removeFromSuperview()
currentChild?.removeFromParent()
addChild(nextChild)
view.addSubview(nextChild.view)
nextChild.view.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
nextChild.didMove(toParent: self)
currentChild = nextChild
}
private func restoringViewController() -> UIViewController {
let controller = UIViewController()
controller.view.backgroundColor = .systemBackground
return controller
}
}