Advance UIKit rewrite with AMap integration and core UI modules.

Integrate高德 SDK with simulator-safe build flags, add map views for operating area and punch points, refactor main tabs and key feature screens to UIKit with Diffable lists, and document Swift concurrency defaults in AGENTS.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 15:16:12 +08:00
parent 9edf993432
commit d99a5b1bf8
124 changed files with 5195 additions and 1536 deletions

View File

@ -2,14 +2,9 @@
## 模块职责
Main 模块负责登录后的主界面 Tab 容器,以及当前尚未迁移页面的占位入口
Main 模块负责登录后的主界面 Tab 容器。
主界面由 `MainTabsView` 承载:
- 根据 `MainTabBarConfiguration.activeStyle` 选择自定义 TabBar 或系统 `TabView`
- 默认使用自定义 TabBar如需切回系统实现将配置改为 `.system`
- 自定义和系统两套外壳都展示首页、订单、数据、我的四个 Tab。
- 每个 Tab 内部都包裹独立的 `NavigationStack`,并通过 `TabNavigationStackHost` 复用同一套导航构建逻辑。
- 每个 Tab 的导航路径由 `AppRouter` 单独保存。
主界面由 `MainTabBarController` 承载,使用系统 `UITabBarController` 展示首页、订单、数据、我的四个 Tab。每个 Tab 内部包裹独立的 `TabNavigationController`,导航路径由 `AppRouter` 单独保存。
## Tab 结构
@ -19,41 +14,33 @@ Main 模块负责登录后的主界面 Tab 容器,以及当前尚未迁移页
- `statistics`:数据
- `profile`:我的
`HomeRootView` 已接入真实的 `HomeView``OrdersRootView` 已接入真实的 `OrdersView``StatisticsRootView` 已接入真实的 `StatisticsView``ProfileRootView` 已接入真实的 `ProfileView`
各 Tab 根页面已接入真实业务页面:`HomeViewController``OrdersViewController``StatisticsViewController``ProfileViewController`
## 导航流程
1. `MainTabsView` 从 Environment 读取 `AppRouter`
2. `MainTabsView` 根据 `MainTabBarConfiguration.activeStyle` 选择 `CustomMainTabsView``SystemMainTabsView`
3. 两套外壳都使用 `appRouter.selectedTab` 作为选中状态
4. 每个 Tab 通过 `TabNavigationStackHost` 创建自己的 `NavigationStack(path:)`
5. 路径绑定来自 `appRouter.binding(for:)`
6. Tab 内页面需要进入尚未迁移的子页面时,通过当前 Tab 注入的 `RouterPath` push 一个 `AppRoute.placeholder`
7. `navigationDestination` 根据 `AppRoute` 展示详情页。
8. 子页面展示时读取 `AppRoute.hidesTabBarWhenPushed`,默认隐藏底部 TabBar。
1. `MainTabBarController` 读取 `AppServices.appRouter` 作为选中 Tab 状态源
2. 用户点击 TabBar 或通过 `AppRouter.select` 切换 Tab 时,两边状态保持同步
3. 每个 Tab 由 `TabNavigationController` 创建独立 `UINavigationController`
4. Tab 内 push 子页面时,通过 `AppRoute.hidesTabBarWhenPushed` 控制是否隐藏系统 TabBar默认隐藏
5. 跨 Tab 跳转通过 `AppRouter.select` `AppRouter.selectOrders(entry:)` 处理
## 自定义 TabBar
## 订单角标
自定义 TabBar 由 `CustomMainTabsView``CustomMainTabBar` 组成:
- `CustomMainTabsView` 负责保留已访问 Tab 页面、刷新订单角标、展示扫码页。
- `CustomTabNavigationStackHost` 会把 `CustomMainTabBar` 拼在每个 Tab 的根页面内容下方。
- `CustomMainTabBar` 只负责展示 UI不直接读取账号、订单 API 或业务上下文。
- `MainTabBadgeViewModel` 通过 `OrdersAPI.writeOffList` 获取待核销数量,失败时静默清空角标。
- 中间扫码按钮使用订单模块已有的 `OrderScannerPage`
- 扫码成功后调用 `AppRouter.routeToOrderVerification(scannedCode:)`,订单页再通过 `consumePendingOrderScanCode()` 一次性消费结果
- 自定义 TabBar 只属于 Tab 根页面内容;当前 Tab 的导航栈 push 到二级页面后,目标页面不包含 TabBar也不会保留底部占位高度。
- 承载根页面和 `CustomMainTabBar` 的容器需要忽略键盘底部安全区,避免订单搜索框等输入控件唤起键盘时把自定义 TabBar 顶起。
- 角标展示在订单 Tab 的 `tabBarItem.badgeValue`
- 账号景区或门店变化、切换到订单 Tab 时会刷新角标
系统 `TabView` 外壳由 `SystemMainTabsView` 保留。系统模式不显示中间扫码按钮,但订单页内部的扫码核销入口仍然可用。
## 扫码核销
系统 TabBar 不展示中间扫码按钮;订单页内部的扫码核销入口仍然可用。如需全局扫码入口,可通过首页或订单页进入。
## 后续迁移规则
迁移新页面时:
- 优先替换对应 Tab 的 RootView
- 保持每个 Tab 自己的 `NavigationStack`
- 优先替换对应 Tab 的根 ViewController
- 保持每个 Tab 自己的 `TabNavigationController` 导航栈
- 跨 Tab 跳转通过 `AppRouter.select` 切换 Tab。
- 需要从首页或全局入口进入订单核销时,优先使用 `AppRouter.selectOrders(entry:)``AppRouter.routeToOrderVerification(scannedCode:)`
- Tab 内跳转通过当前 Tab 的 `RouterPath.navigate` 或扩展后的 `AppRoute` 处理。
- 普通业务子页面默认隐藏 TabBar只有确有产品需求时才在 `AppRoute` 策略中单独放开。
- 不要把业务状态塞进自定义 TabBar 组件TabBar 只接收绑定、文案和动作回调。
- 真实业务页面接入后,应同步补充该模块文档和单元测试。

