Add ScenicPermission module and AMap CocoaPods with simulator support.
Integrate scenic selection and permission flows into home routing, add AMap SDK for device builds while keeping arm64 simulators compilable via Podfile post_install hooks. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -0,0 +1,243 @@
|
||||
//
|
||||
// ScenicSelectionView.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
import SwiftUI
|
||||
|
||||
/// 景区选择页面,支持搜索、定位距离展示和切换当前景区。
|
||||
struct ScenicSelectionView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(PermissionContext.self) private var permissionContext
|
||||
@Environment(AccountSnapshotStore.self) private var snapshotStore
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var viewModel = ScenicSelectionViewModel()
|
||||
@State private var locationProvider = ScenicSelectionLocationProvider()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
applyBanner
|
||||
searchBar
|
||||
currentLocationBar
|
||||
content
|
||||
}
|
||||
.background(AppDesign.backgroundColor.ignoresSafeArea())
|
||||
.navigationTitle("景区选择")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
viewModel.reload(from: accountContext)
|
||||
requestLocation()
|
||||
}
|
||||
}
|
||||
|
||||
private var applyBanner: some View {
|
||||
NavigationLink(value: AppRoute.home(.permissionApply)) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "building.2.fill")
|
||||
.font(.system(size: AppMetrics.ControlSize.checkboxIcon, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.warning)
|
||||
Text("希望开通更多景区权限")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
|
||||
.foregroundStyle(AppDesign.warning)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Text("立即申请")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .bold))
|
||||
}
|
||||
.foregroundStyle(AppDesign.warning)
|
||||
.padding(.horizontal, AppMetrics.Spacing.medium)
|
||||
.frame(height: 56)
|
||||
.background(Color(hex: 0xFFF0E2))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var searchBar: some View {
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: AppMetrics.ControlSize.checkboxIcon))
|
||||
.foregroundStyle(AppDesign.placeholder)
|
||||
TextField("搜索景区", text: $viewModel.searchQuery)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled(true)
|
||||
.tint(AppDesign.primary)
|
||||
}
|
||||
.padding(.horizontal, AppMetrics.Spacing.medium)
|
||||
.frame(height: AppMetrics.ControlSize.inputHeight)
|
||||
.background(Color(hex: 0xF2F4F8), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||||
.padding(.horizontal, AppMetrics.Spacing.medium)
|
||||
.padding(.top, AppMetrics.Spacing.medium)
|
||||
}
|
||||
|
||||
private var currentLocationBar: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
Image(systemName: "location.fill")
|
||||
.font(.system(size: AppMetrics.ControlSize.smallIcon, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
Text(viewModel.currentLocationText)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Button("当前定位地址") {
|
||||
requestLocation()
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
if let warning = viewModel.locationWarning {
|
||||
Text(warning)
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.warning)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, AppMetrics.Spacing.medium)
|
||||
.padding(.vertical, AppMetrics.Spacing.small)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
ScrollView {
|
||||
if viewModel.filteredItems.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"暂无可选景区",
|
||||
systemImage: "mountain.2",
|
||||
description: Text("可尝试清空搜索关键词或刷新定位。")
|
||||
)
|
||||
.frame(maxWidth: .infinity, minHeight: 240)
|
||||
.padding(.top, AppMetrics.Spacing.xLarge)
|
||||
} else {
|
||||
LazyVStack(spacing: AppMetrics.Spacing.small) {
|
||||
ForEach(viewModel.filteredItems) { scenic in
|
||||
ScenicSelectionCard(
|
||||
scenic: scenic,
|
||||
isSelected: accountContext.currentScenic?.id == scenic.id
|
||||
) {
|
||||
viewModel.select(
|
||||
scenicId: scenic.id,
|
||||
accountContext: accountContext,
|
||||
permissionContext: permissionContext,
|
||||
snapshotStore: snapshotStore
|
||||
)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, AppMetrics.Spacing.large)
|
||||
.padding(.bottom, AppMetrics.Spacing.xLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 请求当前位置,并把定位结果回写 ViewModel。
|
||||
private func requestLocation() {
|
||||
viewModel.currentLocationText = "定位中..."
|
||||
viewModel.locationWarning = nil
|
||||
locationProvider.onLocation = { location in
|
||||
Task { @MainActor in
|
||||
viewModel.applyCurrentLocation(
|
||||
latitude: location.coordinate.latitude,
|
||||
longitude: location.coordinate.longitude
|
||||
)
|
||||
}
|
||||
}
|
||||
locationProvider.onFailure = { message in
|
||||
Task { @MainActor in
|
||||
viewModel.applyLocationFailure(message)
|
||||
}
|
||||
}
|
||||
locationProvider.request()
|
||||
}
|
||||
}
|
||||
|
||||
/// 景区选择卡片,展示封面、名称、地址、距离和状态。
|
||||
private struct ScenicSelectionCard: View {
|
||||
let scenic: ScenicSelectionItem
|
||||
let isSelected: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
RemoteImage(urlString: scenic.coverURLString) {
|
||||
ZStack {
|
||||
Color(hex: 0xF0F0F0)
|
||||
Image(systemName: "photo")
|
||||
.font(.system(size: AppMetrics.FontSize.title, weight: .medium))
|
||||
.foregroundStyle(Color(hex: 0xAAB2BD))
|
||||
}
|
||||
}
|
||||
.frame(width: 80, height: 80)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
Text(scenic.name)
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .bold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.lineLimit(1)
|
||||
statusBadge
|
||||
}
|
||||
HStack(spacing: AppMetrics.Spacing.xxSmall) {
|
||||
Text(scenic.distanceText)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.fixedSize()
|
||||
Text(scenic.address)
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
if isSelected {
|
||||
Label("当前景区", systemImage: "checkmark.circle.fill")
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(AppMetrics.Spacing.small)
|
||||
.frame(minHeight: 104)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(isSelected ? AppDesign.primary : Color(hex: 0xEEF2F7), lineWidth: isSelected ? 2 : 1)
|
||||
}
|
||||
.shadow(color: .black.opacity(isSelected ? 0.08 : 0.03), radius: 8, x: 0, y: 3)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusBadge: some View {
|
||||
if scenic.isClosest {
|
||||
Text("距离最近")
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, AppMetrics.Spacing.xSmall)
|
||||
.frame(height: 26)
|
||||
.background(Color(hex: 0xE74C3C), in: RoundedRectangle(cornerRadius: 4))
|
||||
} else if scenic.isClosed {
|
||||
Text("暂未营业")
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.padding(.horizontal, AppMetrics.Spacing.xSmall)
|
||||
.frame(height: 26)
|
||||
.background(Color(hex: 0xE0E0E0), in: RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppDesign {
|
||||
static let backgroundColor = Color(hex: 0xF5F7FA)
|
||||
}
|
||||
Reference in New Issue
Block a user