From 4b62bd34fdfbcf7b9f2e3c01ae1791a24db58628 Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Thu, 4 Dec 2025 12:58:30 -0800 Subject: [PATCH 1/4] Create Apple Maps-style intermediate stop annotations --- .../Map/IntermediateStopAnnotationView.swift | 80 +++++++++++++++++++ .../OTPKit/Core/Map/MKMapViewAdapter.swift | 37 +++++++++ 2 files changed, 117 insertions(+) create mode 100644 OTPKit/Sources/OTPKit/Core/Map/IntermediateStopAnnotationView.swift diff --git a/OTPKit/Sources/OTPKit/Core/Map/IntermediateStopAnnotationView.swift b/OTPKit/Sources/OTPKit/Core/Map/IntermediateStopAnnotationView.swift new file mode 100644 index 0000000..de553c1 --- /dev/null +++ b/OTPKit/Sources/OTPKit/Core/Map/IntermediateStopAnnotationView.swift @@ -0,0 +1,80 @@ +// +// IntermediateStopAnnotationView.swift +// OTPKit +// +// Created by OTPKit on 2025-12-04. +// + +import MapKit +import UIKit + +/// Custom annotation view for displaying intermediate transit stops as small dots with labels +class IntermediateStopAnnotationView: MKAnnotationView { + private let dotView = UIView() + private let label = UILabel() + private let dotSize: CGFloat = 10 + + override init(annotation: MKAnnotation?, reuseIdentifier: String?) { + super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) + setupView() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + setupView() + } + + private func setupView() { + canShowCallout = false + + // Setup dot + dotView.backgroundColor = .white + dotView.layer.cornerRadius = dotSize / 2 + dotView.layer.borderWidth = 2 + dotView.layer.borderColor = UIColor.gray.cgColor + addSubview(dotView) + + // Setup label - dark gray on white shadow (Apple Maps style) + label.font = UIFont.systemFont(ofSize: 12, weight: .medium) + label.textColor = .darkGray + label.shadowColor = .white + label.shadowOffset = CGSize(width: 1, height: 1) + addSubview(label) + } + + func configure(title: String?, borderColor: UIColor?) { + dotView.layer.borderColor = (borderColor ?? .gray).cgColor + label.text = title + label.sizeToFit() + + // Layout: dot on left, label to the right with spacing + let spacing: CGFloat = 6 + let totalWidth = dotSize + spacing + label.frame.width + + frame = CGRect(x: 0, y: 0, width: totalWidth, height: max(dotSize, label.frame.height)) + dotView.frame = CGRect(x: 0, y: (frame.height - dotSize) / 2, width: dotSize, height: dotSize) + label.frame = CGRect(x: dotSize + spacing, y: 0, width: label.frame.width, height: frame.height) + + // Center on the dot position + centerOffset = CGPoint(x: (totalWidth - dotSize) / 2, y: 0) + } + + /// Updates label visibility based on zoom level + /// - Parameter showLabel: Whether to show the label + func updateLabelVisibility(showLabel: Bool) { + label.isHidden = !showLabel + + if showLabel { + // Full width with label + let spacing: CGFloat = 6 + let totalWidth = dotSize + spacing + label.frame.width + frame = CGRect(x: 0, y: 0, width: totalWidth, height: max(dotSize, label.frame.height)) + centerOffset = CGPoint(x: (totalWidth - dotSize) / 2, y: 0) + } else { + // Just the dot + frame = CGRect(x: 0, y: 0, width: dotSize, height: dotSize) + dotView.frame = bounds + centerOffset = CGPoint(x: 0, y: 0) + } + } +} diff --git a/OTPKit/Sources/OTPKit/Core/Map/MKMapViewAdapter.swift b/OTPKit/Sources/OTPKit/Core/Map/MKMapViewAdapter.swift index d6a0281..50c2b1d 100644 --- a/OTPKit/Sources/OTPKit/Core/Map/MKMapViewAdapter.swift +++ b/OTPKit/Sources/OTPKit/Core/Map/MKMapViewAdapter.swift @@ -22,6 +22,10 @@ public class MKMapViewAdapter: NSObject, OTPMapProvider { private var tapHandler: ((CLLocationCoordinate2D) -> Void)? private var annotationSelectedHandler: ((String) -> Void)? + /// Zoom threshold for showing intermediate stop labels (latitudeDelta) + /// Labels are shown when zoomed in closer than this value + private let intermediateStopLabelZoomThreshold: Double = 0.02 + // MARK: - Initialization /// Creates an adapter for the provided MKMapView @@ -266,6 +270,29 @@ extension MKMapViewAdapter: MKMapViewDelegate { return routeView } + // Use small dot view for intermediate stop annotations + if otpAnnotation.annotationType == .intermediateStop { + let identifier = "IntermediateStopAnnotation" + var dotView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) + as? IntermediateStopAnnotationView + + if dotView == nil { + dotView = IntermediateStopAnnotationView(annotation: annotation, + reuseIdentifier: identifier) + } else { + dotView?.annotation = annotation + } + + dotView?.configure(title: otpAnnotation.title, + borderColor: otpAnnotation.routeBackgroundColor) + + // Set initial label visibility based on current zoom level + let showLabels = mapView.region.span.latitudeDelta < intermediateStopLabelZoomThreshold + dotView?.updateLabelVisibility(showLabel: showLabels) + + return dotView + } + // Use standard marker view for other annotation types let identifier = "OTPAnnotation" var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) as? MKMarkerAnnotationView @@ -293,6 +320,16 @@ extension MKMapViewAdapter: MKMapViewDelegate { guard let otpAnnotation = view.annotation as? OTPPointAnnotation else { return } annotationSelectedHandler?(otpAnnotation.identifier) } + + public func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) { + let showLabels = mapView.region.span.latitudeDelta < intermediateStopLabelZoomThreshold + + for annotation in mapView.annotations { + if let view = mapView.view(for: annotation) as? IntermediateStopAnnotationView { + view.updateLabelVisibility(showLabel: showLabels) + } + } + } } // MARK: - UIGestureRecognizerDelegate From c33e0f04fa5c34e3cb408e528eefe65633af3827 Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Thu, 4 Dec 2025 15:40:34 -0800 Subject: [PATCH 2/4] Fix tests and swiftlint issues --- .../OTPKit/Core/Map/MKMapViewAdapter.swift | 82 ++++++++++--------- OTPKit/Tests/TripPlannerViewModelTests.swift | 14 +++- 2 files changed, 56 insertions(+), 40 deletions(-) diff --git a/OTPKit/Sources/OTPKit/Core/Map/MKMapViewAdapter.swift b/OTPKit/Sources/OTPKit/Core/Map/MKMapViewAdapter.swift index 50c2b1d..4db7a5e 100644 --- a/OTPKit/Sources/OTPKit/Core/Map/MKMapViewAdapter.swift +++ b/OTPKit/Sources/OTPKit/Core/Map/MKMapViewAdapter.swift @@ -238,6 +238,49 @@ extension MKMapViewAdapter: MKMapViewDelegate { return MKOverlayRenderer(overlay: overlay) } + fileprivate func buildRouteLegendAnnotationView(_ mapView: MKMapView, _ annotation: any MKAnnotation, _ otpAnnotation: OTPPointAnnotation) -> MKAnnotationView? { + let identifier = "RouteNameAnnotation" + var routeView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) as? RouteNameAnnotationView + + if routeView == nil { + routeView = RouteNameAnnotationView(annotation: annotation, reuseIdentifier: identifier) + } else { + routeView?.annotation = annotation + } + + if let routeName = otpAnnotation.routeName { + routeView?.configure( + routeName: routeName, + backgroundColor: otpAnnotation.routeBackgroundColor, + textColor: otpAnnotation.routeTextColor + ) + } + + return routeView + } + + fileprivate func buildIntermediateStopAnnotationView(_ mapView: MKMapView, _ annotation: any MKAnnotation, _ otpAnnotation: OTPPointAnnotation) -> MKAnnotationView? { + let identifier = "IntermediateStopAnnotation" + var dotView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) + as? IntermediateStopAnnotationView + + if dotView == nil { + dotView = IntermediateStopAnnotationView(annotation: annotation, + reuseIdentifier: identifier) + } else { + dotView?.annotation = annotation + } + + dotView?.configure(title: otpAnnotation.title, + borderColor: otpAnnotation.routeBackgroundColor) + + // Set initial label visibility based on current zoom level + let showLabels = mapView.region.span.latitudeDelta < intermediateStopLabelZoomThreshold + dotView?.updateLabelVisibility(showLabel: showLabels) + + return dotView + } + public func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { // Don't customize user location if annotation is MKUserLocation { @@ -250,47 +293,12 @@ extension MKMapViewAdapter: MKMapViewDelegate { // Use custom view for route legend annotations if otpAnnotation.annotationType == .routeLegend { - let identifier = "RouteNameAnnotation" - var routeView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) as? RouteNameAnnotationView - - if routeView == nil { - routeView = RouteNameAnnotationView(annotation: annotation, reuseIdentifier: identifier) - } else { - routeView?.annotation = annotation - } - - if let routeName = otpAnnotation.routeName { - routeView?.configure( - routeName: routeName, - backgroundColor: otpAnnotation.routeBackgroundColor, - textColor: otpAnnotation.routeTextColor - ) - } - - return routeView + return buildRouteLegendAnnotationView(mapView, annotation, otpAnnotation) } // Use small dot view for intermediate stop annotations if otpAnnotation.annotationType == .intermediateStop { - let identifier = "IntermediateStopAnnotation" - var dotView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) - as? IntermediateStopAnnotationView - - if dotView == nil { - dotView = IntermediateStopAnnotationView(annotation: annotation, - reuseIdentifier: identifier) - } else { - dotView?.annotation = annotation - } - - dotView?.configure(title: otpAnnotation.title, - borderColor: otpAnnotation.routeBackgroundColor) - - // Set initial label visibility based on current zoom level - let showLabels = mapView.region.span.latitudeDelta < intermediateStopLabelZoomThreshold - dotView?.updateLabelVisibility(showLabel: showLabels) - - return dotView + return buildIntermediateStopAnnotationView(mapView, annotation, otpAnnotation) } // Use standard marker view for other annotation types diff --git a/OTPKit/Tests/TripPlannerViewModelTests.swift b/OTPKit/Tests/TripPlannerViewModelTests.swift index 7d6b71c..882241e 100644 --- a/OTPKit/Tests/TripPlannerViewModelTests.swift +++ b/OTPKit/Tests/TripPlannerViewModelTests.swift @@ -450,7 +450,7 @@ struct TripPlannerViewModelTests { func resetTripPlannerClearsAllState() { let viewModel = createViewModel() - // Set up some state + // Set up some state (trip-specific, not persisted preferences) viewModel.selectedOrigin = TestHelpers.location(title: "Origin") viewModel.selectedDestination = TestHelpers.location(title: "Destination") viewModel.tripPlanResponse = TestHelpers.response(with: []) @@ -459,11 +459,19 @@ struct TripPlannerViewModelTests { viewModel.showingError = true viewModel.isLoading = true viewModel.selectedTransportMode = .bike - viewModel.isWheelchairAccessible = true - viewModel.maxWalkingDistance = .halfMile viewModel.timePreference = .arriveBy viewModel.departureTime = Date() viewModel.departureDate = Date() + // Note: Not modifying isWheelchairAccessible/maxWalkingDistance/routePreference + // as these are persisted to UserDefaults asynchronously, which would create + // a race condition with resetTripPlanner()'s reload from UserDefaults + + // Clear UserDefaults again right before reset to avoid race conditions + // with async saves from other tests + let defaults = UserDefaults.standard + defaults.removeObject(forKey: "OTPKit.TripOptions.wheelchairAccessible") + defaults.removeObject(forKey: "OTPKit.TripOptions.maxWalkingDistance") + defaults.removeObject(forKey: "OTPKit.TripOptions.routePreference") // Reset viewModel.resetTripPlanner() From e4b55c22effd7acc054748a3c7fb8a2703bdf0e4 Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Thu, 4 Dec 2025 23:01:13 -0800 Subject: [PATCH 3/4] Add dark mode support to the intermediate stop annotation view --- .../Map/IntermediateStopAnnotationView.swift | 87 +++++++++++++++++-- 1 file changed, 80 insertions(+), 7 deletions(-) diff --git a/OTPKit/Sources/OTPKit/Core/Map/IntermediateStopAnnotationView.swift b/OTPKit/Sources/OTPKit/Core/Map/IntermediateStopAnnotationView.swift index de553c1..5e5013b 100644 --- a/OTPKit/Sources/OTPKit/Core/Map/IntermediateStopAnnotationView.swift +++ b/OTPKit/Sources/OTPKit/Core/Map/IntermediateStopAnnotationView.swift @@ -13,6 +13,8 @@ class IntermediateStopAnnotationView: MKAnnotationView { private let dotView = UIView() private let label = UILabel() private let dotSize: CGFloat = 10 + private var currentTitle: String? + private var currentBorderColor: UIColor? override init(annotation: MKAnnotation?, reuseIdentifier: String?) { super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) @@ -27,24 +29,45 @@ class IntermediateStopAnnotationView: MKAnnotationView { private func setupView() { canShowCallout = false - // Setup dot dotView.backgroundColor = .white dotView.layer.cornerRadius = dotSize / 2 dotView.layer.borderWidth = 2 dotView.layer.borderColor = UIColor.gray.cgColor addSubview(dotView) - // Setup label - dark gray on white shadow (Apple Maps style) - label.font = UIFont.systemFont(ofSize: 12, weight: .medium) - label.textColor = .darkGray - label.shadowColor = .white - label.shadowOffset = CGSize(width: 1, height: 1) addSubview(label) } + private func updateLabelColors() { + guard let title = currentTitle else { return } + + let isDarkMode = traitCollection.userInterfaceStyle == .dark + let foregroundColor = isDarkMode + ? UIColor.white.withAlphaComponent(0.9) + : UIColor.black.withAlphaComponent(0.9) + let strokeColor = isDarkMode ? UIColor.black : UIColor.white + + let attributes: [NSAttributedString.Key: Any] = [ + .font: UIFont(name: "HelveticaNeue-CondensedBold", size: 15) as Any, + .foregroundColor: foregroundColor, + .strokeColor: strokeColor, + .strokeWidth: -4.0 // Negative = fill + stroke + ] + label.attributedText = NSAttributedString(string: title, attributes: attributes) + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { + updateLabelColors() + } + } + func configure(title: String?, borderColor: UIColor?) { + currentTitle = title + currentBorderColor = borderColor dotView.layer.borderColor = (borderColor ?? .gray).cgColor - label.text = title + updateLabelColors() label.sizeToFit() // Layout: dot on left, label to the right with spacing @@ -78,3 +101,53 @@ class IntermediateStopAnnotationView: MKAnnotationView { } } } + +// MARK: - Preview + +import SwiftUI + +#Preview("Light Mode") { + IntermediateStopAnnotationPreview( + title: "24th Ave E & E McGraw St", + borderColor: .orange + ) + .preferredColorScheme(.light) +} + +#Preview("Dark Mode") { + IntermediateStopAnnotationPreview( + title: "24th Ave E & E McGraw St", + borderColor: .orange + ) + .preferredColorScheme(.dark) +} + +#Preview("Dot Only") { + IntermediateStopAnnotationPreview( + title: "24th Ave E & E McGraw St", + borderColor: .blue, + showLabel: false + ) +} + +private struct IntermediateStopAnnotationPreview: UIViewRepresentable { + let title: String + let borderColor: UIColor + var showLabel: Bool = true + + func makeUIView(context: Context) -> UIView { + let container = UIView() + container.backgroundColor = .systemBackground + + let annotationView = IntermediateStopAnnotationView(annotation: nil, reuseIdentifier: nil) + annotationView.configure(title: title, borderColor: borderColor) + annotationView.updateLabelVisibility(showLabel: showLabel) + + container.addSubview(annotationView) + annotationView.center = CGPoint(x: 150, y: 30) + + return container + } + + func updateUIView(_ uiView: UIView, context: Context) {} +} From 634c9da7be8cce2faf0f86f3701f9a1d9666fa33 Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Thu, 4 Dec 2025 23:02:15 -0800 Subject: [PATCH 4/4] Fix linting issues --- .../OTPKit/Core/Map/MKMapViewAdapter.swift | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/OTPKit/Sources/OTPKit/Core/Map/MKMapViewAdapter.swift b/OTPKit/Sources/OTPKit/Core/Map/MKMapViewAdapter.swift index 4db7a5e..e1e383e 100644 --- a/OTPKit/Sources/OTPKit/Core/Map/MKMapViewAdapter.swift +++ b/OTPKit/Sources/OTPKit/Core/Map/MKMapViewAdapter.swift @@ -241,13 +241,13 @@ extension MKMapViewAdapter: MKMapViewDelegate { fileprivate func buildRouteLegendAnnotationView(_ mapView: MKMapView, _ annotation: any MKAnnotation, _ otpAnnotation: OTPPointAnnotation) -> MKAnnotationView? { let identifier = "RouteNameAnnotation" var routeView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) as? RouteNameAnnotationView - + if routeView == nil { routeView = RouteNameAnnotationView(annotation: annotation, reuseIdentifier: identifier) } else { routeView?.annotation = annotation } - + if let routeName = otpAnnotation.routeName { routeView?.configure( routeName: routeName, @@ -255,32 +255,32 @@ extension MKMapViewAdapter: MKMapViewDelegate { textColor: otpAnnotation.routeTextColor ) } - + return routeView } - + fileprivate func buildIntermediateStopAnnotationView(_ mapView: MKMapView, _ annotation: any MKAnnotation, _ otpAnnotation: OTPPointAnnotation) -> MKAnnotationView? { let identifier = "IntermediateStopAnnotation" var dotView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) as? IntermediateStopAnnotationView - + if dotView == nil { dotView = IntermediateStopAnnotationView(annotation: annotation, reuseIdentifier: identifier) } else { dotView?.annotation = annotation } - + dotView?.configure(title: otpAnnotation.title, borderColor: otpAnnotation.routeBackgroundColor) - + // Set initial label visibility based on current zoom level let showLabels = mapView.region.span.latitudeDelta < intermediateStopLabelZoomThreshold dotView?.updateLabelVisibility(showLabel: showLabels) - + return dotView } - + public func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { // Don't customize user location if annotation is MKUserLocation {