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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions Demo/OTPKitDemo-Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!--
iOS resolves an app's language from the main bundle, so a host app that declares no
localizations pins the process to English and OTPKit's translations are never selected.
Declaring the locales OTPKit ships lets the demo actually exercise them.

The remaining Info.plist keys are still generated by the build
(GENERATE_INFOPLIST_FILE) and merged into this file.
-->
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>ar</string>
<string>es</string>
<string>fil</string>
<string>fr</string>
<string>it</string>
<string>ko</string>
<string>pl</string>
<string>pt-BR</string>
<string>ru</string>
<string>vi</string>
<string>zh-Hans</string>
<string>zh-Hant</string>
</array>
</dict>
</plist>
2 changes: 2 additions & 0 deletions Demo/OTPKitDemo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = OTPKitDemo-Info.plist;
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Help you find your location on the map to plan a trip.";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
Expand Down Expand Up @@ -376,6 +377,7 @@
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = OTPKitDemo-Info.plist;
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Help you find your location on the map to plan a trip.";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
Expand Down
48 changes: 36 additions & 12 deletions OTPKit/Sources/OTPKit/Core/Extensions/DateFormatterExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,36 +9,60 @@ import Foundation

extension DateFormatter {

static let tripDateFormatter: DateFormatter = {
/// Builds a fixed-format formatter for OTP query parameters.
///
/// The locale and calendar pins matter because a device set to a non-Gregorian calendar
/// (Buddhist, Japanese Imperial) or a non-Latin numbering system otherwise emits
/// parameters the server can't parse — `05-10-2567` instead of `05-10-2024`. This is the
/// fixed-format guidance from Apple's Technical Q&A QA1480.
private static func makeAPIFormatter(dateFormat: String) -> DateFormatter {
let formatter = DateFormatter()
formatter.dateFormat = "MM-dd-yyyy"
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.calendar = Calendar(identifier: .gregorian)
// Setting the time zone explicitly, after `calendar`, is required: on iOS, dropping
// this line makes the formatter emit UTC, shifting every request by the device's
// offset and planning trips for the wrong local time. (Verified by A/B on the
// simulator; the same experiment on macOS shows no difference, so don't "clean this
// up" based on host-side behavior.) `LocalizationTests` pins the emitted value.
//
// It must be `.autoupdatingCurrent` rather than `.current`, because these formatters
// are `static let` and outlive any time zone change. A rider who flies across a
// boundary — or whose device picks up a new zone automatically — would otherwise keep
// planning trips in the departure zone's offset until the app is relaunched. Unlike
// the locale and calendar above, which are pinned deliberately for wire stability,
// the zone is genuinely meant to follow the device.
formatter.timeZone = .autoupdatingCurrent
formatter.dateFormat = dateFormat
return formatter
}()
}

/// Wire format for the OTP `date` query parameter. Not for display.
static let tripDateFormatter: DateFormatter = makeAPIFormatter(dateFormat: "MM-dd-yyyy")

/// Wire format for the OTP `time` query parameter. Not for display.
///
/// The AM/PM overrides are kept from the original implementation so the emitted symbols
/// can never drift with ICU data, even though `en_US_POSIX` supplies the same values today.
static let tripTimeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "h:mm a"
let formatter = makeAPIFormatter(dateFormat: "h:mm a")
formatter.amSymbol = "AM"
formatter.pmSymbol = "PM"
return formatter
}()

static let tripAPITimeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm"
return formatter
}()
/// Wire format for 24-hour OTP times. Not for display.
static let tripAPITimeFormatter: DateFormatter = makeAPIFormatter(dateFormat: "HH:mm")

}

