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,107 @@
//
// PermissionApplyModels.swift
// suixinkan
//
import Foundation
struct RoleApplyPendingResponse: Decodable, Equatable {
let code: String
let roleId: Int
let roleName: String
let scenicList: [RoleApplyScenicItem]
let status: Int
let statusLabel: String
let createdAt: String
let auditNote: String
let auditedAt: String?
let auditedBy: String?
enum CodingKeys: String, CodingKey {
case code
case roleId = "role_id"
case roleName = "role_name"
case scenicList = "scenic_list"
case status
case statusLabel = "status_label"
case createdAt = "created_at"
case auditNote = "audit_note"
case auditedAt = "audited_at"
case auditedBy = "audited_by"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
code = try container.decodeIfPresent(String.self, forKey: .code) ?? ""
roleId = try container.decodeIfPresent(Int.self, forKey: .roleId) ?? 0
roleName = try container.decodeIfPresent(String.self, forKey: .roleName) ?? ""
scenicList = try container.decodeIfPresent([RoleApplyScenicItem].self, forKey: .scenicList) ?? []
status = try container.decodeIfPresent(Int.self, forKey: .status) ?? 0
statusLabel = try container.decodeIfPresent(String.self, forKey: .statusLabel) ?? ""
createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) ?? ""
auditNote = try container.decodeIfPresent(String.self, forKey: .auditNote) ?? ""
auditedAt = try container.decodeIfPresent(String.self, forKey: .auditedAt)
auditedBy = try container.decodeIfPresent(String.self, forKey: .auditedBy)
}
}
struct RoleApplyScenicItem: Decodable, Equatable {
let id: Int
let name: String
}
struct ScenicListAllResponse: Decodable, Equatable {
let total: Int
let list: [ScenicListItem]
init(total: Int = 0, list: [ScenicListItem] = []) {
self.total = total
self.list = list
}
}
struct ScenicListItem: Decodable, Equatable {
let id: Int
let name: String
}
struct RoleApplySubmitRequest: Encodable {
let scenicId: [Int]
let roleId: Int
enum CodingKeys: String, CodingKey {
case scenicId = "scenic_id"
case roleId = "role_id"
}
}
struct RoleOption: Equatable, Hashable {
let id: Int
let name: String
let description: String
var isSelected: Bool
}
struct ScenicOption: Equatable, Hashable {
let id: Int
let name: String
var isSelected: Bool
var isDisabled: Bool
}
struct RoleApplySubmitResult: Decodable, Equatable {
let code: String
init(code: String = "") {
self.code = code
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
code = try container.decodeIfPresent(String.self, forKey: .code) ?? ""
}
private enum CodingKeys: String, CodingKey {
case code
}
}

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
}
}
}