Skip to content

Commit

Permalink
List and search routes (#204)
Browse files Browse the repository at this point in the history
* List and search routes

- Adds `TKRouteAutocompleter` to search for routes
- Adds `TKBuzzInfoProvider.fetchRoutes` to fetch a list of routes

Related refactoring to allow `TKAutocompletion` to return something
that can't be turned into an `MKAnnotation`.

Updated TripKitUIExample to use the new `TKRouteAutocompleter`.

* Add routes, availableRoutes and operators to TKStopCoordinate
  • Loading branch information
nighthawk authored Oct 28, 2022
1 parent 1f83c2c commit 5b3b7c7
Show file tree
Hide file tree
Showing 35 changed files with 402 additions and 110 deletions.
2 changes: 1 addition & 1 deletion Examples/TripKitUIExample/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {


func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
TripKit.apiKey = ProcessInfo.processInfo.environment["TRIPGO_API_KEY"] ?? "MY_API_KEY"
TripKit.apiKey = ProcessInfo.processInfo.environment["TRIPGO_API_KEY"] ?? preconditionFailure("Either add your TripGo API Key as an environment variable named `TRIPGO_API_KEY` or add it here, replacing this line.") as! String
TripKit.prepareForNewSession()

return true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ extension InMemoryFavoriteManager: TKAutocompleting {
completion(.success(results))
}

func annotation(for result: TKAutocompletionResult, completion: @escaping (Result<MKAnnotation, Error>) -> Void) {
func annotation(for result: TKAutocompletionResult, completion: @escaping (Result<MKAnnotation?, Error>) -> Void) {
guard let favorite = result.object as? Favorite else {
preconditionFailure()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ extension InMemoryHistoryManager: TKAutocompleting {
completion(.success(results))
}

func annotation(for result: TKAutocompletionResult, completion: @escaping (Result<MKAnnotation, Error>) -> Void) {
func annotation(for result: TKAutocompletionResult, completion: @escaping (Result<MKAnnotation?, Error>) -> Void) {
guard let history = result.object as? History else {
preconditionFailure()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ extension InMemoryHistoryManager: TKUIHomeComponentViewModel {

var nextAction: Signal<TKUIHomeCard.ComponentAction> {
selection.map {
.handleSelection($0.annotation, component: self)
.handleSelection(.annotation($0.annotation), component: self)
}
}

Expand Down
2 changes: 2 additions & 0 deletions Examples/TripKitUIExample/TripKitUIExample.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,7 @@
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.personal-information.location</key>
<true/>
</dict>
</plist>
24 changes: 16 additions & 8 deletions Examples/TripKitUIExample/ViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ extension MainViewController {
return
}

let resultsController = TKUIAutocompletionViewController(providers: [TKTripGoGeocoder()])
let resultsController = TKUIAutocompletionViewController(providers: [TKTripGoGeocoder(), TKRouteAutocompleter()])
resultsController.biasMapRect = randomCity.centerBiasedMapRect

resultsController.delegate = self
Expand All @@ -99,25 +99,33 @@ extension MainViewController {

extension MainViewController: TKUIAutocompletionViewControllerDelegate {

func autocompleter(_ controller: TKUIAutocompletionViewController, didSelect annotation: MKAnnotation) {
if let stop = annotation as? TKUIStopAnnotation {
func autocompleter(_ controller: TKUIAutocompletionViewController, didSelect selection: TKAutocompletionSelection) {
switch selection {
case let .annotation(stop as TKUIStopAnnotation):
dismiss(animated: true) {
self.showDepartures(stop: stop)
}

} else {
case let .annotation(annotation):
print("Selected \(annotation)")

case let .result(result):
print("Selected \(result.object)")
}
}

func autocompleter(_ controller: TKUIAutocompletionViewController, didSelectAccessoryFor annotation: MKAnnotation) {
if let stop = annotation as? TKUIStopAnnotation {
func autocompleter(_ controller: TKUIAutocompletionViewController, didSelectAccessoryFor selection: TKAutocompletionSelection) {
switch selection {
case let .annotation(stop as TKUIStopAnnotation):
dismiss(animated: true) {
self.showDepartures(stop: stop)
}

case let .annotation(annotation):
print("Selected \(annotation)")

} else {
print("Selected accessor for \(annotation)")
case let .result(result):
print("Selected \(result.object)")
}
}

Expand Down
5 changes: 2 additions & 3 deletions Sources/TripKit/helpers/TKJSONSanitizer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,15 @@

import Foundation

/// :nodoc:
public enum TKJSONSanitizer {
enum TKJSONSanitizer {

/// Sanitizes the provided input to be JSON compatible03
///
/// Primarily used for handling URLs, UIColors and TimeZones
///
/// - Parameter input: Any object that should be made JSON compatible
/// - Returns: JSON compatible version or `nil` if not possible
public static func sanitize(_ input: Any) -> Any? {
static func sanitize(_ input: Any) -> Any? {
switch input {

case is String, is Int, is Double, is Float, is Bool:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ extension TKCalendarManager: TKAutocompleting {
completion(.success(results))
}

public func annotation(for result: TKAutocompletionResult, completion: @escaping (Result<MKAnnotation, Error>) -> Void) {
public func annotation(for result: TKAutocompletionResult, completion: @escaping (Result<MKAnnotation?, Error>) -> Void) {

guard let event = result.object as? EKEvent else {
assertionFailure("Unexpected object: \(result.object).")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ extension TKContactsManager: TKAutocompleting {
}
}

public func annotation(for result: TKAutocompletionResult, completion: @escaping (Result<MKAnnotation, Error>) -> Void) {
public func annotation(for result: TKAutocompletionResult, completion: @escaping (Result<MKAnnotation?, Error>) -> Void) {
guard let contact = result.object as? TKContactsManager.ContactAddress else {
preconditionFailure("Unexpected object. We require `result.object` to be `TKContactsManager.ContactAddress`, but got: \(result.object)")
}
Expand Down
11 changes: 1 addition & 10 deletions Sources/TripKit/model/API/AlertAPIModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,16 +67,7 @@ extension TKAPI {
public let routes: [TKAPI.Route]?
public let modeInfo: TKModeInfo?
}

public struct Route: Codable, Hashable {
public let id: String
public let name: String?
public let number: String?
public let modeInfo: TKModeInfo

/// This color applies to an individual service.
public var color: TKColor? { return modeInfo.color }
}


}

Expand Down
49 changes: 49 additions & 0 deletions Sources/TripKit/model/API/RouteAPIModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
//
// RouteAPIModel.swift
// TripKit
//
// Created by Adrian Schönig on 27/10/2022.
// Copyright © 2022 SkedGo Pty Ltd. All rights reserved.
//

import Foundation

extension TKAPI {

public struct Route: Codable, Hashable {
public let id: String

public let routeName: String?
public let routeDescription: String?
public let shortName: String?
private let _routeColor: RGBColor?

public let operatorID: String?
public let operatorName: String?

public let modeInfo: TKModeInfo

@available(*, deprecated, renamed: "shortName")
public var number: String? { shortName }

@available(*, deprecated, renamed: "routeName")
public var name: String? { routeName }

public var routeColor: TKColor? { _routeColor?.color }

/// This color applies to an individual service.
public var color: TKColor? { return routeColor ?? modeInfo.color }

enum CodingKeys: String, CodingKey {
case id
case routeName
case routeDescription
case shortName
case modeInfo
case _routeColor = "routeColor"
case operatorID = "operatorId"
case operatorName
}
}

}
16 changes: 15 additions & 1 deletion Sources/TripKit/model/API/StopAPIModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ extension TKAPI {
case modeInfo
case alertHashCodes
case zoneID
case availableRoutes
case operators
case routes
}

public let code: String
Expand All @@ -40,13 +43,24 @@ extension TKAPI {
public let services: String?
public let popularity: Int?
public let zoneID: String?

public let availableRoutes: Int?

public let wheelchairAccessible: Bool?

@DefaultEmptyArray public var children: [Stop]
public let modeInfo: TKModeInfo

public let operators: [Operator]?

public let routes: [Route]?

@DefaultEmptyArray public var alertHashCodes: [Int]

}

public struct Operator: Codable, Hashable {
public let id: String?
public let name: String?
}

}
76 changes: 72 additions & 4 deletions Sources/TripKit/model/TKCoordinates.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ public class TKStopCoordinate: TKModeCoordinate {
case services
case shortName
case popularity
case availableRoutes
case routes
case operators
}

@objc public class override var supportsSecureCoding: Bool { return true }
Expand All @@ -95,6 +98,9 @@ public class TKStopCoordinate: TKModeCoordinate {
services = stop.services
stopShortName = stop.shortName
stopSortScore = stop.popularity
availableRoutes = stop.availableRoutes
routes = stop.routes
operators = stop.operators
}

init(_ stop: TKAPI.ShapeStop, modeInfo: TKModeInfo) {
Expand All @@ -109,12 +115,15 @@ public class TKStopCoordinate: TKModeCoordinate {
isDraggable = false

guard let values = try? decoder.container(keyedBy: CodingKeys.self) else { return }
if data["sg_services"] == nil {
if data["sg_stopCode"] == nil {
// From the API these comes in the decoder rather than in the "data" field
stopCode = try values.decode(String.self, forKey: .stopCode)
services = try? values.decode(String.self, forKey: .services)
stopShortName = try? values.decode(String.self, forKey: .shortName)
stopSortScore = try? values.decode(Int.self, forKey: .popularity)
services = try values.decodeIfPresent(String.self, forKey: .services)
stopShortName = try values.decodeIfPresent(String.self, forKey: .shortName)
stopSortScore = try values.decodeIfPresent(Int.self, forKey: .popularity)
availableRoutes = try values.decodeIfPresent(Int.self, forKey: .availableRoutes)
routes = try values.decodeIfPresent([TKAPI.Route].self, forKey: .routes)
operators = try values.decodeIfPresent([TKAPI.Operator].self, forKey: .operators)
}
}

Expand Down Expand Up @@ -151,4 +160,63 @@ public class TKStopCoordinate: TKModeCoordinate {
set { data["sg_stopSortScore"] = newValue }
}

var availableRoutes: Int? {
get { return data["sg_availableRoutes"] as? Int }
set { data["sg_availableRoutes"] = newValue }
}

private var _routes: [TKAPI.Route]?? = nil
public var routes: [TKAPI.Route]? {
get {
if let decoded = _routes {
return decoded
} else if let json = data["sg_routes"] as Any? {
if let sanitized = TKJSONSanitizer.sanitize(json), let decoded = try? JSONDecoder().decode([TKAPI.Route].self, withJSONObject: sanitized) {
_routes = decoded
return decoded
} else {
_routes = nil
return nil
}
} else {
_routes = nil
return nil
}
}

set {
_routes = newValue
if let newValue {
data["sg_routes"] = try? JSONEncoder().encodeJSONObject(newValue)
}
}
}

private var _operators: [TKAPI.Operator]?? = nil
public var operators: [TKAPI.Operator]? {
get {
if let decoded = _operators {
return decoded
} else if let json = data["sg_operators"] as Any? {
if let sanitized = TKJSONSanitizer.sanitize(json), let decoded = try? JSONDecoder().decode([TKAPI.Operator].self, withJSONObject: sanitized) {
_operators = decoded
return decoded
} else {
_operators = nil
return nil
}
} else {
_operators = nil
return nil
}
}

set {
_operators = newValue
if let newValue {
data["sg_operators"] = try? JSONEncoder().encodeJSONObject(newValue)
}
}
}

}
2 changes: 1 addition & 1 deletion Sources/TripKit/search/TKAppleGeocoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ extension TKAppleGeocoder: TKAutocompleting {
}
}

public func annotation(for result: TKAutocompletionResult, completion: @escaping (Result<MKAnnotation, Error>) -> Void) {
public func annotation(for result: TKAutocompletionResult, completion: @escaping (Result<MKAnnotation?, Error>) -> Void) {
guard let searchCompletion = result.object as? MKLocalSearchCompletion else {
completion(.failure(GeocoderError.unexpectedResult))
return
Expand Down
1 change: 1 addition & 0 deletions Sources/TripKit/search/TKGeocoderHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ public class TKGeocoderHelper: NSObject {
case missingAddress
case serverFoundNoMatch(String)
case unknownServerError(String)
case outdatedResult
}

@objc(errorForNoLocationFoundForInput:)
Expand Down
12 changes: 8 additions & 4 deletions Sources/TripKit/search/TKGeocoding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,14 @@ public protocol TKAutocompleting {
/// - Parameters:
/// - input: Query fragment typed by user
/// - mapRect: Last map rect the map view was zoomed to (can be `MKMapRectNull`)
/// - Returns: Autocompletion results for query fragment. Should fire with empty result or error out if nothing found. Needs to complete.
/// - completion: Handled called with the autocompletion results for query fragment. Should fire with empty result or error out if nothing found. Needs to be called.
func autocomplete(_ input: String, near mapRect: MKMapRect, completion: @escaping (Result<[TKAutocompletionResult], Error>) -> Void)

/// Called to fetch the annotation for a previously returned autocompletion result
///
/// - Parameter result: The result for which to fetch the annotation
/// - Returns: Single-observable with the annotation for the result. Can error out if an unknown
/// result was passed in.
func annotation(for result: TKAutocompletionResult, completion: @escaping (Result<MKAnnotation, Error>) -> Void)
/// - Parameter completion: Completion handler that's called with the annotation for the result, if representable as such. Called with `nil` if not representable, or can also be called with an error if it is representable but the conversion failed. Needs to be called.
func annotation(for result: TKAutocompletionResult, completion: @escaping (Result<MKAnnotation?, Error>) -> Void)

#if os(iOS) || os(tvOS)
/// Text and action for an additional row to display in the results, e.g., to request
Expand All @@ -67,6 +66,11 @@ public protocol TKAutocompleting {

}

public enum TKAutocompletionSelection {
case annotation(MKAnnotation)
case result(TKAutocompletionResult)
}

extension TKAutocompleting {

#if os(iOS) || os(tvOS)
Expand Down
2 changes: 1 addition & 1 deletion Sources/TripKit/search/TKPeliasGeocoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ extension TKPeliasGeocoder: TKAutocompleting {
}
}

public func annotation(for result: TKAutocompletionResult, completion: @escaping (Result<MKAnnotation, Error>) -> Void) {
public func annotation(for result: TKAutocompletionResult, completion: @escaping (Result<MKAnnotation?, Error>) -> Void) {
guard let coordinate = result.object as? TKNamedCoordinate else { preconditionFailure() }
completion(.success(coordinate))
}
Expand Down
Loading

0 comments on commit 5b3b7c7

Please sign in to comment.