From 211f58aa083a7bc4ce16a8ca61efe7d42fb29d13 Mon Sep 17 00:00:00 2001 From: Team Mobile Schorsch Date: Wed, 31 Jul 2024 09:26:08 +0000 Subject: [PATCH] Release version 3.9.0 --- Package.swift | 5 +- .../Core/Custom views/GiniBarButton.swift | 16 ++ .../GiniCaptureSDK/Core/Extensions/Date.swift | 33 +++ .../Core/Extensions/UIViewController.swift | 5 +- .../Analysis/AnalysisViewController.swift | 10 +- .../Core/Screens/Camera/Camera.swift | 12 +- .../Camera/CameraButtonsViewModel.swift | 9 + .../Camera/CameraViewController+Actions.swift | 6 +- .../Camera/CameraViewController+Popups.swift | 54 ++-- .../Camera/Camera/CameraViewController.swift | 56 +++- .../Camera/CameraPreviewViewController.swift | 14 +- .../Views/CameraNotAuthorizedView.swift | 6 +- .../Error/ErrorScreenViewController.swift | 61 ++++- .../Core/Screens/Error/ErrorType.swift | 22 ++ .../HelpMenu/HelpMenuDataSource.swift | 14 +- .../HelpBottomBarEnabledViewController.swift | 1 + .../HelpMenuViewController.swift | 28 +- .../NoResult/BottomButtonsViewModel.swift | 17 +- .../NoResultScreenViewController.swift | 71 +++-- ...OnboardingNavigationBarBottomAdapter.swift | 5 +- .../Onboarding/DefaultOnboardingPage.swift | 61 +++++ .../Onboarding/OnboardingDataSource.swift | 181 ++++++------ .../Screens/Onboarding/OnboardingPage.swift | 4 +- .../Onboarding/OnboardingPageModel.swift | 39 +++ .../Onboarding/OnboardingPageTracker.swift | 27 ++ .../Onboarding/OnboardingViewController.swift | 139 +++++++--- .../Onboarding/Views/OnboardingPageCell.swift | 2 +- .../Screens/Review/ReviewViewController.swift | 31 ++- .../Review/ReviewZoomViewController.swift | 29 +- .../GiniScreenAPICoordinator+Analysis.swift | 114 +++----- .../GiniScreenAPICoordinator+Camera.swift | 30 +- .../GiniScreenAPICoordinator+Review.swift | 4 +- .../GiniScreenAPICoordinator.swift | 116 +++++--- .../Amplitude/AmplitudeBaseEvent.swift | 75 +++++ .../Amplitude/AmplitudeEventOptions.swift | 50 ++++ .../AmplitudeEventsBatchPayload.swift | 32 +++ .../Tracking/Amplitude/AmplitudeService.swift | 258 ++++++++++++++++++ .../Core/Tracking/GiniAnalyticsEvent.swift | 58 ++++ .../Core/Tracking/GiniAnalyticsManager.swift | 236 ++++++++++++++++ .../Core/Tracking/GiniAnalyticsMapper.swift | 87 ++++++ .../Tracking/GiniAnalyticsProperties.swift | 70 +++++ .../Core/Tracking/GiniAnalyticsScreen.swift | 27 ++ .../Tracking/GiniAnalyticsSuperProperty.swift | 12 + .../Tracking/GiniAnalyticsUserProperty.swift | 21 ++ .../GiniTrackingPermissionManager.swift | 31 +++ .../Models/GiniAnalyticsConfiguration.swift | 29 ++ .../GiniCameraPermissionStatusAnalytics.swift | 12 + .../Models/GiniEntryPointAnalytics.swift | 22 ++ .../Tracking/Models/GiniErrorAnalytics.swift | 19 ++ .../Models/GiniLineItemAnalytics.swift | 13 + .../Models/GiniQueuedAnalyticsEvent.swift | 13 + .../Core/Tracking/Utilities/IOSSystem.swift | 61 +++++ .../Tracking/Utilities/JSONCodingKeys.swift | 28 ++ .../Utilities/KeyedEncodingContainer.swift | 57 ++++ .../Utilities/UnkeyedEncodingContainer.swift | 44 +++ .../GiniCaptureSDKVersion.swift | 4 +- .../Networking/AnalysisResult.swift | 9 +- .../GiniCapture+GiniCaptureDelegate.swift | 3 +- .../GiniNetworkingScreenAPICoordinator.swift | 72 +++-- Sources/GiniCaptureSDK/PrivacyInfo.xcprivacy | 6 + .../GiniScreenAPICoordinatorTests.swift | 6 +- .../OnboardingDataSource.swift | 61 +++-- 62 files changed, 2151 insertions(+), 487 deletions(-) create mode 100644 Sources/GiniCaptureSDK/Core/Extensions/Date.swift create mode 100644 Sources/GiniCaptureSDK/Core/Screens/Onboarding/DefaultOnboardingPage.swift create mode 100644 Sources/GiniCaptureSDK/Core/Screens/Onboarding/OnboardingPageModel.swift create mode 100644 Sources/GiniCaptureSDK/Core/Screens/Onboarding/OnboardingPageTracker.swift create mode 100644 Sources/GiniCaptureSDK/Core/Tracking/Amplitude/AmplitudeBaseEvent.swift create mode 100644 Sources/GiniCaptureSDK/Core/Tracking/Amplitude/AmplitudeEventOptions.swift create mode 100644 Sources/GiniCaptureSDK/Core/Tracking/Amplitude/AmplitudeEventsBatchPayload.swift create mode 100644 Sources/GiniCaptureSDK/Core/Tracking/Amplitude/AmplitudeService.swift create mode 100644 Sources/GiniCaptureSDK/Core/Tracking/GiniAnalyticsEvent.swift create mode 100644 Sources/GiniCaptureSDK/Core/Tracking/GiniAnalyticsManager.swift create mode 100644 Sources/GiniCaptureSDK/Core/Tracking/GiniAnalyticsMapper.swift create mode 100644 Sources/GiniCaptureSDK/Core/Tracking/GiniAnalyticsProperties.swift create mode 100644 Sources/GiniCaptureSDK/Core/Tracking/GiniAnalyticsScreen.swift create mode 100644 Sources/GiniCaptureSDK/Core/Tracking/GiniAnalyticsSuperProperty.swift create mode 100644 Sources/GiniCaptureSDK/Core/Tracking/GiniAnalyticsUserProperty.swift create mode 100644 Sources/GiniCaptureSDK/Core/Tracking/GiniTrackingPermissionManager.swift create mode 100644 Sources/GiniCaptureSDK/Core/Tracking/Models/GiniAnalyticsConfiguration.swift create mode 100644 Sources/GiniCaptureSDK/Core/Tracking/Models/GiniCameraPermissionStatusAnalytics.swift create mode 100644 Sources/GiniCaptureSDK/Core/Tracking/Models/GiniEntryPointAnalytics.swift create mode 100644 Sources/GiniCaptureSDK/Core/Tracking/Models/GiniErrorAnalytics.swift create mode 100644 Sources/GiniCaptureSDK/Core/Tracking/Models/GiniLineItemAnalytics.swift create mode 100644 Sources/GiniCaptureSDK/Core/Tracking/Models/GiniQueuedAnalyticsEvent.swift create mode 100644 Sources/GiniCaptureSDK/Core/Tracking/Utilities/IOSSystem.swift create mode 100644 Sources/GiniCaptureSDK/Core/Tracking/Utilities/JSONCodingKeys.swift create mode 100644 Sources/GiniCaptureSDK/Core/Tracking/Utilities/KeyedEncodingContainer.swift create mode 100644 Sources/GiniCaptureSDK/Core/Tracking/Utilities/UnkeyedEncodingContainer.swift diff --git a/Package.swift b/Package.swift index c4aabf8..a72fe6a 100644 --- a/Package.swift +++ b/Package.swift @@ -16,7 +16,7 @@ let package = Package( dependencies: [ // Dependencies declare other packages that this package depends on. // .package(url: /* package url */, from: "1.0.0"), - .package(name: "GiniBankAPILibrary", url: "https://github.com/gini/bank-api-library-ios.git", .exact("3.1.3")), + .package(name: "GiniBankAPILibrary", url: "https://github.com/gini/bank-api-library-ios.git", .exact("3.2.0")) ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. @@ -24,7 +24,8 @@ let package = Package( .target( name: "GiniCaptureSDK", - dependencies: ["GiniBankAPILibrary"]), + dependencies: ["GiniBankAPILibrary" + ]), .testTarget( name: "GiniCaptureSDKTests", dependencies: ["GiniCaptureSDK"], diff --git a/Sources/GiniCaptureSDK/Core/Custom views/GiniBarButton.swift b/Sources/GiniCaptureSDK/Core/Custom views/GiniBarButton.swift index 3114a16..ca91795 100644 --- a/Sources/GiniCaptureSDK/Core/Custom views/GiniBarButton.swift +++ b/Sources/GiniCaptureSDK/Core/Custom views/GiniBarButton.swift @@ -28,6 +28,7 @@ public enum BarButtonType { case help case back(title: String) case done + case skip } /** @@ -151,6 +152,9 @@ public final class GiniBarButton { buttonTitle = NSLocalizedStringPreferredFormat("ginicapture.imagepicker.openbutton", comment: "Done") icon = UIImageNamedPreferred(named: "barButton_done") + case .skip: + buttonTitle = NSLocalizedStringPreferredFormat("ginicapture.onboarding.skip", + comment: "Skip button") } let buttonTitleIsEmpty = buttonTitle == nil || buttonTitle!.isEmpty @@ -188,6 +192,18 @@ public final class GiniBarButton { return attributes } + + public func setContentHuggingPriority(_ priority: UILayoutPriority, for axis: NSLayoutConstraint.Axis) { + stackView.setContentHuggingPriority(priority, for: axis) + titleLabel.setContentHuggingPriority(priority, for: axis) + imageView.setContentHuggingPriority(priority, for: axis) + } + + public func setContentCompressionResistancePriority(_ priority: UILayoutPriority, for axis: NSLayoutConstraint.Axis) { + stackView.setContentCompressionResistancePriority(priority, for: axis) + titleLabel.setContentCompressionResistancePriority(priority, for: axis) + imageView.setContentCompressionResistancePriority(priority, for: axis) + } } private extension GiniBarButton { diff --git a/Sources/GiniCaptureSDK/Core/Extensions/Date.swift b/Sources/GiniCaptureSDK/Core/Extensions/Date.swift new file mode 100644 index 0000000..02073a7 --- /dev/null +++ b/Sources/GiniCaptureSDK/Core/Extensions/Date.swift @@ -0,0 +1,33 @@ +// +// Date.swift +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation + +extension Date { + /** + Returns the current timestamp in milliseconds for the Berlin timezone. + + This method calculates the current date and time, adjusts it to the Berlin timezone, + and then returns the timestamp in milliseconds since the Unix epoch (January 1, 1970). + + - Returns: An `Int64` representing the current timestamp in milliseconds for the Berlin timezone. + */ + static func berlinTimestamp() -> Int64? { + let date = Date() + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + dateFormatter.timeZone = TimeZone(identifier: "Europe/Berlin") + let dateString = dateFormatter.string(from: date) + + // Convert the formatted date string back to a Date object + guard let berlinDate = dateFormatter.date(from: dateString) else { + print("Failed to convert string to date") + return nil + } + // Convert the time interval to milliseconds and then to Int64 + return Int64(berlinDate.timeIntervalSince1970 * 1000) + } +} diff --git a/Sources/GiniCaptureSDK/Core/Extensions/UIViewController.swift b/Sources/GiniCaptureSDK/Core/Extensions/UIViewController.swift index c730de5..93bfd48 100644 --- a/Sources/GiniCaptureSDK/Core/Extensions/UIViewController.swift +++ b/Sources/GiniCaptureSDK/Core/Extensions/UIViewController.swift @@ -48,7 +48,10 @@ extension UIViewController { cancelActionTitle: cancelActionTitle, confirmActionTitle: confirmActionTitle, confirmAction: positiveAction) - + + GiniAnalyticsManager.track(event: .errorDialogShown, + screenName: .camera, + properties: [GiniAnalyticsProperty(key: .errorMessage, value: message)]) present(dialog, animated: true, completion: nil) } diff --git a/Sources/GiniCaptureSDK/Core/Screens/Analysis/AnalysisViewController.swift b/Sources/GiniCaptureSDK/Core/Screens/Analysis/AnalysisViewController.swift index 916212a..68ea69e 100644 --- a/Sources/GiniCaptureSDK/Core/Screens/Analysis/AnalysisViewController.swift +++ b/Sources/GiniCaptureSDK/Core/Screens/Analysis/AnalysisViewController.swift @@ -18,9 +18,8 @@ import UIKit - parameter message: The error type to be displayed. */ - func displayError( - errorType: ErrorType, - animated: Bool + func displayError(errorType: ErrorType, + animated: Bool ) /** @@ -142,6 +141,11 @@ import UIKit override public func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) didShowAnalysis?() + + let eventProperties = [GiniAnalyticsProperty(key: .documentType, + value: GiniAnalyticsMapper.documentTypeAnalytics(from: document.type))] + GiniAnalyticsManager.trackScreenShown(screenName: .analysis, + properties: eventProperties) } // MARK: Toggle animation diff --git a/Sources/GiniCaptureSDK/Core/Screens/Camera/Camera.swift b/Sources/GiniCaptureSDK/Core/Screens/Camera/Camera.swift index d4c45dd..c53954a 100644 --- a/Sources/GiniCaptureSDK/Core/Screens/Camera/Camera.swift +++ b/Sources/GiniCaptureSDK/Core/Screens/Camera/Camera.swift @@ -319,12 +319,14 @@ fileprivate extension Camera { completion(.success(videoDevice)) case .notDetermined: AVCaptureDevice.requestAccess(for: .video) { granted in + GiniAnalyticsManager.track(event: .cameraPermissionShown, screenName: .cameraPermissionView) + let permissionStatus: GiniCameraPermissionStatusAnalytics = granted ? .allowed : .notAllowed + let eventProperties = [GiniAnalyticsProperty(key: .permissionStatus, value: permissionStatus.rawValue)] + GiniAnalyticsManager.track(event: .cameraPermissionTapped, + screenName: .cameraPermissionView, + properties: eventProperties) DispatchQueue.main.async { - if granted { - completion(.success(videoDevice)) - } else { - completion(.failure(.notAuthorizedToUseDevice)) - } + completion(granted ? .success(videoDevice) : .failure(.notAuthorizedToUseDevice)) } } case .denied, .restricted: diff --git a/Sources/GiniCaptureSDK/Core/Screens/Camera/Camera/CameraButtonsViewModel.swift b/Sources/GiniCaptureSDK/Core/Screens/Camera/Camera/CameraButtonsViewModel.swift index 1bddc01..931e02e 100644 --- a/Sources/GiniCaptureSDK/Core/Screens/Camera/Camera/CameraButtonsViewModel.swift +++ b/Sources/GiniCaptureSDK/Core/Screens/Camera/Camera/CameraButtonsViewModel.swift @@ -39,17 +39,26 @@ public final class CameraButtonsViewModel { @objc func toggleFlash() { isFlashOn = !isFlashOn flashAction?(isFlashOn) + + GiniAnalyticsManager.track(event: .flashTapped, + screenName: .camera, + properties: [GiniAnalyticsProperty(key: .flashActive, value: isFlashOn)]) } @objc func importPressed() { + GiniAnalyticsManager.track(event: .importFilesTapped, screenName: .camera) importAction?() } @objc func thumbnailPressed() { + GiniAnalyticsManager.track(event: .multiplePagesCapturedTapped, + screenName: .camera, + properties: [GiniAnalyticsProperty(key: .numberOfPagesScanned, value: images.count)]) imageStackAction?() } @objc func cancelPressed() { + GiniAnalyticsManager.track(event: .closeTapped, screenName: .camera) cancelAction?() } diff --git a/Sources/GiniCaptureSDK/Core/Screens/Camera/Camera/CameraViewController+Actions.swift b/Sources/GiniCaptureSDK/Core/Screens/Camera/Camera/CameraViewController+Actions.swift index ad13b2b..1cffc22 100644 --- a/Sources/GiniCaptureSDK/Core/Screens/Camera/Camera/CameraViewController+Actions.swift +++ b/Sources/GiniCaptureSDK/Core/Screens/Camera/Camera/CameraViewController+Actions.swift @@ -1,9 +1,7 @@ // -// Camera2ViewController+Actions.swift -// +// CameraViewController+Actions.swift // -// Created by Krzysztof Kryniecki on 14/09/2022. -// Copyright © 2022 Gini GmbH. All rights reserved. +// Copyright © 2024 Gini GmbH. All rights reserved. // import UIKit diff --git a/Sources/GiniCaptureSDK/Core/Screens/Camera/Camera/CameraViewController+Popups.swift b/Sources/GiniCaptureSDK/Core/Screens/Camera/Camera/CameraViewController+Popups.swift index bac3978..63c3a98 100644 --- a/Sources/GiniCaptureSDK/Core/Screens/Camera/Camera/CameraViewController+Popups.swift +++ b/Sources/GiniCaptureSDK/Core/Screens/Camera/Camera/CameraViewController+Popups.swift @@ -1,6 +1,6 @@ // -// Camera2ViewController+Extension.swift -// +// CameraViewController+Extension.swift +// // // Created by Krzysztof Kryniecki on 14/09/2022. // Copyright © 2022 Gini GmbH. All rights reserved. @@ -13,36 +13,40 @@ import UIKit extension CameraViewController { @objc func showImportFileSheet() { - let alertViewController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) - let alertViewControllerMessage: String = NSLocalizedStringPreferredFormat( - "ginicapture.camera.popupTitleImportPDForPhotos", - comment: "Info label") + let alertMessage = NSLocalizedStringPreferredFormat("ginicapture.camera.popupTitleImportPDForPhotos", + comment: "Info label") + let message = alertMessage.isEmpty ? nil : alertMessage + let alertViewController = UIAlertController(title: nil, + message: message, + preferredStyle: .actionSheet) + if giniConfiguration.fileImportSupportedTypes == .pdf_and_images { - alertViewController.addAction(UIAlertAction(title: NSLocalizedStringPreferredFormat( - "ginicapture.camera.popupOptionPhotos", - comment: "Photos action"), - style: .default) { [unowned self] _ in + let photosAlertActionTitle = NSLocalizedStringPreferredFormat("ginicapture.camera.popupOptionPhotos", + comment: "Photos action") + let photosAlertAction = UIAlertAction(title: photosAlertActionTitle, + style: .default) { [unowned self] _ in + GiniAnalyticsManager.track(event: .uploadPhotosTapped, screenName: .camera) self.delegate?.camera(self, didSelect: .gallery) - }) + } + alertViewController.addAction(photosAlertAction) } alertViewController.view.tintColor = .GiniCapture.accent1 - - alertViewController.addAction(UIAlertAction(title: NSLocalizedStringPreferredFormat( - "ginicapture.camera.popupOptionFiles", - comment: "files action"), - style: .default) { [unowned self] _ in + let filesAlertActionTitle = NSLocalizedStringPreferredFormat("ginicapture.camera.popupOptionFiles", + comment: "files action") + let filesAlertAction = UIAlertAction(title: filesAlertActionTitle, + style: .default) { [unowned self] _ in + GiniAnalyticsManager.track(event: .uploadDocumentsTapped, screenName: .camera) self.delegate?.camera(self, didSelect: .explorer) - }) - alertViewController.addAction(UIAlertAction(title: NSLocalizedStringPreferredFormat( - "ginicapture.camera.popupCancel", - comment: "cancel action"), - style: .cancel, handler: nil)) - if alertViewControllerMessage.count > 0 { - alertViewController.message = alertViewControllerMessage - } else { - alertViewController.message = nil } + alertViewController.addAction(filesAlertAction) + + let cancelAlertActionTitle = NSLocalizedStringPreferredFormat("ginicapture.camera.popupCancel", + comment: "cancel action") + let cancelAlertAction = UIAlertAction(title: cancelAlertActionTitle, + style: .cancel, handler: nil) + alertViewController.addAction(cancelAlertAction) + alertViewController.popoverPresentationController?.sourceView = cameraPane.fileUploadButton self.present(alertViewController, animated: true, completion: nil) } diff --git a/Sources/GiniCaptureSDK/Core/Screens/Camera/Camera/CameraViewController.swift b/Sources/GiniCaptureSDK/Core/Screens/Camera/Camera/CameraViewController.swift index cd3fa9e..dbb8f9d 100644 --- a/Sources/GiniCaptureSDK/Core/Screens/Camera/Camera/CameraViewController.swift +++ b/Sources/GiniCaptureSDK/Core/Screens/Camera/Camera/CameraViewController.swift @@ -42,6 +42,9 @@ final class CameraViewController: UIViewController { private var validQRCodeProcessing: Bool = false private var isValidIBANDetected: Bool = false + // Analytics + private var invalidQRCodeOverlayFirstAppearance: Bool = true + private var ibanOverlayFirstAppearance: Bool = true weak var delegate: CameraViewControllerDelegate? @@ -107,6 +110,10 @@ final class CameraViewController: UIViewController { super.viewDidAppear(animated) validQRCodeProcessing = false delegate?.cameraDidAppear(self) + + // this event should be sent every time when the user sees this screen, including + // when coming back from Help + GiniAnalyticsManager.trackScreenShown(screenName: .camera) } fileprivate func configureTitle() { @@ -285,6 +292,7 @@ final class CameraViewController: UIViewController { cameraPane.setupAuthorization(isHidden: false) configureLeftButtons() cameraButtonsViewModel.captureAction = { [weak self] in + self?.sendGiniAnalyticsEventCapture() self?.cameraPane.toggleCaptureButtonActivation(state: false) self?.cameraPreviewViewController.captureImage { [weak self] data, error in guard let self = self else { return } @@ -334,6 +342,16 @@ final class CameraViewController: UIViewController { for: .touchUpInside) } + private func sendGiniAnalyticsEventCapture() { + let eventProperties = [GiniAnalyticsProperty(key: .ibanDetectionLayerVisible, + value: !ibanDetectionOverLay.isHidden)] + + GiniAnalyticsManager.track(event: .captureTapped, + screenName: .camera, + properties: eventProperties) + + } + override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) @@ -485,16 +503,21 @@ final class CameraViewController: UIViewController { } private func showIBANOverlay(with IBANs: [String]) { - UIView.animate(withDuration: 0.3) { self.ibanDetectionOverLay.isHidden = false self.cameraPreviewViewController.changeCameraFrameColor(to: .GiniCapture.success2) } + sendGiniAnalyticsEventIBANDetection() ibanDetectionOverLay.configureOverlay(hidden: false) ibanDetectionOverLay.setupView(with: IBANs) } + private func sendGiniAnalyticsEventIBANDetection () { + guard ibanOverlayFirstAppearance else { return } + ibanOverlayFirstAppearance = false + } + private func hideIBANOverlay() { guard !ibanDetectionOverLay.isHidden else { return } UIView.animate(withDuration: 0.3) { @@ -548,6 +571,10 @@ final class CameraViewController: UIViewController { self.cameraPreviewViewController.changeQRFrameColor(to: .GiniCapture.success2) } + // this event is sent once per SDK session since the message can be displayed often in the same session + GiniAnalyticsManager.track(event: .qr_code_scanned, + screenName: .camera, + properties: [GiniAnalyticsProperty(key: .qrCodeValid, value: true)]) qrCodeOverLay.configureQrCodeOverlay(withCorrectQrCode: true) } @@ -561,10 +588,19 @@ final class CameraViewController: UIViewController { self.qrCodeOverLay.isHidden = false self.cameraPreviewViewController.changeQRFrameColor(to: .GiniCapture.warning3) } - + sendGiniAnalyticsEventForInvalidQRCode() qrCodeOverLay.configureQrCodeOverlay(withCorrectQrCode: false) } + private func sendGiniAnalyticsEventForInvalidQRCode() { + guard invalidQRCodeOverlayFirstAppearance else { return } + // this event is sent once per SDK session since the message can be displayed often in the same session + GiniAnalyticsManager.track(event: .qr_code_scanned, + screenName: .camera, + properties: [GiniAnalyticsProperty(key: .qrCodeValid, value: false)]) + invalidQRCodeOverlayFirstAppearance = false + } + private func resetQRCodeScanning(isValid: Bool) { resetQRCodeTask = DispatchWorkItem(block: { self.detectedQRCodeDocument = nil @@ -633,14 +669,14 @@ extension CameraViewController: CameraLensSwitcherViewDelegate { var device: AVCaptureDevice? switch lens { - case .ultraWide: - if #available(iOS 13.0, *) { - device = AVCaptureDevice.default(.builtInUltraWideCamera, for: .video, position: .back) - } - case .wide: - device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) - case .tele: - device = AVCaptureDevice.default(.builtInTelephotoCamera, for: .video, position: .back) + case .ultraWide: + if #available(iOS 13.0, *) { + device = AVCaptureDevice.default(.builtInUltraWideCamera, for: .video, position: .back) + } + case .wide: + device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) + case .tele: + device = AVCaptureDevice.default(.builtInTelephotoCamera, for: .video, position: .back) } guard let device = device else { return } diff --git a/Sources/GiniCaptureSDK/Core/Screens/Camera/CameraPreviewViewController.swift b/Sources/GiniCaptureSDK/Core/Screens/Camera/CameraPreviewViewController.swift index 5344876..df8c386 100644 --- a/Sources/GiniCaptureSDK/Core/Screens/Camera/CameraPreviewViewController.swift +++ b/Sources/GiniCaptureSDK/Core/Screens/Camera/CameraPreviewViewController.swift @@ -86,7 +86,8 @@ final class CameraPreviewViewController: UIViewController { return UIImageNamedPreferred(named: "cameraDefaultDocumentImage") } } - private var isAuthorized = false + + var isAuthorized = false lazy var previewView: CameraPreviewView = { let previewView = CameraPreviewView() @@ -290,13 +291,6 @@ final class CameraPreviewViewController: UIViewController { } func setupCamera() { - if AVCaptureDevice.authorizationStatus(for: .video) != .authorized { - #if !targetEnvironment(simulator) - self.addNotAuthorizedView() - self.delegate?.notAuthorized() - #endif - } - camera.setup { error in if let error = error { switch error { @@ -418,6 +412,10 @@ final class CameraPreviewViewController: UIViewController { extension CameraPreviewViewController { private func addNotAuthorizedView() { + // Send the 'screen_shown' event every time the user returns to this screen. + GiniAnalyticsManager.trackScreenShown(screenName: .cameraAccess) + + guard notAuthorizedView == nil else { return } let notAuthorizedView = CameraNotAuthorizedView() self.notAuthorizedView = notAuthorizedView super.view.addSubview(notAuthorizedView) diff --git a/Sources/GiniCaptureSDK/Core/Screens/Camera/Views/CameraNotAuthorizedView.swift b/Sources/GiniCaptureSDK/Core/Screens/Camera/Views/CameraNotAuthorizedView.swift index 3e943c3..aabadf3 100644 --- a/Sources/GiniCaptureSDK/Core/Screens/Camera/Views/CameraNotAuthorizedView.swift +++ b/Sources/GiniCaptureSDK/Core/Screens/Camera/Views/CameraNotAuthorizedView.swift @@ -1,13 +1,10 @@ // // CameraNotAuthorizedView.swift -// GiniCapture // -// Created by Peter Pult on 06/07/16. -// Copyright © 2016 Gini GmbH. All rights reserved. +// Copyright © 2024 Gini GmbH. All rights reserved. // import UIKit -import AVFoundation final class CameraNotAuthorizedView: UIView { // User interface @@ -94,6 +91,7 @@ final class CameraNotAuthorizedView: UIView { @objc private func openSettings() { + GiniAnalyticsManager.track(event: .giveAccessTapped, screenName: .cameraAccess) UIApplication.shared.openAppSettings() } diff --git a/Sources/GiniCaptureSDK/Core/Screens/Error/ErrorScreenViewController.swift b/Sources/GiniCaptureSDK/Core/Screens/Error/ErrorScreenViewController.swift index e295aea..9add839 100644 --- a/Sources/GiniCaptureSDK/Core/Screens/Error/ErrorScreenViewController.swift +++ b/Sources/GiniCaptureSDK/Core/Screens/Error/ErrorScreenViewController.swift @@ -71,12 +71,10 @@ class ErrorScreenViewController: UIViewController { - returns: A view controller instance allowing the user to take a picture or pick a document. */ - public init( - giniConfiguration: GiniConfiguration, - type: ErrorType, - documentType: GiniCaptureDocumentType, - viewModel: BottomButtonsViewModel - ) { + public init(giniConfiguration: GiniConfiguration, + type: ErrorType, + documentType: GiniCaptureDocumentType, + viewModel: BottomButtonsViewModel) { self.giniConfiguration = giniConfiguration self.viewModel = viewModel self.errorType = type @@ -91,6 +89,26 @@ class ErrorScreenViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() setupView() + + sendAnalyticsScreenShown() + } + + private func sendAnalyticsScreenShown() { + var eventProperties = [GiniAnalyticsProperty(key: .documentType, + value: GiniAnalyticsMapper.documentTypeAnalytics(from: documentType))] + + let errorAnalytics = errorType.errorAnalytics() + eventProperties.append(GiniAnalyticsProperty(key: .errorType, value: errorAnalytics.type)) + if let code = errorAnalytics.code { + eventProperties.append(GiniAnalyticsProperty(key: .errorCode, value: code)) + } + + if let reason = errorAnalytics.reason { + eventProperties.append(GiniAnalyticsProperty(key: .errorMessage, value: reason)) + } + + GiniAnalyticsManager.trackScreenShown(screenName: .error, + properties: eventProperties) } func setupView() { @@ -134,19 +152,17 @@ class ErrorScreenViewController: UIViewController { } private func configureButtons() { - buttonsView.enterButton.addTarget( - viewModel, - action: #selector(viewModel.didPressEnterManually), - for: .touchUpInside) - buttonsView.retakeButton.addTarget( - viewModel, - action: #selector(viewModel.didPressRetake), - for: .touchUpInside) + buttonsView.enterButton.addTarget(self, + action: #selector(didPressEnterManually), + for: .touchUpInside) + buttonsView.retakeButton.addTarget(self, + action: #selector(didPressRetake), + for: .touchUpInside) } private func configureCustomTopNavigationBar() { let cancelButton = GiniBarButton(ofType: .cancel) - cancelButton.addAction(viewModel, #selector(viewModel.didPressCancell)) + cancelButton.addAction(self, #selector(didPressCancel)) if giniConfiguration.bottomNavigationBarEnabled { navigationItem.rightBarButtonItem = cancelButton.barButton @@ -157,6 +173,21 @@ class ErrorScreenViewController: UIViewController { } } + @objc func didPressEnterManually() { + GiniAnalyticsManager.track(event: .enterManuallyTapped, screenName: .error) + viewModel.didPressEnterManually() + } + + @objc func didPressRetake() { + GiniAnalyticsManager.track(event: .backToCameraTapped, screenName: .error) + viewModel.didPressRetake() + } + + @objc func didPressCancel() { + GiniAnalyticsManager.track(event: .closeTapped, screenName: .error) + viewModel.didPressCancel() + } + private func getButtonsMinHeight(numberOfButtons: Int) -> CGFloat { if numberOfButtons == 1 { return Constants.singleButtonHeight diff --git a/Sources/GiniCaptureSDK/Core/Screens/Error/ErrorType.swift b/Sources/GiniCaptureSDK/Core/Screens/Error/ErrorType.swift index 25844d5..e56ae61 100644 --- a/Sources/GiniCaptureSDK/Core/Screens/Error/ErrorType.swift +++ b/Sources/GiniCaptureSDK/Core/Screens/Error/ErrorType.swift @@ -17,6 +17,7 @@ import GiniBankAPILibrary - authentication: Error related to authentication. - unexpected: Unexpected error that is not covered by the other cases. - maintenance: Error returned when the system is under maintenance. + - outage: Error indicating that the service is unavailable due to outage. */ @objc public enum ErrorType: Int { @@ -28,6 +29,9 @@ import GiniBankAPILibrary case maintenance case outage + // Dictionary to store ErrorAnalytics for each case + private static var errorAnalyticsDictionary: [ErrorType: GiniErrorAnalytics] = [:] + /** Initializes a new instance of the `ErrorType` enum based on the given `GiniError`. @@ -55,6 +59,11 @@ import GiniBankAPILibrary default: self = .unexpected } + + // Generate error analytics using GiniAnalyticsMapper + let errorAnalytics = GiniAnalyticsMapper.errorAnalytics(from: error) + // Store error analytics in the dictionary + ErrorType.errorAnalyticsDictionary[self] = errorAnalytics } func iconName() -> String { @@ -139,4 +148,17 @@ import GiniBankAPILibrary comment: "Outage error") } } + + /** + Get the error analytics for the current `ErrorType`. + + - Returns: An `GiniErrorAnalytics` object representing the error for the analytics + */ + func errorAnalytics() -> GiniErrorAnalytics { + // Define a default unknown error + let unknownError = GiniErrorAnalytics(type: "Unknown", code: nil, + reason: "Error analytics not found for \(self)") + // Attempt to retrieve the error analytics from the dictionary + return ErrorType.errorAnalyticsDictionary[self] ?? unknownError + } } diff --git a/Sources/GiniCaptureSDK/Core/Screens/Help/DataSources/HelpMenu/HelpMenuDataSource.swift b/Sources/GiniCaptureSDK/Core/Screens/Help/DataSources/HelpMenu/HelpMenuDataSource.swift index 28fc2f2..35b6db4 100644 --- a/Sources/GiniCaptureSDK/Core/Screens/Help/DataSources/HelpMenu/HelpMenuDataSource.swift +++ b/Sources/GiniCaptureSDK/Core/Screens/Help/DataSources/HelpMenu/HelpMenuDataSource.swift @@ -9,7 +9,7 @@ import UIKit protocol HelpMenuDataSourceDelegate: UIViewController { - func didSelectHelpItem(didSelect item: HelpMenuItem) + func didSelectHelpItem(at index: Int) } final class HelpMenuDataSource: HelpRoundedCornersDataSource { @@ -26,14 +26,17 @@ final class HelpMenuDataSource: HelpRoundedCornersDataSource CGFloat { diff --git a/Sources/GiniCaptureSDK/Core/Screens/Help/Protocols/HelpBottomBarEnabledViewController.swift b/Sources/GiniCaptureSDK/Core/Screens/Help/Protocols/HelpBottomBarEnabledViewController.swift index bfe6b04..83beca9 100644 --- a/Sources/GiniCaptureSDK/Core/Screens/Help/Protocols/HelpBottomBarEnabledViewController.swift +++ b/Sources/GiniCaptureSDK/Core/Screens/Help/Protocols/HelpBottomBarEnabledViewController.swift @@ -53,6 +53,7 @@ extension HelpBottomBarEnabledViewController { } navigationBarBottomAdapter?.setBackButtonClickedActionCallback { [weak self] in + GiniAnalyticsManager.track(event: .closeTapped, screenName: .help) self?.navigationController?.popViewController(animated: true) } if let adapter = navigationBarBottomAdapter { diff --git a/Sources/GiniCaptureSDK/Core/Screens/Help/ViewControllers/HelpMenuViewController.swift b/Sources/GiniCaptureSDK/Core/Screens/Help/ViewControllers/HelpMenuViewController.swift index 16a98a2..8cdbccf 100644 --- a/Sources/GiniCaptureSDK/Core/Screens/Help/ViewControllers/HelpMenuViewController.swift +++ b/Sources/GiniCaptureSDK/Core/Screens/Help/ViewControllers/HelpMenuViewController.swift @@ -1,9 +1,7 @@ // // HelpMenuViewController.swift -// GiniCapture // -// Created by Enrique del Pozo Gómez on 10/18/17. -// Copyright © 2017 Gini GmbH. All rights reserved. +// Copyright © 2024 Gini GmbH. All rights reserved. // import UIKit @@ -58,6 +56,23 @@ final class HelpMenuViewController: UIViewController, HelpBottomBarEnabledViewCo setupView() } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + sendGiniAnalyticsEventScreenShown() + } + + private func sendGiniAnalyticsEventScreenShown() { + guard dataSource.helpItemsAnalyticsValues.isNotEmpty else { return } + var eventProperties = [GiniAnalyticsProperty(key: .hasCustomItems, + value: giniConfiguration.customMenuItems.isNotEmpty)] + + eventProperties.append(GiniAnalyticsProperty(key: .helpItems, + value: dataSource.helpItemsAnalyticsValues)) + GiniAnalyticsManager.trackScreenShown(screenName: .help, + properties: eventProperties) + } + private func setupView() { configureMainView() configureTableView() @@ -135,7 +150,12 @@ final class HelpMenuViewController: UIViewController, HelpBottomBarEnabledViewCo // MARK: - HelpMenuDataSourceDelegate extension HelpMenuViewController: HelpMenuDataSourceDelegate { - func didSelectHelpItem(didSelect item: HelpMenuItem) { + func didSelectHelpItem(at index: Int) { + let item = dataSource.items[index] + GiniAnalyticsManager.track(event: .helpItemTapped, + screenName: .help, + properties: [GiniAnalyticsProperty(key: .itemTapped, + value: item.title)]) delegate?.help(self, didSelect: item) } } diff --git a/Sources/GiniCaptureSDK/Core/Screens/NoResult/BottomButtonsViewModel.swift b/Sources/GiniCaptureSDK/Core/Screens/NoResult/BottomButtonsViewModel.swift index 521adb2..edcd985 100644 --- a/Sources/GiniCaptureSDK/Core/Screens/NoResult/BottomButtonsViewModel.swift +++ b/Sources/GiniCaptureSDK/Core/Screens/NoResult/BottomButtonsViewModel.swift @@ -9,14 +9,13 @@ import Foundation final class BottomButtonsViewModel { - let retakePressed: (() -> Void)? - let enterManuallyPressed: (() -> Void)? - let cancelPressed: (() -> Void) - - init( - retakeBlock: (() -> Void)? = nil, - manuallyPressed: (() -> Void)? = nil, - cancelPressed: @escaping(() -> Void)) { + private let retakePressed: (() -> Void)? + private let enterManuallyPressed: (() -> Void)? + private let cancelPressed: (() -> Void) + + init(retakeBlock: (() -> Void)? = nil, + manuallyPressed: (() -> Void)? = nil, + cancelPressed: @escaping(() -> Void)) { self.retakePressed = retakeBlock self.enterManuallyPressed = manuallyPressed self.cancelPressed = cancelPressed @@ -32,7 +31,7 @@ final class BottomButtonsViewModel { enterManuallyPressed?() } - @objc func didPressCancell() { + @objc func didPressCancel() { errorOccurred = false cancelPressed() } diff --git a/Sources/GiniCaptureSDK/Core/Screens/NoResult/NoResultScreenViewController.swift b/Sources/GiniCaptureSDK/Core/Screens/NoResult/NoResultScreenViewController.swift index 7778cc0..8f752ed 100644 --- a/Sources/GiniCaptureSDK/Core/Screens/NoResult/NoResultScreenViewController.swift +++ b/Sources/GiniCaptureSDK/Core/Screens/NoResult/NoResultScreenViewController.swift @@ -83,11 +83,9 @@ final class NoResultScreenViewController: UIViewController { }).count } - public init( - giniConfiguration: GiniConfiguration, - type: NoResultType, - viewModel: BottomButtonsViewModel - ) { + public init(giniConfiguration: GiniConfiguration, + type: NoResultType, + viewModel: BottomButtonsViewModel) { self.giniConfiguration = giniConfiguration self.type = type switch type { @@ -113,8 +111,13 @@ final class NoResultScreenViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() self.setupView() - } + let eventProperties = [GiniAnalyticsProperty(key: .documentType, + value: GiniAnalyticsMapper.documentTypeAnalytics(from: type))] + GiniAnalyticsManager.trackScreenShown(screenName: .noResults, + properties: eventProperties) + } + override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() if numberOfButtons > 0 { @@ -151,31 +154,26 @@ final class NoResultScreenViewController: UIViewController { } private func configureMainView() { - title = NSLocalizedStringPreferredFormat( - "ginicapture.noresult.title", - comment: "No result screen title") - header.iconImageView.accessibilityLabel = NSLocalizedStringPreferredFormat( - "ginicapture.noresult.title", - comment: "No result screen title") + title = NSLocalizedStringPreferredFormat("ginicapture.noresult.title", + comment: "No result screen title") + header.iconImageView.accessibilityLabel = NSLocalizedStringPreferredFormat("ginicapture.noresult.title", + comment: "No result screen title") header.headerLabel.text = type.description header.headerLabel.font = giniConfiguration.textStyleFonts[.subheadline] - header.headerLabel.textColor = GiniColor( - light: UIColor.GiniCapture.dark1, - dark: UIColor.GiniCapture.light1 - ).uiColor() - view.backgroundColor = GiniColor(light: UIColor.GiniCapture.light2, dark: UIColor.GiniCapture.dark2).uiColor() + header.headerLabel.textColor = GiniColor(light: UIColor.GiniCapture.dark1, + dark: UIColor.GiniCapture.light1).uiColor() + view.backgroundColor = GiniColor(light: UIColor.GiniCapture.light2, + dark: UIColor.GiniCapture.dark2).uiColor() view.addSubview(header) view.addSubview(tableView) view.addSubview(buttonsView) - header.backgroundColor = GiniColor( - light: UIColor.GiniCapture.error4, - dark: UIColor.GiniCapture.error1 - ).uiColor() + header.backgroundColor = GiniColor(light: UIColor.GiniCapture.error4, + dark: UIColor.GiniCapture.error1).uiColor() } private func configureCustomTopNavigationBar() { let cancelButton = GiniBarButton(ofType: .cancel) - cancelButton.addAction(viewModel, #selector(viewModel.didPressCancell)) + cancelButton.addAction(self, #selector(didPressCancel)) if giniConfiguration.bottomNavigationBarEnabled { navigationItem.rightBarButtonItem = cancelButton.barButton @@ -240,14 +238,27 @@ final class NoResultScreenViewController: UIViewController { } private func configureButtons() { - buttonsView.enterButton.addTarget( - viewModel, - action: #selector(viewModel.didPressEnterManually), - for: .touchUpInside) - buttonsView.retakeButton.addTarget( - viewModel, - action: #selector(viewModel.didPressRetake), - for: .touchUpInside) + buttonsView.enterButton.addTarget(self, + action: #selector(didPressEnterManually), + for: .touchUpInside) + buttonsView.retakeButton.addTarget(self, + action: #selector(didPressRetake), + for: .touchUpInside) + } + + @objc func didPressEnterManually() { + GiniAnalyticsManager.track(event: .enterManuallyTapped, screenName: .noResults) + viewModel.didPressEnterManually() + } + + @objc func didPressRetake() { + GiniAnalyticsManager.track(event: .retakeImagesTapped, screenName: .noResults) + viewModel.didPressRetake() + } + + @objc func didPressCancel() { + GiniAnalyticsManager.track(event: .closeTapped, screenName: .noResults) + viewModel.didPressCancel() } private func configureHeaderContraints() { diff --git a/Sources/GiniCaptureSDK/Core/Screens/Onboarding/DefaultOnboardingNavigationBarBottomAdapter.swift b/Sources/GiniCaptureSDK/Core/Screens/Onboarding/DefaultOnboardingNavigationBarBottomAdapter.swift index 3841888..49a0c8f 100644 --- a/Sources/GiniCaptureSDK/Core/Screens/Onboarding/DefaultOnboardingNavigationBarBottomAdapter.swift +++ b/Sources/GiniCaptureSDK/Core/Screens/Onboarding/DefaultOnboardingNavigationBarBottomAdapter.swift @@ -1,8 +1,7 @@ // -// OnboardingNavigationBarBottomAdapter.swift -// +// DefaultOnboardingNavigationBarBottomAdapter.swift // -// Created by Nadya Karaban on 08.08.22. +// Copyright © 2024 Gini GmbH. All rights reserved. // import UIKit diff --git a/Sources/GiniCaptureSDK/Core/Screens/Onboarding/DefaultOnboardingPage.swift b/Sources/GiniCaptureSDK/Core/Screens/Onboarding/DefaultOnboardingPage.swift new file mode 100644 index 0000000..2a43a7f --- /dev/null +++ b/Sources/GiniCaptureSDK/Core/Screens/Onboarding/DefaultOnboardingPage.swift @@ -0,0 +1,61 @@ +// +// DefaultOnboardingPage.swift +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation + +enum DefaultOnboardingPage { + case flatPaper + case lighting + case multipage + case qrcode + + var imageName: String { + switch self { + case .flatPaper: + return "onboardingFlatPaper" + case .lighting: + return "onboardingGoodLighting" + case .multipage: + return "onboardingMultiPages" + case .qrcode: + return "onboardingQRCode" + } + } + + var title: String { + switch self { + case .flatPaper: + return NSLocalizedStringPreferredFormat("ginicapture.onboarding.flatPaper.title", + comment: "onboarding flat paper title") + case .lighting: + return NSLocalizedStringPreferredFormat("ginicapture.onboarding.goodLighting.title", + comment: "onboarding good lighting title") + case .multipage: + return NSLocalizedStringPreferredFormat("ginicapture.onboarding.multiPages.title", + comment: "onboarding multi pages title") + case .qrcode: + return NSLocalizedStringPreferredFormat("ginicapture.onboarding.qrCode.title", + comment: "onboarding qrcode title") + } + } + + var description: String { + switch self { + case .flatPaper: + return NSLocalizedStringPreferredFormat("ginicapture.onboarding.flatPaper.description", + comment: "onboarding flat paper description") + case .lighting: + return NSLocalizedStringPreferredFormat("ginicapture.onboarding.goodLighting.description", + comment: "onboarding good lighting description") + case .multipage: + return NSLocalizedStringPreferredFormat("ginicapture.onboarding.multiPages.description", + comment: "onboarding multi pages description") + case .qrcode: + return NSLocalizedStringPreferredFormat("ginicapture.onboarding.qrCode.description", + comment: "onboarding qrcode description") + } + } +} diff --git a/Sources/GiniCaptureSDK/Core/Screens/Onboarding/OnboardingDataSource.swift b/Sources/GiniCaptureSDK/Core/Screens/Onboarding/OnboardingDataSource.swift index 12d084c..1c35d7f 100644 --- a/Sources/GiniCaptureSDK/Core/Screens/Onboarding/OnboardingDataSource.swift +++ b/Sources/GiniCaptureSDK/Core/Screens/Onboarding/OnboardingDataSource.swift @@ -1,40 +1,43 @@ // // OnboardingPagesDataSource.swift -// // -// Created by Nadya Karaban on 14.09.22. +// Copyright © 2024 Gini GmbH. All rights reserved. // import UIKit protocol BaseCollectionViewDataSource: UICollectionViewDelegate, - UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { - init( - configuration: GiniConfiguration - ) + UICollectionViewDataSource, + UICollectionViewDelegateFlowLayout { + + init(configuration: GiniConfiguration) } protocol OnboardingScreen: AnyObject { - func didScroll(page: Int) + func didScroll(pageIndex: Int) } class OnboardingDataSource: NSObject, BaseCollectionViewDataSource { - typealias OnboardingPageModel = (page: OnboardingPage, illustrationAdapter: OnboardingIllustrationAdapter?) - private let giniConfiguration: GiniConfiguration weak var delegate: OnboardingScreen? - var currentPage = 0 + var isProgrammaticScroll = false + + private let giniConfiguration: GiniConfiguration + private (set) var currentPageIndex = 0 + private var isInitialScroll = true lazy var pageModels: [OnboardingPageModel] = { if let customPages = giniConfiguration.customOnboardingPages { - return customPages.map { page in - return (page: page, illustrationAdapter: nil) - } + return customOnboardingPagesDataSource(from: customPages) } else { return defaultOnboardingPagesDataSource() } }() + private lazy var pagesTracker: OnboardingPageTracker = { + return OnboardingPageTracker(pages: pageModels) + }() + required init(configuration: GiniConfiguration) { giniConfiguration = configuration } @@ -66,38 +69,69 @@ class OnboardingDataSource: NSObject, BaseCollectionViewDataSource { private func defaultOnboardingPagesDataSource() -> [OnboardingPageModel] { var pageModels = [OnboardingPageModel]() - - let flatPaperPageModel = (page: OnboardingPage(imageName: DefaultOnboardingPage.flatPaper.imageName, - title: DefaultOnboardingPage.flatPaper.title, - description: DefaultOnboardingPage.flatPaper.description), - illustrationAdapter: giniConfiguration.onboardingAlignCornersIllustrationAdapter) - - let goodLightingPageModel = (page: OnboardingPage(imageName: DefaultOnboardingPage.lighting.imageName, - title: DefaultOnboardingPage.lighting.title, - description: DefaultOnboardingPage.lighting.description), - illustrationAdapter: giniConfiguration.onboardingLightingIllustrationAdapter) + let flatPaperPage = OnboardingPage(imageName: DefaultOnboardingPage.flatPaper.imageName, + title: DefaultOnboardingPage.flatPaper.title, + description: DefaultOnboardingPage.flatPaper.description) + let flatPaperPageModel = OnboardingPageModel(page: flatPaperPage, + illustrationAdapter: giniConfiguration.onboardingAlignCornersIllustrationAdapter, + analyticsScreen: GiniAnalyticsScreen.onboardingFlatPaper.rawValue) + + let goodLightingPage = OnboardingPage(imageName: DefaultOnboardingPage.lighting.imageName, + title: DefaultOnboardingPage.lighting.title, + description: DefaultOnboardingPage.lighting.description) + let goodLightingPageModel = OnboardingPageModel(page: goodLightingPage, + illustrationAdapter: giniConfiguration.onboardingLightingIllustrationAdapter, + analyticsScreen: GiniAnalyticsScreen.onboardingLighting.rawValue) pageModels = [flatPaperPageModel, goodLightingPageModel] if giniConfiguration.multipageEnabled { - let multiPageModel = (page: OnboardingPage(imageName: DefaultOnboardingPage.multipage.imageName, - title: DefaultOnboardingPage.multipage.title, - description: DefaultOnboardingPage.multipage.description), - illustrationAdapter: giniConfiguration.onboardingMultiPageIllustrationAdapter) + let multiPage = OnboardingPage(imageName: DefaultOnboardingPage.multipage.imageName, + title: DefaultOnboardingPage.multipage.title, + description: DefaultOnboardingPage.multipage.description) + let multiPageModel = OnboardingPageModel(page: multiPage, + illustrationAdapter: giniConfiguration.onboardingMultiPageIllustrationAdapter, + analyticsScreen: GiniAnalyticsScreen.onboardingMultipage.rawValue) pageModels.append(multiPageModel) } if giniConfiguration.qrCodeScanningEnabled { - let qrCodePageModel = (page: OnboardingPage(imageName: DefaultOnboardingPage.qrcode.imageName, - title: DefaultOnboardingPage.qrcode.title, - description: DefaultOnboardingPage.qrcode.description), - illustrationAdapter: giniConfiguration.onboardingQRCodeIllustrationAdapter) + let qrCodePage = OnboardingPage(imageName: DefaultOnboardingPage.qrcode.imageName, + title: DefaultOnboardingPage.qrcode.title, + description: DefaultOnboardingPage.qrcode.description) + let qrCodePageModel = OnboardingPageModel(page: qrCodePage, + illustrationAdapter: giniConfiguration.onboardingQRCodeIllustrationAdapter, + analyticsScreen: GiniAnalyticsScreen.onboardingQRcode.rawValue) pageModels.append(qrCodePageModel) } return pageModels } + private func customOnboardingPagesDataSource(from customPages: [OnboardingPage]) -> [OnboardingPageModel] { + return customPages.enumerated().map { index, page in + let analyticsScreen = "\(GiniAnalyticsScreen.onboardingCustom.rawValue)\(index + 1)" + return OnboardingPageModel(page: page, + analyticsScreen: analyticsScreen, + isCustom: true) + } + } + + private func trackEventForPage(_ pageModel: OnboardingPageModel) { + guard !pagesTracker.seenAllPages else { return } + guard pagesTracker.isPageNotSeen(pageModel) else { return } + var eventProperties = [GiniAnalyticsProperty]() + if pageModel.isCustom { + eventProperties.append(.init(key: .customOnboardingTitle, value: pageModel.page.title)) + } + let hasCustomItems = giniConfiguration.customOnboardingPages?.isNotEmpty ?? false + eventProperties.append(GiniAnalyticsProperty(key: .hasCustomItems, + value: hasCustomItems)) + GiniAnalyticsManager.trackScreenShown(screenNameString: pageModel.analyticsScreen, + properties: eventProperties) + pagesTracker.markPageAsSeen(pageModel) + } + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { if let cell = collectionView.dequeueReusableCell( @@ -113,6 +147,8 @@ class OnboardingDataSource: NSObject, BaseCollectionViewDataSource { willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { let pageModel = pageModels[indexPath.row] + + trackEventForPage(pageModel) if let adapter = pageModel.illustrationAdapter { adapter.pageDidAppear() } @@ -129,16 +165,34 @@ class OnboardingDataSource: NSObject, BaseCollectionViewDataSource { func collectionView(_ collectionView: UICollectionView, targetContentOffsetForProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint { - let index = IndexPath(row: currentPage, section: 0) + let index = IndexPath(row: currentPageIndex, section: 0) let attr = collectionView.layoutAttributesForItem(at: index) return attr?.frame.origin ?? CGPoint.zero } - // MARK: - Display the page number in page controll of collection view Cell + // MARK: - Display the page number in page control of collection view cell public func scrollViewDidScroll(_ scrollView: UIScrollView) { - let page = Int(scrollView.contentOffset.x) / Int(scrollView.frame.width) - currentPage = page - delegate?.didScroll(page: page) + // this method is called twice when the screen is displayed for the first time + guard !isInitialScroll else { + isInitialScroll = false + return + } + guard scrollView.frame.width > 0 else { return } + + let pageWidth = scrollView.frame.width + let contentOffsetX = scrollView.contentOffset.x + let adjustedContentOffsetX = max(0, contentOffsetX) + let pageIndex = Int((adjustedContentOffsetX + pageWidth / 2) / pageWidth) + currentPageIndex = max(0, pageIndex) + delegate?.didScroll(pageIndex: pageIndex) + } + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + isProgrammaticScroll = false + } + + func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + isProgrammaticScroll = false } // MARK: - UICollectionViewDelegateFlowLayout @@ -147,59 +201,4 @@ class OnboardingDataSource: NSObject, BaseCollectionViewDataSource { sizeForItemAt indexPath: IndexPath) -> CGSize { return collectionView.bounds.size } - -} - -enum DefaultOnboardingPage { - case flatPaper - case lighting - case multipage - case qrcode - - var imageName: String { - switch self { - case .flatPaper: - return "onboardingFlatPaper" - case .lighting: - return "onboardingGoodLighting" - case .multipage: - return "onboardingMultiPages" - case .qrcode: - return "onboardingQRCode" - } - } - - var title: String { - switch self { - case .flatPaper: - return NSLocalizedStringPreferredFormat("ginicapture.onboarding.flatPaper.title", - comment: "onboarding flat paper title") - case .lighting: - return NSLocalizedStringPreferredFormat("ginicapture.onboarding.goodLighting.title", - comment: "onboarding good lighting title") - case .multipage: - return NSLocalizedStringPreferredFormat("ginicapture.onboarding.multiPages.title", - comment: "onboarding multi pages title") - case .qrcode: - return NSLocalizedStringPreferredFormat("ginicapture.onboarding.qrCode.title", - comment: "onboarding qrcode title") - } - } - - var description: String { - switch self { - case .flatPaper: - return NSLocalizedStringPreferredFormat("ginicapture.onboarding.flatPaper.description", - comment: "onboarding flat paper description") - case .lighting: - return NSLocalizedStringPreferredFormat("ginicapture.onboarding.goodLighting.description", - comment: "onboarding good lighting description") - case .multipage: - return NSLocalizedStringPreferredFormat("ginicapture.onboarding.multiPages.description", - comment: "onboarding multi pages description") - case .qrcode: - return NSLocalizedStringPreferredFormat("ginicapture.onboarding.qrCode.description", - comment: "onboarding qrcode description") - } - } } diff --git a/Sources/GiniCaptureSDK/Core/Screens/Onboarding/OnboardingPage.swift b/Sources/GiniCaptureSDK/Core/Screens/Onboarding/OnboardingPage.swift index dc7ee39..8fb33a3 100644 --- a/Sources/GiniCaptureSDK/Core/Screens/Onboarding/OnboardingPage.swift +++ b/Sources/GiniCaptureSDK/Core/Screens/Onboarding/OnboardingPage.swift @@ -1,8 +1,7 @@ // // OnboardingPage.swift -// // -// Created by Nadya Karaban on 15.09.22. +// Copyright © 2024 Gini GmbH. All rights reserved. // import Foundation @@ -15,7 +14,6 @@ public struct OnboardingPage { let imageName: String let title: String let description: String - /** * Creates an `OnboardingPage` instance. * diff --git a/Sources/GiniCaptureSDK/Core/Screens/Onboarding/OnboardingPageModel.swift b/Sources/GiniCaptureSDK/Core/Screens/Onboarding/OnboardingPageModel.swift new file mode 100644 index 0000000..395782e --- /dev/null +++ b/Sources/GiniCaptureSDK/Core/Screens/Onboarding/OnboardingPageModel.swift @@ -0,0 +1,39 @@ +// +// OnboardingPageModel.swift +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation + +protocol OnboardingPageAnalytics { + var analyticsScreen: String { get } + var isCustom: Bool { get } +} + +struct OnboardingPageModel: OnboardingPageAnalytics { + let page: OnboardingPage + let illustrationAdapter: OnboardingIllustrationAdapter? + var analyticsScreen: String + var isCustom: Bool + + init(page: OnboardingPage, + illustrationAdapter: OnboardingIllustrationAdapter? = nil, + analyticsScreen: String, + isCustom: Bool = false) { + self.page = page + self.illustrationAdapter = illustrationAdapter + self.analyticsScreen = analyticsScreen + self.isCustom = isCustom + } +} + +extension OnboardingPageModel: Equatable { + static func == (lhs: OnboardingPageModel, rhs: OnboardingPageModel) -> Bool { + return lhs.page.title == rhs.page.title && + lhs.page.imageName == rhs.page.imageName && + lhs.page.description == rhs.page.description && + lhs.analyticsScreen == rhs.analyticsScreen && + lhs.isCustom == rhs.isCustom + } +} diff --git a/Sources/GiniCaptureSDK/Core/Screens/Onboarding/OnboardingPageTracker.swift b/Sources/GiniCaptureSDK/Core/Screens/Onboarding/OnboardingPageTracker.swift new file mode 100644 index 0000000..28d6371 --- /dev/null +++ b/Sources/GiniCaptureSDK/Core/Screens/Onboarding/OnboardingPageTracker.swift @@ -0,0 +1,27 @@ +// +// OnboardingPageTracker.swift +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation + +class OnboardingPageTracker { + private var pages: [OnboardingPageModel] + + init(pages: [OnboardingPageModel]) { + self.pages = pages + } + + func isPageNotSeen(_ page: OnboardingPageModel) -> Bool { + pages.contains(page) + } + + func markPageAsSeen(_ page: OnboardingPageModel) { + pages = pages.filter { $0 != page } + } + + var seenAllPages: Bool { + pages.isEmpty + } +} diff --git a/Sources/GiniCaptureSDK/Core/Screens/Onboarding/OnboardingViewController.swift b/Sources/GiniCaptureSDK/Core/Screens/Onboarding/OnboardingViewController.swift index 0300745..f39e378 100644 --- a/Sources/GiniCaptureSDK/Core/Screens/Onboarding/OnboardingViewController.swift +++ b/Sources/GiniCaptureSDK/Core/Screens/Onboarding/OnboardingViewController.swift @@ -1,8 +1,7 @@ // // OnboardingViewController.swift -// GiniCaptureSDK // -// Created by Nadya Karaban on 07.06.22. +// Copyright © 2024 Gini GmbH. All rights reserved. // import UIKit @@ -15,13 +14,8 @@ class OnboardingViewController: UIViewController { private let configuration = GiniConfiguration.shared private var navigationBarBottomAdapter: OnboardingNavigationBarBottomAdapter? private var bottomNavigationBar: UIView? + private lazy var skipButton = GiniBarButton(ofType: .skip) - lazy var skipButton = UIBarButtonItem(title: NSLocalizedStringPreferredFormat( - "ginicapture.onboarding.skip", - comment: "Skip button"), - style: .plain, - target: self, - action: #selector(close)) init() { dataSource = OnboardingDataSource(configuration: configuration) super.init(nibName: "OnboardingViewController", bundle: giniCaptureBundle()) @@ -52,6 +46,8 @@ class OnboardingViewController: UIViewController { pageControl.addTarget(self, action: #selector(self.pageControlSelectionAction(_:)), for: .valueChanged) pageControl.numberOfPages = dataSource.pageModels.count pageControl.isAccessibilityElement = true + configureNavigationButtons(for: 0) + pageControl.currentPage = 0 } private func setupView() { @@ -83,11 +79,11 @@ class OnboardingViewController: UIViewController { private func configureBasicNavigation() { nextButton.titleLabel?.font = configuration.textStyleFonts[.bodyBold] - nextButton.accessibilityValue = NSLocalizedStringPreferredFormat("ginicapture.onboarding.next", - comment: "Next button") nextButton.configure(with: GiniConfiguration.shared.primaryButtonConfiguration) nextButton.addTarget(self, action: #selector(nextPage), for: .touchUpInside) - navigationItem.rightBarButtonItem = skipButton + configureNextButton() + + configureSkipButton() } private func hideTopNavigation() { @@ -106,10 +102,10 @@ class OnboardingViewController: UIViewController { self?.nextPage() } navigationBarBottomAdapter?.setSkipButtonClickedActionCallback { [weak self] in - self?.close() + self?.skipTapped() } navigationBarBottomAdapter?.setGetStartedButtonClickedActionCallback { [weak self] in - self?.close() + self?.getStartedButtonAction() } if let navigationBar = navigationBarBottomAdapter?.injectedView() { bottomNavigationBar = navigationBar @@ -117,12 +113,10 @@ class OnboardingViewController: UIViewController { layoutBottomNavigationBar(navigationBar) navigationBarBottomAdapter?.showButtons(navigationButtons: [.skip, .next], navigationBar: navigationBar) - nextButton.setTitle(NSLocalizedStringPreferredFormat( - "ginicapture.onboarding.next", - comment: "Next button"), for: .normal) - + configureNextButton() nextButton.addTarget(self, action: #selector(nextPage), for: .touchUpInside) - navigationItem.rightBarButtonItem = skipButton + + configureSkipButton() } } @@ -130,7 +124,35 @@ class OnboardingViewController: UIViewController { nextButton.removeFromSuperview() } - @objc private func close() { + private func configureSkipButton() { + skipButton.addAction(self, #selector(skipTapped)) + navigationItem.rightBarButtonItem = skipButton.barButton + } + + private func configureGetStartedButton() { + let getStartedTitle = NSLocalizedStringPreferredFormat("ginicapture.onboarding.getstarted", + comment: "Get Started button") + nextButton.setTitle(getStartedTitle, for: .normal) + nextButton.accessibilityValue = getStartedTitle + } + + private func configureNextButton() { + let nextButtonTitle = NSLocalizedStringPreferredFormat("ginicapture.onboarding.next", + comment: "Next button") + nextButton.accessibilityValue = nextButtonTitle + nextButton.setTitle(nextButtonTitle, for: .normal) + } + + @objc private func skipTapped() { + // Handle the skip button tap if there are more onboarding pages. + // The skip button is not present on the last onboarding page. + let currentPageIndex = dataSource.currentPageIndex + guard currentPageIndex < dataSource.pageModels.count - 1 else { return } + track(event: .skipTapped, for: currentPageIndex) + close() + } + + private func close() { dismiss(animated: true) } @@ -140,14 +162,35 @@ class OnboardingViewController: UIViewController { } @objc private func nextPage() { - if dataSource.currentPage < dataSource.pageModels.count - 1 { - let index = IndexPath(item: dataSource.currentPage + 1, section: 0) + let currentPageIndex = dataSource.currentPageIndex + if currentPageIndex < dataSource.pageModels.count - 1 { + // Next button tapped + track(event: .nextStepTapped, for: currentPageIndex) + let index = IndexPath(item: currentPageIndex + 1, section: 0) pagesCollection.scrollToItem(at: index, at: .centeredHorizontally, animated: true) + dataSource.isProgrammaticScroll = true } else { - close() + getStartedButtonAction() } } + private func getStartedButtonAction() { + track(event: .getStartedTapped, for: dataSource.currentPageIndex) + close() + } + + private func track(event: GiniAnalyticsEvent, for pageIndex: Int) { + let pageModel = dataSource.pageModels[pageIndex] + let currentPageScreenName = pageModel.analyticsScreen + var eventProperties = [GiniAnalyticsProperty]() + if pageModel.isCustom { + eventProperties.append(.init(key: .customOnboardingTitle, value: pageModel.page.title)) + } + GiniAnalyticsManager.track(event: event, + screenNameString: currentPageScreenName, + properties: eventProperties) + } + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) pagesCollection.collectionViewLayout.invalidateLayout() @@ -164,43 +207,49 @@ class OnboardingViewController: UIViewController { } extension OnboardingViewController: OnboardingScreen { - func didScroll(page: Int) { - switch page { + func didScroll(pageIndex: Int) { + guard pageControl.currentPage != pageIndex else { return } + + sendGiniAnalyticsEventPageSwiped() + configureNavigationButtons(for: pageIndex) + pageControl.currentPage = pageIndex + } + + private func sendGiniAnalyticsEventPageSwiped() { + // Ignore events triggered by programmatic scrolling. + guard !dataSource.isProgrammaticScroll else { return } + + // Registers the `pageSwiped` event for the page swiped action, tracking based on the page from which the swipe was triggered. + // `pageControl.currentPage` should be updated after this method is called as it is done in `didScroll(pageIndex: Int)` method + track(event: .pageSwiped, for: pageControl.currentPage) + } + + private func configureNavigationButtons(for pageIndex: Int) { + switch pageIndex { case dataSource.pageModels.count - 1: if configuration.bottomNavigationBarEnabled, - let bottomNavigationBar = bottomNavigationBar { - navigationBarBottomAdapter?.showButtons( - navigationButtons: [.getStarted], - navigationBar: bottomNavigationBar) + let bottomNavigationBar = bottomNavigationBar { + navigationBarBottomAdapter?.showButtons(navigationButtons: [.getStarted], + navigationBar: bottomNavigationBar) } else { navigationItem.rightBarButtonItem = nil if nextButton != nil { - nextButton.setTitle(NSLocalizedStringPreferredFormat( - "ginicapture.onboarding.getstarted", - comment: "Get Started button"), for: .normal) - nextButton.accessibilityValue = NSLocalizedStringPreferredFormat( - "ginicapture.onboarding.getstarted", - comment: "Get Started button") + configureGetStartedButton() } } default: if configuration.bottomNavigationBarEnabled, - let bottomNavigationBar = bottomNavigationBar { - navigationBarBottomAdapter?.showButtons( - navigationButtons: [.skip, .next], - navigationBar: bottomNavigationBar) + let bottomNavigationBar = bottomNavigationBar { + navigationBarBottomAdapter?.showButtons(navigationButtons: [.skip, .next], + navigationBar: bottomNavigationBar) } else { - navigationItem.rightBarButtonItem = skipButton + configureSkipButton() + if nextButton != nil { - nextButton.setTitle( - NSLocalizedStringPreferredFormat( - "ginicapture.onboarding.next", - comment: "Next button"), - for: .normal) + configureNextButton() } } } - pageControl.currentPage = page } } diff --git a/Sources/GiniCaptureSDK/Core/Screens/Onboarding/Views/OnboardingPageCell.swift b/Sources/GiniCaptureSDK/Core/Screens/Onboarding/Views/OnboardingPageCell.swift index f126cf7..193f6ef 100644 --- a/Sources/GiniCaptureSDK/Core/Screens/Onboarding/Views/OnboardingPageCell.swift +++ b/Sources/GiniCaptureSDK/Core/Screens/Onboarding/Views/OnboardingPageCell.swift @@ -29,7 +29,7 @@ class OnboardingPageCell: UICollectionViewCell { descriptionLabel.textColor = GiniColor(light: UIColor.GiniCapture.dark6, dark: UIColor.GiniCapture.dark7).uiColor() - descriptionLabel.font = GiniConfiguration.shared.textStyleFonts[.title2Bold] + descriptionLabel.font = GiniConfiguration.shared.textStyleFonts[.subheadline] descriptionLabel.isAccessibilityElement = true } diff --git a/Sources/GiniCaptureSDK/Core/Screens/Review/ReviewViewController.swift b/Sources/GiniCaptureSDK/Core/Screens/Review/ReviewViewController.swift index f3651e2..7edd364 100644 --- a/Sources/GiniCaptureSDK/Core/Screens/Review/ReviewViewController.swift +++ b/Sources/GiniCaptureSDK/Core/Screens/Review/ReviewViewController.swift @@ -1,8 +1,7 @@ // // ReviewViewController.swift -// GiniCapture // -// Created by Vizaknai David on 28.09.2022 +// Copyright © 2024 Gini GmbH. All rights reserved. // import UIKit @@ -179,8 +178,7 @@ public final class ReviewViewController: UIViewController { addPagesButton.configure(with: giniConfiguration.addPageButtonConfiguration) addPagesButton.didTapButton = { [weak self] in guard let self = self else { return } - self.setCellStatus(for: self.currentPage, isActive: false) - self.delegate?.reviewDidTapAddImage(self) + self.didTapAddPages() } addPagesButton.isAccessibilityElement = true addPagesButton.accessibilityTraits = .button @@ -308,10 +306,11 @@ extension ReviewViewController { } navigationBarBottomAdapter?.setMainButtonClickedActionCallback { [weak self] in guard let self = self else { return } - self.delegate?.reviewDidTapProcess(self) + self.didTapProcessDocument() } navigationBarBottomAdapter?.setSecondaryButtonClickedActionCallback { [weak self] in guard let self = self else { return } + GiniAnalyticsManager.track(event: .addPagesTapped, screenName: .review) self.setCellStatus(for: self.currentPage, isActive: false) self.delegate?.reviewDidTapAddImage(self) } @@ -362,6 +361,8 @@ extension ReviewViewController { setCellStatus(for: currentPage, isActive: true) } collectionView.reloadData() + + GiniAnalyticsManager.trackScreenShown(screenName: .review) } public override func viewWillLayoutSubviews() { @@ -542,6 +543,12 @@ extension ReviewViewController { @objc private func didTapProcessDocument() { + let eventProperties = [GiniAnalyticsProperty(key: .numberOfPagesScanned, + value: pages.count)] + + GiniAnalyticsManager.track(event: .processTapped, + screenName: .review, + properties: eventProperties) delegate?.reviewDidTapProcess(self) } @@ -552,6 +559,12 @@ extension ReviewViewController { delegate?.review(self, didDelete: pageToDelete) } + private func didTapAddPages() { + GiniAnalyticsManager.track(event: .addPagesTapped, screenName: .review) + setCellStatus(for: currentPage, isActive: false) + delegate?.reviewDidTapAddImage(self) + } + @objc private func swipeHandler(sender: UISwipeGestureRecognizer) { guard pages.count > 1 else { return } @@ -562,6 +575,9 @@ extension ReviewViewController { collectionView.scrollToItem(at: IndexPath(row: currentPage, section: 0), at: .centeredHorizontally, animated: true) pageControl.currentPage = currentPage + + GiniAnalyticsManager.track(event: .pageSwiped, screenName: .review) + } else if sender.direction == .right { guard currentPage > 0 else { return } setCellStatus(for: currentPage, isActive: false) @@ -569,6 +585,8 @@ extension ReviewViewController { collectionView.scrollToItem(at: IndexPath(row: currentPage, section: 0), at: .centeredHorizontally, animated: true) pageControl.currentPage = currentPage + + GiniAnalyticsManager.track(event: .pageSwiped, screenName: .review) } } @@ -655,6 +673,7 @@ extension ReviewViewController: UICollectionViewDelegateFlowLayout { public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { let page = pages[indexPath.row] + GiniAnalyticsManager.track(event: .fullScreenPageTapped, screenName: .review) delegate?.review(self, didSelectPage: page) } @@ -682,7 +701,7 @@ extension ReviewViewController: ReviewCollectionViewDelegate { func didTapDelete(on cell: ReviewCollectionCell) { guard let indexpath = collectionView.indexPath(for: cell) else { return } deleteItem(at: indexpath) - + GiniAnalyticsManager.track(event: .deletePagesTapped, screenName: .review) setCurrentPage(basedOn: collectionView) } } diff --git a/Sources/GiniCaptureSDK/Core/Screens/Review/ReviewZoomViewController.swift b/Sources/GiniCaptureSDK/Core/Screens/Review/ReviewZoomViewController.swift index cac7017..e1bf48b 100644 --- a/Sources/GiniCaptureSDK/Core/Screens/Review/ReviewZoomViewController.swift +++ b/Sources/GiniCaptureSDK/Core/Screens/Review/ReviewZoomViewController.swift @@ -1,8 +1,7 @@ // // ReviewZoomViewController.swift -// // -// Created by David Vizaknai on 04.10.2022. +// Copyright © 2024 Gini GmbH. All rights reserved. // import UIKit @@ -19,8 +18,11 @@ final class ReviewZoomViewController: UIViewController { closeButton.isExclusiveTouch = true return closeButton }() + private var page: GiniCapturePage + private var zoomAnalyticsEventSent: Bool = false + // MARK: - Init init(page: GiniCapturePage) { @@ -41,6 +43,7 @@ final class ReviewZoomViewController: UIViewController { setupView() setupLayout() setupImage(page.document.previewImage) + GiniAnalyticsManager.trackScreenShown(screenName: .reviewZoom) } override func viewWillLayoutSubviews() { @@ -84,10 +87,12 @@ final class ReviewZoomViewController: UIViewController { imageView.frame = scrollView.bounds NSLayoutConstraint.activate([ - closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16), - closeButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), - closeButton.heightAnchor.constraint(equalToConstant: 44), - closeButton.widthAnchor.constraint(equalToConstant: 44) + closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, + constant: Constants.buttonPadding), + closeButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, + constant: Constants.buttonPadding), + closeButton.heightAnchor.constraint(equalToConstant: Constants.buttonSize), + closeButton.widthAnchor.constraint(equalToConstant: Constants.buttonSize) ]) } @@ -118,6 +123,7 @@ final class ReviewZoomViewController: UIViewController { @objc private func didTapCloseButton() { + GiniAnalyticsManager.track(event: .closeTapped, screenName: .reviewZoom) dismiss(animated: true) } @@ -161,6 +167,17 @@ extension ReviewZoomViewController: UIScrollViewDelegate { } func scrollViewDidZoom(_ scrollView: UIScrollView) { + if !zoomAnalyticsEventSent { + zoomAnalyticsEventSent = true + GiniAnalyticsManager.track(event: .previewZoomed, screenName: .reviewZoom) + } adjustImageToCenter() } } + +private extension ReviewZoomViewController { + enum Constants { + static let buttonSize: CGFloat = 44 + static let buttonPadding: CGFloat = 16 + } +} diff --git a/Sources/GiniCaptureSDK/Core/Screens/Screen API Coordinator/GiniScreenAPICoordinator+Analysis.swift b/Sources/GiniCaptureSDK/Core/Screens/Screen API Coordinator/GiniScreenAPICoordinator+Analysis.swift index b814651..db8f42c 100644 --- a/Sources/GiniCaptureSDK/Core/Screens/Screen API Coordinator/GiniScreenAPICoordinator+Analysis.swift +++ b/Sources/GiniCaptureSDK/Core/Screens/Screen API Coordinator/GiniScreenAPICoordinator+Analysis.swift @@ -1,8 +1,7 @@ // // GiniScreenAPICoordinator+Analysis.swift -// GiniCapture // -// Created by Enrique del Pozo Gómez on 4/4/18. +// Copyright © 2024 Gini GmbH. All rights reserved. // import Foundation @@ -33,39 +32,34 @@ extension GiniScreenAPICoordinator { func createImageAnalysisNoResultsScreen( type: NoResultScreenViewController.NoResultType ) -> NoResultScreenViewController { - let viewModel: BottomButtonsViewModel - let viewController: NoResultScreenViewController - switch type { - case .qrCode: - viewModel = createRetakeAndEnterManuallyButtonsViewModel() - case .image: - if pages.contains(where: { $0.document.isImported == false }) { - // if there is a photo captured with camera + let viewModel: BottomButtonsViewModel + let viewController: NoResultScreenViewController + switch type { + case .qrCode: viewModel = createRetakeAndEnterManuallyButtonsViewModel() - } else { - viewModel = BottomButtonsViewModel( - manuallyPressed: { [weak self] in - if let delegate = self?.visionDelegate { - delegate.didPressEnterManually() - } else { - self?.screenAPINavigationController.dismiss(animated: true) - } - }, cancelPressed: { [weak self] in - self?.closeScreenApi() - }) + case .image: + if pages.contains(where: { $0.document.isImported == false }) { + // if there is a photo captured with camera + viewModel = createRetakeAndEnterManuallyButtonsViewModel() + } else { + viewModel = createDefaultButtonsViewModel() + } + default: + viewModel = createDefaultButtonsViewModel() } - default: - viewModel = BottomButtonsViewModel( - manuallyPressed: { [weak self] in - self?.screenAPINavigationController.dismiss(animated: true) - }, cancelPressed: { [weak self] in - self?.closeScreenApi() - }) + viewController = NoResultScreenViewController(giniConfiguration: giniConfiguration, + type: type, + viewModel: viewModel) + return viewController } - viewController = NoResultScreenViewController(giniConfiguration: giniConfiguration, - type: type, - viewModel: viewModel) - return viewController + + private func createDefaultButtonsViewModel() -> BottomButtonsViewModel { + BottomButtonsViewModel( + manuallyPressed: { [weak self] in + self?.finishWithEnterManually() + }, cancelPressed: { [weak self] in + self?.finishWithCancellation() + }) } private func createRetakeAndEnterManuallyButtonsViewModel() -> BottomButtonsViewModel { @@ -76,13 +70,9 @@ extension GiniScreenAPICoordinator { self?.backToCamera() }, manuallyPressed: { [weak self] in - if let delegate = self?.visionDelegate { - delegate.didPressEnterManually() - } else { - self?.screenAPINavigationController.dismiss(animated: true) - } + self?.finishWithEnterManually() }, cancelPressed: { [weak self] in - self?.closeScreenApi() + self?.finishWithCancellation() }) } } @@ -91,57 +81,25 @@ extension GiniScreenAPICoordinator { extension GiniScreenAPICoordinator: AnalysisDelegate { - public func displayError( - errorType: ErrorType, - animated: Bool - ) { + public func displayError(errorType: ErrorType, animated: Bool) { let viewModel: BottomButtonsViewModel switch pages.type { case .image: if self.pages.contains(where: { $0.document.isImported == false }) { // if there is a photo captured with camera - viewModel = BottomButtonsViewModel( - retakeBlock: { [weak self] in - self?.pages = [] - self?.trackingDelegate?.onAnalysisScreenEvent(event: Event(type: .retry)) - self?.backToCamera() - }, - manuallyPressed: { [weak self] in - if let delegate = self?.visionDelegate { - delegate.didPressEnterManually() - } else { - self?.screenAPINavigationController.dismiss(animated: animated) - } - }, cancelPressed: { [weak self] in - self?.closeScreenApi() - }) + viewModel = createRetakeAndEnterManuallyButtonsViewModel() } else { - viewModel = BottomButtonsViewModel( - manuallyPressed: { [weak self] in - if let delegate = self?.visionDelegate { - delegate.didPressEnterManually() - } else { - self?.screenAPINavigationController.dismiss(animated: animated) - } - }, cancelPressed: { [weak self] in - self?.closeScreenApi() - }) + viewModel = createDefaultButtonsViewModel() } default: - viewModel = BottomButtonsViewModel( - manuallyPressed: { [weak self] in - self?.screenAPINavigationController.dismiss(animated: true) - }, cancelPressed: { [weak self] in - self?.closeScreenApi() - }) + viewModel = createDefaultButtonsViewModel() } self.trackingDelegate?.onAnalysisScreenEvent(event: Event(type: .error)) - let viewController = ErrorScreenViewController( - giniConfiguration: giniConfiguration, - type: errorType, - documentType: pages.type ?? .pdf, - viewModel: viewModel) + let viewController = ErrorScreenViewController(giniConfiguration: giniConfiguration, + type: errorType, + documentType: pages.type ?? .pdf, + viewModel: viewModel) screenAPINavigationController.pushViewController(viewController, animated: animated) } diff --git a/Sources/GiniCaptureSDK/Core/Screens/Screen API Coordinator/GiniScreenAPICoordinator+Camera.swift b/Sources/GiniCaptureSDK/Core/Screens/Screen API Coordinator/GiniScreenAPICoordinator+Camera.swift index 3452299..358ad2c 100644 --- a/Sources/GiniCaptureSDK/Core/Screens/Screen API Coordinator/GiniScreenAPICoordinator+Camera.swift +++ b/Sources/GiniCaptureSDK/Core/Screens/Screen API Coordinator/GiniScreenAPICoordinator+Camera.swift @@ -1,6 +1,5 @@ // // GiniScreenAPICoordinator+Camera.swift -// GiniCapture // // Copyright © 2024 Gini GmbH. All rights reserved. // @@ -61,7 +60,7 @@ extension GiniScreenAPICoordinator: CameraViewControllerDelegate { } func cameraDidAppear(_ viewController: CameraViewController) { - if shouldShowOnBoarding() { + if shouldShowOnboarding() { showOnboardingScreen(cameraViewController: viewController, completion: { viewController.setupCamera() }) @@ -82,7 +81,7 @@ extension GiniScreenAPICoordinator: CameraViewControllerDelegate { self?.showHelpMenuScreen() } cameraButtonsViewModel.cancelAction = { [weak self] in - self?.closeScreenApi() + self?.finishWithCancellation() } return cameraButtonsViewModel } @@ -102,7 +101,8 @@ extension GiniScreenAPICoordinator: CameraViewControllerDelegate { self?.cameraDidTapReviewButton(cameraViewController) } } else { - self?.closeScreenApi() + GiniAnalyticsManager.track(event: .closeTapped, screenName: .camera) + self?.finishWithCancellation() } } @@ -138,7 +138,7 @@ extension GiniScreenAPICoordinator: CameraViewControllerDelegate { visionDelegate?.didCapture(document: document, networkDelegate: self) } - private func shouldShowOnBoarding() -> Bool { + private func shouldShowOnboarding() -> Bool { if giniConfiguration.onboardingShowAtFirstLaunch && !UserDefaults.standard.bool(forKey: "ginicapture.defaults.onboardingShowed") { UserDefaults.standard.set(true, forKey: "ginicapture.defaults.onboardingShowed") @@ -236,7 +236,7 @@ extension GiniScreenAPICoordinator: DocumentPickerCoordinatorDelegate { } if coordinator.currentPickerDismissesAutomatically { self.cameraScreen?.showErrorDialog(for: error, - positiveAction: positiveAction) + positiveAction: positiveAction) } else { coordinator.currentPickerViewController?.showErrorDialog(for: error, positiveAction: positiveAction) @@ -245,16 +245,16 @@ extension GiniScreenAPICoordinator: DocumentPickerCoordinatorDelegate { } } - public func documentPicker(_ coordinator: DocumentPickerCoordinator, failedToPickDocumentsAt urls: [URL]) { - let error = FilePickerError.failedToOpenDocument - if coordinator.currentPickerDismissesAutomatically { - self.cameraScreen?.showErrorDialog(for: error, - positiveAction: nil) - } else { - coordinator.currentPickerViewController?.showErrorDialog(for: error, - positiveAction: nil) + public func documentPicker(_ coordinator: DocumentPickerCoordinator, failedToPickDocumentsAt urls: [URL]) { + let error = FilePickerError.failedToOpenDocument + if coordinator.currentPickerDismissesAutomatically { + self.cameraScreen?.showErrorDialog(for: error, + positiveAction: nil) + } else { + coordinator.currentPickerViewController?.showErrorDialog(for: error, + positiveAction: nil) + } } - } fileprivate func addDropInteraction(forView view: UIView, with delegate: UIDropInteractionDelegate) { let dropInteraction = UIDropInteraction(delegate: delegate) diff --git a/Sources/GiniCaptureSDK/Core/Screens/Screen API Coordinator/GiniScreenAPICoordinator+Review.swift b/Sources/GiniCaptureSDK/Core/Screens/Screen API Coordinator/GiniScreenAPICoordinator+Review.swift index ca53a46..04162fa 100644 --- a/Sources/GiniCaptureSDK/Core/Screens/Screen API Coordinator/GiniScreenAPICoordinator+Review.swift +++ b/Sources/GiniCaptureSDK/Core/Screens/Screen API Coordinator/GiniScreenAPICoordinator+Review.swift @@ -1,8 +1,7 @@ // // GiniScreenAPICoordinator+Review.swift -// GiniCapture // -// Created by Enrique del Pozo Gómez on 4/4/18. +// Copyright © 2024 Gini GmbH. All rights reserved. // import UIKit @@ -51,6 +50,7 @@ extension GiniScreenAPICoordinator: ReviewViewControllerDelegate { @objc fileprivate func closeScreen() { trackingDelegate?.onReviewScreenEvent(event: Event(type: .back)) + GiniAnalyticsManager.track(event: .closeTapped, screenName: .review) screenAPINavigationController.dismiss(animated: true) } diff --git a/Sources/GiniCaptureSDK/Core/Screens/Screen API Coordinator/GiniScreenAPICoordinator.swift b/Sources/GiniCaptureSDK/Core/Screens/Screen API Coordinator/GiniScreenAPICoordinator.swift index 659c81b..021c04c 100644 --- a/Sources/GiniCaptureSDK/Core/Screens/Screen API Coordinator/GiniScreenAPICoordinator.swift +++ b/Sources/GiniCaptureSDK/Core/Screens/Screen API Coordinator/GiniScreenAPICoordinator.swift @@ -1,9 +1,7 @@ // // GiniScreenAPICoordinator.swift -// GiniCapture // -// Created by Enrique del Pozo Gómez on 12/19/17. -// Copyright © 2017 Gini GmbH. All rights reserved. +// Copyright © 2024 Gini GmbH. All rights reserved. // import Foundation @@ -87,10 +85,8 @@ open class GiniScreenAPICoordinator: NSObject, Coordinator { super.init() } - public func start( - withDocuments documents: [GiniCaptureDocument]?, - animated: Bool = false - ) -> UIViewController { + public func start(withDocuments documents: [GiniCaptureDocument]?, + animated: Bool = false) -> UIViewController { var viewControllers: [UIViewController] = [] if let documents = documents, !documents.isEmpty { @@ -142,8 +138,7 @@ open class GiniScreenAPICoordinator: NSObject, Coordinator { } if pages.type == .image { - reviewViewController = - createReviewScreenContainer(with: pages) + reviewViewController = createReviewScreenContainer(with: pages) return [reviewViewController] } else { @@ -201,35 +196,67 @@ extension GiniScreenAPICoordinator { @objc func back() { switch screenAPINavigationController.topViewController { case is CameraViewController: - trackingDelegate?.onCameraScreenEvent(event: Event(type: .exit)) - - if pages.type == .qrcode { - screenAPINavigationController.dismiss(animated: true) - } - - if pages.count > 0 { - if screenAPINavigationController.viewControllers.count > 1 { - screenAPINavigationController.popViewController(animated: true) - } else { - screenAPINavigationController.dismiss(animated: true) - } - } else { - closeScreenApi() - } + navigateBackFromCameraViewController() case is AnalysisViewController: - trackingDelegate?.onAnalysisScreenEvent(event: Event(type: .cancel)) - screenAPINavigationController.dismiss(animated: true) + navigateBackFromAnalysisViewController() default: - if screenAPINavigationController.viewControllers.count > 1 { - screenAPINavigationController.popViewController(animated: true) - } else { - screenAPINavigationController.dismiss(animated: true) - } + navigateBack() + } + } + + @objc func finishWithCancellation() { + if let delegate = self.visionDelegate { + delegate.didCancelCapturing() + } else { + Log(message: "GiniCaptureResultsDelegate is not implemented", event: .error) + } + } + + @objc func finishWithEnterManually() { + if let delegate = self.visionDelegate { + delegate.didPressEnterManually() + } else { + Log(message: "GiniCaptureResultsDelegate is not implemented", event: .error) + } + } + + private func navigateBackFromCameraViewController() { + trackingDelegate?.onCameraScreenEvent(event: Event(type: .exit)) + GiniAnalyticsManager.track(event: .closeTapped, screenName: screenName()) + guard pages.type != .qrcode else { + finishWithCancellation() + return + } + + if pages.count > 0 { + navigateBack() + } else { + finishWithCancellation() + } + } + + private func navigateBackFromAnalysisViewController() { + trackingDelegate?.onAnalysisScreenEvent(event: Event(type: .cancel)) + GiniAnalyticsManager.track(event: .closeTapped, screenName: .analysis) + finishWithCancellation() + } + + private func navigateBack() { + if screenAPINavigationController.viewControllers.count > 1 { + screenAPINavigationController.popViewController(animated: true) + } else { + finishWithCancellation() } } - @objc func closeScreenApi() { - self.visionDelegate?.didCancelCapturing() + // Determine the screen name based on the top view controller + private func screenName() -> GiniAnalyticsScreen { + if let topViewController = screenAPINavigationController.topViewController as? CameraViewController, + !topViewController.cameraPreviewViewController.isAuthorized { + return .cameraAccess + } else { + return .camera + } } @objc func showHelpMenuScreen() { @@ -238,16 +265,17 @@ extension GiniScreenAPICoordinator { return } - let helpMenuViewController = HelpMenuViewController( - giniConfiguration: giniConfiguration - ) + GiniAnalyticsManager.track(event: .helpTapped, screenName: screenName()) + + let helpMenuViewController = HelpMenuViewController(giniConfiguration: giniConfiguration) + helpMenuViewController.delegate = self trackingDelegate?.onCameraScreenEvent(event: Event(type: .help)) let backButtonTitle = NSLocalizedStringPreferredFormat("ginicapture.navigationbar.help.backToCamera", comment: "Camera") let barButton = GiniBarButton(ofType: .back(title: backButtonTitle)) - barButton.addAction(self, #selector(back)) + barButton.addAction(self, #selector(backToCameraTapped)) helpMenuViewController.navigationItem.leftBarButtonItem = barButton.barButton // In case of 1 menu item it's better to show the item immediately without any selection @@ -260,6 +288,11 @@ extension GiniScreenAPICoordinator { } + @objc func backToCameraTapped() { + GiniAnalyticsManager.track(event: .closeTapped, screenName: .help) + back() + } + @objc func showAnalysisScreen() { if screenAPINavigationController.topViewController is ReviewViewController { trackingDelegate?.onReviewScreenEvent(event: Event(type: .next)) @@ -285,11 +318,10 @@ extension GiniScreenAPICoordinator { // MARK: - Navigation delegate extension GiniScreenAPICoordinator: UINavigationControllerDelegate { - public func navigationController( - _ navigationController: UINavigationController, - animationControllerFor operation: UINavigationController.Operation, - from fromVC: UIViewController, - to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? { + public func navigationController(_ navigationController: UINavigationController, + animationControllerFor operation: UINavigationController.Operation, + from fromVC: UIViewController, + to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? { if fromVC is AnalysisViewController { analysisViewController = nil if operation == .pop { diff --git a/Sources/GiniCaptureSDK/Core/Tracking/Amplitude/AmplitudeBaseEvent.swift b/Sources/GiniCaptureSDK/Core/Tracking/Amplitude/AmplitudeBaseEvent.swift new file mode 100644 index 0000000..9162bbf --- /dev/null +++ b/Sources/GiniCaptureSDK/Core/Tracking/Amplitude/AmplitudeBaseEvent.swift @@ -0,0 +1,75 @@ +// +// AmplitudeBaseEvent.swift +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation + +/// The `AmplitudeBaseEvent` struct represents an event with various properties and implements encoding for serialization. +struct AmplitudeBaseEvent: Encodable, Equatable { + var eventType: String + var eventProperties: [String: Any]? + var userProperties: [String: Any]? + var eventOptions: AmplitudeEventOptions + + enum CodingKeys: String, CodingKey { + case eventType = "event_type" + case eventProperties = "event_properties" + case userProperties = "user_properties" + case userId = "user_id" + case deviceId = "device_id" + case timestamp = "time" + case eventId = "event_id" + case sessionId = "session_id" + case insertId = "insert_id" + case appVersion = "app_version" + case platform + case osName = "os_name" + case osVersion = "os_version" + case deviceBrand = "device_brand" + case deviceModel = "device_model" + case country + case city + case language + case ip + } + + /// Initializes a new instance of the `AmplitudeBaseEvent` struct. + init(eventType: String, + eventProperties: [String: Any]? = nil, + userProperties: [String: Any]? = nil, + eventOptions: AmplitudeEventOptions) { + self.eventType = eventType + self.eventProperties = eventProperties + self.userProperties = userProperties + self.eventOptions = eventOptions + } + + /// Encodes the event into the provided encoder. + /// + /// - Parameter encoder: The encoder to write data to. + /// - Throws: An error if any values are invalid for the given encoder's format. + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(eventType, forKey: .eventType) + try container.encodeIfPresent(eventProperties, forKey: .eventProperties) + try container.encodeIfPresent(userProperties, forKey: .userProperties) + try container.encodeIfPresent(eventOptions.userId, forKey: .userId) + try container.encodeIfPresent(eventOptions.deviceId, forKey: .deviceId) + try container.encodeIfPresent(eventOptions.time, forKey: .timestamp) + try container.encodeIfPresent(eventOptions.eventId, forKey: .eventId) + try container.encodeIfPresent(eventOptions.sessionId, forKey: .sessionId) + try container.encodeIfPresent(eventOptions.platform, forKey: .platform) + try container.encodeIfPresent(eventOptions.osName, forKey: .osName) + try container.encodeIfPresent(eventOptions.osVersion, forKey: .osVersion) + try container.encodeIfPresent(eventOptions.deviceBrand, forKey: .deviceBrand) + try container.encodeIfPresent(eventOptions.deviceModel, forKey: .deviceModel) + try container.encodeIfPresent(eventOptions.language, forKey: .language) + try container.encodeIfPresent(eventOptions.ip, forKey: .ip) + } + + static func == (lhs: AmplitudeBaseEvent, rhs: AmplitudeBaseEvent) -> Bool { + return lhs.eventType == rhs.eventType && lhs.eventOptions.eventId == rhs.eventOptions.eventId + } +} diff --git a/Sources/GiniCaptureSDK/Core/Tracking/Amplitude/AmplitudeEventOptions.swift b/Sources/GiniCaptureSDK/Core/Tracking/Amplitude/AmplitudeEventOptions.swift new file mode 100644 index 0000000..4846db6 --- /dev/null +++ b/Sources/GiniCaptureSDK/Core/Tracking/Amplitude/AmplitudeEventOptions.swift @@ -0,0 +1,50 @@ +// +// AmplitudeEventOptions.swift +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation + +/// The `AmplitudeEventOptions` struct holds common properties for events. +struct AmplitudeEventOptions { + var userId: String? + var time: Int64? + var sessionId: Int64? + var platform: String? + var osVersion: String? + var osName: String? + var language: String? + var ip: String? + var eventId: Int64? + var deviceModel: String? + var deviceId: String? + var deviceBrand: String? + + /// Initializes a new instance of the `AmplitudeEventOptions` struct. + init(userId: String? = nil, + deviceId: String? = nil, + time: Int64? = nil, + sessionId: Int64? = nil, + platform: String? = nil, + osVersion: String? = nil, + osName: String? = nil, + language: String? = nil, + ip: String? = nil, + eventId: Int64? = nil, + deviceModel: String? = nil, + deviceBrand: String? = nil) { + self.userId = userId + self.deviceId = deviceId + self.time = time + self.sessionId = sessionId + self.platform = platform + self.osVersion = osVersion + self.osName = osName + self.language = language + self.ip = ip + self.eventId = eventId + self.deviceModel = deviceModel + self.deviceBrand = deviceBrand + } +} diff --git a/Sources/GiniCaptureSDK/Core/Tracking/Amplitude/AmplitudeEventsBatchPayload.swift b/Sources/GiniCaptureSDK/Core/Tracking/Amplitude/AmplitudeEventsBatchPayload.swift new file mode 100644 index 0000000..233f7dd --- /dev/null +++ b/Sources/GiniCaptureSDK/Core/Tracking/Amplitude/AmplitudeEventsBatchPayload.swift @@ -0,0 +1,32 @@ +// +// AmplitudeEventsBatchPayload.swift +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation +/** + A struct representing the payload for batching events to be sent to the Amplitude server. + + This struct conforms to the `Encodable` protocol to facilitate easy encoding + to JSON format. It includes the API key and an array of events to be uploaded. + + - Parameters: + - apiKey: The API key for the Amplitude analytics platform. + - events: An array of `AmplitudeBaseEvent` objects to be included in the batch upload. + */ +struct AmplitudeEventsBatchPayload: Encodable { + let apiKey: String + let events: [AmplitudeBaseEvent] + + /** + Customizes the coding keys for the `AmplitudeEventsBatchPayload` struct to match the expected JSON format. + + - apiKey: Encoded as "api_key" in the JSON payload. + - events: Encoded as "events" in the JSON payload. + */ + enum CodingKeys: String, CodingKey { + case apiKey = "api_key" + case events + } +} diff --git a/Sources/GiniCaptureSDK/Core/Tracking/Amplitude/AmplitudeService.swift b/Sources/GiniCaptureSDK/Core/Tracking/Amplitude/AmplitudeService.swift new file mode 100644 index 0000000..5719638 --- /dev/null +++ b/Sources/GiniCaptureSDK/Core/Tracking/Amplitude/AmplitudeService.swift @@ -0,0 +1,258 @@ +// +// AmplitudeService.swift +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation +import UIKit + +/** + A service for tracking and uploading events to the Amplitude analytics platform using directly Ampltitude API. + + This service manages an event queue and handles the periodic uploading of events + to the Amplitude server. It supports automatic retries with exponential backoff + in case of upload failures. The service also observes application lifecycle events + to manage background tasks appropriately. + */ + +final class AmplitudeService { + /** + * The state of an event in the queue. + */ + private enum EventState { + case pending + case inProgress + case sent + } + + /** + * A wrapper for an event, including its state and retry count. + */ + private struct EventWrapper { + var event: AmplitudeBaseEvent + var state: EventState + var retryCount: Int = 0 + } + + private var eventQueue: [EventWrapper] = [] + private var apiKey: String? + private var timer: Timer? + private var backgroundTask: UIBackgroundTaskIdentifier = .invalid + private let maxRetryAttempts = 3 + private let eventUploadInterval: TimeInterval = 5.0 + private let apiURL = "https://api.eu.amplitude.com/batch" + private let queue = DispatchQueue(label: "com.amplitude.service.queue") + + /** + * Initializes the AmplitudeService with an optional API key. + * - Parameter apiKey: The API key for Amplitude. + */ + init(apiKey: String?) { + self.apiKey = apiKey + setupObservers() + startEventUploadTimer() + } + + deinit { + NotificationCenter.default.removeObserver(self) + stopEventUploadTimer() + } + + /** + * Tracks a list of events by adding them to the event queue. + * - Parameter events: The events to be tracked. + */ + func trackEvents(_ events: [AmplitudeBaseEvent]) { + let newEvents = events.map { EventWrapper(event: $0, state: .pending) } + queue.async { + self.eventQueue.append(contentsOf: newEvents) + self.uploadPendingEvents() + } + } + + /** + * Uploads a list of events to the Amplitude server. + * - Parameter events: The events to be uploaded. + */ + private func uploadEvents(events: [EventWrapper]) { + guard let url = URL(string: apiURL), let apiKey, !events.isEmpty else { return } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let payload = AmplitudeEventsBatchPayload(apiKey: apiKey, events: events.map { $0.event }) + + do { + let jsonData = try JSONEncoder().encode(payload) + request.httpBody = jsonData + + let task = URLSession.shared.dataTask(with: request) { [weak self] _, response, error in + guard let self = self else { return } + if let error = error { + print("❌ Error uploading events: \(error)") + self.handleUploadFailure(events: events) + return + } + + if let httpResponse = response as? HTTPURLResponse { + if httpResponse.statusCode == 200 { + print("✅ Successfully uploaded events") + self.markEventsAsSent(events: events) + self.resetAndCleanup() + } else { + print("❌ Failed to upload events: \(httpResponse.statusCode)") + self.handleUploadFailure(events: events) + } + } + } + task.resume() + } catch { + print("❌ Error encoding events: \(error)") + handleUploadFailure(events: events) + } + } + + /** + * Handles the failure of an event upload by retrying the upload if the maximum retry attempts have not been reached. + * - Parameter events: The events that failed to upload. + */ + private func handleUploadFailure(events: [EventWrapper]) { + guard !events.isEmpty else { return } + + queue.async { + for event in events { + if let index = self.eventQueue.firstIndex(where: { $0.event == event.event }) { + self.eventQueue[index].retryCount += 1 + if self.eventQueue[index].retryCount > self.maxRetryAttempts { + self.eventQueue.remove(at: index) + } else { + self.eventQueue[index].state = .pending + } + } + } + + let retryDelay = pow(2.0, Double(events.first?.retryCount ?? 0)) + DispatchQueue.global().asyncAfter(deadline: .now() + retryDelay) { [weak self] in + self?.uploadPendingEvents() + } + } + } + + /** + * Starts the timer that periodically attempts to upload pending events. + */ + private func startEventUploadTimer() { + timer = Timer.scheduledTimer(withTimeInterval: eventUploadInterval, + repeats: true) { [weak self] _ in + self?.uploadPendingEvents() + } + } + + /** + * Stops the event upload timer. + */ + private func stopEventUploadTimer() { + timer?.invalidate() + timer = nil + } + + /** + * Uploads all pending events by changing their state to inProgress and attempting to send them. + */ + private func uploadPendingEvents() { + queue.async { + let pendingEvents = self.eventQueue.filter { $0.state == .pending } + guard !pendingEvents.isEmpty else { return } + for event in pendingEvents { + if let index = self.eventQueue.firstIndex(where: { $0.event == event.event }) { + self.eventQueue[index].state = .inProgress + } + } + self.uploadEvents(events: pendingEvents) + } + } + + /** + * Resets the retry attempts and removes all sent events from the queue. + */ + private func resetAndCleanup() { + queue.async { + self.eventQueue.removeAll { $0.state == .sent } + } + } + + /** + * Marks the specified events as sent by updating their state in the queue. + * - Parameter events: The events to mark as sent. + */ + private func markEventsAsSent(events: [EventWrapper]) { + queue.async { + for event in events { + if let index = self.eventQueue.firstIndex(where: { $0.event == event.event }) { + self.eventQueue[index].state = .sent + } + } + } + } + + /** + * Sets up the observers for application lifecycle events. + */ + private func setupObservers() { + NotificationCenter.default.addObserver(self, selector: #selector(appDidEnterBackground), + name: UIApplication.didEnterBackgroundNotification, + object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(appWillEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(appWillTerminate), + name: UIApplication.willTerminateNotification, + object: nil) + } + + /** + * Called when the application enters the background. + */ + @objc private func appDidEnterBackground() { + stopEventUploadTimer() + startBackgroundTask() + } + + /** + * Called when the application will enter the foreground. + */ + @objc private func appWillEnterForeground() { + endBackgroundTask() + startEventUploadTimer() + } + + /** + * Called when the application will terminate. + */ + @objc private func appWillTerminate() { + stopEventUploadTimer() + uploadPendingEvents() + } + + /** + * Starts a background task to continue uploading events when the app enters the background. + */ + private func startBackgroundTask() { + backgroundTask = UIApplication.shared.beginBackgroundTask { [weak self] in + self?.endBackgroundTask() + } + uploadPendingEvents() + } + + /** + * Ends the background task. + */ + private func endBackgroundTask() { + if backgroundTask != .invalid { + UIApplication.shared.endBackgroundTask(backgroundTask) + backgroundTask = .invalid + } + } +} diff --git a/Sources/GiniCaptureSDK/Core/Tracking/GiniAnalyticsEvent.swift b/Sources/GiniCaptureSDK/Core/Tracking/GiniAnalyticsEvent.swift new file mode 100644 index 0000000..8a05d1b --- /dev/null +++ b/Sources/GiniCaptureSDK/Core/Tracking/GiniAnalyticsEvent.swift @@ -0,0 +1,58 @@ +// +// GiniAnalyticsEvent.swift +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation + +public enum GiniAnalyticsEvent: String { + case screenShown = "screen_shown" + case closeTapped = "close_tapped" + case sdkOpened = "sdk_opened" + case sdkClosed = "sdk_closed" + + // MARK: - Camera + case captureTapped = "capture_tapped" + case importFilesTapped = "import_files_tapped" + case uploadPhotosTapped = "upload_photos_tapped" + case uploadDocumentsTapped = "upload_documents_tapped" + case flashTapped = "flash_tapped" + case helpTapped = "help_tapped" + case multiplePagesCapturedTapped = "multiple_pages_captured_tapped" + case errorDialogShown = "error_dialog_shown" + case qr_code_scanned = "qr_code_scanned" + + // MARK: Camera permission + case cameraPermissionShown = "camera_permission_shown" + case cameraPermissionTapped = "camera_permission_tapped" + case giveAccessTapped = "give_access_tapped" + + // MARK: - Review + case processTapped = "process_tapped" + case deletePagesTapped = "delete_pages_tapped" + case addPagesTapped = "add_pages_tapped" + case pageSwiped = "page_swiped" + case fullScreenPageTapped = "full_screen_page_tapped" + case previewZoomed = "preview_zoomed" + + // MARK: - No Results and Error + case enterManuallyTapped = "enter_manually_tapped" + case retakeImagesTapped = "retake_images_tapped" + case backToCameraTapped = "back_to_camera_tapped" + + // MARK: - Help + case helpItemTapped = "help_item_tapped" + + // MARK: - Onboarding + case skipTapped = "skip_tapped" + case nextStepTapped = "next_step_tapped" + case getStartedTapped = "get_started_tapped" + + // MARK: - Return assistant + case dismissed + case saveTapped = "save_tapped" + case editTapped = "edit_tapped" + case itemSwitchTapped = "item_switch_tapped" + case proceedTapped = "proceed_tapped" +} diff --git a/Sources/GiniCaptureSDK/Core/Tracking/GiniAnalyticsManager.swift b/Sources/GiniCaptureSDK/Core/Tracking/GiniAnalyticsManager.swift new file mode 100644 index 0000000..0da437d --- /dev/null +++ b/Sources/GiniCaptureSDK/Core/Tracking/GiniAnalyticsManager.swift @@ -0,0 +1,236 @@ +// +// GiniAnalyticsManager.swift +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import UIKit + +public final class GiniAnalyticsManager { + private static var amplitudeService: AmplitudeService? { + didSet { + handleAnalyticsSDKsInit() + } + } + private static var userProperties: [GiniAnalyticsUserProperty: GiniAnalyticsPropertyValue] = [:] + private static var superProperties: [GiniAnalyticsSuperProperty: GiniAnalyticsPropertyValue] = [:] + private static var sessionId: Int64? + + private static var eventsQueue: [GiniQueuedAnalyticsEvent] = [] + private static let deviceID = UIDevice.current.identifierForVendor?.uuidString ?? "" + private static var giniClientID: String? + private static var eventId: Int64 = 0 + + public static func initializeAnalytics(with configuration: GiniAnalyticsConfiguration) { + guard configuration.userJourneyAnalyticsEnabled, + GiniTrackingPermissionManager.shared.trackingAuthorized() else { return } + + giniClientID = configuration.clientID + initializeAmplitude(with: configuration.amplitudeApiKey) + } + + public static func cleanManager() { + userProperties = [:] + superProperties = [:] + eventsQueue = [] + sessionId = nil + eventId = 0 + } + + public static func setSessionId() { + // Generate a new session identifier + sessionId = Date.berlinTimestamp() + } + + // MARK: Initialization + + private static func initializeAmplitude(with apiKey: String?) { + amplitudeService = AmplitudeService(apiKey: apiKey) + } + + private static func handleAnalyticsSDKsInit() { + guard amplitudeService != nil else { return } + userProperties[.captureSDKVersion] = GiniCapture.versionString + registerSuperProperties(superProperties) + trackUserProperties(userProperties) + trackAccessibilityUserPropertiesAtInitialization() + processEventsQueue() + } + + // MARK: - Event counter + private static func incrementEventId() -> Int64 { + eventId += 1 + return eventId + } + + // MARK: - Track screen shown + public static func trackScreenShown(screenName: GiniAnalyticsScreen, + properties: [GiniAnalyticsProperty] = []) { + track(event: GiniAnalyticsEvent.screenShown, + screenName: screenName, + properties: properties) + } + + static func trackScreenShown(screenNameString: String, + properties: [GiniAnalyticsProperty] = []) { + track(event: GiniAnalyticsEvent.screenShown, + screenNameString: screenNameString, + properties: properties) + } + + // MARK: - Track event on screen + public static func track(event: GiniAnalyticsEvent, + screenName: GiniAnalyticsScreen? = nil, + properties: [GiniAnalyticsProperty] = []) { + track(event: event, + screenNameString: screenName?.rawValue, + properties: properties) + + } + + static func track(event: GiniAnalyticsEvent, + screenNameString: String? = nil, + properties: [GiniAnalyticsProperty] = []) { + let queuedEvent = GiniQueuedAnalyticsEvent(event: event, + screenNameString: screenNameString, + properties: properties) + eventsQueue.append(queuedEvent) + + // Process the event queue if AmplitudeService is initialized + if amplitudeService != nil { + processEventsQueue() + } + } + + /// Processes the events queue by sending each queued event to Mixpanel and Amplitude + private static func processEventsQueue() { + var baseEvents: [AmplitudeBaseEvent] = [] + + while !eventsQueue.isEmpty { + let queuedEvent = eventsQueue.removeFirst() + if let baseEvent = convertToBaseEvent(event: queuedEvent) { + baseEvents.append(baseEvent) + } + } + + amplitudeService?.trackEvents(baseEvents) + } + + /// Converts a `GiniQueuedAnalyticsEvent` to a `AmplitudeBaseEvent` + private static func convertToBaseEvent(event: GiniQueuedAnalyticsEvent) -> AmplitudeBaseEvent? { + var eventProperties: [String: String] = [:] + + if let screenName = event.screenNameString { + eventProperties[GiniAnalyticsPropertyKey.screenName.rawValue] = screenName + } + + for property in event.properties { + let propertyValue = property.value.analyticsPropertyValue() + eventProperties[property.key.rawValue] = convertPropertyValueToString(propertyValue) + } + + superProperties[.giniClientID] = giniClientID + + // Merge event properties with super properties. In case of key collisions, values from eventProperties will be used. + eventProperties = eventProperties + .merging(mapAmplitudeSuperProperties(properties: superProperties)) { (_, new) in new } + + // Add `giniClientID` to `userProperties` + var userProperties = mapAmplitudeUserProperties(properties: userProperties) + userProperties[GiniAnalyticsSuperProperty.giniClientID.rawValue] = giniClientID + + let iosSystem = IOSSystem() + let eventId = incrementEventId() + let eventOptions = AmplitudeEventOptions(userId: deviceID, + deviceId: iosSystem.identifierForVendor, + time: Date.berlinTimestamp(), + sessionId: sessionId, + platform: iosSystem.platform, + osVersion: iosSystem.osVersion, + osName: iosSystem.osName, + language: iosSystem.systemLanguage, + ip: "$remote", + eventId: eventId, + deviceModel: iosSystem.model, + deviceBrand: iosSystem.manufacturer) + + return AmplitudeBaseEvent(eventType: event.event.rawValue, + eventProperties: eventProperties, + userProperties: userProperties, + eventOptions: eventOptions) + } + + public static func trackUserProperties(_ properties: [GiniAnalyticsUserProperty: GiniAnalyticsPropertyValue]) { + for (key, value) in properties { + userProperties[key] = value + } + } + + public static func registerSuperProperties(_ properties: [GiniAnalyticsSuperProperty: GiniAnalyticsPropertyValue]) { + for (key, value) in properties { + superProperties[key] = value + } + } + + private static func trackAccessibilityUserPropertiesAtInitialization() { + let accessibilityProperties: [GiniAnalyticsUserProperty: GiniAnalyticsPropertyValue] = [ + .voiceOverEnabled: UIAccessibility.isVoiceOverRunning, + .guidedAccessEnabled: UIAccessibility.isGuidedAccessEnabled, + .boldTextEnabled: UIAccessibility.isBoldTextEnabled, + .grayscaleEnabled: UIAccessibility.isGrayscaleEnabled, + .speakSelectionEnabled: UIAccessibility.isSpeakSelectionEnabled, + .speakScreenEnabled: UIAccessibility.isSpeakScreenEnabled, + .assistiveTouchEnabled: UIAccessibility.isAssistiveTouchRunning + ] + trackUserProperties(accessibilityProperties) + } + + // MARK: - Helper methods + private static func boolToString(from original: Bool) -> String { + return original ? "yes" : "no" + } + + private static func arrayToString(from original: [String]) -> String { + var result = "[" + result += original.map { "\"\($0)\"" }.joined(separator: ", ") + result += "]" + return result + } + + private static func convertPropertyValueToString(_ value: GiniAnalyticsPropertyValue) -> String { + switch value { + case let value as Bool: + return boolToString(from: value) + case let value as String: + return value + case let value as Int: + return "\(value)" + case let value as [String]: + return arrayToString(from: value) + default: + return "" + } + } + + private static func mapAmplitudeSuperProperties(properties: [GiniAnalyticsSuperProperty: GiniAnalyticsPropertyValue]) + -> [String: String] { + return properties + .map { (key, value) in + (key.rawValue, convertPropertyValueToString(value)) + } + .reduce(into: [String: String]()) { (dict, pair) in + dict[pair.0] = pair.1 + } + } + + private static func mapAmplitudeUserProperties(properties: [GiniAnalyticsUserProperty: GiniAnalyticsPropertyValue]) + -> [String: String] { + return properties + .map { (key, value) in + (key.rawValue, convertPropertyValueToString(value)) + } + .reduce(into: [String: String]()) { (dict, pair) in + dict[pair.0] = pair.1 + } + } +} diff --git a/Sources/GiniCaptureSDK/Core/Tracking/GiniAnalyticsMapper.swift b/Sources/GiniCaptureSDK/Core/Tracking/GiniAnalyticsMapper.swift new file mode 100644 index 0000000..6e2f0ea --- /dev/null +++ b/Sources/GiniCaptureSDK/Core/Tracking/GiniAnalyticsMapper.swift @@ -0,0 +1,87 @@ +// +// GiniAnalyticsMapper.swift +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation +import GiniBankAPILibrary + +/** + A utility class for mapping different types to their respective analytics representations. + */ +class GiniAnalyticsMapper { + + /** + Converts a `NoResultScreenViewController.NoResultType` to its analytics string representation. + + - Parameter noResultType: The type of no result screen. + - Returns: A string representing the document type for analytics purposes. + */ + static func documentTypeAnalytics(from noResultType: NoResultScreenViewController.NoResultType) -> String { + switch noResultType { + case .pdf: + return "pdf" + case .image: + return "image" + case .qrCode: + return "qrCode" + default: + return "unknown" + } + } + + /** + Converts a `GiniCaptureDocumentType` to its analytics string representation. + + - Parameter documentType: The type of document captured. + - Returns: A string representing the document type for analytics purposes. + */ + static func documentTypeAnalytics(from documentType: GiniCaptureDocumentType) -> String { + switch documentType { + case .pdf: + return "pdf" + case .image: + return "image" + case .qrcode: + return "qrCode" + } + } + + /** + Converts a `GiniError` to an `GiniErrorAnalytics` object for analytics purposes. + + - Parameter error: The `GiniError` encountered. + - Returns: An `GiniErrorAnalytics` object containing details about the error that will be logged in analytics. + */ + static func errorAnalytics(from error: GiniError) -> GiniErrorAnalytics { + switch error { + case .badRequest(let response, _): + return GiniErrorAnalytics(type: "bad_request", code: response?.statusCode, reason: error.message) + case .notAcceptable(let response, _): + return GiniErrorAnalytics(type: "not_acceptable", code: response?.statusCode, reason: error.message) + case .notFound(let response, _): + return GiniErrorAnalytics(type: "not_found", code: response?.statusCode, reason: error.message) + case .noResponse: + return GiniErrorAnalytics(type: "no_response", reason: error.message) + case .parseError(_, let response, _): + return GiniErrorAnalytics(type: "bad_request", code: response?.statusCode, reason: error.message) + case .requestCancelled: + return GiniErrorAnalytics(type: "request_cancelled", reason: error.message) + case .tooManyRequests(let response, _): + return GiniErrorAnalytics(type: "too_many_requests", code: response?.statusCode, reason: error.message) + case .unauthorized(let response, _): + return GiniErrorAnalytics(type: "unauthorized", code: response?.statusCode, reason: error.message) + case .maintenance(let errorCode): + return GiniErrorAnalytics(type: "maintenance", code: errorCode, reason: error.message) + case .outage(let errorCode): + return GiniErrorAnalytics(type: "outage", code: errorCode, reason: error.message) + case .server(let errorCode): + return GiniErrorAnalytics(type: "server", code: errorCode, reason: error.message) + case .unknown(let response, _): + return GiniErrorAnalytics(type: "unknown", code: response?.statusCode, reason: error.message) + case .noInternetConnection: + return GiniErrorAnalytics(type: "no_internet") + } + } +} diff --git a/Sources/GiniCaptureSDK/Core/Tracking/GiniAnalyticsProperties.swift b/Sources/GiniCaptureSDK/Core/Tracking/GiniAnalyticsProperties.swift new file mode 100644 index 0000000..710e710 --- /dev/null +++ b/Sources/GiniCaptureSDK/Core/Tracking/GiniAnalyticsProperties.swift @@ -0,0 +1,70 @@ +// +// GiniAnalyticsProperties.swift +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation + +public struct GiniAnalyticsProperty { + public let key: GiniAnalyticsPropertyKey + public var value: GiniAnalyticsPropertyValue + + public init(key: GiniAnalyticsPropertyKey, value: GiniAnalyticsPropertyValue) { + self.key = key + self.value = value + } +} + +public protocol GiniAnalyticsPropertyValue { + func analyticsPropertyValue() -> Self +} + +extension String: GiniAnalyticsPropertyValue { + public func analyticsPropertyValue() -> String { + return self + } +} + +extension Int: GiniAnalyticsPropertyValue { + public func analyticsPropertyValue() -> Int { + return self + } +} + +extension Bool: GiniAnalyticsPropertyValue { + public func analyticsPropertyValue() -> Bool { + return self + } +} + +extension Array: GiniAnalyticsPropertyValue where Element == String { + public func analyticsPropertyValue() -> [String] { + return self + } +} + +public enum GiniAnalyticsPropertyKey: String { + case screenName = "screen" + + case flashActive = "flash_active" + case qrCodeValid = "qr_code_valid" + case numberOfPagesScanned = "number_of_pages_scanned" + case ibanDetectionLayerVisible = "iban_detection_layer_visible" + + case errorMessage = "error_message" + case documentType = "document_type" + case errorCode = "error_code" + case errorType = "error_type" + + case hasCustomItems = "has_custom_items" + case helpItems = "help_items" + case itemTapped = "item_tapped" + case customOnboardingTitle = "custom_onboarding_title" + case documentId = "document_id" + + case itemsChanged = "items_changed" + case switchActive = "switch_active" + case permissionStatus = "permission_status" + case status +} diff --git a/Sources/GiniCaptureSDK/Core/Tracking/GiniAnalyticsScreen.swift b/Sources/GiniCaptureSDK/Core/Tracking/GiniAnalyticsScreen.swift new file mode 100644 index 0000000..cec9ef1 --- /dev/null +++ b/Sources/GiniCaptureSDK/Core/Tracking/GiniAnalyticsScreen.swift @@ -0,0 +1,27 @@ +// +// AnalyticsScreen.swift +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation + +public enum GiniAnalyticsScreen: String { + case camera + case review + case reviewZoom = "review_zoom" + case analysis + case noResults = "no_results" + case error + case help + case onboardingFlatPaper = "onboarding_flat_paper" + case onboardingLighting = "onboarding_lighting" + case onboardingMultipage = "onboarding_multiple_pages" + case onboardingQRcode = "onboarding_qr_code" + case onboardingCustom = "onboarding_custom_" // e.g: onboarding_custom_1, onboarding_custom_2 + case onboardingReturnAssistant = "onboarding_return_assistant" + case returnAssistant = "return_assistant" + case editReturnAssistant = "edit_return_assistant" + case cameraPermissionView = "camera_permission_view" + case cameraAccess = "camera_access" +} diff --git a/Sources/GiniCaptureSDK/Core/Tracking/GiniAnalyticsSuperProperty.swift b/Sources/GiniCaptureSDK/Core/Tracking/GiniAnalyticsSuperProperty.swift new file mode 100644 index 0000000..c5af39d --- /dev/null +++ b/Sources/GiniCaptureSDK/Core/Tracking/GiniAnalyticsSuperProperty.swift @@ -0,0 +1,12 @@ +// +// GiniAnalyticsSuperProperty.swift +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation + +public enum GiniAnalyticsSuperProperty: String { + case entryPoint = "entry_point" + case giniClientID = "gini_client_id" +} diff --git a/Sources/GiniCaptureSDK/Core/Tracking/GiniAnalyticsUserProperty.swift b/Sources/GiniCaptureSDK/Core/Tracking/GiniAnalyticsUserProperty.swift new file mode 100644 index 0000000..228798f --- /dev/null +++ b/Sources/GiniCaptureSDK/Core/Tracking/GiniAnalyticsUserProperty.swift @@ -0,0 +1,21 @@ +// +// GiniAnalyticsUserProperty.swift +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation + +public enum GiniAnalyticsUserProperty: String { + case voiceOverEnabled = "voice_over_enabled" + case guidedAccessEnabled = "guided_access_enabled" + case boldTextEnabled = "bold_text_enabled" + case grayscaleEnabled = "grayscale_enabled" + case speakSelectionEnabled = "speak_selection_enabled" + case speakScreenEnabled = "speak_screen_enabled" + case assistiveTouchEnabled = "assistive_touch_enabled" + case returnReasonsEnabled = "return_reasons_enabled" + case returnAssistantEnabled = "return_assistant_enabled" + case bankSDKVersion = "bank_sdk_version" + case captureSDKVersion = "capture_sdk_version" +} diff --git a/Sources/GiniCaptureSDK/Core/Tracking/GiniTrackingPermissionManager.swift b/Sources/GiniCaptureSDK/Core/Tracking/GiniTrackingPermissionManager.swift new file mode 100644 index 0000000..71a9c0c --- /dev/null +++ b/Sources/GiniCaptureSDK/Core/Tracking/GiniTrackingPermissionManager.swift @@ -0,0 +1,31 @@ +// +// GiniTrackingPermissionManager.swift +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import AppTrackingTransparency +import AdSupport + +class GiniTrackingPermissionManager { + + static let shared = GiniTrackingPermissionManager() + + // The private empty initializer ensures that no other instances of this class can be created. + // By making the initializer private and providing a static shared instance, we enforce a singleton pattern. + // This means there is only one instance of GiniTrackingPermissionManager throughout the app's lifecycle. + private init() {} + + // This function checks whether tracking is authorized. + // - If the iOS version is 14 or later, it uses ATTrackingManager to determine the authorization status. + // - If the iOS version is earlier than 14, it assumes tracking is enabled by default, because the + // concept of tracking authorization did not exist in those versions. + func trackingAuthorized() -> Bool { + if #available(iOS 14, *) { + return ATTrackingManager.trackingAuthorizationStatus == .authorized + || ATTrackingManager.trackingAuthorizationStatus == .notDetermined + } else { + return true // Tracking is enabled by default on earlier iOS versions + } + } +} diff --git a/Sources/GiniCaptureSDK/Core/Tracking/Models/GiniAnalyticsConfiguration.swift b/Sources/GiniCaptureSDK/Core/Tracking/Models/GiniAnalyticsConfiguration.swift new file mode 100644 index 0000000..cfe5643 --- /dev/null +++ b/Sources/GiniCaptureSDK/Core/Tracking/Models/GiniAnalyticsConfiguration.swift @@ -0,0 +1,29 @@ +// +// GiniAnalyticsConfiguration.swift +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +/** + Struct for analytics configuration settings + */ +public struct GiniAnalyticsConfiguration { + /** + An initializer for an `GiniAnalyticsConfiguration` structure + + - parameter clientID: A unique identifier for the client. + - parameter userJourneyAnalyticsEnabled: A flag indicating whether user journey analytics is enabled. + - parameter amplitudeApiKey: An optional API key for Amplitude integration. + */ + public init(clientID: String, + userJourneyAnalyticsEnabled: Bool, + amplitudeApiKey: String?) { + self.clientID = clientID + self.userJourneyAnalyticsEnabled = userJourneyAnalyticsEnabled + self.amplitudeApiKey = amplitudeApiKey + } + + public let clientID: String + public let userJourneyAnalyticsEnabled: Bool + public let amplitudeApiKey: String? +} diff --git a/Sources/GiniCaptureSDK/Core/Tracking/Models/GiniCameraPermissionStatusAnalytics.swift b/Sources/GiniCaptureSDK/Core/Tracking/Models/GiniCameraPermissionStatusAnalytics.swift new file mode 100644 index 0000000..123578a --- /dev/null +++ b/Sources/GiniCaptureSDK/Core/Tracking/Models/GiniCameraPermissionStatusAnalytics.swift @@ -0,0 +1,12 @@ +// +// GiniCameraPermissionStatusAnalytics.swift +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation + +enum GiniCameraPermissionStatusAnalytics: String { + case allowed + case notAllowed = "not_allowed" +} diff --git a/Sources/GiniCaptureSDK/Core/Tracking/Models/GiniEntryPointAnalytics.swift b/Sources/GiniCaptureSDK/Core/Tracking/Models/GiniEntryPointAnalytics.swift new file mode 100644 index 0000000..61d757a --- /dev/null +++ b/Sources/GiniCaptureSDK/Core/Tracking/Models/GiniEntryPointAnalytics.swift @@ -0,0 +1,22 @@ +// +// GiniEntryPointAnalytics.swift +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation + +public enum GiniEntryPointAnalytics: String { + case button + case field + case openWith = "open_with" + + public static func makeFrom(entryPoint: GiniConfiguration.GiniEntryPoint) -> GiniEntryPointAnalytics { + switch entryPoint { + case .button: + return .button + case .field: + return .field + } + } +} diff --git a/Sources/GiniCaptureSDK/Core/Tracking/Models/GiniErrorAnalytics.swift b/Sources/GiniCaptureSDK/Core/Tracking/Models/GiniErrorAnalytics.swift new file mode 100644 index 0000000..24c043f --- /dev/null +++ b/Sources/GiniCaptureSDK/Core/Tracking/Models/GiniErrorAnalytics.swift @@ -0,0 +1,19 @@ +// +// GiniErrorAnalytics.swift +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation + +public struct GiniErrorAnalytics { + let type: String + let code: Int? + let reason: String? + + init(type: String, code: Int? = nil, reason: String? = nil) { + self.type = type + self.code = code + self.reason = reason + } +} diff --git a/Sources/GiniCaptureSDK/Core/Tracking/Models/GiniLineItemAnalytics.swift b/Sources/GiniCaptureSDK/Core/Tracking/Models/GiniLineItemAnalytics.swift new file mode 100644 index 0000000..202a79f --- /dev/null +++ b/Sources/GiniCaptureSDK/Core/Tracking/Models/GiniLineItemAnalytics.swift @@ -0,0 +1,13 @@ +// +// GiniLineItemAnalytics.swift +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation + +public enum GiniLineItemAnalytics: String { + case name + case quantity + case price +} diff --git a/Sources/GiniCaptureSDK/Core/Tracking/Models/GiniQueuedAnalyticsEvent.swift b/Sources/GiniCaptureSDK/Core/Tracking/Models/GiniQueuedAnalyticsEvent.swift new file mode 100644 index 0000000..0089c59 --- /dev/null +++ b/Sources/GiniCaptureSDK/Core/Tracking/Models/GiniQueuedAnalyticsEvent.swift @@ -0,0 +1,13 @@ +// +// GiniQueuedAnalyticsEvent.swift +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation + +public struct GiniQueuedAnalyticsEvent { + let event: GiniAnalyticsEvent + let screenNameString: String? + let properties: [GiniAnalyticsProperty] +} diff --git a/Sources/GiniCaptureSDK/Core/Tracking/Utilities/IOSSystem.swift b/Sources/GiniCaptureSDK/Core/Tracking/Utilities/IOSSystem.swift new file mode 100644 index 0000000..80c19f5 --- /dev/null +++ b/Sources/GiniCaptureSDK/Core/Tracking/Utilities/IOSSystem.swift @@ -0,0 +1,61 @@ +// +// IOSSystem.swift +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import UIKit + +internal class IOSSystem { + private let device = UIDevice.current + let manufacturer: String = "Apple" + + var model: String { + deviceModel() + } + + var identifierForVendor: String? { + device.identifierForVendor?.uuidString + } + + var osName: String { + device.systemName.lowercased() + } + + var osVersion: String { + device.systemVersion + } + + var systemLanguage: String? { + Locale.preferredLanguages.first + } + + var platform: String { + device.userInterfaceIdiom == .pad ? "iPadOS" : "iOS" + } + + // MARK: - Private methods + private func getPlatformString() -> String { + var name: [Int32] = [CTL_HW, HW_MACHINE] + var size: Int = 2 + sysctl(&name, 2, nil, &size, nil, 0) + var hwMachine = [CChar](repeating: 0, count: Int(size)) + sysctl(&name, 2, &hwMachine, &size, nil, 0) + return String(cString: hwMachine) + } + + private func deviceModel() -> String { + let platform = getPlatformString() + return getDeviceModel(platform: platform) + } + + private func getDeviceModel(platform: String) -> String { + // use server device mapping except for the following exceptions + + if platform == "i386" || platform == "x86_64" { + return "Simulator" + } + + return platform + } +} diff --git a/Sources/GiniCaptureSDK/Core/Tracking/Utilities/JSONCodingKeys.swift b/Sources/GiniCaptureSDK/Core/Tracking/Utilities/JSONCodingKeys.swift new file mode 100644 index 0000000..593d97a --- /dev/null +++ b/Sources/GiniCaptureSDK/Core/Tracking/Utilities/JSONCodingKeys.swift @@ -0,0 +1,28 @@ +// +// JSONCodingKeys.swift +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation + +/// A structure conforming to the `CodingKey` protocol, used for encoding JSON keys. +internal struct JSONCodingKeys: CodingKey { + var stringValue: String + var intValue: Int? + + /// Initializes a `JSONCodingKeys` instance with a string value. + /// + /// - Parameter stringValue: The string value of the key. + init?(stringValue: String) { + self.stringValue = stringValue + } + + /// Initializes a `JSONCodingKeys` instance with an integer value. + /// + /// - Parameter intValue: The integer value of the key. + init?(intValue: Int) { + self.init(stringValue: "\(intValue)") + self.intValue = intValue + } +} diff --git a/Sources/GiniCaptureSDK/Core/Tracking/Utilities/KeyedEncodingContainer.swift b/Sources/GiniCaptureSDK/Core/Tracking/Utilities/KeyedEncodingContainer.swift new file mode 100644 index 0000000..6cc0e8d --- /dev/null +++ b/Sources/GiniCaptureSDK/Core/Tracking/Utilities/KeyedEncodingContainer.swift @@ -0,0 +1,57 @@ +// +// KeyedEncodingContainer.swift +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation + +internal extension KeyedEncodingContainer { + + /// Encodes a dictionary into the container for the given key, if present. + /// + /// - Parameters: + /// - value: The dictionary to encode. + /// - key: The key to associate with the encoded value. + /// - Throws: An encoding error if the data is invalid. + mutating func encodeIfPresent(_ value: [String: Any?]?, forKey key: KeyedEncodingContainer.Key) throws { + guard let safeValue = value, !safeValue.isEmpty else { return } + var container = self.nestedContainer(keyedBy: JSONCodingKeys.self, forKey: key) + for item in safeValue { + guard let codingKey = JSONCodingKeys(stringValue: item.key) else { + continue // Skip the item if we cannot create a JSONCodingKey + } + if let val = item.value as? Int { + try container.encodeIfPresent(val, forKey: codingKey) + } else if let val = item.value as? String { + try container.encodeIfPresent(val, forKey: codingKey) + } else if let val = item.value as? Bool { + try container.encodeIfPresent(val, forKey: codingKey) + } else if let val = item.value as? [Any] { + try container.encodeIfPresent(val, forKey: codingKey) + } else if let val = item.value as? [String: Any] { + try container.encodeIfPresent(val, forKey: codingKey) + } + } + } + + /// Encodes an array into the container for the given key, if present. + /// + /// - Parameters: + /// - value: The array to encode. + /// - key: The key to associate with the encoded value. + /// - Throws: An encoding error if the data is invalid. + mutating func encodeIfPresent(_ value: [Any]?, forKey key: KeyedEncodingContainer.Key) throws { + guard let safeValue = value else { return } + if let val = safeValue as? [Int] { + try self.encodeIfPresent(val, forKey: key) + } else if let val = safeValue as? [String] { + try self.encodeIfPresent(val, forKey: key) + } else if let val = safeValue as? [Bool] { + try self.encodeIfPresent(val, forKey: key) + } else if let val = value as? [[String: Any]] { + var container = self.nestedUnkeyedContainer(forKey: key) + try container.encode(contentsOf: val) + } + } +} diff --git a/Sources/GiniCaptureSDK/Core/Tracking/Utilities/UnkeyedEncodingContainer.swift b/Sources/GiniCaptureSDK/Core/Tracking/Utilities/UnkeyedEncodingContainer.swift new file mode 100644 index 0000000..9f3e543 --- /dev/null +++ b/Sources/GiniCaptureSDK/Core/Tracking/Utilities/UnkeyedEncodingContainer.swift @@ -0,0 +1,44 @@ +// +// UnkeyedEncodingContainer.swift +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation + +internal extension UnkeyedEncodingContainer { + + /// Encodes a sequence of dictionaries into the container. + /// + /// - Parameter sequence: The sequence of dictionaries to encode. + /// - Throws: An encoding error if the data is invalid. + mutating func encode(contentsOf sequence: [[String: Any]]) throws { + for dict in sequence { + try self.encodeIfPresent(dict) + } + } + + /// Encodes a dictionary into the container, if present. + /// + /// - Parameter value: The dictionary to encode. + /// - Throws: An encoding error if the data is invalid. + mutating func encodeIfPresent(_ value: [String: Any]) throws { + var container = self.nestedContainer(keyedBy: JSONCodingKeys.self) + for item in value { + guard let codingKey = JSONCodingKeys(stringValue: item.key) else { + continue // Skip the item if we cannot create a JSONCodingKey + } + if let val = item.value as? Int { + try container.encodeIfPresent(val, forKey: codingKey) + } else if let val = item.value as? String { + try container.encodeIfPresent(val, forKey: codingKey) + } else if let val = item.value as? Bool { + try container.encodeIfPresent(val, forKey: codingKey) + } else if let val = item.value as? [Any] { + try container.encodeIfPresent(val, forKey: codingKey) + } else if let val = item.value as? [String: Any] { + try container.encodeIfPresent(val, forKey: codingKey) + } + } + } +} diff --git a/Sources/GiniCaptureSDK/GiniCaptureSDKVersion.swift b/Sources/GiniCaptureSDK/GiniCaptureSDKVersion.swift index e75daee..5b24101 100644 --- a/Sources/GiniCaptureSDK/GiniCaptureSDKVersion.swift +++ b/Sources/GiniCaptureSDK/GiniCaptureSDKVersion.swift @@ -2,7 +2,7 @@ // GiniCaptureSDKVersion.swift // // -// Created by Nadya Karaban on 29.10.21. +// Copyright © 2024 Gini GmbH. All rights reserved. // -public let GiniCaptureSDKVersion = "3.8.0" +public let GiniCaptureSDKVersion = "3.9.0" diff --git a/Sources/GiniCaptureSDK/Networking/AnalysisResult.swift b/Sources/GiniCaptureSDK/Networking/AnalysisResult.swift index 098f46d..e3da635 100644 --- a/Sources/GiniCaptureSDK/Networking/AnalysisResult.swift +++ b/Sources/GiniCaptureSDK/Networking/AnalysisResult.swift @@ -27,7 +27,12 @@ import GiniBankAPILibrary * Line item compound extractions obtained in the analysis. */ public let lineItems: [[Extraction]]? - + + /** + * Skonto extractions obtained in the analysis. + */ + public let skontoDiscounts: [[Extraction]]? + /** * The analyzed Gini Bank API document. * @@ -45,12 +50,14 @@ import GiniBankAPILibrary public init(extractions: [String: Extraction], lineItems: [[Extraction]]? = nil, + skontoDiscounts: [[Extraction]]? = nil, images: [UIImage], document: Document? = nil, candidates: [String: [Extraction.Candidate]]) { self.images = images self.extractions = extractions self.lineItems = lineItems + self.skontoDiscounts = skontoDiscounts self.document = document self.candidates = candidates } diff --git a/Sources/GiniCaptureSDK/Networking/Extensions/GiniCapture+GiniCaptureDelegate.swift b/Sources/GiniCaptureSDK/Networking/Extensions/GiniCapture+GiniCaptureDelegate.swift index 8da3b89..f997a91 100644 --- a/Sources/GiniCaptureSDK/Networking/Extensions/GiniCapture+GiniCaptureDelegate.swift +++ b/Sources/GiniCaptureSDK/Networking/Extensions/GiniCapture+GiniCaptureDelegate.swift @@ -65,7 +65,8 @@ extension GiniCapture { resultsDelegate: GiniCaptureResultsDelegate, documentMetadata: Document.Metadata? = nil, trackingDelegate: GiniCaptureTrackingDelegate? = nil, - networkingService: GiniCaptureNetworkService) -> UIViewController { + networkingService: GiniCaptureNetworkService, + configurationService: ClientConfigurationServiceProtocol? = nil) -> UIViewController { GiniCapture.setConfiguration(configuration) let screenCoordinator = GiniNetworkingScreenAPICoordinator(resultsDelegate: resultsDelegate, giniConfiguration: configuration, documentMetadata: documentMetadata, trackingDelegate: trackingDelegate, captureNetworkService: networkingService) diff --git a/Sources/GiniCaptureSDK/Networking/GiniNetworkingScreenAPICoordinator.swift b/Sources/GiniCaptureSDK/Networking/GiniNetworkingScreenAPICoordinator.swift index e0f0b93..b440cf7 100644 --- a/Sources/GiniCaptureSDK/Networking/GiniNetworkingScreenAPICoordinator.swift +++ b/Sources/GiniCaptureSDK/Networking/GiniNetworkingScreenAPICoordinator.swift @@ -2,7 +2,7 @@ // GiniNetworkingScreenAPICoordinator.swift // GiniCapture // -// Created by Alpár Szotyori on 25.06.19. +// Copyright © 2024 Gini GmbH. All rights reserved. // import Foundation @@ -31,36 +31,33 @@ import GiniBankAPILibrary func giniCaptureDidEnterManually() } - public class GiniNetworkingScreenAPICoordinator: GiniScreenAPICoordinator { +public class GiniNetworkingScreenAPICoordinator: GiniScreenAPICoordinator { public weak var resultsDelegate: GiniCaptureResultsDelegate? public let documentService: DocumentServiceProtocol - public init(client: Client, - resultsDelegate: GiniCaptureResultsDelegate, - giniConfiguration: GiniConfiguration, - documentMetadata: Document.Metadata?, - api: APIDomain, - trackingDelegate: GiniCaptureTrackingDelegate?, - lib : GiniBankAPI) { - - self.documentService = GiniNetworkingScreenAPICoordinator.documentService(with: lib, - documentMetadata: documentMetadata, - giniConfiguration: giniConfiguration, - for: api) - super.init(withDelegate: nil, - giniConfiguration: giniConfiguration) - - self.giniConfiguration.documentService = documentService - self.visionDelegate = self - self.resultsDelegate = resultsDelegate - self.trackingDelegate = trackingDelegate + public init(client: Client, + resultsDelegate: GiniCaptureResultsDelegate, + giniConfiguration: GiniConfiguration, + documentMetadata: Document.Metadata?, + api: APIDomain, + trackingDelegate: GiniCaptureTrackingDelegate?, + lib : GiniBankAPI) { + + self.documentService = DocumentService(lib: lib, metadata: documentMetadata) + super.init(withDelegate: nil, + giniConfiguration: giniConfiguration) + + self.giniConfiguration.documentService = documentService + self.visionDelegate = self + self.resultsDelegate = resultsDelegate + self.trackingDelegate = trackingDelegate } public init(resultsDelegate: GiniCaptureResultsDelegate, - giniConfiguration: GiniConfiguration, - documentMetadata: Document.Metadata?, - trackingDelegate: GiniCaptureTrackingDelegate?, - captureNetworkService: GiniCaptureNetworkService) { + giniConfiguration: GiniConfiguration, + documentMetadata: Document.Metadata?, + trackingDelegate: GiniCaptureTrackingDelegate?, + captureNetworkService: GiniCaptureNetworkService) { self.documentService = DocumentService(giniCaptureNetworkService: captureNetworkService, metadata: documentMetadata) @@ -94,16 +91,6 @@ import GiniBankAPILibrary lib: lib) } - private static func documentService(with lib: GiniBankAPI, - documentMetadata: Document.Metadata?, - giniConfiguration: GiniConfiguration, - for api: APIDomain) -> DocumentServiceProtocol { - switch api { - case .default, .gym, .custom: - return DocumentService(lib: lib, metadata: documentMetadata) - } - } - public func deliver(result: ExtractionResult, and document: Document? = nil, to analysisDelegate: AnalysisDelegate) { let hasExtactions = result.extractions.count > 0 @@ -118,8 +105,12 @@ import GiniBankAPILibrary }) - let result = AnalysisResult(extractions: extractions, lineItems: result.lineItems, images: images, document: document, candidates: result.candidates) - + let result = AnalysisResult(extractions: extractions, + lineItems: result.lineItems, + images: images, + document: document, + candidates: result.candidates) + self.resultsDelegate?.giniCaptureAnalysisDidFinishWith(result: result) } else { analysisDelegate.tryDisplayNoResultsScreen() @@ -159,10 +150,9 @@ extension GiniNetworkingScreenAPICoordinator { } } - fileprivate func uploadAndStartAnalysis( - document: GiniCaptureDocument, - networkDelegate: GiniCaptureNetworkDelegate, - uploadDidFail: @escaping () -> Void) { + fileprivate func uploadAndStartAnalysis(document: GiniCaptureDocument, + networkDelegate: GiniCaptureNetworkDelegate, + uploadDidFail: @escaping () -> Void) { self.upload(document: document, didComplete: { _ in self.startAnalysis(networkDelegate: networkDelegate) }, didFail: { _, error in diff --git a/Sources/GiniCaptureSDK/PrivacyInfo.xcprivacy b/Sources/GiniCaptureSDK/PrivacyInfo.xcprivacy index 4e6d952..d57d8ae 100644 --- a/Sources/GiniCaptureSDK/PrivacyInfo.xcprivacy +++ b/Sources/GiniCaptureSDK/PrivacyInfo.xcprivacy @@ -2,6 +2,12 @@ + NSPrivacyTracking + + NSPrivacyTrackingDomains + + https://api.eu.amplitude.com/ + NSPrivacyAccessedAPITypes diff --git a/Tests/GiniCaptureSDKTests/GiniScreenAPICoordinatorTests.swift b/Tests/GiniCaptureSDKTests/GiniScreenAPICoordinatorTests.swift index 29c3b8c..df3ed83 100644 --- a/Tests/GiniCaptureSDKTests/GiniScreenAPICoordinatorTests.swift +++ b/Tests/GiniCaptureSDKTests/GiniScreenAPICoordinatorTests.swift @@ -174,7 +174,7 @@ final class GiniScreenAPICoordinatorTests: XCTestCase { let rootViewController = coordinator.start(withDocuments: capturedImages) _ = rootViewController.view - let errorType = ErrorType(error: .server) + let errorType = ErrorType(error: .server(errorCode: 502)) coordinator.displayError(errorType: errorType, animated: false) let screenNavigator = rootViewController.children.first as? UINavigationController let errorScreen = screenNavigator?.viewControllers.last as? ErrorScreenViewController @@ -193,7 +193,7 @@ final class GiniScreenAPICoordinatorTests: XCTestCase { let rootViewController = coordinator.start(withDocuments: capturedImages) _ = rootViewController.view - let errorType = ErrorType(error: .maintenance) + let errorType = ErrorType(error: .maintenance(errorCode: 503)) coordinator.displayError(errorType: errorType, animated: false) let screenNavigator = rootViewController.children.first as? UINavigationController let errorScreen = screenNavigator?.viewControllers.last as? ErrorScreenViewController @@ -212,7 +212,7 @@ final class GiniScreenAPICoordinatorTests: XCTestCase { let rootViewController = coordinator.start(withDocuments: capturedImages) _ = rootViewController.view - let errorType = ErrorType(error: .outage) + let errorType = ErrorType(error: .outage(errorCode: 500)) coordinator.displayError(errorType: errorType, animated: false) let screenNavigator = rootViewController.children.first as? UINavigationController let errorScreen = screenNavigator?.viewControllers.last as? ErrorScreenViewController diff --git a/Tests/GiniCaptureSDKTests/OnboardingDataSource.swift b/Tests/GiniCaptureSDKTests/OnboardingDataSource.swift index 9a252ff..d4da1b9 100644 --- a/Tests/GiniCaptureSDKTests/OnboardingDataSource.swift +++ b/Tests/GiniCaptureSDKTests/OnboardingDataSource.swift @@ -12,7 +12,7 @@ final class OnboardingDataSourceTests: XCTestCase { private let giniConfiguration = GiniConfiguration.shared - private var pages = [OnboardingDataSource.OnboardingPageModel]() + private var pages = [OnboardingPageModel]() override func setUp() { super.setUp() @@ -22,34 +22,53 @@ final class OnboardingDataSourceTests: XCTestCase { } private func setDefaultOnboardingPages() { - pages = [(page: OnboardingPage(imageName: DefaultOnboardingPage.flatPaper.imageName, - title: DefaultOnboardingPage.flatPaper.title, - description: DefaultOnboardingPage.flatPaper.description), - illustrationAdapter: giniConfiguration.onboardingAlignCornersIllustrationAdapter), - (page: OnboardingPage(imageName: DefaultOnboardingPage.lighting.imageName, - title: DefaultOnboardingPage.lighting.title, - description: DefaultOnboardingPage.lighting.description), - illustrationAdapter: giniConfiguration.onboardingLightingIllustrationAdapter)] - + + + let flatPaperPage = OnboardingPage(imageName: DefaultOnboardingPage.flatPaper.imageName, + title: DefaultOnboardingPage.flatPaper.title, + description: DefaultOnboardingPage.flatPaper.description) + let flatPaperPageModel = OnboardingPageModel(page: flatPaperPage, + illustrationAdapter: giniConfiguration.onboardingAlignCornersIllustrationAdapter, + analyticsScreen: GiniAnalyticsScreen.onboardingFlatPaper.rawValue) + + let goodLightingPage = OnboardingPage(imageName: DefaultOnboardingPage.lighting.imageName, + title: DefaultOnboardingPage.lighting.title, + description: DefaultOnboardingPage.lighting.description) + let goodLightingPageModel = OnboardingPageModel(page: goodLightingPage, + illustrationAdapter: giniConfiguration.onboardingLightingIllustrationAdapter, + analyticsScreen: GiniAnalyticsScreen.onboardingLighting.rawValue) + + pages = [flatPaperPageModel, goodLightingPageModel] + if giniConfiguration.multipageEnabled { - pages.append((page: OnboardingPage(imageName: DefaultOnboardingPage.multipage.imageName, - title: DefaultOnboardingPage.multipage.title, - description: DefaultOnboardingPage.multipage.description), - illustrationAdapter: giniConfiguration.onboardingMultiPageIllustrationAdapter)) + let multiPage = OnboardingPage(imageName: DefaultOnboardingPage.multipage.imageName, + title: DefaultOnboardingPage.multipage.title, + description: DefaultOnboardingPage.multipage.description) + let multiPageModel = OnboardingPageModel(page: multiPage, + illustrationAdapter: giniConfiguration.onboardingMultiPageIllustrationAdapter, + analyticsScreen: GiniAnalyticsScreen.onboardingMultipage.rawValue) + pages.append(multiPageModel) } - + if giniConfiguration.qrCodeScanningEnabled { - pages.append((page: OnboardingPage(imageName: DefaultOnboardingPage.qrcode.imageName, - title: DefaultOnboardingPage.qrcode.title, - description: DefaultOnboardingPage.qrcode.description), - illustrationAdapter: giniConfiguration.onboardingQRCodeIllustrationAdapter)) + let qrCodePage = OnboardingPage(imageName: DefaultOnboardingPage.qrcode.imageName, + title: DefaultOnboardingPage.qrcode.title, + description: DefaultOnboardingPage.qrcode.description) + let qrCodePageModel = OnboardingPageModel(page: qrCodePage, + illustrationAdapter: giniConfiguration.onboardingQRCodeIllustrationAdapter, + analyticsScreen: GiniAnalyticsScreen.onboardingQRcode.rawValue) + pages.append(qrCodePageModel) } + } private func setCustomOnboardingPages() { guard let customPages = giniConfiguration.customOnboardingPages else { return } - pages = customPages.map { page in - return (page: page, illustrationAdapter: nil) + pages = customPages.enumerated().map { index, page in + let analyticsScreen = "\(GiniAnalyticsScreen.onboardingCustom.rawValue)\(index + 1)" + return OnboardingPageModel(page: page, + analyticsScreen: analyticsScreen, + isCustom: true) } }