Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions OTPKit/Sources/OTPKit/Core/Extensions/ColorExtension.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* Copyright (C) Open Transit Software Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy at:
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for specific language governing permissions and
* limitations under the License.
*/

import SwiftUI

extension Color {
/// Rental purple (#7B4FD1). Every rental surface — browse-layer pins,
/// trip-planner pickup/dropoff markers, and rail rows — shares this color
/// so rentals read as one system.
static let otpRentalPurple = Color(red: 0x7B / 255.0, green: 0x4F / 255.0, blue: 0xD1 / 255.0)
}
8 changes: 8 additions & 0 deletions OTPKit/Sources/OTPKit/Core/Extensions/StringExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ extension String {
isEmpty ? self : prefix(1).uppercased() + dropFirst()
}

/// True when this is a known rental-feed placeholder name ("Default vehicle type")
/// that must never reach the UI. The single source of truth for the check —
/// `VehicleRental.displayLabel`, `Leg.riderFacingName(of:)`, and the rail views
/// all decide their own substitution policy on top of this one predicate.
var isRentalPlaceholderName: Bool {
trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "default vehicle type"
}

/// Renders an unrecognized OTP token as readable text: `SPIN_AROUND` becomes `Spin Around`.
///
/// Last-resort display fallback for a mode or direction this client doesn't know about.
Expand Down
36 changes: 34 additions & 2 deletions OTPKit/Sources/OTPKit/Core/Map/MapCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import MapKit
import SwiftUI
import OSLog

// swiftlint:disable file_length

