固定消息中心为首页入口,并在推送到达、已读后刷新桌面角标。 Co-authored-by: Cursor <cursoragent@cursor.com>
608 lines
22 KiB
Swift
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()
|
|
}
|
|
)
|
|
}
|
|
}
|