Sources/UberRides/RideRequestButton.swift (202 lines of code) (raw):

// // RideRequestButton.swift // UberRides // // Copyright © 2015 Uber Technologies, Inc. All rights reserved. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. import UIKit import CoreLocation import UberCore /// A protocol used to response to Uber RideRequestButton events public protocol RideRequestButtonDelegate: AnyObject { /** The button finished loading ride information successfully. - parameter button: the RideRequestButton */ func rideRequestButtonDidLoadRideInformation(_ button: RideRequestButton) /** The button encountered an error when refreshing its metadata content. - parameter button: the RideRequestButton - parameter error: the error that it encountered */ func rideRequestButton(_ button: RideRequestButton, didReceiveError error: UberError) } public class RideRequestButton: UberButton { // MARK: Public Properties /// Delegate is informed of events that occur with request button. public weak var delegate: RideRequestButtonDelegate? /// The RideParameters object this button will use to make a request public var rideParameters: RideParameters /// The RideRequesting object the button will use to make a request public var requestBehavior: RideRequesting /// The RidesClient used for retrieving metadata for the button. public var client: RidesClient? // MARK: Internal Properties static let sourceString = "button" var metadata = ButtonMetadata() // MARK: Private Properties private var _title: NSAttributedString? = .init(string: "Ride there with Uber") private var _subtitle: NSAttributedString? = nil private lazy var _image: UIImage? = image(name: "Badge") private let opticalCorrection: CGFloat = 1.0 // MARK: Initializers public init(client: RidesClient = RidesClient(), rideParameters: RideParameters = RideParametersBuilder().build(), requestBehavior: RideRequesting = DeeplinkRequestingBehavior()) { self.client = client self.rideParameters = rideParameters self.requestBehavior = requestBehavior super.init(frame: CGRect.zero) configure() } required public init?(coder: NSCoder) { self.client = RidesClient() self.rideParameters = RideParametersBuilder().build() self.requestBehavior = DeeplinkRequestingBehavior() super.init(coder: coder) configure() } // MARK: Public Methods public func loadRideInformation() { guard client != nil else { delegate?.rideRequestButton(self, didReceiveError: createValidationFailedError()) return } metadata.productID = rideParameters.productID metadata.pickupLatitude = rideParameters.pickupLocation?.coordinate.latitude metadata.pickupLongitude = rideParameters.pickupLocation?.coordinate.longitude metadata.dropoffLatitude = rideParameters.dropoffLocation?.coordinate.latitude metadata.dropoffLongitude = rideParameters.dropoffLocation?.coordinate.longitude setMetadata() } // MARK: UberButton public override var title: NSAttributedString? { _title } public override var subtitle: NSAttributedString? { _subtitle } public override var image: UIImage? { _image } public override var horizontalAlignment: UIControl.ContentHorizontalAlignment { .leading } // MARK: Private private func configure() { addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) } @objc func buttonTapped(_ sender: UIButton) { rideParameters.source = RideRequestButton.sourceString requestBehavior.requestRide(parameters: rideParameters) } private func createValidationFailedError() -> UberError { return UberError(status: 422, code: "validation_failed", title: "Invalid Request") } private func setMetadata() { guard let client = client, let pickupLatitude = metadata.pickupLatitude, let pickupLongitude = metadata.pickupLongitude, let productID = metadata.productID else { delegate?.rideRequestButton(self, didReceiveError: createValidationFailedError()) return } let downloadGroup = DispatchGroup() downloadGroup.enter() var errors = [UberError]() let pickupLocation = CLLocation(latitude: pickupLatitude, longitude: pickupLongitude) // Set the information on the button label once all information is retrieved. downloadGroup.notify(queue: DispatchQueue.main, execute: { var titleText = "" var subtitleText = "" if let timeEstimate = self.metadata.timeEstimate?.estimate { let mins = timeEstimate / 60 if mins == 1 { titleText = String(format: NSLocalizedString("%d min away", bundle: Bundle(for: type(of: self)), comment: "Estimate is for car one minute away"), mins).uppercased(with: Locale.current) } else { titleText = String(format: NSLocalizedString("%d mins away", bundle: Bundle(for: type(of: self)), comment: "Estimate is for car multiple minutes away"), mins).uppercased(with: Locale.current) } } var surge = false for estimate in self.metadata.priceEstimates { if let price = estimate.estimate, let productName = estimate.name, estimate.productID == productID { if let surgeMultiplier = estimate.surgeMultiplier, surgeMultiplier > 1.0 { surge = true } let priceEstimateString = String(format: NSLocalizedString("%1$@ for %2$@", bundle: Bundle(for: type(of: self)), comment: "Price estimate string for an Uber product"), price, productName) if titleText.isEmpty { titleText = priceEstimateString } else { subtitleText = priceEstimateString } break } } if !titleText.isEmpty { self.setMultilineAttributedString(title: titleText, subtitle: subtitleText, surge: surge) } for error in errors { self.delegate?.rideRequestButton(self, didReceiveError: error) } self.delegate?.rideRequestButtonDidLoadRideInformation(self) }) // Get time estimate for productID let timeEstimatesCompletion: ([TimeEstimate], Response) -> () = { timeEstimates, response in if let error = response.error { errors.append(error) downloadGroup.leave() return } self.metadata.timeEstimate = timeEstimates.first self.metadata.productName = timeEstimates.first?.name downloadGroup.leave() } // If dropoff location was set, get price estimates. if let dropoffLatitude = metadata.dropoffLatitude, let dropoffLongitude = metadata.dropoffLongitude { downloadGroup.enter() let dropoffLocation = CLLocation(latitude: dropoffLatitude, longitude: dropoffLongitude) let priceEstimatesCompletion: ([PriceEstimate], Response) -> () = {priceEstimates, response in if let error = response.error { errors.append(error) downloadGroup.leave() return } self.metadata.priceEstimates = priceEstimates downloadGroup.leave() } client.fetchPriceEstimates(pickupLocation: pickupLocation, dropoffLocation: dropoffLocation, completion:priceEstimatesCompletion ) } client.fetchTimeEstimates(pickupLocation: pickupLocation, productID:productID, completion: timeEstimatesCompletion) } /** Helper function that sets appropriate attributes on multi-line label. - parameter title: The main title of the label. (ex. "3 MINS AWAY" or "Get a Ride") - parameter subtitle: The subtitle of the label. (ex. "$6-8 for uberX") - parameter surge: Whether the price estimate should include a surge image. Default false. */ private func setMultilineAttributedString(title: String, subtitle: String = "", surge: Bool = false) { let metadataFont = UIFont(name: "HelveticaNeue-Regular", size: 12) ?? UIFont.systemFont(ofSize: 12) let attrString = NSMutableAttributedString(string: title) // If there is a price estimate to include, add a new line if !subtitle.isEmpty { attrString.append(NSAttributedString(string: "\n")) // If the price estimate is higher due to a surge, add the surge icon if surge == true { let attachment = getSurgeAttachment() // Adjust bounds to center the text attachment attachment.bounds = CGRect(x: 0, y: metadataFont.descender-opticalCorrection, width: attachment.image?.size.width ?? 0, height: attachment.image!.size.height) let surgeImage = NSAttributedString(attachment: attachment) attrString.append(surgeImage) attrString.append(NSAttributedString(string: " ")) // Adding the text attachment increases the space between lines so set the max line height let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.alignment = .right paragraphStyle.maximumLineHeight = 16 attrString.addAttribute(NSAttributedString.Key.paragraphStyle, value:paragraphStyle, range:NSMakeRange(0, attrString.length)) } attrString.append(NSAttributedString(string: "\(subtitle)")) } attrString.addAttribute(NSAttributedString.Key.font, value: metadataFont, range: (attrString.string as NSString).range(of: title)) attrString.addAttribute(NSAttributedString.Key.font, value: metadataFont, range: (attrString.string as NSString).range(of: subtitle)) if attrString.string.isEmpty { _title = NSAttributedString( string: NSLocalizedString("Ride there with Uber", bundle: Bundle(for: type(of: self)), comment: "Request button description") ) } else { _title = NSAttributedString( string: NSLocalizedString("Get a ride", bundle: Bundle(for: type(of: self)), comment: "Request button shorter description") ) } _subtitle = attrString update() } private func getSurgeAttachment() -> NSTextAttachment { let attachment = NSTextAttachment() attachment.image = image(name: "Surge-WhiteOutline") return attachment } private func image(name: String) -> UIImage? { UIImage(named: name, in: .resource(for: RideRequestButton.self), compatibleWith: nil) } } /** * Stores information about current product and its metadata as the information is retrieved. */ struct ButtonMetadata { var productID: String? var productName: String? var pickupLatitude: Double? var pickupLongitude: Double? var dropoffLatitude: Double? var dropoffLongitude: Double? var timeEstimate: TimeEstimate? private var priceEstimateList: [PriceEstimate]? var priceEstimates: [PriceEstimate] { get { return priceEstimateList ?? [] } set { priceEstimateList = newValue } } }