完善任务流程、键盘适配与页面交互

This commit is contained in:
2026-07-10 15:56:15 +08:00
parent f88a85a807
commit ab5220e460
189 changed files with 16779 additions and 1038 deletions

View File

@ -21,7 +21,7 @@ final class WildReportRiskMapViewController: BaseViewController {
private let mapView = MKMapView()
private let scenicCapsule = UIView()
private let scenicNameLabel = UILabel()
private let locateButton = UIButton(type: .system)
private let mapControls = LocationReportMapControlStack()
private let legendBar = UIStackView()
private let detailContainerView = UIView()
private let scrollView = UIScrollView()
@ -88,7 +88,7 @@ final class WildReportRiskMapViewController: BaseViewController {
mapContainerView.addSubview(mapView)
configureScenicCapsule()
configureLocateButton()
configureMapControls()
configureLegendBar()
configureDetailArea()
refreshContent(animated: false)
@ -108,9 +108,8 @@ final class WildReportRiskMapViewController: BaseViewController {
make.top.leading.equalToSuperview().inset(12)
make.height.equalTo(34)
}
locateButton.snp.makeConstraints { make in
mapControls.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(12)
make.size.equalTo(44)
}
detailContainerView.snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(180)
@ -154,18 +153,21 @@ final class WildReportRiskMapViewController: BaseViewController {
}
}
private func configureLocateButton() {
locateButton.backgroundColor = UIColor.white.withAlphaComponent(0.96)
locateButton.tintColor = AppColor.primary
locateButton.setImage(UIImage(systemName: "location.fill"), for: .normal)
locateButton.layer.cornerRadius = 22
locateButton.layer.shadowColor = UIColor.black.cgColor
locateButton.layer.shadowOpacity = 0.1
locateButton.layer.shadowRadius = 8
locateButton.layer.shadowOffset = CGSize(width: 0, height: 4)
locateButton.accessibilityLabel = "回到我的位置"
locateButton.addTarget(self, action: #selector(locateButtonTapped), for: .touchUpInside)
mapContainerView.addSubview(locateButton)
private func configureMapControls() {
mapControls.layer.shadowColor = UIColor.black.cgColor
mapControls.layer.shadowOpacity = 0.1
mapControls.layer.shadowRadius = 8
mapControls.layer.shadowOffset = CGSize(width: 0, height: 4)
mapControls.onZoomIn = { [weak self] in
self?.zoomMap(by: 0.5)
}
mapControls.onZoomOut = { [weak self] in
self?.zoomMap(by: 2)
}
mapControls.onRelocate = { [weak self] in
self?.focusCurrentLocation()
}
mapContainerView.addSubview(mapControls)
}
private func configureLegendBar() {
@ -226,8 +228,7 @@ final class WildReportRiskMapViewController: BaseViewController {
@MainActor
private func updateLocateButtonState() {
let hasCurrentLocation = viewModel.markers.contains { $0.kind == .selfLocation }
locateButton.isEnabled = hasCurrentLocation
locateButton.alpha = hasCurrentLocation ? 1 : 0.46
mapControls.setRelocateEnabled(hasCurrentLocation)
}
private func syncMapAnnotations() {
@ -325,6 +326,10 @@ final class WildReportRiskMapViewController: BaseViewController {
}
private func makeRadarHeader(clue: WildReportRadarActiveClue) -> UIView {
let container = UIStackView()
container.axis = .vertical
container.spacing = 6
let row = UIStackView()
row.axis = .horizontal
row.alignment = .top
@ -348,21 +353,18 @@ final class WildReportRiskMapViewController: BaseViewController {
let textStack = UIStackView()
textStack.axis = .vertical
textStack.spacing = 6
var reportMetaText: String?
if isReportClue(clue) {
let title = WildReportUI.label("线索 ID\(clue.id)", font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary, lines: 2)
textStack.addArrangedSubview(title)
let metaRow = UIStackView()
metaRow.axis = .horizontal
metaRow.spacing = 12
var metaParts: [String] = []
if let distance = clue.distanceText ?? clue.distance {
metaRow.addArrangedSubview(WildReportUI.label("距离:距离您\(distance)", font: .systemFont(ofSize: 12), color: AppColor.textSecondary))
metaParts.append("距离:距离您\(distance)")
}
if let updatedAt = clue.updatedAt {
metaRow.addArrangedSubview(WildReportUI.label("时间:\(updatedAt)", font: .systemFont(ofSize: 12), color: AppColor.textSecondary))
}
if !metaRow.arrangedSubviews.isEmpty {
textStack.addArrangedSubview(metaRow)
metaParts.append("时间:\(updatedAt)")
}
reportMetaText = metaParts.isEmpty ? nil : metaParts.joined(separator: " ")
} else {
let title = WildReportUI.label(clue.displayTitle, font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary, lines: 2)
textStack.addArrangedSubview(title)
@ -380,7 +382,24 @@ final class WildReportRiskMapViewController: BaseViewController {
row.addArrangedSubview(badge)
textStack.setContentHuggingPriority(.defaultLow, for: .horizontal)
badge.setContentHuggingPriority(.required, for: .horizontal)
return row
container.addArrangedSubview(row)
if let reportMetaText {
let metaRow = UIStackView()
metaRow.axis = .horizontal
metaRow.spacing = 0
let spacer = UIView()
spacer.snp.makeConstraints { make in
make.width.equalTo(56)
}
let metaLabel = WildReportUI.label(reportMetaText, font: .systemFont(ofSize: 12), color: AppColor.textSecondary)
metaLabel.textAlignment = .right
metaLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
metaRow.addArrangedSubview(spacer)
metaRow.addArrangedSubview(metaLabel)
container.addArrangedSubview(metaRow)
}
return container
}
private func addPhotographerProfile(for clue: WildReportRadarActiveClue, to stack: UIStackView) {
@ -888,22 +907,134 @@ final class WildReportRiskMapViewController: BaseViewController {
return
}
guard let target = viewModel.navigateToSelectedMarker() else { return }
let item = MKMapItem(placemark: MKPlacemark(coordinate: target.coordinate))
item.name = target.title
item.openInMaps(launchOptions: [
MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDriving
])
presentNavigationOptions(for: target)
}
@objc private func shareTapped() {
viewModel.shareSelectedClue()
showLoading()
Task { [weak self] in
guard let self else { return }
let payload = await self.viewModel.shareSelectedClue(api: self.api)
await MainActor.run {
self.hideLoading()
guard let payload else { return }
WeChatShareService.shareMiniProgram(payload, previewImage: UIImage(named: "report_share_cover")) { [weak self] result in
self?.showShareResult(result)
}
}
}
}
@objc private func locateButtonTapped() {
private func presentNavigationOptions(for target: WildReportMapMarker) {
let options = availableNavigationOptions(for: target)
let alert = UIAlertController(title: "选择导航软件", message: target.title, preferredStyle: .actionSheet)
options.forEach { option in
alert.addAction(UIAlertAction(title: option.title, style: .default) { [weak self] _ in
self?.openNavigation(option)
})
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
if let popover = alert.popoverPresentationController {
popover.sourceView = view
popover.sourceRect = CGRect(x: view.bounds.midX, y: view.bounds.maxY - 80, width: 1, height: 1)
}
present(alert, animated: true)
}
private func availableNavigationOptions(for target: WildReportMapMarker) -> [WildReportNavigationOption] {
[
WildReportNavigationOption(title: "Apple 地图", url: navigationURL(for: .apple, target: target)),
WildReportNavigationOption(title: "高德地图", url: navigationURL(for: .amap, target: target)),
WildReportNavigationOption(title: "百度地图", url: navigationURL(for: .baidu, target: target)),
WildReportNavigationOption(title: "腾讯地图", url: navigationURL(for: .tencent, target: target)),
WildReportNavigationOption(title: "Google Maps", url: navigationURL(for: .google, target: target))
].compactMap { option in
guard option.url != nil else { return nil }
return option
}
}
private func openNavigation(_ option: WildReportNavigationOption) {
guard let url = option.url else { return }
UIApplication.shared.open(url) { [weak self] success in
guard !success else { return }
Task { @MainActor in
self?.showToast("未安装\(option.title)或无法打开")
}
}
}
private func navigationURL(for app: WildReportNavigationApp, target: WildReportMapMarker) -> URL? {
let latitude = target.coordinate.latitude
let longitude = target.coordinate.longitude
let name = encodedNavigationText(target.title)
switch app {
case .apple:
return URL(string: "http://maps.apple.com/?daddr=\(latitude),\(longitude)&dirflg=d&q=\(name)")
case .amap:
return URL(string: "iosamap://path?sourceApplication=suixinkan&dlat=\(latitude)&dlon=\(longitude)&dname=\(name)&dev=0&t=0")
case .baidu:
return URL(string: "baidumap://map/direction?destination=latlng:\(latitude),\(longitude)|name:\(name)&mode=driving&coord_type=wgs84")
case .tencent:
return URL(string: "qqmap://map/routeplan?type=drive&tocoord=\(latitude),\(longitude)&to=\(name)&referer=suixinkan")
case .google:
return URL(string: "comgooglemaps://?daddr=\(latitude),\(longitude)&directionsmode=driving")
}
}
private func encodedNavigationText(_ text: String) -> String {
var allowed = CharacterSet.urlQueryAllowed
allowed.remove(charactersIn: "&=?")
return text.addingPercentEncoding(withAllowedCharacters: allowed) ?? text
}
@MainActor
private func showShareResult(_ result: WeChatShareResult) {
switch result {
case .success:
showToast("分享成功")
case .cancelled:
showToast("已取消分享")
case .notInstalled:
showToast("请先安装微信")
case .unsupported:
showToast("当前微信版本不支持分享")
case .invalidPayload(let message):
showToast(message)
case .sendFailed:
showToast("微信分享发送失败")
case .unknown:
showToast("微信分享失败")
}
}
private func zoomMap(by multiplier: CLLocationDegrees) {
let visibleRegion = mapView.region
let span = MKCoordinateSpan(
latitudeDelta: min(max(visibleRegion.span.latitudeDelta * multiplier, 0.001), 180),
longitudeDelta: min(max(visibleRegion.span.longitudeDelta * multiplier, 0.001), 180)
)
mapView.setRegion(MKCoordinateRegion(center: visibleRegion.center, span: span), animated: true)
}
private func focusCurrentLocation() {
viewModel.focusCurrentLocation()
}
}
private struct WildReportNavigationOption {
let title: String
let url: URL?
}
private enum WildReportNavigationApp {
case apple
case amap
case baidu
case tencent
case google
}
extension WildReportRiskMapViewController: MKMapViewDelegate {
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
guard let annotation = annotation as? WildReportMapAnnotation else { return nil }
@ -1130,6 +1261,8 @@ private final class WildReportRiskLegendView: UIView {
///
private final class WildReportRiskStatusBadge: UILabel {
private let contentInsets = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10)
init(text: String, color: UIColor) {
super.init(frame: .zero)
self.text = text
@ -1142,13 +1275,26 @@ private final class WildReportRiskStatusBadge: UILabel {
numberOfLines = 1
adjustsFontSizeToFitWidth = true
minimumScaleFactor = 0.8
setContentHuggingPriority(.required, for: .horizontal)
setContentCompressionResistancePriority(.required, for: .horizontal)
snp.makeConstraints { make in
make.height.equalTo(24)
make.width.greaterThanOrEqualTo(64)
make.width.lessThanOrEqualTo(96)
make.width.greaterThanOrEqualTo(52)
}
}
override var intrinsicContentSize: CGSize {
let size = super.intrinsicContentSize
return CGSize(
width: size.width + contentInsets.left + contentInsets.right,
height: max(24, size.height + contentInsets.top + contentInsets.bottom)
)
}
override func drawText(in rect: CGRect) {
super.drawText(in: rect.inset(by: contentInsets))
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")