完善风险地图定位与标记详情
This commit is contained in:
@ -21,6 +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 legendBar = UIStackView()
|
||||
private let detailContainerView = UIView()
|
||||
private let scrollView = UIScrollView()
|
||||
@ -87,6 +88,7 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
mapContainerView.addSubview(mapView)
|
||||
|
||||
configureScenicCapsule()
|
||||
configureLocateButton()
|
||||
configureLegendBar()
|
||||
configureDetailArea()
|
||||
refreshContent(animated: false)
|
||||
@ -106,6 +108,10 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
make.top.leading.equalToSuperview().inset(12)
|
||||
make.height.equalTo(34)
|
||||
}
|
||||
locateButton.snp.makeConstraints { make in
|
||||
make.top.trailing.equalToSuperview().inset(12)
|
||||
make.size.equalTo(44)
|
||||
}
|
||||
detailContainerView.snp.makeConstraints { make in
|
||||
make.height.greaterThanOrEqualTo(180)
|
||||
}
|
||||
@ -117,9 +123,6 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
self?.refreshContent(animated: true)
|
||||
}
|
||||
}
|
||||
viewModel.onShowMessage = { [weak self] message in
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
}
|
||||
|
||||
private func configureScenicCapsule() {
|
||||
@ -151,6 +154,20 @@ 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 configureLegendBar() {
|
||||
legendBar.axis = .horizontal
|
||||
legendBar.alignment = .center
|
||||
@ -182,8 +199,8 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
contentStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 14, left: 18, bottom: 18, right: 18))
|
||||
make.width.equalTo(scrollView.snp.width).offset(-36)
|
||||
make.edges.equalToSuperview().inset(16)
|
||||
make.width.equalTo(scrollView.snp.width).offset(-32)
|
||||
}
|
||||
}
|
||||
|
||||
@ -191,6 +208,7 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
private func refreshContent(animated: Bool) {
|
||||
setRiskMapLoadingVisible(viewModel.isLoading)
|
||||
scenicNameLabel.text = viewModel.scenicAreaName
|
||||
updateLocateButtonState()
|
||||
syncMapAnnotations()
|
||||
mapView.setRegion(viewModel.region, animated: animated)
|
||||
updateAnnotationSelection()
|
||||
@ -205,6 +223,13 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
isVisible ? showLoading() : hideLoading()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func updateLocateButtonState() {
|
||||
let hasCurrentLocation = viewModel.markers.contains { $0.kind == .selfLocation }
|
||||
locateButton.isEnabled = hasCurrentLocation
|
||||
locateButton.alpha = hasCurrentLocation ? 1 : 0.46
|
||||
}
|
||||
|
||||
private func syncMapAnnotations() {
|
||||
let oldAnnotations = mapView.annotations.compactMap { $0 as? WildReportMapAnnotation }
|
||||
mapView.removeAnnotations(oldAnnotations)
|
||||
@ -258,39 +283,63 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
return
|
||||
}
|
||||
|
||||
let card = WildReportCardView(spacing: 16, inset: 16)
|
||||
card.layer.cornerRadius = 16
|
||||
card.stack.addArrangedSubview(makeHeader(marker: marker, clue: clue))
|
||||
if viewModel.isLoadingDetail {
|
||||
card.stack.addArrangedSubview(makeInlineLoading(text: "正在加载标记详情..."))
|
||||
}
|
||||
if let nearest = clue.nearestAbnormalText {
|
||||
card.stack.addArrangedSubview(makeHighlight(text: nearest, color: AppColor.danger))
|
||||
}
|
||||
addInfoRows(for: clue, to: card.stack)
|
||||
addDescriptionBlocks(for: clue, to: card.stack)
|
||||
addMediaRow(for: clue, to: card.stack)
|
||||
addProcessingBlock(for: clue, to: card.stack)
|
||||
card.stack.addArrangedSubview(makeButtonRow(for: clue))
|
||||
let card = makeRadarResultCard(clue: clue)
|
||||
contentStack.addArrangedSubview(card)
|
||||
}
|
||||
|
||||
private func makeHeader(marker: WildReportMapMarker, clue: WildReportRadarActiveClue) -> UIView {
|
||||
private func makeRadarResultCard(clue: WildReportRadarActiveClue) -> UIView {
|
||||
let card = WildReportCardView(spacing: 14, inset: 16)
|
||||
card.layer.cornerRadius = 16
|
||||
card.stack.spacing = 14
|
||||
card.stack.addArrangedSubview(makeRadarHeader(clue: clue))
|
||||
|
||||
if clue.type == .blue, let nearest = clue.nearestAbnormalText {
|
||||
card.stack.addArrangedSubview(makeCompareText(nearest))
|
||||
}
|
||||
|
||||
if clue.type == .green {
|
||||
addPhotographerProfile(for: clue, to: card.stack)
|
||||
}
|
||||
|
||||
if isReportClue(clue) {
|
||||
card.stack.addArrangedSubview(makeReporterInfo(clue: clue))
|
||||
addReportContents(for: clue, to: card.stack)
|
||||
addRadarImages(for: clue, to: card.stack)
|
||||
if clue.processingStarted, let record = clue.record {
|
||||
card.stack.addArrangedSubview(makeProcessingPanel(clue: clue, record: record))
|
||||
}
|
||||
}
|
||||
|
||||
if clue.type == .green, let summary = clue.summary {
|
||||
card.stack.addArrangedSubview(makeCompareText(summary))
|
||||
}
|
||||
|
||||
if clue.type == .green, let detail = clue.detail {
|
||||
card.stack.addArrangedSubview(makeDetailSection(detail))
|
||||
}
|
||||
|
||||
if !clue.completed {
|
||||
card.stack.addArrangedSubview(makeRadarActionButtons(for: clue))
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
private func makeRadarHeader(clue: WildReportRadarActiveClue) -> UIView {
|
||||
let row = UIStackView()
|
||||
row.axis = .horizontal
|
||||
row.alignment = .center
|
||||
row.alignment = .top
|
||||
row.spacing = 12
|
||||
|
||||
let iconWrap = UIView()
|
||||
iconWrap.backgroundColor = marker.kind.color.withAlphaComponent(0.14)
|
||||
iconWrap.layer.cornerRadius = 22
|
||||
let icon = UIImageView(image: UIImage(systemName: iconName(for: marker.kind)))
|
||||
icon.tintColor = marker.kind.color
|
||||
iconWrap.backgroundColor = headerIconColor(for: clue)
|
||||
iconWrap.layer.cornerRadius = 12
|
||||
let icon = UIImageView(image: UIImage(systemName: "location.fill"))
|
||||
icon.tintColor = .white
|
||||
icon.contentMode = .scaleAspectFit
|
||||
iconWrap.addSubview(icon)
|
||||
icon.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.size.equalTo(22)
|
||||
make.size.equalTo(20)
|
||||
}
|
||||
iconWrap.snp.makeConstraints { make in
|
||||
make.size.equalTo(44)
|
||||
@ -298,14 +347,34 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
|
||||
let textStack = UIStackView()
|
||||
textStack.axis = .vertical
|
||||
textStack.spacing = 4
|
||||
let title = WildReportUI.label(clue.displayTitle, font: .systemFont(ofSize: 17, weight: .bold), color: AppColor.textPrimary, lines: 2)
|
||||
let metaText = [clue.distanceText ?? clue.distance, clue.updatedAt].compactMap { $0 }.joined(separator: " · ")
|
||||
let meta = WildReportUI.label(metaText.isEmpty ? marker.title : metaText, font: .systemFont(ofSize: 12), color: AppColor.textSecondary, lines: 1)
|
||||
textStack.addArrangedSubview(title)
|
||||
textStack.addArrangedSubview(meta)
|
||||
textStack.spacing = 6
|
||||
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
|
||||
if let distance = clue.distanceText ?? clue.distance {
|
||||
metaRow.addArrangedSubview(WildReportUI.label("距离:距离您\(distance)", font: .systemFont(ofSize: 12), color: AppColor.textSecondary))
|
||||
}
|
||||
if let updatedAt = clue.updatedAt {
|
||||
metaRow.addArrangedSubview(WildReportUI.label("时间:\(updatedAt)", font: .systemFont(ofSize: 12), color: AppColor.textSecondary))
|
||||
}
|
||||
if !metaRow.arrangedSubviews.isEmpty {
|
||||
textStack.addArrangedSubview(metaRow)
|
||||
}
|
||||
} else {
|
||||
let title = WildReportUI.label(clue.displayTitle, font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary, lines: 2)
|
||||
textStack.addArrangedSubview(title)
|
||||
if clue.type == .green {
|
||||
let metaText = [clue.distance.map { "距蓝点 \($0)" }, clue.direction, clue.updatedAt].compactMap { $0 }.joined(separator: " ")
|
||||
if !metaText.isEmpty {
|
||||
textStack.addArrangedSubview(WildReportUI.label(metaText, font: .systemFont(ofSize: 12), color: AppColor.textSecondary))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let badge = WildReportRiskStatusBadge(text: clue.statusText, color: marker.kind.color)
|
||||
let badge = WildReportRiskStatusBadge(text: clue.statusText, color: statusColor(for: clue))
|
||||
row.addArrangedSubview(iconWrap)
|
||||
row.addArrangedSubview(textStack)
|
||||
row.addArrangedSubview(badge)
|
||||
@ -314,6 +383,341 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
return row
|
||||
}
|
||||
|
||||
private func addPhotographerProfile(for clue: WildReportRadarActiveClue, to stack: UIStackView) {
|
||||
let row = UIStackView()
|
||||
row.axis = .horizontal
|
||||
row.alignment = .center
|
||||
row.spacing = 12
|
||||
|
||||
let avatarWrap = UIView()
|
||||
avatarWrap.backgroundColor = AppColor.success.withAlphaComponent(0.12)
|
||||
avatarWrap.layer.cornerRadius = 28
|
||||
let avatar = UIImageView(image: UIImage(systemName: clue.avatar ?? "person.crop.circle.fill"))
|
||||
avatar.tintColor = AppColor.success
|
||||
avatar.contentMode = .scaleAspectFit
|
||||
avatarWrap.addSubview(avatar)
|
||||
avatar.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.size.equalTo(42)
|
||||
}
|
||||
avatarWrap.snp.makeConstraints { make in
|
||||
make.size.equalTo(56)
|
||||
}
|
||||
|
||||
let textStack = UIStackView()
|
||||
textStack.axis = .vertical
|
||||
textStack.spacing = 4
|
||||
if let name = clue.name {
|
||||
textStack.addArrangedSubview(WildReportUI.label(name, font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary))
|
||||
}
|
||||
if let storeName = clue.storeName {
|
||||
textStack.addArrangedSubview(WildReportUI.label(storeName, font: .systemFont(ofSize: 13, weight: .semibold), color: AppColor.success))
|
||||
}
|
||||
|
||||
row.addArrangedSubview(avatarWrap)
|
||||
row.addArrangedSubview(textStack)
|
||||
stack.addArrangedSubview(row)
|
||||
}
|
||||
|
||||
private func makeReporterInfo(clue: WildReportRadarActiveClue) -> UIView {
|
||||
let box = UIView()
|
||||
box.backgroundColor = UIColor(hex: 0xF9FAFB)
|
||||
box.layer.cornerRadius = 10
|
||||
|
||||
let inner = UIStackView()
|
||||
inner.axis = .vertical
|
||||
inner.spacing = 10
|
||||
box.addSubview(inner)
|
||||
inner.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(12)
|
||||
}
|
||||
|
||||
inner.addArrangedSubview(makeInfoLabelRow(label: "举报人", value: clue.reporterName ?? "-"))
|
||||
inner.addArrangedSubview(makePhoneRow(
|
||||
label: "举报人手机号",
|
||||
phone: clue.reporterPhone,
|
||||
displayPhone: clue.maskedReporterPhone ?? clue.reporterPhone
|
||||
))
|
||||
return box
|
||||
}
|
||||
|
||||
private func addReportContents(for clue: WildReportRadarActiveClue, to stack: UIStackView) {
|
||||
guard !clue.reportContents.isEmpty else { return }
|
||||
let section = UIStackView()
|
||||
section.axis = .vertical
|
||||
section.spacing = 10
|
||||
for item in clue.reportContents {
|
||||
let itemStack = UIStackView()
|
||||
itemStack.axis = .vertical
|
||||
itemStack.spacing = 4
|
||||
if !item.time.isEmpty {
|
||||
itemStack.addArrangedSubview(WildReportUI.label(item.time, font: .systemFont(ofSize: 11, weight: .semibold), color: AppColor.textSecondary))
|
||||
}
|
||||
itemStack.addArrangedSubview(makeLineSpacedLabel(item.content, font: .systemFont(ofSize: 13), color: AppColor.textPrimary))
|
||||
section.addArrangedSubview(itemStack)
|
||||
}
|
||||
stack.addArrangedSubview(section)
|
||||
}
|
||||
|
||||
private func addRadarImages(for clue: WildReportRadarActiveClue, to stack: UIStackView) {
|
||||
guard !clue.images.isEmpty else { return }
|
||||
let section = UIStackView()
|
||||
section.axis = .vertical
|
||||
section.spacing = 10
|
||||
section.addArrangedSubview(WildReportUI.label("现场图片/视频", font: .systemFont(ofSize: 15, weight: .bold), color: AppColor.textPrimary))
|
||||
|
||||
let scroll = UIScrollView()
|
||||
scroll.showsHorizontalScrollIndicator = false
|
||||
let row = UIStackView()
|
||||
row.axis = .horizontal
|
||||
row.spacing = 10
|
||||
scroll.addSubview(row)
|
||||
row.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
make.height.equalTo(108)
|
||||
}
|
||||
for url in clue.images {
|
||||
let tile = WildReportRiskImageTile(url: url, size: 108)
|
||||
tile.onTap = { [weak self] in self?.presentImagePreview(url: url) }
|
||||
row.addArrangedSubview(tile)
|
||||
}
|
||||
section.addArrangedSubview(scroll)
|
||||
scroll.snp.makeConstraints { make in
|
||||
make.height.equalTo(108)
|
||||
}
|
||||
stack.addArrangedSubview(section)
|
||||
}
|
||||
|
||||
private func makeProcessingPanel(clue: WildReportRadarActiveClue, record: WildReportRadarProcessRecord) -> UIView {
|
||||
let box = UIView()
|
||||
box.backgroundColor = UIColor(hex: 0xFFFBEB)
|
||||
box.layer.cornerRadius = 10
|
||||
|
||||
let inner = UIStackView()
|
||||
inner.axis = .vertical
|
||||
inner.spacing = 10
|
||||
box.addSubview(inner)
|
||||
inner.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(12)
|
||||
}
|
||||
|
||||
inner.addArrangedSubview(makeInfoLabelRow(label: "处理人", value: record.handlerName))
|
||||
inner.addArrangedSubview(makeInfoLabelRow(
|
||||
label: "状态",
|
||||
value: clue.completed ? "已处理" : "正在处理中",
|
||||
valueColor: clue.completed ? AppColor.success : UIColor(hex: 0xF59E0B)
|
||||
))
|
||||
inner.addArrangedSubview(makePhoneRow(label: "联系电话", phone: record.phone, displayPhone: record.maskedPhone))
|
||||
|
||||
let progressStack = UIStackView()
|
||||
progressStack.axis = .vertical
|
||||
progressStack.spacing = 10
|
||||
progressStack.addArrangedSubview(WildReportUI.label("处理进度", font: .systemFont(ofSize: 14, weight: .bold), color: AppColor.textPrimary))
|
||||
for item in clue.processingTimeline {
|
||||
progressStack.addArrangedSubview(makeTimelineRow(item))
|
||||
}
|
||||
inner.addArrangedSubview(progressStack)
|
||||
return box
|
||||
}
|
||||
|
||||
private func makeTimelineRow(_ item: WildReportRadarProcessingTimelineItem) -> UIView {
|
||||
let row = UIStackView()
|
||||
row.axis = .horizontal
|
||||
row.alignment = .top
|
||||
row.spacing = 10
|
||||
|
||||
let dot = UIView()
|
||||
dot.backgroundColor = timelineDotColor(item.status)
|
||||
dot.layer.cornerRadius = 5
|
||||
dot.snp.makeConstraints { make in
|
||||
make.size.equalTo(10)
|
||||
}
|
||||
let dotContainer = UIView()
|
||||
dotContainer.addSubview(dot)
|
||||
dot.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(4)
|
||||
make.centerX.equalToSuperview()
|
||||
}
|
||||
dotContainer.snp.makeConstraints { make in
|
||||
make.width.equalTo(10)
|
||||
}
|
||||
|
||||
let textStack = UIStackView()
|
||||
textStack.axis = .vertical
|
||||
textStack.spacing = 2
|
||||
textStack.addArrangedSubview(WildReportUI.label(item.title, font: .systemFont(ofSize: 13, weight: .semibold), color: AppColor.textPrimary))
|
||||
textStack.addArrangedSubview(WildReportUI.label(item.time, font: .systemFont(ofSize: 12), color: AppColor.textSecondary))
|
||||
|
||||
row.addArrangedSubview(dotContainer)
|
||||
row.addArrangedSubview(textStack)
|
||||
return row
|
||||
}
|
||||
|
||||
private func makeRadarActionButtons(for clue: WildReportRadarActiveClue) -> UIView {
|
||||
let row = UIStackView()
|
||||
row.axis = .horizontal
|
||||
row.spacing = 10
|
||||
row.distribution = .fillEqually
|
||||
|
||||
let navButton = makeRadarButton(title: clue.actionText, imageName: "map.fill", color: AppColor.primary)
|
||||
navButton.addTarget(self, action: #selector(navigateTapped), for: .touchUpInside)
|
||||
row.addArrangedSubview(navButton)
|
||||
|
||||
if isReportClue(clue), !clue.completed {
|
||||
let shareButton = makeRadarButton(title: "分享", imageName: "square.and.arrow.up", color: AppColor.success)
|
||||
shareButton.addTarget(self, action: #selector(shareTapped), for: .touchUpInside)
|
||||
row.addArrangedSubview(shareButton)
|
||||
}
|
||||
|
||||
[navButton].forEach { button in
|
||||
button.snp.makeConstraints { make in make.height.equalTo(44) }
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
private func makeRadarButton(title: String, imageName: String, color: UIColor) -> UIButton {
|
||||
let button = UIButton(type: .system)
|
||||
button.titleLabel?.font = .systemFont(ofSize: 15, weight: .bold)
|
||||
button.setTitle(title, for: .normal)
|
||||
button.setImage(UIImage(systemName: imageName), for: .normal)
|
||||
button.tintColor = .white
|
||||
button.setTitleColor(.white, for: .normal)
|
||||
button.backgroundColor = color
|
||||
button.layer.cornerRadius = 12
|
||||
button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -3, bottom: 0, right: 3)
|
||||
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
|
||||
button.snp.makeConstraints { make in make.height.equalTo(44) }
|
||||
return button
|
||||
}
|
||||
|
||||
private func makeCompareText(_ text: String) -> UIView {
|
||||
let label = makeLineSpacedLabel(text, font: .systemFont(ofSize: 13), color: AppColor.textSecondary)
|
||||
label.backgroundColor = UIColor(hex: 0xF3F4F6)
|
||||
label.layer.cornerRadius = 10
|
||||
label.clipsToBounds = true
|
||||
label.textInsets = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
|
||||
return label
|
||||
}
|
||||
|
||||
private func makeDetailSection(_ detail: String) -> UIView {
|
||||
let row = UIStackView()
|
||||
row.axis = .horizontal
|
||||
row.alignment = .top
|
||||
row.spacing = 8
|
||||
let icon = UIImageView(image: UIImage(systemName: "doc.text.fill"))
|
||||
icon.tintColor = AppColor.primary
|
||||
icon.contentMode = .scaleAspectFit
|
||||
icon.snp.makeConstraints { make in
|
||||
make.size.equalTo(16)
|
||||
}
|
||||
row.addArrangedSubview(icon)
|
||||
row.addArrangedSubview(makeLineSpacedLabel(detail, font: .systemFont(ofSize: 13), color: AppColor.textPrimary))
|
||||
return row
|
||||
}
|
||||
|
||||
private func makeInfoLabelRow(label: String, value: String, valueColor: UIColor = AppColor.textPrimary) -> UIView {
|
||||
let row = UIStackView()
|
||||
row.axis = .horizontal
|
||||
row.alignment = .center
|
||||
row.spacing = 12
|
||||
let titleLabel = WildReportUI.label(label, font: .systemFont(ofSize: 13), color: AppColor.textSecondary)
|
||||
let valueLabel = WildReportUI.label(value, font: .systemFont(ofSize: 14, weight: .semibold), color: valueColor, lines: 0)
|
||||
valueLabel.textAlignment = .right
|
||||
row.addArrangedSubview(titleLabel)
|
||||
row.addArrangedSubview(valueLabel)
|
||||
titleLabel.setContentHuggingPriority(.required, for: .horizontal)
|
||||
valueLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
return row
|
||||
}
|
||||
|
||||
private func makePhoneRow(label: String, phone: String?, displayPhone: String?) -> UIView {
|
||||
let row = UIStackView()
|
||||
row.axis = .horizontal
|
||||
row.alignment = .center
|
||||
row.spacing = 12
|
||||
let titleLabel = WildReportUI.label(label, font: .systemFont(ofSize: 13), color: AppColor.textSecondary)
|
||||
row.addArrangedSubview(titleLabel)
|
||||
row.addArrangedSubview(UIView())
|
||||
|
||||
guard let displayPhone, !displayPhone.isEmpty else {
|
||||
row.addArrangedSubview(WildReportUI.label("-", font: .systemFont(ofSize: 14, weight: .semibold), color: AppColor.textPrimary))
|
||||
return row
|
||||
}
|
||||
|
||||
let button = UIButton(type: .system)
|
||||
button.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold)
|
||||
button.setTitle(displayPhone, for: .normal)
|
||||
button.setImage(UIImage(systemName: "phone.fill"), for: .normal)
|
||||
button.tintColor = AppColor.primary
|
||||
button.setTitleColor(AppColor.primary, for: .normal)
|
||||
button.semanticContentAttribute = .forceRightToLeft
|
||||
button.imageEdgeInsets = UIEdgeInsets(top: 0, left: 4, bottom: 0, right: -4)
|
||||
if let phone {
|
||||
button.addAction(UIAction { [weak self] _ in
|
||||
self?.callPhone(phone)
|
||||
}, for: .touchUpInside)
|
||||
}
|
||||
row.addArrangedSubview(button)
|
||||
button.setContentHuggingPriority(.required, for: .horizontal)
|
||||
return row
|
||||
}
|
||||
|
||||
private func makeLineSpacedLabel(_ text: String, font: UIFont, color: UIColor) -> WildReportRiskInsetLabel {
|
||||
let label = WildReportRiskInsetLabel()
|
||||
label.font = font
|
||||
label.textColor = color
|
||||
label.numberOfLines = 0
|
||||
let paragraph = NSMutableParagraphStyle()
|
||||
paragraph.lineSpacing = 4
|
||||
label.attributedText = NSAttributedString(
|
||||
string: text,
|
||||
attributes: [.paragraphStyle: paragraph, .font: font, .foregroundColor: color]
|
||||
)
|
||||
return label
|
||||
}
|
||||
|
||||
private func isReportClue(_ clue: WildReportRadarActiveClue) -> Bool {
|
||||
clue.type == .red || clue.type == .yellow
|
||||
}
|
||||
|
||||
private func headerIconColor(for clue: WildReportRadarActiveClue) -> UIColor {
|
||||
if isReportClue(clue), clue.processingStarted, !clue.completed {
|
||||
return UIColor(hex: 0xF59E0B)
|
||||
}
|
||||
switch clue.type {
|
||||
case .blue: return AppColor.primary
|
||||
case .green: return AppColor.success
|
||||
case .red: return AppColor.danger
|
||||
case .yellow: return UIColor(hex: 0xF59E0B)
|
||||
}
|
||||
}
|
||||
|
||||
private func statusColor(for clue: WildReportRadarActiveClue) -> UIColor {
|
||||
if clue.completed { return AppColor.success }
|
||||
if clue.processingStarted, !clue.completed { return UIColor(hex: 0xF59E0B) }
|
||||
switch clue.type {
|
||||
case .blue: return AppColor.primary
|
||||
case .green: return AppColor.success
|
||||
case .red: return AppColor.danger
|
||||
case .yellow: return UIColor(hex: 0xF59E0B)
|
||||
}
|
||||
}
|
||||
|
||||
private func timelineDotColor(_ status: String) -> UIColor {
|
||||
switch status {
|
||||
case "done": return AppColor.success
|
||||
case "current": return UIColor(hex: 0xF59E0B)
|
||||
default: return UIColor(hex: 0xD1D5DB)
|
||||
}
|
||||
}
|
||||
|
||||
private func callPhone(_ phone: String) {
|
||||
guard phone.contains("*") == false,
|
||||
let url = URL(string: "tel://\(phone)")
|
||||
else { return }
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
|
||||
private func addInfoRows(for clue: WildReportRadarActiveClue, to stack: UIStackView) {
|
||||
if let name = clue.name {
|
||||
stack.addArrangedSubview(WildReportInfoRowView(title: clue.type == .green ? "摄影师" : "点位", value: name))
|
||||
@ -494,6 +898,10 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
@objc private func shareTapped() {
|
||||
viewModel.shareSelectedClue()
|
||||
}
|
||||
|
||||
@objc private func locateButtonTapped() {
|
||||
viewModel.focusCurrentLocation()
|
||||
}
|
||||
}
|
||||
|
||||
extension WildReportRiskMapViewController: MKMapViewDelegate {
|
||||
@ -533,16 +941,17 @@ final class WildReportMapAnnotation: NSObject, MKAnnotation {
|
||||
|
||||
/// 风险地图自定义脉冲圆点标记。
|
||||
private final class WildReportPulseMarkerAnnotationView: MKAnnotationView {
|
||||
private let pulseOuter = UIView()
|
||||
private let pulseInner = UIView()
|
||||
private let titleBadge = WildReportMapMarkerTitleBadge()
|
||||
private let markerContainer = UIView()
|
||||
private let pulseRings = (0..<3).map { _ in UIView() }
|
||||
private let selectedRing = UIView()
|
||||
private let dot = UIView()
|
||||
private let label = UILabel()
|
||||
private var showsTitleBadge = false
|
||||
|
||||
override init(annotation: MKAnnotation?, reuseIdentifier: String?) {
|
||||
super.init(annotation: annotation, reuseIdentifier: reuseIdentifier)
|
||||
frame = CGRect(x: 0, y: 0, width: 56, height: 56)
|
||||
centerOffset = CGPoint(x: 0, y: -12)
|
||||
frame = CGRect(x: 0, y: 0, width: 72, height: 96)
|
||||
centerOffset = .zero
|
||||
canShowCallout = false
|
||||
setupUI()
|
||||
}
|
||||
@ -554,41 +963,51 @@ private final class WildReportPulseMarkerAnnotationView: MKAnnotationView {
|
||||
|
||||
func apply(marker: WildReportMapMarker, selected: Bool) {
|
||||
let color = marker.kind.color
|
||||
pulseOuter.layer.borderColor = color.withAlphaComponent(0.18).cgColor
|
||||
pulseInner.layer.borderColor = color.withAlphaComponent(0.28).cgColor
|
||||
selectedRing.layer.borderColor = color.cgColor
|
||||
updateTitleBadgeVisibility(marker.kind == .selfLocation)
|
||||
titleBadge.text = marker.title
|
||||
pulseRings.forEach { ring in
|
||||
ring.layer.borderColor = color.withAlphaComponent(0.32).cgColor
|
||||
}
|
||||
selectedRing.layer.borderColor = color.withAlphaComponent(0.45).cgColor
|
||||
selectedRing.isHidden = !selected
|
||||
dot.backgroundColor = color
|
||||
label.isHidden = marker.kind != .selfLocation
|
||||
label.text = "我"
|
||||
startPulseIfNeeded()
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
backgroundColor = .clear
|
||||
[pulseOuter, pulseInner, selectedRing, dot, label].forEach(addSubview)
|
||||
[pulseOuter, pulseInner, selectedRing].forEach { view in
|
||||
titleBadge.isHidden = true
|
||||
addSubview(titleBadge)
|
||||
addSubview(markerContainer)
|
||||
pulseRings.forEach(markerContainer.addSubview)
|
||||
markerContainer.addSubview(selectedRing)
|
||||
markerContainer.addSubview(dot)
|
||||
|
||||
pulseRings.forEach { view in
|
||||
view.backgroundColor = .clear
|
||||
view.layer.borderWidth = 2
|
||||
view.layer.cornerRadius = 10
|
||||
view.isUserInteractionEnabled = false
|
||||
}
|
||||
pulseOuter.layer.cornerRadius = 20
|
||||
pulseInner.layer.cornerRadius = 14
|
||||
selectedRing.layer.cornerRadius = 14
|
||||
selectedRing.layer.borderWidth = 2
|
||||
dot.layer.cornerRadius = 7
|
||||
dot.layer.borderWidth = 2
|
||||
dot.layer.borderColor = UIColor.white.cgColor
|
||||
|
||||
label.font = .systemFont(ofSize: 10, weight: .bold)
|
||||
label.textColor = .white
|
||||
label.textAlignment = .center
|
||||
|
||||
pulseOuter.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.size.equalTo(40)
|
||||
titleBadge.snp.makeConstraints { make in
|
||||
make.top.centerX.equalToSuperview()
|
||||
make.height.equalTo(24)
|
||||
}
|
||||
pulseInner.snp.makeConstraints { make in
|
||||
markerContainer.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.size.equalTo(28)
|
||||
make.size.equalTo(56)
|
||||
}
|
||||
pulseRings.forEach { ring in
|
||||
ring.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.size.equalTo(20)
|
||||
}
|
||||
}
|
||||
selectedRing.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
@ -598,21 +1017,81 @@ private final class WildReportPulseMarkerAnnotationView: MKAnnotationView {
|
||||
make.center.equalToSuperview()
|
||||
make.size.equalTo(14)
|
||||
}
|
||||
label.snp.makeConstraints { make in
|
||||
make.center.equalTo(dot)
|
||||
make.size.equalTo(14)
|
||||
}
|
||||
startPulse()
|
||||
}
|
||||
|
||||
private func startPulse() {
|
||||
let animation = CABasicAnimation(keyPath: "opacity")
|
||||
animation.fromValue = 0.85
|
||||
animation.toValue = 0.2
|
||||
animation.duration = 1.2
|
||||
animation.autoreverses = true
|
||||
animation.repeatCount = .infinity
|
||||
pulseOuter.layer.add(animation, forKey: "opacityPulse")
|
||||
private func updateTitleBadgeVisibility(_ isVisible: Bool) {
|
||||
guard showsTitleBadge != isVisible else { return }
|
||||
showsTitleBadge = isVisible
|
||||
titleBadge.isHidden = !isVisible
|
||||
markerContainer.snp.remakeConstraints { make in
|
||||
if isVisible {
|
||||
make.top.equalTo(titleBadge.snp.bottom).offset(4)
|
||||
make.centerX.equalToSuperview()
|
||||
} else {
|
||||
make.center.equalToSuperview()
|
||||
}
|
||||
make.size.equalTo(56)
|
||||
}
|
||||
}
|
||||
|
||||
private func startPulseIfNeeded() {
|
||||
guard pulseRings.first?.layer.animation(forKey: "riskMapPulseOpacity") == nil else { return }
|
||||
let endSizes: [CGFloat] = [28, 40, 52]
|
||||
for (index, ring) in pulseRings.enumerated() {
|
||||
ring.layer.opacity = 0.72
|
||||
|
||||
let scale = CABasicAnimation(keyPath: "transform.scale")
|
||||
scale.fromValue = 1
|
||||
scale.toValue = endSizes[index] / 20
|
||||
|
||||
let opacity = CABasicAnimation(keyPath: "opacity")
|
||||
opacity.fromValue = 0.72
|
||||
opacity.toValue = 0
|
||||
|
||||
let animation = CAAnimationGroup()
|
||||
animation.animations = [scale, opacity]
|
||||
animation.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
animation.duration = 2
|
||||
animation.repeatCount = .infinity
|
||||
animation.isRemovedOnCompletion = false
|
||||
ring.layer.add(animation, forKey: "riskMapPulseOpacity")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 风险地图当前位置标记标题胶囊。
|
||||
private final class WildReportMapMarkerTitleBadge: UIView {
|
||||
var text: String? {
|
||||
get { label.text }
|
||||
set { label.text = newValue }
|
||||
}
|
||||
|
||||
private let label = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
setupUI()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
backgroundColor = .white
|
||||
layer.cornerRadius = 12
|
||||
layer.shadowColor = UIColor.black.cgColor
|
||||
layer.shadowOpacity = 0.08
|
||||
layer.shadowRadius = 4
|
||||
layer.shadowOffset = CGSize(width: 0, height: 2)
|
||||
|
||||
label.font = .systemFont(ofSize: 11, weight: .semibold)
|
||||
label.textColor = AppColor.textPrimary
|
||||
addSubview(label)
|
||||
label.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -676,12 +1155,31 @@ private final class WildReportRiskStatusBadge: UILabel {
|
||||
}
|
||||
}
|
||||
|
||||
/// 风险地图底部卡片带内边距文本。
|
||||
private final class WildReportRiskInsetLabel: UILabel {
|
||||
var textInsets = UIEdgeInsets.zero {
|
||||
didSet { invalidateIntrinsicContentSize() }
|
||||
}
|
||||
|
||||
override func drawText(in rect: CGRect) {
|
||||
super.drawText(in: rect.inset(by: textInsets))
|
||||
}
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
let size = super.intrinsicContentSize
|
||||
return CGSize(
|
||||
width: size.width + textInsets.left + textInsets.right,
|
||||
height: size.height + textInsets.top + textInsets.bottom
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 风险地图举报图片缩略图。
|
||||
private final class WildReportRiskImageTile: UIView {
|
||||
private let imageView = UIImageView()
|
||||
var onTap: (() -> Void)?
|
||||
|
||||
init(url: String) {
|
||||
init(url: String, size: CGFloat = 72) {
|
||||
super.init(frame: .zero)
|
||||
backgroundColor = AppColor.pageBackgroundSoft
|
||||
layer.cornerRadius = 10
|
||||
@ -699,7 +1197,7 @@ private final class WildReportRiskImageTile: UIView {
|
||||
}
|
||||
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tapped)))
|
||||
snp.makeConstraints { make in
|
||||
make.size.equalTo(72)
|
||||
make.size.equalTo(size)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user