// // 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, currentRoleCode: permissionContext.currentAppRole?.rawValue ) } /// 定位成功后计算距离,并标记距离最近景区。 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() 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 } }