extension Date {

/// Format date as "MM-dd-yyy"
/// The date as an OTP `date` query parameter (`MM-dd-yyyy`). Not for display.
var formattedTripDate: String {
return DateFormatter.tripDateFormatter.string(from: self)
}

/// Format time as "h:mm a"
/// The time as an OTP `time` query parameter (`h:mm a`). Not for display.
var formattedTripTime: String {
return DateFormatter.tripTimeFormatter.string(from: self)
}
Expand Down
25 changes: 25 additions & 0 deletions OTPKit/Sources/OTPKit/Core/Extensions/StringExtension.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//
// StringExtension.swift
// OTPKit
//

import Foundation

extension String {
/// Canonicalizes an OTP wire token for enum lookup: trims, uppercases, and treats
/// spaces as underscores so `"Cable Car"` and `"cable_car"` both match `CABLE_CAR`.
var normalizedOTPToken: String {
trimmingCharacters(in: .whitespaces)
.uppercased()
.replacingOccurrences(of: " ", with: "_")
}

/// 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.
/// The result is untranslated English by construction — it exists so a new server-side
/// token degrades to something readable rather than shouting a raw wire token.
var humanizedOTPToken: String {
replacingOccurrences(of: "_", with: " ").capitalized
}
}
22 changes: 8 additions & 14 deletions OTPKit/Sources/OTPKit/Core/Helper/Location/LocationManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ public class LocationManager: NSObject, ObservableObject {
// Fall back to street address
let streetComponents = [placemark.subThoroughfare, placemark.thoroughfare]
.compactMap { $0 }
return streetComponents.isEmpty ? "Unknown Location" : streetComponents.joined(separator: " ")
return streetComponents.isEmpty
? OTPLoc("location.unknown_place", comment: "Title for a place whose street address can't be resolved")
: streetComponents.joined(separator: " ")
}
}

Expand All @@ -112,7 +114,9 @@ public class LocationManager: NSObject, ObservableObject {
components.append(locality)
}

return components.isEmpty ? "No address available" : components.joined(separator: ", ")
return components.isEmpty
? OTPLoc("location.no_address", comment: "Shown when a place has no resolvable address")
: components.joined(separator: ", ")
}

