| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580 |
- 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<TimelineLocation?>) {
- _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<String>()
- 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: " ")
- }
- }
|