TimelineLocationPicker.swift 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580
  1. import CoreLocation
  2. import MapKit
  3. import SwiftUI
  4. import UIKit
  5. struct TimelineLocationButton: View {
  6. @Binding var location: TimelineLocation?
  7. @State private var showLocationPicker = false
  8. var body: some View {
  9. Button {
  10. showLocationPicker = true
  11. } label: {
  12. HStack(spacing: 10) {
  13. Image(systemName: location == nil ? "location" : "location.fill")
  14. .font(.system(size: 13, weight: .medium))
  15. .foregroundStyle(location == nil ? Color.secondary : Color.lessCosmosGold)
  16. .frame(width: 24, height: 24)
  17. VStack(alignment: .leading, spacing: 2) {
  18. Text(location?.name ?? "所在位置")
  19. .font(.system(size: 13, weight: .medium))
  20. .foregroundStyle(Color.primary)
  21. .lineLimit(1)
  22. Text(locationSubtitle)
  23. .font(.system(size: 10))
  24. .foregroundStyle(Color.secondary)
  25. .lineLimit(1)
  26. }
  27. Spacer(minLength: 8)
  28. Image(systemName: "chevron.right")
  29. .font(.system(size: 9, weight: .medium))
  30. .foregroundStyle(Color.secondary.opacity(0.6))
  31. }
  32. .padding(.horizontal, 12)
  33. .padding(.vertical, 10)
  34. .background(Color.cardBackground.opacity(0.35))
  35. .businessBorder(cornerRadius: 8)
  36. .contentShape(Rectangle())
  37. }
  38. .buttonStyle(.plain)
  39. .accessibilityLabel(location == nil ? "添加所在位置" : "修改所在位置")
  40. .accessibilityValue(location?.name ?? "尚未添加")
  41. .sheet(isPresented: $showLocationPicker) {
  42. TimelineLocationPickerSheet(selection: $location)
  43. }
  44. }
  45. private var locationSubtitle: String {
  46. guard let location else { return "点击后根据 GPS 选择当前位置" }
  47. return location.subtitle.isEmpty ? "已添加位置" : location.subtitle
  48. }
  49. }
  50. struct TimelineLocationPickerSheet: View {
  51. @Environment(\.dismiss) private var dismiss
  52. @Binding var selection: TimelineLocation?
  53. @StateObject private var locationService = TimelineLocationService()
  54. @State private var selectedLocation: TimelineLocation?
  55. @State private var nearbyLocations: [TimelineLocation] = []
  56. @State private var searchResults: [TimelineLocation] = []
  57. @State private var cameraPosition: MapCameraPosition = .automatic
  58. @State private var searchText = ""
  59. @State private var isLoadingPlaces = false
  60. @State private var placeError: String?
  61. @State private var hasMadeSelection = false
  62. init(selection: Binding<TimelineLocation?>) {
  63. _selection = selection
  64. _selectedLocation = State(initialValue: selection.wrappedValue)
  65. }
  66. var body: some View {
  67. NavigationStack {
  68. VStack(spacing: 0) {
  69. locationMap
  70. .frame(height: 220)
  71. Divider()
  72. .overlay(Color.lineBorder)
  73. locationList
  74. }
  75. .background(Color.spaceBlack)
  76. .navigationTitle("所在位置")
  77. .navigationBarTitleDisplayMode(.inline)
  78. .searchable(
  79. text: $searchText,
  80. placement: .navigationBarDrawer(displayMode: .always),
  81. prompt: "搜索附近地点"
  82. )
  83. .onSubmit(of: .search) {
  84. Task {
  85. await searchPlaces()
  86. }
  87. }
  88. .onChange(of: searchText) { _, newValue in
  89. if newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
  90. searchResults = []
  91. placeError = nil
  92. }
  93. }
  94. .toolbar {
  95. ToolbarItem(placement: .cancellationAction) {
  96. Button("取消") {
  97. dismiss()
  98. }
  99. }
  100. ToolbarItem(placement: .confirmationAction) {
  101. Button("完成") {
  102. selection = selectedLocation
  103. dismiss()
  104. }
  105. }
  106. }
  107. }
  108. .onAppear {
  109. locationService.requestCurrentLocation()
  110. if let selectedLocation {
  111. centerMap(on: selectedLocation)
  112. }
  113. }
  114. .onChange(of: locationService.currentLocation) { _, location in
  115. guard let location else { return }
  116. if selectedLocation == nil, !hasMadeSelection {
  117. selectedLocation = location
  118. }
  119. centerMap(on: selectedLocation ?? location)
  120. Task {
  121. await loadNearbyPlaces(around: location)
  122. }
  123. }
  124. .presentationDetents([.large])
  125. .presentationDragIndicator(.visible)
  126. .presentationBackground(Color.spaceBlack)
  127. }
  128. private var locationMap: some View {
  129. Map(position: $cameraPosition) {
  130. UserAnnotation()
  131. if let selectedLocation {
  132. Marker(
  133. selectedLocation.name,
  134. coordinate: coordinate(for: selectedLocation)
  135. )
  136. .tint(Color.lessCosmosGold)
  137. }
  138. }
  139. .mapStyle(.standard(pointsOfInterest: .all))
  140. .overlay(alignment: .bottomTrailing) {
  141. Button {
  142. locationService.requestCurrentLocation()
  143. } label: {
  144. Image(systemName: "location.fill")
  145. .font(.system(size: 14, weight: .semibold))
  146. .foregroundStyle(Color.primary)
  147. .frame(width: 38, height: 38)
  148. .background(.ultraThinMaterial, in: Circle())
  149. }
  150. .padding(12)
  151. .accessibilityLabel("重新定位")
  152. }
  153. }
  154. private var locationList: some View {
  155. ScrollView {
  156. LazyVStack(spacing: 0) {
  157. noLocationRow
  158. if let currentLocation = locationService.currentLocation {
  159. locationRow(
  160. currentLocation,
  161. icon: "location.fill",
  162. iconColor: Color.lessCosmosGold
  163. )
  164. } else {
  165. locatingRow
  166. }
  167. if isLoadingPlaces {
  168. HStack(spacing: 10) {
  169. ProgressView()
  170. Text(searchText.isEmpty ? "正在查找附近地点…" : "正在搜索地点…")
  171. .font(.system(size: 12))
  172. .foregroundStyle(Color.secondary)
  173. Spacer()
  174. }
  175. .padding(.horizontal, 20)
  176. .padding(.vertical, 16)
  177. }
  178. ForEach(displayedLocations, id: \.self) { location in
  179. locationRow(location)
  180. }
  181. if let message = locationService.errorMessage ?? placeError {
  182. locationErrorRow(message)
  183. }
  184. }
  185. }
  186. .scrollDismissesKeyboard(.interactively)
  187. }
  188. private var noLocationRow: some View {
  189. Button {
  190. hasMadeSelection = true
  191. selectedLocation = nil
  192. } label: {
  193. HStack(spacing: 12) {
  194. Image(systemName: "location.slash")
  195. .font(.system(size: 13, weight: .medium))
  196. .foregroundStyle(Color.secondary)
  197. .frame(width: 28)
  198. Text("不显示位置")
  199. .font(.system(size: 14, weight: .medium))
  200. .foregroundStyle(Color.primary)
  201. Spacer()
  202. if selectedLocation == nil {
  203. Image(systemName: "checkmark")
  204. .font(.system(size: 13, weight: .semibold))
  205. .foregroundStyle(Color.lessCosmosGold)
  206. }
  207. }
  208. .padding(.horizontal, 20)
  209. .padding(.vertical, 15)
  210. .contentShape(Rectangle())
  211. }
  212. .buttonStyle(.plain)
  213. .overlay(alignment: .bottom) {
  214. Divider()
  215. .padding(.leading, 60)
  216. .overlay(Color.lineBorder)
  217. }
  218. }
  219. private var locatingRow: some View {
  220. HStack(spacing: 12) {
  221. if locationService.isLocating {
  222. ProgressView()
  223. .frame(width: 28)
  224. } else {
  225. Image(systemName: "location")
  226. .foregroundStyle(Color.secondary)
  227. .frame(width: 28)
  228. }
  229. VStack(alignment: .leading, spacing: 3) {
  230. Text(locationService.isLocating ? "正在定位…" : "当前位置")
  231. .font(.system(size: 14, weight: .medium))
  232. .foregroundStyle(Color.primary)
  233. Text("用于查找你附近的地点")
  234. .font(.system(size: 11))
  235. .foregroundStyle(Color.secondary)
  236. }
  237. Spacer()
  238. }
  239. .padding(.horizontal, 20)
  240. .padding(.vertical, 14)
  241. }
  242. private func locationRow(
  243. _ location: TimelineLocation,
  244. icon: String = "mappin.and.ellipse",
  245. iconColor: Color = Color.secondary
  246. ) -> some View {
  247. Button {
  248. hasMadeSelection = true
  249. selectedLocation = location
  250. centerMap(on: location)
  251. } label: {
  252. HStack(spacing: 12) {
  253. Image(systemName: icon)
  254. .font(.system(size: 13, weight: .medium))
  255. .foregroundStyle(iconColor)
  256. .frame(width: 28)
  257. VStack(alignment: .leading, spacing: 3) {
  258. Text(location.name)
  259. .font(.system(size: 14, weight: .medium))
  260. .foregroundStyle(Color.primary)
  261. .lineLimit(1)
  262. if !location.subtitle.isEmpty {
  263. Text(location.subtitle)
  264. .font(.system(size: 11))
  265. .foregroundStyle(Color.secondary)
  266. .lineLimit(2)
  267. }
  268. }
  269. Spacer(minLength: 8)
  270. if selectedLocation == location {
  271. Image(systemName: "checkmark")
  272. .font(.system(size: 13, weight: .semibold))
  273. .foregroundStyle(Color.lessCosmosGold)
  274. }
  275. }
  276. .padding(.horizontal, 20)
  277. .padding(.vertical, 13)
  278. .contentShape(Rectangle())
  279. }
  280. .buttonStyle(.plain)
  281. .overlay(alignment: .bottom) {
  282. Divider()
  283. .padding(.leading, 60)
  284. .overlay(Color.lineBorder.opacity(0.7))
  285. }
  286. }
  287. private func locationErrorRow(_ message: String) -> some View {
  288. VStack(alignment: .leading, spacing: 10) {
  289. Text(message)
  290. .font(.system(size: 12))
  291. .foregroundStyle(Color.secondary)
  292. if locationService.isAuthorizationDenied {
  293. Button("前往系统设置") {
  294. guard let settingsURL = URL(string: UIApplication.openSettingsURLString) else {
  295. return
  296. }
  297. UIApplication.shared.open(settingsURL)
  298. }
  299. .font(.system(size: 12, weight: .semibold))
  300. } else {
  301. Button("重新定位") {
  302. locationService.requestCurrentLocation()
  303. }
  304. .font(.system(size: 12, weight: .semibold))
  305. }
  306. }
  307. .frame(maxWidth: .infinity, alignment: .leading)
  308. .padding(20)
  309. }
  310. private var displayedLocations: [TimelineLocation] {
  311. searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
  312. ? nearbyLocations
  313. : searchResults
  314. }
  315. @MainActor
  316. private func loadNearbyPlaces(around location: TimelineLocation) async {
  317. isLoadingPlaces = true
  318. placeError = nil
  319. defer { isLoadingPlaces = false }
  320. let request = MKLocalPointsOfInterestRequest(
  321. center: coordinate(for: location),
  322. radius: 2_000
  323. )
  324. request.pointOfInterestFilter = .includingAll
  325. do {
  326. let response = try await MKLocalSearch(request: request).start()
  327. nearbyLocations = uniqueLocations(
  328. response.mapItems.compactMap(location(from:))
  329. )
  330. } catch {
  331. placeError = "附近地点暂时无法加载,你仍可使用 GPS 当前位置。"
  332. }
  333. }
  334. @MainActor
  335. private func searchPlaces() async {
  336. let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
  337. guard !query.isEmpty,
  338. let center = locationService.currentLocation ?? selectedLocation else {
  339. return
  340. }
  341. isLoadingPlaces = true
  342. placeError = nil
  343. defer { isLoadingPlaces = false }
  344. let request = MKLocalSearch.Request()
  345. request.naturalLanguageQuery = query
  346. request.region = MKCoordinateRegion(
  347. center: coordinate(for: center),
  348. latitudinalMeters: 10_000,
  349. longitudinalMeters: 10_000
  350. )
  351. do {
  352. let response = try await MKLocalSearch(request: request).start()
  353. searchResults = uniqueLocations(
  354. response.mapItems.compactMap(location(from:))
  355. )
  356. if searchResults.isEmpty {
  357. placeError = "没有找到相关地点,请换一个关键词。"
  358. }
  359. } catch {
  360. placeError = "地点搜索失败,请稍后重试。"
  361. }
  362. }
  363. private func location(from mapItem: MKMapItem) -> TimelineLocation? {
  364. guard let name = mapItem.name?
  365. .trimmingCharacters(in: .whitespacesAndNewlines),
  366. !name.isEmpty else {
  367. return nil
  368. }
  369. let coordinate = mapItem.placemark.coordinate
  370. return TimelineLocation(
  371. name: name,
  372. address: mapItem.placemark.title ?? "",
  373. latitude: coordinate.latitude,
  374. longitude: coordinate.longitude
  375. )
  376. }
  377. private func uniqueLocations(
  378. _ locations: [TimelineLocation]
  379. ) -> [TimelineLocation] {
  380. var keys = Set<String>()
  381. return locations.filter { location in
  382. let key = [
  383. location.name,
  384. String(format: "%.5f", location.latitude),
  385. String(format: "%.5f", location.longitude)
  386. ].joined(separator: "|")
  387. return keys.insert(key).inserted
  388. }
  389. }
  390. private func centerMap(on location: TimelineLocation) {
  391. cameraPosition = .region(
  392. MKCoordinateRegion(
  393. center: coordinate(for: location),
  394. latitudinalMeters: 1_200,
  395. longitudinalMeters: 1_200
  396. )
  397. )
  398. }
  399. private func coordinate(
  400. for location: TimelineLocation
  401. ) -> CLLocationCoordinate2D {
  402. CLLocationCoordinate2D(
  403. latitude: location.latitude,
  404. longitude: location.longitude
  405. )
  406. }
  407. }
  408. private final class TimelineLocationService: NSObject, ObservableObject {
  409. @Published private(set) var currentLocation: TimelineLocation?
  410. @Published private(set) var isLocating = false
  411. @Published private(set) var errorMessage: String?
  412. @Published private(set) var authorizationStatus: CLAuthorizationStatus
  413. private let manager = CLLocationManager()
  414. private let geocoder = CLGeocoder()
  415. override init() {
  416. authorizationStatus = manager.authorizationStatus
  417. super.init()
  418. manager.delegate = self
  419. manager.desiredAccuracy = kCLLocationAccuracyBest
  420. }
  421. var isAuthorizationDenied: Bool {
  422. authorizationStatus == .denied || authorizationStatus == .restricted
  423. }
  424. func requestCurrentLocation() {
  425. errorMessage = nil
  426. switch manager.authorizationStatus {
  427. case .notDetermined:
  428. isLocating = true
  429. manager.requestWhenInUseAuthorization()
  430. case .authorizedAlways, .authorizedWhenInUse:
  431. isLocating = true
  432. manager.requestLocation()
  433. case .denied, .restricted:
  434. isLocating = false
  435. errorMessage = "定位权限未开启。你可以前往系统设置允许访问当前位置。"
  436. @unknown default:
  437. isLocating = false
  438. errorMessage = "暂时无法确认定位权限。"
  439. }
  440. }
  441. }
  442. extension TimelineLocationService: CLLocationManagerDelegate {
  443. func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
  444. authorizationStatus = manager.authorizationStatus
  445. if manager.authorizationStatus == .authorizedAlways
  446. || manager.authorizationStatus == .authorizedWhenInUse {
  447. isLocating = true
  448. manager.requestLocation()
  449. } else if manager.authorizationStatus == .denied
  450. || manager.authorizationStatus == .restricted {
  451. isLocating = false
  452. errorMessage = "定位权限未开启。你可以前往系统设置允许访问当前位置。"
  453. }
  454. }
  455. func locationManager(
  456. _ manager: CLLocationManager,
  457. didUpdateLocations locations: [CLLocation]
  458. ) {
  459. guard let location = locations.last else { return }
  460. isLocating = false
  461. geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, error in
  462. DispatchQueue.main.async {
  463. guard let self else { return }
  464. if let placemark = placemarks?.first {
  465. self.currentLocation = TimelineLocation(
  466. name: "当前位置",
  467. address: Self.address(from: placemark),
  468. latitude: location.coordinate.latitude,
  469. longitude: location.coordinate.longitude
  470. )
  471. } else {
  472. self.currentLocation = TimelineLocation(
  473. name: "当前位置",
  474. address: String(
  475. format: "%.6f, %.6f",
  476. location.coordinate.latitude,
  477. location.coordinate.longitude
  478. ),
  479. latitude: location.coordinate.latitude,
  480. longitude: location.coordinate.longitude
  481. )
  482. if error != nil {
  483. self.errorMessage = "已取得 GPS 位置,但暂时无法解析详细地址。"
  484. }
  485. }
  486. }
  487. }
  488. }
  489. func locationManager(
  490. _ manager: CLLocationManager,
  491. didFailWithError error: Error
  492. ) {
  493. isLocating = false
  494. if (error as? CLError)?.code == .locationUnknown {
  495. errorMessage = "暂时无法确定当前位置,请移到开阔区域后重试。"
  496. } else {
  497. errorMessage = "定位失败,请检查系统定位服务后重试。"
  498. }
  499. }
  500. private static func address(from placemark: CLPlacemark) -> String {
  501. var parts: [String] = []
  502. for part in [
  503. placemark.administrativeArea,
  504. placemark.locality,
  505. placemark.subLocality,
  506. placemark.thoroughfare,
  507. placemark.subThoroughfare,
  508. placemark.name
  509. ] {
  510. guard let part = part?.trimmingCharacters(in: .whitespacesAndNewlines),
  511. !part.isEmpty,
  512. !parts.contains(part) else {
  513. continue
  514. }
  515. parts.append(part)
  516. }
  517. return parts.joined(separator: " ")
  518. }
  519. }