// MARK: - Current Location
Expand All @@ -121,12 +125,7 @@ public class LocationManager: NSObject, ObservableObject {
public func getCurrentLocation() async -> Location? {
// Return cached location if available
if let location = currentLocation {
return Location(
title: "Current Location",
subTitle: "Your current GPS location",
latitude: location.coordinate.latitude,
longitude: location.coordinate.longitude
)
return Location.currentLocation(from: location.coordinate)
}

// Request location if not available
Expand All @@ -143,12 +142,7 @@ public class LocationManager: NSObject, ObservableObject {

guard let location = currentLocation else { return nil }

return Location(
title: "Current Location",
subTitle: "Your current GPS location",
latitude: location.coordinate.latitude,
longitude: location.coordinate.longitude
)
return Location.currentLocation(from: location.coordinate)
}
}

Expand Down
6 changes: 3 additions & 3 deletions OTPKit/Sources/OTPKit/Core/Models/OTP/Itinerary.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,11 @@ public struct Itinerary: Codable, Hashable {
}

public var summary: String {
// TODO: localize this!
let time = Formatters.formatDateToTime(startTime)
let formattedDuration = Formatters.formatTimeDuration(duration)
// return something like "43 minutes, departs at X:YY PM"
return "Departs at \(time); duration: \(formattedDuration)"
return OTPLoc("itinerary.summary",
comment: "Summary of an itinerary: departure time, then duration",
time, formattedDuration)
}

/// Calculates a bounding box for the coordinates represented by this Itinerary's `Leg`s.
Expand Down
89 changes: 89 additions & 0 deletions OTPKit/Sources/OTPKit/Core/Models/OTP/LegMode.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
//
// LegMode.swift
// OTPKit
//

import Foundation
import OSLog

/// The means of conveyance for a `Leg`, as reported by OTP.
///
/// OTP sends these as uppercase tokens (`CABLE_CAR`). Rendering the token directly leaks
/// English-shaped data into every locale, so callers should use ``displayName``.
public enum LegMode: String, CaseIterable, Sendable {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Make the new OTP model types Codable.

Both newly introduced model enums omit the repository’s required JSON serialization conformance. Add Codable to both declarations.

  • OTPKit/Sources/OTPKit/Core/Models/OTP/LegMode.swift#L13-L13: add Codable.
  • OTPKit/Sources/OTPKit/Core/Models/OTP/RelativeDirection.swift#L13-L13: add Codable.

As per coding guidelines, “All models must conform to Codable for JSON serialization.”

📍 Affects 2 files
  • OTPKit/Sources/OTPKit/Core/Models/OTP/LegMode.swift#L13-L13 (this comment)
  • OTPKit/Sources/OTPKit/Core/Models/OTP/RelativeDirection.swift#L13-L13
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@OTPKit/Sources/OTPKit/Core/Models/OTP/LegMode.swift` at line 13, Make both
model enums conform to Codable: update LegMode in
OTPKit/Sources/OTPKit/Core/Models/OTP/LegMode.swift at lines 13-13 and
RelativeDirection in
OTPKit/Sources/OTPKit/Core/Models/OTP/RelativeDirection.swift at lines 13-13,
preserving their existing conformances.

Source: Coding guidelines

case walk = "WALK"
case bicycle = "BICYCLE"
case car = "CAR"
case bus = "BUS"
case tram = "TRAM"
case subway = "SUBWAY"
case rail = "RAIL"
case ferry = "FERRY"
case cableCar = "CABLE_CAR"
case gondola = "GONDOLA"
case funicular = "FUNICULAR"
case transit = "TRANSIT"
case airplane = "AIRPLANE"
case trolleybus = "TROLLEYBUS"
case monorail = "MONORAIL"

/// OTP tokens that don't match a case's raw value but mean the same thing.
private static let aliases: [String: LegMode] = ["BIKE": .bicycle, "TRAIN": .rail]

/// Creates a mode from an OTP token, tolerating casing, spaces, and the `BIKE`/`TRAIN` aliases.
public init?(otpMode: String) {
let normalized = otpMode.normalizedOTPToken
guard let mode = LegMode(rawValue: normalized) ?? Self.aliases[normalized] else { return nil }
self = mode
}

/// Localized name of the mode.
///
/// The four modes that also exist as request-side ``TransportMode`` values reuse those
/// translations rather than maintaining a second copy of the same four words.
public var displayName: String {
switch self {
case .walk:
return TransportMode.walk.displayName
case .bicycle:
return TransportMode.bike.displayName
case .car:
return TransportMode.car.displayName
case .transit:
return TransportMode.transit.displayName
case .bus:
return OTPLoc("leg_mode.bus", comment: "Travel mode: bus")
case .tram:
return OTPLoc("leg_mode.tram", comment: "Travel mode: tram or streetcar")
case .subway:
return OTPLoc("leg_mode.subway", comment: "Travel mode: subway or metro")
case .rail:
return OTPLoc("leg_mode.rail", comment: "Travel mode: train")
case .ferry:
return OTPLoc("leg_mode.ferry", comment: "Travel mode: ferry")
case .cableCar:
return OTPLoc("leg_mode.cable_car", comment: "Travel mode: cable car")
case .gondola:
return OTPLoc("leg_mode.gondola", comment: "Travel mode: aerial gondola")
case .funicular:
return OTPLoc("leg_mode.funicular", comment: "Travel mode: funicular")
case .airplane:
return OTPLoc("leg_mode.airplane", comment: "Travel mode: airplane")
case .trolleybus:
return OTPLoc("leg_mode.trolleybus", comment: "Travel mode: trolleybus")
case .monorail:
return OTPLoc("leg_mode.monorail", comment: "Travel mode: monorail")
}
}
}

public extension Leg {
/// Localized name of this leg's mode, falling back to the raw OTP token when unrecognized.
var modeDisplayName: String {
guard let legMode = LegMode(otpMode: mode) else {
Logger.main.warning("Unrecognized OTP leg mode: \(mode)")
return mode.humanizedOTPToken
}
return legMode.displayName
}
}
12 changes: 12 additions & 0 deletions OTPKit/Sources/OTPKit/Core/Models/OTP/Location.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,18 @@ public struct Location: Identifiable, Codable, Equatable, Hashable {
self.longitude = longitude
}

/// Builds the "Current Location" entry shown wherever the user's own position is offered
/// as an origin or destination. Centralized so its localized title and subtitle stay in
/// sync across the location manager and the picker sheet.
public static func currentLocation(from coordinate: CLLocationCoordinate2D) -> Location {
Location(
title: OTPLoc("location_picker.current_location_title", comment: "Name given to the device's location"),
subTitle: OTPLoc("location_picker.gps_location_subtitle", comment: "Subtitle for the device's location"),
latitude: coordinate.latitude,
longitude: coordinate.longitude
)
}

public static func == (lhs: Location, rhs: Location) -> Bool {
return lhs.title == rhs.title &&
lhs.subTitle == rhs.subTitle &&
Expand Down
Loading
Loading