Implement permission-driven home tab aligned with Android.

Load role-permission on first visit to drive menus, role-based layout, store card, and location reporting with account-scoped persistence and unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 17:27:54 +08:00
parent 75d0cb1f9a
commit 3b17b7f7f0
27 changed files with 2573 additions and 10 deletions

View File

@ -6,27 +6,359 @@
import SnapKit
import UIKit
/// Tab
/// Tab
final class HomeViewController: BaseViewController {
private let titleLabel = UILabel()
private let viewModel = HomeViewModel()
private let homeAPI = NetworkServices.shared.homeAPI
private let scenicHeaderView = HomeScenicHeaderView()
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let workStatusCardView = HomeWorkStatusCardView()
private let locationReportCardView = HomeLocationReportCardView()
private let quickActionsView = HomeQuickActionsView()
private let storeCardView = HomeStoreCardView()
private let commonAppsGridView = HomeCommonAppsGridView()
private var hasInitialized = false
private var countdownTimer: Timer?
private var activeDialog: HomeDialogKind?
override func setupNavigationBar() {
navigationController?.setNavigationBarHidden(true, animated: false)
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF7FAFF)
titleLabel.text = "首页"
titleLabel.font = .systemFont(ofSize: 24, weight: .medium)
titleLabel.textAlignment = .center
titleLabel.textColor = AppColor.text333
view.addSubview(titleLabel)
view.backgroundColor = UIColor(hex: 0xF5F5F5)
contentStack.axis = .vertical
contentStack.spacing = 12
scrollView.showsVerticalScrollIndicator = false
view.addSubview(scenicHeaderView)
view.addSubview(scrollView)
scrollView.addSubview(contentStack)
contentStack.addArrangedSubview(workStatusCardView)
contentStack.addArrangedSubview(locationReportCardView)
contentStack.addArrangedSubview(quickActionsView)
contentStack.addArrangedSubview(storeCardView)
contentStack.addArrangedSubview(commonAppsGridView)
}
override func setupConstraints() {
titleLabel.snp.makeConstraints { make in
make.center.equalToSuperview()
scenicHeaderView.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide)
make.leading.trailing.equalToSuperview()
make.height.equalTo(52)
}
scrollView.snp.makeConstraints { make in
make.top.equalTo(scenicHeaderView.snp.bottom)
make.leading.trailing.bottom.equalToSuperview()
}
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
make.width.equalTo(scrollView.snp.width).offset(-32)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
scenicHeaderView.onTap = { [weak self] in self?.presentScenicSelection() }
workStatusCardView.onOnlineTap = { [weak self] in self?.viewModel.showOnlineStatusSwitchDialog() }
workStatusCardView.onReminderTap = { [weak self] in self?.presentReminderPicker() }
locationReportCardView.onReportTap = { [weak self] in
Task { await self?.viewModel.manualReportLocation(api: self?.homeAPI ?? NetworkServices.shared.homeAPI) }
}
quickActionsView.onCollectPayment = { [weak self] in self?.showToast("功能开发中") }
quickActionsView.onSubmitTask = { [weak self] in self?.showToast("功能开发中") }
quickActionsView.onToggleOnline = { [weak self] in self?.viewModel.showOnlineStatusSwitchDialog() }
commonAppsGridView.onMenuSelected = { [weak self] menu in
guard let self else { return }
HomeRouteHandler.open(uri: menu.uri, from: self, storeItem: self.viewModel.storeItem)
}
NotificationCenter.default.addObserver(
self,
selector: #selector(handleAccountDidSwitch),
name: NotificationName.accountDidSwitch,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleScenicDidChange),
name: NotificationName.scenicDidChange,
object: nil
)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if !hasInitialized {
hasInitialized = true
Task { await initializeHome() }
} else {
Task { await viewModel.reloadIfNeeded(api: homeAPI) }
}
startCountdownTimer()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
countdownTimer?.invalidate()
countdownTimer = nil
}
deinit {
NotificationCenter.default.removeObserver(self)
}
private func initializeHome() async {
showLoading()
defer { hideLoading() }
await viewModel.initialize(api: homeAPI)
applyViewModel()
await evaluateDialogsWithDelay()
await viewModel.refreshLocationInfo()
applyViewModel()
}
private func evaluateDialogsWithDelay() async {
try? await Task.sleep(nanoseconds: 100_000_000)
await viewModel.evaluateDialogs(api: homeAPI)
applyViewModel()
presentDialogsIfNeeded()
}
@MainActor
private func applyViewModel() {
scenicHeaderView.apply(scenicName: viewModel.currentScenicName)
let showWorkBlock = !viewModel.isMinimalTopRole
workStatusCardView.isHidden = !showWorkBlock
locationReportCardView.isHidden = !showWorkBlock
quickActionsView.isHidden = !showWorkBlock
if showWorkBlock {
workStatusCardView.apply(
isOnline: viewModel.isOnline,
countdownText: viewModel.countdownDisplayText,
reminderMinutes: viewModel.reminderMinutes
)
locationReportCardView.apply(
address: viewModel.locationInfoText,
isReporting: viewModel.isLocationReporting
)
quickActionsView.apply(isOnline: viewModel.isOnline)
}
let showStore = viewModel.currentAppRole == .storeAdmin && viewModel.storeItem != nil
storeCardView.isHidden = !showStore
if showStore, let store = viewModel.storeItem {
storeCardView.apply(store: store)
}
commonAppsGridView.apply(menus: viewModel.commonMenus)
presentDialogsIfNeeded()
}
private func presentDialogsIfNeeded() {
if viewModel.showPermissionDialog {
presentDialog(.permission)
return
}
if viewModel.showScenicDialog {
presentDialog(.scenic)
return
}
if viewModel.showOnlineStatusDialog {
presentDialog(.onlineStatus)
return
}
if viewModel.showLocationTimeoutDialog {
presentDialog(.locationTimeout)
return
}
if viewModel.showLocationReportSuccessDialog {
presentDialog(.locationSuccess)
return
}
activeDialog = nil
}
private enum HomeDialogKind {
case permission
case scenic
case onlineStatus
case locationTimeout
case locationSuccess
}
private func presentDialog(_ kind: HomeDialogKind) {
guard activeDialog != kind, presentedViewController == nil else { return }
activeDialog = kind
switch kind {
case .permission: presentPermissionDialog()
case .scenic: presentScenicDialog()
case .onlineStatus: presentOnlineStatusDialog()
case .locationTimeout: presentLocationTimeoutDialog()
case .locationSuccess: presentLocationSuccessDialog()
}
}
private func presentPermissionDialog() {
let alert = UIAlertController(
title: "权限提示",
message: "您还没有权限,请先申请权限",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "取消", style: .cancel) { [weak self] _ in
self?.viewModel.hidePermissionDialog()
self?.activeDialog = nil
})
alert.addAction(UIAlertAction(title: "去申请", style: .default) { [weak self] _ in
self?.showToast("功能开发中")
self?.activeDialog = nil
})
present(alert, animated: true)
}
private func presentScenicDialog() {
let alert = UIAlertController(
title: "请选择景区",
message: "使用首页功能前需要先选择当前景区",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "稍后", style: .cancel) { [weak self] _ in
self?.viewModel.hideScenicDialog()
self?.activeDialog = nil
})
alert.addAction(UIAlertAction(title: "去选择", style: .default) { [weak self] _ in
self?.viewModel.hideScenicDialog()
self?.activeDialog = nil
self?.presentScenicSelection()
})
present(alert, animated: true)
}
private func presentOnlineStatusDialog() {
let goingOnline = !viewModel.isOnline
let alert = UIAlertController(
title: goingOnline ? "切换在线" : "切换离线",
message: goingOnline ? "确认切换到在线状态并上报位置?" : "确认切换到离线状态?",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "取消", style: .cancel) { [weak self] _ in
self?.viewModel.hideOnlineStatusSwitchDialog()
self?.activeDialog = nil
})
alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self] _ in
guard let self else { return }
self.activeDialog = nil
Task { await self.viewModel.switchOnlineStatus(api: self.homeAPI) }
})
present(alert, animated: true)
}
private func presentLocationTimeoutDialog() {
let alert = UIAlertController(
title: "位置上报提醒",
message: "您的位置上报即将超时,请立即上报",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "稍后", style: .cancel) { [weak self] _ in
self?.viewModel.hideLocationTimeoutDialog()
self?.activeDialog = nil
})
alert.addAction(UIAlertAction(title: "立即上报", style: .default) { [weak self] _ in
guard let self else { return }
self.activeDialog = nil
Task { await self.viewModel.confirmLocationTimeoutReport(api: self.homeAPI) }
})
present(alert, animated: true)
}
private func presentLocationSuccessDialog() {
let alert = UIAlertController(
title: "上报成功",
message: "上报时间:\(viewModel.reportTimeText)",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self] _ in
self?.viewModel.hideLocationReportSuccessDialog()
self?.activeDialog = nil
})
present(alert, animated: true)
}
private func presentReminderPicker() {
let alert = UIAlertController(title: "提前提醒", message: "选择位置超时提前提醒时间", preferredStyle: .actionSheet)
[0, 5, 10, 15, 30].forEach { minutes in
let title = minutes == 0 ? "不提醒" : "提前\(minutes)分钟"
alert.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
self?.viewModel.updateReminderMinutes(minutes)
})
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
present(alert, animated: true)
}
private func presentScenicSelection() {
let scenicList = AppStore.shared.roleScenicList()
guard !scenicList.isEmpty else {
showToast("暂无可选景区")
return
}
let controller = ScenicSelectionViewController(scenicList: scenicList)
controller.onSelected = { [weak self] in
guard let self else { return }
Task {
await self.viewModel.reloadIfNeeded(api: self.homeAPI)
await self.viewModel.loadStoreListIfNeeded(api: self.homeAPI)
self.applyViewModel()
await self.evaluateDialogsWithDelay()
}
}
let nav = UINavigationController(rootViewController: controller)
nav.modalPresentationStyle = .pageSheet
present(nav, animated: true)
}
private func startCountdownTimer() {
countdownTimer?.invalidate()
countdownTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
guard let self else { return }
self.viewModel.triggerLocationTimeoutIfNeeded()
if !self.viewModel.isMinimalTopRole {
self.workStatusCardView.apply(
isOnline: self.viewModel.isOnline,
countdownText: self.viewModel.countdownDisplayText,
reminderMinutes: self.viewModel.reminderMinutes
)
}
}
}
@objc private func handleAccountDidSwitch() {
viewModel.markNeedsPermissionReload()
Task {
await viewModel.reloadIfNeeded(api: homeAPI)
applyViewModel()
await evaluateDialogsWithDelay()
}
}
@objc private func handleScenicDidChange() {
viewModel.refreshLocalDisplayState()
Task {
await viewModel.loadStoreListIfNeeded(api: homeAPI)
applyViewModel()
}
}
}