Upgrade scenic picker with search, location sorting, permission apply/status, and blocking home dialog; add cooperation order module and order source picker improvements with unit tests. Co-authored-by: Cursor <cursoragent@cursor.com>
515 lines
19 KiB
Swift
515 lines
19 KiB
Swift
//
|
||
// HomeViewController.swift
|
||
// suixinkan
|
||
//
|
||
|
||
import SnapKit
|
||
import UIKit
|
||
|
||
/// 首页 Tab 根页面,按身份与权限展示信息与入口。
|
||
final class HomeViewController: BaseViewController {
|
||
|
||
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.isDirectionalLockEnabled = true
|
||
// 水平边距由 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.equalTo(view.safeAreaLayoutGuide)
|
||
make.leading.trailing.equalToSuperview()
|
||
make.height.equalTo(AppSpacing.homeHeaderHeight)
|
||
}
|
||
collectionView.snp.makeConstraints { make in
|
||
make.top.equalTo(scenicHeaderView.snp.bottom)
|
||
make.leading.trailing.bottom.equalToSuperview()
|
||
}
|
||
}
|
||
|
||
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 { 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 registerCollectionCells() {
|
||
collectionView.register(HomeWorkStatusCell.self, forCellWithReuseIdentifier: HomeWorkStatusCell.reuseIdentifier)
|
||
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 UICollectionViewCell() }
|
||
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 { await self.viewModel.manualReportLocation(api: self.homeAPI) }
|
||
}
|
||
cell.apply(
|
||
address: self.viewModel.locationInfoText,
|
||
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.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 UICollectionViewCell()
|
||
}
|
||
}
|
||
|
||
dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in
|
||
guard kind == UICollectionView.elementKindSectionHeader,
|
||
indexPath.section < self.currentSections.count,
|
||
self.currentSections[indexPath.section] == .commonApps else {
|
||
return nil
|
||
}
|
||
let header = collectionView.dequeueReusableSupplementaryView(
|
||
ofKind: kind,
|
||
withReuseIdentifier: HomeSectionHeaderView.reuseIdentifier,
|
||
for: indexPath
|
||
) as! HomeSectionHeaderView
|
||
header.apply(title: "常用应用")
|
||
return header
|
||
}
|
||
|
||
return dataSource
|
||
}
|
||
|
||
private func initializeHome() async {
|
||
showLoading()
|
||
await viewModel.initialize(api: homeAPI)
|
||
hideLoading()
|
||
|
||
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
|
||
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)
|
||
presentDialogsIfNeeded()
|
||
}
|
||
|
||
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 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.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() {
|
||
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() {
|
||
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()
|
||
}
|
||
)
|
||
}
|
||
}
|