Files
suixinkan_uikit/suixinkan/UI/Home/HomeViewController.swift
汉秋 efb3925257 完善首页权限申请流程与队列播报体验。
对齐 Android 无权限强制弹窗、角色下拉与申请页交互,补充 UIButton configuration 适配,并增强景区排队 TTS 与相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 13:46:45 +08:00

567 lines
21 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// HomeViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// Tab
final class HomeViewController: BaseViewController {
private static let fallbackCellReuseIdentifier = "HomeFallbackCell"
private let viewModel = HomeViewModel()
private let homeAPI = NetworkServices.shared.homeAPI
private let scenicHeaderView = HomeScenicHeaderView()
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<HomeCollectionSection, HomeCollectionItem>!
private var currentSections: [HomeCollectionSection] = []
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 = AppColor.pageBackground
collectionView = UICollectionView(
frame: .zero,
collectionViewLayout: HomeCollectionLayoutBuilder.makeLayout(sections: [.commonApps])
)
collectionView.backgroundColor = .clear
// collectionView.alwaysBounceVertical = true
// collectionView.alwaysBounceHorizontal = false
// collectionView.showsHorizontalScrollIndicator = false
// collectionView.contentInsetAdjustmentBehavior = .never
// section.contentInsets contentInset
collectionView.contentInset = UIEdgeInsets(
top: AppSpacing.sm,
left: 0,
bottom: AppSpacing.md,
right: 0
)
registerCollectionCells()
view.addSubview(scenicHeaderView)
view.addSubview(collectionView)
dataSource = makeDataSource()
}
override func setupConstraints() {
scenicHeaderView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.bottom.equalTo(view.safeAreaLayoutGuide.snp.top).offset(AppSpacing.homeHeaderHeight)
}
collectionView.snp.makeConstraints { make in
make.top.equalTo(scenicHeaderView.snp.bottom)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(view.safeAreaLayoutGuide)
}
}
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() }
collectionView.delegate = self
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 viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.setNavigationBarHidden(true, animated: animated)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if !hasInitialized {
hasInitialized = true
Task { await initializeHome() }
} else {
Task {
let shouldReevaluateDialogs = viewModel.needsPermissionReload
await viewModel.reloadIfNeeded(api: homeAPI)
if shouldReevaluateDialogs {
await viewModel.evaluateDialogs(api: homeAPI)
applyViewModel()
}
}
}
startCountdownTimer()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
countdownTimer?.invalidate()
countdownTimer = nil
}
deinit {
NotificationCenter.default.removeObserver(self)
}
private func registerCollectionCells() {
collectionView.register(HomeWorkStatusCell.self, forCellWithReuseIdentifier: HomeWorkStatusCell.reuseIdentifier)
collectionView.register(
UICollectionViewCell.self,
forCellWithReuseIdentifier: Self.fallbackCellReuseIdentifier
)
collectionView.register(
HomeLocationReportCell.self,
forCellWithReuseIdentifier: HomeLocationReportCell.reuseIdentifier
)
collectionView.register(
HomeQuickActionsCell.self,
forCellWithReuseIdentifier: HomeQuickActionsCell.reuseIdentifier
)
collectionView.register(HomeStoreCell.self, forCellWithReuseIdentifier: HomeStoreCell.reuseIdentifier)
collectionView.register(HomeMenuCell.self, forCellWithReuseIdentifier: HomeMenuCell.reuseIdentifier)
collectionView.register(
HomeSectionHeaderView.self,
forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
withReuseIdentifier: HomeSectionHeaderView.reuseIdentifier
)
}
private func makeDataSource() -> UICollectionViewDiffableDataSource<HomeCollectionSection, HomeCollectionItem> {
let dataSource = UICollectionViewDiffableDataSource<HomeCollectionSection, HomeCollectionItem>(
collectionView: collectionView
) { [weak self] collectionView, indexPath, item in
guard let self else {
return collectionView.dequeueReusableCell(
withReuseIdentifier: Self.fallbackCellReuseIdentifier,
for: indexPath
)
}
guard indexPath.section < self.currentSections.count else {
return collectionView.dequeueReusableCell(
withReuseIdentifier: Self.fallbackCellReuseIdentifier,
for: indexPath
)
}
let section = self.currentSections[indexPath.section]
switch (section, item) {
case (.workStatus, .workStatus):
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: HomeWorkStatusCell.reuseIdentifier,
for: indexPath
) as! HomeWorkStatusCell
cell.cardView.onOnlineTap = { [weak self] in
self?.viewModel.showOnlineStatusSwitchDialog()
}
cell.cardView.onReminderTap = { [weak self] in
self?.presentReminderPicker()
}
cell.apply(
isOnline: self.viewModel.isOnline,
countdownText: self.viewModel.countdownDisplayText,
reminderMinutes: self.viewModel.reminderMinutes
)
return cell
case (.locationReport, .locationReport):
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: HomeLocationReportCell.reuseIdentifier,
for: indexPath
) as! HomeLocationReportCell
cell.cardView.onReportTap = { [weak self] in
guard let self else { return }
Task { @MainActor in
self.showLoading()
defer { self.hideLoading() }
await self.viewModel.manualReportLocation(api: self.homeAPI)
}
}
cell.apply(
address: HomeViewModel.locationReportPromptText,
isReporting: self.viewModel.isLocationReporting
)
return cell
case (.quickActions, .quickActions):
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: HomeQuickActionsCell.reuseIdentifier,
for: indexPath
) as! HomeQuickActionsCell
cell.cardView.onCollectPayment = { [weak self] in
guard AppStore.shared.session.currentScenicId > 0 else {
self?.showToast("请先选择景区")
return
}
self?.navigationController?.pushViewController(
PaymentCollectionDetailsViewController(),
animated: true
)
}
cell.cardView.onSubmitTask = { [weak self] in
self?.navigationController?.pushViewController(TaskAddViewController(), animated: true)
}
cell.cardView.onToggleOnline = { [weak self] in
self?.viewModel.showOnlineStatusSwitchDialog()
}
cell.apply(isOnline: self.viewModel.isOnline)
return cell
case (.store, .store):
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: HomeStoreCell.reuseIdentifier,
for: indexPath
) as! HomeStoreCell
if let store = self.viewModel.storeItem {
cell.apply(store: store)
}
return cell
case (.commonApps, .menu(let menu)):
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: HomeMenuCell.reuseIdentifier,
for: indexPath
) as! HomeMenuCell
cell.apply(menu: menu)
return cell
default:
return collectionView.dequeueReusableCell(
withReuseIdentifier: Self.fallbackCellReuseIdentifier,
for: indexPath
)
}
}
dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in
guard kind == UICollectionView.elementKindSectionHeader else {
return nil
}
let header = collectionView.dequeueReusableSupplementaryView(
ofKind: kind,
withReuseIdentifier: HomeSectionHeaderView.reuseIdentifier,
for: indexPath
) as! HomeSectionHeaderView
let title = indexPath.section < self.currentSections.count
&& self.currentSections[indexPath.section] == .commonApps ? "常用应用" : ""
header.apply(title: title)
return header
}
return dataSource
}
private func initializeHome() async {
showLoading()
await viewModel.initialize(api: homeAPI)
hideLoading()
applyViewModel()
await evaluateDialogsWithDelay()
}
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
let showStore = viewModel.currentAppRole == .storeAdmin && viewModel.storeItem != nil
let sections = HomeCollectionLayoutBuilder.sectionOrder(
showWorkBlock: showWorkBlock,
showStore: showStore,
storeItem: viewModel.storeItem
)
if sections != currentSections {
currentSections = sections
collectionView.setCollectionViewLayout(
HomeCollectionLayoutBuilder.makeLayout(sections: sections),
animated: false
)
}
let snapshot = HomeCollectionLayoutBuilder.makeSnapshot(
showWorkBlock: showWorkBlock,
showStore: showStore,
storeItem: viewModel.storeItem,
commonMenus: viewModel.commonMenus
)
dataSource.apply(snapshot, animatingDifferences: false) { [weak self] in
self?.refreshStatusVisibleCells()
}
presentDialogsIfNeeded()
}
private func refreshStatusVisibleCells() {
refreshWorkStatusVisibleCell()
refreshQuickActionsVisibleCell()
}
private func refreshWorkStatusVisibleCell() {
guard !viewModel.isMinimalTopRole,
let sectionIndex = currentSections.firstIndex(of: .workStatus) else {
return
}
let indexPath = IndexPath(item: 0, section: sectionIndex)
guard let cell = collectionView.cellForItem(at: indexPath) as? HomeWorkStatusCell else {
return
}
cell.apply(
isOnline: viewModel.isOnline,
countdownText: viewModel.countdownDisplayText,
reminderMinutes: viewModel.reminderMinutes
)
}
private func refreshQuickActionsVisibleCell() {
guard !viewModel.isMinimalTopRole,
let sectionIndex = currentSections.firstIndex(of: .quickActions) else {
return
}
let indexPath = IndexPath(item: 0, section: sectionIndex)
guard let cell = collectionView.cellForItem(at: indexPath) as? HomeQuickActionsCell else {
return
}
cell.apply(isOnline: viewModel.isOnline)
}
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 dialog = PermissionRequiredDialogViewController(
onExitApp: {
UIApplication.shared.perform(#selector(NSXPCConnection.suspend))
},
onGoToApply: { [weak self] in
guard let self else { return }
self.activeDialog = nil
Task { await self.openPermissionApplyFlow() }
}
)
present(dialog, animated: true)
}
private func openPermissionApplyFlow() async {
showLoading()
let destination = await viewModel.resolvePermissionApplyDestination(api: homeAPI)
hideLoading()
viewModel.markNeedsPermissionReload()
navigationController?.setNavigationBarHidden(false, animated: true)
switch destination {
case .apply:
navigationController?.pushViewController(PermissionApplyViewController(), animated: true)
case .status(let applyCode):
navigationController?.pushViewController(
PermissionApplyStatusViewController(applyCode: applyCode),
animated: true
)
}
}
private func presentScenicDialog() {
let alert = UIAlertController(
title: "提示",
message: "您还没有选定入驻景区,请先入驻",
preferredStyle: .alert
)
alert.isModalInPresentation = true
alert.addAction(UIAlertAction(title: "关闭app", style: .default) { _ in
UIApplication.shared.perform(#selector(NSXPCConnection.suspend))
})
alert.addAction(UIAlertAction(title: "去入驻", style: .default) { [weak self] _ in
self?.activeDialog = nil
self?.pushScenicSelection()
})
present(alert, animated: true)
}
private func presentOnlineStatusDialog() {
viewModel.hideOnlineStatusSwitchDialog()
LocationReportDialogPresenter.presentOnlineStatusSwitch(
from: self,
isOnline: viewModel.isOnline,
onConfirm: { [weak self] in
guard let self else { return }
self.activeDialog = nil
Task { await self.viewModel.switchOnlineStatus(api: self.homeAPI) }
},
onCancel: { [weak self] in
self?.viewModel.hideOnlineStatusSwitchDialog()
self?.activeDialog = nil
}
)
}
private func presentLocationTimeoutDialog() {
viewModel.hideLocationTimeoutDialog()
LocationReportDialogPresenter.presentLocationTimeout(
from: self,
onReport: { [weak self] in
guard let self else { return }
self.activeDialog = nil
Task { await self.viewModel.confirmLocationTimeoutReport(api: self.homeAPI) }
},
onLater: { [weak self] in
self?.viewModel.hideLocationTimeoutDialog()
self?.activeDialog = nil
}
)
}
private func presentLocationSuccessDialog() {
let reportTime = viewModel.reportTimeText
let countdown = viewModel.countdownDisplayText
viewModel.hideLocationReportSuccessDialog()
LocationReportDialogPresenter.presentReportSuccess(
from: self,
reportTime: reportTime,
countdown: countdown,
onDismiss: { [weak self] in
self?.activeDialog = nil
}
)
}
private func presentReminderPicker() {
LocationReportDialogPresenter.presentReminderPicker(from: self) { [weak self] minutes in
self?.viewModel.updateReminderMinutes(minutes)
}
}
private func presentScenicSelection() {
pushScenicSelection()
}
private func pushScenicSelection() {
navigationController?.setNavigationBarHidden(false, animated: true)
let controller = ScenicSelectionViewController()
controller.onSelectionComplete = { [weak self] in
guard let self else { return }
Task {
self.viewModel.refreshLocalDisplayState()
self.viewModel.rebuildCommonMenus()
await self.viewModel.loadStoreListIfNeeded(api: self.homeAPI)
self.applyViewModel()
await self.viewModel.evaluateDialogs(api: self.homeAPI)
await MainActor.run { self.presentDialogsIfNeeded() }
}
}
navigationController?.pushViewController(controller, 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()
self.refreshWorkStatusVisibleCell()
}
}
@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()
}
}
}
extension HomeViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard indexPath.section < currentSections.count,
currentSections[indexPath.section] == .commonApps,
let item = dataSource.itemIdentifier(for: indexPath),
case let .menu(menu) = item else {
return
}
HomeRouteHandler.open(
uri: menu.uri,
from: self,
storeItem: viewModel.storeItem,
onCommonMenusChanged: { [weak self] in
guard let self else { return }
self.viewModel.rebuildCommonMenus()
self.applyViewModel()
}
)
}
}