From 71483d9ec4e48eb738843a403984bcf8471b5dc9 Mon Sep 17 00:00:00 2001 From: Soumen Rautray Date: Mon, 9 Dec 2024 10:00:43 +0530 Subject: [PATCH] chore: Implement UI display with manual scroll for Carousel View (RMCCX-7619) --- .../RInAppMessaging/CampaignDispatcher.swift | 95 ++++++++- Sources/RInAppMessaging/Constants.swift | 1 + .../Models/Responses/CampaignDataModels.swift | 10 + .../RInAppMessaging/Resources/FullView.xib | 69 +++++- .../Resources/en.lproj/Localizable.strings | 1 + .../Resources/ja.lproj/Localizable.strings | 1 + Sources/RInAppMessaging/Router.swift | 10 +- .../RInAppMessaging/Views/CarouselView.swift | 196 ++++++++++++++++++ Sources/RInAppMessaging/Views/FullView.swift | 21 +- .../Views/Presenters/BaseViewPresenter.swift | 2 + .../Views/Presenters/FullViewPresenter.swift | 3 +- .../Views/ViewModel/FullViewModel.swift | 1 + Tests/Tests/Helpers/SharedMocks.swift | 6 +- Tests/Tests/Helpers/TestHelpers.swift | 2 +- Tests/Tests/RouterSpec.swift | 42 ++-- Tests/Tests/ViewModelSpec.swift | 2 +- Tests/Tests/ViewSpec.swift | 6 +- 17 files changed, 421 insertions(+), 47 deletions(-) create mode 100644 Sources/RInAppMessaging/Views/CarouselView.swift diff --git a/Sources/RInAppMessaging/CampaignDispatcher.swift b/Sources/RInAppMessaging/CampaignDispatcher.swift index 466b9e11..f5677b8f 100644 --- a/Sources/RInAppMessaging/CampaignDispatcher.swift +++ b/Sources/RInAppMessaging/CampaignDispatcher.swift @@ -24,6 +24,14 @@ internal class CampaignDispatcher: CampaignDispatcherType, TaskSchedulable { private let dispatchQueue = DispatchQueue(label: "IAM.CampaignDisplay", qos: .userInteractive) private(set) var queuedCampaignIDs = [String]() private(set) var isDispatching = false + + private let urlCache: URLCache = { + // response must be <= 5% of mem/disk cap in order to committed to cache + let cache = URLCache(memoryCapacity: URLCache.shared.memoryCapacity, + diskCapacity: 100 * 1024 * 1024, // fits up to 5MB images + diskPath: "RInAppMessaging") + return cache + }() weak var delegate: CampaignDispatcherDelegate? var scheduledTask: DispatchWorkItem? @@ -41,11 +49,7 @@ internal class CampaignDispatcher: CampaignDispatcherType, TaskSchedulable { sessionConfig.timeoutIntervalForRequest = Constants.CampaignMessage.imageRequestTimeoutSeconds sessionConfig.timeoutIntervalForResource = Constants.CampaignMessage.imageResourceTimeoutSeconds sessionConfig.waitsForConnectivity = true - sessionConfig.urlCache = URLCache( - // response must be <= 5% of mem/disk cap in order to committed to cache - memoryCapacity: URLCache.shared.memoryCapacity, - diskCapacity: 100*1024*1024, // fits up to 5MB images - diskPath: "RInAppMessaging") + sessionConfig.urlCache = urlCache httpSession = URLSession(configuration: sessionConfig) } @@ -109,7 +113,17 @@ internal class CampaignDispatcher: CampaignDispatcherType, TaskSchedulable { self.dispatchNext() return } - self.displayCampaign(campaign, imageBlob: imgBlob) + if !(campaign.data.customJson?.carousel?.images?.isEmpty ?? true) { + if let carouselData = campaign.data.customJson?.carousel { + DispatchQueue.main.sync { + self.fetchImagesArray(from: carouselData) { images in + self.displayCampaign(campaign, imageBlob: imgBlob, carouselImages: images) + } + } + } + } else { + self.displayCampaign(campaign, imageBlob: imgBlob) + } } } } else { @@ -118,10 +132,9 @@ internal class CampaignDispatcher: CampaignDispatcherType, TaskSchedulable { } } - private func displayCampaign(_ campaign: Campaign, imageBlob: Data? = nil) { + private func displayCampaign(_ campaign: Campaign, imageBlob: Data? = nil, carouselImages: [UIImage?]? = nil) { let campaignTitle = campaign.data.messagePayload.title - - router.displayCampaign(campaign, associatedImageData: imageBlob, confirmation: { + router.displayCampaign(campaign, associatedImageData: imageBlob, carouselImages: carouselImages, confirmation: { let contexts = campaign.contexts guard let delegate = self.delegate, !contexts.isEmpty, !campaign.data.isTest else { return true @@ -167,4 +180,68 @@ internal class CampaignDispatcher: CampaignDispatcherType, TaskSchedulable { completion(data) }.resume() } + + func fetchImagesArray(from carousel: Carousel, completion: @escaping ([UIImage?]) -> Void) { + guard let imageDetails = carousel.images else { + completion([]) + return + } + + let filteredDetails = imageDetails + .sorted { $0.key < $1.key } + .prefix(5) + .map { $0 } + + let dispatchGroup = DispatchGroup() + var images: [UIImage?] = Array(repeating: nil, count: filteredDetails.count) + + for (index, detail) in filteredDetails.enumerated() { + guard let urlString = detail.value.imgUrl else { + images[index] = nil + continue + } + + dispatchGroup.enter() + fetchCarouselImage(for: urlString) { image in + images[index] = image + dispatchGroup.leave() + } + } + + dispatchGroup.notify(queue: .main) { + completion(images) + } + } + + func fetchCarouselImage(for urlString: String, completion: @escaping (UIImage?) -> Void) { + guard let url = URL(string: urlString), + ["jpg", "jpeg", "png"].contains(url.pathExtension.lowercased()) else { + completion(nil) + return + } + + let request = URLRequest(url: url) + + // Check cache + if let cachedResponse = self.urlCache.cachedResponse(for: request), + let cachedImage = UIImage(data: cachedResponse.data) { + completion(cachedImage) + return + } + + URLSession.shared.dataTask(with: request) { data, response, error in + if let data = data, + let response = response, + error == nil, + let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200, + let image = UIImage(data: data) { + let cachedData = CachedURLResponse(response: response, data: data) + self.urlCache.storeCachedResponse(cachedData, for: request) + completion(image) + } else { + completion(nil) + } + }.resume() + } } diff --git a/Sources/RInAppMessaging/Constants.swift b/Sources/RInAppMessaging/Constants.swift index 39e3f7ac..1db12c5f 100644 --- a/Sources/RInAppMessaging/Constants.swift +++ b/Sources/RInAppMessaging/Constants.swift @@ -5,6 +5,7 @@ internal enum Constants { enum CampaignMessage { static let imageRequestTimeoutSeconds: TimeInterval = 20 static let imageResourceTimeoutSeconds: TimeInterval = 300 + static let carouselThreshold: Int = 5 } enum Request { diff --git a/Sources/RInAppMessaging/Models/Responses/CampaignDataModels.swift b/Sources/RInAppMessaging/Models/Responses/CampaignDataModels.swift index c2482832..c6824810 100644 --- a/Sources/RInAppMessaging/Models/Responses/CampaignDataModels.swift +++ b/Sources/RInAppMessaging/Models/Responses/CampaignDataModels.swift @@ -160,6 +160,10 @@ struct Carousel: Codable { case images } + init(images: [String: ImageDetails]?) { + self.images = images + } + init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) images = try container.decodeIfPresent([String: ImageDetails].self, forKey: .images) @@ -177,6 +181,12 @@ struct ImageDetails: Codable { case altText } + init(imgUrl: String?, link: String?, altText: String?) { + self.imgUrl = imgUrl + self.altText = altText + self.link = link + } + init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) imgUrl = try container.decodeIfPresent(String.self, forKey: .imgUrl) diff --git a/Sources/RInAppMessaging/Resources/FullView.xib b/Sources/RInAppMessaging/Resources/FullView.xib index 224bede3..2cf7333a 100644 --- a/Sources/RInAppMessaging/Resources/FullView.xib +++ b/Sources/RInAppMessaging/Resources/FullView.xib @@ -1,9 +1,9 @@ - - + + - + @@ -17,6 +17,7 @@ + @@ -27,6 +28,7 @@ + @@ -35,11 +37,11 @@ - - + + - + @@ -64,10 +66,10 @@ - + - + @@ -89,14 +91,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -108,8 +144,16 @@ + + + + + + + + - + @@ -138,7 +182,7 @@ - + @@ -167,6 +211,9 @@ + + + diff --git a/Sources/RInAppMessaging/Resources/en.lproj/Localizable.strings b/Sources/RInAppMessaging/Resources/en.lproj/Localizable.strings index 64492c55..623e74e6 100644 --- a/Sources/RInAppMessaging/Resources/en.lproj/Localizable.strings +++ b/Sources/RInAppMessaging/Resources/en.lproj/Localizable.strings @@ -2,3 +2,4 @@ "dialog_alert_invalidURI_title" = "Page not found"; "dialog_alert_invalidURI_message" = "Encountered error while navigating to the page"; "dialog_alert_invalidURI_close" = "Close"; +"carousel_image_load_error" = "Unable to load image" diff --git a/Sources/RInAppMessaging/Resources/ja.lproj/Localizable.strings b/Sources/RInAppMessaging/Resources/ja.lproj/Localizable.strings index 97c0b060..e289553d 100644 --- a/Sources/RInAppMessaging/Resources/ja.lproj/Localizable.strings +++ b/Sources/RInAppMessaging/Resources/ja.lproj/Localizable.strings @@ -2,3 +2,4 @@ "dialog_alert_invalidURI_title" = "ページが見つかりません"; "dialog_alert_invalidURI_message" = "ページへの移動中にエラーが発生しました"; "dialog_alert_invalidURI_close" = "閉じる"; +"carousel_image_load_error" = "画像を読み込めません"; diff --git a/Sources/RInAppMessaging/Router.swift b/Sources/RInAppMessaging/Router.swift index 76e4f601..cc6e6ed0 100644 --- a/Sources/RInAppMessaging/Router.swift +++ b/Sources/RInAppMessaging/Router.swift @@ -18,6 +18,7 @@ internal protocol RouterType: ErrorReportable { /// - Parameter cancelled: true when message display was cancelled func displayCampaign(_ campaign: Campaign, associatedImageData: Data?, + carouselImages: [UIImage?]?, confirmation: @escaping @autoclosure () -> Bool, completion: @escaping (_ cancelled: Bool) -> Void) @@ -104,6 +105,7 @@ internal class Router: RouterType, ViewListenerObserver { func displayCampaign(_ campaign: Campaign, associatedImageData: Data?, + carouselImages: [UIImage?]?, confirmation: @escaping @autoclosure () -> Bool, completion: @escaping (_ cancelled: Bool) -> Void) { @@ -122,7 +124,7 @@ internal class Router: RouterType, ViewListenerObserver { } displayQueue.async { - let viewConstructor = self.createViewConstructor(for: campaign, presenter: presenter, associatedImageData: associatedImageData) + let viewConstructor = self.createViewConstructor(for: campaign, presenter: presenter, associatedImageData: associatedImageData, carouselImage: carouselImages) DispatchQueue.main.async { guard let rootView = UIApplication.shared.getKeyWindow(), @@ -167,7 +169,8 @@ internal class Router: RouterType, ViewListenerObserver { return presenter } - private func createViewConstructor(for campaign: Campaign, presenter: BaseViewPresenterType, associatedImageData: Data?) -> (() -> BaseView) { + private func createViewConstructor(for campaign: Campaign, presenter: BaseViewPresenterType, associatedImageData: Data?, carouselImage: [UIImage?]? + ) -> (() -> BaseView) { var view: (() -> BaseView)! let type = campaign.data.type @@ -178,6 +181,9 @@ internal class Router: RouterType, ViewListenerObserver { if let associatedImageData = associatedImageData { presenter.associatedImage = UIImage(data: associatedImageData) } + if let carouselImage = carouselImage { + presenter.carouselImages = carouselImage + } view = type == .modal ? { ModalView(presenter: presenter) } : { FullScreenView(presenter: presenter) } case .slide: let presenter = presenter as! SlideUpViewPresenterType diff --git a/Sources/RInAppMessaging/Views/CarouselView.swift b/Sources/RInAppMessaging/Views/CarouselView.swift new file mode 100644 index 00000000..6d23cec0 --- /dev/null +++ b/Sources/RInAppMessaging/Views/CarouselView.swift @@ -0,0 +1,196 @@ +import Foundation + +class CarouselView: UIView { + @IBOutlet weak var collectionView: UICollectionView! + @IBOutlet private weak var carouselPageControl: UIPageControl! + @IBOutlet private weak var carouselHeightConstraint: NSLayoutConstraint! + + private var images: [UIImage?] = [] + private var links: [String?] = [] + private var altTexts: [String?] = [] + private var heightPercentage: CGFloat = 1 + + required init?(coder: NSCoder) { + super.init(coder: coder) + self.setupOrientationObserver() + } + + deinit { + NotificationCenter.default.removeObserver(self, name: UIDevice.orientationDidChangeNotification, object: nil) + } + + func configure(images: [UIImage?], carouselData: Carousel?, maxHeightPercent: CGFloat = 1 ) { + self.images = images + getCarouselData(from: carouselData) + heightPercentage = maxHeightPercent + setupCollectionView() + setupPageControl() + collectionView.reloadData() + } + + override func layoutSubviews() { + super.layoutSubviews() + carouselHeightConstraint.constant = collectionView.frame.width * getMaxImageAspectRatio() + 2 + collectionView.collectionViewLayout.invalidateLayout() + layoutIfNeeded() + } + + private func setupPageControl() { + carouselPageControl.numberOfPages = images.count + carouselPageControl.currentPage = 0 + carouselPageControl.currentPageIndicatorTintColor = .systemBlue + carouselPageControl.pageIndicatorTintColor = .lightGray + carouselPageControl.addTarget(self, action: #selector(pageControlValueChanged), for: .valueChanged) + } + + private func setupCollectionView() { + collectionView.dataSource = self + collectionView.delegate = self + collectionView.showsHorizontalScrollIndicator = false + collectionView.isPagingEnabled = true + + if let layout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout { + layout.scrollDirection = .horizontal + layout.minimumLineSpacing = 0 + } + collectionView.register(CarouselCell.self, forCellWithReuseIdentifier: "CarouselCell") + } + + func setPageControlVisibility(isHdden: Bool) { + carouselPageControl.isHidden = isHdden + } + + @objc private func pageControlValueChanged() { + let currentPage = carouselPageControl.currentPage + collectionView.scrollToItem(at: IndexPath(item: currentPage, section: 0), at: .centeredHorizontally, animated: true) + } +} + +extension CarouselView: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return images.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CarouselCell.identifier, for: indexPath) as? CarouselCell else { + return UICollectionViewCell() + } + + cell.configure(with: images[indexPath.item], + altText: altTexts[indexPath.item] ?? "carousel_image_load_error".localized) + return cell + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + let itemWidth = collectionView.frame.width + var itemHeight = itemWidth * getMaxImageAspectRatio() + + return CGSize(width: itemWidth, height: itemHeight) + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let page = Int(scrollView.contentOffset.x / collectionView.frame.width) + carouselPageControl?.currentPage = page + } + + func getMaxImageAspectRatio()-> CGFloat { + guard let maxImage = images.compactMap({ $0 }).max(by: { $0.size.height < $1.size.height }) else { + return .zero + } + return maxImage.size.height / maxImage.size.width + } + + func getCarouselData(from carousel: Carousel?) { + guard let images = carousel?.images, !images.isEmpty else { + return + } + let sortedDetails = images.sorted { $0.key < $1.key }.prefix(Constants.CampaignMessage.carouselThreshold) + + self.links = sortedDetails.map { $0.value.link } + self.altTexts = sortedDetails.map { $0.value.altText } + } + + private func setupOrientationObserver() { + NotificationCenter.default.addObserver( + self, + selector: #selector(handleOrientationChange), + name: UIDevice.orientationDidChangeNotification, + object: nil + ) + } + + @objc private func handleOrientationChange() { + guard let collectionView = self.collectionView else { return } + + let visibleRect = CGRect(origin: collectionView.contentOffset, size: collectionView.bounds.size) + guard let visibleIndexPath = collectionView.indexPathForItem(at: CGPoint(x: visibleRect.midX, y: visibleRect.midY)) else { return } + + collectionView.collectionViewLayout.invalidateLayout() + DispatchQueue.main.async { + collectionView.scrollToItem(at: visibleIndexPath, at: .centeredHorizontally, animated: false) + } + } +} + +class CarouselCell: UICollectionViewCell { + static let identifier = "CarouselCell" + private let imageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + imageView.clipsToBounds = true + imageView.backgroundColor = .clear + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + private let textLabel: UILabel = { + let label = UILabel() + label.textColor = .black + label.font = UIFont.boldSystemFont(ofSize: 16) + label.textAlignment = .center + label.numberOfLines = 5 + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + setupViews() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupViews() + } + + private func setupViews() { + contentView.addSubview(imageView) + contentView.addSubview(textLabel) + } + + override func layoutSubviews() { + super.layoutSubviews() + + imageView.frame = contentView.bounds + + let maxTextWidth = contentView.bounds.width * 0.8 + let textSize = textLabel.sizeThatFits(CGSize(width: maxTextWidth, height: .greatestFiniteMagnitude)) + let textX = (contentView.bounds.width - textSize.width) / 2 + let textY = (contentView.bounds.height - textSize.height) / 2 + textLabel.frame = CGRect(x: textX, y: textY, width: textSize.width, height: textSize.height) + } + + func configure(with image: UIImage?, altText: String) { + if let image = image { + imageView.image = image + textLabel.isHidden = true + } else { + imageView.image = UIImage() + textLabel.isHidden = false + } + textLabel.text = altText + setNeedsLayout() + } +} + + diff --git a/Sources/RInAppMessaging/Views/FullView.swift b/Sources/RInAppMessaging/Views/FullView.swift index bb6daa23..ead9d294 100644 --- a/Sources/RInAppMessaging/Views/FullView.swift +++ b/Sources/RInAppMessaging/Views/FullView.swift @@ -40,6 +40,7 @@ internal class FullView: UIView, FullViewType, RichContentBrowsable { case textAndImage } + @IBOutlet weak var carouselView: CarouselView! @IBOutlet private(set) weak var contentView: UIView! // Wraps dialog view to allow rounded corners @IBOutlet private weak var backgroundView: UIView! @IBOutlet private weak var imageView: UIImageView! @@ -63,8 +64,9 @@ internal class FullView: UIView, FullViewType, RichContentBrowsable { @IBOutlet private weak var contentWidthOffsetConstraint: NSLayoutConstraint! @IBOutlet private weak var bodyViewOffsetYConstraint: NSLayoutConstraint! - private weak var exitButtonHeightConstraint: NSLayoutConstraint! + @IBOutlet weak var outOutButtonTopSpacer: UIView! + private weak var exitButtonHeightConstraint: NSLayoutConstraint! private let presenter: FullViewPresenterType var uiConstants = UIConstants() @@ -174,6 +176,19 @@ internal class FullView: UIView, FullViewType, RichContentBrowsable { exitButtonHeightConstraint.constant = uiConstants.textTopMarginForNotDismissableCampaigns } } + if isImageCarousel { + switch mode { + case .fullScreen: + carouselView.configure(images: viewModel.carouselImages!, + carouselData: viewModel.customJson?.carousel) + case .modal(let maxWindowHeightPercentage): + carouselView.configure(images: viewModel.carouselImages!, + carouselData: viewModel.customJson?.carousel, + maxHeightPercent: maxWindowHeightPercentage) + default: + assertionFailure("Unsupported mode") + } + } presenter.logImpression(type: .impression) } @@ -294,8 +309,12 @@ internal class FullView: UIView, FullViewType, RichContentBrowsable { } private func updateUIComponentsVisibility(viewModel: FullViewModel) { + imageView.isHidden = isImageCarousel + carouselView.isHidden = !isImageCarousel + carouselView.setPageControlVisibility(isHdden: !isImageCarousel) buttonsContainer.isHidden = !viewModel.showButtons optOutView.isHidden = !viewModel.showOptOut + outOutButtonTopSpacer.isHidden = isImageCarousel && (!buttonsContainer.isHidden || !optOutView.isHidden) optOutAndButtonsSpacer.isHidden = buttonsContainer.isHidden || optOutView.isHidden controlsView.isHidden = buttonsContainer.isHidden && optOutView.isHidden bodyView.isHidden = viewModel.isHTML || !viewModel.hasText diff --git a/Sources/RInAppMessaging/Views/Presenters/BaseViewPresenter.swift b/Sources/RInAppMessaging/Views/Presenters/BaseViewPresenter.swift index 3fa2d1ec..2564147c 100644 --- a/Sources/RInAppMessaging/Views/Presenters/BaseViewPresenter.swift +++ b/Sources/RInAppMessaging/Views/Presenters/BaseViewPresenter.swift @@ -3,6 +3,7 @@ import UIKit internal protocol BaseViewPresenterType: ImpressionTrackable { var campaign: Campaign! { get set } var associatedImage: UIImage? { get set } + var carouselImages: [UIImage?]? { get set } func viewDidInitialize() func handleButtonTrigger(_ trigger: Trigger?) @@ -20,6 +21,7 @@ internal class BaseViewPresenter: BaseViewPresenterType { var campaign: Campaign! var impressions: [Impression] = [] var associatedImage: UIImage? + var carouselImages: [UIImage?]? init(campaignRepository: CampaignRepositoryType, impressionService: ImpressionServiceType, diff --git a/Sources/RInAppMessaging/Views/Presenters/FullViewPresenter.swift b/Sources/RInAppMessaging/Views/Presenters/FullViewPresenter.swift index 8c91de78..2a0ae58a 100644 --- a/Sources/RInAppMessaging/Views/Presenters/FullViewPresenter.swift +++ b/Sources/RInAppMessaging/Views/Presenters/FullViewPresenter.swift @@ -51,7 +51,8 @@ internal class FullViewPresenter: BaseViewPresenter, FullViewPresenterType, Erro showOptOut: messagePayload.messageSettings.displaySettings.optOut, showButtons: !messagePayload.messageSettings.controlSettings.buttons.isEmpty, isDismissable: campaign.data.isCampaignDismissable, - customJson: campaign.data.customJson) + customJson: campaign.data.customJson, + carouselImages: carouselImages) view?.setup(viewModel: viewModel) } diff --git a/Sources/RInAppMessaging/Views/ViewModel/FullViewModel.swift b/Sources/RInAppMessaging/Views/ViewModel/FullViewModel.swift index 46ef78ce..3aa99ede 100644 --- a/Sources/RInAppMessaging/Views/ViewModel/FullViewModel.swift +++ b/Sources/RInAppMessaging/Views/ViewModel/FullViewModel.swift @@ -14,6 +14,7 @@ internal struct FullViewModel { let showButtons: Bool let isDismissable: Bool let customJson: CustomJson? + let carouselImages: [UIImage?]? var hasText: Bool { [header, messageBody].contains { $0?.isEmpty == false } diff --git a/Tests/Tests/Helpers/SharedMocks.swift b/Tests/Tests/Helpers/SharedMocks.swift index 19a523c0..deebc40c 100644 --- a/Tests/Tests/Helpers/SharedMocks.swift +++ b/Tests/Tests/Helpers/SharedMocks.swift @@ -345,11 +345,7 @@ class RouterMock: RouterType { private let displayQueue = DispatchQueue(label: "RouterMock.displayQueue") - func displayCampaign(_ campaign: Campaign, - associatedImageData: Data?, - confirmation: @escaping @autoclosure () -> Bool, - completion: @escaping (_ cancelled: Bool) -> Void) { - +func displayCampaign(_ campaign: Campaign, associatedImageData: Data?, carouselImages: [UIImage?]?, confirmation: @autoclosure @escaping () -> Bool, completion: @escaping (Bool) -> Void) { wasDisplayCampaignCalled = true guard confirmation() else { completion(true) diff --git a/Tests/Tests/Helpers/TestHelpers.swift b/Tests/Tests/Helpers/TestHelpers.swift index 7d0b5a5c..259e3dd5 100644 --- a/Tests/Tests/Helpers/TestHelpers.swift +++ b/Tests/Tests/Helpers/TestHelpers.swift @@ -141,7 +141,7 @@ struct TestHelpers { showOptOut: true, showButtons: true, isDismissable: true, - customJson: customJson) + customJson: customJson, carouselImages: nil) } enum MockResponse { diff --git a/Tests/Tests/RouterSpec.swift b/Tests/Tests/RouterSpec.swift index 2b581e82..7f75c7de 100644 --- a/Tests/Tests/RouterSpec.swift +++ b/Tests/Tests/RouterSpec.swift @@ -55,7 +55,7 @@ class RouterSpec: QuickSpec { it("will not show campaign message") { let campaign = TestHelpers.generateCampaign(id: "test", type: .modal) - router.displayCampaign(campaign, associatedImageData: nil, confirmation: false, completion: { _ in }) + router.displayCampaign(campaign, associatedImageData: nil, carouselImages: nil, confirmation: false, completion: { _ in }) expect(window.findIAMView()).toAfterTimeout(beNil()) } } @@ -66,28 +66,38 @@ class RouterSpec: QuickSpec { it("will show ModalView for modal campaign type") { let campaign = TestHelpers.generateCampaign(id: "test", type: .modal) - router.displayCampaign(campaign, associatedImageData: imageBlob, confirmation: true, completion: { _ in }) + router.displayCampaign(campaign, + associatedImageData: imageBlob, + carouselImages: nil, + confirmation: true, + completion: { _ in }) expect(window.subviews) .toEventually(containElementSatisfying({ $0 is ModalView })) } it("will show FullScreenView for full campaign type") { let campaign = TestHelpers.generateCampaign(id: "test", type: .full) - router.displayCampaign(campaign, associatedImageData: imageBlob, confirmation: true, completion: { _ in }) + router.displayCampaign(campaign, associatedImageData: imageBlob, + carouselImages: nil, + confirmation: true, + completion: { _ in }) expect(window.subviews) .toEventually(containElementSatisfying({ $0 is FullScreenView })) } it("will show SlideUpView for slide campaign type") { let campaign = TestHelpers.generateCampaign(id: "test", type: .slide) - router.displayCampaign(campaign, associatedImageData: imageBlob, confirmation: true, completion: { _ in }) + router.displayCampaign(campaign, associatedImageData: imageBlob, + carouselImages: nil, + confirmation: true, + completion: { _ in }) expect(window.subviews) .toEventually(containElementSatisfying({ $0 is SlideUpView })) } it("will not show any view for invalid campaign type") { let campaign = TestHelpers.generateCampaign(id: "test", type: .invalid) - router.displayCampaign(campaign, associatedImageData: nil, confirmation: true, completion: { _ in }) + router.displayCampaign(campaign, associatedImageData: nil, carouselImages: nil, confirmation: true, completion: { _ in }) expect(window.findIAMView()).toAfterTimeout(beNil()) } @@ -95,7 +105,7 @@ class RouterSpec: QuickSpec { let campaign = TestHelpers.generateCampaign(id: "test", type: .html) expect(router.displayCampaign( campaign, - associatedImageData: nil, + associatedImageData: nil, carouselImages: nil, confirmation: true, completion: { _ in })).to(throwAssertion()) expect(window.findIAMView()).toAfterTimeout(beNil()) @@ -104,10 +114,10 @@ class RouterSpec: QuickSpec { it("will not show another view when one is already displayed") { let campaign1 = TestHelpers.generateCampaign(id: "test", type: .modal) let campaign2 = TestHelpers.generateCampaign(id: "test", type: .full) - router.displayCampaign(campaign1, associatedImageData: nil, confirmation: true, completion: { _ in }) + router.displayCampaign(campaign1, associatedImageData: nil, carouselImages: nil, confirmation: true, completion: { _ in }) expect(window.subviews) .toEventually(containElementSatisfying({ $0 is ModalView })) // wait - router.displayCampaign(campaign2, associatedImageData: nil, confirmation: true, completion: { _ in }) + router.displayCampaign(campaign2, associatedImageData: nil, carouselImages: nil, confirmation: true, completion: { _ in }) expect(window.subviews) .toAfterTimeoutNot(containElementSatisfying({ $0 is FullScreenView })) expect(window.subviews) @@ -119,7 +129,7 @@ class RouterSpec: QuickSpec { let campaign = TestHelpers.generateCampaign(id: "test", type: .modal) expect(router.displayCampaign( campaign, - associatedImageData: nil, + associatedImageData: nil, carouselImages: nil, confirmation: false, completion: { _ in })).to(throwAssertion()) } @@ -130,6 +140,7 @@ class RouterSpec: QuickSpec { expect(router.displayCampaign( campaign, associatedImageData: nil, + carouselImages: nil, confirmation: false, completion: { _ in })).to(throwAssertion()) expect(window.findIAMView()).toAfterTimeout(beNil()) @@ -141,6 +152,7 @@ class RouterSpec: QuickSpec { expect(router.displayCampaign( campaign, associatedImageData: nil, + carouselImages: nil, confirmation: false, completion: { _ in })).to(throwAssertion()) expect(window.findIAMView()).toAfterTimeout(beNil()) @@ -149,7 +161,7 @@ class RouterSpec: QuickSpec { it("will add a view to the UIWindow's view when `accessibilityCompatible` is false") { router.accessibilityCompatibleDisplay = false let campaign = TestHelpers.generateCampaign(id: "test", type: .modal) - router.displayCampaign(campaign, associatedImageData: nil, confirmation: true, completion: { _ in }) + router.displayCampaign(campaign, associatedImageData: nil, carouselImages: nil, confirmation: true, completion: { _ in }) expect(window.findIAMView()).toEventuallyNot(beNil()) } @@ -159,7 +171,7 @@ class RouterSpec: QuickSpec { UIApplication.shared.keyWindow?.addSubview(rootView) let campaign = TestHelpers.generateCampaign(id: "test", type: .modal) - router.displayCampaign(campaign, associatedImageData: nil, confirmation: true, completion: { _ in }) + router.displayCampaign(campaign, associatedImageData: nil, carouselImages: nil, confirmation: true, completion: { _ in }) expect(rootView.findIAMView()).toEventuallyNot(beNil()) rootView.removeFromSuperview() } @@ -170,7 +182,7 @@ class RouterSpec: QuickSpec { it("will remove displayed campaign view") { let campaign = TestHelpers.generateCampaign(id: "test", type: .modal) - router.displayCampaign(campaign, associatedImageData: nil, confirmation: true, completion: { _ in }) + router.displayCampaign(campaign, associatedImageData: nil, carouselImages: nil, confirmation: true, completion: { _ in }) expect(window.findIAMView()).toEventuallyNot(beNil()) router.discardDisplayedCampaign() @@ -184,7 +196,7 @@ class RouterSpec: QuickSpec { it("will call onDismiss/completion callback with cancelled flag") { let campaign = TestHelpers.generateCampaign(id: "test", type: .modal) var completionCalled = false - router.displayCampaign(campaign, associatedImageData: nil, confirmation: true, completion: { cancelled in + router.displayCampaign(campaign, associatedImageData: nil, carouselImages: nil, confirmation: true, completion: { cancelled in completionCalled = true expect(cancelled).to(beTrue()) }) @@ -379,7 +391,7 @@ class RouterSpec: QuickSpec { UIApplication.shared.keyWindow?.addSubview(rootView) router.accessibilityCompatibleDisplay = true let campaign = TestHelpers.generateCampaign(id: "test", type: .modal) - router.displayCampaign(campaign, associatedImageData: nil, confirmation: true, completion: { _ in }) + router.displayCampaign(campaign, associatedImageData: nil, carouselImages: nil, confirmation: true, completion: { _ in }) expect(window.findIAMView()).toEventuallyNot(beNil()) router.displayTooltip(tooltip, @@ -578,7 +590,7 @@ class RouterSpec: QuickSpec { it("will insert a tooltip below existing campaign view") { let campaign = TestHelpers.generateCampaign(id: "test", type: .modal) - router.displayCampaign(campaign, associatedImageData: nil, confirmation: true, completion: { _ in }) + router.displayCampaign(campaign, associatedImageData: nil, carouselImages: nil, confirmation: true, completion: { _ in }) router.displayTooltip(tooltip, targetView: targetView, diff --git a/Tests/Tests/ViewModelSpec.swift b/Tests/Tests/ViewModelSpec.swift index 14f91d5e..3d02cf37 100644 --- a/Tests/Tests/ViewModelSpec.swift +++ b/Tests/Tests/ViewModelSpec.swift @@ -16,7 +16,7 @@ class ViewModelSpec: QuickSpec { messageBody: messageBody, header: header, titleColor: .black, headerColor: .black, messageBodyColor: .black, - isHTML: false, showOptOut: true, showButtons: true, isDismissable: true, customJson: nil) + isHTML: false, showOptOut: true, showButtons: true, isDismissable: true, customJson: nil, carouselImages: nil) } it("should return true if bodyMessage is not nil") { diff --git a/Tests/Tests/ViewSpec.swift b/Tests/Tests/ViewSpec.swift index ab9cb511..d9448919 100644 --- a/Tests/Tests/ViewSpec.swift +++ b/Tests/Tests/ViewSpec.swift @@ -267,6 +267,7 @@ class BaseViewTestObject: UIView, BaseView { } class BaseViewPresenterMock: BaseViewPresenterType { + var carouselImages: [UIImage?]? var campaign: Campaign! var impressions: [Impression] = [] var impressionService: ImpressionServiceType = ImpressionServiceMock() @@ -278,6 +279,7 @@ class BaseViewPresenterMock: BaseViewPresenterType { } class FullViewPresenterMock: FullViewPresenterType { + var carouselImages: [UIImage?]? var view: FullViewType? var campaign: Campaign! var impressions: [Impression] = [] @@ -298,6 +300,7 @@ class FullViewPresenterMock: FullViewPresenterType { } class SlideUpViewPresenterMock: SlideUpViewPresenterType { + var carouselImages: [UIImage?]? var view: SlideUpViewType? var campaign: Campaign! var impressions: [Impression] = [] @@ -329,7 +332,8 @@ extension FullViewModel { showOptOut: true, showButtons: true, isDismissable: true, - customJson: nil) + customJson: nil, + carouselImages: nil) } }