将最低部署版本降至 iOS 16,以 ObservableObject 替换 @Observable,新增导航与 UI 兼容层,并补充登录冒烟 UI 测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
150 lines
5.6 KiB
Swift
150 lines
5.6 KiB
Swift
//
|
||
// ScenicSelectionViewModel.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/23.
|
||
//
|
||
|
||
import CoreLocation
|
||
import Foundation
|
||
import Combine
|
||
|
||
/// 景区选择展示实体,表示列表中的一个可切换景区。
|
||
struct ScenicSelectionItem: Equatable, Identifiable {
|
||
let id: Int
|
||
let name: String
|
||
let status: Int?
|
||
let address: String
|
||
let latitude: Double?
|
||
let longitude: Double?
|
||
let coverURLString: String?
|
||
var isClosest: Bool
|
||
var distanceMeters: CLLocationDistance?
|
||
|
||
/// 从业务作用域创建景区选择展示实体。
|
||
init(scope: BusinessScope) {
|
||
id = scope.id
|
||
name = scope.name
|
||
status = scope.status
|
||
let trimmedAddress = scope.address?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||
address = trimmedAddress.isEmpty ? "--" : trimmedAddress
|
||
latitude = scope.latitude
|
||
longitude = scope.longitude
|
||
coverURLString = scope.coverURLString
|
||
isClosest = false
|
||
distanceMeters = nil
|
||
}
|
||
|
||
/// 返回景区距离文案,无定位结果时显示占位。
|
||
var distanceText: String {
|
||
guard let distanceMeters else { return "--" }
|
||
if distanceMeters < 1_000 {
|
||
return "\(Int(distanceMeters.rounded()))m"
|
||
}
|
||
return String(format: "%.2fkm", distanceMeters / 1_000)
|
||
}
|
||
|
||
/// 返回景区是否处于旧工程定义的暂未营业状态。
|
||
var isClosed: Bool {
|
||
status == 2
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
/// 景区选择 ViewModel,负责搜索、定位距离计算和切换景区持久化。
|
||
final class ScenicSelectionViewModel: ObservableObject {
|
||
@Published var searchQuery = ""
|
||
@Published var currentLocationText = "定位后获取最近景区"
|
||
@Published var locationWarning: String?
|
||
@Published private(set) var items: [ScenicSelectionItem] = []
|
||
|
||
/// 返回按搜索关键词过滤后的景区列表。
|
||
var filteredItems: [ScenicSelectionItem] {
|
||
let keyword = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !keyword.isEmpty else { return items }
|
||
return items.filter { item in
|
||
item.name.localizedCaseInsensitiveContains(keyword)
|
||
|| item.address.localizedCaseInsensitiveContains(keyword)
|
||
}
|
||
}
|
||
|
||
/// 从账号上下文重新加载可选景区,并优先把当前景区放在第一位。
|
||
func reload(from accountContext: AccountContext) {
|
||
let currentScenicId = accountContext.currentScenic?.id
|
||
items = Self.makeItems(scopes: accountContext.scenicScopes, currentScenicId: currentScenicId)
|
||
currentLocationText = items.first?.address ?? "定位后获取最近景区"
|
||
locationWarning = nil
|
||
}
|
||
|
||
/// 切换景区并把当前选择保存到账号快照。
|
||
func select(
|
||
scenicId: Int,
|
||
accountContext: AccountContext,
|
||
permissionContext: PermissionContext,
|
||
snapshotStore: AccountSnapshotStore
|
||
) {
|
||
accountContext.selectScenic(id: scenicId)
|
||
snapshotStore.saveCurrentSelection(
|
||
accountContext: accountContext,
|
||
currentRoleId: permissionContext.currentRole?.id
|
||
)
|
||
}
|
||
|
||
/// 定位成功后计算距离,并标记距离最近景区。
|
||
func applyCurrentLocation(latitude: Double, longitude: Double) {
|
||
locationWarning = nil
|
||
let current = CLLocation(latitude: latitude, longitude: longitude)
|
||
for index in items.indices {
|
||
guard let scenicLatitude = items[index].latitude,
|
||
let scenicLongitude = items[index].longitude,
|
||
scenicLatitude != 0 || scenicLongitude != 0
|
||
else {
|
||
items[index].distanceMeters = nil
|
||
items[index].isClosest = false
|
||
continue
|
||
}
|
||
let scenicLocation = CLLocation(latitude: scenicLatitude, longitude: scenicLongitude)
|
||
items[index].distanceMeters = scenicLocation.distance(from: current)
|
||
items[index].isClosest = false
|
||
}
|
||
if let closestIndex = items.indices
|
||
.filter({ items[$0].distanceMeters != nil })
|
||
.min(by: { (items[$0].distanceMeters ?? .greatestFiniteMagnitude) < (items[$1].distanceMeters ?? .greatestFiniteMagnitude) }) {
|
||
items[closestIndex].isClosest = true
|
||
currentLocationText = items[closestIndex].address
|
||
} else if let first = items.first {
|
||
items[0].isClosest = true
|
||
currentLocationText = first.address
|
||
}
|
||
}
|
||
|
||
/// 定位失败时保留原列表并暴露提示文案。
|
||
func applyLocationFailure(_ message: String) {
|
||
locationWarning = message
|
||
currentLocationText = items.first?.address ?? "定位后获取最近景区"
|
||
if !items.isEmpty, !items.contains(where: \.isClosest) {
|
||
items[0].isClosest = true
|
||
}
|
||
}
|
||
|
||
/// 从景区作用域生成展示列表,并把当前景区移动到首位。
|
||
static func makeItems(scopes: [BusinessScope], currentScenicId: Int?) -> [ScenicSelectionItem] {
|
||
var seen = Set<Int>()
|
||
let uniqueItems = scopes.compactMap { scope -> ScenicSelectionItem? in
|
||
guard scope.kind == .scenic, seen.insert(scope.id).inserted else { return nil }
|
||
return ScenicSelectionItem(scope: scope)
|
||
}
|
||
var result: [ScenicSelectionItem]
|
||
if let currentScenicId,
|
||
let current = uniqueItems.first(where: { $0.id == currentScenicId }) {
|
||
result = [current] + uniqueItems.filter { $0.id != currentScenicId }
|
||
} else {
|
||
result = uniqueItems
|
||
}
|
||
if !result.isEmpty {
|
||
result[0].isClosest = true
|
||
}
|
||
return result
|
||
}
|
||
}
|