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:
@ -0,0 +1,177 @@
|
||||
//
|
||||
// LocationReportHistoryViews.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 历史上报列表卡片。
|
||||
final class LocationReportHistoryCell: UITableViewCell {
|
||||
|
||||
static let reuseIdentifier = "LocationReportHistoryCell"
|
||||
|
||||
private let typeBadge = UILabel()
|
||||
private let timeLabel = UILabel()
|
||||
private let coordinateLabel = UILabel()
|
||||
private let addressLabel = UILabel()
|
||||
private let remarkLabel = UILabel()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
|
||||
let card = UIView()
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = 10
|
||||
|
||||
typeBadge.font = .systemFont(ofSize: 12, weight: .medium)
|
||||
typeBadge.textAlignment = .center
|
||||
typeBadge.layer.cornerRadius = 4
|
||||
typeBadge.clipsToBounds = true
|
||||
|
||||
timeLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
timeLabel.textColor = AppColor.textPrimary
|
||||
|
||||
coordinateLabel.font = .systemFont(ofSize: 12)
|
||||
coordinateLabel.textColor = AppColor.textSecondary
|
||||
|
||||
addressLabel.font = .systemFont(ofSize: 13)
|
||||
addressLabel.textColor = AppColor.textPrimary
|
||||
addressLabel.numberOfLines = 2
|
||||
|
||||
remarkLabel.font = .systemFont(ofSize: 12)
|
||||
remarkLabel.textColor = AppColor.textTertiary
|
||||
remarkLabel.numberOfLines = 2
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [timeLabel, coordinateLabel, addressLabel, remarkLabel])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 6
|
||||
|
||||
card.addSubview(typeBadge)
|
||||
card.addSubview(stack)
|
||||
contentView.addSubview(card)
|
||||
|
||||
card.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 6, left: 16, bottom: 6, right: 16))
|
||||
}
|
||||
typeBadge.snp.makeConstraints { make in
|
||||
make.top.leading.equalToSuperview().offset(12)
|
||||
make.height.equalTo(22)
|
||||
make.width.greaterThanOrEqualTo(64)
|
||||
}
|
||||
stack.snp.makeConstraints { make in
|
||||
make.top.equalTo(typeBadge.snp.bottom).offset(8)
|
||||
make.leading.trailing.bottom.equalToSuperview().inset(12)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(item: LocationReportHistoryItem) {
|
||||
let filterType = item.filterType
|
||||
typeBadge.text = " \(filterType.displayName) "
|
||||
typeBadge.textColor = UIColor(hex: filterType.tagColorHex)
|
||||
typeBadge.backgroundColor = UIColor(hex: filterType.tagColorHex).withAlphaComponent(0.12)
|
||||
timeLabel.text = item.createdAt.isEmpty ? "--" : item.createdAt
|
||||
coordinateLabel.text = "经纬度:\(item.latitude), \(item.longitude)"
|
||||
addressLabel.text = item.address.isEmpty ? "--" : item.address
|
||||
if item.remark.isEmpty {
|
||||
remarkLabel.isHidden = true
|
||||
} else {
|
||||
remarkLabel.isHidden = false
|
||||
remarkLabel.text = "备注:\(item.remark)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 筛选与日期工具栏。
|
||||
final class LocationReportHistoryFilterBar: UIView {
|
||||
|
||||
var onFilterTap: (() -> Void)?
|
||||
var onDateFilterTap: (() -> Void)?
|
||||
var onClearDates: (() -> Void)?
|
||||
|
||||
private let filterButton = UIButton(type: .system)
|
||||
private let dateButton = UIButton(type: .system)
|
||||
private let startLabel = UILabel()
|
||||
private let endLabel = UILabel()
|
||||
private let clearButton = UIButton(type: .system)
|
||||
private let dateRangeStack = UIStackView()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white
|
||||
|
||||
filterButton.setTitle("全部", for: .normal)
|
||||
filterButton.setTitleColor(AppColor.textPrimary, for: .normal)
|
||||
filterButton.titleLabel?.font = .systemFont(ofSize: 14)
|
||||
filterButton.setImage(UIImage(systemName: "chevron.down"), for: .normal)
|
||||
filterButton.semanticContentAttribute = .forceRightToLeft
|
||||
filterButton.addTarget(self, action: #selector(filterTapped), for: .touchUpInside)
|
||||
|
||||
dateButton.setTitle("时间筛选", for: .normal)
|
||||
dateButton.setTitleColor(AppColor.primary, for: .normal)
|
||||
dateButton.titleLabel?.font = .systemFont(ofSize: 14)
|
||||
dateButton.addTarget(self, action: #selector(dateTapped), for: .touchUpInside)
|
||||
|
||||
startLabel.font = .systemFont(ofSize: 13)
|
||||
endLabel.font = .systemFont(ofSize: 13)
|
||||
startLabel.textColor = AppColor.textSecondary
|
||||
endLabel.textColor = AppColor.textSecondary
|
||||
|
||||
clearButton.setTitle("清除", for: .normal)
|
||||
clearButton.setTitleColor(AppColor.danger, for: .normal)
|
||||
clearButton.titleLabel?.font = .systemFont(ofSize: 13)
|
||||
clearButton.addTarget(self, action: #selector(clearTapped), for: .touchUpInside)
|
||||
|
||||
dateRangeStack.axis = .horizontal
|
||||
dateRangeStack.spacing = 8
|
||||
dateRangeStack.alignment = .center
|
||||
dateRangeStack.addArrangedSubview(startLabel)
|
||||
dateRangeStack.addArrangedSubview(UILabel(text: "至"))
|
||||
dateRangeStack.addArrangedSubview(endLabel)
|
||||
dateRangeStack.addArrangedSubview(clearButton)
|
||||
dateRangeStack.isHidden = true
|
||||
|
||||
let topRow = UIStackView(arrangedSubviews: [filterButton, UIView(), dateButton])
|
||||
topRow.axis = .horizontal
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [topRow, dateRangeStack])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 8
|
||||
addSubview(stack)
|
||||
stack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(12)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(filterTitle: String, startText: String, endText: String, showsDateRange: Bool) {
|
||||
filterButton.setTitle(filterTitle, for: .normal)
|
||||
startLabel.text = startText
|
||||
endLabel.text = endText
|
||||
dateRangeStack.isHidden = !showsDateRange
|
||||
}
|
||||
|
||||
@objc private func filterTapped() { onFilterTap?() }
|
||||
@objc private func dateTapped() { onDateFilterTap?() }
|
||||
@objc private func clearTapped() { onClearDates?() }
|
||||
}
|
||||
|
||||
private extension UILabel {
|
||||
convenience init(text: String) {
|
||||
self.init()
|
||||
self.text = text
|
||||
font = .systemFont(ofSize: 13)
|
||||
textColor = AppColor.textSecondary
|
||||
}
|
||||
}
|
||||
357
suixinkan/UI/LocationReport/Views/LocationReportViews.swift
Normal file
357
suixinkan/UI/LocationReport/Views/LocationReportViews.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user