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