From 44abd67627d75437d6b2fdce6078d8fcc928b96b Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Thu, 14 Dec 2023 17:37:59 -0800 Subject: [PATCH] Always show TransitAlertDetailViewController when tapping on a transit alert Fixes https://github.com/OneBusAway/onebusaway-ios/issues/700 We now show the full content of the transit alert's body in a popup view controller, and optionally include a "Learn More" button at the bottom of it if the transit alert includes a URL. --- .../TransitAlertDetailViewController.swift | 75 ++++++++++++--- OBAKit/ViewRouting/Router.swift | 10 +- OBAKit/WebView/DocumentWebView.swift | 84 +++++++---------- OBAKit/WebView/document_web_view_content.html | 91 +++++++++++++++++++ OBAKitCore/Extensions/UIKitExtensions.swift | 79 +++++++++++++--- 5 files changed, 258 insertions(+), 81 deletions(-) create mode 100644 OBAKit/WebView/document_web_view_content.html diff --git a/OBAKit/Alerts/TransitAlertDetailViewController.swift b/OBAKit/Alerts/TransitAlertDetailViewController.swift index 8c8ae3b9a..d4d0ad389 100644 --- a/OBAKit/Alerts/TransitAlertDetailViewController.swift +++ b/OBAKit/Alerts/TransitAlertDetailViewController.swift @@ -7,13 +7,18 @@ import OBAKitCore import UIKit +import WebKit +import SafariServices -class TransitAlertDetailViewController: UIViewController { - private let transitAlert: TransitAlertViewModel - private let webView = DocumentWebView() +/// Renders a full page version of a `TransitAlertViewModel` +/// +/// This includes an optional "Learn More" button at the bottom of the page if the transit alert has a value for `url(forLocale:)`. +class TransitAlertDetailViewController: UIViewController, WKScriptMessageHandler { + private let locale: Locale - init(_ transitAlert: TransitAlertViewModel) { + init(_ transitAlert: TransitAlertViewModel, locale: Locale = .current) { self.transitAlert = transitAlert + self.locale = locale super.init(nibName: nil, bundle: nil) self.title = Strings.serviceAlert @@ -22,19 +27,20 @@ class TransitAlertDetailViewController: UIViewController { } override func viewDidLoad() { - webView.frame = view.bounds - webView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + navigationItem.rightBarButtonItem = UIBarButtonItem(customView: closeButton) + view.addSubview(webView) - let title = transitAlert.title(forLocale: .current) ?? Strings.serviceAlert - let body = transitAlert.body(forLocale: .current) ?? OBALoc("transit_alert.no_additional_details.body", value: "No additional details available.", comment: "A notice when a transit alert doesn't have body text.") + let title = transitAlert.title(forLocale: locale) ?? Strings.serviceAlert + var body = transitAlert.body(forLocale: locale) ?? OBALoc("transit_alert.no_additional_details.body", value: "No additional details available.", comment: "A notice when a transit alert doesn't have body text.") + body = body.replacingOccurrences(of: "\n", with: "
") let html = """ -

\(title)

-

\(body)

+

\(title)

+

\(body)

