Files
suixinkan_uikit/suixinkan/UI/Home/HomeViewController.swift
lujiuyinandCursor caeeb9a1cf feat: 接入消息未读数、全部已读与首页红点角标同步。
固定消息中心为首页入口,并在推送到达、已读后刷新桌面角标。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 18:02:43 +08:00

608 lines
22 KiB
Swift

//
// 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 messageCenterAPI = NetworkServices.shared.messageCenterAPI
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() }
scenicHeaderView.onMessageTap = { [weak self] in self?.openMessageCenter() }
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
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleUnreadMessageCountDidChange(_:)),
name: NotificationName.unreadMessageCountDidChange,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleUnreadMessageCountDidChange(_:)),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.setNavigationBarHidden(true, animated: animated)
if hasInitialized {
Task { await refreshUnreadMessageStatus() }
}
}
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)
await refreshUnreadMessageStatus()
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,
hasUnreadMessages: viewModel.hasUnreadMessages
)
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 openMessageCenter() {
navigationController?.pushViewController(MessageCenterViewController(), animated: true)
}
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)
await refreshUnreadMessageStatus()
applyViewModel()
await evaluateDialogsWithDelay()
}
}
@objc private func handleScenicDidChange() {
viewModel.refreshLocalDisplayState()
Task {
await viewModel.loadStoreListIfNeeded(api: homeAPI)
applyViewModel()
}
}
@objc private func handleUnreadMessageCountDidChange(_ notification: Notification) {
guard hasInitialized else { return }
if let count = notification.userInfo?[NotificationUserInfoKey.unreadCount] as? Int {
viewModel.updateUnreadMessageCount(count)
Task { await PushNotificationManager.shared.updateApplicationIconBadgeCount(count) }
} else {
Task { await refreshUnreadMessageStatus() }
}
}
private func refreshUnreadMessageStatus() async {
guard await viewModel.refreshUnreadMessageStatus(api: messageCenterAPI) else { return }
await PushNotificationManager.shared.updateApplicationIconBadgeCount(viewModel.unreadMessageCount)
}
}
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()
}
)
}
}