/// Coordinates all map operations between OTPKit and the external map provider
/// This class manages routes, annotations, and user interactions with the map
@MainActor
Expand Down Expand Up @@ -212,7 +214,9 @@ public class MapCoordinator: ObservableObject { // swiftlint:disable:this type_b
return .gray
case "BUS", "TRAM", "TRAIN", "SUBWAY", "FERRY":
return .blue
case "BIKE", "CAR":
// OTP 2.x spells bicycle legs "BICYCLE" (rental rides included); "BIKE" is
// the OTP 1.x REST spelling.
case "BIKE", "BICYCLE", "CAR":
return .orange
default:
return .gray
Expand All @@ -238,7 +242,7 @@ public class MapCoordinator: ObservableObject { // swiftlint:disable:this type_b
return "bus"
case "TRAM":
return "tram"
case "BIKE":
case "BIKE", "BICYCLE":
return "bicycle"
case "CAR":
return "car"
Expand Down Expand Up @@ -297,6 +301,12 @@ public class MapCoordinator: ObservableObject { // swiftlint:disable:this type_b
}

private func addStationAnnotations(for leg: Leg, index: Int, totalLegs: Int) {
// Rental legs get pickup/dropoff markers; transit legs get embark/debark markers.
if leg.isRentalRide {
addRentalAnnotations(for: leg, index: index)
return
}

// Only add embark/debark markers for transit legs
guard leg.transitLeg == true else { return }
// Add annotation for "from" location (embark point)
Expand Down Expand Up @@ -329,6 +339,26 @@ public class MapCoordinator: ObservableObject { // swiftlint:disable:this type_b
// Add intermediate stop markers
addIntermediateStopAnnotations(for: leg, index: index)
}
/// Marks the rental pickup and dropoff points of a rental ride leg. Rental places
/// carry a `bikeShareId` instead of a `vertexType`/`stopId`, so the transit-station
/// checks never match them. Only vehicles on the planned route are annotated — the
/// browse layer is a separate surface owned by the host.
private func addRentalAnnotations(for leg: Leg, index: Int) {
let endpoints = [(leg.from, "rental_pickup_\(index)"), (leg.to, "rental_dropoff_\(index)")]
for (place, identifier) in endpoints where place.bikeShareId != nil {
mapProvider.addAnnotation(
coordinate: CLLocationCoordinate2D(latitude: place.lat, longitude: place.lon),
title: Leg.riderFacingName(of: place),
subtitle: nil,
identifier: identifier,
type: .rentalVehicle,
routeName: nil,
routeBackgroundColor: nil,
routeTextColor: nil
)
}
}

private func addIntermediateStopAnnotations(for leg: Leg, index: Int) {
guard leg.transitLeg == true, let stops = leg.intermediateStops, !stops.isEmpty else { return }
let routeColor = leg.routeColor.flatMap { UIColor(hex: $0) }
Expand Down Expand Up @@ -377,3 +407,5 @@ public class MapCoordinator: ObservableObject { // swiftlint:disable:this type_b
Logger.main.info("Annotation selected: \(identifier)")
}
}

// swiftlint:enable file_length
6 changes: 6 additions & 0 deletions OTPKit/Sources/OTPKit/Core/Map/OTPMapProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ public enum OTPAnnotationType {
case embark
case debark
case intermediateStop
/// A rental vehicle or station that is part of the planned route (pickup/dropoff).
case rentalVehicle

/// Returns the appropriate color for this annotation type
public var color: Color {
Expand All @@ -162,6 +164,8 @@ public enum OTPAnnotationType {
return .orange
case .intermediateStop:
return .gray
case .rentalVehicle:
return .otpRentalPurple
case .routeLegend:
return .clear // Custom view will handle coloring
}
Expand Down Expand Up @@ -190,6 +194,8 @@ public enum OTPAnnotationType {
return "magnifyingglass"
case .intermediateStop:
return "circle.fill"
case .rentalVehicle:
return "bicycle.circle.fill"
case .routeLegend:
return "" // Custom view will handle display
}
Expand Down
27 changes: 27 additions & 0 deletions OTPKit/Sources/OTPKit/Core/Models/OTP/Leg.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ public struct Leg: Codable, Hashable {
mode.lowercased() == "walk"
}

/// True when this leg is ridden on a rented vehicle. The ride leg's `mode` is
/// plain "BICYCLE", so `rentedBike` is the only reliable rental discriminator.
public var isRentalRide: Bool {
rentedBike == true
}

public let routeType: RouteType?

public let routeColor: String?
Expand Down Expand Up @@ -196,6 +202,12 @@ public struct Leg: Codable, Hashable {
/// - leg2: The later leg
/// - Returns: Whether or not to merge the legs.
public static func shouldMergeLegs(leg1: Leg, leg2: Leg) -> Bool {
// A merge must never cross a rental boundary: a merged leg keeps leg1's
// rentedBike flag, which would silently absorb a pickup or dropoff. In
// practice rental legs are non-transit and can't match the conditions
// below anyway — this guard keeps that invariant explicit.
guard leg1.isRentalRide == leg2.isRentalRide else { return false }

return leg1.route != nil &&
leg2.route != nil &&
leg1.route == leg2.route &&
Expand Down Expand Up @@ -264,6 +276,21 @@ public struct Leg: Codable, Hashable {
route ?? modeDisplayName
}

/// The rider-facing name of this leg's ending place. Rental feeds ship the
/// literal placeholder "Default vehicle type" as a free-floating vehicle's
/// name; it is replaced with a localized generic so it never reaches the UI.
public var riderFacingToName: String {
Self.riderFacingName(of: to)
}

static func riderFacingName(of place: Place) -> String {
let trimmed = place.name.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isRentalPlaceholderName || (trimmed.isEmpty && place.bikeShareId != nil) {
return OTPLoc("place.rental_bike", comment: "Generic name for a rental bike location")
}
return trimmed
}

// MARK: - Real-Time Status

/// Real-time status of this leg's departure, from `realTime` and `departureDelay`.
Expand Down
31 changes: 28 additions & 3 deletions OTPKit/Sources/OTPKit/Core/Models/OTP/TransportMode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,14 @@ public enum TransportMode: String, CaseIterable, Codable {
case bike = "BIKE"
/// Driving
case car = "CAR"
/// Rented bicycle/micromobility (bikeshare). The raw value is the OTP 1.x REST wire
/// token; the GraphQL service translates it to `{mode: BICYCLE, qualifier: RENT}`.
/// Rented bicycle/micromobility (bikeshare) without transit — "Bikeshare Only".
/// The raw value is the OTP 1.x REST wire token; the GraphQL service translates
/// it to `{mode: BICYCLE, qualifier: RENT}`.
case bikeRental = "BICYCLE_RENT"
/// Transit combined with rented micromobility — "Transit + Bikeshare". Unlike the
/// other cases, the raw value is *not* a wire token: this mode only ever reaches a
/// request expanded through `apiModes`, never as itself.
case transitBikeRental = "TRANSIT_BICYCLE_RENT"

/// Localized, human-readable description of the transport mode
public var displayName: String {
Expand All @@ -33,7 +38,9 @@ public enum TransportMode: String, CaseIterable, Codable {
case .car:
return OTPLoc("transport_mode.car", comment: "Transport mode: Car")
case .bikeRental:
return OTPLoc("transport_mode.bike_rental", comment: "Transport mode: Bike Rental")
return OTPLoc("transport_mode.bike_rental", comment: "Transport mode: Bikeshare Only")
case .transitBikeRental:
return OTPLoc("transport_mode.transit_bike_rental", comment: "Transport mode: Transit + Bikeshare")
}
}

Expand All @@ -50,6 +57,8 @@ public enum TransportMode: String, CaseIterable, Codable {
return "car"
case .bikeRental:
return "bicycle.circle"
case .transitBikeRental:
return "bicycle.circle.fill"
}
}

Expand All @@ -67,6 +76,22 @@ public enum TransportMode: String, CaseIterable, Codable {
return [.car]
case .bikeRental:
return [.bikeRental, .walk]
case .transitBikeRental:
return [.transit, .walk, .bikeRental]
}
}

/// True for modes that only work against a rental-capable backend
/// (`apiService is VehicleRentalService`). The UI hides these otherwise.
public var requiresVehicleRentalSupport: Bool {
self == .bikeRental || self == .transitBikeRental
}

/// The primitive modes this mode puts on the wire. Composite UI modes
/// (`.transitBikeRental`) expand to their `apiModes`; primitives are themselves.
/// Both services serialize through this, so a composite's fabricated raw value
/// can never leak into a request — no matter how the host built it.
public var wireModes: [TransportMode] {
self == .transitBikeRental ? apiModes : [self]
}
}
27 changes: 23 additions & 4 deletions OTPKit/Sources/OTPKit/Core/Models/OTP/TripPlanRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ public struct TripPlanRequest: Codable, Hashable {
public let wheelchairAccessible: Bool
/// Whether the time parameter refers to arrival time (true) or departure time (false)
public let arriveBy: Bool
/// An intermediate coordinate the trip must pass through, e.g. a specific rental
/// vehicle's location for "plan a trip using this bike". Servers may require a
/// transit mode in `transportModes` to route through a via point.
public let viaPoint: CLLocationCoordinate2D?

/// Creates a new trip plan request
/// - Parameters:
Expand All @@ -46,6 +50,7 @@ public struct TripPlanRequest: Codable, Hashable {
/// - maxWalkDistance: The maximum walking distance in meters (defaults to 1000)
/// - wheelchairAccessible: Whether the route should be wheelchair accessible (defaults to false)
/// - arriveBy: Whether the time parameter refers to arrival time (defaults to false)
/// - viaPoint: An intermediate coordinate the trip must pass through (defaults to nil)
public init(
origin: CLLocationCoordinate2D,
destination: CLLocationCoordinate2D,
Expand All @@ -54,7 +59,8 @@ public struct TripPlanRequest: Codable, Hashable {
transportModes: [TransportMode] = [.transit, .walk],
maxWalkDistance: Int = 1000,
wheelchairAccessible: Bool = false,
arriveBy: Bool = false
arriveBy: Bool = false,
viaPoint: CLLocationCoordinate2D? = nil
) {
self.origin = origin
self.destination = destination
Expand All @@ -64,11 +70,20 @@ public struct TripPlanRequest: Codable, Hashable {
self.maxWalkDistance = maxWalkDistance
self.wheelchairAccessible = wheelchairAccessible
self.arriveBy = arriveBy
self.viaPoint = viaPoint
}

/// Converts the transport modes to the API string format
/// Converts the transport modes to the OTP 1.x REST `mode` parameter: wire tokens,
/// comma-joined. Composite UI modes expand to their primitives, deduplicated in
/// first-appearance order.
public var transportModesString: String {
transportModes.map { $0.rawValue }.joined(separator: ",")
wireTransportModes.map { $0.rawValue }.joined(separator: ",")
}

/// The primitive, deduplicated modes requests actually serialize.
public var wireTransportModes: [TransportMode] {
var seen = Set<TransportMode>()
return transportModes.flatMap(\.wireModes).filter { seen.insert($0).inserted }
}

/// Validates the request parameters
Expand Down Expand Up @@ -108,6 +123,8 @@ public struct TripPlanRequest: Codable, Hashable {
hasher.combine(maxWalkDistance)
hasher.combine(wheelchairAccessible)
hasher.combine(arriveBy)
hasher.combine(viaPoint?.latitude)
hasher.combine(viaPoint?.longitude)
}

public static func == (lhs: TripPlanRequest, rhs: TripPlanRequest) -> Bool {
Expand All @@ -120,7 +137,9 @@ public struct TripPlanRequest: Codable, Hashable {
lhs.transportModes == rhs.transportModes &&
lhs.maxWalkDistance == rhs.maxWalkDistance &&
lhs.wheelchairAccessible == rhs.wheelchairAccessible &&
lhs.arriveBy == rhs.arriveBy
lhs.arriveBy == rhs.arriveBy &&
lhs.viaPoint?.latitude == rhs.viaPoint?.latitude &&
lhs.viaPoint?.longitude == rhs.viaPoint?.longitude
}
}

Expand Down
6 changes: 1 addition & 5 deletions OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleRental.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,18 +105,14 @@ public enum VehicleRental: Identifiable, Hashable, Sendable {
}

let trimmedName = vehicle.name.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedName.isEmpty, !Self.isPlaceholderName(trimmedName) {
if !trimmedName.isEmpty, !trimmedName.isRentalPlaceholderName {
return trimmedName
}

return typeName.capitalizedFirst
}
}

private static func isPlaceholderName(_ name: String) -> Bool {
name.lowercased() == "default vehicle type"
}

private static func localizedTypeName(for vehicleType: VehicleType?) -> String {
guard let vehicleType, let formFactor = vehicleType.formFactor else {
return OTPLoc("rental.vehicle_type.vehicle", comment: "Generic rental vehicle type name")
Expand Down
Loading
Loading