diff --git a/OTPKit/Sources/OTPKit/Core/Extensions/ColorExtension.swift b/OTPKit/Sources/OTPKit/Core/Extensions/ColorExtension.swift new file mode 100644 index 0000000..e9ed001 --- /dev/null +++ b/OTPKit/Sources/OTPKit/Core/Extensions/ColorExtension.swift @@ -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) +} diff --git a/OTPKit/Sources/OTPKit/Core/Extensions/StringExtension.swift b/OTPKit/Sources/OTPKit/Core/Extensions/StringExtension.swift index fee39f4..53d8f5f 100644 --- a/OTPKit/Sources/OTPKit/Core/Extensions/StringExtension.swift +++ b/OTPKit/Sources/OTPKit/Core/Extensions/StringExtension.swift @@ -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. diff --git a/OTPKit/Sources/OTPKit/Core/Map/MapCoordinator.swift b/OTPKit/Sources/OTPKit/Core/Map/MapCoordinator.swift index 95bf50c..c1b29cc 100644 --- a/OTPKit/Sources/OTPKit/Core/Map/MapCoordinator.swift +++ b/OTPKit/Sources/OTPKit/Core/Map/MapCoordinator.swift @@ -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 @@ -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 @@ -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" @@ -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) @@ -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) } @@ -377,3 +407,5 @@ public class MapCoordinator: ObservableObject { // swiftlint:disable:this type_b Logger.main.info("Annotation selected: \(identifier)") } } + +// swiftlint:enable file_length diff --git a/OTPKit/Sources/OTPKit/Core/Map/OTPMapProvider.swift b/OTPKit/Sources/OTPKit/Core/Map/OTPMapProvider.swift index 1eacc01..f1269e1 100644 --- a/OTPKit/Sources/OTPKit/Core/Map/OTPMapProvider.swift +++ b/OTPKit/Sources/OTPKit/Core/Map/OTPMapProvider.swift @@ -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 { @@ -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 } @@ -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 } diff --git a/OTPKit/Sources/OTPKit/Core/Models/OTP/Leg.swift b/OTPKit/Sources/OTPKit/Core/Models/OTP/Leg.swift index ad9f57a..7fd7230 100644 --- a/OTPKit/Sources/OTPKit/Core/Models/OTP/Leg.swift +++ b/OTPKit/Sources/OTPKit/Core/Models/OTP/Leg.swift @@ -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? @@ -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 && @@ -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`. diff --git a/OTPKit/Sources/OTPKit/Core/Models/OTP/TransportMode.swift b/OTPKit/Sources/OTPKit/Core/Models/OTP/TransportMode.swift index 014830c..56c1a57 100644 --- a/OTPKit/Sources/OTPKit/Core/Models/OTP/TransportMode.swift +++ b/OTPKit/Sources/OTPKit/Core/Models/OTP/TransportMode.swift @@ -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 { @@ -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") } } @@ -50,6 +57,8 @@ public enum TransportMode: String, CaseIterable, Codable { return "car" case .bikeRental: return "bicycle.circle" + case .transitBikeRental: + return "bicycle.circle.fill" } } @@ -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] + } } diff --git a/OTPKit/Sources/OTPKit/Core/Models/OTP/TripPlanRequest.swift b/OTPKit/Sources/OTPKit/Core/Models/OTP/TripPlanRequest.swift index 124b701..984e6a6 100644 --- a/OTPKit/Sources/OTPKit/Core/Models/OTP/TripPlanRequest.swift +++ b/OTPKit/Sources/OTPKit/Core/Models/OTP/TripPlanRequest.swift @@ -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: @@ -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, @@ -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 @@ -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() + return transportModes.flatMap(\.wireModes).filter { seen.insert($0).inserted } } /// Validates the request parameters @@ -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 { @@ -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 } } diff --git a/OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleRental.swift b/OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleRental.swift index f9c06b5..544e8cd 100644 --- a/OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleRental.swift +++ b/OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleRental.swift @@ -105,7 +105,7 @@ 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 } @@ -113,10 +113,6 @@ public enum VehicleRental: Identifiable, Hashable, Sendable { } } - 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") diff --git a/OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleRentalSnapshot.swift b/OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleRentalSnapshot.swift new file mode 100644 index 0000000..13df5d2 --- /dev/null +++ b/OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleRentalSnapshot.swift @@ -0,0 +1,58 @@ +/* + * 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 + +/// A diffed set of rental entities delivered by `VehicleRentalSource` as the +/// viewport changes. Consumers apply the diff directly — no reconciliation +/// against previously delivered state is needed. +public struct VehicleRentalSnapshot: Sendable { + /// Entities that were not present in the previous snapshot. + public let added: [VehicleRental] + + /// IDs of entities present in the previous snapshot but gone now. + public let removed: [VehicleRental.ID] + + /// Entities whose data changed since the previous snapshot (position, + /// availability, fuel, operative state). Updating these in place — rather + /// than remove/re-add — preserves a selected callout across refreshes. + public let updated: [VehicleRental] + + public let fetchedAt: Date + + /// Non-fatal GraphQL error messages that accompanied partial data. + /// Empty on full success. + public let partialErrors: [String] + + public init( + added: [VehicleRental], + removed: [VehicleRental.ID], + updated: [VehicleRental], + fetchedAt: Date, + partialErrors: [String] = [] + ) { + self.added = added + self.removed = removed + self.updated = updated + self.fetchedAt = fetchedAt + self.partialErrors = partialErrors + } + + /// True when the snapshot changes nothing (no adds, removals, or updates). + public var isEmpty: Bool { + added.isEmpty && removed.isEmpty && updated.isEmpty + } +} diff --git a/OTPKit/Sources/OTPKit/Core/TripProgress/TripProgress.swift b/OTPKit/Sources/OTPKit/Core/TripProgress/TripProgress.swift index 0730a09..7fcb190 100644 --- a/OTPKit/Sources/OTPKit/Core/TripProgress/TripProgress.swift +++ b/OTPKit/Sources/OTPKit/Core/TripProgress/TripProgress.swift @@ -16,6 +16,11 @@ import Foundation +// swiftlint:disable file_length +// The pure in-trip state machine: phases, rows, and the rider questions they +// answer all live together deliberately — splitting them would scatter one +// state machine across files. + /// Where the rider is in an itinerary right now. /// /// This is the `currentLeg` cursor of the in-trip panel's two-cursor model: it @@ -30,7 +35,7 @@ public enum TripPhase: Equatable { /// The rider has finished the previous leg and is waiting to board transit. case waiting(boardingLegIndex: Int) - /// The rider is aboard a transit leg. + /// The rider is aboard a transit leg or riding a rental vehicle. case riding(legIndex: Int) /// The itinerary's end time has passed. @@ -64,6 +69,12 @@ public struct RailRow: Identifiable, Equatable { case ride(legIndex: Int) /// Alight from this leg. case getOff(legIndex: Int) + /// Pick up the rental vehicle that starts this rental ride leg. + case pickUpVehicle(legIndex: Int) + /// Currently riding this rental leg. Present only while `TripPhase.riding` it. + case rideRental(legIndex: Int) + /// Drop off the rental vehicle at the end of this rental ride leg. + case dropOffVehicle(legIndex: Int) /// The final destination. case arrive } @@ -91,7 +102,8 @@ public struct RailRow: Identifiable, Equatable { /// The index of the leg this row belongs to, if any. public var legIndex: Int? { switch kind { - case .walk(let index), .board(let index), .ride(let index), .getOff(let index): + case .walk(let index), .board(let index), .ride(let index), .getOff(let index), + .pickUpVehicle(let index), .rideRental(let index), .dropOffVehicle(let index): return index case .arrive: return nil @@ -127,16 +139,20 @@ public struct TripProgress { } for (index, leg) in legs.enumerated() { - // Inside the leg's own time window. + // Inside the leg's own time window. Rental rides count as riding even + // though they are not transit legs. if now >= leg.startTime && now < leg.endTime { - return leg.transitLeg == true ? .riding(legIndex: index) : .walking(legIndex: index) + let isAboard = leg.transitLeg == true || leg.isRentalRide + return isAboard ? .riding(legIndex: index) : .walking(legIndex: index) } - // In the gap between this leg and the next: waiting if the next leg - // is transit (the rider is at the stop), otherwise treat the gap as - // part of the upcoming walk. + // In the gap between this leg and the next: waiting if the next leg is + // transit (the rider is at the stop) or a rental pickup (the rider is + // walking up to the vehicle — sub-minute walks get merged away, leaving + // a real gap). Otherwise the gap is part of the upcoming walk. if now < leg.startTime { - return leg.transitLeg == true ? .waiting(boardingLegIndex: index) : .walking(legIndex: index) + let isBoardable = leg.transitLeg == true || leg.isRentalRide + return isBoardable ? .waiting(boardingLegIndex: index) : .walking(legIndex: index) } } @@ -153,20 +169,18 @@ public struct TripProgress { for (index, leg) in legs.enumerated() { if leg.transitLeg == true { rows.append(boardRow(for: leg, at: index, phase: phase)) - - if case .riding(let ridingIndex) = phase, ridingIndex == index { - rows.append( - RailRow( - id: "ride-\(index)", - kind: .ride(legIndex: index), - state: .current, - time: now, - status: nil - ) - ) + if isRiding(index, phase: phase) { + rows.append(currentRideRow(id: "ride-\(index)", kind: .ride(legIndex: index))) } - - rows.append(getOffRow(for: leg, at: index)) + rows.append(legEndRow(id: "getoff-\(index)", kind: .getOff(legIndex: index), for: leg)) + } else if leg.isRentalRide { + // Rental legs get pickup → ride → dropoff semantics, mirroring the + // transit board → ride → get off shape so the rail reads uniformly. + rows.append(pickUpVehicleRow(for: leg, at: index, phase: phase)) + if isRiding(index, phase: phase) { + rows.append(currentRideRow(id: "riderental-\(index)", kind: .rideRental(legIndex: index))) + } + rows.append(legEndRow(id: "dropoff-\(index)", kind: .dropOffVehicle(legIndex: index), for: leg)) } else { rows.append(walkRow(for: leg, at: index, phase: phase)) } @@ -214,16 +228,41 @@ public struct TripProgress { ) } - private func getOffRow(for leg: Leg, at index: Int) -> RailRow { - RailRow( - id: "getoff-\(index)", - kind: .getOff(legIndex: index), - state: now >= leg.endTime ? .done : .upcoming, - time: leg.endTime, + /// The pickup row goes current during a `.waiting` gap before the ride — the + /// stretch where the rider is walking up to the parked vehicle. During the ride + /// itself the current row is the synthetic `rideRental` row. + private func pickUpVehicleRow(for leg: Leg, at index: Int, phase: TripPhase) -> RailRow { + let state: RailRow.State + if case .waiting(let boardingIndex) = phase, boardingIndex == index { + state = .current + } else { + state = now >= leg.startTime ? .done : .upcoming + } + + return RailRow( + id: "pickup-\(index)", + kind: .pickUpVehicle(legIndex: index), + state: state, + time: leg.startTime, status: nil ) } + /// The synthetic row present only while the rider is aboard this leg. + private func currentRideRow(id: String, kind: RailRow.Kind) -> RailRow { + RailRow(id: id, kind: kind, state: .current, time: now, status: nil) + } + + /// A leg's final moment: get off transit, or drop off the rental vehicle. + private func legEndRow(id: String, kind: RailRow.Kind, for leg: Leg) -> RailRow { + RailRow(id: id, kind: kind, state: now >= leg.endTime ? .done : .upcoming, time: leg.endTime, status: nil) + } + + private func isRiding(_ index: Int, phase: TripPhase) -> Bool { + if case .riding(let ridingIndex) = phase { return ridingIndex == index } + return false + } + /// The rider's current activity, localized ("Walking", "Waiting for the C Line", /// "Riding C Line"). Nil before the trip starts and after it ends — the one /// vocabulary shared by the Back-to-now pill, the tip footer, and the @@ -233,10 +272,16 @@ public struct TripProgress { case .walking: return OTPLoc("rail.now_walking", comment: "The rider is currently walking") case .waiting(let index): + if legs[index].isRentalRide { + return OTPLoc("rail.pick_up_bike", comment: "Instruction to pick up the rental bike") + } return OTPLoc("rail.now_waiting_fmt", comment: "The rider is waiting for this route", legs[index].riderFacingRouteName) case .riding(let index): + if legs[index].isRentalRide { + return OTPLoc("rail.now_riding_rental", comment: "The rider is riding a rental bike") + } return OTPLoc("rail.now_riding_fmt", comment: "The rider is aboard this route", legs[index].riderFacingRouteName) @@ -366,6 +411,8 @@ public struct TripProgress { public let fillFraction: Double /// True when this leg is a transit leg (drawn in the route color). public let isTransit: Bool + /// True when this leg is a rental ride (drawn in rental purple). + public let isRental: Bool } /// Proportional segments for the tip-detent progress bar. @@ -389,8 +436,11 @@ public struct TripProgress { legIndex: index, widthFraction: duration / totalDuration, fillFraction: fill, - isTransit: leg.transitLeg == true + isTransit: leg.transitLeg == true, + isRental: leg.isRentalRide ) } } } + +// swiftlint:enable file_length diff --git a/OTPKit/Sources/OTPKit/Network/GraphQLAPIService.swift b/OTPKit/Sources/OTPKit/Network/GraphQLAPIService.swift index 9b1b761..0dc2c0c 100644 --- a/OTPKit/Sources/OTPKit/Network/GraphQLAPIService.swift +++ b/OTPKit/Sources/OTPKit/Network/GraphQLAPIService.swift @@ -19,7 +19,10 @@ import OSLog /// Actor-based GraphQL API client for OTP 2.x trip planning and vehicle rentals /// via the GTFS GraphQL API. -public actor GraphQLAPIService: APIService, VehicleRentalService { +/// +/// Most of the type body is the two static GraphQL documents; the lint pragmas +/// below account for them, not for logic. +public actor GraphQLAPIService: APIService, VehicleRentalService { // swiftlint:disable:this type_body_length public nonisolated let baseURL: URL public nonisolated let dataLoader: URLDataLoader @@ -42,7 +45,7 @@ public actor GraphQLAPIService: APIService, VehicleRentalService { /// Fetches a trip plan using a `TripPlanRequest` public func fetchPlan(_ request: TripPlanRequest) async throws -> OTPResponse { let urlRequest = try makeGraphQLRequest( - query: Self.planQuery, + query: Self.planQuery(includingVia: request.viaPoint != nil), variables: Self.planVariables(for: request) ) @@ -140,16 +143,26 @@ public actor GraphQLAPIService: APIService, VehicleRentalService { /// Builds the GraphQL `variables` payload for a trip plan request. private static func planVariables(for request: TripPlanRequest) -> [String: Any] { - [ + var variables: [String: Any] = [ "from": ["lat": request.origin.latitude, "lon": request.origin.longitude], "to": ["lat": request.destination.latitude, "lon": request.destination.longitude], "date": request.date.formattedTripDate, "time": request.time.formattedTripTime, - "transportModes": request.transportModes.map { graphQLTransportMode(for: $0) }, + "transportModes": request.wireTransportModes.map { graphQLTransportMode(for: $0) }, "arriveBy": request.arriveBy, "wheelchair": request.wheelchairAccessible, "maxWalkDistance": Double(request.maxWalkDistance) ] + + // Omitted entirely when absent: a null `via` and a missing `via` are not + // guaranteed to be treated identically by every OTP build. + if let viaPoint = request.viaPoint { + variables["via"] = [ + ["visit": ["coordinate": ["latitude": viaPoint.latitude, "longitude": viaPoint.longitude]]] + ] + } + + return variables } /// The GraphQL `TransportMode` input value for a transport mode. `TransportMode.rawValue` @@ -161,6 +174,10 @@ public actor GraphQLAPIService: APIService, VehicleRentalService { return ["mode": "BICYCLE"] case .bikeRental: return ["mode": "BICYCLE", "qualifier": "RENT"] + case .transitBikeRental: + // Unreachable: `wireTransportModes` expands composites before this + // mapping ever runs; the arm exists only for switch exhaustiveness. + return ["mode": "TRANSIT"] case .transit, .walk, .car: return ["mode": mode.rawValue] } @@ -203,7 +220,16 @@ public actor GraphQLAPIService: APIService, VehicleRentalService { /// The GTFS GraphQL API `plan` query. Requests only the fields OTPKit's models consume, /// mirroring what the OTP 1.x REST API returns. - static let planQuery = """ + /// + /// The `via` argument only exists in the query document when the request actually + /// carries a via point: GraphQL validates documents statically, and `plan`'s `via` + /// argument (with its `PlanViaLocationInput` type) only exists on OTP 2.7+ — a + /// document that always declared it would break every plan request, transit + /// included, against older 2.x servers. + static func planQuery(includingVia: Bool) -> String { // swiftlint:disable:this function_body_length + let viaDeclaration = includingVia ? "\n $via: [PlanViaLocationInput!]" : "" + let viaArgument = includingVia ? "\n via: $via" : "" + return """ query TripPlan( $from: InputCoordinates! $to: InputCoordinates! @@ -212,7 +238,7 @@ public actor GraphQLAPIService: APIService, VehicleRentalService { $transportModes: [TransportMode!] $arriveBy: Boolean $wheelchair: Boolean - $maxWalkDistance: Float + $maxWalkDistance: Float\(viaDeclaration) ) { plan( from: $from @@ -222,7 +248,7 @@ public actor GraphQLAPIService: APIService, VehicleRentalService { transportModes: $transportModes arriveBy: $arriveBy wheelchair: $wheelchair - maxWalkDistance: $maxWalkDistance + maxWalkDistance: $maxWalkDistance\(viaArgument) ) { date from { name lon lat vertexType } @@ -275,6 +301,7 @@ public actor GraphQLAPIService: APIService, VehicleRentalService { } } """ + } /// The GTFS GraphQL API `vehicleRentalsByBbox` query. Returns the `RentalPlace` /// union; `__typename` discriminates stations from free-floating vehicles. diff --git a/OTPKit/Sources/OTPKit/Network/RestAPIService.swift b/OTPKit/Sources/OTPKit/Network/RestAPIService.swift index 5b9af41..43472f2 100644 --- a/OTPKit/Sources/OTPKit/Network/RestAPIService.swift +++ b/OTPKit/Sources/OTPKit/Network/RestAPIService.swift @@ -46,7 +46,8 @@ public actor RestAPIService: APIService { mode: request.transportModesString, arriveBy: request.arriveBy, maxWalkDistance: request.maxWalkDistance, - wheelchair: request.wheelchairAccessible + wheelchair: request.wheelchairAccessible, + intermediatePlaces: request.viaPoint.map { [$0.formattedForAPI] } ?? [] ) } @@ -59,7 +60,8 @@ public actor RestAPIService: APIService { mode: String, arriveBy: Bool, maxWalkDistance: Int, - wheelchair: Bool + wheelchair: Bool, + intermediatePlaces: [String] = [] ) async throws -> OTPResponse { var components = URLComponents( url: buildURL(endpoint: "plan"), @@ -78,6 +80,11 @@ public actor RestAPIService: APIService { .init(name: "showIntermediateStops", value: "true") ] + // OTP 1.x routes through each intermediatePlaces value ("lat,lon") in order. + components.queryItems?.append(contentsOf: intermediatePlaces.map { + .init(name: "intermediatePlaces", value: $0) + }) + let request = URLRequest(url: components.url!) Logger.main.info("Fetching trip plan: \(request.url!.absoluteString)") diff --git a/OTPKit/Sources/OTPKit/Network/VehicleRentalService.swift b/OTPKit/Sources/OTPKit/Network/VehicleRentalService.swift index 755766c..7473512 100644 --- a/OTPKit/Sources/OTPKit/Network/VehicleRentalService.swift +++ b/OTPKit/Sources/OTPKit/Network/VehicleRentalService.swift @@ -22,7 +22,11 @@ import Foundation /// `RestAPIService` (OTP 1.x) does not. Conformance doubles as the capability /// flag: hosts check `apiService is VehicleRentalService` to decide whether /// rental features can work at all. -public protocol VehicleRentalService { +/// +/// `Sendable` because services are shared across isolation domains by design: +/// `VehicleRentalSource` (an actor) fetches through one while hosts hold it on +/// the main actor. Conformers are typically actors already. +public protocol VehicleRentalService: Sendable { /// Fetches rental stations and free-floating vehicles in a bounding box. /// /// - Parameters: diff --git a/OTPKit/Sources/OTPKit/Network/VehicleRentalSource.swift b/OTPKit/Sources/OTPKit/Network/VehicleRentalSource.swift new file mode 100644 index 0000000..c830946 --- /dev/null +++ b/OTPKit/Sources/OTPKit/Network/VehicleRentalSource.swift @@ -0,0 +1,241 @@ +/* + * 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 + +/// A stateful, cancellable pipeline from viewport updates to diffed rental snapshots. +/// +/// The work between "user pans the map" and "annotations are correct" is the same for +/// every host and involves no MapKit: coalescing viewport changes, cancelling superseded +/// fetches, filtering by form factor, and reconciling against what was previously +/// delivered. This actor owns all of it; hosts apply the emitted diffs directly. +/// +/// `snapshots` and `fetchFailures` are single-consumer streams: attach exactly one +/// iterator to each for the lifetime of the source. +public actor VehicleRentalSource { + + /// A failed fetch. Emitted on `fetchFailures` instead of the snapshot stream so a + /// transient error never disturbs already-delivered map state. Hosts use the *first* + /// failure to dim a layer row ("Not available right now") and any later success to + /// un-dim it. + public struct FetchFailure: Sendable { + public let underlyingError: any Error + public let occurredAt: Date + + /// Convenience for logging and rider-facing reasons. + public var message: String { underlyingError.localizedDescription } + } + + /// Snapshots delivered as the viewport or form-factor selection changes. + public nonisolated let snapshots: AsyncStream + + /// Fetch failures (transport, HTTP, decode). Superseded and cancelled fetches + /// are never reported. + public nonisolated let fetchFailures: AsyncStream + + private let snapshotContinuation: AsyncStream.Continuation + private let failureContinuation: AsyncStream.Continuation + + private let service: VehicleRentalService + private let coalescingInterval: Duration + private let boundingBoxPadding: Double + + private var formFactors: Set? + private var viewport: VehicleRentalBoundingBox? + private var delivered: [VehicleRental.ID: VehicleRental] = [:] + private var pendingFetch: Task? + + /// Incremented whenever pending work becomes stale (new viewport, new filter, + /// clear). A fetch only applies its result if its generation is still current — + /// this covers the window where a task has passed its cancellation checks but + /// not yet delivered. + private var generation = 0 + + /// - Parameters: + /// - service: The rental-capable backend, typically a `GraphQLAPIService`. + /// - formFactors: Initial form-factor filter; `nil` fetches everything. + /// - coalescingInterval: Trailing debounce applied to viewport/filter changes. + /// The 250 ms default matches OBA's established map debounce. + /// - boundingBoxPadding: Multiplier applied to the viewport before fetching, so + /// small pans are already covered. 1.1 mirrors OBA's region fudge factor. + public init( + service: VehicleRentalService, + formFactors: Set? = nil, + coalescingInterval: Duration = .milliseconds(250), + boundingBoxPadding: Double = 1.1 + ) { + self.service = service + self.formFactors = formFactors + self.coalescingInterval = coalescingInterval + self.boundingBoxPadding = boundingBoxPadding + + (snapshots, snapshotContinuation) = AsyncStream.makeStream(of: VehicleRentalSnapshot.self) + // Failures are advisory and hosts only need the latest; bounding the buffer + // means a host that never consumes this stream can't accumulate errors + // without bound against a persistently failing endpoint. + (fetchFailures, failureContinuation) = AsyncStream.makeStream( + of: FetchFailure.self, + bufferingPolicy: .bufferingNewest(1) + ) + } + + deinit { + pendingFetch?.cancel() + snapshotContinuation.finish() + failureContinuation.finish() + } + + // MARK: - Inputs + + /// Called on every map region change. Coalesces; cancels superseded work. + /// Passing `nil` (e.g. the zoom gate closed) immediately emits a snapshot + /// removing everything. + public func setViewport(_ boundingBox: VehicleRentalBoundingBox?) { + guard let boundingBox else { + reset() + return + } + // Map frameworks re-emit identical regions on layout passes; an unchanged + // viewport must not cancel and refetch a multi-thousand-entity payload. + guard boundingBox != viewport else { return } + viewport = boundingBox + scheduleFetch() + } + + /// Changes what is being fetched without tearing down the stream. Triggers a + /// refetch when a viewport is set. + public func setFormFactors(_ formFactors: Set?) { + guard formFactors != self.formFactors else { return } + self.formFactors = formFactors + if viewport != nil { + scheduleFetch() + } + } + + /// Clears all state (e.g. the layer was switched off, or the zoom gate + /// closed) and emits a snapshot removing everything previously delivered. + public func reset() { + pendingFetch?.cancel() + pendingFetch = nil + generation += 1 + viewport = nil + + let removed = delivered.keys.sorted() + delivered = [:] + snapshotContinuation.yield(VehicleRentalSnapshot( + added: [], + removed: removed, + updated: [], + fetchedAt: Date() + )) + } + + // MARK: - Pipeline + + private func scheduleFetch() { + pendingFetch?.cancel() + generation += 1 + let scheduledGeneration = generation + let interval = coalescingInterval + + // Weak so an abandoned source deallocates immediately instead of being + // kept alive through the debounce plus a multi-thousand-entity fetch + // whose snapshot nobody would consume. The body otherwise inherits the + // actor's isolation: the sleep and fetch suspend without blocking other + // actor work (fetchPlan on the same service is unaffected — decode + // already runs off-actor in GraphQLAPIService). + pendingFetch = Task { [weak self] in + try? await Task.sleep(for: interval) + guard !Task.isCancelled, let self else { return } + await self.performFetch(generation: scheduledGeneration) + } + } + + private func performFetch(generation scheduledGeneration: Int) async { + guard scheduledGeneration == generation, let viewport else { return } + let paddedBox = viewport.padded(by: boundingBoxPadding) + + do { + let result = try await service.fetchVehicleRentals(in: paddedBox, formFactors: formFactors) + guard scheduledGeneration == generation else { return } + apply(result) + } catch is CancellationError { + // Superseded by a newer viewport; the newer fetch reports instead. + } catch { + guard scheduledGeneration == generation else { return } + // Forget the failed viewport so the next region emission — identical or + // not — retries instead of being swallowed by the same-viewport guard. + // A stationary map must be able to heal from a transient failure. + self.viewport = nil + failureContinuation.yield(FetchFailure(underlyingError: error, occurredAt: Date())) + } + } + + /// Diffs the fetch result against what was previously delivered, keyed on + /// `VehicleRental.id`. `added`/`updated` preserve response order; `removed` is + /// sorted for determinism. + private func apply(_ result: VehicleRentalFetchResult) { + var added: [VehicleRental] = [] + var updated: [VehicleRental] = [] + var incoming: [VehicleRental.ID: VehicleRental] = [:] + incoming.reserveCapacity(result.rentals.count) + + for rental in result.rentals { + // Duplicate IDs in one payload: last write wins in state, first wins in the + // delivered arrays (guarded by the incoming-dict check). + guard incoming.updateValue(rental, forKey: rental.id) == nil else { continue } + + if let previous = delivered[rental.id] { + if previous != rental { + updated.append(rental) + } + } else { + added.append(rental) + } + } + + let removed = delivered.keys.filter { incoming[$0] == nil }.sorted() + delivered = incoming + + snapshotContinuation.yield(VehicleRentalSnapshot( + added: added, + removed: removed, + updated: updated, + fetchedAt: Date(), + partialErrors: result.partialErrors + )) + } +} + +extension VehicleRentalBoundingBox { + /// Expands the box around its center by the given factor, clamped to valid + /// coordinate ranges. Does not handle antimeridian-spanning boxes. + func padded(by factor: Double) -> VehicleRentalBoundingBox { + guard factor != 1 else { return self } + + let latitudeCenter = (minimumLatitude + maximumLatitude) / 2 + let longitudeCenter = (minimumLongitude + maximumLongitude) / 2 + let latitudeHalfSpan = (maximumLatitude - minimumLatitude) / 2 * factor + let longitudeHalfSpan = (maximumLongitude - minimumLongitude) / 2 * factor + + return VehicleRentalBoundingBox( + minimumLatitude: max(-90, latitudeCenter - latitudeHalfSpan), + maximumLatitude: min(90, latitudeCenter + latitudeHalfSpan), + minimumLongitude: max(-180, longitudeCenter - longitudeHalfSpan), + maximumLongitude: min(180, longitudeCenter + longitudeHalfSpan) + ) + } +} diff --git a/OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Components/DirectionLegs/DirectionLegBikeView.swift b/OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Components/DirectionLegs/DirectionLegBikeView.swift new file mode 100644 index 0000000..26c76b1 --- /dev/null +++ b/OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Components/DirectionLegs/DirectionLegBikeView.swift @@ -0,0 +1,48 @@ +// +// DirectionLegBikeView.swift +// OTPKit +// + +import SwiftUI + +/// A bicycle leg in the legacy directions list — personal bike or rental ride. +struct DirectionLegBikeView: View { + let leg: Leg + + var body: some View { + DirectionLegContainerView { + Image(systemName: "bicycle") + .font(.system(size: 24)) + .foregroundStyle(leg.isRentalRide ? Color.otpRentalPurple : Color.primary) + } rightContent: { + VStack(alignment: .leading, spacing: 4) { + Text(instruction) + .font(.title3) + .fontWeight(.bold) + .fixedSize(horizontal: false, vertical: true) + + Text(OTPLoc( + "leg.walk_distance_duration", + comment: "Walking distance followed by approximate duration", + Formatters.formatDistance(Int(leg.distance)), + Formatters.formatTimeDuration(leg.duration) + )) + .foregroundStyle(.gray) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private var instruction: String { + if leg.isRentalRide { + return OTPLoc("leg.ride_rental_bike_to", + comment: "Instruction to ride a rental bike to a place", + leg.riderFacingToName) + } + return OTPLoc("leg.bike_to", comment: "Instruction to bike to a place", leg.riderFacingToName) + } +} + +#Preview { + DirectionLegBikeView(leg: PreviewHelpers.buildLeg()) +} diff --git a/OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Components/DirectionLegs/DirectionLegView.swift b/OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Components/DirectionLegs/DirectionLegView.swift index af8466a..501dac9 100644 --- a/OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Components/DirectionLegs/DirectionLegView.swift +++ b/OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Components/DirectionLegs/DirectionLegView.swift @@ -17,6 +17,10 @@ struct DirectionLegView: View { DirectionLegVehicleView(leg: leg) case "WALK": DirectionLegWalkView(leg: leg) + // OTP 2.x spells bicycle legs "BICYCLE" (rental rides included); + // "BIKE" is the OTP 1.x REST spelling. + case "BICYCLE", "BIKE": + DirectionLegBikeView(leg: leg) default: DirectionLegUnknownView(leg: leg) } diff --git a/OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Components/DirectionLegs/DirectionLegWalkView.swift b/OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Components/DirectionLegs/DirectionLegWalkView.swift index 73b2226..9bcca0e 100644 --- a/OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Components/DirectionLegs/DirectionLegWalkView.swift +++ b/OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Components/DirectionLegs/DirectionLegWalkView.swift @@ -19,7 +19,7 @@ struct DirectionLegWalkView: View { } rightContent: { HStack { VStack(alignment: .leading, spacing: 4) { - Text(OTPLoc("leg.walk_to", comment: "Instruction to walk to a place", leg.to.name)) + Text(OTPLoc("leg.walk_to", comment: "Instruction to walk to a place", leg.riderFacingToName)) .font(.title3) .fontWeight(.bold) .fixedSize(horizontal: false, vertical: true) diff --git a/OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/InTripRailView.swift b/OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/InTripRailView.swift index e1cae56..6fe8d28 100644 --- a/OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/InTripRailView.swift +++ b/OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/InTripRailView.swift @@ -111,6 +111,16 @@ struct InTripRailView: View { RideRowContent(progress: progress, legIndex: index) case .getOff(let index): GetOffRowContent(progress: progress, legIndex: index, state: row.state) + case .pickUpVehicle(let index): + PickUpVehicleRowContent( + leg: progress.legs[index], + state: row.state, + isExpanded: row.state == .current || isFocused + ) + case .rideRental(let index): + RideRentalRowContent(progress: progress, legIndex: index) + case .dropOffVehicle(let index): + DropOffVehicleRowContent(progress: progress, legIndex: index, state: row.state) case .arrive: ArriveRowContent() } @@ -137,6 +147,8 @@ struct InTripRailView: View { return .destination case (.walk, _): return .ring(Color(.systemGray3)) + case (.pickUpVehicle, _), (.rideRental, _), (.dropOffVehicle, _): + return .ring(.otpRentalPurple) case (.board(let index), _), (.getOff(let index), _), (.ride(let index), _): return .ring(progress.legs[index].routeUIColor ?? Color(.systemGray2)) } @@ -153,7 +165,14 @@ struct InTripRailView: View { return .thin } return .bar(progress.legs[index].routeUIColor ?? Color(.systemGray2)) - case .walk, .getOff: + case .rideRental: + return .bar(.otpRentalPurple) + case .pickUpVehicle(let index): + if row.state == .done, !isRiding(index) { + return .thin + } + return .bar(.otpRentalPurple) + case .walk, .getOff, .dropOffVehicle: return .thin } } diff --git a/OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/RailRowContentViews.swift b/OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/RailRowContentViews.swift index 94bfa52..5d7ea5e 100644 --- a/OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/RailRowContentViews.swift +++ b/OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/RailRowContentViews.swift @@ -7,6 +7,8 @@ import SwiftUI +// swiftlint:disable file_length + // The content column of each rail row kind. The views are state-aware: done // rows collapse to one line, current boarding/riding rows expand into the // tinted NowCard, and a focused boarding row shows its full detail inside the @@ -49,6 +51,16 @@ enum RailText { Formatters.formatDateToTime(leg.startTime)) } + /// The rider-facing name of a rental place, or nil when the feed sent a known + /// placeholder that must never reach the UI. Same predicate as + /// `Leg.riderFacingName(of:)`, different policy: these rows hide the line + /// instead of substituting a generic. + static func rentalPlaceName(_ name: String) -> String? { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !trimmed.isRentalPlaceholderName else { return nil } + return trimmed + } + /// "12 stops" / "1 stop". static func stops(_ count: Int) -> String { count == 1 @@ -104,7 +116,7 @@ struct WalkRowContent: View { VStack(alignment: .leading, spacing: 2) { Text(OTPLoc("rail.walk_fmt", comment: "Walking instruction: distance, then destination", - Formatters.formatDistance(Int(leg.distance)), leg.to.name)) + Formatters.formatDistance(Int(leg.distance)), leg.riderFacingToName)) .font(.body.weight(.semibold)) Text(OTPLoc("rail.about_duration_fmt", @@ -357,6 +369,104 @@ struct GetOffRowContent: View { } } +// MARK: - Rental rows + +/// Pickup point of a rental ride: "Pick up rental bike", with the station or +/// vehicle name when the feed provides a real one. +struct PickUpVehicleRowContent: View { + let leg: Leg + let state: RailRow.State + let isExpanded: Bool + + var body: some View { + if state == .done { + Text(OTPLoc("rail.picked_up_bike", comment: "Collapsed row for a rental vehicle already picked up")) + .font(.subheadline) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 2) { + Text(OTPLoc("rail.pick_up_bike", comment: "Instruction to pick up the rental bike")) + .font(.body.weight(.semibold)) + + if let name = RailText.rentalPlaceName(leg.from.name) { + Text(name) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + if isExpanded { + Text(OTPLoc("rail.ride_distance_duration_fmt", + comment: "Distance, then approximate duration of the rental ride", + Formatters.formatDistance(Int(leg.distance)), + Formatters.formatTimeDuration(leg.duration))) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + } +} + +/// The synthetic row present only while riding a rental: destination and time +/// remaining inside the now-card. +struct RideRentalRowContent: View { + let progress: TripProgress + let legIndex: Int + + private var leg: Leg { progress.legs[legIndex] } + + var body: some View { + NowCard { + VStack(alignment: .leading, spacing: 6) { + Text(OTPLoc("rail.ride_bike_to_fmt", + comment: "Riding instruction: where the rental ride ends", leg.riderFacingToName)) + .font(.title3.weight(.semibold)) + + Text(OTPLoc("rail.ride_distance_duration_fmt", + comment: "Distance, then approximate duration of the rental ride", + Formatters.formatDistance(Int(leg.distance)), + Formatters.formatTimeDuration(leg.duration))) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } +} + +/// "Drop off bike", with the dropoff station named when the ride ends docked. +struct DropOffVehicleRowContent: View { + let progress: TripProgress + let legIndex: Int + let state: RailRow.State + + private var leg: Leg { progress.legs[legIndex] } + + var body: some View { + if state == .done { + Text(OTPLoc("rail.drop_off_bike", comment: "Instruction to drop off the rental bike")) + .font(.subheadline) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 2) { + Text(OTPLoc("rail.drop_off_bike", comment: "Instruction to drop off the rental bike")) + .font(.body.weight(.semibold)) + + if let name = RailText.rentalPlaceName(leg.to.name) { + Text(name) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + if let wait = RailText.waitDescription(progress: progress, afterLegAt: legIndex) { + Text(wait) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + } +} + // MARK: - Arrive row /// The final destination row: a solid dark pip and one word. @@ -366,3 +476,5 @@ struct ArriveRowContent: View { .font(.body.weight(.semibold)) } } + +// swiftlint:enable file_length diff --git a/OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/TipContentView.swift b/OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/TipContentView.swift index 840637c..ad87e22 100644 --- a/OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/TipContentView.swift +++ b/OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/TipContentView.swift @@ -99,7 +99,7 @@ struct TipContentView: View { walkIcon title(OTPLoc("rail.walk_fmt", comment: "Walking instruction: distance, then destination", - Formatters.formatDistance(Int(leg.distance)), leg.to.name), + Formatters.formatDistance(Int(leg.distance)), leg.riderFacingToName), subtitle: progress.currentStep(onLegAt: index)?.localizedInstruction) trailingCountdown( Formatters.formatCountdown(leg.endTime.timeIntervalSince(progress.now)), @@ -109,22 +109,43 @@ struct TipContentView: View { case .waiting(let index): let leg = progress.legs[index] - HStack(alignment: .top, spacing: 12) { - RouteBadge(leg: leg) - title(RailText.boardingCountdown(for: leg, now: progress.now), - subtitle: OTPLoc("rail.at_stop_fmt", - comment: "The stop the rider boards at", leg.from.name)) - trailingTime(leg.startTime, status: leg.departureStatus) + if leg.isRentalRide { + HStack(alignment: .top, spacing: 12) { + bikeIcon + title(OTPLoc("rail.pick_up_bike", comment: "Instruction to pick up the rental bike"), + subtitle: RailText.rentalPlaceName(leg.from.name)) + trailingTime(leg.startTime, status: nil) + } + } else { + HStack(alignment: .top, spacing: 12) { + RouteBadge(leg: leg) + title(RailText.boardingCountdown(for: leg, now: progress.now), + subtitle: OTPLoc("rail.at_stop_fmt", + comment: "The stop the rider boards at", leg.from.name)) + trailingTime(leg.startTime, status: leg.departureStatus) + } } case .riding(let index): let leg = progress.legs[index] - HStack(alignment: .top, spacing: 12) { - RouteBadge(leg: leg) - title(progress.stopsRemaining(onLegAt: index).map(RailText.stopsToGo) ?? RailText.routeName(leg), - subtitle: OTPLoc("rail.get_off_at_fmt", - comment: "The stop where the rider gets off", leg.to.name)) - trailingTime(leg.endTime, status: leg.arrivalStatus) + if leg.isRentalRide { + HStack(alignment: .top, spacing: 12) { + bikeIcon + title(OTPLoc("rail.ride_bike_to_fmt", + comment: "Riding instruction: where the rental ride ends", + leg.riderFacingToName), + subtitle: OTPLoc("rail.drop_off_bike", + comment: "Instruction to drop off the rental bike")) + trailingTime(leg.endTime, status: nil) + } + } else { + HStack(alignment: .top, spacing: 12) { + RouteBadge(leg: leg) + title(progress.stopsRemaining(onLegAt: index).map(RailText.stopsToGo) ?? RailText.routeName(leg), + subtitle: OTPLoc("rail.get_off_at_fmt", + comment: "The stop where the rider gets off", leg.to.name)) + trailingTime(leg.endTime, status: leg.arrivalStatus) + } } case .arrived: @@ -146,11 +167,19 @@ struct TipContentView: View { subtitle: leg.headsign.map { OTPLoc("rail.toward_fmt", comment: "Vehicle headsign", $0) } ?? leg.to.name) + } else if leg.isRentalRide { + bikeIcon + title(OTPLoc("rail.ride_bike_to_fmt", + comment: "Riding instruction: where the rental ride ends", + leg.riderFacingToName), + subtitle: OTPLoc("rail.about_duration_fmt", + comment: "Approximate duration of a walking leg", + Formatters.formatTimeDuration(leg.duration))) } else { walkIcon title(OTPLoc("rail.walk_fmt", comment: "Walking instruction: distance, then destination", - Formatters.formatDistance(Int(leg.distance)), leg.to.name), + Formatters.formatDistance(Int(leg.distance)), leg.riderFacingToName), subtitle: OTPLoc("rail.about_duration_fmt", comment: "Approximate duration of a walking leg", Formatters.formatTimeDuration(leg.duration))) @@ -162,11 +191,19 @@ struct TipContentView: View { // MARK: - Pieces private var walkIcon: some View { - Image(systemName: "figure.walk") + modeIcon("figure.walk", background: Color(.label)) + } + + private var bikeIcon: some View { + modeIcon("bicycle", background: .otpRentalPurple) + } + + private func modeIcon(_ systemName: String, background: Color) -> some View { + Image(systemName: systemName) .font(.subheadline.weight(.semibold)) .foregroundStyle(.white) .frame(width: 32, height: 32) - .background(Color(.label), in: RoundedRectangle(cornerRadius: 9)) + .background(background, in: RoundedRectangle(cornerRadius: 9)) .accessibilityHidden(true) } diff --git a/OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/TripProgressBarView.swift b/OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/TripProgressBarView.swift index 9a531b8..97b7ca0 100644 --- a/OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/TripProgressBarView.swift +++ b/OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/TripProgressBarView.swift @@ -37,6 +37,9 @@ struct TripProgressBarView: View { private func segmentView(_ segment: TripProgress.Segment) -> some View { let fillColor: Color = { + if segment.isRental { + return .otpRentalPurple + } if segment.isTransit { return progress.legs[segment.legIndex].routeUIColor ?? theme.primaryColor } diff --git a/OTPKit/Sources/OTPKit/Presentation/Sheets/TripPlanner/Components/ItineraryLegs/ItineraryLegBikeView.swift b/OTPKit/Sources/OTPKit/Presentation/Sheets/TripPlanner/Components/ItineraryLegs/ItineraryLegBikeView.swift new file mode 100644 index 0000000..957f01a --- /dev/null +++ b/OTPKit/Sources/OTPKit/Presentation/Sheets/TripPlanner/Components/ItineraryLegs/ItineraryLegBikeView.swift @@ -0,0 +1,31 @@ +// +// ItineraryLegBikeView.swift +// OTPKit +// + +import SwiftUI + +/// Represents an itinerary leg ridden on a bicycle — personal or rental. +/// Rental rides tint in rental purple so they read as part of the rental system. +struct ItineraryLegBikeView: View { + let leg: Leg + + var body: some View { + HStack(spacing: 4) { + Image(systemName: "bicycle") + .font(.caption) + Text(Formatters.formatTimeDuration(leg.duration)) + .font(.caption) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(leg.isRentalRide ? Color.otpRentalPurple.opacity(0.15) : Color.gray.opacity(0.2)) + .foregroundStyle(leg.isRentalRide ? Color.otpRentalPurple : Color.primary) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .frame(height: 40) + } +} + +#Preview { + ItineraryLegBikeView(leg: PreviewHelpers.buildLeg()) +} diff --git a/OTPKit/Sources/OTPKit/Presentation/Sheets/TripPlanner/Components/ItineraryPreviewView.swift b/OTPKit/Sources/OTPKit/Presentation/Sheets/TripPlanner/Components/ItineraryPreviewView.swift index ad0827d..d6d0d72 100644 --- a/OTPKit/Sources/OTPKit/Presentation/Sheets/TripPlanner/Components/ItineraryPreviewView.swift +++ b/OTPKit/Sources/OTPKit/Presentation/Sheets/TripPlanner/Components/ItineraryPreviewView.swift @@ -71,6 +71,8 @@ struct ItineraryPreviewView: View { private func legView(for leg: Leg) -> some View { if leg.walkMode { ItineraryLegWalkView(leg: leg) + } else if leg.isRentalRide || LegMode(otpMode: leg.mode) == .bicycle { + ItineraryLegBikeView(leg: leg) } else if let routeType = leg.routeType, routeType != .nonTransit { ItineraryLegVehicleView(leg: leg) } else { diff --git a/OTPKit/Sources/OTPKit/Presentation/TripPlanner.swift b/OTPKit/Sources/OTPKit/Presentation/TripPlanner.swift index 6570497..5ebf4b9 100644 --- a/OTPKit/Sources/OTPKit/Presentation/TripPlanner.swift +++ b/OTPKit/Sources/OTPKit/Presentation/TripPlanner.swift @@ -5,6 +5,7 @@ // Created by Manu on 2025-09-18. // +import CoreLocation import UIKit import SwiftUI @@ -63,7 +64,40 @@ public class TripPlanner { // MARK: - Presentation & Dismissal - public func createTripPlannerView(origin: Location? = nil, destination: Location? = nil, onClose: @escaping VoidBlock) -> some View { + /// Creates the trip planner UI, optionally prefilled. + /// + /// - Parameters: + /// - origin: Prefilled origin location. + /// - destination: Prefilled destination location. + /// - viaPoint: An intermediate coordinate every planned trip must pass through — + /// the "plan a trip using this bike" entry point passes the vehicle's location. + /// Note: OTP servers may require a transit mode in the request to route through + /// a via point, so pair this with `.transitBikeRental` rather than `.bikeRental`. + /// - transportMode: Preselected transport mode. Ignored when the injected API + /// service cannot support it (e.g. rental modes on an OTP 1.x REST backend). + /// - onClose: Called when the rider dismisses the planner. + public func createTripPlannerView( + origin: Location? = nil, + destination: Location? = nil, + viaPoint: CLLocationCoordinate2D? = nil, + transportMode: TransportMode? = nil, + onClose: @escaping VoidBlock + ) -> some View { + // The prefill is all-or-nothing: a via point paired with an unsupported mode + // must not be applied alone, or the planner would route the rider through a + // rental vehicle's location in a mode that can't use it. Nil parameters + // leave existing state untouched, so re-invoking the factory is harmless. + let modeIsAvailable = transportMode.map { viewModel.availableTransportModes.contains($0) } ?? true + if modeIsAvailable { + if let transportMode { + viewModel.selectTransportMode(transportMode) + } + if let viaPoint { + // After the mode change: selecting a mode clears any stale via point. + viewModel.viaPoint = viaPoint + } + } + let view = TripPlannerView( viewModel: viewModel, mapCoordinator: mapCoordinator, diff --git a/OTPKit/Sources/OTPKit/Presentation/ViewModel/TripPlannerViewModel.swift b/OTPKit/Sources/OTPKit/Presentation/ViewModel/TripPlannerViewModel.swift index cedf53f..164f9c4 100644 --- a/OTPKit/Sources/OTPKit/Presentation/ViewModel/TripPlannerViewModel.swift +++ b/OTPKit/Sources/OTPKit/Presentation/ViewModel/TripPlannerViewModel.swift @@ -30,6 +30,9 @@ public class TripPlannerViewModel: ObservableObject { @Published var departureTime: Date? /// User-selected departure date @Published var departureDate: Date? + /// An intermediate coordinate every planned trip must pass through — e.g. a rental + /// vehicle's location for "plan a trip using this bike". Cleared on reset. + @Published var viaPoint: CLLocationCoordinate2D? // MARK: - Advanced Options @@ -164,7 +167,7 @@ public class TripPlannerViewModel: ObservableObject { /// Whether the API service can actually plan trips for the given mode. private static func isModeAvailable(_ mode: TransportMode, apiService: APIService) -> Bool { - mode != .bikeRental || apiService is VehicleRentalService + !mode.requiresVehicleRentalSupport || apiService is VehicleRentalService } /// The default selected mode: the first capability-available configured mode. @@ -183,6 +186,12 @@ public class TripPlannerViewModel: ObservableObject { /// Update the selected transport mode /// - Parameter mode: The transport mode to select func selectTransportMode(_ mode: TransportMode) { + // A via point exists to route through a specific rental vehicle. A rider who + // switches modes has abandoned that plan — keeping the via would silently + // force every later trip through a detour with no visible cause. + if mode != selectedTransportMode { + viaPoint = nil + } selectedTransportMode = mode } @@ -216,7 +225,8 @@ public class TripPlannerViewModel: ObservableObject { transportModes: selectedTransportMode.apiModes, maxWalkDistance: maxWalkingDistance.meters, wheelchairAccessible: isWheelchairAccessible, - arriveBy: timePreference == .arriveBy + arriveBy: timePreference == .arriveBy, + viaPoint: viaPoint ) // Start loading state @@ -387,6 +397,7 @@ public class TripPlannerViewModel: ObservableObject { // Clear location selections selectedOrigin = nil selectedDestination = nil + viaPoint = nil // Clear trip planning results tripPlanResponse = nil diff --git a/OTPKit/Sources/OTPKit/Resources/ar.lproj/Localizable.strings b/OTPKit/Sources/OTPKit/Resources/ar.lproj/Localizable.strings index 580d765..989db7b 100644 --- a/OTPKit/Sources/OTPKit/Resources/ar.lproj/Localizable.strings +++ b/OTPKit/Sources/OTPKit/Resources/ar.lproj/Localizable.strings @@ -216,7 +216,8 @@ "transport_mode.walk" = "المشي"; "transport_mode.bike" = "الدراجة"; "transport_mode.car" = "السيارة"; -"transport_mode.bike_rental" = "دراجات مشتركة"; +"transport_mode.bike_rental" = "دراجات مشتركة فقط"; +"transport_mode.transit_bike_rental" = "المواصلات + دراجات مشتركة"; "rental.vehicle_type.bike" = "دراجة"; "rental.vehicle_type.ebike" = "دراجة كهربائية"; @@ -312,3 +313,14 @@ "rail.last_step" = "الخطوة الأخيرة"; "rail.arrive_time_fmt" = "الوصول %@"; "rail.now" = "الآن"; + +/* Vehicle rental legs */ +"leg.bike_to" = "اذهب بالدراجة إلى %@"; +"leg.ride_rental_bike_to" = "اركب الدراجة المستأجرة إلى %@"; +"place.rental_bike" = "دراجة مستأجرة"; +"rail.now_riding_rental" = "تركب دراجة مستأجرة"; +"rail.pick_up_bike" = "استلم الدراجة المستأجرة"; +"rail.picked_up_bike" = "تم استلام الدراجة المستأجرة"; +"rail.ride_bike_to_fmt" = "اركب إلى %@"; +"rail.drop_off_bike" = "أعد الدراجة"; +"rail.ride_distance_duration_fmt" = "%1$@، حوالي %2$@"; diff --git a/OTPKit/Sources/OTPKit/Resources/en.lproj/Localizable.strings b/OTPKit/Sources/OTPKit/Resources/en.lproj/Localizable.strings index 30e8934..6701fb9 100644 --- a/OTPKit/Sources/OTPKit/Resources/en.lproj/Localizable.strings +++ b/OTPKit/Sources/OTPKit/Resources/en.lproj/Localizable.strings @@ -217,7 +217,8 @@ "transport_mode.walk" = "Walk"; "transport_mode.bike" = "Bike"; "transport_mode.car" = "Car"; -"transport_mode.bike_rental" = "Bike Rental"; +"transport_mode.bike_rental" = "Bikeshare Only"; +"transport_mode.transit_bike_rental" = "Transit + Bikeshare"; "rental.vehicle_type.bike" = "bike"; "rental.vehicle_type.ebike" = "e-bike"; @@ -313,3 +314,14 @@ "rail.last_step" = "Last step"; "rail.arrive_time_fmt" = "Arrive %@"; "rail.now" = "now"; + +/* Vehicle rental legs */ +"leg.bike_to" = "Bike to %@"; +"leg.ride_rental_bike_to" = "Ride rental bike to %@"; +"place.rental_bike" = "rental bike"; +"rail.now_riding_rental" = "Riding a rental bike"; +"rail.pick_up_bike" = "Pick up rental bike"; +"rail.picked_up_bike" = "Picked up rental bike"; +"rail.ride_bike_to_fmt" = "Ride to %@"; +"rail.drop_off_bike" = "Drop off bike"; +"rail.ride_distance_duration_fmt" = "%1$@, about %2$@"; diff --git a/OTPKit/Sources/OTPKit/Resources/es.lproj/Localizable.strings b/OTPKit/Sources/OTPKit/Resources/es.lproj/Localizable.strings index e3363e0..c9f623b 100644 --- a/OTPKit/Sources/OTPKit/Resources/es.lproj/Localizable.strings +++ b/OTPKit/Sources/OTPKit/Resources/es.lproj/Localizable.strings @@ -216,7 +216,8 @@ "transport_mode.walk" = "A pie"; "transport_mode.bike" = "Bicicleta"; "transport_mode.car" = "Coche"; -"transport_mode.bike_rental" = "Bicicleta compartida"; +"transport_mode.bike_rental" = "Solo bicicleta compartida"; +"transport_mode.transit_bike_rental" = "Transporte público + bicicleta compartida"; "rental.vehicle_type.bike" = "bici"; "rental.vehicle_type.ebike" = "bici eléctrica"; @@ -312,3 +313,14 @@ "rail.last_step" = "Último paso"; "rail.arrive_time_fmt" = "Llegada %@"; "rail.now" = "ahora"; + +/* Vehicle rental legs */ +"leg.bike_to" = "Ve en bici a %@"; +"leg.ride_rental_bike_to" = "Monta la bici compartida hasta %@"; +"place.rental_bike" = "bici compartida"; +"rail.now_riding_rental" = "Montando una bici compartida"; +"rail.pick_up_bike" = "Recoge la bici compartida"; +"rail.picked_up_bike" = "Bici compartida recogida"; +"rail.ride_bike_to_fmt" = "Monta hasta %@"; +"rail.drop_off_bike" = "Deja la bici"; +"rail.ride_distance_duration_fmt" = "%1$@, unos %2$@"; diff --git a/OTPKit/Sources/OTPKit/Resources/fil.lproj/Localizable.strings b/OTPKit/Sources/OTPKit/Resources/fil.lproj/Localizable.strings index e53e84a..f26adb9 100644 --- a/OTPKit/Sources/OTPKit/Resources/fil.lproj/Localizable.strings +++ b/OTPKit/Sources/OTPKit/Resources/fil.lproj/Localizable.strings @@ -216,7 +216,8 @@ "transport_mode.walk" = "Lakad"; "transport_mode.bike" = "Bisikleta"; "transport_mode.car" = "Kotse"; -"transport_mode.bike_rental" = "Rentahan ng Bisikleta"; +"transport_mode.bike_rental" = "Rentahan ng Bisikleta Lamang"; +"transport_mode.transit_bike_rental" = "Transit + Rentahan ng Bisikleta"; "rental.vehicle_type.bike" = "bisikleta"; "rental.vehicle_type.ebike" = "e-bike"; @@ -312,3 +313,14 @@ "rail.last_step" = "Huling hakbang"; "rail.arrive_time_fmt" = "Dating %@"; "rail.now" = "ngayon"; + +/* Vehicle rental legs */ +"leg.bike_to" = "Magbisikleta papuntang %@"; +"leg.ride_rental_bike_to" = "Sakyan ang rentahang bisikleta papuntang %@"; +"place.rental_bike" = "rentahang bisikleta"; +"rail.now_riding_rental" = "Nakasakay sa rentahang bisikleta"; +"rail.pick_up_bike" = "Kunin ang rentahang bisikleta"; +"rail.picked_up_bike" = "Nakuha na ang rentahang bisikleta"; +"rail.ride_bike_to_fmt" = "Sumakay papuntang %@"; +"rail.drop_off_bike" = "Ibalik ang bisikleta"; +"rail.ride_distance_duration_fmt" = "%1$@, mga %2$@"; diff --git a/OTPKit/Sources/OTPKit/Resources/fr.lproj/Localizable.strings b/OTPKit/Sources/OTPKit/Resources/fr.lproj/Localizable.strings index 58d6f07..47a46ac 100644 --- a/OTPKit/Sources/OTPKit/Resources/fr.lproj/Localizable.strings +++ b/OTPKit/Sources/OTPKit/Resources/fr.lproj/Localizable.strings @@ -216,7 +216,8 @@ "transport_mode.walk" = "À pied"; "transport_mode.bike" = "Vélo"; "transport_mode.car" = "Voiture"; -"transport_mode.bike_rental" = "Vélo en libre-service"; +"transport_mode.bike_rental" = "Vélo en libre-service uniquement"; +"transport_mode.transit_bike_rental" = "Transports en commun + vélo en libre-service"; "rental.vehicle_type.bike" = "vélo"; "rental.vehicle_type.ebike" = "vélo électrique"; @@ -312,3 +313,14 @@ "rail.last_step" = "Dernière étape"; "rail.arrive_time_fmt" = "Arrivée %@"; "rail.now" = "maintenant"; + +/* Vehicle rental legs */ +"leg.bike_to" = "Allez à vélo jusqu'à %@"; +"leg.ride_rental_bike_to" = "Roulez en vélo en libre-service jusqu'à %@"; +"place.rental_bike" = "vélo en libre-service"; +"rail.now_riding_rental" = "En vélo en libre-service"; +"rail.pick_up_bike" = "Prenez le vélo en libre-service"; +"rail.picked_up_bike" = "Vélo en libre-service pris"; +"rail.ride_bike_to_fmt" = "Roulez jusqu'à %@"; +"rail.drop_off_bike" = "Déposez le vélo"; +"rail.ride_distance_duration_fmt" = "%1$@, environ %2$@"; diff --git a/OTPKit/Sources/OTPKit/Resources/it.lproj/Localizable.strings b/OTPKit/Sources/OTPKit/Resources/it.lproj/Localizable.strings index d9a1a26..4e62978 100644 --- a/OTPKit/Sources/OTPKit/Resources/it.lproj/Localizable.strings +++ b/OTPKit/Sources/OTPKit/Resources/it.lproj/Localizable.strings @@ -216,7 +216,8 @@ "transport_mode.walk" = "A piedi"; "transport_mode.bike" = "Bicicletta"; "transport_mode.car" = "Auto"; -"transport_mode.bike_rental" = "Bike sharing"; +"transport_mode.bike_rental" = "Solo bike sharing"; +"transport_mode.transit_bike_rental" = "Trasporto pubblico + bike sharing"; "rental.vehicle_type.bike" = "bici"; "rental.vehicle_type.ebike" = "bici elettrica"; @@ -312,3 +313,14 @@ "rail.last_step" = "Ultimo passaggio"; "rail.arrive_time_fmt" = "Arrivo alle %@"; "rail.now" = "ora"; + +/* Vehicle rental legs */ +"leg.bike_to" = "Vai in bici fino a %@"; +"leg.ride_rental_bike_to" = "Pedala con la bici in sharing fino a %@"; +"place.rental_bike" = "bici in sharing"; +"rail.now_riding_rental" = "In sella a una bici in sharing"; +"rail.pick_up_bike" = "Prendi la bici in sharing"; +"rail.picked_up_bike" = "Bici in sharing presa"; +"rail.ride_bike_to_fmt" = "Pedala fino a %@"; +"rail.drop_off_bike" = "Riconsegna la bici"; +"rail.ride_distance_duration_fmt" = "%1$@, circa %2$@"; diff --git a/OTPKit/Sources/OTPKit/Resources/ko.lproj/Localizable.strings b/OTPKit/Sources/OTPKit/Resources/ko.lproj/Localizable.strings index dc9b9fd..d384ee8 100644 --- a/OTPKit/Sources/OTPKit/Resources/ko.lproj/Localizable.strings +++ b/OTPKit/Sources/OTPKit/Resources/ko.lproj/Localizable.strings @@ -216,7 +216,8 @@ "transport_mode.walk" = "도보"; "transport_mode.bike" = "자전거"; "transport_mode.car" = "자동차"; -"transport_mode.bike_rental" = "공유 자전거"; +"transport_mode.bike_rental" = "공유 자전거만"; +"transport_mode.transit_bike_rental" = "대중교통 + 공유 자전거"; "rental.vehicle_type.bike" = "자전거"; "rental.vehicle_type.ebike" = "전기 자전거"; @@ -312,3 +313,14 @@ "rail.last_step" = "마지막 단계"; "rail.arrive_time_fmt" = "도착 %@"; "rail.now" = "지금"; + +/* Vehicle rental legs */ +"leg.bike_to" = "%@까지 자전거 타기"; +"leg.ride_rental_bike_to" = "공유 자전거로 %@까지 이동"; +"place.rental_bike" = "공유 자전거"; +"rail.now_riding_rental" = "공유 자전거 탑승 중"; +"rail.pick_up_bike" = "공유 자전거 픽업"; +"rail.picked_up_bike" = "공유 자전거 픽업 완료"; +"rail.ride_bike_to_fmt" = "%@까지 타고 가기"; +"rail.drop_off_bike" = "자전거 반납"; +"rail.ride_distance_duration_fmt" = "%1$@, 약 %2$@"; diff --git a/OTPKit/Sources/OTPKit/Resources/pl.lproj/Localizable.strings b/OTPKit/Sources/OTPKit/Resources/pl.lproj/Localizable.strings index c431058..b83b602 100644 --- a/OTPKit/Sources/OTPKit/Resources/pl.lproj/Localizable.strings +++ b/OTPKit/Sources/OTPKit/Resources/pl.lproj/Localizable.strings @@ -216,7 +216,8 @@ "transport_mode.walk" = "Pieszo"; "transport_mode.bike" = "Rower"; "transport_mode.car" = "Samochód"; -"transport_mode.bike_rental" = "Rower miejski"; +"transport_mode.bike_rental" = "Tylko rower miejski"; +"transport_mode.transit_bike_rental" = "Komunikacja miejska + rower miejski"; "rental.vehicle_type.bike" = "rower"; "rental.vehicle_type.ebike" = "rower elektryczny"; @@ -312,3 +313,14 @@ "rail.last_step" = "Ostatni krok"; "rail.arrive_time_fmt" = "Przyjazd %@"; "rail.now" = "teraz"; + +/* Vehicle rental legs */ +"leg.bike_to" = "Jedź rowerem do %@"; +"leg.ride_rental_bike_to" = "Jedź rowerem miejskim do %@"; +"place.rental_bike" = "rower miejski"; +"rail.now_riding_rental" = "Jedziesz rowerem miejskim"; +"rail.pick_up_bike" = "Odbierz rower miejski"; +"rail.picked_up_bike" = "Rower miejski odebrany"; +"rail.ride_bike_to_fmt" = "Jedź do %@"; +"rail.drop_off_bike" = "Zostaw rower"; +"rail.ride_distance_duration_fmt" = "%1$@, około %2$@"; diff --git a/OTPKit/Sources/OTPKit/Resources/pt-BR.lproj/Localizable.strings b/OTPKit/Sources/OTPKit/Resources/pt-BR.lproj/Localizable.strings index d7904bd..8e9acf6 100644 --- a/OTPKit/Sources/OTPKit/Resources/pt-BR.lproj/Localizable.strings +++ b/OTPKit/Sources/OTPKit/Resources/pt-BR.lproj/Localizable.strings @@ -216,7 +216,8 @@ "transport_mode.walk" = "A pé"; "transport_mode.bike" = "Bicicleta"; "transport_mode.car" = "Carro"; -"transport_mode.bike_rental" = "Bicicleta compartilhada"; +"transport_mode.bike_rental" = "Apenas bicicleta compartilhada"; +"transport_mode.transit_bike_rental" = "Transporte público + bicicleta compartilhada"; "rental.vehicle_type.bike" = "bicicleta"; "rental.vehicle_type.ebike" = "bicicleta elétrica"; @@ -312,3 +313,14 @@ "rail.last_step" = "Última etapa"; "rail.arrive_time_fmt" = "Chegada %@"; "rail.now" = "agora"; + +/* Vehicle rental legs */ +"leg.bike_to" = "Vá de bicicleta até %@"; +"leg.ride_rental_bike_to" = "Pedale a bicicleta compartilhada até %@"; +"place.rental_bike" = "bicicleta compartilhada"; +"rail.now_riding_rental" = "Pedalando uma bicicleta compartilhada"; +"rail.pick_up_bike" = "Pegue a bicicleta compartilhada"; +"rail.picked_up_bike" = "Bicicleta compartilhada retirada"; +"rail.ride_bike_to_fmt" = "Pedale até %@"; +"rail.drop_off_bike" = "Devolva a bicicleta"; +"rail.ride_distance_duration_fmt" = "%1$@, cerca de %2$@"; diff --git a/OTPKit/Sources/OTPKit/Resources/ru.lproj/Localizable.strings b/OTPKit/Sources/OTPKit/Resources/ru.lproj/Localizable.strings index 79f3dd1..afc1fb2 100644 --- a/OTPKit/Sources/OTPKit/Resources/ru.lproj/Localizable.strings +++ b/OTPKit/Sources/OTPKit/Resources/ru.lproj/Localizable.strings @@ -216,7 +216,8 @@ "transport_mode.walk" = "Пешком"; "transport_mode.bike" = "Велосипед"; "transport_mode.car" = "Автомобиль"; -"transport_mode.bike_rental" = "Велопрокат"; +"transport_mode.bike_rental" = "Только велопрокат"; +"transport_mode.transit_bike_rental" = "Транспорт + велопрокат"; "rental.vehicle_type.bike" = "велосипед"; "rental.vehicle_type.ebike" = "электровелосипед"; @@ -312,3 +313,14 @@ "rail.last_step" = "Последний шаг"; "rail.arrive_time_fmt" = "Прибытие в %@"; "rail.now" = "сейчас"; + +/* Vehicle rental legs */ +"leg.bike_to" = "Поезжайте на велосипеде до %@"; +"leg.ride_rental_bike_to" = "Поезжайте на прокатном велосипеде до %@"; +"place.rental_bike" = "прокатный велосипед"; +"rail.now_riding_rental" = "Едете на прокатном велосипеде"; +"rail.pick_up_bike" = "Возьмите прокатный велосипед"; +"rail.picked_up_bike" = "Прокатный велосипед взят"; +"rail.ride_bike_to_fmt" = "Поезжайте до %@"; +"rail.drop_off_bike" = "Верните велосипед"; +"rail.ride_distance_duration_fmt" = "%1$@, около %2$@"; diff --git a/OTPKit/Sources/OTPKit/Resources/vi.lproj/Localizable.strings b/OTPKit/Sources/OTPKit/Resources/vi.lproj/Localizable.strings index b2b463b..afa2038 100644 --- a/OTPKit/Sources/OTPKit/Resources/vi.lproj/Localizable.strings +++ b/OTPKit/Sources/OTPKit/Resources/vi.lproj/Localizable.strings @@ -216,7 +216,8 @@ "transport_mode.walk" = "Đi bộ"; "transport_mode.bike" = "Xe đạp"; "transport_mode.car" = "Ô tô"; -"transport_mode.bike_rental" = "Xe đạp công cộng"; +"transport_mode.bike_rental" = "Chỉ xe đạp công cộng"; +"transport_mode.transit_bike_rental" = "Phương tiện công cộng + xe đạp công cộng"; "rental.vehicle_type.bike" = "xe đạp"; "rental.vehicle_type.ebike" = "xe đạp điện"; @@ -312,3 +313,14 @@ "rail.last_step" = "Bước cuối"; "rail.arrive_time_fmt" = "Đến lúc %@"; "rail.now" = "bây giờ"; + +/* Vehicle rental legs */ +"leg.bike_to" = "Đạp xe đến %@"; +"leg.ride_rental_bike_to" = "Đi xe đạp thuê đến %@"; +"place.rental_bike" = "xe đạp thuê"; +"rail.now_riding_rental" = "Đang đi xe đạp thuê"; +"rail.pick_up_bike" = "Nhận xe đạp thuê"; +"rail.picked_up_bike" = "Đã nhận xe đạp thuê"; +"rail.ride_bike_to_fmt" = "Đi đến %@"; +"rail.drop_off_bike" = "Trả xe đạp"; +"rail.ride_distance_duration_fmt" = "%1$@, khoảng %2$@"; diff --git a/OTPKit/Sources/OTPKit/Resources/zh-Hans.lproj/Localizable.strings b/OTPKit/Sources/OTPKit/Resources/zh-Hans.lproj/Localizable.strings index ad6ba17..fca2109 100644 --- a/OTPKit/Sources/OTPKit/Resources/zh-Hans.lproj/Localizable.strings +++ b/OTPKit/Sources/OTPKit/Resources/zh-Hans.lproj/Localizable.strings @@ -215,7 +215,8 @@ "transport_mode.walk" = "步行"; "transport_mode.bike" = "骑行"; "transport_mode.car" = "驾车"; -"transport_mode.bike_rental" = "共享单车"; +"transport_mode.bike_rental" = "仅共享单车"; +"transport_mode.transit_bike_rental" = "公交 + 共享单车"; "rental.vehicle_type.bike" = "单车"; "rental.vehicle_type.ebike" = "电动单车"; @@ -311,3 +312,14 @@ "rail.last_step" = "最后一步"; "rail.arrive_time_fmt" = "%@ 到达"; "rail.now" = "现在"; + +/* Vehicle rental legs */ +"leg.bike_to" = "骑车前往%@"; +"leg.ride_rental_bike_to" = "骑共享单车前往%@"; +"place.rental_bike" = "共享单车"; +"rail.now_riding_rental" = "正在骑共享单车"; +"rail.pick_up_bike" = "取用共享单车"; +"rail.picked_up_bike" = "已取用共享单车"; +"rail.ride_bike_to_fmt" = "骑行前往%@"; +"rail.drop_off_bike" = "归还单车"; +"rail.ride_distance_duration_fmt" = "%1$@,约%2$@"; diff --git a/OTPKit/Sources/OTPKit/Resources/zh-Hant.lproj/Localizable.strings b/OTPKit/Sources/OTPKit/Resources/zh-Hant.lproj/Localizable.strings index 82fff66..a9a290c 100644 --- a/OTPKit/Sources/OTPKit/Resources/zh-Hant.lproj/Localizable.strings +++ b/OTPKit/Sources/OTPKit/Resources/zh-Hant.lproj/Localizable.strings @@ -216,7 +216,8 @@ "transport_mode.walk" = "步行"; "transport_mode.bike" = "自行車"; "transport_mode.car" = "開車"; -"transport_mode.bike_rental" = "共享單車"; +"transport_mode.bike_rental" = "僅共享單車"; +"transport_mode.transit_bike_rental" = "大眾運輸 + 共享單車"; "rental.vehicle_type.bike" = "單車"; "rental.vehicle_type.ebike" = "電動單車"; @@ -312,3 +313,14 @@ "rail.last_step" = "最後一步"; "rail.arrive_time_fmt" = "抵達 %@"; "rail.now" = "現在"; + +/* Vehicle rental legs */ +"leg.bike_to" = "騎車前往%@"; +"leg.ride_rental_bike_to" = "騎共享單車前往%@"; +"place.rental_bike" = "共享單車"; +"rail.now_riding_rental" = "正在騎共享單車"; +"rail.pick_up_bike" = "取用共享單車"; +"rail.picked_up_bike" = "已取用共享單車"; +"rail.ride_bike_to_fmt" = "騎行前往%@"; +"rail.drop_off_bike" = "歸還單車"; +"rail.ride_distance_duration_fmt" = "%1$@,約%2$@"; diff --git a/OTPKit/Tests/GraphQLAPIServiceTests.swift b/OTPKit/Tests/GraphQLAPIServiceTests.swift index 22b4390..c5fb482 100644 --- a/OTPKit/Tests/GraphQLAPIServiceTests.swift +++ b/OTPKit/Tests/GraphQLAPIServiceTests.swift @@ -110,6 +110,72 @@ class GraphQLAPIServiceTests: OTPTestCase { XCTAssertNil(modes[1]["qualifier"]) } + func testFetchPlanSendsViaPoint() async throws { + mockDataLoader.mockResponse(data: Fixtures.loadData(file: "graphql_plan_success.json")) + + let request = TripPlanRequest( + origin: CLLocationCoordinate2D(latitude: 47.6097, longitude: -122.3331), + destination: CLLocationCoordinate2D(latitude: 47.6205, longitude: -122.3493), + date: Self.testDate, + time: Self.testTime, + transportModes: TransportMode.transitBikeRental.apiModes, + maxWalkDistance: 800, + viaPoint: CLLocationCoordinate2D(latitude: 47.6095, longitude: -122.337) + ) + _ = try await service.fetchPlan(request) + + let urlRequest = try XCTUnwrap(mockDataLoader.lastRequest) + let body = try XCTUnwrap(urlRequest.httpBody) + let payload = try XCTUnwrap(try JSONSerialization.jsonObject(with: body) as? [String: Any]) + + let query = try XCTUnwrap(payload["query"] as? String) + XCTAssertTrue(query.contains("via: $via")) + + let variables = try XCTUnwrap(payload["variables"] as? [String: Any]) + let via = try XCTUnwrap(variables["via"] as? [[String: Any]]) + XCTAssertEqual(via.count, 1) + let visit = try XCTUnwrap(via[0]["visit"] as? [String: Any]) + let coordinate = try XCTUnwrap(visit["coordinate"] as? [String: Any]) + XCTAssertEqual(coordinate["latitude"] as? Double, 47.6095) + XCTAssertEqual(coordinate["longitude"] as? Double, -122.337) + } + + func testFetchPlanOmitsViaWhenAbsent() async throws { + mockDataLoader.mockResponse(data: Fixtures.loadData(file: "graphql_plan_success.json")) + + _ = try await service.fetchPlan(createTripPlanRequest()) + + let request = try XCTUnwrap(mockDataLoader.lastRequest) + let body = try XCTUnwrap(request.httpBody) + let payload = try XCTUnwrap(try JSONSerialization.jsonObject(with: body) as? [String: Any]) + let variables = try XCTUnwrap(payload["variables"] as? [String: Any]) + // Absent, not null: not every OTP build treats a null via as "no via". + XCTAssertNil(variables["via"]) + + // The query document itself must not mention via either: GraphQL validates + // documents statically, and `plan`'s via argument only exists on OTP 2.7+ — + // declaring it unconditionally would break every request against older servers. + let query = try XCTUnwrap(payload["query"] as? String) + XCTAssertFalse(query.contains("$via")) + XCTAssertFalse(query.contains("via:")) + } + + func testFetchPlanExpandsCompositeModeToWireModes() async throws { + mockDataLoader.mockResponse(data: Fixtures.loadData(file: "graphql_plan_success.json")) + + // A host passing the composite mode directly must get the expanded wire + // modes, never the fabricated TRANSIT_BICYCLE_RENT raw value. + _ = try await service.fetchPlan(createTripPlanRequest(transportModes: [.transitBikeRental])) + + let request = try XCTUnwrap(mockDataLoader.lastRequest) + let body = try XCTUnwrap(request.httpBody) + let payload = try XCTUnwrap(try JSONSerialization.jsonObject(with: body) as? [String: Any]) + let variables = try XCTUnwrap(payload["variables"] as? [String: Any]) + let modes = try XCTUnwrap(variables["transportModes"] as? [[String: Any]]) + XCTAssertEqual(modes.map { $0["mode"] as? String }, ["TRANSIT", "WALK", "BICYCLE"]) + XCTAssertEqual(modes[2]["qualifier"] as? String, "RENT") + } + // MARK: - Response Mapping func testFetchPlanMapsItineraries() async throws { diff --git a/OTPKit/Tests/Helpers/TestFixtures.swift b/OTPKit/Tests/Helpers/TestFixtures.swift index 766cb64..6dd03ce 100644 --- a/OTPKit/Tests/Helpers/TestFixtures.swift +++ b/OTPKit/Tests/Helpers/TestFixtures.swift @@ -64,8 +64,9 @@ enum TestFixtures { } /// A mock APIService that also advertises vehicle rental capability, for testing - /// capability-gated behavior like `availableTransportModes`. - class MockRentalAPIService: MockAPIService, VehicleRentalService { + /// capability-gated behavior like `availableTransportModes`. `@unchecked Sendable`: + /// single-threaded test usage; `VehicleRentalService` requires `Sendable`. + final class MockRentalAPIService: MockAPIService, VehicleRentalService, @unchecked Sendable { var mockRentals: [VehicleRental] = [] func fetchVehicleRentals( diff --git a/OTPKit/Tests/TripPlanRequestTests.swift b/OTPKit/Tests/TripPlanRequestTests.swift index df07b92..aed7b2e 100644 --- a/OTPKit/Tests/TripPlanRequestTests.swift +++ b/OTPKit/Tests/TripPlanRequestTests.swift @@ -93,6 +93,55 @@ struct TripPlanRequestTests { #expect(request.transportModesString == "BICYCLE_RENT,WALK") } + @Test("Transit + Bikeshare expands to the REST mode list from the spec") + func transportModesStringTransitBikeRental() { + let request = TripPlanRequest( + origin: CLLocationCoordinate2D(latitude: 0, longitude: 0), + destination: CLLocationCoordinate2D(latitude: 1, longitude: 1), + date: Date(), + time: Date(), + transportModes: TransportMode.transitBikeRental.apiModes + ) + + // The composite mode itself never reaches the wire; its apiModes do. + #expect(request.transportModesString == "TRANSIT,WALK,BICYCLE_RENT") + } + + @Test("A composite mode passed directly still serializes to primitive wire tokens") + func compositeModeExpandsAndDeduplicates() { + let request = TripPlanRequest( + origin: CLLocationCoordinate2D(latitude: 0, longitude: 0), + destination: CLLocationCoordinate2D(latitude: 1, longitude: 1), + date: Date(), + time: Date(), + transportModes: [.transitBikeRental, .walk] + ) + + // The fabricated TRANSIT_BICYCLE_RENT raw value must never reach the wire, + // and the duplicate walk collapses in first-appearance order. + #expect(request.transportModesString == "TRANSIT,WALK,BICYCLE_RENT") + } + + @Test("viaPoint participates in equality") + func viaPointEquality() { + let base = TripPlanRequest( + origin: CLLocationCoordinate2D(latitude: 0, longitude: 0), + destination: CLLocationCoordinate2D(latitude: 1, longitude: 1), + date: Date(timeIntervalSince1970: 0), + time: Date(timeIntervalSince1970: 0) + ) + let withVia = TripPlanRequest( + origin: CLLocationCoordinate2D(latitude: 0, longitude: 0), + destination: CLLocationCoordinate2D(latitude: 1, longitude: 1), + date: Date(timeIntervalSince1970: 0), + time: Date(timeIntervalSince1970: 0), + viaPoint: CLLocationCoordinate2D(latitude: 47.6, longitude: -122.3) + ) + + #expect(base != withVia) + #expect(base == base) + } + @Test("transportModesString with single mode") func transportModesStringSingleMode() { let request = TripPlanRequest( diff --git a/OTPKit/Tests/TripPlannerViewModelTests.swift b/OTPKit/Tests/TripPlannerViewModelTests.swift index 7163f01..e154aa3 100644 --- a/OTPKit/Tests/TripPlannerViewModelTests.swift +++ b/OTPKit/Tests/TripPlannerViewModelTests.swift @@ -151,6 +151,23 @@ struct TripPlannerViewModelTests { #expect(viewModel.availableTransportModes == [.bike, .car]) } + @Test("availableTransportModes filters transit + bikeshare when the service lacks rental support") + func availableTransportModesFiltersTransitBikeRental() { + let viewModel = createViewModel(enabledModes: [.transit, .walk, .transitBikeRental, .bikeRental]) + + #expect(viewModel.availableTransportModes == [.transit, .walk]) + } + + @Test("availableTransportModes keeps transit + bikeshare when the service supports rentals") + func availableTransportModesKeepsTransitBikeRentalWithCapableService() { + let viewModel = createViewModel( + enabledModes: [.transit, .walk, .transitBikeRental], + mockAPIService: TestFixtures.MockRentalAPIService() + ) + + #expect(viewModel.availableTransportModes == [.transit, .walk, .transitBikeRental]) + } + @Test("Default selected mode skips an unavailable rental mode listed first") func defaultSelectedModeSkipsUnavailableRentalMode() { // .bikeRental configured first, but the service has no rental support: diff --git a/OTPKit/Tests/TripProgressTests.swift b/OTPKit/Tests/TripProgressTests.swift index 750437b..146305a 100644 --- a/OTPKit/Tests/TripProgressTests.swift +++ b/OTPKit/Tests/TripProgressTests.swift @@ -289,4 +289,140 @@ struct TripProgressTests { #expect(leg.departureStatus == .late(minutes: 7)) #expect(leg.arrivalStatus == .late(minutes: 6)) } + + // MARK: - Rental legs + + /// Walk 3m to the vehicle → (optional gap) → ride the rental 10m → walk 2m to + /// the destination. A gap models the sub-minute approach walk that + /// `relevantLegs` merges away, leaving a hole before the ride starts. + func makeRentalItinerary(pickupGap: TimeInterval = 0) -> Itinerary { + let walkEnd = tripStart.addingTimeInterval(180) + let rideStart = walkEnd.addingTimeInterval(pickupGap) + let rideEnd = rideStart.addingTimeInterval(600) + let arrivalTime = rideEnd.addingTimeInterval(120) + + let vehiclePlace = Place(name: "Default vehicle type", lon: -122.34, lat: 47.615, + vertexType: "BIKESHARE", bikeShareId: "lime_seattle:abc") + let dropoffPlace = Place(name: "3rd Ave & Pine St", lon: -122.33, lat: 47.61, + vertexType: "BIKESHARE", bikeShareId: "pronto:BT-01") + + let walkLeg = Leg( + startTime: tripStart, endTime: walkEnd, mode: "WALK", + routeType: nil, routeColor: nil, routeTextColor: nil, route: nil, agencyName: nil, + from: Place(name: "Home", lon: -122.35, lat: 47.62, vertexType: "NORMAL"), + to: vehiclePlace, + legGeometry: LegGeometry(points: "AA@@", length: 4), + distance: 250, transitLeg: false, duration: 180, realTime: nil, + streetNames: nil, pathway: nil, steps: nil, headsign: nil, intermediateStops: nil, + rentedBike: false + ) + + let rideLeg = Leg( + startTime: rideStart, endTime: rideEnd, mode: "BICYCLE", + routeType: nil, routeColor: nil, routeTextColor: nil, route: nil, agencyName: nil, + from: vehiclePlace, to: dropoffPlace, + legGeometry: LegGeometry(points: "AA@@", length: 4), + distance: 2100, transitLeg: false, duration: 600, realTime: nil, + streetNames: nil, pathway: nil, steps: nil, headsign: nil, intermediateStops: nil, + rentedBike: true + ) + + let finalWalkLeg = Leg( + startTime: rideEnd, endTime: arrivalTime, mode: "WALK", + routeType: nil, routeColor: nil, routeTextColor: nil, route: nil, agencyName: nil, + from: dropoffPlace, + to: Place(name: "Pike Place Market", lon: -122.34, lat: 47.609, vertexType: "NORMAL"), + legGeometry: LegGeometry(points: "AA@@", length: 4), + distance: 140, transitLeg: false, duration: 120, realTime: nil, + streetNames: nil, pathway: nil, steps: nil, headsign: nil, intermediateStops: nil, + rentedBike: false + ) + + return Itinerary( + duration: Int(arrivalTime.timeIntervalSince(tripStart)), + startTime: tripStart, + endTime: arrivalTime, + walkTime: 300, transitTime: 0, waitingTime: 0, + walkDistance: 390, walkLimitExceeded: false, + elevationLost: 0, elevationGained: 0, + transfers: 0, + legs: [walkLeg, rideLeg, finalWalkLeg] + ) + } + + func rentalProgress(at offset: TimeInterval) -> TripProgress { + TripProgress(itinerary: makeRentalItinerary(), now: tripStart.addingTimeInterval(offset)) + } + + @Test func rentalRideCountsAsRiding() { + // 180s..780s is the ride window. + #expect(rentalProgress(at: 400).phase == .riding(legIndex: 1)) + } + + @Test func gapBeforeRentalRideIsWaitingAtThePickup() throws { + // Walk ends at 180s, ride starts at 300s: the rider is approaching the + // parked vehicle. Classifying this as .walking would point the tip at the + // rental leg's full ride distance and leave the rail with no current row. + let progress = TripProgress( + itinerary: makeRentalItinerary(pickupGap: 120), + now: tripStart.addingTimeInterval(240) + ) + + #expect(progress.phase == .waiting(boardingLegIndex: 1)) + + let currentRows = progress.rows.filter { $0.state == .current } + #expect(currentRows.map(\.kind) == [.pickUpVehicle(legIndex: 1)]) + #expect(progress.localizedActivityName == OTPLoc("rail.pick_up_bike", comment: "")) + } + + @Test func rentalLegProducesPickupRideAndDropoffRows() { + let rows = rentalProgress(at: 400).rows + let kinds = rows.map(\.kind) + + #expect(kinds.contains(.pickUpVehicle(legIndex: 1))) + #expect(kinds.contains(.rideRental(legIndex: 1))) + #expect(kinds.contains(.dropOffVehicle(legIndex: 1))) + + // Exactly one current row: the synthetic riding row. + let currentRows = rows.filter { $0.state == .current } + #expect(currentRows.map(\.kind) == [.rideRental(legIndex: 1)]) + } + + @Test func rentalRideRowAbsentWhenNotRiding() { + let kinds = rentalProgress(at: 60).rows.map(\.kind) + #expect(!kinds.contains(.rideRental(legIndex: 1))) + #expect(kinds.contains(.pickUpVehicle(legIndex: 1))) + } + + @Test func rentalActivityNameIsRentalSpecific() { + let name = rentalProgress(at: 400).localizedActivityName + #expect(name == OTPLoc("rail.now_riding_rental", comment: "")) + } + + @Test func rentalSegmentIsMarkedRental() { + let segments = rentalProgress(at: 400).segments + #expect(segments[1].isRental) + #expect(!segments[1].isTransit) + #expect(!segments[0].isRental) + } + + @Test func stopsRemainingIsNilForRentalLegs() { + #expect(rentalProgress(at: 400).stopsRemaining(onLegAt: 1) == nil) + } + + @Test func placeholderVehicleNameNeverSurfaces() { + let itinerary = makeRentalItinerary() + // The walk leg ends at the vehicle, whose feed name is the placeholder. + #expect(itinerary.legs[0].riderFacingToName == OTPLoc("place.rental_bike", comment: "")) + // Real names pass through untouched. + #expect(itinerary.legs[1].riderFacingToName == "3rd Ave & Pine St") + } + + @Test func legsNeverMergeAcrossARentalBoundary() { + let itinerary = makeRentalItinerary() + // relevantLegs must keep the rental ride distinct from its neighbors. + #expect(itinerary.relevantLegs.filter(\.isRentalRide).count == 1) + #expect(!Leg.shouldMergeLegs(leg1: itinerary.legs[0], leg2: itinerary.legs[1])) + #expect(!Leg.shouldMergeLegs(leg1: itinerary.legs[1], leg2: itinerary.legs[2])) + } } diff --git a/OTPKit/Tests/VehicleRentalSourceTests.swift b/OTPKit/Tests/VehicleRentalSourceTests.swift new file mode 100644 index 0000000..3348338 --- /dev/null +++ b/OTPKit/Tests/VehicleRentalSourceTests.swift @@ -0,0 +1,408 @@ +// +// VehicleRentalSourceTests.swift +// OTPKitTests +// +// Tests for the stateful viewport→snapshot rental pipeline: coalescing, +// cancellation, diffing, clearing, and failure reporting. +// + +import Foundation +import Testing +@testable import OTPKit + +/// One recorded `fetchVehicleRentals` invocation on the scripted service. +private struct RentalServiceCall: Sendable { + let boundingBox: VehicleRentalBoundingBox + let formFactors: Set? +} + +@Suite("VehicleRentalSource") +struct VehicleRentalSourceTests { + + // MARK: - Scripted service + + private actor ScriptedRentalService: VehicleRentalService { + private(set) var calls: [RentalServiceCall] = [] + private var results: [Result] + private var delay: Duration = .zero + + init(results: [Result]) { + self.results = results + } + + func setDelay(_ delay: Duration) { + self.delay = delay + } + + func fetchVehicleRentals( + in boundingBox: VehicleRentalBoundingBox, + formFactors: Set? + ) async throws -> VehicleRentalFetchResult { + calls.append(RentalServiceCall(boundingBox: boundingBox, formFactors: formFactors)) + + // Claim the scripted result at call time, before any delay: a cancelled + // call must still consume its result so later calls stay aligned with + // the script. The last result is sticky so repeated fetches keep working. + let result: Result + if results.isEmpty { + result = .success(VehicleRentalFetchResult(rentals: [])) + } else if results.count == 1 { + result = results[0] + } else { + result = results.removeFirst() + } + + if delay > .zero { + try await Task.sleep(for: delay) + } + return try result.get() + } + } + + private struct ScriptedError: Error {} + + // MARK: - Fixtures + + private static let seattleBox = VehicleRentalBoundingBox( + minimumLatitude: 47.5, + maximumLatitude: 47.7, + minimumLongitude: -122.4, + maximumLongitude: -122.2 + ) + + /// A slightly panned viewport: identical boxes are deliberately deduplicated + /// by the source, so successive fetches in tests must actually move. + private static func pannedBox(_ offset: Double) -> VehicleRentalBoundingBox { + VehicleRentalBoundingBox( + minimumLatitude: 47.5 + offset, + maximumLatitude: 47.7 + offset, + minimumLongitude: -122.4, + maximumLongitude: -122.2 + ) + } + + private static func makeRental(id: String, lat: Double = 47.61) -> VehicleRental { + .vehicle(RentalVehicle( + vehicleId: id, + name: "Default vehicle type", + lat: lat, + lon: -122.33, + allowPickupNow: true, + operative: true, + rentalNetwork: RentalNetwork(networkId: "lime_seattle", url: nil), + rentalUris: nil, + vehicleType: VehicleType(formFactor: .bicycle, propulsionType: "ELECTRIC_ASSIST"), + fuel: nil + )) + } + + private static func makeSource( + service: ScriptedRentalService, + formFactors: Set? = nil, + coalescingInterval: Duration = .milliseconds(1), + boundingBoxPadding: Double = 1.0 + ) -> VehicleRentalSource { + VehicleRentalSource( + service: service, + formFactors: formFactors, + coalescingInterval: coalescingInterval, + boundingBoxPadding: boundingBoxPadding + ) + } + + // MARK: - Tests + + @Test("First fetch delivers everything as added") + func firstFetchAddsEverything() async throws { + let rentals = [Self.makeRental(id: "a"), Self.makeRental(id: "b")] + let service = ScriptedRentalService(results: [.success(VehicleRentalFetchResult(rentals: rentals))]) + let source = Self.makeSource(service: service) + var snapshots = source.snapshots.makeAsyncIterator() + + await source.setViewport(Self.seattleBox) + + let snapshot = try #require(await snapshots.next()) + #expect(snapshot.added.map(\.id) == ["a", "b"]) + #expect(snapshot.removed.isEmpty) + #expect(snapshot.updated.isEmpty) + #expect(snapshot.partialErrors.isEmpty) + } + + @Test("Successive fetches deliver an id-keyed diff") + func diffAcrossViewports() async throws { + let first = [Self.makeRental(id: "a"), Self.makeRental(id: "b")] + let second = [Self.makeRental(id: "b", lat: 47.62), Self.makeRental(id: "c")] + let service = ScriptedRentalService(results: [ + .success(VehicleRentalFetchResult(rentals: first)), + .success(VehicleRentalFetchResult(rentals: second)) + ]) + let source = Self.makeSource(service: service) + var snapshots = source.snapshots.makeAsyncIterator() + + await source.setViewport(Self.seattleBox) + _ = await snapshots.next() + + await source.setViewport(Self.pannedBox(0.01)) + let snapshot = try #require(await snapshots.next()) + + #expect(snapshot.added.map(\.id) == ["c"]) + #expect(snapshot.removed == ["a"]) + #expect(snapshot.updated.map(\.id) == ["b"]) + } + + @Test("An identical viewport does not refetch") + func identicalViewportDeduplicated() async throws { + let service = ScriptedRentalService(results: [ + .success(VehicleRentalFetchResult(rentals: [Self.makeRental(id: "a")])) + ]) + let source = Self.makeSource(service: service) + var snapshots = source.snapshots.makeAsyncIterator() + + await source.setViewport(Self.seattleBox) + _ = await snapshots.next() + + await source.setViewport(Self.seattleBox) + try await Task.sleep(for: .milliseconds(30)) + #expect(await service.calls.count == 1) + } + + @Test("An unchanged entity is neither added nor updated") + func unchangedEntityNotRedelivered() async throws { + let rentals = [Self.makeRental(id: "a")] + let service = ScriptedRentalService(results: [.success(VehicleRentalFetchResult(rentals: rentals))]) + let source = Self.makeSource(service: service) + var snapshots = source.snapshots.makeAsyncIterator() + + await source.setViewport(Self.seattleBox) + _ = await snapshots.next() + + await source.setViewport(Self.pannedBox(0.01)) + let snapshot = try #require(await snapshots.next()) + #expect(snapshot.isEmpty) + } + + @Test("Rapid viewport changes coalesce into one fetch") + func coalescesViewportChanges() async throws { + let service = ScriptedRentalService(results: [ + .success(VehicleRentalFetchResult(rentals: [Self.makeRental(id: "a")])) + ]) + let source = Self.makeSource(service: service, coalescingInterval: .milliseconds(100)) + var snapshots = source.snapshots.makeAsyncIterator() + + await source.setViewport(VehicleRentalBoundingBox( + minimumLatitude: 40, maximumLatitude: 41, minimumLongitude: -100, maximumLongitude: -99 + )) + await source.setViewport(VehicleRentalBoundingBox( + minimumLatitude: 41, maximumLatitude: 42, minimumLongitude: -101, maximumLongitude: -100 + )) + await source.setViewport(Self.seattleBox) + + _ = try #require(await snapshots.next()) + + let calls = await service.calls + #expect(calls.count == 1) + #expect(calls.first?.boundingBox == Self.seattleBox) + } + + @Test("A superseded in-flight fetch is cancelled, not reported as a failure") + func supersededFetchIsCancelled() async throws { + let first = [Self.makeRental(id: "stale")] + let second = [Self.makeRental(id: "fresh")] + let service = ScriptedRentalService(results: [ + .success(VehicleRentalFetchResult(rentals: first)), + .success(VehicleRentalFetchResult(rentals: second)) + ]) + await service.setDelay(.milliseconds(200)) + let source = Self.makeSource(service: service) + var snapshots = source.snapshots.makeAsyncIterator() + + let failures = Box() + let failureWatcher = Task { + for await failure in source.fetchFailures { + await failures.append(failure.message) + } + } + + await source.setViewport(Self.seattleBox) + try await Task.sleep(for: .milliseconds(50)) // let the first fetch get in flight + await source.setViewport(Self.pannedBox(0.01)) + + let snapshot = try #require(await snapshots.next()) + #expect(snapshot.added.map(\.id) == ["fresh"]) + #expect(await service.calls.count == 2) + + try await Task.sleep(for: .milliseconds(50)) + #expect(await failures.values.isEmpty) + failureWatcher.cancel() + } + + @Test("A nil viewport clears everything immediately") + func nilViewportClears() async throws { + let rentals = [Self.makeRental(id: "a"), Self.makeRental(id: "b")] + let service = ScriptedRentalService(results: [.success(VehicleRentalFetchResult(rentals: rentals))]) + let source = Self.makeSource(service: service) + var snapshots = source.snapshots.makeAsyncIterator() + + await source.setViewport(Self.seattleBox) + _ = await snapshots.next() + + await source.setViewport(nil) + let snapshot = try #require(await snapshots.next()) + #expect(snapshot.added.isEmpty) + #expect(snapshot.removed == ["a", "b"]) + #expect(await service.calls.count == 1) + } + + @Test("reset() clears state and emits a removal snapshot") + func resetClears() async throws { + let rentals = [Self.makeRental(id: "a")] + let service = ScriptedRentalService(results: [.success(VehicleRentalFetchResult(rentals: rentals))]) + let source = Self.makeSource(service: service) + var snapshots = source.snapshots.makeAsyncIterator() + + await source.setViewport(Self.seattleBox) + _ = await snapshots.next() + + await source.reset() + let snapshot = try #require(await snapshots.next()) + #expect(snapshot.removed == ["a"]) + } + + @Test("Partial GraphQL errors ride along on the snapshot") + func partialErrorsForwarded() async throws { + let result = VehicleRentalFetchResult( + rentals: [Self.makeRental(id: "a")], + partialErrors: ["feed lime_tacoma unavailable"] + ) + let service = ScriptedRentalService(results: [.success(result)]) + let source = Self.makeSource(service: service) + var snapshots = source.snapshots.makeAsyncIterator() + + await source.setViewport(Self.seattleBox) + let snapshot = try #require(await snapshots.next()) + #expect(snapshot.partialErrors == ["feed lime_tacoma unavailable"]) + } + + @Test("A failed fetch reports on fetchFailures and preserves delivered state") + func failureReported() async throws { + let service = ScriptedRentalService(results: [ + .success(VehicleRentalFetchResult(rentals: [Self.makeRental(id: "a")])), + .failure(ScriptedError()), + // The script's last result is sticky, so the recovery fetch below needs + // its own success entry — otherwise the failure repeats forever and the + // snapshot this test waits on is never emitted. + .success(VehicleRentalFetchResult(rentals: [Self.makeRental(id: "a")])) + ]) + let source = Self.makeSource(service: service) + var snapshots = source.snapshots.makeAsyncIterator() + var failures = source.fetchFailures.makeAsyncIterator() + + await source.setViewport(Self.seattleBox) + _ = await snapshots.next() + + await source.setViewport(Self.pannedBox(0.01)) + let failure = try #require(await failures.next()) + #expect(failure.underlyingError is ScriptedError) + + // The next successful fetch diffs against state that survived the failure. + await source.setViewport(Self.pannedBox(0.02)) + let snapshot = try #require(await snapshots.next()) + #expect(snapshot.isEmpty) + } + + @Test("A failed viewport retries on the next identical region emission") + func failedViewportRetries() async throws { + let service = ScriptedRentalService(results: [ + .failure(ScriptedError()), + .success(VehicleRentalFetchResult(rentals: [Self.makeRental(id: "a")])) + ]) + let source = Self.makeSource(service: service) + var snapshots = source.snapshots.makeAsyncIterator() + var failures = source.fetchFailures.makeAsyncIterator() + + await source.setViewport(Self.seattleBox) + _ = try #require(await failures.next()) + + // A stationary map re-emits the same region; the failure must not be + // swallowed by the same-viewport deduplication. + await source.setViewport(Self.seattleBox) + let snapshot = try #require(await snapshots.next()) + #expect(snapshot.added.map(\.id) == ["a"]) + #expect(await service.calls.count == 2) + } + + @Test("Changing form factors refetches with the new filter") + func formFactorChangeRefetches() async throws { + let service = ScriptedRentalService(results: [ + .success(VehicleRentalFetchResult(rentals: [Self.makeRental(id: "a")])) + ]) + let source = Self.makeSource(service: service, formFactors: [.bicycle]) + var snapshots = source.snapshots.makeAsyncIterator() + + await source.setViewport(Self.seattleBox) + _ = await snapshots.next() + + await source.setFormFactors([.bicycle, .scooter]) + _ = await snapshots.next() + + let calls = await service.calls + #expect(calls.count == 2) + #expect(calls[0].formFactors == [.bicycle]) + #expect(calls[1].formFactors == [.bicycle, .scooter]) + } + + @Test("Setting the same form factors does not refetch") + func sameFormFactorsNoRefetch() async throws { + let service = ScriptedRentalService(results: [ + .success(VehicleRentalFetchResult(rentals: [])) + ]) + let source = Self.makeSource(service: service, formFactors: [.bicycle]) + var snapshots = source.snapshots.makeAsyncIterator() + + await source.setViewport(Self.seattleBox) + _ = await snapshots.next() + + await source.setFormFactors([.bicycle]) + try await Task.sleep(for: .milliseconds(30)) + #expect(await service.calls.count == 1) + } + + @Test("The viewport is padded before fetching") + func paddingApplied() async throws { + let service = ScriptedRentalService(results: [ + .success(VehicleRentalFetchResult(rentals: [])) + ]) + let source = Self.makeSource(service: service, boundingBoxPadding: 2.0) + var snapshots = source.snapshots.makeAsyncIterator() + + await source.setViewport(VehicleRentalBoundingBox( + minimumLatitude: 47.0, maximumLatitude: 48.0, minimumLongitude: -122.0, maximumLongitude: -121.0 + )) + _ = await snapshots.next() + + let box = try #require(await service.calls.first?.boundingBox) + #expect(abs(box.minimumLatitude - 46.5) < 0.0001) + #expect(abs(box.maximumLatitude - 48.5) < 0.0001) + #expect(abs(box.minimumLongitude - (-122.5)) < 0.0001) + #expect(abs(box.maximumLongitude - (-120.5)) < 0.0001) + } + + @Test("Duplicate ids in one payload are delivered once") + func duplicateIdsDeliveredOnce() async throws { + let rentals = [Self.makeRental(id: "a"), Self.makeRental(id: "a", lat: 47.62)] + let service = ScriptedRentalService(results: [.success(VehicleRentalFetchResult(rentals: rentals))]) + let source = Self.makeSource(service: service) + var snapshots = source.snapshots.makeAsyncIterator() + + await source.setViewport(Self.seattleBox) + let snapshot = try #require(await snapshots.next()) + #expect(snapshot.added.map(\.id) == ["a"]) + } + + // MARK: - Helpers + + private actor Box { + private(set) var values: [String] = [] + func append(_ value: String) { values.append(value) } + } +}