diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index b5cc4d7ec7d..fc1ca60baf2 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -2,6 +2,7 @@ 7.7 ----- +- [*] Show banner on screens that use cached data when device is offline. [https://github.com/woocommerce/woocommerce-ios/pull/5000] 7.6 @@ -10,7 +11,6 @@ - [***] Shipping Labels: Merchants can now add new custom and service packages for shipping labels directly from the app. [https://github.com/woocommerce/woocommerce-ios/pull/4976] - [*] Fix: when product image upload fails, the image cell stop loading. [https://github.com/woocommerce/woocommerce-ios/pull/4989] - 7.5 ----- - [***] Merchants can now purchase shipping labels and declare customs forms for international orders. [https://github.com/woocommerce/woocommerce-ios/pull/4896] diff --git a/WooCommerce/Classes/Extensions/UIImage+Woo.swift b/WooCommerce/Classes/Extensions/UIImage+Woo.swift index 63ee3de472f..8bb6c6c6882 100644 --- a/WooCommerce/Classes/Extensions/UIImage+Woo.swift +++ b/WooCommerce/Classes/Extensions/UIImage+Woo.swift @@ -384,6 +384,12 @@ extension UIImage { return UIImage.gridicon(.menu) } + /// Lightning icon on offline banner + /// + static var lightningImage: UIImage { + return UIImage.gridicon(.offline).imageFlippedForRightToLeftLayoutDirection() + } + /// Invisible Image /// static var invisibleImage: UIImage { diff --git a/WooCommerce/Classes/Extensions/UIViewController+Connectivity.swift b/WooCommerce/Classes/Extensions/UIViewController+Connectivity.swift new file mode 100644 index 00000000000..f9eb5c1c26d --- /dev/null +++ b/WooCommerce/Classes/Extensions/UIViewController+Connectivity.swift @@ -0,0 +1,12 @@ +import UIKit +import Combine + +extension UIViewController { + /// Defines if the view controller should show a "no connection" banner when offline. + /// This requires the view controller to be contained inside a `WooNavigationController`. + /// Defaults to `false`. + /// + @objc var shouldShowOfflineBanner: Bool { + false + } +} diff --git a/WooCommerce/Classes/System/WooNavigationController.swift b/WooCommerce/Classes/System/WooNavigationController.swift index dd85f817a94..2a26799164f 100644 --- a/WooCommerce/Classes/System/WooNavigationController.swift +++ b/WooCommerce/Classes/System/WooNavigationController.swift @@ -1,3 +1,4 @@ +import Combine import UIKit /// Subclass to set Woo styling. Removes back button text on managed view controllers. @@ -38,6 +39,16 @@ class WooNavigationController: UINavigationController { /// private class WooNavigationControllerDelegate: NSObject, UINavigationControllerDelegate { + private let connectivityObserver: ConnectivityObserver + private var currentController: UIViewController? + private var subscriptions: Set = [] + + init(connectivityObserver: ConnectivityObserver = ServiceLocator.connectivityObserver) { + self.connectivityObserver = connectivityObserver + super.init() + observeConnectivity() + } + /// Children delegate, all events will be forwarded to this object /// weak var forwardDelegate: UINavigationControllerDelegate? @@ -45,6 +56,8 @@ private class WooNavigationControllerDelegate: NSObject, UINavigationControllerD /// Configures the back button for the managed `ViewController` and forwards the event to the children delegate. /// func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) { + currentController = viewController + configureOfflineBanner(for: viewController) configureBackButton(for: viewController) forwardDelegate?.navigationController?(navigationController, willShow: viewController, animated: animated) } @@ -76,3 +89,69 @@ private extension WooNavigationControllerDelegate { viewController.removeNavigationBackBarButtonText() } } + +// MARK: Offline banner configuration +private extension WooNavigationControllerDelegate { + + /// Observes changes in status of connectivity and updates the offline banner in current view controller accordingly. + /// + func observeConnectivity() { + connectivityObserver.statusPublisher + .sink { [weak self] status in + guard let self = self, let currentController = self.currentController else { return } + self.configureOfflineBanner(for: currentController, status: status) + } + .store(in: &subscriptions) + } + + /// Shows or hides offline banner based on the input connectivity status and + /// whether the view controller supports showing the banner. + /// + func configureOfflineBanner(for viewController: UIViewController, status: ConnectivityStatus? = nil) { + if viewController.shouldShowOfflineBanner { + setOfflineBannerWhenNoConnection(for: viewController, status: status ?? connectivityObserver.currentStatus) + } else { + removeOfflineBanner(for: viewController) + } + } + + /// Adds offline banner at the bottom of the view controller. + /// + func setOfflineBannerWhenNoConnection(for viewController: UIViewController, status: ConnectivityStatus) { + // We can only show it when we are sure we can't reach the internet + guard status == .notReachable else { + return removeOfflineBanner(for: viewController) + } + + // Only add banner view if it's not already added. + guard let navigationController = viewController.navigationController, + let view = viewController.view, + view.subviews.first(where: { $0 is OfflineBannerView }) == nil else { + return + } + + let offlineBannerView = OfflineBannerView(frame: .zero) + offlineBannerView.backgroundColor = .gray + offlineBannerView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(offlineBannerView) + + let extraBottomSpace = viewController.hidesBottomBarWhenPushed ? navigationController.view.safeAreaInsets.bottom : 0 + NSLayoutConstraint.activate([ + offlineBannerView.heightAnchor.constraint(equalToConstant: OfflineBannerView.height), + offlineBannerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + offlineBannerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + offlineBannerView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -extraBottomSpace) + ]) + viewController.additionalSafeAreaInsets = UIEdgeInsets(top: 0, left: 0, bottom: OfflineBannerView.height, right: 0) + } + + /// Removes the offline banner from the view controller if it exists. + /// + func removeOfflineBanner(for viewController: UIViewController) { + guard let offlineBanner = viewController.view.subviews.first(where: { $0 is OfflineBannerView }) else { + return + } + offlineBanner.removeFromSuperview() + viewController.additionalSafeAreaInsets = .zero + } +} diff --git a/WooCommerce/Classes/Tools/Connectivity/ConnectivityObserver.swift b/WooCommerce/Classes/Tools/Connectivity/ConnectivityObserver.swift index cecf6b6ef5b..0f9cc08d555 100644 --- a/WooCommerce/Classes/Tools/Connectivity/ConnectivityObserver.swift +++ b/WooCommerce/Classes/Tools/Connectivity/ConnectivityObserver.swift @@ -1,17 +1,17 @@ -import Foundation +import Combine /// Interface for the observing connectivity /// protocol ConnectivityObserver { /// Getter for current state of the connectivity. - var isConnectivityAvailable: Bool { get } + var currentStatus: ConnectivityStatus { get } + + /// Publisher for connectivity availability. + var statusPublisher: AnyPublisher { get } /// Starts the observer. func startObserving() - /// Updates the listener for the connectivity observer. - func updateListener(_ listener: @escaping (ConnectivityStatus) -> Void) - /// Stops the observer. func stopObserving() } diff --git a/WooCommerce/Classes/Tools/Connectivity/DefaultConnectivityObserver.swift b/WooCommerce/Classes/Tools/Connectivity/DefaultConnectivityObserver.swift index dd35f8dfb8c..757998e2db4 100644 --- a/WooCommerce/Classes/Tools/Connectivity/DefaultConnectivityObserver.swift +++ b/WooCommerce/Classes/Tools/Connectivity/DefaultConnectivityObserver.swift @@ -1,4 +1,4 @@ -import Foundation +import Combine import Network final class DefaultConnectivityObserver: ConnectivityObserver { @@ -8,32 +8,27 @@ final class DefaultConnectivityObserver: ConnectivityObserver { private let networkMonitor: NetworkMonitoring private let observingQueue: DispatchQueue = .global(qos: .background) - var isConnectivityAvailable: Bool { - if case .reachable = connectivityStatus(from: networkMonitor.currentNetwork) { - return true - } - return false + @Published private(set) var currentStatus: ConnectivityStatus = .unknown + + var statusPublisher: AnyPublisher { + $currentStatus.eraseToAnyPublisher() } init(networkMonitor: NetworkMonitoring = NWPathMonitor()) { self.networkMonitor = networkMonitor startObserving() - } - - func startObserving() { - networkMonitor.start(queue: observingQueue) - } - - func updateListener(_ listener: @escaping (ConnectivityStatus) -> Void) { networkMonitor.networkUpdateHandler = { [weak self] path in guard let self = self else { return } - let connectivityStatus = self.connectivityStatus(from: path) DispatchQueue.main.async { - listener(connectivityStatus) + self.currentStatus = self.connectivityStatus(from: path) } } } + func startObserving() { + networkMonitor.start(queue: observingQueue) + } + func stopObserving() { networkMonitor.cancel() } @@ -65,8 +60,6 @@ final class DefaultConnectivityObserver: ConnectivityObserver { /// Proxy protocol for mocking `NWPathMonitor`. protocol NetworkMonitoring: AnyObject { - var currentNetwork: NetworkMonitorable { get } - /// A handler that receives network updates. var networkUpdateHandler: ((NetworkMonitorable) -> Void)? { get set } @@ -88,10 +81,6 @@ protocol NetworkMonitorable { extension NWPath: NetworkMonitorable {} extension NWPathMonitor: NetworkMonitoring { - var currentNetwork: NetworkMonitorable { - currentPath - } - var networkUpdateHandler: ((NetworkMonitorable) -> Void)? { get { let closure: ((NetworkMonitorable) -> Void)? = { diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/DashboardViewController.swift b/WooCommerce/Classes/ViewRelated/Dashboard/DashboardViewController.swift index a89229c516a..5091e040d80 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/DashboardViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/DashboardViewController.swift @@ -119,6 +119,10 @@ final class DashboardViewController: UIViewController { super.viewDidLayoutSubviews() dashboardUI?.view.frame = containerView.bounds } + + override var shouldShowOfflineBanner: Bool { + return true + } } // MARK: - Configuration diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/OrderDetailsViewController.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/OrderDetailsViewController.swift index f71ed44d604..74250c5355d 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/OrderDetailsViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/OrderDetailsViewController.swift @@ -122,6 +122,10 @@ final class OrderDetailsViewController: UIViewController { super.viewDidLayoutSubviews() tableView.updateHeaderHeight() } + + override var shouldShowOfflineBanner: Bool { + return true + } } diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Review Order/ReviewOrderViewController.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Review Order/ReviewOrderViewController.swift index f51bd359131..3b79cf8d939 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Review Order/ReviewOrderViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Review Order/ReviewOrderViewController.swift @@ -64,11 +64,16 @@ final class ReviewOrderViewController: UIViewController { super.viewDidLayoutSubviews() tableView.updateFooterHeight() } + + override var shouldShowOfflineBanner: Bool { + return true + } } // MARK: - UI Configuration // private extension ReviewOrderViewController { + func configureViewModel() { viewModel.configureResultsControllers { [weak self] in self?.tableView.reloadData() diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/Create Shipping Label Form/ShippingLabelFormViewController.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/Create Shipping Label Form/ShippingLabelFormViewController.swift index 4728c804b44..e8818df9ffc 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/Create Shipping Label Form/ShippingLabelFormViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/Create Shipping Label Form/ShippingLabelFormViewController.swift @@ -44,6 +44,10 @@ final class ShippingLabelFormViewController: UIViewController { registerTableViewHeaderFooters() observeViewModel() } + + override var shouldShowOfflineBanner: Bool { + return true + } } // MARK: - View Configuration diff --git a/WooCommerce/Classes/ViewRelated/Orders/OrdersRootViewController.swift b/WooCommerce/Classes/ViewRelated/Orders/OrdersRootViewController.swift index 01f1da7a064..fa65024b8cf 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/OrdersRootViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/OrdersRootViewController.swift @@ -64,6 +64,10 @@ final class OrdersRootViewController: UIViewController { func presentDetails(for note: Note) { ordersViewController.presentDetails(for: note) } + + override var shouldShowOfflineBanner: Bool { + return true + } } // MARK: - Configuration diff --git a/WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormViewController.swift b/WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormViewController.swift index f5c72558799..76564bd9af4 100644 --- a/WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormViewController.swift @@ -134,6 +134,10 @@ final class ProductFormViewController: view.endEditing(true) } + override var shouldShowOfflineBanner: Bool { + return true + } + // MARK: - Navigation actions handling override func shouldPopOnBackButton() -> Bool { @@ -403,6 +407,7 @@ final class ProductFormViewController: // MARK: - Configuration // private extension ProductFormViewController { + func configureNavigationBar() { updateNavigationBar() updateBackButtonTitle() diff --git a/WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift b/WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift index 0e6e8eed264..29b06c8e34a 100644 --- a/WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift @@ -201,6 +201,10 @@ final class ProductsViewController: UIViewController { updateTableHeaderViewHeight() } + + override var shouldShowOfflineBanner: Bool { + return true + } } // MARK: - Navigation Bar Actions diff --git a/WooCommerce/Classes/ViewRelated/ReusableViews/OfflineBannerView.swift b/WooCommerce/Classes/ViewRelated/ReusableViews/OfflineBannerView.swift new file mode 100644 index 00000000000..0d66af231ec --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/ReusableViews/OfflineBannerView.swift @@ -0,0 +1,52 @@ +import UIKit + +/// Gray banner showing message when device is offline. +/// +final class OfflineBannerView: UIView { + + static let height: CGFloat = 44 + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupView() { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = 3 + stackView.distribution = .fillProportionally + stackView.alignment = .center + stackView.translatesAutoresizingMaskIntoConstraints = false + + let imageView = UIImageView(image: .lightningImage) + imageView.tintColor = .white + imageView.contentMode = .scaleAspectFit + imageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + imageView.widthAnchor.constraint(equalToConstant: 24), + imageView.heightAnchor.constraint(equalToConstant: 24) + ]) + + let messageLabel = UILabel() + messageLabel.text = NSLocalizedString("Offline - using cached data", comment: "Message for offline banner") + messageLabel.applyCalloutStyle() + messageLabel.textColor = .white + messageLabel.translatesAutoresizingMaskIntoConstraints = false + + stackView.addArrangedSubview(imageView) + stackView.addArrangedSubview(messageLabel) + + addSubview(stackView) + NSLayoutConstraint.activate([ + stackView.safeLeadingAnchor.constraint(greaterThanOrEqualTo: safeLeadingAnchor, constant: 0), + stackView.safeBottomAnchor.constraint(greaterThanOrEqualTo: safeBottomAnchor, constant: 0), + stackView.centerXAnchor.constraint(equalTo: centerXAnchor), + stackView.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) + } +} diff --git a/WooCommerce/Classes/ViewRelated/Reviews/ReviewDetailsViewController.swift b/WooCommerce/Classes/ViewRelated/Reviews/ReviewDetailsViewController.swift index f1a2afb1655..818754f3d67 100644 --- a/WooCommerce/Classes/ViewRelated/Reviews/ReviewDetailsViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Reviews/ReviewDetailsViewController.swift @@ -1,4 +1,3 @@ -import Foundation import UIKit import Yosemite import Gridicons @@ -77,6 +76,10 @@ final class ReviewDetailsViewController: UIViewController { super.viewWillAppear(animated) markAsReadIfNeeded(notification) } + + override var shouldShowOfflineBanner: Bool { + return true + } } diff --git a/WooCommerce/Classes/ViewRelated/Reviews/ReviewsViewController.swift b/WooCommerce/Classes/ViewRelated/Reviews/ReviewsViewController.swift index fdb2ca2fbaf..575f1f4ad34 100644 --- a/WooCommerce/Classes/ViewRelated/Reviews/ReviewsViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Reviews/ReviewsViewController.swift @@ -163,6 +163,10 @@ final class ReviewsViewController: UIViewController { self.displayPlaceholderReviews() } } + + override var shouldShowOfflineBanner: Bool { + return true + } } diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index be41ec86bd0..1de890fe02c 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -1297,9 +1297,11 @@ DE525499268C8B32007A5829 /* UIRefreshControl+Woo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE525498268C8B32007A5829 /* UIRefreshControl+Woo.swift */; }; DE67D46726B98FD000EFE8DB /* Publisher+WithLatestFrom.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE67D46626B98FD000EFE8DB /* Publisher+WithLatestFrom.swift */; }; DE67D46926BAA82600EFE8DB /* Publisher+WithLatestFromTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE67D46826BAA82600EFE8DB /* Publisher+WithLatestFromTests.swift */; }; + DE68B81F26F86B1700C86CFB /* OfflineBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE68B81E26F86B1700C86CFB /* OfflineBannerView.swift */; }; + DE68B84326FAF17A00C86CFB /* DefaultConnectivityObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE68B84226FAF17A00C86CFB /* DefaultConnectivityObserver.swift */; }; DE7842ED26F061650030C792 /* NumberFormatter+Localized.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE7842EC26F061650030C792 /* NumberFormatter+Localized.swift */; }; DE7842EF26F079A60030C792 /* NumberFormatter+LocalizedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE7842EE26F079A60030C792 /* NumberFormatter+LocalizedTests.swift */; }; - DE7842F926F435070030C792 /* DefaultConnectivityObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE7842F826F435070030C792 /* DefaultConnectivityObserver.swift */; }; + DE7842F726F2E9340030C792 /* UIViewController+Connectivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE7842F626F2E9340030C792 /* UIViewController+Connectivity.swift */; }; DE792E1826EF35F40071200C /* ConnectivityObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE792E1726EF35F40071200C /* ConnectivityObserver.swift */; }; DE792E1B26EF37ED0071200C /* DefaultConnectivityObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE792E1A26EF37ED0071200C /* DefaultConnectivityObserver.swift */; }; DE8C94662646990000C94823 /* PluginListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE8C94652646990000C94823 /* PluginListViewController.swift */; }; @@ -2737,9 +2739,11 @@ DE525498268C8B32007A5829 /* UIRefreshControl+Woo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIRefreshControl+Woo.swift"; sourceTree = ""; }; DE67D46626B98FD000EFE8DB /* Publisher+WithLatestFrom.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+WithLatestFrom.swift"; sourceTree = ""; }; DE67D46826BAA82600EFE8DB /* Publisher+WithLatestFromTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+WithLatestFromTests.swift"; sourceTree = ""; }; + DE68B81E26F86B1700C86CFB /* OfflineBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineBannerView.swift; sourceTree = ""; }; + DE68B84226FAF17A00C86CFB /* DefaultConnectivityObserver.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DefaultConnectivityObserver.swift; sourceTree = ""; }; DE7842EC26F061650030C792 /* NumberFormatter+Localized.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NumberFormatter+Localized.swift"; sourceTree = ""; }; DE7842EE26F079A60030C792 /* NumberFormatter+LocalizedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NumberFormatter+LocalizedTests.swift"; sourceTree = ""; }; - DE7842F826F435070030C792 /* DefaultConnectivityObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultConnectivityObserver.swift; sourceTree = ""; }; + DE7842F626F2E9340030C792 /* UIViewController+Connectivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Connectivity.swift"; sourceTree = ""; }; DE792E1726EF35F40071200C /* ConnectivityObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectivityObserver.swift; sourceTree = ""; }; DE792E1A26EF37ED0071200C /* DefaultConnectivityObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultConnectivityObserver.swift; sourceTree = ""; }; DE8C94652646990000C94823 /* PluginListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginListViewController.swift; sourceTree = ""; }; @@ -4945,8 +4949,8 @@ CC0324A2263AD9F40056C6B7 /* MockShippingLabelAccountSettings.swift */, 0277AEAA256CAA5300F45C4A /* MockShippingLabelAddress.swift */, 0211252D25773FB00075AD2A /* MockAggregateOrderItem.swift */, + DE68B84226FAF17A00C86CFB /* DefaultConnectivityObserver.swift */, 4590B651261C8D1E00A6FCE0 /* WeightFormatterTests.swift */, - DE7842F826F435070030C792 /* DefaultConnectivityObserver.swift */, ); path = Tools; sourceTree = ""; @@ -5752,6 +5756,7 @@ DE67D46626B98FD000EFE8DB /* Publisher+WithLatestFrom.swift */, DEC2962626C17AD8005A056B /* ShippingLabelCustomsForm+Localization.swift */, DE7842EC26F061650030C792 /* NumberFormatter+Localized.swift */, + DE7842F626F2E9340030C792 /* UIViewController+Connectivity.swift */, ); path = Extensions; sourceTree = ""; @@ -6093,6 +6098,7 @@ 02A410F32583A84C005E2925 /* SpacerTableViewCell.swift */, 2664210026F3E1BB001FC5B4 /* ModalHostingPresentationController.swift */, 02A410F42583A84C005E2925 /* SpacerTableViewCell.xib */, + DE68B81E26F86B1700C86CFB /* OfflineBannerView.swift */, ); path = ReusableViews; sourceTree = ""; @@ -7197,6 +7203,7 @@ D8736B5322EF4F5900A14A29 /* NotificationsBadgeController.swift in Sources */, B541B220218A007C008FE7C1 /* NSMutableParagraphStyle+Helpers.swift in Sources */, 45AE582C230D9D35001901E3 /* OrderNoteHeaderTableViewCell.swift in Sources */, + DE7842F726F2E9340030C792 /* UIViewController+Connectivity.swift in Sources */, E1C47209267A1ECC00D06DA1 /* CrashLoggingStack.swift in Sources */, D85A3C5226C15DE200C0E026 /* InPersonPaymentsPluginNotSupportedVersionView.swift in Sources */, CE583A0B2107937F00D73C1C /* TextViewTableViewCell.swift in Sources */, @@ -7657,6 +7664,7 @@ 31B0551E264B3C7A00134D87 /* CardPresentModalFoundReader.swift in Sources */, 45A24E5F2451DF1A0050606B /* ProductMenuOrderViewController.swift in Sources */, 2667BFE1252FA117008099D4 /* RefundItemQuantityListSelectorCommand.swift in Sources */, + DE68B81F26F86B1700C86CFB /* OfflineBannerView.swift in Sources */, 02D4564C231D05E2008CF0A9 /* BetaFeaturesViewController.swift in Sources */, D8610BCC256F284700A5DF27 /* ULErrorViewModel.swift in Sources */, 4590CEE4249BA46700949F05 /* AddProductCategoryViewController.swift in Sources */, @@ -7931,7 +7939,7 @@ 450C2CB324D0803000D570DD /* ProductSettingsRowsTests.swift in Sources */, 45AF9DAF265CFAB4001EB794 /* MockShippingLabelCarrierRate.swift in Sources */, CC593A6726EA116300EF0E04 /* ShippingLabelAddNewPackageViewModelTests.swift in Sources */, - DE7842F926F435070030C792 /* DefaultConnectivityObserver.swift in Sources */, + DE68B84326FAF17A00C86CFB /* DefaultConnectivityObserver.swift in Sources */, 455A2FDB246B1349000CA72C /* ProductVisibilityTests.swift in Sources */, 0215C6FC2518A3CD005240CD /* ProductFormViewModel+SaveTests.swift in Sources */, 265284092624ACE900F91BA1 /* AddOnCrossreferenceTests.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/Extensions/IconsTests.swift b/WooCommerce/WooCommerceTests/Extensions/IconsTests.swift index bada09f9aef..d97de710797 100644 --- a/WooCommerce/WooCommerceTests/Extensions/IconsTests.swift +++ b/WooCommerce/WooCommerceTests/Extensions/IconsTests.swift @@ -496,4 +496,8 @@ final class IconsTests: XCTestCase { func test_welcomeImage_is_not_nil() { XCTAssertNotNil(UIImage.welcomeImage) } + + func test_lightningImage_is_not_nil() { + XCTAssertNotNil(UIImage.lightningImage) + } } diff --git a/WooCommerce/WooCommerceTests/Tools/DefaultConnectivityObserver.swift b/WooCommerce/WooCommerceTests/Tools/DefaultConnectivityObserver.swift index 1e9108ccee2..8af3f64fbaa 100644 --- a/WooCommerce/WooCommerceTests/Tools/DefaultConnectivityObserver.swift +++ b/WooCommerce/WooCommerceTests/Tools/DefaultConnectivityObserver.swift @@ -1,12 +1,14 @@ +import Combine import Network import XCTest @testable import WooCommerce final class DefaultConnectivityObserverTests: XCTestCase { + private var subscriptions: Set = [] + func test_initializing_observer_triggers_network_monitoring() { // Given - let network = MockNetwork(status: .satisfied, currentInterface: .wifi) - let networkMonitor = MockNetworkMonitor(currentNetwork: network) + let networkMonitor = MockNetworkMonitor() // When let _ = DefaultConnectivityObserver(networkMonitor: networkMonitor) @@ -17,8 +19,7 @@ final class DefaultConnectivityObserverTests: XCTestCase { func test_stopping_observer_stops_network_monitoring() { // Given - let network = MockNetwork(status: .satisfied, currentInterface: .wifi) - let networkMonitor = MockNetworkMonitor(currentNetwork: network) + let networkMonitor = MockNetworkMonitor() // When let observer = DefaultConnectivityObserver(networkMonitor: networkMonitor) @@ -28,67 +29,60 @@ final class DefaultConnectivityObserverTests: XCTestCase { XCTAssertTrue(networkMonitor.didStopMonitoring) } - func test_isConnectivityAvailable_returns_true_when_network_is_satisfied() { - // Given - let network = MockNetwork(status: .satisfied, currentInterface: .wifi) - let networkMonitor = MockNetworkMonitor(currentNetwork: network) - - // When - let observer = DefaultConnectivityObserver(networkMonitor: networkMonitor) - - // Then - XCTAssertTrue(observer.isConnectivityAvailable) - } - - func test_isConnectivityAvailable_returns_false_when_network_is_unsatisfied() { + func test_currentStatus_and_statusPublisher_return_correctly_when_network_is_satisfied() { // Given - let network = MockNetwork(status: .unsatisfied, currentInterface: .wifi) - let networkMonitor = MockNetworkMonitor(currentNetwork: network) + let networkMonitor = MockNetworkMonitor() + let expectation = expectation(description: "Current status and status publisher values") // When + var result: ConnectivityStatus = .unknown let observer = DefaultConnectivityObserver(networkMonitor: networkMonitor) + observer.statusPublisher + .dropFirst() + .sink { status in + result = status + expectation.fulfill() + } + .store(in: &subscriptions) + networkMonitor.fakeNetworkUpdate(network: MockNetwork(status: .satisfied, currentInterface: .wifi)) // Then - XCTAssertFalse(observer.isConnectivityAvailable) + wait(for: [expectation], timeout: 1) + XCTAssertEqual(observer.currentStatus, .reachable(type: .ethernetOrWiFi)) + XCTAssertEqual(result, .reachable(type: .ethernetOrWiFi)) } - func test_updateListener_returns_correct_status_in_callback_closure() { + func test_currentStatus_and_statusPublisher_return_correctly_when_network_is_unsatisfied() { // Given - let network = MockNetwork(status: .satisfied, currentInterface: .wifi) - let networkMonitor = MockNetworkMonitor(currentNetwork: network) - let networkUpdate = MockNetwork(status: .satisfied, currentInterface: .cellular) - let statusExpectation = expectation(description: "Status in callback closure") + let networkMonitor = MockNetworkMonitor() + let expectation = expectation(description: "Current status and status publisher values") // When - let observer = DefaultConnectivityObserver(networkMonitor: networkMonitor) var result: ConnectivityStatus = .unknown - observer.updateListener { status in - result = status - statusExpectation.fulfill() - } - networkMonitor.fakeNetworkUpdate(network: networkUpdate) + let observer = DefaultConnectivityObserver(networkMonitor: networkMonitor) + observer.statusPublisher + .dropFirst() + .sink { status in + result = status + expectation.fulfill() + } + .store(in: &subscriptions) + networkMonitor.fakeNetworkUpdate(network: MockNetwork(status: .unsatisfied, currentInterface: .wifi)) // Then - waitForExpectations(timeout: 0.3, handler: nil) - if case .reachable(let type) = result { - XCTAssertEqual(type, .cellular) - } else { - XCTFail("Incorrect result status in callback closure") - } + wait(for: [expectation], timeout: 1) + XCTAssertEqual(observer.currentStatus, .notReachable) + XCTAssertEqual(result, .notReachable) } } final class MockNetworkMonitor: NetworkMonitoring { - let currentNetwork: NetworkMonitorable - var networkUpdateHandler: ((NetworkMonitorable) -> Void)? private(set) var didStartMonitoring = false private(set) var didStopMonitoring = false - init(currentNetwork: NetworkMonitorable) { - self.currentNetwork = currentNetwork - } + init() {} func fakeNetworkUpdate(network: NetworkMonitorable) { networkUpdateHandler?(network)