Add scenic selection and cooperation order flows aligned with Android.

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>
This commit is contained in:
2026-07-07 10:48:43 +08:00
parent 3c4ca7eefb
commit 2bea05b1d9
50 changed files with 5603 additions and 191 deletions

View File

@ -0,0 +1,149 @@
//
// 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
}
}
}