diff --git a/Cryptomator/MainCoordinator.swift b/Cryptomator/MainCoordinator.swift index ee2bcace6..f7f5bf63a 100644 --- a/Cryptomator/MainCoordinator.swift +++ b/Cryptomator/MainCoordinator.swift @@ -8,6 +8,7 @@ import CryptomatorCommonCore import Promises +import StoreKit import UIKit class MainCoordinator: NSObject, Coordinator, UINavigationControllerDelegate { @@ -77,6 +78,15 @@ class MainCoordinator: NSObject, Coordinator, UINavigationControllerDelegate { rootViewController.showDetailViewController(detailNavigationController, sender: nil) } + // Temporarily added for December 2024 Sale + func showPurchase() { + let modalNavigationController = BaseNavigationController() + let child = PurchaseCoordinator(navigationController: modalNavigationController) + childCoordinators.append(child) + navigationController.topViewController?.present(modalNavigationController, animated: true) + child.start() + } + // MARK: - UINavigationControllerDelegate func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) { @@ -114,6 +124,8 @@ extension MainCoordinator: StoreObserverDelegate { switch transaction { case .fullVersion, .yearlySubscription: showFullVersionAlert() + // Temporarily added for December 2024 Sale + NotificationCenter.default.post(name: .purchasedFullVersionNotification, object: nil) case let .freeTrial(expiresOn): showTrialAlert(expirationDate: expiresOn) case .unknown: @@ -126,7 +138,11 @@ extension MainCoordinator: StoreObserverDelegate { guard let navigationController = self?.navigationController else { return } - _ = PurchaseAlert.showForFullVersion(title: LocalizedString.getValue("purchase.unlockedFullVersion.title"), on: navigationController) + PurchaseAlert.showForFullVersion(title: LocalizedString.getValue("purchase.unlockedFullVersion.title"), on: navigationController).then { + if let windowScene = navigationController.view.window?.windowScene { + SKStoreReviewController.requestReview(in: windowScene) + } + } } } diff --git a/Cryptomator/Purchase/Cells/PurchaseCell.swift b/Cryptomator/Purchase/Cells/PurchaseCell.swift index 0e1548ce3..44b85bbb7 100644 --- a/Cryptomator/Purchase/Cells/PurchaseCell.swift +++ b/Cryptomator/Purchase/Cells/PurchaseCell.swift @@ -12,6 +12,7 @@ import UIKit struct PurchaseCellViewModel: Hashable { let productName: String + let productDetail: String? let price: String let purchaseDetail: String? let purchaseButtonViewModel = PurchaseButtonViewModel() @@ -36,6 +37,7 @@ class PurchaseCell: IAPCell { func configure(with viewModel: PurchaseCellViewModel) { productTitleLabel.text = viewModel.productName + productDetailLabel.text = viewModel.productDetail accessory.button.setTitle(viewModel.price, for: .normal) accessory.detailLabel.text = viewModel.purchaseDetail accessory.configure(with: viewModel.purchaseButtonViewModel) diff --git a/Cryptomator/Purchase/PurchaseCoordinator.swift b/Cryptomator/Purchase/PurchaseCoordinator.swift index 306abdf5d..7982d311b 100644 --- a/Cryptomator/Purchase/PurchaseCoordinator.swift +++ b/Cryptomator/Purchase/PurchaseCoordinator.swift @@ -49,8 +49,13 @@ class PurchaseCoordinator: Coordinator { func fullVersionPurchased() { PurchaseAlert.showForFullVersion(title: LocalizedString.getValue("purchase.unlockedFullVersion.title"), on: navigationController).then { + if let windowScene = self.navigationController.view.window?.windowScene { + SKStoreReviewController.requestReview(in: windowScene) + } self.unlockedPro() } + // Temporarily added for December 2024 Sale + NotificationCenter.default.post(name: .purchasedFullVersionNotification, object: nil) } func handleRestoreResult(_ result: RestoreTransactionsResult) { diff --git a/Cryptomator/Purchase/PurchaseViewModel.swift b/Cryptomator/Purchase/PurchaseViewModel.swift index 017200381..a84692187 100644 --- a/Cryptomator/Purchase/PurchaseViewModel.swift +++ b/Cryptomator/Purchase/PurchaseViewModel.swift @@ -33,6 +33,23 @@ class PurchaseViewModel: BaseIAPViewModel, ProductFetching { return LocalizedString.getValue("purchase.title") } + // Temporarily added for December 2024 Sale + override var infoText: NSAttributedString? { + let currentYear = Calendar.current.component(.year, from: Date()) + let currentMonth = Calendar.current.component(.month, from: Date()) + if currentYear == 2024 && currentMonth == 12 { + return NSAttributedString( + string: "*Note: The discount amount may vary by region.", + attributes: [ + .font: UIFont.preferredFont(forTextStyle: .footnote), + .foregroundColor: UIColor.secondaryLabel + ] + ) + } else { + return nil + } + } + private let cryptomatorSettings: CryptomatorSettings init(storeManager: IAPStore = StoreManager.shared, iapManager: IAPManager = StoreObserver.shared, cryptomatorSettings: CryptomatorSettings = CryptomatorUserDefaults.shared, minimumDisplayTime: TimeInterval = 1.0) { @@ -56,6 +73,7 @@ class PurchaseViewModel: BaseIAPViewModel, ProductFetching { cells.append(.trialCell(TrialCellViewModel(expirationDate: trialExpirationDate))) } else { cells.append(.purchaseCell(PurchaseCellViewModel(productName: LocalizedString.getValue("purchase.product.trial"), + productDetail: nil, price: LocalizedString.getValue("purchase.product.pricing.free"), purchaseDetail: LocalizedString.getValue("purchase.product.trial.duration"), productIdentifier: .thirtyDayTrial))) @@ -65,6 +83,7 @@ class PurchaseViewModel: BaseIAPViewModel, ProductFetching { private func addSubscriptionItem() { if let product = products[.yearlySubscription], let localizedPrice = product.localizedPrice { let viewModel = PurchaseCellViewModel(productName: LocalizedString.getValue("purchase.product.yearlySubscription"), + productDetail: nil, price: localizedPrice, purchaseDetail: LocalizedString.getValue("purchase.product.yearlySubscription.duration"), productIdentifier: .yearlySubscription) @@ -75,6 +94,7 @@ class PurchaseViewModel: BaseIAPViewModel, ProductFetching { private func addLifetimeLicenseItem() { if let product = products[.fullVersion], let localizedPrice = product.localizedPrice { let viewModel = PurchaseCellViewModel(productName: LocalizedString.getValue("purchase.product.lifetimeLicense"), + productDetail: "🎁 33%* off in December", price: localizedPrice, purchaseDetail: LocalizedString.getValue("purchase.product.lifetimeLicense.duration"), productIdentifier: .fullVersion) diff --git a/Cryptomator/Purchase/UpgradeViewModel.swift b/Cryptomator/Purchase/UpgradeViewModel.swift index f31614017..9bb43dc16 100644 --- a/Cryptomator/Purchase/UpgradeViewModel.swift +++ b/Cryptomator/Purchase/UpgradeViewModel.swift @@ -34,6 +34,7 @@ class UpgradeViewModel: BaseIAPViewModel, ProductFetching { func addFreeUpgradeItem() { guard products[.freeUpgrade] != nil else { return } let viewModel = PurchaseCellViewModel(productName: LocalizedString.getValue("purchase.product.freeUpgrade"), + productDetail: nil, price: LocalizedString.getValue("purchase.product.pricing.free"), purchaseDetail: nil, productIdentifier: .freeUpgrade) @@ -43,6 +44,7 @@ class UpgradeViewModel: BaseIAPViewModel, ProductFetching { func addPaidUpgradeItem() { if let product = products[.paidUpgrade], let localizedPrice = product.localizedPrice { let viewModel = PurchaseCellViewModel(productName: LocalizedString.getValue("purchase.product.donateAndUpgrade"), + productDetail: nil, price: localizedPrice, purchaseDetail: LocalizedString.getValue("purchase.product.lifetimeLicense.duration"), productIdentifier: .paidUpgrade) diff --git a/Cryptomator/VaultList/VaultListViewController.swift b/Cryptomator/VaultList/VaultListViewController.swift index 010251d14..e3fa0c0a4 100644 --- a/Cryptomator/VaultList/VaultListViewController.swift +++ b/Cryptomator/VaultList/VaultListViewController.swift @@ -17,8 +17,14 @@ class VaultListViewController: ListViewController { weak var coordinator: MainCoordinator? private let viewModel: VaultListViewModelProtocol - private var observer: NSObjectProtocol? + private var willEnterForegroundObserver: NSObjectProtocol? @Dependency(\.fullVersionChecker) private var fullVersionChecker + @Dependency(\.cryptomatorSettings) private var cryptomatorSettings + + #if !ALWAYS_PREMIUM + private var bannerView: UIView? + private var fullVersionPurchasedObserver: NSObjectProtocol? + #endif init(with viewModel: VaultListViewModelProtocol) { self.viewModel = viewModel @@ -44,11 +50,18 @@ class VaultListViewController: ListViewController { let addNewVaulButton = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addNewVault)) navigationItem.rightBarButtonItem = addNewVaulButton - observer = NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: .main) { [weak self] _ in + willEnterForegroundObserver = NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: .main) { [weak self] _ in self?.viewModel.refreshVaultLockStates().catch { error in DDLogError("Refresh vault lock states failed with error: \(error)") } } + + #if !ALWAYS_PREMIUM + fullVersionPurchasedObserver = NotificationCenter.default.addObserver(forName: .purchasedFullVersionNotification, object: nil, queue: .main) { [weak self] _ in + self?.dismissBanner() + } + checkAndShowBanner() + #endif } override func viewWillAppear(_ animated: Bool) { @@ -108,4 +121,94 @@ class VaultListViewController: ListViewController { coordinator?.showVaultDetail(for: vaultCellViewModel.vault) } } + + // MARK: - Discount Banner + + #if !ALWAYS_PREMIUM + private func checkAndShowBanner() { + let currentYear = Calendar.current.component(.year, from: Date()) + let currentMonth = Calendar.current.component(.month, from: Date()) + if currentYear == 2024, currentMonth == 12, !(cryptomatorSettings.fullVersionUnlocked || cryptomatorSettings.hasRunningSubscription), !cryptomatorSettings.december2024BannerDismissed { + showBanner() + } + } + + private func showBanner() { + let banner = UIView() + banner.backgroundColor = UIColor.cryptomatorPrimary + banner.translatesAutoresizingMaskIntoConstraints = false + banner.layer.cornerRadius = 12 + banner.layer.masksToBounds = true + + let emojiLabel = UILabel() + emojiLabel.text = "🎁" + emojiLabel.translatesAutoresizingMaskIntoConstraints = false + emojiLabel.setContentHuggingPriority(.required, for: .horizontal) + emojiLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + + let textLabel = UILabel() + textLabel.text = "Lifetime License is 33%* off in December!" + textLabel.textColor = .white + textLabel.font = UIFont.preferredFont(forTextStyle: .footnote) + textLabel.adjustsFontSizeToFitWidth = true + textLabel.minimumScaleFactor = 0.5 + textLabel.numberOfLines = 2 + textLabel.translatesAutoresizingMaskIntoConstraints = false + + let dismissButton = UIButton(type: .close) + dismissButton.addTarget(self, action: #selector(dismissBanner), for: .touchUpInside) + dismissButton.translatesAutoresizingMaskIntoConstraints = false + dismissButton.setContentHuggingPriority(.required, for: .horizontal) + dismissButton.setContentCompressionResistancePriority(.required, for: .horizontal) + + banner.addSubview(emojiLabel) + banner.addSubview(textLabel) + banner.addSubview(dismissButton) + + NSLayoutConstraint.activate([ + emojiLabel.leadingAnchor.constraint(equalTo: banner.leadingAnchor, constant: 16), + emojiLabel.centerYAnchor.constraint(equalTo: banner.centerYAnchor), + + textLabel.leadingAnchor.constraint(equalTo: emojiLabel.trailingAnchor, constant: 8), + textLabel.centerYAnchor.constraint(equalTo: banner.centerYAnchor), + + dismissButton.leadingAnchor.constraint(equalTo: textLabel.trailingAnchor, constant: 8), + dismissButton.trailingAnchor.constraint(equalTo: banner.trailingAnchor, constant: -16), + dismissButton.centerYAnchor.constraint(equalTo: banner.centerYAnchor) + ]) + + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(bannerTapped)) + banner.addGestureRecognizer(tapGestureRecognizer) + + view.addSubview(banner) + + NSLayoutConstraint.activate([ + banner.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + banner.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + banner.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -16), + banner.centerXAnchor.constraint(equalTo: view.centerXAnchor), + banner.heightAnchor.constraint(equalToConstant: 50) + ]) + + bannerView = banner + } + + @objc private func dismissBanner() { + UIView.animate(withDuration: 0.3, animations: { + self.bannerView?.alpha = 0 + }, completion: { _ in + self.bannerView?.removeFromSuperview() + self.bannerView = nil + }) + CryptomatorUserDefaults.shared.december2024BannerDismissed = true + } + + @objc private func bannerTapped() { + coordinator?.showPurchase() + } + #endif +} + +extension Notification.Name { + static let purchasedFullVersionNotification = Notification.Name("PurchasedFullVersionNotification") } diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/CryptomatorUserDefaults.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/CryptomatorUserDefaults.swift index cb8b81081..27729b740 100644 --- a/CryptomatorCommon/Sources/CryptomatorCommonCore/CryptomatorUserDefaults.swift +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/CryptomatorUserDefaults.swift @@ -15,6 +15,9 @@ public protocol CryptomatorSettings { var trialExpirationDate: Date? { get set } var fullVersionUnlocked: Bool { get set } var hasRunningSubscription: Bool { get set } + #if !ALWAYS_PREMIUM + var december2024BannerDismissed: Bool { get set } + #endif } private enum CryptomatorSettingsKey: DependencyKey { @@ -108,4 +111,11 @@ extension CryptomatorUserDefaults: CryptomatorSettings { get { read() ?? false } set { write(value: newValue) } } + + #if !ALWAYS_PREMIUM + public var december2024BannerDismissed: Bool { + get { read() ?? false } + set { write(value: newValue) } + } + #endif } diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/Mocks/CryptomatorSettingsMock.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Mocks/CryptomatorSettingsMock.swift index 4d0d9d049..46b63600b 100644 --- a/CryptomatorCommon/Sources/CryptomatorCommonCore/Mocks/CryptomatorSettingsMock.swift +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Mocks/CryptomatorSettingsMock.swift @@ -14,5 +14,8 @@ class CryptomatorSettingsMock: CryptomatorSettings { var debugModeEnabled: Bool = false var fullVersionUnlocked: Bool = false var hasRunningSubscription: Bool = false + #if !ALWAYS_PREMIUM + var december2024BannerDismissed: Bool = false + #endif } #endif diff --git a/CryptomatorTests/Purchase/PurchaseViewModelTests.swift b/CryptomatorTests/Purchase/PurchaseViewModelTests.swift index 7177aa911..d3e07bc8e 100644 --- a/CryptomatorTests/Purchase/PurchaseViewModelTests.swift +++ b/CryptomatorTests/Purchase/PurchaseViewModelTests.swift @@ -153,6 +153,7 @@ class PurchaseViewModelTests: IAPViewModelTestCase { private var purchaseTrialCell: Item { return .purchaseCell(.init(productName: LocalizedString.getValue("purchase.product.trial"), + productDetail: nil, price: LocalizedString.getValue("purchase.product.pricing.free"), purchaseDetail: LocalizedString.getValue("purchase.product.trial.duration"), productIdentifier: .thirtyDayTrial)) @@ -160,6 +161,7 @@ class PurchaseViewModelTests: IAPViewModelTestCase { private var yearlySubscriptionCell: Item { return .purchaseCell(.init(productName: LocalizedString.getValue("purchase.product.yearlySubscription"), + productDetail: nil, price: "$5.99", purchaseDetail: LocalizedString.getValue("purchase.product.yearlySubscription.duration"), productIdentifier: .yearlySubscription)) @@ -167,6 +169,7 @@ class PurchaseViewModelTests: IAPViewModelTestCase { private var lifetimeLicenseCell: Item { return .purchaseCell(.init(productName: LocalizedString.getValue("purchase.product.lifetimeLicense"), + productDetail: "🎁 33%* off in December", price: "$11.99", purchaseDetail: LocalizedString.getValue("purchase.product.lifetimeLicense.duration"), productIdentifier: .fullVersion)) diff --git a/CryptomatorTests/Purchase/UpgradeViewModelTests.swift b/CryptomatorTests/Purchase/UpgradeViewModelTests.swift index f791d737a..8f6323b18 100644 --- a/CryptomatorTests/Purchase/UpgradeViewModelTests.swift +++ b/CryptomatorTests/Purchase/UpgradeViewModelTests.swift @@ -77,6 +77,7 @@ class UpgradeViewModelTests: IAPViewModelTestCase { private var freeUpgradeCell: Item { return .purchaseCell(.init(productName: LocalizedString.getValue("purchase.product.freeUpgrade"), + productDetail: nil, price: LocalizedString.getValue("purchase.product.pricing.free"), purchaseDetail: nil, productIdentifier: .freeUpgrade)) @@ -84,6 +85,7 @@ class UpgradeViewModelTests: IAPViewModelTestCase { private var paidUpgradeCell: Item { return .purchaseCell(.init(productName: LocalizedString.getValue("purchase.product.donateAndUpgrade"), + productDetail: nil, price: "$1.99", purchaseDetail: LocalizedString.getValue("purchase.product.lifetimeLicense.duration"), productIdentifier: .paidUpgrade))