Advance UIKit rewrite with AMap integration and core UI modules.
Integrate高德 SDK with simulator-safe build flags, add map views for operating area and punch points, refactor main tabs and key feature screens to UIKit with Diffable lists, and document Swift concurrency defaults in AGENTS.md. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
16
suixinkan_ios/Core/Map/Map.md
Normal file
16
suixinkan_ios/Core/Map/Map.md
Normal file
@ -0,0 +1,16 @@
|
||||
# Core/Map 模块
|
||||
|
||||
## 职责
|
||||
|
||||
封装高德地图 UIKit 组件,供运营区域围栏展示与打卡点编辑选点使用。
|
||||
|
||||
## 组件
|
||||
|
||||
- `OperatingAreaMapView`:绘制运营围栏 polygon;模拟器降级为坐标摘要。
|
||||
- `PunchPointMapPickerView`:打卡点编辑页地图点选;模拟器提示使用「当前位置」。
|
||||
|
||||
## 构建说明
|
||||
|
||||
- 真机构建:`AMAP_ENABLED` + CocoaPods 高德 SDK。
|
||||
- 模拟器:不链接 AMap,自动展示文字/坐标兜底 UI。
|
||||
- 在 `Info.plist` 配置 `AMapAPIKey` 为高德控制台 Key(需与 Bundle ID 绑定)。
|
||||
199
suixinkan_ios/Core/Map/OperatingAreaMapView.swift
Normal file
199
suixinkan_ios/Core/Map/OperatingAreaMapView.swift
Normal file
@ -0,0 +1,199 @@
|
||||
//
|
||||
// OperatingAreaMapView.swift
|
||||
// suixinkan_ios
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 运营区域围栏地图:真机启用高德 polygon;模拟器展示坐标摘要。
|
||||
final class OperatingAreaMapView: UIView {
|
||||
var rings: [OperatingFenceRing] = [] {
|
||||
didSet { updateContent() }
|
||||
}
|
||||
|
||||
private let contentContainer = UIView()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
contentContainer.clipsToBounds = true
|
||||
contentContainer.layer.cornerRadius = AppMetrics.CornerRadius.card
|
||||
addSubview(contentContainer)
|
||||
contentContainer.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
updateContent()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private func updateContent() {
|
||||
contentContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||
#if AMAP_ENABLED
|
||||
let mapView = OperatingAreaAMapView(rings: rings)
|
||||
contentContainer.addSubview(mapView)
|
||||
mapView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
#else
|
||||
let fallback = OperatingAreaMapFallbackView(rings: rings)
|
||||
contentContainer.addSubview(fallback)
|
||||
fallback.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#if AMAP_ENABLED
|
||||
import MAMapKit
|
||||
|
||||
private final class OperatingAreaAMapView: UIView, MAMapViewDelegate {
|
||||
private let mapView = MAMapView()
|
||||
private var ringsByOverlay: [ObjectIdentifier: OperatingFenceRing] = [:]
|
||||
private var rings: [OperatingFenceRing]
|
||||
|
||||
init(rings: [OperatingFenceRing]) {
|
||||
self.rings = rings
|
||||
super.init(frame: .zero)
|
||||
mapView.delegate = self
|
||||
mapView.showsCompass = false
|
||||
mapView.showsScale = false
|
||||
mapView.isRotateEnabled = false
|
||||
addSubview(mapView)
|
||||
mapView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
applyRings()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private func applyRings() {
|
||||
ringsByOverlay.removeAll()
|
||||
mapView.removeOverlays(mapView.overlays)
|
||||
mapView.removeAnnotations(mapView.annotations)
|
||||
|
||||
var allCoordinates: [CLLocationCoordinate2D] = []
|
||||
for ring in rings {
|
||||
var coordinates = ring.points.map {
|
||||
CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude)
|
||||
}
|
||||
guard coordinates.count >= 3 else { continue }
|
||||
guard let polygon = MAPolygon(coordinates: &coordinates, count: UInt(coordinates.count)) else {
|
||||
continue
|
||||
}
|
||||
ringsByOverlay[ObjectIdentifier(polygon)] = ring
|
||||
mapView.add(polygon)
|
||||
allCoordinates.append(contentsOf: coordinates)
|
||||
|
||||
if let center = ring.centerCoordinate {
|
||||
let annotation = MAPointAnnotation()
|
||||
annotation.coordinate = center
|
||||
annotation.title = ring.regionName
|
||||
mapView.addAnnotation(annotation)
|
||||
}
|
||||
}
|
||||
|
||||
if let region = Self.region(for: allCoordinates) {
|
||||
mapView.setRegion(region, animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
func mapView(_ mapView: MAMapView!, rendererFor overlay: MAOverlay!) -> MAOverlayRenderer! {
|
||||
guard let polygon = overlay as? MAPolygon else { return nil }
|
||||
let ring = ringsByOverlay[ObjectIdentifier(polygon)]
|
||||
let renderer = MAPolygonRenderer(polygon: polygon)
|
||||
let color = ring?.isCurrentStore == true ? UIColor.systemRed : UIColor.systemBlue
|
||||
renderer?.strokeColor = color
|
||||
renderer?.fillColor = color.withAlphaComponent(0.18)
|
||||
renderer?.lineWidth = 3
|
||||
return renderer
|
||||
}
|
||||
|
||||
private static func region(for coordinates: [CLLocationCoordinate2D]) -> MACoordinateRegion? {
|
||||
guard !coordinates.isEmpty else { return nil }
|
||||
let minLat = coordinates.map(\.latitude).min() ?? 0
|
||||
let maxLat = coordinates.map(\.latitude).max() ?? 0
|
||||
let minLng = coordinates.map(\.longitude).min() ?? 0
|
||||
let maxLng = coordinates.map(\.longitude).max() ?? 0
|
||||
let center = CLLocationCoordinate2D(
|
||||
latitude: (minLat + maxLat) / 2,
|
||||
longitude: (minLng + maxLng) / 2
|
||||
)
|
||||
let span = MACoordinateSpan(
|
||||
latitudeDelta: max((maxLat - minLat) * 1.4, 0.01),
|
||||
longitudeDelta: max((maxLng - minLng) * 1.4, 0.01)
|
||||
)
|
||||
return MACoordinateRegion(center: center, span: span)
|
||||
}
|
||||
}
|
||||
|
||||
private extension OperatingFenceRing {
|
||||
var centerCoordinate: CLLocationCoordinate2D? {
|
||||
guard !points.isEmpty else { return nil }
|
||||
let latitude = points.reduce(0) { $0 + $1.latitude } / Double(points.count)
|
||||
let longitude = points.reduce(0) { $0 + $1.longitude } / Double(points.count)
|
||||
return CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private final class OperatingAreaMapFallbackView: UIView {
|
||||
init(rings: [OperatingFenceRing]) {
|
||||
super.init(frame: .zero)
|
||||
backgroundColor = UIColor(hex: 0xEEF2F7)
|
||||
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = "模拟器未启用高德地图,以下为围栏坐标摘要"
|
||||
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .medium)
|
||||
titleLabel.textColor = AppDesign.textSecondary
|
||||
titleLabel.numberOfLines = 0
|
||||
|
||||
let stack = UIStackView()
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 8
|
||||
|
||||
addSubview(titleLabel)
|
||||
addSubview(stack)
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.medium)
|
||||
}
|
||||
stack.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(AppMetrics.Spacing.small)
|
||||
make.leading.trailing.bottom.equalToSuperview().inset(AppMetrics.Spacing.medium)
|
||||
}
|
||||
|
||||
for ring in rings.prefix(6) {
|
||||
let label = UILabel()
|
||||
label.numberOfLines = 0
|
||||
label.font = .systemFont(ofSize: AppMetrics.FontSize.caption)
|
||||
label.textColor = AppDesign.textSecondary
|
||||
let name = ring.regionName.isEmpty ? "未命名区域" : ring.regionName
|
||||
let coords = ring.points.prefix(4)
|
||||
.map { String(format: "%.6f, %.6f", $0.latitude, $0.longitude) }
|
||||
.joined(separator: " ")
|
||||
label.text = "\(name)\n\(coords)"
|
||||
stack.addArrangedSubview(label)
|
||||
}
|
||||
|
||||
if rings.isEmpty {
|
||||
let empty = UILabel()
|
||||
empty.text = "暂无围栏数据"
|
||||
empty.textColor = AppDesign.placeholder
|
||||
stack.addArrangedSubview(empty)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
139
suixinkan_ios/Core/Map/PunchPointMapPickerView.swift
Normal file
139
suixinkan_ios/Core/Map/PunchPointMapPickerView.swift
Normal file
@ -0,0 +1,139 @@
|
||||
//
|
||||
// PunchPointMapPickerView.swift
|
||||
// suixinkan_ios
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 打卡点地图选点:真机高德地图点选 + 逆地理;模拟器展示提示。
|
||||
@MainActor
|
||||
final class PunchPointMapPickerView: UIView {
|
||||
var onLocationPicked: ((Double, Double, String) -> Void)?
|
||||
|
||||
var coordinate: CLLocationCoordinate2D? {
|
||||
didSet { updatePin() }
|
||||
}
|
||||
|
||||
private let hintLabel = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
clipsToBounds = true
|
||||
layer.cornerRadius = AppMetrics.CornerRadius.card
|
||||
backgroundColor = UIColor(hex: 0xEEF2F7)
|
||||
|
||||
hintLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption)
|
||||
hintLabel.textColor = AppDesign.textSecondary
|
||||
hintLabel.numberOfLines = 0
|
||||
hintLabel.textAlignment = .center
|
||||
|
||||
#if AMAP_ENABLED
|
||||
hintLabel.text = "点击地图选择打卡位置"
|
||||
let mapView = PunchPointAMapPickerView { [weak self] lat, lng, address in
|
||||
self?.onLocationPicked?(lat, lng, address)
|
||||
}
|
||||
addSubview(mapView)
|
||||
addSubview(hintLabel)
|
||||
mapView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
hintLabel.snp.makeConstraints { make in
|
||||
make.leading.trailing.bottom.equalToSuperview().inset(8)
|
||||
}
|
||||
#else
|
||||
hintLabel.text = "模拟器未启用高德地图,请使用「当前位置」按钮选点"
|
||||
addSubview(hintLabel)
|
||||
hintLabel.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(AppMetrics.Spacing.medium)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private func updatePin() {
|
||||
// Pin updates handled inside AMap subview when coordinate is set externally.
|
||||
}
|
||||
|
||||
func setCoordinate(latitude: Double, longitude: Double) {
|
||||
coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
|
||||
}
|
||||
}
|
||||
|
||||
#if AMAP_ENABLED
|
||||
import AMapSearchKit
|
||||
import MAMapKit
|
||||
|
||||
private final class PunchPointAMapPickerView: UIView, MAMapViewDelegate, AMapSearchDelegate {
|
||||
private let mapView = MAMapView()
|
||||
private let searchAPI = AMapSearchAPI()
|
||||
private var pin: MAPointAnnotation?
|
||||
private var pendingRegeo: CheckedContinuation<String, Never>?
|
||||
private let onPick: (Double, Double, String) -> Void
|
||||
|
||||
init(onPick: @escaping (Double, Double, String) -> Void) {
|
||||
self.onPick = onPick
|
||||
super.init(frame: .zero)
|
||||
searchAPI?.delegate = self
|
||||
mapView.delegate = self
|
||||
mapView.showsUserLocation = true
|
||||
mapView.userTrackingMode = .none
|
||||
mapView.zoomLevel = 16
|
||||
addSubview(mapView)
|
||||
mapView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func mapView(_ mapView: MAMapView!, didSingleTappedAt coordinate: CLLocationCoordinate2D) {
|
||||
Task { await handlePick(coordinate) }
|
||||
}
|
||||
|
||||
private func handlePick(_ coordinate: CLLocationCoordinate2D) async {
|
||||
if let pin {
|
||||
mapView.removeAnnotation(pin)
|
||||
}
|
||||
let annotation = MAPointAnnotation()
|
||||
annotation.coordinate = coordinate
|
||||
pin = annotation
|
||||
mapView.addAnnotation(annotation)
|
||||
|
||||
let address = await reverseGeocode(coordinate)
|
||||
onPick(coordinate.latitude, coordinate.longitude, address)
|
||||
}
|
||||
|
||||
private func reverseGeocode(_ coordinate: CLLocationCoordinate2D) async -> String {
|
||||
await withCheckedContinuation { continuation in
|
||||
pendingRegeo = continuation
|
||||
let request = AMapReGeocodeSearchRequest()
|
||||
request.location = AMapGeoPoint.location(
|
||||
withLatitude: CGFloat(coordinate.latitude),
|
||||
longitude: CGFloat(coordinate.longitude)
|
||||
)
|
||||
request.requireExtension = true
|
||||
searchAPI?.aMapReGoecodeSearch(request)
|
||||
}
|
||||
}
|
||||
|
||||
func onReGeocodeSearchDone(_ request: AMapReGeocodeSearchRequest!, response: AMapReGeocodeSearchResponse!) {
|
||||
let address = response.regeocode.formattedAddress ?? "已选位置"
|
||||
pendingRegeo?.resume(returning: address)
|
||||
pendingRegeo = nil
|
||||
}
|
||||
|
||||
func aMapSearchRequest(_ request: Any!, didFailWithError error: Error!) {
|
||||
pendingRegeo?.resume(returning: "已选位置")
|
||||
pendingRegeo = nil
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user