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>
244 lines
10 KiB
Swift
244 lines
10 KiB
Swift
//
|
||
// 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)
|
||
}
|