diff --git a/Sources/TripKit/model/API/RoutingAPIModel.swift b/Sources/TripKit/model/API/RoutingAPIModel.swift index 671c6e31a..0cd38dbe2 100644 --- a/Sources/TripKit/model/API/RoutingAPIModel.swift +++ b/Sources/TripKit/model/API/RoutingAPIModel.swift @@ -63,6 +63,7 @@ extension TKAPI { public var unsubscribeURL: URL? @UnknownNil public var availability: TripAvailability? + public var availabilityInfo: String? } public enum TripAvailability: String, Codable, Hashable { diff --git a/Sources/TripKit/model/CoreData/Trip+Data.swift b/Sources/TripKit/model/CoreData/Trip+Data.swift index 6d014e082..00378e837 100644 --- a/Sources/TripKit/model/CoreData/Trip+Data.swift +++ b/Sources/TripKit/model/CoreData/Trip+Data.swift @@ -11,6 +11,13 @@ import Foundation extension Trip: DataAttachable {} extension Trip { + /// Additional information when a trip is not available, e.g., due to missing the booking window or + /// it being cancelled. This is localised and meant to be user-facing. + public var availabilityInfo: String? { + get { decodePrimitive(String.self, key: "availabilityInfo") } + set { encodePrimitive(newValue, key: "availabilityInfo") } + } + public var bundleId: String? { get { decodePrimitive(String.self, key: "bundleId") } set { encodePrimitive(newValue, key: "bundleId") } diff --git a/Sources/TripKit/server/parsing/TKRoutingParser.swift b/Sources/TripKit/server/parsing/TKRoutingParser.swift index c4d38f1fd..dbb7e83f3 100644 --- a/Sources/TripKit/server/parsing/TKRoutingParser.swift +++ b/Sources/TripKit/server/parsing/TKRoutingParser.swift @@ -294,6 +294,7 @@ public final class TKRoutingParser { case .canceled: trip.isCanceled = true case .none: break } + trip.update(\.availabilityInfo, value: apiTrip.availabilityInfo) // updated trip isn't strictly speaking new, but we want to process it // as a successful match. diff --git a/Sources/TripKitUI/cards/TKUICardAction.swift b/Sources/TripKitUI/cards/TKUICardAction.swift index 5db6856bd..59e52933f 100644 --- a/Sources/TripKitUI/cards/TKUICardAction.swift +++ b/Sources/TripKitUI/cards/TKUICardAction.swift @@ -67,6 +67,7 @@ open class TKUICardAction: ObservableObject where Card: TGCard { /// - icon: Icon to display as the action. Should be a template image. /// - style: Style for the button. /// - priority: Priority of action to determine ordering in a list + /// - isEnabled: Set to `false` to show the button but disable it /// - handler: Handler executed when user taps on the button. Parameters are the action itself, the owning card, the model instance, and the sender. Should return whether the button should be refreshed, by calling the relevant "actions factory" again. public init( title: String, @@ -74,13 +75,15 @@ open class TKUICardAction: ObservableObject where Card: TGCard { icon: UIImage, style: TKUICardActionStyle = .normal, priority: Int = 0, + isEnabled: Bool = true, handler: @escaping @MainActor (TKUICardAction, Card, Model, UIView) -> Bool ) { self.content = .init( title: title, accessibilityLabel: accessibilityLabel, icon: icon, - style: style + style: style, + isEnabled: isEnabled ) self.handler = handler self.priority = priority @@ -96,6 +99,7 @@ open class TKUICardAction: ObservableObject where Card: TGCard { /// - icon: Provider of icon for the button. Should be a template image. /// - style: Provider of style for the button. /// - priority: Priority of action to determine ordering in a list + /// - isEnabled: Set to `false` to show the button but disable it /// - handler: Handler executed when user taps on the button. Parameters are the owning card, the model instance, and the sender. public convenience init( title: @escaping () -> String, @@ -103,6 +107,7 @@ open class TKUICardAction: ObservableObject where Card: TGCard { icon: @escaping () -> UIImage, style: (() -> TKUICardActionStyle)? = nil, priority: Int = 0, + isEnabled: Bool = true, handler: @escaping @MainActor (Card, Model, UIView) -> Void ) { self.init( @@ -110,7 +115,8 @@ open class TKUICardAction: ObservableObject where Card: TGCard { accessibilityLabel: accessibilityLabel?(), icon: icon(), style: style?() ?? .normal, - priority: priority + priority: priority, + isEnabled: isEnabled ) { action, card, model, view in handler(card, model, view) action.content = .init( @@ -155,6 +161,11 @@ open class TKUICardAction: ObservableObject where Card: TGCard { set { content.isInProgress = newValue } } + public var isEnabled: Bool { + get { content.isEnabled } + set { content.isEnabled = newValue } + } + /// Priority of the action to determine ordering in a list. Defaults to 0. /// /// If multiple actions have the same priority, then `.bold` style is @@ -171,12 +182,13 @@ open class TKUICardAction: ObservableObject where Card: TGCard { } public struct TKUICardActionContent { - public init(title: String, accessibilityLabel: String? = nil, icon: UIImage, style: TKUICardActionStyle = .normal, isInProgress: Bool = false) { + public init(title: String, accessibilityLabel: String? = nil, icon: UIImage, style: TKUICardActionStyle = .normal, isInProgress: Bool = false, isEnabled: Bool = true) { self.title = title self.accessibilityLabel = accessibilityLabel self.icon = icon self.style = style self.isInProgress = isInProgress + self.isEnabled = isEnabled } /// Title of the button @@ -191,4 +203,6 @@ public struct TKUICardActionContent { public var style: TKUICardActionStyle public var isInProgress: Bool + + public var isEnabled: Bool } diff --git a/Sources/TripKitUI/cards/TKUIRoutingResultsCard.swift b/Sources/TripKitUI/cards/TKUIRoutingResultsCard.swift index 26949b110..ec1d68ff1 100644 --- a/Sources/TripKitUI/cards/TKUIRoutingResultsCard.swift +++ b/Sources/TripKitUI/cards/TKUIRoutingResultsCard.swift @@ -501,6 +501,27 @@ extension TKUIRoutingResultsCard { } extension TKUIRoutingResultsCard: UITableViewDelegate { + + typealias FooterContent = TKUIResultsSectionFooterView.Content + + private func footer(for sectionIndex: Int) -> FooterContent? { + // progress cell does not need a footer + let items = dataSource.sectionModels[sectionIndex].items + guard let firstTrip = items.first?.trip else { + return nil + } + + let section = dataSource.sectionModels[sectionIndex] + var content = FooterContent(action: section.action) + if items.count == 1, let info = firstTrip.availabilityInfo { + content.cost = info + content.isWarning = true + } else if controller?.traitCollection.preferredContentSizeCategory.isAccessibilityCategory != true { + content.cost = TKUITripCell.Formatter.costString(costs: section.costs) + content.costAccessibility = TKUITripCell.Formatter.costAccessibilityLabel(costs: section.costs) + } + return content + } public func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { guard let footerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: TKUIResultsSectionFooterView.reuseIdentifier) as? TKUIResultsSectionFooterView else { @@ -508,54 +529,24 @@ extension TKUIRoutingResultsCard: UITableViewDelegate { return nil } - // progress cell does not need a footer - guard dataSource.sectionModels[section].items.first?.trip != nil else { - return nil - } - - let section = dataSource.sectionModels[section] - if controller?.traitCollection.preferredContentSizeCategory.isAccessibilityCategory == true { - footerView.cost = nil - footerView.costLabel.accessibilityLabel = nil - } else { - footerView.cost = TKUITripCell.Formatter.costString(costs: section.costs) - footerView.costLabel.accessibilityLabel = TKUITripCell.Formatter.costAccessibilityLabel(costs: section.costs) - } - - if let buttonContent = section.action { - footerView.button.isHidden = false - footerView.button.setTitle(buttonContent.title, for: .normal) - footerView.button.rx.tap - .subscribe(onNext: { [unowned tappedSectionButton] in - tappedSectionButton.onNext(buttonContent.payload) - }) - .disposed(by: footerView.disposeBag) - - } else { - footerView.button.isHidden = true + guard let content = footer(for: section) else { return nil } + footerView.configure(content) { [unowned tappedSectionButton] action in + tappedSectionButton.onNext(action.payload) } return footerView } public func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { - return getFooterHeight(from: section) + let content = footer(for: section) + return TKUIResultsSectionFooterView.height(for: content, maxWidth: tableView.frame.width) } public func tableView(_ tableView: UITableView, estimatedHeightForFooterInSection section: Int) -> CGFloat { - return getFooterHeight(from: section) + let content = footer(for: section) + return TKUIResultsSectionFooterView.height(for: content, maxWidth: tableView.frame.width) } - private func getFooterHeight(from section: Int) -> CGFloat { - if dataSource.sectionModels[section].items.first?.trip == nil { - return .leastNonzeroMagnitude - } else { - let sizingFooter = TKUIResultsSectionFooterView.forSizing - let size = sizingFooter.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) - return size.height - } - } - public func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { let section = dataSource.sectionModels[section] guard let content = section.badge?.footerContent else { diff --git a/Sources/TripKitUI/view model/TKUIRoutingResultsViewModel+Content.swift b/Sources/TripKitUI/view model/TKUIRoutingResultsViewModel+Content.swift index dd02f733b..3861bd02b 100644 --- a/Sources/TripKitUI/view model/TKUIRoutingResultsViewModel+Content.swift +++ b/Sources/TripKitUI/view model/TKUIRoutingResultsViewModel+Content.swift @@ -135,15 +135,16 @@ extension TKUIRoutingResultsViewModel { let action: SectionAction? if let primaryAction = TKUIRoutingResultsCard.config.tripGroupActionFactory?(group) { show = items - action = ( + action = .init( title: primaryAction.title, accessibilityLabel: primaryAction.accessibilityLabel, - payload: .trigger(primaryAction, group) + payload: .trigger(primaryAction, group), + isEnabled: primaryAction.isEnabled ) } else if items.count > 2, expand == group { show = items - action = (title: Loc.Less, accessibilityLabel: nil, payload: .collapse) + action = .init(title: Loc.Less, payload: .collapse) } else if items.count > 2 { let good = items @@ -154,7 +155,7 @@ extension TKUIRoutingResultsViewModel { } else { show = Array(good.prefix(2)) } - action = (title: Loc.More, accessibilityLabel: nil, payload: .expand(group)) + action = .init(title: Loc.More, payload: .expand(group)) } else { show = items @@ -246,7 +247,12 @@ extension TKUIRoutingResultsViewModel { case trigger(TKUIRoutingResultsCard.TripGroupAction, TripGroup) } - typealias SectionAction = (title: String, accessibilityLabel: String?, payload: ActionPayload) + struct SectionAction { + var title: String + var accessibilityLabel: String? = nil + var payload: ActionPayload + var isEnabled: Bool = true + } /// A section on the results screen, which consists of various sorted items struct Section { diff --git a/Sources/TripKitUI/views/results/TKUIResultsAccessoryView.swift b/Sources/TripKitUI/views/results/TKUIResultsAccessoryView.swift index b51ce481a..e199ccea9 100644 --- a/Sources/TripKitUI/views/results/TKUIResultsAccessoryView.swift +++ b/Sources/TripKitUI/views/results/TKUIResultsAccessoryView.swift @@ -113,11 +113,12 @@ class TKUIResultsAccessoryView: UIView { override func layoutSubviews() { // We switch dynamically to vertical layout if the natural size doesn't - // fit horizontally OR if the time button is taller than wide. + // fit horizontally OR if the either button is taller than wide. let timeSize = timeButton.intrinsicContentSize let transportSize = transportButton.intrinsicContentSize let fits = timeSize.width + transportSize.width + 32 < frame.width && timeSize.height < timeSize.width * 1.1 + && transportSize.height < transportSize.width * 1.1 stackView.axis = fits ? .horizontal : .vertical super.layoutSubviews() diff --git a/Sources/TripKitUI/views/results/TKUIResultsSectionFooterView.swift b/Sources/TripKitUI/views/results/TKUIResultsSectionFooterView.swift index 93c845566..b300e974d 100644 --- a/Sources/TripKitUI/views/results/TKUIResultsSectionFooterView.swift +++ b/Sources/TripKitUI/views/results/TKUIResultsSectionFooterView.swift @@ -14,16 +14,12 @@ import TripKit class TKUIResultsSectionFooterView: UITableViewHeaderFooterView { - static let forSizing: TKUIResultsSectionFooterView = { - let footer = TKUIResultsSectionFooterView() - footer.costLabel.text = "Size me" - return footer - }() - static let reuseIdentifier = "TKUIResultsSectionFooterView" + @IBOutlet weak var imageView: UIImageView! @IBOutlet weak var costLabel: UILabel! @IBOutlet weak var button: UIButton! + @IBOutlet weak var leadingToTextConstraint: NSLayoutConstraint! var disposeBag = DisposeBag() @@ -45,6 +41,12 @@ class TKUIResultsSectionFooterView: UITableViewHeaderFooterView { private func didInit() { contentView.backgroundColor = .tkBackground + let imageView = UIImageView() + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.isHidden = true + self.imageView = imageView + contentView.addSubview(imageView) + let costLabel = UILabel() costLabel.contentMode = .left costLabel.numberOfLines = 0 @@ -67,20 +69,25 @@ class TKUIResultsSectionFooterView: UITableViewHeaderFooterView { button.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) self.button = button contentView.addSubview(button) - + let topMarginConstraint = costLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 6) topMarginConstraint.priority = UILayoutPriority(rawValue: 999) - + let bottomMarginConstraint = contentView.bottomAnchor.constraint(equalTo: costLabel.bottomAnchor, constant: 6) bottomMarginConstraint.priority = UILayoutPriority(rawValue: 999) + leadingToTextConstraint = costLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16) + NSLayoutConstraint.activate([ - costLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + leadingToTextConstraint, button.leadingAnchor.constraint(equalTo: costLabel.trailingAnchor, constant: 16), contentView.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: 16), + imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + topMarginConstraint, costLabel.centerYAnchor.constraint(equalTo: button.centerYAnchor), + costLabel.centerYAnchor.constraint(equalTo: imageView.centerYAnchor), bottomMarginConstraint ]) } @@ -91,10 +98,83 @@ class TKUIResultsSectionFooterView: UITableViewHeaderFooterView { costLabel.text = nil disposeBag = DisposeBag() } +} - var cost: String? { - get { costLabel.text } - set { costLabel.text = newValue } +// MARK: - Content + +extension TKUIResultsSectionFooterView { + + typealias Action = TKUIRoutingResultsViewModel.SectionAction + + struct Content { + var image: UIImage? = nil + var cost: String? = nil + var costAccessibility: String? = nil + var action: Action? = nil + var isWarning: Bool = false + } + + func configure(_ content: Content, actionHandler: @escaping (Action) -> Void) { + costLabel.accessibilityLabel = content.costAccessibility ?? content.cost + costLabel.text = content.cost + + if content.isWarning { + imageView.isHidden = false + imageView.image = .iconAlert + leadingToTextConstraint.constant = 16 + 8 + 20 + costLabel.font = TKStyleManager.boldCustomFont(forTextStyle: .footnote) + costLabel.textColor = .tkStateError + } else { + imageView.isHidden = true + imageView.image = nil + leadingToTextConstraint.constant = 16 + costLabel.font = TKStyleManager.customFont(forTextStyle: .footnote) + costLabel.textColor = .tkLabelSecondary + } + + if let buttonContent = content.action { + button.isHidden = false + button.isEnabled = buttonContent.isEnabled + button.setTitle(buttonContent.title, for: .normal) + button.rx.tap + .subscribe(onNext: { + actionHandler(buttonContent) + }) + .disposed(by: disposeBag) + } else { + button.isHidden = true + } + } + +} + +// MARK: Sizing + +extension TKUIResultsSectionFooterView { + + private static let forSizing: TKUIResultsSectionFooterView = { + return TKUIResultsSectionFooterView() + }() + + static func height(for content: Content?, maxWidth: CGFloat) -> CGFloat { + guard let content = content else { return .leastNonzeroMagnitude } + + let sizingFooter = TKUIResultsSectionFooterView.forSizing + sizingFooter.leadingToTextConstraint.constant = content.isWarning ? 16 + 8 + 20 : 16 + + sizingFooter.costLabel.text = content.cost + sizingFooter.costLabel.font = content.isWarning ? TKStyleManager.boldCustomFont(forTextStyle: .footnote) : TKStyleManager.customFont(forTextStyle: .footnote) + + if let action = content.action { + sizingFooter.button.isHidden = false + sizingFooter.button.setTitle(action.title, for: .normal) + } else { + sizingFooter.button.isHidden = true + } + + sizingFooter.contentView.widthAnchor.constraint(lessThanOrEqualToConstant: maxWidth).isActive = true + let size = sizingFooter.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + return size.height } }