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>
150 lines
4.8 KiB
Swift
150 lines
4.8 KiB
Swift
//
|
||
// ScenicSelectionModels.swift
|
||
// suixinkan
|
||
//
|
||
|
||
import Foundation
|
||
|
||
/// 景区列表展示标签,对齐 Android `openStatus`。
|
||
enum ScenicSelectionOpenStatus: Equatable {
|
||
case nearest
|
||
case closed
|
||
case none
|
||
}
|
||
|
||
/// 景区选择列表项,对齐 Android `Sceniclistv2Entity` 展示字段。
|
||
struct ScenicSpotDisplayItem: Equatable, Hashable {
|
||
let id: Int
|
||
let name: String
|
||
let coverImg: String
|
||
let address: String
|
||
let distanceText: String
|
||
let openStatus: ScenicSelectionOpenStatus
|
||
let isSelected: Bool
|
||
|
||
var statusLabel: String? {
|
||
switch openStatus {
|
||
case .nearest: "距离最近"
|
||
case .closed: "暂未营业"
|
||
case .none: nil
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 景区距离计算,对齐 Android Haversine 逻辑。
|
||
enum ScenicDistanceCalculator {
|
||
private static let earthRadiusMeters = 6_371_000.0
|
||
|
||
static func distanceKilometers(
|
||
fromLat: Double,
|
||
fromLng: Double,
|
||
toLat: Double,
|
||
toLng: Double
|
||
) -> Double {
|
||
let dLat = (toLat - fromLat) * .pi / 180
|
||
let dLng = (toLng - fromLng) * .pi / 180
|
||
let lat1 = fromLat * .pi / 180
|
||
let lat2 = toLat * .pi / 180
|
||
let a = sin(dLat / 2) * sin(dLat / 2)
|
||
+ cos(lat1) * cos(lat2) * sin(dLng / 2) * sin(dLng / 2)
|
||
let c = 2 * atan2(sqrt(a), sqrt(1 - a))
|
||
return (earthRadiusMeters * c) / 1000
|
||
}
|
||
|
||
static func formatDistanceKilometers(_ kilometers: Double) -> String {
|
||
if kilometers <= 0 || !kilometers.isFinite {
|
||
return "0"
|
||
}
|
||
return String(format: "%.2f", kilometers)
|
||
}
|
||
}
|
||
|
||
/// 将 role-permission 景区映射为选择页展示项。
|
||
enum ScenicSpotDisplayMapper {
|
||
static func buildItems(
|
||
scenicList: [ScenicInfo],
|
||
searchQuery: String,
|
||
currentLat: Double?,
|
||
currentLng: Double?,
|
||
selectedScenicId: Int
|
||
) -> [ScenicSpotDisplayItem] {
|
||
let hasLocation = (currentLat ?? 0) > 0 && (currentLng ?? 0) > 0
|
||
let searchLower = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||
|
||
struct ScenicWithDistance {
|
||
let item: ScenicSpotDisplayItem
|
||
let distance: Double
|
||
}
|
||
|
||
let mapped = scenicList.map { scenic -> ScenicWithDistance in
|
||
let loc = scenic.location
|
||
let distance: Double
|
||
if let loc, hasLocation, let currentLat, let currentLng {
|
||
distance = ScenicDistanceCalculator.distanceKilometers(
|
||
fromLat: currentLat,
|
||
fromLng: currentLng,
|
||
toLat: loc.lat,
|
||
toLng: loc.lng
|
||
)
|
||
} else if loc == nil, hasLocation {
|
||
distance = .greatestFiniteMagnitude
|
||
} else {
|
||
distance = 0
|
||
}
|
||
|
||
let address = loc?.address.isEmpty == false ? (loc?.address ?? "--") : "--"
|
||
let distanceText: String
|
||
if loc == nil {
|
||
distanceText = "--"
|
||
} else if distance > 0, distance < .greatestFiniteMagnitude {
|
||
distanceText = ScenicDistanceCalculator.formatDistanceKilometers(distance)
|
||
} else {
|
||
distanceText = "0"
|
||
}
|
||
|
||
let openStatus: ScenicSelectionOpenStatus = scenic.status == 2 ? .closed : .none
|
||
let item = ScenicSpotDisplayItem(
|
||
id: scenic.id,
|
||
name: scenic.name,
|
||
coverImg: scenic.coverImg ?? "",
|
||
address: address,
|
||
distanceText: distanceText,
|
||
openStatus: openStatus,
|
||
isSelected: selectedScenicId > 0 && scenic.id == selectedScenicId
|
||
)
|
||
return ScenicWithDistance(item: item, distance: distance)
|
||
}
|
||
|
||
let filtered = mapped.filter { entry in
|
||
guard !searchLower.isEmpty else { return true }
|
||
return entry.item.name.lowercased().contains(searchLower)
|
||
|| entry.item.address.lowercased().contains(searchLower)
|
||
}
|
||
|
||
let sorted: [ScenicWithDistance]
|
||
if hasLocation {
|
||
sorted = filtered.sorted { $0.distance < $1.distance }
|
||
} else {
|
||
sorted = filtered
|
||
}
|
||
|
||
let baseItems = sorted.map(\.item)
|
||
guard hasLocation, !baseItems.isEmpty else { return baseItems }
|
||
|
||
return baseItems.enumerated().map { index, item in
|
||
if index == 0, item.openStatus != .closed {
|
||
return ScenicSpotDisplayItem(
|
||
id: item.id,
|
||
name: item.name,
|
||
coverImg: item.coverImg,
|
||
address: item.address,
|
||
distanceText: item.distanceText,
|
||
openStatus: .nearest,
|
||
isSelected: item.isSelected
|
||
)
|
||
}
|
||
return item
|
||
}
|
||
}
|
||
}
|