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:
107
suixinkan/Features/Home/Models/PermissionApplyModels.swift
Normal file
107
suixinkan/Features/Home/Models/PermissionApplyModels.swift
Normal 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
|
||||
}
|
||||
}
|
||||
149
suixinkan/Features/Home/Models/ScenicSelectionModels.swift
Normal file
149
suixinkan/Features/Home/Models/ScenicSelectionModels.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user