Add location report map page and history list aligned with Android.

Extract shared LocationReportService, wire location_report menu routing, and add unit tests for reporting cooldown and history pagination.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-07 11:34:54 +08:00
parent 5a2b7b6097
commit d2879ad7e2
17 changed files with 1975 additions and 152 deletions

View File

@ -0,0 +1,357 @@
//
// LocationReportViews.swift
// suixinkan
//
import MapKit
import SnapKit
import UIKit
/// /
final class LocationReportMapControlStack: UIView {
var onZoomIn: (() -> Void)?
var onZoomOut: (() -> Void)?
var onRelocate: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
let zoomIn = makeButton(symbol: "plus", action: #selector(zoomInTapped))
let zoomOut = makeButton(symbol: "minus", action: #selector(zoomOutTapped))
let relocate = makeButton(symbol: "location.fill", action: #selector(relocateTapped))
let zoomStack = UIStackView(arrangedSubviews: [zoomIn, zoomOut])
zoomStack.axis = .vertical
zoomStack.spacing = 0
zoomStack.backgroundColor = .white
zoomStack.layer.cornerRadius = 12
zoomStack.clipsToBounds = true
relocate.backgroundColor = .white
relocate.layer.cornerRadius = 12
let stack = UIStackView(arrangedSubviews: [zoomStack, relocate])
stack.axis = .vertical
stack.spacing = 12
addSubview(stack)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
[zoomIn, zoomOut, relocate].forEach { button in
button.snp.makeConstraints { make in
make.size.equalTo(44)
}
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func makeButton(symbol: String, action: Selector) -> UIButton {
let button = UIButton(type: .system)
button.setImage(UIImage(systemName: symbol), for: .normal)
button.tintColor = AppColor.primary
button.addTarget(self, action: action, for: .touchUpInside)
return button
}
@objc private func zoomInTapped() { onZoomIn?() }
@objc private func zoomOutTapped() { onZoomOut?() }
@objc private func relocateTapped() { onRelocate?() }
}
///
final class LocationReportStatusCardView: UIView {
var onOnlineStatusTap: (() -> Void)?
var onReminderTap: (() -> Void)?
private let onlineBadge = UILabel()
private let countdownLabel = UILabel()
private let reminderLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
layer.cornerRadius = 10
let statusTitle = makeTitle("当前状态")
onlineBadge.font = .systemFont(ofSize: 12, weight: .medium)
onlineBadge.textAlignment = .center
onlineBadge.layer.cornerRadius = 4
onlineBadge.clipsToBounds = true
onlineBadge.isUserInteractionEnabled = true
onlineBadge.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(onlineTapped)))
let countdownTitle = makeTitle("距离下次上报时间")
countdownLabel.font = .systemFont(ofSize: 16, weight: .medium)
countdownLabel.textColor = AppColor.primary
let reminderTitle = makeTitle("提醒设置")
reminderLabel.font = .systemFont(ofSize: 16, weight: .medium)
reminderLabel.textColor = AppColor.primary
reminderLabel.isUserInteractionEnabled = true
reminderLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(reminderTapped)))
let stack = UIStackView()
stack.axis = .vertical
stack.spacing = 12
stack.addArrangedSubview(makeRow(left: statusTitle, right: onlineBadge))
stack.addArrangedSubview(makeRow(left: countdownTitle, right: countdownLabel))
stack.addArrangedSubview(makeRow(left: reminderTitle, right: reminderLabel))
addSubview(stack)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(15)
}
onlineBadge.snp.makeConstraints { make in
make.width.greaterThanOrEqualTo(52)
make.height.equalTo(28)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(isOnline: Bool, countdown: String, reminderMinutes: Int) {
onlineBadge.text = isOnline ? "在线" : "离线"
onlineBadge.textColor = isOnline ? AppColor.online : UIColor(hex: 0x7B8EAA)
onlineBadge.backgroundColor = isOnline ? UIColor(hex: 0xF0FDF4) : AppColor.inputBackground
countdownLabel.text = countdown
reminderLabel.text = reminderMinutes == 0 ? "不提醒" : "提前\(reminderMinutes)分钟"
}
@objc private func onlineTapped() { onOnlineStatusTap?() }
@objc private func reminderTapped() { onReminderTap?() }
private func makeTitle(_ text: String) -> UILabel {
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: 14)
label.textColor = AppColor.textPrimary
return label
}
private func makeRow(left: UIView, right: UIView) -> UIView {
let row = UIStackView(arrangedSubviews: [left, right])
row.axis = .horizontal
row.distribution = .equalSpacing
row.alignment = .center
return row
}
}
///
final class LocationReportActionCardView: UIView {
var onReportTap: (() -> Void)?
private let reportButton = UIButton(type: .system)
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
layer.cornerRadius = 10
let icon = UIImageView(image: UIImage(systemName: "arrow.up"))
icon.tintColor = AppColor.primary
let title = UILabel()
title.text = "立即上报"
title.font = .systemFont(ofSize: 16, weight: .bold)
title.textColor = AppColor.textPrimary
let subtitle = UILabel()
subtitle.text = "您已进入打卡范围"
subtitle.font = .systemFont(ofSize: 12)
subtitle.textColor = AppColor.textTertiary
let textStack = UIStackView(arrangedSubviews: [title, subtitle])
textStack.axis = .vertical
textStack.spacing = 4
reportButton.backgroundColor = AppColor.primary
reportButton.tintColor = .white
reportButton.setImage(UIImage(systemName: "location.fill"), for: .normal)
reportButton.layer.cornerRadius = 36
reportButton.addTarget(self, action: #selector(reportTapped), for: .touchUpInside)
addSubview(icon)
addSubview(textStack)
addSubview(reportButton)
icon.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(15)
make.centerY.equalToSuperview()
make.size.equalTo(24)
}
textStack.snp.makeConstraints { make in
make.leading.equalTo(icon.snp.trailing).offset(8)
make.centerY.equalToSuperview()
}
reportButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-15)
make.centerY.equalToSuperview()
make.size.equalTo(72)
}
snp.makeConstraints { make in
make.height.equalTo(96)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setReporting(_ reporting: Bool) {
reportButton.isEnabled = !reporting
reportButton.alpha = reporting ? 0.6 : 1
}
@objc private func reportTapped() { onReportTap?() }
}
///
final class LocationReportBottomActionView: UIControl {
private let iconView = UIImageView()
private let titleLabel = UILabel()
init(title: String, symbol: String) {
super.init(frame: .zero)
backgroundColor = .white
layer.cornerRadius = 10
iconView.image = UIImage(systemName: symbol)
iconView.tintColor = AppColor.primary
iconView.contentMode = .scaleAspectFit
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 12)
titleLabel.textColor = AppColor.textPrimary
titleLabel.textAlignment = .center
let stack = UIStackView(arrangedSubviews: [iconView, titleLabel])
stack.axis = .vertical
stack.spacing = 4
stack.alignment = .center
stack.isUserInteractionEnabled = false
addSubview(stack)
stack.snp.makeConstraints { make in
make.center.equalToSuperview()
}
iconView.snp.makeConstraints { make in
make.size.equalTo(24)
}
snp.makeConstraints { make in
make.height.equalTo(80)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var isSelected: Bool {
didSet { applySelectedStyle() }
}
func applyOnlineStyle(isOnline: Bool) {
if isOnline {
backgroundColor = AppColor.online
iconView.tintColor = .white
titleLabel.text = "在线"
titleLabel.textColor = .white
} else {
backgroundColor = .white
iconView.tintColor = UIColor(hex: 0x7B8EAA)
titleLabel.text = "离线"
titleLabel.textColor = UIColor(hex: 0x7B8EAA)
}
}
private func applySelectedStyle() {
guard titleLabel.text == "标记上报" else { return }
if isSelected {
backgroundColor = AppColor.online
iconView.tintColor = UIColor(hex: 0xF0FDF4)
titleLabel.textColor = UIColor(hex: 0xF0FDF4)
} else {
backgroundColor = .white
iconView.tintColor = AppColor.primary
titleLabel.textColor = AppColor.textPrimary
}
}
}
/// MKMapView
final class LocationReportMapView: UIView, MKMapViewDelegate {
var onMapTap: ((CLLocationCoordinate2D) -> Void)?
let mapView = MKMapView()
private var markerAnnotation: MKPointAnnotation?
override init(frame: CGRect) {
super.init(frame: frame)
mapView.delegate = self
mapView.showsUserLocation = true
mapView.showsCompass = false
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
mapView.addGestureRecognizer(tap)
addSubview(mapView)
mapView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func centerOnUserLocation() {
guard let coordinate = mapView.userLocation.location?.coordinate else { return }
setCenter(coordinate)
}
func setCenter(_ coordinate: CLLocationCoordinate2D, animated: Bool = true) {
let region = MKCoordinateRegion(center: coordinate, latitudinalMeters: 800, longitudinalMeters: 800)
mapView.setRegion(region, animated: animated)
}
func zoomIn() {
var region = mapView.region
region.span.latitudeDelta *= 0.5
region.span.longitudeDelta *= 0.5
mapView.setRegion(region, animated: true)
}
func zoomOut() {
var region = mapView.region
region.span.latitudeDelta = min(region.span.latitudeDelta * 2, 180)
region.span.longitudeDelta = min(region.span.longitudeDelta * 2, 180)
mapView.setRegion(region, animated: true)
}
func updateMarker(latitude: Double?, longitude: Double?) {
if let annotation = markerAnnotation {
mapView.removeAnnotation(annotation)
markerAnnotation = nil
}
guard let latitude, let longitude else { return }
let annotation = MKPointAnnotation()
annotation.coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
markerAnnotation = annotation
mapView.addAnnotation(annotation)
}
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
let point = gesture.location(in: mapView)
let coordinate = mapView.convert(point, toCoordinateFrom: mapView)
onMapTap?(coordinate)
}
}