diff --git a/OTPKit/Sources/OTPKit/Core/Map/IntermediateStopAnnotationView.swift b/OTPKit/Sources/OTPKit/Core/Map/IntermediateStopAnnotationView.swift new file mode 100644 index 0000000..5e5013b --- /dev/null +++ b/OTPKit/Sources/OTPKit/Core/Map/IntermediateStopAnnotationView.swift @@ -0,0 +1,153 @@ +// +// 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 + private var currentTitle: String? + private var currentBorderColor: UIColor? + + 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 + + dotView.backgroundColor = .white + dotView.layer.cornerRadius = dotSize / 2 + dotView.layer.borderWidth = 2 + dotView.layer.borderColor = UIColor.gray.cgColor + addSubview(dotView) + + 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 + updateLabelColors() + 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) + } + } +} + +// 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) {} +} diff --git a/OTPKit/Sources/OTPKit/Core/Map/MKMapViewAdapter.swift b/OTPKit/Sources/OTPKit/Core/Map/MKMapViewAdapter.swift index d6a0281..e1e383e 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 @@ -234,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 { @@ -246,24 +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 buildRouteLegendAnnotationView(mapView, annotation, otpAnnotation) + } - return routeView + // Use small dot view for intermediate stop annotations + if otpAnnotation.annotationType == .intermediateStop { + return buildIntermediateStopAnnotationView(mapView, annotation, otpAnnotation) } // Use standard marker view for other annotation types @@ -293,6 +328,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 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()