Files
suixinkan_ios_new/suixinkan/Features/ScenicPermission/Views/ScenicSelectionView.swift
汉秋 b61bf0d070 新增景区权限模块,集成 AMap CocoaPods 并支持模拟器
将景区选择与权限申请流程接入首页路由,集成高德 SDK 用于真机构建,并通过 Podfile post_install 钩子保证 arm64 模拟器可编译。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 10:15:26 +08:00

244 lines
10 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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