新增景区权限模块,集成 AMap CocoaPods 并支持模拟器
将景区选择与权限申请流程接入首页路由,集成高德 SDK 用于真机构建,并通过 Podfile post_install 钩子保证 arm64 模拟器可编译。 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