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
6 changes: 6 additions & 0 deletions OTPKit/Sources/OTPKit/Core/Extensions/StringExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ extension String {
.replacingOccurrences(of: " ", with: "_")
}

/// Uppercases only the first character, leaving the rest untouched: `e-bike` becomes
/// `E-bike`. Unlike `capitalized`, this never lowercases the remainder.
var capitalizedFirst: String {
isEmpty ? self : prefix(1).uppercased() + dropFirst()
}

/// 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
12 changes: 10 additions & 2 deletions OTPKit/Sources/OTPKit/Core/Models/OTP/Leg.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ public struct Leg: Codable, Hashable {
/// Optional flag indicating whether this leg involves transit.
public let transitLeg: Bool?

/// True when this leg is ridden on a rented vehicle (bikeshare/micromobility).
/// Present in both OTP 1.x REST and 2.x GraphQL responses; the ride leg's `mode`
/// is plain "BICYCLE", so this flag is the only reliable rental discriminator.
public let rentedBike: Bool?

/// Duration of the leg in seconds.
public let duration: Int

Expand Down Expand Up @@ -124,7 +129,8 @@ public struct Leg: Codable, Hashable {
headsign: String?,
intermediateStops: [Place]?,
departureDelay: Int? = nil,
arrivalDelay: Int? = nil
arrivalDelay: Int? = nil,
rentedBike: Bool? = nil
) {
self.startTime = startTime
self.endTime = endTime
Expand All @@ -148,6 +154,7 @@ public struct Leg: Codable, Hashable {
self.intermediateStops = intermediateStops
self.departureDelay = departureDelay
self.arrivalDelay = arrivalDelay
self.rentedBike = rentedBike
}

/// Merges `Itinerary` `Leg`s that are part of the same route on the same vehicle.
Expand Down Expand Up @@ -178,7 +185,8 @@ public struct Leg: Codable, Hashable {
headsign: leg1.headsign,
intermediateStops: leg1.intermediateStops,
departureDelay: leg1.departureDelay,
arrivalDelay: leg2.arrivalDelay
arrivalDelay: leg2.arrivalDelay,
rentedBike: leg1.rentedBike
)
}

Expand Down
8 changes: 7 additions & 1 deletion OTPKit/Sources/OTPKit/Core/Models/OTP/Place.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,24 @@ public struct Place: Codable, Hashable {
// StopCode of the stop
public let stopCode: String?

/// Identifier of the vehicle rental entity at this place — a station's `stationId` or a
/// free-floating vehicle's `vehicleId`, whichever the leg references. Nil for non-rental places.
public let bikeShareId: String?

/// Custom initializer for creating Place instances
public init(name: String,
lon: Double,
lat: Double,
vertexType: String,
stopId: String? = nil,
stopCode: String? = nil) {
stopCode: String? = nil,
bikeShareId: String? = nil) {
self.name = name
self.lon = lon
self.lat = lat
self.vertexType = vertexType
self.stopId = stopId
self.stopCode = stopCode
self.bikeShareId = bikeShareId
}
}
51 changes: 51 additions & 0 deletions OTPKit/Sources/OTPKit/Core/Models/OTP/RentalVehicle.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* 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 CoreLocation
import Foundation

/// A free-floating rental vehicle (dockless bike, scooter, etc.) — the dominant
/// entity type on real feeds.
public struct RentalVehicle: Codable, Hashable, Sendable {
public let vehicleId: String
/// Raw feed name. Often a placeholder like "Default vehicle type" — surface
/// `VehicleRental.displayLabel` to riders instead.
public let name: String
public let lat: Double
public let lon: Double
public let allowPickupNow: Bool?
public let operative: Bool?
public let rentalNetwork: RentalNetwork?
public let rentalUris: RentalUris?
public let vehicleType: VehicleType?
public let fuel: FuelInfo?

public var coordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(latitude: lat, longitude: lon)
}

/// Whether the vehicle is in service. Treats missing data as operative.
public var isOperative: Bool {
operative ?? true
}

/// True when the vehicle matches one of the given form factors.
/// Fail-open: a vehicle with no typed data is assumed to match.
public func matches(formFactors: Set<VehicleFormFactor>) -> Bool {
guard let formFactor = vehicleType?.formFactor else { return true }
return formFactors.contains(formFactor)
}
}
9 changes: 9 additions & 0 deletions OTPKit/Sources/OTPKit/Core/Models/OTP/TransportMode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ 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}`.
case bikeRental = "BICYCLE_RENT"

/// Localized, human-readable description of the transport mode
public var displayName: String {
Expand All @@ -29,6 +32,8 @@ public enum TransportMode: String, CaseIterable, Codable {
return OTPLoc("transport_mode.bike", comment: "Transport mode: Bike")
case .car:
return OTPLoc("transport_mode.car", comment: "Transport mode: Car")
case .bikeRental:
return OTPLoc("transport_mode.bike_rental", comment: "Transport mode: Bike Rental")
}
}

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

Expand All @@ -58,6 +65,8 @@ public enum TransportMode: String, CaseIterable, Codable {
return [.bike, .walk]
case .car:
return [.car]
case .bikeRental:
return [.bikeRental, .walk]
}
}
}
57 changes: 57 additions & 0 deletions OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleFormFactor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* 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 Foundation

/// The physical form factor of a rental vehicle, mirroring OTP's `FormFactor` GraphQL enum.
///
/// Decodes fail-open: an unrecognized wire value becomes `.other` instead of throwing,
/// so one novel vehicle type can never invalidate an entire multi-thousand-entity payload.
public enum VehicleFormFactor: String, Codable, Hashable, Sendable, CaseIterable {
case bicycle = "BICYCLE"
case cargoBicycle = "CARGO_BICYCLE"
case car = "CAR"
case moped = "MOPED"
case scooter = "SCOOTER"
case scooterSeated = "SCOOTER_SEATED"
case scooterStanding = "SCOOTER_STANDING"
case other = "OTHER"

public init(from decoder: Decoder) throws {
let raw = try decoder.singleValueContainer().decode(String.self)
self = VehicleFormFactor(rawValue: raw.normalizedOTPToken) ?? .other
}

/// True for any scooter variant (standing, seated, or unspecified).
public var isScooter: Bool {
switch self {
case .scooter, .scooterSeated, .scooterStanding:
return true
case .bicycle, .cargoBicycle, .car, .moped, .other:
return false
}
}

/// True for any bicycle variant (including cargo bikes).
public var isBicycle: Bool {
switch self {
case .bicycle, .cargoBicycle:
return true
case .scooter, .scooterSeated, .scooterStanding, .car, .moped, .other:
return false
}
}
}
168 changes: 168 additions & 0 deletions OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleRental.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/*
* 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 CoreLocation
import Foundation

/// A vehicle rental entity — either a docked station or a free-floating vehicle.
///
/// Decodes the GTFS GraphQL `RentalPlace` union using `__typename` discrimination.
/// `Decodable` only: these are read from responses and never serialized back.
public enum VehicleRental: Identifiable, Hashable, Sendable {
case station(VehicleRentalStation)
case vehicle(RentalVehicle)

public var id: String {
switch self {
case .station(let station): return station.stationId
case .vehicle(let vehicle): return vehicle.vehicleId
}
}

// MARK: - Convenience Accessors

/// The raw feed name. Prefer `displayLabel` for rider-facing UI.
public var name: String {
switch self {
case .station(let station): return station.name
case .vehicle(let vehicle): return vehicle.name
}
}

public var coordinate: CLLocationCoordinate2D {
switch self {
case .station(let station): return station.coordinate
case .vehicle(let vehicle): return vehicle.coordinate
}
}

/// Whether the entity is in service. Treats missing data as operative.
public var isOperative: Bool {
switch self {
case .station(let station): return station.isOperative
case .vehicle(let vehicle): return vehicle.isOperative
}
}

public var rentalNetwork: RentalNetwork? {
switch self {
case .station(let station): return station.rentalNetwork
case .vehicle(let vehicle): return vehicle.rentalNetwork
}
}

public var rentalUris: RentalUris? {
switch self {
case .station(let station): return station.rentalUris
case .vehicle(let vehicle): return vehicle.rentalUris
}
}

/// Battery charge ratio in 0...1, when the feed provides it. Frequently nil.
public var batteryPercent: Double? {
switch self {
case .station: return nil
case .vehicle(let vehicle): return vehicle.fuel?.percent
}
}

/// True when the entity matches one of the given form factors (fail-open on
/// missing typed data — see the underlying model's `matches(formFactors:)`).
public func matches(formFactors: Set<VehicleFormFactor>) -> Bool {
switch self {
case .station(let station): return station.matches(formFactors: formFactors)
case .vehicle(let vehicle): return vehicle.matches(formFactors: formFactors)
}
}

// MARK: - Display Label

/// A rider-facing label, e.g. "Lime e-bike" or "Pine St Station". Never surfaces
/// known feed placeholders like "Default vehicle type".
public var displayLabel: String {
switch self {
case .station(let station):
return station.name

case .vehicle(let vehicle):
let typeName = Self.localizedTypeName(for: vehicle.vehicleType)

if let network = vehicle.rentalNetwork?.displayName, !network.isEmpty {
return "\(network) \(typeName)"
}

let trimmedName = vehicle.name.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedName.isEmpty, !Self.isPlaceholderName(trimmedName) {
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")
}

if formFactor.isBicycle {
return vehicleType.isPowered
? OTPLoc("rental.vehicle_type.ebike", comment: "Rental vehicle type: electric bike")
: OTPLoc("rental.vehicle_type.bike", comment: "Rental vehicle type: bike")
}
if formFactor.isScooter {
return OTPLoc("rental.vehicle_type.scooter", comment: "Rental vehicle type: scooter")
}

switch formFactor {
case .car:
return OTPLoc("rental.vehicle_type.car", comment: "Rental vehicle type: car")
case .moped:
return OTPLoc("rental.vehicle_type.moped", comment: "Rental vehicle type: moped")
default:
return OTPLoc("rental.vehicle_type.vehicle", comment: "Generic rental vehicle type name")
}
}
}

// MARK: - Decodable

extension VehicleRental: Decodable {
private enum TypeNameCodingKeys: String, CodingKey {
case typename = "__typename"
}

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: TypeNameCodingKeys.self)
let typename = try container.decode(String.self, forKey: .typename)

switch typename {
case "VehicleRentalStation":
self = .station(try VehicleRentalStation(from: decoder))
case "RentalVehicle":
self = .vehicle(try RentalVehicle(from: decoder))
default:
throw DecodingError.dataCorrupted(DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Unknown RentalPlace __typename: \(typename)"
))
}
}
}
Loading
Loading