import CoreLocation import MapKit import SwiftUI import UIKit struct TimelineLocationButton: View { @Binding var location: TimelineLocation? @State private var showLocationPicker = false var body: some View { Button { showLocationPicker = true } label: { HStack(spacing: 10) { Image(systemName: location == nil ? "location" : "location.fill") .font(.system(size: 13, weight: .medium)) .foregroundStyle(location == nil ? Color.secondary : Color.lessCosmosGold) .frame(width: 24, height: 24) VStack(alignment: .leading, spacing: 2) { Text(location?.name ?? "所在位置") .font(.system(size: 13, weight: .medium)) .foregroundStyle(Color.primary) .lineLimit(1) Text(locationSubtitle) .font(.system(size: 10)) .foregroundStyle(Color.secondary) .lineLimit(1) } Spacer(minLength: 8) Image(systemName: "chevron.right") .font(.system(size: 9, weight: .medium)) .foregroundStyle(Color.secondary.opacity(0.6)) } .padding(.horizontal, 12) .padding(.vertical, 10) .background(Color.cardBackground.opacity(0.35)) .businessBorder(cornerRadius: 8) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityLabel(location == nil ? "添加所在位置" : "修改所在位置") .accessibilityValue(location?.name ?? "尚未添加") .sheet(isPresented: $showLocationPicker) { TimelineLocationPickerSheet(selection: $location) } } private var locationSubtitle: String { guard let location else { return "点击后根据 GPS 选择当前位置" } return location.subtitle.isEmpty ? "已添加位置" : location.subtitle } } struct TimelineLocationPickerSheet: View { @Environment(\.dismiss) private var dismiss @Binding var selection: TimelineLocation? @StateObject private var locationService = TimelineLocationService() @State private var selectedLocation: TimelineLocation? @State private var nearbyLocations: [TimelineLocation] = [] @State private var searchResults: [TimelineLocation] = [] @State private var cameraPosition: MapCameraPosition = .automatic @State private var searchText = "" @State private var isLoadingPlaces = false @State private var placeError: String? @State private var hasMadeSelection = false init(selection: Binding) { _selection = selection _selectedLocation = State(initialValue: selection.wrappedValue) } var body: some View { NavigationStack { VStack(spacing: 0) { locationMap .frame(height: 220) Divider() .overlay(Color.lineBorder) locationList } .background(Color.spaceBlack) .navigationTitle("所在位置") .navigationBarTitleDisplayMode(.inline) .searchable( text: $searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "搜索附近地点" ) .onSubmit(of: .search) { Task { await searchPlaces() } } .onChange(of: searchText) { _, newValue in if newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { searchResults = [] placeError = nil } } .toolbar { ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("完成") { selection = selectedLocation dismiss() } } } } .onAppear { locationService.requestCurrentLocation() if let selectedLocation { centerMap(on: selectedLocation) } } .onChange(of: locationService.currentLocation) { _, location in guard let location else { return } if selectedLocation == nil, !hasMadeSelection { selectedLocation = location } centerMap(on: selectedLocation ?? location) Task { await loadNearbyPlaces(around: location) } } .presentationDetents([.large]) .presentationDragIndicator(.visible) .presentationBackground(Color.spaceBlack) } private var locationMap: some View { Map(position: $cameraPosition) { UserAnnotation() if let selectedLocation { Marker( selectedLocation.name, coordinate: coordinate(for: selectedLocation) ) .tint(Color.lessCosmosGold) } } .mapStyle(.standard(pointsOfInterest: .all)) .overlay(alignment: .bottomTrailing) { Button { locationService.requestCurrentLocation() } label: { Image(systemName: "location.fill") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(Color.primary) .frame(width: 38, height: 38) .background(.ultraThinMaterial, in: Circle()) } .padding(12) .accessibilityLabel("重新定位") } } private var locationList: some View { ScrollView { LazyVStack(spacing: 0) { noLocationRow if let currentLocation = locationService.currentLocation { locationRow( currentLocation, icon: "location.fill", iconColor: Color.lessCosmosGold ) } else { locatingRow } if isLoadingPlaces { HStack(spacing: 10) { ProgressView() Text(searchText.isEmpty ? "正在查找附近地点…" : "正在搜索地点…") .font(.system(size: 12)) .foregroundStyle(Color.secondary) Spacer() } .padding(.horizontal, 20) .padding(.vertical, 16) } ForEach(displayedLocations, id: \.self) { location in locationRow(location) } if let message = locationService.errorMessage ?? placeError { locationErrorRow(message) } } } .scrollDismissesKeyboard(.interactively) } private var noLocationRow: some View { Button { hasMadeSelection = true selectedLocation = nil } label: { HStack(spacing: 12) { Image(systemName: "location.slash") .font(.system(size: 13, weight: .medium)) .foregroundStyle(Color.secondary) .frame(width: 28) Text("不显示位置") .font(.system(size: 14, weight: .medium)) .foregroundStyle(Color.primary) Spacer() if selectedLocation == nil { Image(systemName: "checkmark") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Color.lessCosmosGold) } } .padding(.horizontal, 20) .padding(.vertical, 15) .contentShape(Rectangle()) } .buttonStyle(.plain) .overlay(alignment: .bottom) { Divider() .padding(.leading, 60) .overlay(Color.lineBorder) } } private var locatingRow: some View { HStack(spacing: 12) { if locationService.isLocating { ProgressView() .frame(width: 28) } else { Image(systemName: "location") .foregroundStyle(Color.secondary) .frame(width: 28) } VStack(alignment: .leading, spacing: 3) { Text(locationService.isLocating ? "正在定位…" : "当前位置") .font(.system(size: 14, weight: .medium)) .foregroundStyle(Color.primary) Text("用于查找你附近的地点") .font(.system(size: 11)) .foregroundStyle(Color.secondary) } Spacer() } .padding(.horizontal, 20) .padding(.vertical, 14) } private func locationRow( _ location: TimelineLocation, icon: String = "mappin.and.ellipse", iconColor: Color = Color.secondary ) -> some View { Button { hasMadeSelection = true selectedLocation = location centerMap(on: location) } label: { HStack(spacing: 12) { Image(systemName: icon) .font(.system(size: 13, weight: .medium)) .foregroundStyle(iconColor) .frame(width: 28) VStack(alignment: .leading, spacing: 3) { Text(location.name) .font(.system(size: 14, weight: .medium)) .foregroundStyle(Color.primary) .lineLimit(1) if !location.subtitle.isEmpty { Text(location.subtitle) .font(.system(size: 11)) .foregroundStyle(Color.secondary) .lineLimit(2) } } Spacer(minLength: 8) if selectedLocation == location { Image(systemName: "checkmark") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Color.lessCosmosGold) } } .padding(.horizontal, 20) .padding(.vertical, 13) .contentShape(Rectangle()) } .buttonStyle(.plain) .overlay(alignment: .bottom) { Divider() .padding(.leading, 60) .overlay(Color.lineBorder.opacity(0.7)) } } private func locationErrorRow(_ message: String) -> some View { VStack(alignment: .leading, spacing: 10) { Text(message) .font(.system(size: 12)) .foregroundStyle(Color.secondary) if locationService.isAuthorizationDenied { Button("前往系统设置") { guard let settingsURL = URL(string: UIApplication.openSettingsURLString) else { return } UIApplication.shared.open(settingsURL) } .font(.system(size: 12, weight: .semibold)) } else { Button("重新定位") { locationService.requestCurrentLocation() } .font(.system(size: 12, weight: .semibold)) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(20) } private var displayedLocations: [TimelineLocation] { searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nearbyLocations : searchResults } @MainActor private func loadNearbyPlaces(around location: TimelineLocation) async { isLoadingPlaces = true placeError = nil defer { isLoadingPlaces = false } let request = MKLocalPointsOfInterestRequest( center: coordinate(for: location), radius: 2_000 ) request.pointOfInterestFilter = .includingAll do { let response = try await MKLocalSearch(request: request).start() nearbyLocations = uniqueLocations( response.mapItems.compactMap(location(from:)) ) } catch { placeError = "附近地点暂时无法加载,你仍可使用 GPS 当前位置。" } } @MainActor private func searchPlaces() async { let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) guard !query.isEmpty, let center = locationService.currentLocation ?? selectedLocation else { return } isLoadingPlaces = true placeError = nil defer { isLoadingPlaces = false } let request = MKLocalSearch.Request() request.naturalLanguageQuery = query request.region = MKCoordinateRegion( center: coordinate(for: center), latitudinalMeters: 10_000, longitudinalMeters: 10_000 ) do { let response = try await MKLocalSearch(request: request).start() searchResults = uniqueLocations( response.mapItems.compactMap(location(from:)) ) if searchResults.isEmpty { placeError = "没有找到相关地点,请换一个关键词。" } } catch { placeError = "地点搜索失败,请稍后重试。" } } private func location(from mapItem: MKMapItem) -> TimelineLocation? { guard let name = mapItem.name? .trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty else { return nil } let coordinate = mapItem.placemark.coordinate return TimelineLocation( name: name, address: mapItem.placemark.title ?? "", latitude: coordinate.latitude, longitude: coordinate.longitude ) } private func uniqueLocations( _ locations: [TimelineLocation] ) -> [TimelineLocation] { var keys = Set() return locations.filter { location in let key = [ location.name, String(format: "%.5f", location.latitude), String(format: "%.5f", location.longitude) ].joined(separator: "|") return keys.insert(key).inserted } } private func centerMap(on location: TimelineLocation) { cameraPosition = .region( MKCoordinateRegion( center: coordinate(for: location), latitudinalMeters: 1_200, longitudinalMeters: 1_200 ) ) } private func coordinate( for location: TimelineLocation ) -> CLLocationCoordinate2D { CLLocationCoordinate2D( latitude: location.latitude, longitude: location.longitude ) } } private final class TimelineLocationService: NSObject, ObservableObject { @Published private(set) var currentLocation: TimelineLocation? @Published private(set) var isLocating = false @Published private(set) var errorMessage: String? @Published private(set) var authorizationStatus: CLAuthorizationStatus private let manager = CLLocationManager() private let geocoder = CLGeocoder() override init() { authorizationStatus = manager.authorizationStatus super.init() manager.delegate = self manager.desiredAccuracy = kCLLocationAccuracyBest } var isAuthorizationDenied: Bool { authorizationStatus == .denied || authorizationStatus == .restricted } func requestCurrentLocation() { errorMessage = nil switch manager.authorizationStatus { case .notDetermined: isLocating = true manager.requestWhenInUseAuthorization() case .authorizedAlways, .authorizedWhenInUse: isLocating = true manager.requestLocation() case .denied, .restricted: isLocating = false errorMessage = "定位权限未开启。你可以前往系统设置允许访问当前位置。" @unknown default: isLocating = false errorMessage = "暂时无法确认定位权限。" } } } extension TimelineLocationService: CLLocationManagerDelegate { func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { authorizationStatus = manager.authorizationStatus if manager.authorizationStatus == .authorizedAlways || manager.authorizationStatus == .authorizedWhenInUse { isLocating = true manager.requestLocation() } else if manager.authorizationStatus == .denied || manager.authorizationStatus == .restricted { isLocating = false errorMessage = "定位权限未开启。你可以前往系统设置允许访问当前位置。" } } func locationManager( _ manager: CLLocationManager, didUpdateLocations locations: [CLLocation] ) { guard let location = locations.last else { return } isLocating = false geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, error in DispatchQueue.main.async { guard let self else { return } if let placemark = placemarks?.first { self.currentLocation = TimelineLocation( name: "当前位置", address: Self.address(from: placemark), latitude: location.coordinate.latitude, longitude: location.coordinate.longitude ) } else { self.currentLocation = TimelineLocation( name: "当前位置", address: String( format: "%.6f, %.6f", location.coordinate.latitude, location.coordinate.longitude ), latitude: location.coordinate.latitude, longitude: location.coordinate.longitude ) if error != nil { self.errorMessage = "已取得 GPS 位置,但暂时无法解析详细地址。" } } } } } func locationManager( _ manager: CLLocationManager, didFailWithError error: Error ) { isLocating = false if (error as? CLError)?.code == .locationUnknown { errorMessage = "暂时无法确定当前位置,请移到开阔区域后重试。" } else { errorMessage = "定位失败,请检查系统定位服务后重试。" } } private static func address(from placemark: CLPlacemark) -> String { var parts: [String] = [] for part in [ placemark.administrativeArea, placemark.locality, placemark.subLocality, placemark.thoroughfare, placemark.subThoroughfare, placemark.name ] { guard let part = part?.trimmingCharacters(in: .whitespacesAndNewlines), !part.isEmpty, !parts.contains(part) else { continue } parts.append(part) } return parts.joined(separator: " ") } }