将会话、权限、位置、收款与排队存储拆分为独立模块,同步更新各 ViewModel 与单元测试,并补充素材管理 UI 与个人空间设置交互。 Co-authored-by: Cursor <cursoragent@cursor.com>
432 lines
15 KiB
Swift
432 lines
15 KiB
Swift
//
|
||
// HomeViewModel.swift
|
||
// suixinkan
|
||
//
|
||
|
||
import Foundation
|
||
|
||
/// 首页 ViewModel,对齐 Android `HomeViewModel` 的权限、门店与位置逻辑。
|
||
final class HomeViewModel {
|
||
|
||
/// 首页位置上报卡片固定提示文案。
|
||
static let locationReportPromptText = "您已进入打卡范围"
|
||
|
||
private(set) var currentScenicName = ""
|
||
private(set) var currentAppRole: AppRoleCode?
|
||
private(set) var commonMenus: [HomeMenuItem] = []
|
||
private(set) var storeItem: StoreItem?
|
||
private(set) var showPermissionDialog = false
|
||
private(set) var showScenicDialog = false
|
||
private(set) var showLocationTimeoutDialog = false
|
||
private(set) var showOnlineStatusDialog = false
|
||
private(set) var showLocationReportSuccessDialog = false
|
||
private(set) var isMinimalTopRole = false
|
||
private(set) var locationInfoText = HomeViewModel.locationReportPromptText
|
||
private(set) var reportTimeText = ""
|
||
private(set) var isLocationReporting = false
|
||
private(set) var needsPermissionReload = false
|
||
|
||
var onStateChange: (() -> Void)?
|
||
var onShowMessage: ((String) -> Void)?
|
||
|
||
let locationStateStore: HomeLocationStateStore
|
||
let locationReportService: LocationReportService
|
||
private let appStore: AppStore
|
||
private let commonMenuStore: HomeCommonMenuStore
|
||
private let locationProvider: any LocationProviding
|
||
|
||
private var timeoutDialogTriggered = false
|
||
private var dismissedLocationTimeoutDialog = false
|
||
|
||
init(
|
||
appStore: AppStore = .shared,
|
||
locationStateStore: HomeLocationStateStore = .shared,
|
||
commonMenuStore: HomeCommonMenuStore = HomeCommonMenuStore(),
|
||
locationProvider: any LocationProviding = LocationProvider.shared
|
||
) {
|
||
self.appStore = appStore
|
||
self.locationStateStore = locationStateStore
|
||
self.locationProvider = locationProvider
|
||
self.locationReportService = LocationReportService(
|
||
appStore: appStore,
|
||
locationStateStore: locationStateStore,
|
||
locationProvider: locationProvider
|
||
)
|
||
self.commonMenuStore = commonMenuStore
|
||
self.locationStateStore.observe(self) { [weak self] in
|
||
self?.notifyStateChange()
|
||
}
|
||
}
|
||
|
||
deinit {
|
||
locationStateStore.removeObserver(self)
|
||
}
|
||
|
||
var isOnline: Bool { locationStateStore.isOnline }
|
||
var countdownDisplayText: String { locationStateStore.countdownDisplayText }
|
||
var reminderMinutes: Int { locationStateStore.reminderMinutes }
|
||
|
||
/// 首次进入首页时加载权限并恢复在线状态。
|
||
func initialize(api: HomeAPI) async {
|
||
locationStateStore.restoreStateIfNeeded()
|
||
refreshLocalDisplayState()
|
||
await loadPermissions(api: api, force: true)
|
||
}
|
||
|
||
/// 账号切换后标记需要重新拉取权限。
|
||
func markNeedsPermissionReload() {
|
||
needsPermissionReload = true
|
||
}
|
||
|
||
/// 按需重新拉取权限。
|
||
func reloadIfNeeded(api: HomeAPI) async {
|
||
locationStateStore.restoreStateIfNeeded()
|
||
guard needsPermissionReload else {
|
||
refreshLocalDisplayState()
|
||
rebuildCommonMenus()
|
||
await loadStoreListIfNeeded(api: api)
|
||
notifyStateChange()
|
||
return
|
||
}
|
||
needsPermissionReload = false
|
||
await loadPermissions(api: api, force: true)
|
||
}
|
||
|
||
/// 拉取 role-permission 并刷新菜单。
|
||
func loadPermissions(api: HomeAPI, force: Bool = false) async {
|
||
if !force, !appStore.permissions.permissionItems().isEmpty {
|
||
rebuildCommonMenus()
|
||
await loadStoreListIfNeeded(api: api)
|
||
notifyStateChange()
|
||
return
|
||
}
|
||
|
||
do {
|
||
let rolePermissionList = try await api.rolePermissions()
|
||
if rolePermissionList.isEmpty {
|
||
appStore.permissions.savePermissionItems([])
|
||
showPermissionDialog = true
|
||
rebuildCommonMenus()
|
||
notifyStateChange()
|
||
return
|
||
}
|
||
|
||
appStore.permissions.saveRolePermissionList(rolePermissionList)
|
||
let matched = appStore.permissions.matchRolePermissionItem(in: rolePermissionList) ?? rolePermissionList[0]
|
||
if !matched.role.name.isEmpty {
|
||
appStore.session.roleName = matched.role.name
|
||
}
|
||
appStore.permissions.saveRoleScenicList(matched.scenic)
|
||
let permissions = matched.role.permission.map(HomePermissionItem.init)
|
||
appStore.permissions.savePermissionItems(permissions)
|
||
showPermissionDialog = false
|
||
refreshLocalDisplayState()
|
||
rebuildCommonMenus()
|
||
await loadStoreListIfNeeded(api: api)
|
||
notifyStateChange()
|
||
} catch is CancellationError {
|
||
return
|
||
} catch {
|
||
appStore.permissions.savePermissionItems([])
|
||
showPermissionDialog = true
|
||
rebuildCommonMenus()
|
||
onShowMessage?(error.localizedDescription)
|
||
notifyStateChange()
|
||
}
|
||
}
|
||
|
||
/// 店铺管理员加载当前景区门店。
|
||
func loadStoreListIfNeeded(api: HomeAPI) async {
|
||
guard currentAppRole == .storeAdmin else {
|
||
storeItem = nil
|
||
notifyStateChange()
|
||
return
|
||
}
|
||
guard appStore.session.currentScenicId > 0 else {
|
||
storeItem = nil
|
||
notifyStateChange()
|
||
return
|
||
}
|
||
|
||
do {
|
||
let response = try await api.storeList()
|
||
let scenicId = appStore.session.currentScenicId
|
||
let savedStoreId = appStore.session.currentStoreId
|
||
let savedMatch = savedStoreId > 0 ? response.list.first { $0.id == savedStoreId } : nil
|
||
let matched = savedMatch ?? response.list.first { $0.scenicId == scenicId }
|
||
storeItem = matched
|
||
if let matched {
|
||
appStore.session.currentStoreId = matched.id
|
||
}
|
||
notifyStateChange()
|
||
} catch is CancellationError {
|
||
return
|
||
} catch {
|
||
storeItem = nil
|
||
notifyStateChange()
|
||
}
|
||
}
|
||
|
||
/// 评估弹窗展示优先级:权限 > 景区 > 位置超时。
|
||
func evaluateDialogs(api: HomeAPI) async {
|
||
if showPermissionDialog {
|
||
showScenicDialog = false
|
||
return
|
||
}
|
||
let hasScenic = appStore.session.currentScenicId > 0
|
||
showScenicDialog = !hasScenic
|
||
if showScenicDialog || dismissedLocationTimeoutDialog {
|
||
showLocationTimeoutDialog = false
|
||
notifyStateChange()
|
||
return
|
||
}
|
||
guard !isMinimalTopRole else {
|
||
showLocationTimeoutDialog = false
|
||
notifyStateChange()
|
||
return
|
||
}
|
||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||
await checkLocationTimeout(api: api)
|
||
}
|
||
|
||
/// 检查位置上报是否接近超时。
|
||
func checkLocationTimeout(api: HomeAPI) async {
|
||
guard reminderMinutes > 0 else {
|
||
showLocationTimeoutDialog = false
|
||
notifyStateChange()
|
||
return
|
||
}
|
||
let staffId = appStore.session.userId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !staffId.isEmpty else {
|
||
showLocationTimeoutDialog = true
|
||
notifyStateChange()
|
||
return
|
||
}
|
||
|
||
do {
|
||
let detail = try await api.locationDetail(staffId: staffId)
|
||
if detail.expireTime <= 0 {
|
||
showLocationTimeoutDialog = true
|
||
notifyStateChange()
|
||
return
|
||
}
|
||
let currentMillis = Int64(Date().timeIntervalSince1970 * 1000)
|
||
let expireMillis = detail.expireTime * 1000
|
||
let threshold = expireMillis - Int64(reminderMinutes * 60 * 1000)
|
||
showLocationTimeoutDialog = currentMillis >= threshold
|
||
notifyStateChange()
|
||
} catch is CancellationError {
|
||
return
|
||
} catch {
|
||
showLocationTimeoutDialog = true
|
||
notifyStateChange()
|
||
}
|
||
}
|
||
|
||
/// 刷新景区名与角色展示状态,对齐 Android `initCurrentScenicName`。
|
||
func refreshLocalDisplayState() {
|
||
let scenicId = appStore.session.currentScenicId
|
||
let scenicName = appStore.session.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if scenicId > 0, !scenicName.isEmpty {
|
||
currentScenicName = scenicName
|
||
} else {
|
||
currentScenicName = ""
|
||
appStore.session.currentScenicId = 0
|
||
appStore.session.currentScenicName = ""
|
||
}
|
||
currentAppRole = appStore.session.currentAppRole
|
||
isMinimalTopRole = currentAppRole?.usesHomeMinimalTopLayout ?? false
|
||
}
|
||
|
||
/// 重建常用应用列表。
|
||
func rebuildCommonMenus() {
|
||
let permissions = appStore.permissions.permissionItems()
|
||
let allMenus = HomeMenuCatalog.visibleMenus(from: permissions)
|
||
let accountScope = appStore.session.accountCachePrefix
|
||
let roleCode = appStore.session.roleCode
|
||
let savedURIs = commonMenuStore.savedCommonURIs(accountScope: accountScope, roleCode: roleCode)
|
||
|
||
if savedURIs.isEmpty, !permissions.isEmpty {
|
||
let defaultURIs = HomeCommonMenuStore.defaultCommonURIs(from: permissions)
|
||
commonMenuStore.saveCommonURIs(defaultURIs, accountScope: accountScope, roleCode: roleCode)
|
||
}
|
||
|
||
commonMenus = commonMenuStore.commonMenus(
|
||
from: allMenus,
|
||
permissions: permissions,
|
||
accountScope: accountScope,
|
||
roleCode: roleCode
|
||
)
|
||
}
|
||
|
||
func hidePermissionDialog() {
|
||
showPermissionDialog = false
|
||
notifyStateChange()
|
||
}
|
||
|
||
func hideScenicDialog() {
|
||
showScenicDialog = false
|
||
notifyStateChange()
|
||
}
|
||
|
||
func showOnlineStatusSwitchDialog() {
|
||
showOnlineStatusDialog = true
|
||
notifyStateChange()
|
||
}
|
||
|
||
func hideOnlineStatusSwitchDialog() {
|
||
showOnlineStatusDialog = false
|
||
notifyStateChange()
|
||
}
|
||
|
||
func hideLocationTimeoutDialog() {
|
||
showLocationTimeoutDialog = false
|
||
dismissedLocationTimeoutDialog = true
|
||
timeoutDialogTriggered = false
|
||
locationStateStore.updateOnlineStatus(false)
|
||
notifyStateChange()
|
||
}
|
||
|
||
func hideLocationReportSuccessDialog() {
|
||
showLocationReportSuccessDialog = false
|
||
notifyStateChange()
|
||
}
|
||
|
||
func updateReminderMinutes(_ minutes: Int) {
|
||
locationStateStore.updateReminderMinutes(minutes)
|
||
notifyStateChange()
|
||
}
|
||
|
||
/// 切换在线/离线并触发位置上报。
|
||
func switchOnlineStatus(api: HomeAPI) async {
|
||
hideOnlineStatusSwitchDialog()
|
||
isLocationReporting = true
|
||
notifyStateChange()
|
||
defer {
|
||
isLocationReporting = locationReportService.isReporting
|
||
notifyStateChange()
|
||
}
|
||
|
||
do {
|
||
let goingOnline = !isOnline
|
||
if let result = try await locationReportService.switchOnlineStatus(api: api) {
|
||
applyReportSuccess(result)
|
||
onShowMessage?(goingOnline ? "已切换到在线状态,正在上报位置" : "已切换到离线状态")
|
||
} else {
|
||
onShowMessage?("已切换到离线状态")
|
||
}
|
||
timeoutDialogTriggered = false
|
||
dismissedLocationTimeoutDialog = false
|
||
} catch let error as LocationReportServiceError {
|
||
onShowMessage?(error.localizedDescription)
|
||
} catch is CancellationError {
|
||
return
|
||
} catch {
|
||
onShowMessage?(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
/// 手动上报位置。
|
||
func manualReportLocation(api: HomeAPI) async {
|
||
isLocationReporting = true
|
||
notifyStateChange()
|
||
defer {
|
||
isLocationReporting = locationReportService.isReporting
|
||
notifyStateChange()
|
||
}
|
||
do {
|
||
let result = try await locationReportService.manualReport(api: api)
|
||
applyReportSuccess(result)
|
||
onShowMessage?("位置上报成功")
|
||
} catch let error as LocationReportServiceError {
|
||
onShowMessage?(error.localizedDescription)
|
||
} catch is CancellationError {
|
||
return
|
||
} catch {
|
||
onShowMessage?(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
/// 指定坐标上报(标记上报 type=2)。
|
||
func reportLocation(
|
||
api: HomeAPI,
|
||
latitude: Double,
|
||
longitude: Double,
|
||
address: String?,
|
||
type: Int
|
||
) async {
|
||
isLocationReporting = true
|
||
notifyStateChange()
|
||
defer {
|
||
isLocationReporting = locationReportService.isReporting
|
||
notifyStateChange()
|
||
}
|
||
do {
|
||
let result = try await locationReportService.reportWithCoordinates(
|
||
api: api,
|
||
latitude: latitude,
|
||
longitude: longitude,
|
||
address: address,
|
||
type: type
|
||
)
|
||
applyReportSuccess(result)
|
||
onShowMessage?("位置上报成功")
|
||
} catch let error as LocationReportServiceError {
|
||
onShowMessage?(error.localizedDescription)
|
||
} catch is CancellationError {
|
||
return
|
||
} catch {
|
||
onShowMessage?(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
/// 位置超时弹窗确认上报。
|
||
func confirmLocationTimeoutReport(api: HomeAPI) async {
|
||
showLocationTimeoutDialog = false
|
||
dismissedLocationTimeoutDialog = false
|
||
await manualReportLocation(api: api)
|
||
}
|
||
|
||
/// 触发本地倒计时提醒。
|
||
func triggerLocationTimeoutIfNeeded() {
|
||
guard locationStateStore.shouldTriggerTimeoutReminder() else { return }
|
||
guard !timeoutDialogTriggered else { return }
|
||
timeoutDialogTriggered = true
|
||
showLocationTimeoutDialog = true
|
||
notifyStateChange()
|
||
}
|
||
|
||
/// 刷新当前位置文案。
|
||
func refreshLocationInfo() async {
|
||
isLocationReporting = true
|
||
notifyStateChange()
|
||
defer {
|
||
isLocationReporting = false
|
||
notifyStateChange()
|
||
}
|
||
do {
|
||
let snapshot = try await locationProvider.requestSnapshot()
|
||
locationInfoText = snapshot.address.isEmpty
|
||
? String(format: "%.5f, %.5f", snapshot.latitude, snapshot.longitude)
|
||
: snapshot.address
|
||
} catch {
|
||
locationInfoText = "定位失败"
|
||
}
|
||
}
|
||
|
||
private func applyReportSuccess(_ result: LocationReportSuccessResult) {
|
||
locationInfoText = result.locationInfoText
|
||
if result.shouldShowSuccessDialog {
|
||
reportTimeText = result.reportTimeText
|
||
showLocationReportSuccessDialog = true
|
||
timeoutDialogTriggered = false
|
||
dismissedLocationTimeoutDialog = false
|
||
}
|
||
notifyStateChange()
|
||
}
|
||
|
||
private func notifyStateChange() {
|
||
onStateChange?()
|
||
}
|
||
}
|