From 23f26abedc60ec4f8d18e9f0145fa72ad47a31c9 Mon Sep 17 00:00:00 2001 From: Michal Smaga Date: Sun, 30 Jun 2024 21:53:58 +0200 Subject: [PATCH] Improvements to subscription settings (#2959) Task/Issue URL: https://app.asana.com/0/1203936086921904/1207147238749956/f Description: Make the entry point for managing subscription functionality more obvious so that users have a sense of control over their subscriptions, allowing them to make changes easily without the need for customer support. --- Core/PixelEvent.swift | 2 - DuckDuckGo.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- DuckDuckGo/AppDelegate.swift | 16 +- DuckDuckGo/SettingsRootView.swift | 3 - DuckDuckGo/SettingsViewModel.swift | 2 - ...scriptionPagesUseSubscriptionFeature.swift | 9 +- .../SubscriptionContainerViewModel.swift | 4 +- .../SubscriptionEmailViewModel.swift | 11 +- .../SubscriptionRestoreViewModel.swift | 42 ----- .../SubscriptionSettingsViewModel.swift | 162 ++++++++++++------ .../Views/SubscriptionContainerView.swift | 4 +- .../SubscriptionContainerViewFactory.swift | 31 +++- .../Views/SubscriptionRestoreView.swift | 92 +++------- .../Views/SubscriptionSettingsView.swift | 123 +++++++++---- DuckDuckGo/UserText.swift | 51 +++--- DuckDuckGo/en.lproj/Localizable.strings | 85 ++++----- .../SubscriptionContainerViewModelTests.swift | 9 +- .../SubscriptionFlowViewModelTests.swift | 12 +- 19 files changed, 349 insertions(+), 315 deletions(-) diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index 107c561f6f..60da1bb74a 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -647,7 +647,6 @@ extension Pixel { case privacyProRestoreAfterPurchaseAttempt case privacyProSubscriptionActivated case privacyProWelcomeAddDevice - case privacyProSettingsAddDevice case privacyProAddDeviceEnterEmail case privacyProWelcomeVPN case privacyProWelcomePersonalInformationRemoval @@ -1348,7 +1347,6 @@ extension Pixel.Event { case .privacyProRestoreAfterPurchaseAttempt: return "m_privacy-pro_app_subscription-restore-after-purchase-attempt_success" case .privacyProSubscriptionActivated: return "m_privacy-pro_app_subscription_activated_u" case .privacyProWelcomeAddDevice: return "m_privacy-pro_welcome_add-device_click_u" - case .privacyProSettingsAddDevice: return "m_privacy-pro_settings_add-device_click" case .privacyProAddDeviceEnterEmail: return "m_privacy-pro_add-device_enter-email_click" case .privacyProWelcomeVPN: return "m_privacy-pro_welcome_vpn_click_u" case .privacyProWelcomePersonalInformationRemoval: return "m_privacy-pro_welcome_personal-information-removal_click_u" diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 97502669c2..5634ff679d 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -9858,7 +9858,7 @@ repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 163.0.0; + version = 163.0.1; }; }; 9F8FE9472BAE50E50071E372 /* XCRemoteSwiftPackageReference "lottie-spm" */ = { diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0c6f75634d..10fe30f9f2 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/DuckDuckGo/BrowserServicesKit", "state" : { - "revision" : "a51fed4db0c332cd4f02eafca2d9c7a178c0829a", - "version" : "163.0.0" + "revision" : "39e10c8eeddeb03750350597bd55fd8c43b5fd83", + "version" : "163.0.1" } }, { diff --git a/DuckDuckGo/AppDelegate.swift b/DuckDuckGo/AppDelegate.swift index 81b50dd257..6baea32fd5 100644 --- a/DuckDuckGo/AppDelegate.swift +++ b/DuckDuckGo/AppDelegate.swift @@ -344,6 +344,8 @@ import WebKit AppDependencyProvider.shared.userBehaviorMonitor.handleAction(.reopenApp) + AppDependencyProvider.shared.subscriptionManager.loadInitialData() + setUpAutofillPixelReporter() return true @@ -551,18 +553,10 @@ import WebKit } func updateSubscriptionStatus() { - Task { - guard let token = accountManager.accessToken else { return } - var subscriptionService: SubscriptionEndpointService { - AppDependencyProvider.shared.subscriptionManager.subscriptionEndpointService - } - if case .success(let subscription) = await subscriptionService.getSubscription(accessToken: token, - cachePolicy: .reloadIgnoringLocalCacheData) { - if subscription.isActive { - DailyPixel.fire(pixel: .privacyProSubscriptionActive) - } + AppDependencyProvider.shared.subscriptionManager.updateSubscriptionStatus { isActive in + if isActive { + DailyPixel.fire(pixel: .privacyProSubscriptionActive) } - await accountManager.fetchEntitlements(cachePolicy: .reloadIgnoringLocalCacheData) } } diff --git a/DuckDuckGo/SettingsRootView.swift b/DuckDuckGo/SettingsRootView.swift index cc3e346a67..32d4599313 100644 --- a/DuckDuckGo/SettingsRootView.swift +++ b/DuckDuckGo/SettingsRootView.swift @@ -117,9 +117,6 @@ struct SettingsRootView: View { SubscriptionContainerViewFactory.makeSubscribeFlow(origin: origin, navigationCoordinator: subscriptionNavigationCoordinator, subscriptionManager: AppDependencyProvider.shared.subscriptionManager) - case .subscriptionRestoreFlow: - SubscriptionContainerViewFactory.makeRestoreFlow(navigationCoordinator: subscriptionNavigationCoordinator, - subscriptionManager: AppDependencyProvider.shared.subscriptionManager) default: EmptyView() } diff --git a/DuckDuckGo/SettingsViewModel.swift b/DuckDuckGo/SettingsViewModel.swift index 228140d87b..be27a4981a 100644 --- a/DuckDuckGo/SettingsViewModel.swift +++ b/DuckDuckGo/SettingsViewModel.swift @@ -609,7 +609,6 @@ extension SettingsViewModel { case dbp case itr case subscriptionFlow(origin: String? = nil) - case subscriptionRestoreFlow // Add other cases as needed var id: String { @@ -618,7 +617,6 @@ extension SettingsViewModel { case .dbp: return "dbp" case .itr: return "itr" case .subscriptionFlow: return "subscriptionFlow" - case .subscriptionRestoreFlow: return "subscriptionRestoreFlow" // Ensure all cases are covered } } diff --git a/DuckDuckGo/Subscription/UserScripts/SubscriptionPagesUseSubscriptionFeature.swift b/DuckDuckGo/Subscription/UserScripts/SubscriptionPagesUseSubscriptionFeature.swift index 699720a670..e257a026be 100644 --- a/DuckDuckGo/Subscription/UserScripts/SubscriptionPagesUseSubscriptionFeature.swift +++ b/DuckDuckGo/Subscription/UserScripts/SubscriptionPagesUseSubscriptionFeature.swift @@ -95,15 +95,19 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec private let subscriptionManager: SubscriptionManager private var accountManager: AccountManager { subscriptionManager.accountManager } private let appStorePurchaseFlow: AppStorePurchaseFlow + private let appStoreRestoreFlow: AppStoreRestoreFlow + private let appStoreAccountManagementFlow: AppStoreAccountManagementFlow init(subscriptionManager: SubscriptionManager, subscriptionAttributionOrigin: String?, appStorePurchaseFlow: AppStorePurchaseFlow, - appStoreRestoreFlow: AppStoreRestoreFlow) { + appStoreRestoreFlow: AppStoreRestoreFlow, + appStoreAccountManagementFlow: AppStoreAccountManagementFlow) { self.subscriptionManager = subscriptionManager self.appStorePurchaseFlow = appStorePurchaseFlow self.appStoreRestoreFlow = appStoreRestoreFlow + self.appStoreAccountManagementFlow = appStoreAccountManagementFlow self.subscriptionAttributionOrigin = subscriptionAttributionOrigin } @@ -187,7 +191,9 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec // MARK: Broker Methods (Called from WebView via UserScripts) func getSubscription(params: Any, original: WKScriptMessage) async -> Encodable? { + await appStoreAccountManagementFlow.refreshAuthTokenIfNeeded() let authToken = accountManager.authToken ?? Constants.empty + return [Constants.token: authToken] } @@ -399,7 +405,6 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec // MARK: Native methods - Called from ViewModels func restoreAccountFromAppStorePurchase() async throws { setTransactionStatus(.restoring) -// let appStoreRestoreFlow = AppStoreRestoreFlow(subscriptionManager: subscriptionManager) let result = await appStoreRestoreFlow.restoreAccountFromPastPurchase() switch result { case .success: diff --git a/DuckDuckGo/Subscription/ViewModel/SubscriptionContainerViewModel.swift b/DuckDuckGo/Subscription/ViewModel/SubscriptionContainerViewModel.swift index 0bfbc9410a..17274f5c48 100644 --- a/DuckDuckGo/Subscription/ViewModel/SubscriptionContainerViewModel.swift +++ b/DuckDuckGo/Subscription/ViewModel/SubscriptionContainerViewModel.swift @@ -38,15 +38,13 @@ final class SubscriptionContainerViewModel: ObservableObject { self.userScript = userScript subFeature.cleanup() self.subFeature = subFeature - let appStoreAccountManagementFlow = DefaultAppStoreAccountManagementFlow(subscriptionManager: subscriptionManager) self.flow = SubscriptionFlowViewModel(origin: origin, userScript: userScript, subFeature: subFeature, subscriptionManager: subscriptionManager) self.restore = SubscriptionRestoreViewModel(userScript: userScript, subFeature: subFeature, - subscriptionManager: subscriptionManager, - appStoreAccountManagementFlow: appStoreAccountManagementFlow) + subscriptionManager: subscriptionManager) self.email = SubscriptionEmailViewModel(userScript: userScript, subFeature: subFeature, subscriptionManager: subscriptionManager) diff --git a/DuckDuckGo/Subscription/ViewModel/SubscriptionEmailViewModel.swift b/DuckDuckGo/Subscription/ViewModel/SubscriptionEmailViewModel.swift index 8fc0ea302e..b185c9d512 100644 --- a/DuckDuckGo/Subscription/ViewModel/SubscriptionEmailViewModel.swift +++ b/DuckDuckGo/Subscription/ViewModel/SubscriptionEmailViewModel.swift @@ -79,6 +79,11 @@ final class SubscriptionEmailViewModel: ObservableObject { webViewModel.url?.forComparison() == subscriptionPurchaseURL.forComparison() } + private var isVerifySubscriptionPage: Bool { + let confirmSubscriptionURL = subscriptionManager.url(for: .baseURL).appendingPathComponent("confirm") + return webViewModel.url?.forComparison() == confirmSubscriptionURL.forComparison() + } + init(userScript: SubscriptionPagesUserScript, subFeature: SubscriptionPagesUseSubscriptionFeature, subscriptionManager: SubscriptionManager) { @@ -132,7 +137,7 @@ final class SubscriptionEmailViewModel: ObservableObject { let addEmailToSubscriptionURL = subscriptionManager.url(for: .addEmail) let manageSubscriptionEmailURL = subscriptionManager.url(for: .manageEmail) emailURL = accountManager.email == nil ? addEmailToSubscriptionURL : manageSubscriptionEmailURL - state.viewTitle = accountManager.email == nil ? UserText.subscriptionRestoreAddEmailTitle : UserText.subscriptionManageEmailTitle + state.viewTitle = accountManager.email == nil ? UserText.subscriptionRestoreAddEmailTitle : UserText.subscriptionEditEmailTitle // Also we assume subscription requires managing, and not activation state.managingSubscriptionEmail = true @@ -224,15 +229,13 @@ final class SubscriptionEmailViewModel: ObservableObject { private func updateBackButton(canNavigateBack: Bool) { // If the view is not Activation Success, or Welcome page, allow WebView Back Navigation - if !isWelcomePageOrSuccessPage { + if !isWelcomePageOrSuccessPage && !isVerifySubscriptionPage { self.state.canNavigateBack = canNavigateBack self.state.backButtonTitle = UserText.backButtonTitle } else { self.state.canNavigateBack = false self.state.backButtonTitle = UserText.settingsTitle } - - } // MARK: - diff --git a/DuckDuckGo/Subscription/ViewModel/SubscriptionRestoreViewModel.swift b/DuckDuckGo/Subscription/ViewModel/SubscriptionRestoreViewModel.swift index f6fb943634..0c178c8257 100644 --- a/DuckDuckGo/Subscription/ViewModel/SubscriptionRestoreViewModel.swift +++ b/DuckDuckGo/Subscription/ViewModel/SubscriptionRestoreViewModel.swift @@ -30,7 +30,6 @@ final class SubscriptionRestoreViewModel: ObservableObject { let subFeature: SubscriptionPagesUseSubscriptionFeature let subscriptionManager: SubscriptionManager var accountManager: AccountManager { subscriptionManager.accountManager } - let appStoreAccountManagementFlow: AppStoreAccountManagementFlow private var cancellables = Set() @@ -39,7 +38,6 @@ final class SubscriptionRestoreViewModel: ObservableObject { } struct State { - var isAddingDevice: Bool = false var transactionStatus: SubscriptionTransactionStatus = .idle var activationResult: SubscriptionActivationResult = .unknown var subscriptionEmail: String? @@ -60,68 +58,28 @@ final class SubscriptionRestoreViewModel: ObservableObject { init(userScript: SubscriptionPagesUserScript, subFeature: SubscriptionPagesUseSubscriptionFeature, subscriptionManager: SubscriptionManager, - appStoreAccountManagementFlow: AppStoreAccountManagementFlow, isAddingDevice: Bool = false) { self.userScript = userScript self.subFeature = subFeature self.subscriptionManager = subscriptionManager - self.appStoreAccountManagementFlow = appStoreAccountManagementFlow - self.state.isAddingDevice = false } func onAppear() { DispatchQueue.main.async { self.resetState() } - Task { await setupContent() } } func onFirstAppear() async { - Pixel.fire(pixel: .privacyProSettingsAddDevice) await setupTransactionObserver() - await refreshToken() } private func cleanUp() { cancellables.removeAll() } - private func refreshToken() async { - if state.isAddingDevice { - await appStoreAccountManagementFlow.refreshAuthTokenIfNeeded() - } - } - - private func setupContent() async { - if state.isAddingDevice { - DispatchQueue.main.async { - self.state.isLoading = true - } - - guard let token = accountManager.accessToken else { return } - switch await accountManager.fetchAccountDetails(with: token) { - case .success(let details): - DispatchQueue.main.async { - self.state.subscriptionEmail = details.email - self.state.isLoading = false - self.state.viewTitle = UserText.subscriptionAddDeviceTitle - } - default: - DispatchQueue.main.async { - self.state.viewTitle = UserText.subscriptionActivate - self.state.isLoading = false - } - } - } - } - @MainActor private func resetState() { - state.isAddingDevice = false - if accountManager.isUserAuthenticated { - state.isAddingDevice = true - } - state.isShowingActivationFlow = false state.shouldShowPlans = false state.isShowingWelcomePage = false diff --git a/DuckDuckGo/Subscription/ViewModel/SubscriptionSettingsViewModel.swift b/DuckDuckGo/Subscription/ViewModel/SubscriptionSettingsViewModel.swift index bb67efca93..61afacec98 100644 --- a/DuckDuckGo/Subscription/ViewModel/SubscriptionSettingsViewModel.swift +++ b/DuckDuckGo/Subscription/ViewModel/SubscriptionSettingsViewModel.swift @@ -27,21 +27,22 @@ import Core final class SubscriptionSettingsViewModel: ObservableObject { private let subscriptionManager: SubscriptionManager - private var subscriptionUpdateTimer: Timer? private var signOutObserver: Any? private var externalAllowedDomains = ["stripe.com"] struct State { var subscriptionDetails: String = "" - var subscriptionType: String = "" + var subscriptionEmail: String? var isShowingRemovalNotice: Bool = false var shouldDismissView: Bool = false var isShowingGoogleView: Bool = false var isShowingFAQView: Bool = false + var isShowingLearnMoreView: Bool = false var subscriptionInfo: Subscription? var isLoadingSubscriptionInfo: Bool = false - + var isLoadingEmailInfo: Bool = false + // Used to display stripe WebUI var stripeViewModel: SubscriptionExternalLinkViewModel? var isShowingStripeView: Bool = false @@ -51,9 +52,11 @@ final class SubscriptionSettingsViewModel: ObservableObject { // Used to display the FAQ WebUI var faqViewModel: SubscriptionExternalLinkViewModel + var learnMoreViewModel: SubscriptionExternalLinkViewModel - init(faqURL: URL) { + init(faqURL: URL, learnMoreURL: URL) { self.faqViewModel = SubscriptionExternalLinkViewModel(url: faqURL) + self.learnMoreViewModel = SubscriptionExternalLinkViewModel(url: learnMoreURL) } } @@ -67,56 +70,111 @@ final class SubscriptionSettingsViewModel: ObservableObject { init(subscriptionManager: SubscriptionManager = AppDependencyProvider.shared.subscriptionManager) { self.subscriptionManager = subscriptionManager let subscriptionFAQURL = subscriptionManager.url(for: .faq) - self.state = State(faqURL: subscriptionFAQURL) + let learnMoreURL = subscriptionFAQURL.appendingPathComponent("adding-email") + self.state = State(faqURL: subscriptionFAQURL, learnMoreURL: learnMoreURL) - setupSubscriptionUpdater() setupNotificationObservers() } private var dateFormatter: DateFormatter = { let formatter = DateFormatter() - formatter.dateFormat = "MMM dd, yyyy" + formatter.dateFormat = "MMMM dd, yyyy" return formatter }() func onFirstAppear() { - self.fetchAndUpdateSubscriptionDetails(cachePolicy: .returnCacheDataElseLoad) - } - - private func fetchAndUpdateSubscriptionDetails(cachePolicy: APICachePolicy = .returnCacheDataElseLoad, - loadingIndicator: Bool = true) { Task { - if loadingIndicator { displayLoader(true) } - guard let token = self.subscriptionManager.accountManager.accessToken else { return } - let subscriptionResult = await self.subscriptionManager.subscriptionEndpointService.getSubscription(accessToken: token, - cachePolicy: cachePolicy) - switch subscriptionResult { - case .success(let subscription): - DispatchQueue.main.async { - self.state.subscriptionInfo = subscription - if loadingIndicator { self.displayLoader(false) } - } - await updateSubscriptionsStatusMessage(status: subscription.status, - date: subscription.expiresOrRenewsAt, - product: subscription.productId, - billingPeriod: subscription.billingPeriod) - default: - DispatchQueue.main.async { - if loadingIndicator { self.displayLoader(true) } - self.showConnectionError(true) - } - - subscriptionUpdateTimer?.invalidate() + // Load initial state from the cache + async let loadedEmailFromCache = await self.fetchAndUpdateAccountEmail(cachePolicy: .returnCacheDataDontLoad, + loadingIndicator: false) + async let loadedSubscriptionFromCache = await self.fetchAndUpdateSubscriptionDetails(cachePolicy: .returnCacheDataDontLoad, + loadingIndicator: false) + let (hasLoadedEmailFromCache, hasLoadedSubscriptionFromCache) = await (loadedEmailFromCache, loadedSubscriptionFromCache) + + // Reload remote subscription and email state + async let reloadedEmail = await self.fetchAndUpdateAccountEmail(cachePolicy: .reloadIgnoringLocalCacheData, + loadingIndicator: !hasLoadedEmailFromCache) + async let reloadedSubscription = await self.fetchAndUpdateSubscriptionDetails(cachePolicy: .reloadIgnoringLocalCacheData, + loadingIndicator: !hasLoadedSubscriptionFromCache) + let (hasReloadedEmail, hasReloadedSubscription) = await (reloadedEmail, reloadedSubscription) + + // In case any fetch fails show an error + if !hasReloadedEmail || !hasReloadedSubscription { + self.showConnectionError(true) } } } - - private func displayLoader(_ show: Bool) { + + private func fetchAndUpdateSubscriptionDetails(cachePolicy: APICachePolicy, loadingIndicator: Bool) async -> Bool { + guard let token = self.subscriptionManager.accountManager.accessToken else { return false } + + if loadingIndicator { displaySubscriptionLoader(true) } + let subscriptionResult = await self.subscriptionManager.subscriptionEndpointService.getSubscription(accessToken: token, + cachePolicy: cachePolicy) + switch subscriptionResult { + case .success(let subscription): + DispatchQueue.main.async { + self.state.subscriptionInfo = subscription + if loadingIndicator { self.displaySubscriptionLoader(false) } + } + await updateSubscriptionsStatusMessage(status: subscription.status, + date: subscription.expiresOrRenewsAt, + product: subscription.productId, + billingPeriod: subscription.billingPeriod) + return true + default: + DispatchQueue.main.async { + if loadingIndicator { self.displaySubscriptionLoader(true) } + } + return false + } + } + + func fetchAndUpdateAccountEmail(cachePolicy: APICachePolicy = .returnCacheDataElseLoad, loadingIndicator: Bool) async -> Bool { + guard let token = self.subscriptionManager.accountManager.accessToken else { return false } + + switch cachePolicy { + case .returnCacheDataDontLoad, .returnCacheDataElseLoad: + self.state.subscriptionEmail = self.subscriptionManager.accountManager.email + return true + case .reloadIgnoringLocalCacheData: + break + } + + if loadingIndicator { displayEmailLoader(true) } + switch await self.subscriptionManager.accountManager.fetchAccountDetails(with: token) { + case .success(let details): + DispatchQueue.main.async { + self.state.subscriptionEmail = details.email + if loadingIndicator { self.displayEmailLoader(false) } + } + + // If fetched email is different then update accountManager + if details.email != subscriptionManager.accountManager.email { + let externalID = subscriptionManager.accountManager.externalID + subscriptionManager.accountManager.storeAccount(token: token, email: details.email, externalID: externalID) + } + return true + default: + DispatchQueue.main.async { + if loadingIndicator { self.displayEmailLoader(true) } + } + return false + } + } + + private func displaySubscriptionLoader(_ show: Bool) { DispatchQueue.main.async { self.state.isLoadingSubscriptionInfo = show } } - + + private func displayEmailLoader(_ show: Bool) { + DispatchQueue.main.async { + self.state.isLoadingEmailInfo = show + } + } + func manageSubscription() { switch state.subscriptionInfo?.platform { case .apple: @@ -140,25 +198,18 @@ final class SubscriptionSettingsViewModel: ObservableObject { } } - // Re-fetch subscription from server ignoring cache - // This ensure that if the user re-subscribed or changed plan on the Apple view, state is updated - private func setupSubscriptionUpdater() { - subscriptionUpdateTimer = Timer.scheduledTimer(withTimeInterval: 8.0, repeats: true) { [weak self] _ in - guard let strongSelf = self else { return } - strongSelf.fetchAndUpdateSubscriptionDetails(cachePolicy: .reloadIgnoringLocalCacheData, loadingIndicator: false) - } - } - @MainActor private func updateSubscriptionsStatusMessage(status: Subscription.Status, date: Date, product: String, billingPeriod: Subscription.BillingPeriod) { + let billingPeriod = billingPeriod == .monthly ? UserText.subscriptionMonthlyBillingPeriod : UserText.subscriptionAnnualBillingPeriod let date = dateFormatter.string(from: date) - let expiredStates: [Subscription.Status] = [.expired, .inactive] - if expiredStates.contains(status) { + + switch status { + case .autoRenewable: + state.subscriptionDetails = UserText.renewingSubscriptionInfo(billingPeriod: billingPeriod, renewalDate: date) + case .expired, .inactive: state.subscriptionDetails = UserText.expiredSubscriptionInfo(expiration: date) - } else { - let statusString = (status == .autoRenewable) ? UserText.subscriptionRenews : UserText.subscriptionExpires - state.subscriptionDetails = UserText.subscriptionInfo(status: statusString, expiration: date) - state.subscriptionType = billingPeriod == .monthly ? UserText.subscriptionMonthly : UserText.subscriptionAnnual + default: + state.subscriptionDetails = UserText.expiringSubscriptionInfo(billingPeriod: billingPeriod, expiryDate: date) } } @@ -192,7 +243,13 @@ final class SubscriptionSettingsViewModel: ObservableObject { state.isShowingFAQView = value } } - + + func displayLearnMoreView(_ value: Bool) { + if value != state.isShowingLearnMoreView { + state.isShowingLearnMoreView = value + } + } + func showConnectionError(_ value: Bool) { if value != state.isShowingConnectionError { DispatchQueue.main.async { @@ -248,7 +305,6 @@ final class SubscriptionSettingsViewModel: ObservableObject { } deinit { - subscriptionUpdateTimer?.invalidate() signOutObserver = nil } } diff --git a/DuckDuckGo/Subscription/Views/SubscriptionContainerView.swift b/DuckDuckGo/Subscription/Views/SubscriptionContainerView.swift index 5f56f8d7d6..61b38b3292 100644 --- a/DuckDuckGo/Subscription/Views/SubscriptionContainerView.swift +++ b/DuckDuckGo/Subscription/Views/SubscriptionContainerView.swift @@ -24,7 +24,7 @@ import SwiftUI struct SubscriptionContainerView: View { enum CurrentView { - case subscribe, restore + case subscribe, restore, email } @Environment(\.dismiss) var dismiss @@ -54,6 +54,8 @@ struct SubscriptionContainerView: View { SubscriptionRestoreView(viewModel: restoreViewModel, emailViewModel: emailViewModel, currentView: $currentViewState).environmentObject(subscriptionNavigationCoordinator) + case .email: + SubscriptionEmailView(viewModel: emailViewModel) } } } diff --git a/DuckDuckGo/Subscription/Views/SubscriptionContainerViewFactory.swift b/DuckDuckGo/Subscription/Views/SubscriptionContainerViewFactory.swift index d3fc8369ad..32c1423204 100644 --- a/DuckDuckGo/Subscription/Views/SubscriptionContainerViewFactory.swift +++ b/DuckDuckGo/Subscription/Views/SubscriptionContainerViewFactory.swift @@ -27,6 +27,7 @@ enum SubscriptionContainerViewFactory { let appStoreRestoreFlow = DefaultAppStoreRestoreFlow(subscriptionManager: subscriptionManager) let appStorePurchaseFlow = DefaultAppStorePurchaseFlow(subscriptionManager: subscriptionManager, appStoreRestoreFlow: appStoreRestoreFlow) + let appStoreAccountManagementFlow = DefaultAppStoreAccountManagementFlow(subscriptionManager: subscriptionManager) let viewModel = SubscriptionContainerViewModel( subscriptionManager: subscriptionManager, @@ -35,7 +36,8 @@ enum SubscriptionContainerViewFactory { subFeature: SubscriptionPagesUseSubscriptionFeature(subscriptionManager: subscriptionManager, subscriptionAttributionOrigin: origin, appStorePurchaseFlow: appStorePurchaseFlow, - appStoreRestoreFlow: appStoreRestoreFlow) + appStoreRestoreFlow: appStoreRestoreFlow, + appStoreAccountManagementFlow: appStoreAccountManagementFlow) ) return SubscriptionContainerView(currentView: .subscribe, viewModel: viewModel) .environmentObject(navigationCoordinator) @@ -45,6 +47,7 @@ enum SubscriptionContainerViewFactory { let appStoreRestoreFlow = DefaultAppStoreRestoreFlow(subscriptionManager: subscriptionManager) let appStorePurchaseFlow = DefaultAppStorePurchaseFlow(subscriptionManager: subscriptionManager, appStoreRestoreFlow: appStoreRestoreFlow) + let appStoreAccountManagementFlow = DefaultAppStoreAccountManagementFlow(subscriptionManager: subscriptionManager) let viewModel = SubscriptionContainerViewModel( subscriptionManager: subscriptionManager, @@ -53,10 +56,34 @@ enum SubscriptionContainerViewFactory { subFeature: SubscriptionPagesUseSubscriptionFeature(subscriptionManager: subscriptionManager, subscriptionAttributionOrigin: nil, appStorePurchaseFlow: appStorePurchaseFlow, - appStoreRestoreFlow: appStoreRestoreFlow) + appStoreRestoreFlow: appStoreRestoreFlow, + appStoreAccountManagementFlow: appStoreAccountManagementFlow) ) return SubscriptionContainerView(currentView: .restore, viewModel: viewModel) .environmentObject(navigationCoordinator) } + static func makeEmailFlow(navigationCoordinator: SubscriptionNavigationCoordinator, + subscriptionManager: SubscriptionManager, + onDisappear: @escaping () -> Void) -> some View { + let appStoreRestoreFlow = DefaultAppStoreRestoreFlow(subscriptionManager: subscriptionManager) + let appStorePurchaseFlow = DefaultAppStorePurchaseFlow(subscriptionManager: subscriptionManager, + appStoreRestoreFlow: appStoreRestoreFlow) + let appStoreAccountManagementFlow = DefaultAppStoreAccountManagementFlow(subscriptionManager: subscriptionManager) + + let viewModel = SubscriptionContainerViewModel( + subscriptionManager: subscriptionManager, + origin: nil, + userScript: SubscriptionPagesUserScript(), + subFeature: SubscriptionPagesUseSubscriptionFeature(subscriptionManager: subscriptionManager, + subscriptionAttributionOrigin: nil, + appStorePurchaseFlow: appStorePurchaseFlow, + appStoreRestoreFlow: appStoreRestoreFlow, + appStoreAccountManagementFlow: appStoreAccountManagementFlow) + ) + return SubscriptionContainerView(currentView: .email, viewModel: viewModel) + .environmentObject(navigationCoordinator) + .onDisappear(perform: { onDisappear() }) + } + } diff --git a/DuckDuckGo/Subscription/Views/SubscriptionRestoreView.swift b/DuckDuckGo/Subscription/Views/SubscriptionRestoreView.swift index 648cc3b925..3368a036b0 100644 --- a/DuckDuckGo/Subscription/Views/SubscriptionRestoreView.swift +++ b/DuckDuckGo/Subscription/Views/SubscriptionRestoreView.swift @@ -23,7 +23,6 @@ import DesignResourcesKit import Core @available(iOS 15.0, *) -// swiftlint:disable type_body_length struct SubscriptionRestoreView: View { @Environment(\.dismiss) var dismiss @@ -65,18 +64,11 @@ struct SubscriptionRestoreView: View { } var body: some View { - if viewModel.state.isAddingDevice { - ZStack { - baseView - } - } else { - ZStack { - baseView - - if viewModel.state.transactionStatus != .idle { - PurchaseInProgressView(status: getTransactionStatus()) - } - + ZStack { + baseView + + if viewModel.state.transactionStatus != .idle { + PurchaseInProgressView(status: getTransactionStatus()) } } } @@ -153,8 +145,6 @@ struct SubscriptionRestoreView: View { .onAppear { viewModel.onAppear() } - - } // MARK: - @@ -182,38 +172,15 @@ struct SubscriptionRestoreView: View { if !viewModel.state.isLoading { VStack(alignment: .leading) { - if !viewModel.state.isAddingDevice { - Text(UserText.subscriptionActivateEmailDescription) - .daxSubheadRegular() - .foregroundColor(Color(designSystemColor: .textSecondary)) - getCellButton(buttonText: UserText.subscriptionActivateEmailButton, - action: { - DailyPixel.fireDailyAndCount(pixel: .privacyProRestorePurchaseEmailStart) - DailyPixel.fire(pixel: .privacyProWelcomeAddDevice) - viewModel.showActivationFlow(true) - }) - } else if viewModel.state.subscriptionEmail == nil { - Text(UserText.subscriptionAddDeviceEmailDescription) - .daxSubheadRegular() - .foregroundColor(Color(designSystemColor: .textSecondary)) - getCellButton(buttonText: UserText.subscriptionRestoreAddEmailButton, - action: { - Pixel.fire(pixel: .privacyProAddDeviceEnterEmail, debounce: 1) - viewModel.showActivationFlow(true) - }) - } else { - Text(viewModel.state.subscriptionEmail ?? "").daxSubheadSemibold() - Text(UserText.subscriptionManageEmailDescription) - .daxSubheadRegular() - .foregroundColor(Color(designSystemColor: .textSecondary)) - HStack { - getCellButton(buttonText: UserText.subscriptionManageEmailButton, - action: { - Pixel.fire(pixel: .privacyProSubscriptionManagementEmail, debounce: 1) - viewModel.showActivationFlow(true) - }) - } - } + Text(UserText.subscriptionActivateEmailDescription) + .daxSubheadRegular() + .foregroundColor(Color(designSystemColor: .textSecondary)) + getCellButton(buttonText: UserText.subscriptionActivateEmailButton, + action: { + DailyPixel.fireDailyAndCount(pixel: .privacyProRestorePurchaseEmailStart) + DailyPixel.fire(pixel: .privacyProWelcomeAddDevice) + viewModel.showActivationFlow(true) + }) } } else { SwiftUI.ProgressView() @@ -258,11 +225,11 @@ struct SubscriptionRestoreView: View { private var headerView: some View { VStack(spacing: Constants.headerLineSpacing) { Image(Constants.heroImage) - Text(viewModel.state.isAddingDevice ? UserText.subscriptionAddDeviceHeaderTitle : UserText.subscriptionActivateTitle) + Text(UserText.subscriptionActivateTitle) .daxHeadline() .multilineTextAlignment(.center) .foregroundColor(Color(designSystemColor: .textPrimary)) - Text(viewModel.state.isAddingDevice ? UserText.subscriptionAddDeviceDescription : UserText.subscriptionActivateHeaderDescription) + Text(UserText.subscriptionActivateHeaderDescription) .daxFootnoteRegular() .foregroundColor(Color(designSystemColor: .textSecondary)) .multilineTextAlignment(.center) @@ -272,22 +239,20 @@ struct SubscriptionRestoreView: View { @ViewBuilder private var footerView: some View { - if !viewModel.state.isAddingDevice { - VStack(alignment: .leading, spacing: Constants.footerLineSpacing) { - Text(UserText.subscriptionActivateDescription) - .daxFootnoteRegular() - .foregroundColor(Color(designSystemColor: .textSecondary)) - Button(action: { - viewModel.restoreAppstoreTransaction() - }, label: { - Text(UserText.subscriptionRestoreAppleID) - .daxFootnoteSemibold() - .foregroundColor(Color(designSystemColor: .accent)) - }) - } + VStack(alignment: .leading, spacing: Constants.footerLineSpacing) { + Text(UserText.subscriptionActivateDescription) + .daxFootnoteRegular() + .foregroundColor(Color(designSystemColor: .textSecondary)) + Button(action: { + viewModel.restoreAppstoreTransaction() + }, label: { + Text(UserText.subscriptionRestoreAppleID) + .daxFootnoteSemibold() + .foregroundColor(Color(designSystemColor: .accent)) + }) } } - + private func getAlert() -> Alert { switch viewModel.state.activationResult { case .activated: @@ -336,4 +301,3 @@ struct SubscriptionRestoreView: View { } } -// swiftlint:enable type_body_length diff --git a/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift b/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift index 679bae8284..76dcf7f7d7 100644 --- a/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift +++ b/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift @@ -34,10 +34,10 @@ struct SubscriptionSettingsView: View { @State var isShowingGoogleView = false @State var isShowingRemovalNotice = false @State var isShowingFAQView = false - @State var isShowingRestoreView = false + @State var isShowingLearnMoreView = false + @State var isShowingEmailView = false @State var isShowingConnectionError = false - @State var isLoading = false - + enum Constants { static let alertIcon = "Exclamation-Color-16" } @@ -54,35 +54,66 @@ struct SubscriptionSettingsView: View { private var headerSection: some View { Section { - let active = viewModel.state.subscriptionInfo?.isActive ?? false + let isExpired = !(viewModel.state.subscriptionInfo?.isActive ?? false) VStack(alignment: .center, spacing: 7) { Image("Privacy-Pro-96x96") Text(UserText.subscriptionTitle).daxTitle2() - if !viewModel.state.isLoadingSubscriptionInfo { - if active { - Text(viewModel.state.subscriptionType).daxHeadline() - } + + if isExpired { HStack { - if !active { Image(Constants.alertIcon) } + Image(Constants.alertIcon) Text(viewModel.state.subscriptionDetails) .daxSubheadRegular() .foregroundColor(Color(designSystemColor: .textSecondary)) } - } else { - SwiftUI.ProgressView() } } } .listRowBackground(Color.clear) .frame(maxWidth: .infinity, alignment: .center) - } - + + private var devicesSection: some View { + Section(header: Text(UserText.subscriptionDevicesSectionHeader), + footer: devicesSectionFooter) { + + if !viewModel.state.isLoadingEmailInfo { + NavigationLink(destination: SubscriptionContainerViewFactory.makeEmailFlow( + navigationCoordinator: subscriptionNavigationCoordinator, + subscriptionManager: AppDependencyProvider.shared.subscriptionManager, + onDisappear: { + Task { await viewModel.fetchAndUpdateAccountEmail(cachePolicy: .reloadIgnoringLocalCacheData, loadingIndicator: false) } + }), + isActive: $isShowingEmailView) { + if let email = viewModel.state.subscriptionEmail { + SettingsCellView(label: UserText.subscriptionEditEmailButton, + subtitle: email) + } else { + SettingsCellView(label: UserText.subscriptionAddEmailButton) + } + }.isDetailLink(false) + } else { + SwiftUI.ProgressView() + } + } + } + + private var devicesSectionFooter: some View { + let hasEmail = !(viewModel.state.subscriptionEmail ?? "").isEmpty + let footerText = hasEmail ? UserText.subscriptionDevicesSectionWithEmailFooter : UserText.subscriptionDevicesSectionNoEmailFooter + return Text(.init("\(footerText)")) // required to parse markdown formatting + .environment(\.openURL, OpenURLAction { _ in + viewModel.displayLearnMoreView(true) + return .handled + }) + } + private var manageSection: some View { - Section(header: Text(UserText.subscriptionManageTitle)) { + Section(header: Text(UserText.subscriptionManageTitle), + footer: manageSectionFooter) { let active = viewModel.state.subscriptionInfo?.isActive ?? false SettingsCustomCell(content: { - + if !viewModel.state.isLoadingSubscriptionInfo { if active { Text(UserText.subscriptionChangePlan) @@ -115,21 +146,6 @@ struct SubscriptionSettingsView: View { SubscriptionExternalLinkView(viewModel: stripeViewModel, title: UserText.subscriptionManagePlan) } } - } - } - - private var devicesSection: some View { - Section(header: Text(UserText.subscriptionManageDevices)) { - - NavigationLink(destination: SubscriptionContainerViewFactory.makeRestoreFlow( - navigationCoordinator: subscriptionNavigationCoordinator, - subscriptionManager: AppDependencyProvider.shared.subscriptionManager), - isActive: $isShowingRestoreView) { - SettingsCustomCell(content: { - Text(UserText.subscriptionAddDeviceButton) - .daxBodyRegular() - }) - }.isDetailLink(false) SettingsCustomCell(content: { Text(UserText.subscriptionRemoveFromDevice) @@ -137,10 +153,20 @@ struct SubscriptionSettingsView: View { .foregroundColor(Color.init(designSystemColor: .accent))}, action: { viewModel.displayRemovalNotice(true) }, isButton: true) - } } - + + private var manageSectionFooter: some View { + let isExpired = !(viewModel.state.subscriptionInfo?.isActive ?? false) + return Group { + if isExpired { + EmptyView() + } else { + Text(viewModel.state.subscriptionDetails) + } + } + } + @ViewBuilder var helpSection: some View { Section(header: Text(UserText.subscriptionHelpAndSupport), footer: Text(UserText.subscriptionFAQFooter)) { @@ -167,7 +193,6 @@ struct SubscriptionSettingsView: View { List { headerSection - manageSection devicesSection .alert(isPresented: $isShowingRemovalNotice) { Alert( @@ -182,6 +207,7 @@ struct SubscriptionSettingsView: View { } ) } + manageSection helpSection } @@ -218,14 +244,22 @@ struct SubscriptionSettingsView: View { viewModel.displayRemovalNotice(value) } - // Removal Notice + // FAQ .onChange(of: viewModel.state.isShowingFAQView) { value in isShowingFAQView = value } .onChange(of: isShowingFAQView) { value in viewModel.displayFAQView(value) } - + + // Learn More + .onChange(of: viewModel.state.isShowingLearnMoreView) { value in + isShowingLearnMoreView = value + } + .onChange(of: isShowingLearnMoreView) { value in + viewModel.displayLearnMoreView(value) + } + // Connection Error .onChange(of: viewModel.state.isShowingConnectionError) { value in isShowingConnectionError = value @@ -233,11 +267,20 @@ struct SubscriptionSettingsView: View { .onChange(of: isShowingConnectionError) { value in viewModel.showConnectionError(value) } - + + .onChange(of: isShowingEmailView) { value in + if value { + if let email = viewModel.state.subscriptionEmail, !email.isEmpty { + Pixel.fire(pixel: .privacyProSubscriptionManagementEmail, debounce: 1) + } else { + Pixel.fire(pixel: .privacyProAddDeviceEnterEmail, debounce: 1) + } + } + } .onReceive(subscriptionNavigationCoordinator.$shouldPopToSubscriptionSettings) { shouldDismiss in if shouldDismiss { - isShowingRestoreView = false + isShowingEmailView = false } } @@ -254,7 +297,11 @@ struct SubscriptionSettingsView: View { .sheet(isPresented: $isShowingFAQView, content: { SubscriptionExternalLinkView(viewModel: viewModel.state.faqViewModel, title: UserText.subscriptionFAQ) }) - + + .sheet(isPresented: $isShowingLearnMoreView, content: { + SubscriptionExternalLinkView(viewModel: viewModel.state.learnMoreViewModel, title: UserText.subscriptionFAQ) + }) + .onFirstAppear { viewModel.onFirstAppear() } diff --git a/DuckDuckGo/UserText.swift b/DuckDuckGo/UserText.swift index 4b0f67e5e3..33224018d2 100644 --- a/DuckDuckGo/UserText.swift +++ b/DuckDuckGo/UserText.swift @@ -1030,13 +1030,20 @@ But if you *do* want a peek under the hood, you can find more information about public static let subscriptionTitle = NSLocalizedString("subscription.title", value: "Privacy Pro", comment: "Navigation bar Title for subscriptions") public static let subscriptionCloseButton = NSLocalizedString("subscription.close", value: "Close", comment: "Navigation Button for closing subscription view") - static func subscriptionInfo(status: String, expiration: String) -> String { - let localized = NSLocalizedString("subscription.subscription.active.caption", - value: "Your subscription %@ on %@", - comment: "Subscription Expiration Data. This reads as 'Your subscription (renews or expires) on (date)'") - return String(format: localized, status, expiration) + static func renewingSubscriptionInfo(billingPeriod: String, renewalDate: String) -> String { + let localized = NSLocalizedString("subscription.subscription.renewing.caption", + value: "Your %@ subscription renews on %@.", + comment: "Subscription renewal info. This reads as 'Your (monthly or annual) subscription renews on (date)'") + return String(format: localized, billingPeriod, renewalDate) } - + + static func expiringSubscriptionInfo(billingPeriod: String, expiryDate: String) -> String { + let localized = NSLocalizedString("subscription.subscription.expiring.caption", + value: "Your %@ subscription expires on %@.", + comment: "Subscription expiration info. This reads as 'Your (monthly or annual) subscription expires on (date)'") + return String(format: localized, billingPeriod, expiryDate) + } + static func expiredSubscriptionInfo(expiration: String) -> String { let localized = NSLocalizedString("subscription.subscription.expired.caption", value: "Your subscription expired on %@", @@ -1044,20 +1051,19 @@ But if you *do* want a peek under the hood, you can find more information about return String(format: localized, expiration) } - public static let subscriptionRenews = NSLocalizedString("subscription.renews", value: "renews", comment: "text for renewal string") - public static let subscriptionExpires = NSLocalizedString("subscription.expires", value: "expires", comment: "text for expiration string") - public static let subscriptionMonthly = NSLocalizedString("subscription.monthly", value: "Monthly Subscription", comment: "Subscription type") - public static let subscriptionAnnual = NSLocalizedString("subscription.annual", value: "Annual Subscription", comment: "Subscription type") - - public static let subscriptionManageDevices = NSLocalizedString("subscription.manage.devices", value: "Manage Devices", comment: "Header for the device management section") - public static let subscriptionAddDeviceButton = NSLocalizedString("subscription.add.device.button", value: "Add to Another Device", comment: "Add to another device button") + public static let subscriptionMonthlyBillingPeriod = NSLocalizedString("subscription.billing.period.monthly", value: "monthly", comment: "Subscription monthly billing period type") + public static let subscriptionAnnualBillingPeriod = NSLocalizedString("subscription.billing.period.annual", value: "annual", comment: "Subscription annual billing period type") + + public static let subscriptionDevicesSectionHeader = NSLocalizedString("subscription.devices.header", value: "Activate on Other Devices", comment: "Header for section for activating subscription on other devices") + public static let subscriptionDevicesSectionNoEmailFooter = NSLocalizedString("subscription.devices.no.email.footer", value: "Add an optional email to your subscription or use your Apple ID to access Privacy Pro on other devices. **[Learn more](https://duckduckgo.com/duckduckgo-help-pages/privacy-pro/adding-email/)**", comment: "Footer for section for activating subscription on other devices when email was not yet added") + public static let subscriptionDevicesSectionWithEmailFooter = NSLocalizedString("subscription.devices.with.email.footer", value: "Use this email to activate your subscription in Settings > Privacy Pro in the DuckDuckGo app on your other devices. **[Learn more](https://duckduckgo.com/duckduckgo-help-pages/privacy-pro/adding-email/)**", comment: "Footer for section for activating subscription on other devices when email is added") public static let subscriptionRemoveFromDevice = NSLocalizedString("subscription.remove.from.device.button", value: "Remove From This Device", comment: "Remove from this device button") public static let subscriptionManageTitle = NSLocalizedString("subscription.manage.title", value: "Subscription", comment: "Header for the subscription section") public static let subscriptionManagePlan = NSLocalizedString("subscription.manage.plan", value: "Manage Plan", comment: "Manage Plan header") - public static let subscriptionChangePlan = NSLocalizedString("subscription.change.plan", value: "Change Plan or Billing", comment: "Change plan or billing title") + public static let subscriptionChangePlan = NSLocalizedString("subscription.change.plan", value: "Update Plan or Cancel", comment: "Change plan or cancel title") public static let subscriptionHelpAndSupport = NSLocalizedString("subscription.help", value: "Help and support", comment: "Help and support Section header") - public static let subscriptionFAQ = NSLocalizedString("subscription.faq", value: "Privacy Pro FAQ", comment: "FAQ Button") - public static let subscriptionFAQFooter = NSLocalizedString("subscription.faq.description", value: "Get answers to frequently asked questions about Privacy Pro in our help pages.", comment: "FAQ Description") + public static let subscriptionFAQ = NSLocalizedString("subscription.faq", value: "FAQs and Support", comment: "FAQ Button") + public static let subscriptionFAQFooter = NSLocalizedString("subscription.faq.description", value: "Get answers to frequently asked questions or contact Privacy Pro support from our help pages.", comment: "FAQ Description") // Remove subscription confirmation public static let subscriptionRemoveFromDeviceConfirmTitle = NSLocalizedString("subscription.remove.from.device.title", value: "Remove from this device?", comment: "Remove from device confirmation dialog title") @@ -1067,7 +1073,6 @@ But if you *do* want a peek under the hood, you can find more information about public static let subscriptionRemovalConfirmation = NSLocalizedString("subscription.cancel.message", value: "Your subscription has been removed from this device.", comment: "Subscription Removal confirmation message") // Subscription Restore - public static let subscriptionActivate = NSLocalizedString("subscription.activate", value: "Activate Subscription", comment: "Subscription Activation Window Title") public static let subscriptionActivateTitle = NSLocalizedString("subscription.activate.title", value: "Activate your subscription on this device", comment: "Subscription Activation Title") public static let subscriptionActivateDescription = NSLocalizedString("subscription.activate.description", value: "Your subscription is automatically available in DuckDuckGo on any device signed in to your Apple ID.", comment: "Subscription Activation Info") public static let subscriptionActivateHeaderDescription = NSLocalizedString("subscription.activate..header.description", value: "Access your Privacy Pro subscription on this device via Apple ID or an email address.", comment: "Subscription Activation Info") @@ -1078,14 +1083,11 @@ But if you *do* want a peek under the hood, you can find more information about public static let subscriptionActivateEmail = NSLocalizedString("subscription.activate.email", value: "Email", comment: "Email option for activation") public static let subscriptionActivateEmailTitle = NSLocalizedString("subscription.activate.email.title", value: "Activate Subscription", comment: "Activate subscription title") public static let subscriptionActivateEmailDescription = NSLocalizedString("subscription.activate.email.description", value: "Use your email to activate your subscription on this device.", comment: "Description for Email activation") - public static let subscriptionAddDeviceEmailDescription = NSLocalizedString("subscription.addDevice.email.description", value: "Add an email address to access your subscription in DuckDuckGo on other devices. We’ll only use this address to verify your subscription.", comment: "Description for Email adding") - public static let subscriptionAddEmailButton = NSLocalizedString("subscription.activate.add.email.button", value: "Add Email", comment: "Restore button title for Email") + public static let subscriptionAddEmailButton = NSLocalizedString("subscription.activate.add.email.button", value: "Add Email", comment: "Button for adding email address to subscription") + public static let subscriptionEditEmailButton = NSLocalizedString("subscription.activate.edit.email.button", value: "Edit Email", comment: "Button for editing email address added to subscription") public static let subscriptionActivateEmailButton = NSLocalizedString("subscription.activate.email.button", value: "Enter Email", comment: "Restore button title for Email") // Add to other devices (AppleID / Email) - public static let subscriptionAddDeviceTitle = NSLocalizedString("subscription.add.device.title", value: "Add Device", comment: "Add to another device view title") - public static let subscriptionAddDeviceHeaderTitle = NSLocalizedString("subscription.add.device.header.title", value: "Use your subscription on other devices", comment: "Add subscription to other device title ") - public static let subscriptionAddDeviceDescription = NSLocalizedString("subscription.add.device.description", value: "Access your Privacy Pro subscription via an email address.", comment: "Subscription Add device Info") public static let subscriptionAvailableInApple = NSLocalizedString("subscription.available.apple", value: "Privacy Pro is available on any device signed in to the same Apple ID.", comment: "Subscription availability message on Apple devices") public static let subscriptionManageEmailResendInstructions = NSLocalizedString("subscription.add.device.resend.instructions", value: "Resend Instructions", comment: "Resend activation instructions button") @@ -1094,13 +1096,10 @@ But if you *do* want a peek under the hood, you can find more information about // Add Email To subscription public static let subscriptionAddEmail = NSLocalizedString("subscription.add.email", value: "Add an email address to activate your subscription on your other devices. We’ll only use this address to verify your subscription.", comment: "Add email to an existing subscription") - public static let subscriptionRestoreAddEmailButton = NSLocalizedString("subscription.add.email.button", value: "Add Email", comment: "Button title for adding email to subscription") public static let subscriptionRestoreAddEmailTitle = NSLocalizedString("subscription.add.email.title", value: "Add Email", comment: "View title for adding email to subscription") // Manage Subscription Email - public static let subscriptionManageEmailDescription = NSLocalizedString("subscription.manage.email.description", value: "Use this email to activate your subscription from browser settings in the DuckDuckGo app on other devices..", comment: "Description for Email Management options") - public static let subscriptionManageEmailButton = NSLocalizedString("subscription.activate.manage.email.button", value: "Manage", comment: "Restore button title for Managing Email") - public static let subscriptionManageEmailTitle = NSLocalizedString("subscription.activate.manage.email.title", value: "Manage Email", comment: "View Title for managing your email account") + public static let subscriptionEditEmailTitle = NSLocalizedString("subscription.activate.edit.email.title", value: "Edit Email", comment: "View Title for editing your email account") public static let subscriptionManageEmailCancelButton = NSLocalizedString("subscription.activate.manage.email.cancel", value: "Cancel", comment: "Button title for cancelling email deletion") public static let subscriptionManageEmailOKButton = NSLocalizedString("subscription.activate.manage.email.OK", value: "OK", comment: "Button title for confirming email deletion") diff --git a/DuckDuckGo/en.lproj/Localizable.strings b/DuckDuckGo/en.lproj/Localizable.strings index d032747950..c2393ca177 100644 --- a/DuckDuckGo/en.lproj/Localizable.strings +++ b/DuckDuckGo/en.lproj/Localizable.strings @@ -1990,13 +1990,10 @@ But if you *do* want a peek under the hood, you can find more information about /* No comment provided by engineer. */ "siteFeedback.urlPlaceholder" = "Which website is broken?"; -/* Subscription Activation Window Title */ -"subscription.activate" = "Activate Subscription"; - /* Subscription Activation Info */ "subscription.activate..header.description" = "Access your Privacy Pro subscription on this device via Apple ID or an email address."; -/* Restore button title for Email */ +/* Button for adding email address to subscription */ "subscription.activate.add.email.button" = "Add Email"; /* Apple ID option for activation */ @@ -2011,6 +2008,12 @@ But if you *do* want a peek under the hood, you can find more information about /* Subscription Activation Info */ "subscription.activate.description" = "Your subscription is automatically available in DuckDuckGo on any device signed in to your Apple ID."; +/* Button for editing email address added to subscription */ +"subscription.activate.edit.email.button" = "Edit Email"; + +/* View Title for editing your email account */ +"subscription.activate.edit.email.title" = "Edit Email"; + /* Email option for activation */ "subscription.activate.email" = "Email"; @@ -2023,57 +2026,30 @@ But if you *do* want a peek under the hood, you can find more information about /* Activate subscription title */ "subscription.activate.email.title" = "Activate Subscription"; -/* Restore button title for Managing Email */ -"subscription.activate.manage.email.button" = "Manage"; - /* Button title for cancelling email deletion */ "subscription.activate.manage.email.cancel" = "Cancel"; /* Button title for confirming email deletion */ "subscription.activate.manage.email.OK" = "OK"; -/* View Title for managing your email account */ -"subscription.activate.manage.email.title" = "Manage Email"; - /* Restore button title for AppleID */ "subscription.activate.restore.apple" = "Restore Purchase"; /* Subscription Activation Title */ "subscription.activate.title" = "Activate your subscription on this device"; -/* Add to another device button */ -"subscription.add.device.button" = "Add to Another Device"; - -/* Subscription Add device Info */ -"subscription.add.device.description" = "Access your Privacy Pro subscription via an email address."; - -/* Add subscription to other device title */ -"subscription.add.device.header.title" = "Use your subscription on other devices"; - /* Resend activation instructions button */ "subscription.add.device.resend.instructions" = "Resend Instructions"; -/* Add to another device view title */ -"subscription.add.device.title" = "Add Device"; - /* Add email to an existing subscription */ "subscription.add.email" = "Add an email address to activate your subscription on your other devices. We’ll only use this address to verify your subscription."; -/* Button title for adding email to subscription */ -"subscription.add.email.button" = "Add Email"; - /* View title for adding email to subscription */ "subscription.add.email.title" = "Add Email"; -/* Description for Email adding */ -"subscription.addDevice.email.description" = "Add an email address to access your subscription in DuckDuckGo on other devices. We’ll only use this address to verify your subscription."; - /* Title for Alert messages */ "subscription.alert.title" = "subscription.alert.title"; -/* Subscription type */ -"subscription.annual" = "Annual Subscription"; - /* Subscription availability message on Apple devices */ "subscription.available.apple" = "Privacy Pro is available on any device signed in to the same Apple ID."; @@ -2083,11 +2059,17 @@ But if you *do* want a peek under the hood, you can find more information about /* Title for the manage billing page */ "subscription.billing.google.title" = "Subscription Plans"; +/* Subscription annual billing period type */ +"subscription.billing.period.annual" = "annual"; + +/* Subscription monthly billing period type */ +"subscription.billing.period.monthly" = "monthly"; + /* Subscription Removal confirmation message */ "subscription.cancel.message" = "Your subscription has been removed from this device."; -/* Change plan or billing title */ -"subscription.change.plan" = "Change Plan or Billing"; +/* Change plan or cancel title */ +"subscription.change.plan" = "Update Plan or Cancel"; /* Navigation Button for closing subscription view */ "subscription.close" = "Close"; @@ -2095,6 +2077,15 @@ But if you *do* want a peek under the hood, you can find more information about /* Title for Confirm messages */ "subscription.confirm.title" = "Are you sure?"; +/* Header for section for activating subscription on other devices */ +"subscription.devices.header" = "Activate on Other Devices"; + +/* Footer for section for activating subscription on other devices when email was not yet added */ +"subscription.devices.no.email.footer" = "Add an optional email to your subscription or use your Apple ID to access Privacy Pro on other devices. **[Learn more](https://duckduckgo.com/duckduckgo-help-pages/privacy-pro/adding-email/)**"; + +/* Footer for section for activating subscription on other devices when email is added */ +"subscription.devices.with.email.footer" = "Use this email to activate your subscription in Settings > Privacy Pro in the DuckDuckGo app on your other devices. **[Learn more](https://duckduckgo.com/duckduckgo-help-pages/privacy-pro/adding-email/)**"; + /* Alert content for not found subscription */ "subscription.email.inactive.alert.message" = "The subscription associated with this email is no longer active."; @@ -2104,14 +2095,11 @@ But if you *do* want a peek under the hood, you can find more information about /* Alert content for not found subscription */ "subscription.expired.alert.message" = "The subscription associated with this Apple ID is no longer active."; -/* text for expiration string */ -"subscription.expires" = "expires"; - /* FAQ Button */ -"subscription.faq" = "Privacy Pro FAQ"; +"subscription.faq" = "FAQs and Support"; /* FAQ Description */ -"subscription.faq.description" = "Get answers to frequently asked questions about Privacy Pro in our help pages."; +"subscription.faq.description" = "Get answers to frequently asked questions or contact Privacy Pro support from our help pages."; /* Cancel action for the existing subscription dialog */ "subscription.found.cancel" = "Cancel"; @@ -2128,21 +2116,12 @@ But if you *do* want a peek under the hood, you can find more information about /* Help and support Section header */ "subscription.help" = "Help and support"; -/* Header for the device management section */ -"subscription.manage.devices" = "Manage Devices"; - -/* Description for Email Management options */ -"subscription.manage.email.description" = "Use this email to activate your subscription from browser settings in the DuckDuckGo app on other devices.."; - /* Manage Plan header */ "subscription.manage.plan" = "Manage Plan"; /* Header for the subscription section */ "subscription.manage.title" = "Subscription"; -/* Subscription type */ -"subscription.monthly" = "Monthly Subscription"; - /* Alert content for not found subscription */ "subscription.notFound.alert.message" = "There is no subscription associated with this Apple ID."; @@ -2194,9 +2173,6 @@ But if you *do* want a peek under the hood, you can find more information about /* Remove subscription cancel button text */ "subscription.remove.subscription.cancel" = "Cancel"; -/* text for renewal string */ -"subscription.renews" = "renews"; - /* Button text for general error message */ "subscription.restore.backend.error.button" = "Back to Settings"; @@ -2221,12 +2197,15 @@ But if you *do* want a peek under the hood, you can find more information about /* Alert title for restored purchase */ "subscription.restore.success.alert.title" = "You’re all set."; -/* Subscription Expiration Data. This reads as 'Your subscription (renews or expires) on (date)' */ -"subscription.subscription.active.caption" = "Your subscription %1$@ on %2$@"; - /* Subscription Expired Data. This reads as 'Your subscription expired on (date)' */ "subscription.subscription.expired.caption" = "Your subscription expired on %@"; +/* Subscription expiration info. This reads as 'Your (monthly or annual) subscription expires on (date)' */ +"subscription.subscription.expiring.caption" = "Your %1$@ subscription expires on %2$@."; + +/* Subscription renewal info. This reads as 'Your (monthly or annual) subscription renews on (date)' */ +"subscription.subscription.renewing.caption" = "Your %1$@ subscription renews on %2$@."; + /* Navigation bar Title for subscriptions */ "subscription.title" = "Privacy Pro"; diff --git a/DuckDuckGoTests/SubscriptionContainerViewModelTests.swift b/DuckDuckGoTests/SubscriptionContainerViewModelTests.swift index 747a509329..922c406ba6 100644 --- a/DuckDuckGoTests/SubscriptionContainerViewModelTests.swift +++ b/DuckDuckGoTests/SubscriptionContainerViewModelTests.swift @@ -35,6 +35,7 @@ final class SubscriptionContainerViewModelTests: XCTestCase { let appStoreRestoreFlow = DefaultAppStoreRestoreFlow(subscriptionManager: mockDependencyProvider.subscriptionManager) let appStorePurchaseFlow = DefaultAppStorePurchaseFlow(subscriptionManager: mockDependencyProvider.subscriptionManager, appStoreRestoreFlow: appStoreRestoreFlow) + let appStoreAccountManagementFlow = DefaultAppStoreAccountManagementFlow(subscriptionManager: mockDependencyProvider.subscriptionManager) // WHEN sut = .init(subscriptionManager: mockDependencyProvider.subscriptionManager, @@ -43,7 +44,8 @@ final class SubscriptionContainerViewModelTests: XCTestCase { subFeature: .init(subscriptionManager: mockDependencyProvider.subscriptionManager, subscriptionAttributionOrigin: nil, appStorePurchaseFlow: appStorePurchaseFlow, - appStoreRestoreFlow: appStoreRestoreFlow)) + appStoreRestoreFlow: appStoreRestoreFlow, + appStoreAccountManagementFlow: appStoreAccountManagementFlow)) // THEN XCTAssertEqual(sut.flow.purchaseURL, expectedURL) @@ -53,6 +55,8 @@ final class SubscriptionContainerViewModelTests: XCTestCase { let appStoreRestoreFlow = DefaultAppStoreRestoreFlow(subscriptionManager: mockDependencyProvider.subscriptionManager) let appStorePurchaseFlow = DefaultAppStorePurchaseFlow(subscriptionManager: mockDependencyProvider.subscriptionManager, appStoreRestoreFlow: appStoreRestoreFlow) + let appStoreAccountManagementFlow = DefaultAppStoreAccountManagementFlow(subscriptionManager: mockDependencyProvider.subscriptionManager) + // WHEN sut = .init(subscriptionManager: mockDependencyProvider.subscriptionManager, origin: nil, @@ -60,7 +64,8 @@ final class SubscriptionContainerViewModelTests: XCTestCase { subFeature: .init(subscriptionManager: mockDependencyProvider.subscriptionManager, subscriptionAttributionOrigin: nil, appStorePurchaseFlow: appStorePurchaseFlow, - appStoreRestoreFlow: appStoreRestoreFlow)) + appStoreRestoreFlow: appStoreRestoreFlow, + appStoreAccountManagementFlow: appStoreAccountManagementFlow)) // THEN XCTAssertEqual(sut.flow.purchaseURL, SubscriptionURL.purchase.subscriptionURL(environment: .production)) diff --git a/DuckDuckGoTests/SubscriptionFlowViewModelTests.swift b/DuckDuckGoTests/SubscriptionFlowViewModelTests.swift index 48a0a3ba34..6d375a2a87 100644 --- a/DuckDuckGoTests/SubscriptionFlowViewModelTests.swift +++ b/DuckDuckGoTests/SubscriptionFlowViewModelTests.swift @@ -36,12 +36,14 @@ final class SubscriptionFlowViewModelTests: XCTestCase { let appStoreRestoreFlow = DefaultAppStoreRestoreFlow(subscriptionManager: mockDependencyProvider.subscriptionManager) let appStorePurchaseFlow = DefaultAppStorePurchaseFlow(subscriptionManager: mockDependencyProvider.subscriptionManager, appStoreRestoreFlow: appStoreRestoreFlow) - + let appStoreAccountManagementFlow = DefaultAppStoreAccountManagementFlow(subscriptionManager: mockDependencyProvider.subscriptionManager) + // WHEN sut = .init(origin: origin, userScript: .init(), subFeature: .init(subscriptionManager: mockDependencyProvider.subscriptionManager, subscriptionAttributionOrigin: nil, appStorePurchaseFlow: appStorePurchaseFlow, - appStoreRestoreFlow: appStoreRestoreFlow), + appStoreRestoreFlow: appStoreRestoreFlow, + appStoreAccountManagementFlow: appStoreAccountManagementFlow), subscriptionManager: mockDependencyProvider.subscriptionManager) // THEN @@ -52,12 +54,14 @@ final class SubscriptionFlowViewModelTests: XCTestCase { let appStoreRestoreFlow = DefaultAppStoreRestoreFlow(subscriptionManager: mockDependencyProvider.subscriptionManager) let appStorePurchaseFlow = DefaultAppStorePurchaseFlow(subscriptionManager: mockDependencyProvider.subscriptionManager, appStoreRestoreFlow: appStoreRestoreFlow) - + let appStoreAccountManagementFlow = DefaultAppStoreAccountManagementFlow(subscriptionManager: mockDependencyProvider.subscriptionManager) + // WHEN sut = .init(origin: nil, userScript: .init(), subFeature: .init(subscriptionManager: mockDependencyProvider.subscriptionManager, subscriptionAttributionOrigin: nil, appStorePurchaseFlow: appStorePurchaseFlow, - appStoreRestoreFlow: appStoreRestoreFlow), + appStoreRestoreFlow: appStoreRestoreFlow, + appStoreAccountManagementFlow: appStoreAccountManagementFlow), subscriptionManager: mockDependencyProvider.subscriptionManager) // THEN