""" - webView.setPageContent(html) + webView.setPageContent(html, actionButtonTitle: destinationURL != nil ? Strings.learnMore : nil) } override func viewWillAppear(_ animated: Bool) { @@ -43,6 +49,53 @@ class TransitAlertDetailViewController: UIViewController { self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: Strings.close, style: .done, target: self, action: #selector(close)) } + // MARK: - Transit Alert + + private let transitAlert: TransitAlertViewModel + + private var destinationURL: URL? { + transitAlert.url(forLocale: locale) + } + + // MARK: - Web View + + private lazy var webView: DocumentWebView = { + let configuration = WKWebViewConfiguration() + let userContentController = WKUserContentController() + userContentController.add(self, name: DocumentWebView.actionButtonHandlerName) + configuration.userContentController = userContentController + + let view = DocumentWebView(frame: view.bounds, configuration: configuration) + view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + + if #available(iOS 16.4, *) { + view.isInspectable = true + } + + return view + }() + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard + message.name == DocumentWebView.actionButtonHandlerName, + let destinationURL + else { + return + } + + let safari = SFSafariViewController(url: destinationURL) + present(safari, animated: true) + } + + // MARK: - Close + + private lazy var closeButton: UIButton = { + let btn = UIButton.buildCloseButton() + btn.addTarget(self, action: #selector(close), for: .touchUpInside) + + return btn + }() + @objc func close() { if let navController = self.navigationController, navController.topViewController != self { navController.popViewController(animated: true) diff --git a/OBAKit/ViewRouting/Router.swift b/OBAKit/ViewRouting/Router.swift index 70bfc4ea3..9d3552bd9 100644 --- a/OBAKit/ViewRouting/Router.swift +++ b/OBAKit/ViewRouting/Router.swift @@ -107,13 +107,9 @@ public class ViewRouter: NSObject, UINavigationControllerDelegate { public func navigateTo(alert: TransitAlertViewModel, locale: Locale = .current, from fromController: UIViewController) { guard shouldNavigate(from: fromController, to: .transitAlert(alert)) else { return } - if let url = alert.url(forLocale: locale) { - let safari = SFSafariViewController(url: url) - present(safari, from: fromController, isModal: true) - } else { - let view = TransitAlertDetailViewController(alert) - present(view, from: fromController) - } + let view = TransitAlertDetailViewController(alert, locale: locale) + let navigationController = UINavigationController(rootViewController: view) + present(navigationController, from: fromController) } // MARK: - Helpers diff --git a/OBAKit/WebView/DocumentWebView.swift b/OBAKit/WebView/DocumentWebView.swift index 62d2a1a7e..438bd0a83 100644 --- a/OBAKit/WebView/DocumentWebView.swift +++ b/OBAKit/WebView/DocumentWebView.swift @@ -16,64 +16,46 @@ import UIKit /// HTML fragment with an HTML document, allowing for comfortable reading on a phone. class DocumentWebView: WKWebView { + static let actionButtonHandlerName = "actionButtonClicked" + /// Pass along either a plain string or an HTML fragment to render it in the web view. - /// + /// /// Example: You can pass in either values like "hello world" or `"

Hello

World

"` - /// + /// /// - Parameter htmlFragment: The content to render in the web view. - func setPageContent(_ htmlFragment: String) { - let content = pageBody.replacingOccurrences(of: "{{{oba_page_content}}}", with: htmlFragment) + /// - Parameter actionButtonTitle: The title of the optional button shown at the bottom of the web view. + func setPageContent(_ htmlFragment: String, actionButtonTitle: String? = nil) { + var content = pageBody.replacingOccurrences(of: "{{{oba_page_content}}}", with: htmlFragment) + content = content.replacingOccurrences(of: "{{{accent_color}}}", with: accentHexColor) + content = content.replacingOccurrences(of: "{{{accent_foreground_color}}}", with: accentForegroundColor) + + if let actionButtonTitle { + let buttonText = """ +
+ +
+ """ + content = content.replacingOccurrences(of: "{{{oba_page_actions}}}", with: buttonText) + } + loadHTMLString(content, baseURL: nil) } - private var pageBody: String { - """ - - - - - - - - {{{oba_page_content}}} - - - """ + private var pageBody: String { + let frameworkBundle = Bundle(for: type(of: self)) + let htmlPath = frameworkBundle.path(forResource: "document_web_view_content", ofType: "html")! + return try! String(contentsOfFile: htmlPath) // swiftlint:disable:this force_try } } diff --git a/OBAKit/WebView/document_web_view_content.html b/OBAKit/WebView/document_web_view_content.html new file mode 100644 index 000000000..f9239d97d --- /dev/null +++ b/OBAKit/WebView/document_web_view_content.html @@ -0,0 +1,91 @@ + + + + + + + + + + +
+ {{{oba_page_content}}} +
+
+ {{{oba_page_actions}}} +
+ + + diff --git a/OBAKitCore/Extensions/UIKitExtensions.swift b/OBAKitCore/Extensions/UIKitExtensions.swift index 56943f1f9..d0846a9c2 100644 --- a/OBAKitCore/Extensions/UIKitExtensions.swift +++ b/OBAKitCore/Extensions/UIKitExtensions.swift @@ -79,6 +79,11 @@ public extension UIBarButtonItem { // Adapted from https://cocoacasts.com/from-hex-to-uicolor-and-back-in-swift public extension UIColor { + /// Returns the accent color defined in the app's xcasset bundle. Make sure this is set, or calling it will crash the app! + static var accentColor: UIColor { + return UIColor(named: "AccentColor")! + } + /// Initializes a `UIColor` using `0-255` range `Int` values. /// - Parameter r: Red, `0-255`. /// - Parameter g: Green, `0-255`. @@ -135,28 +140,78 @@ public extension UIColor { } // MARK: - From UIColor to String - + + /// Generates a hex value from the receiver + /// + /// The hex values _do not_ have leading `#` values. + /// In other words, `UIColor.red` -> `ff0000`. + /// + /// - Parameter alpha: Whether to include the alpha channel. + /// - Returns: The hex string. func toHex(alpha: Bool = false) -> String? { - guard let components = cgColor.components, components.count >= 3 else { + let components = cgColor.components + let numberOfComponents = cgColor.numberOfComponents + + var r: CGFloat = 0 + var g: CGFloat = 0 + var b: CGFloat = 0 + var a: CGFloat = 1 + + switch numberOfComponents { + case 2: // Grayscale + r = components?[0] ?? 0 + g = components?[0] ?? 0 + b = components?[0] ?? 0 + a = components?[1] ?? 1 + case 4: // RGBA + r = components?[0] ?? 0 + g = components?[1] ?? 0 + b = components?[2] ?? 0 + a = components?[3] ?? 1 + default: return nil } - let r = Float(components[0]) - let g = Float(components[1]) - let b = Float(components[2]) - var a = Float(1.0) - - if components.count >= 4 { - a = Float(components[3]) + if alpha { + return String(format: "%02lX%02lX%02lX%02lX", + lroundf(Float(r) * 255), + lroundf(Float(g) * 255), + lroundf(Float(b) * 255), + lroundf(Float(a) * 255)) + } else { + return String(format: "%02lX%02lX%02lX", + lroundf(Float(r) * 255), + lroundf(Float(g) * 255), + lroundf(Float(b) * 255)) } + } - if alpha { - return String(format: "%02lX%02lX%02lX%02lX", lroundf(r * 255), lroundf(g * 255), lroundf(b * 255), lroundf(a * 255)) + // MARK: - Luminance + + /// Returns a dark text color if the receiver is light, and light if the receiver is dark. + var contrastingTextColor: UIColor { + if isLightColor { + return UIColor.darkText } else { - return String(format: "%02lX%02lX%02lX", lroundf(r * 255), lroundf(g * 255), lroundf(b * 255)) + return UIColor.lightText } } + + /// Determine if the receiver is a 'light' or 'dark' color. + var isLightColor: Bool { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + + self.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + + // Calculating the Perceived Luminance + let luminance = 0.299 * red + 0.587 * green + 0.114 * blue + return luminance > 0.5 + } + } // MARK: - UICollectionView