View File

@ -3,24 +3,22 @@
// suixinkan
//
import SnapKit
import UIKit
@MainActor
/// Tab 使
final class MainTabBarController: UIViewController {
/// Tab 使 `UITabBarController`
final class MainTabBarController: UITabBarController, UITabBarControllerDelegate {
private let services: AppServices
private let badgeViewModel = MainTabBadgeViewModel()
private let contentContainer = UIView()
private let customTabBar = CustomMainTabBarView()
private var tabNavigationControllers: [AppTab: TabNavigationController] = [:]
private var loadedTabs: Set<AppTab> = [.home]
private var isSyncingTabSelection = false
///
init(services: AppServices) {
self.services = services
super.init(nibName: nil, bundle: nil)
delegate = self
}
@available(*, unavailable)
@ -28,124 +26,88 @@ final class MainTabBarController: UIViewController {
fatalError("init(coder:) has not been implemented")
}
/// UI
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
configureLayout()
configureTabBarAppearance()
configureTabs()
bindRouter()
bindBadgeViewModel()
bindAccountContext()
switchToTab(services.appRouter.selectedTab, animated: false)
syncSelectedTabFromRouter(animated: false)
Task { await refreshOrderBadge() }
#if DEBUG
Task { await AppUITestRouteDriver.applyIfNeeded(services: services) }
#endif
}
/// TabBar push
func setCustomTabBarHidden(_ hidden: Bool, animated: Bool) {
let updates = {
self.customTabBar.alpha = hidden ? 0 : 1
self.customTabBar.isUserInteractionEnabled = !hidden
}
guard animated else {
updates()
return
}
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseInOut], animations: updates)
/// TabBar
private func configureTabBarAppearance() {
tabBar.tintColor = AppDesign.primary
tabBar.backgroundColor = .white
}
private func configureLayout() {
view.addSubview(contentContainer)
view.addSubview(customTabBar)
customTabBar.onTabSelected = { [weak self] tab in
self?.services.appRouter.select(tab)
}
customTabBar.onScanTapped = { [weak self] in
self?.presentScanner()
}
contentContainer.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.bottom.equalTo(customTabBar.snp.top)
}
customTabBar.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(view.safeAreaLayoutGuide)
make.height.equalTo(72)
/// 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
private func bindRouter() {
services.appRouter.onChange = { [weak self] in
self?.handleRouterChange()
}
}
/// ViewModel
private func bindBadgeViewModel() {
badgeViewModel.onChange = { [weak self] in
self?.updateOrderBadge()
}
}
///
private func bindAccountContext() {
services.accountContext.onChange = { [weak self] in
Task { await self?.refreshOrderBadge() }
}
}
/// Router Tab `AppRouter`
private func handleRouterChange() {
switchToTab(services.appRouter.selectedTab, animated: false)
syncSelectedTabFromRouter(animated: false)
if services.appRouter.selectedTab == .orders {
Task { await refreshOrderBadge() }
}
}
private func switchToTab(_ tab: AppTab, animated: Bool) {
loadedTabs.insert(tab)
customTabBar.selectedTab = tab
/// `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 }
let navigationController = navigationController(for: tab)
for child in children where child !== navigationController {
child.willMove(toParent: nil)
child.view.removeFromSuperview()
child.removeFromParent()
}
addChild(navigationController)
contentContainer.addSubview(navigationController.view)
navigationController.view.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
navigationController.didMove(toParent: self)
let isRoot = navigationController.viewControllers.count <= 1
setCustomTabBarHidden(!isRoot, animated: animated)
}
private func navigationController(for tab: AppTab) -> TabNavigationController {
if let existing = tabNavigationControllers[tab] {
return existing
}
let navigationController = TabNavigationController(tab: tab, services: services)
navigationController.tabBarHost = self
tabNavigationControllers[tab] = navigationController
return navigationController
isSyncingTabSelection = true
selectedIndex = index
isSyncingTabSelection = false
}
/// Tab
private func updateOrderBadge() {
guard let count = badgeViewModel.pendingWriteOffCount, count > 0 else {
customTabBar.orderBadgeText = nil
return
let badgeValue: String?
if let count = badgeViewModel.pendingWriteOffCount, count > 0 {
badgeValue = "\(count)"
} else {
badgeValue = nil
}
customTabBar.orderBadgeText = "\(count)"
tabNavigationControllers[.orders]?.tabBarItem.badgeValue = badgeValue
}
/// Tab
private func refreshOrderBadge() async {
await badgeViewModel.refreshPendingWriteOffCount(
api: services.ordersAPI,
@ -154,198 +116,14 @@ final class MainTabBarController: UIViewController {
)
}
private func presentScanner() {
let scanner = OrderCodeScannerViewController()
scanner.onScanResult = { [weak self] result in
guard let self else { return }
switch result {
case .success(let rawCode):
dismiss(animated: true) {
self.services.appRouter.routeToOrderVerification(scannedCode: rawCode)
}
case .failure(let error):
showToast(error.localizedDescription)
}
/// TabBar `AppRouter`
func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
guard !isSyncingTabSelection,
let navigationController = viewController as? TabNavigationController else { return }
services.appRouter.select(navigationController.appTab)
if navigationController.appTab == .orders {
Task { await refreshOrderBadge() }
}
let navigation = UINavigationController(rootViewController: scanner)
navigation.modalPresentationStyle = .fullScreen
present(navigation, animated: true)
}
}
/// TabBar
private final class CustomMainTabBarView: UIView {
var selectedTab: AppTab = .home {
didSet { refreshSelection() }
}
var orderBadgeText: String? {
didSet { ordersBadgeLabel.text = orderBadgeText; ordersBadgeLabel.isHidden = orderBadgeText == nil }
}
var onTabSelected: ((AppTab) -> Void)?
var onScanTapped: (() -> Void)?
private struct TabItem {
let tab: AppTab
let title: String
let selectedImage: String
let unselectedImage: String
}
private let items: [TabItem] = [
.init(tab: .home, title: "首页", selectedImage: "TabHomeSelected", unselectedImage: "TabHomeUnselected"),
.init(tab: .orders, title: "订单", selectedImage: "TabOrderSelected", unselectedImage: "TabOrderUnselected"),
.init(tab: .statistics, title: "数据", selectedImage: "TabDataSelected", unselectedImage: "TabDataUnselected"),
.init(tab: .profile, title: "我的", selectedImage: "TabProfileSelected", unselectedImage: "TabProfileUnselected")
]
private var tabButtons: [AppTab: UIButton] = [:]
private var tabTitleLabels: [AppTab: UILabel] = [:]
private let ordersBadgeLabel = UILabel()
private let topDivider = UIView()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
configureViews()
refreshSelection()
}
required init?(coder: NSCoder) {
nil
}
private func configureViews() {
topDivider.backgroundColor = UIColor.black.withAlphaComponent(0.04)
addSubview(topDivider)
topDivider.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(0.5)
}
let leftStack = UIStackView()
leftStack.axis = .horizontal
leftStack.distribution = .fillEqually
leftStack.spacing = 0
let rightStack = UIStackView()
rightStack.axis = .horizontal
rightStack.distribution = .fillEqually
rightStack.spacing = 0
for (index, item) in items.enumerated() {
let buttonContainer = makeTabButton(for: item)
if index < 2 {
leftStack.addArrangedSubview(buttonContainer)
} else {
rightStack.addArrangedSubview(buttonContainer)
}
}
let scanButton = UIButton(type: .system)
scanButton.backgroundColor = AppDesign.primary
scanButton.layer.cornerRadius = 29
scanButton.tintColor = .white
scanButton.setImage(
UIImage(systemName: "qrcode.viewfinder")?.withConfiguration(
UIImage.SymbolConfiguration(pointSize: 30, weight: .bold)
),
for: .normal
)
scanButton.accessibilityLabel = "扫码核销"
scanButton.accessibilityIdentifier = "main.scan"
scanButton.addTarget(self, action: #selector(scanTapped), for: .touchUpInside)
let row = UIStackView(arrangedSubviews: [leftStack, scanButton, rightStack])
row.axis = .horizontal
row.alignment = .center
row.spacing = 0
addSubview(row)
scanButton.snp.makeConstraints { make in
make.width.equalTo(74)
make.height.equalTo(64)
}
row.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(22)
make.top.bottom.equalToSuperview()
}
leftStack.snp.makeConstraints { make in
make.width.equalTo(rightStack)
}
}
private func makeTabButton(for item: TabItem) -> UIView {
let container = UIView()
let button = UIButton(type: .custom)
button.accessibilityLabel = item.title
button.accessibilityIdentifier = "main.tab.\(item.tab.rawValue)"
button.tag = items.firstIndex(where: { $0.tab == item.tab }) ?? 0
button.addTarget(self, action: #selector(tabTapped(_:)), for: .touchUpInside)
tabButtons[item.tab] = button
let titleLabel = UILabel()
titleLabel.text = item.title
titleLabel.font = .systemFont(ofSize: 12)
titleLabel.textAlignment = .center
tabTitleLabels[item.tab] = titleLabel
container.addSubview(button)
container.addSubview(titleLabel)
button.snp.makeConstraints { make in
make.top.equalToSuperview().offset(8)
make.centerX.equalToSuperview()
make.width.height.equalTo(24)
}
titleLabel.snp.makeConstraints { make in
make.top.equalTo(button.snp.bottom).offset(3)
make.centerX.equalToSuperview()
make.bottom.lessThanOrEqualToSuperview()
}
if item.tab == .orders {
ordersBadgeLabel.font = .systemFont(ofSize: 9, weight: .bold)
ordersBadgeLabel.textColor = .white
ordersBadgeLabel.backgroundColor = UIColor(hex: 0xEF4444)
ordersBadgeLabel.textAlignment = .center
ordersBadgeLabel.layer.cornerRadius = 7.5
ordersBadgeLabel.clipsToBounds = true
ordersBadgeLabel.isHidden = true
container.addSubview(ordersBadgeLabel)
ordersBadgeLabel.snp.makeConstraints { make in
make.top.equalTo(button).offset(-4)
make.leading.equalTo(button.snp.trailing).offset(-6)
make.height.equalTo(15)
make.width.greaterThanOrEqualTo(15)
}
}
return container
}
private func refreshSelection() {
for item in items {
let isSelected = item.tab == selectedTab
let imageName = isSelected ? item.selectedImage : item.unselectedImage
tabButtons[item.tab]?.setImage(UIImage(named: imageName), for: .normal)
tabTitleLabels[item.tab]?.textColor = isSelected ? AppDesign.primary : UIColor(hex: 0x7D8DA3)
tabTitleLabels[item.tab]?.font = .systemFont(ofSize: 12, weight: isSelected ? .medium : .regular)
}
}
@objc private func tabTapped(_ sender: UIButton) {
guard sender.tag >= 0, sender.tag < items.count else { return }
onTabSelected?(items[sender.tag].tab)
}
@objc private func scanTapped() {
onScanTapped?()
}
}

View File

@ -11,6 +11,7 @@ final class PlaceholderViewController: UIViewController {
private let pageTitle: String
///
init(title: String) {
pageTitle = title
super.init(nibName: nil, bundle: nil)
@ -21,6 +22,7 @@ final class PlaceholderViewController: UIViewController {
fatalError("init(coder:) has not been implemented")
}
/// UI
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(hex: 0xF5F7FA)