From 82af9c2166f7faff1a24b3ccb6db07647335b5c7 Mon Sep 17 00:00:00 2001 From: Daniel Bernal Date: Tue, 2 Apr 2024 18:50:10 +0200 Subject: [PATCH] Subscriptions: 27: Update navigation to use Push Views (#2659) Task/Issue URL: https://app.asana.com/0/414235014887631/1206942263287858/f Description: Updates Subscription navigation to use "Push" transitions instead of sheets Adds .onFirstAppear() modifier to view as SwiftUI's .onAppear() might be called multiple times Cache subscription in memory to prevent UI Glitches Other minor fixes and renames --- Core/PixelEvent.swift | 2 + DuckDuckGo.xcodeproj/project.pbxproj | 20 +- DuckDuckGo/SettingsSubscriptionView.swift | 138 +++++--------- DuckDuckGo/SettingsView.swift | 11 +- DuckDuckGo/SettingsViewModel.swift | 39 ++-- .../Extensions/View+AppearModifiers.swift | 70 +++++++ .../Extensions/View+TopMostController.swift | 40 ---- .../SubscriptionEmailViewModel.swift | 112 +++++------ .../SubscriptionExternalLinkViewModel.swift | 2 +- .../ViewModel/SubscriptionFlowViewModel.swift | 101 +++++----- .../ViewModel/SubscriptionITPViewModel.swift | 25 +-- .../ViewModel/SubscriptionPIRViewModel.swift | 4 +- .../SubscriptionRestoreViewModel.swift | 64 ++++--- .../SubscriptionSettingsViewModel.swift | 43 +++-- .../Views/RootPresentationMode.swift | 45 ----- .../Views/SubscriptionContainerView.swift | 62 +++++++ .../Views/SubscriptionEmailView.swift | 111 ++++++----- .../Views/SubscriptionExternalLinkView.swift | 6 +- .../Views/SubscriptionFlowView.swift | 136 +++++++------- .../Views/SubscriptionITPView.swift | 73 +++----- .../SubscriptionNavigationCoordinator.swift | 25 +++ .../Views/SubscriptionPIRView.swift | 40 ++-- .../Views/SubscriptionRestoreView.swift | 175 +++++++++--------- .../Views/SubscriptionSettingsView.swift | 82 ++++---- 24 files changed, 725 insertions(+), 701 deletions(-) create mode 100644 DuckDuckGo/Subscription/Extensions/View+AppearModifiers.swift delete mode 100644 DuckDuckGo/Subscription/Extensions/View+TopMostController.swift delete mode 100644 DuckDuckGo/Subscription/Views/RootPresentationMode.swift create mode 100644 DuckDuckGo/Subscription/Views/SubscriptionContainerView.swift create mode 100644 DuckDuckGo/Subscription/Views/SubscriptionNavigationCoordinator.swift diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index 4db2056522..ba826f3a0a 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -578,6 +578,7 @@ extension Pixel { case privacyProPurchaseFailureAccountNotCreated case privacyProPurchaseSuccess case privacyProRestorePurchaseOfferPageEntry + case privacyProRestorePurchaseClick case privacyProRestorePurchaseEmailStart case privacyProRestorePurchaseStoreStart case privacyProRestorePurchaseEmailSuccess @@ -1164,6 +1165,7 @@ extension Pixel.Event { case .privacyProPurchaseFailureBackendError: return "m_privacy-pro_app_subscription-purchase_failure_account-creation" case .privacyProPurchaseSuccess: return "m_privacy-pro_app_subscription-purchase_success" case .privacyProRestorePurchaseOfferPageEntry: return "m_privacy-pro_offer_restore-purchase_click" + case .privacyProRestorePurchaseClick: return "m_privacy-pro_app-settings_restore-purchase_click" case .privacyProRestorePurchaseEmailStart: return "m_privacy-pro_activate-subscription_enter-email_click" case .privacyProRestorePurchaseStoreStart: return "m_privacy-pro_activate-subscription_restore-purchase_click" case .privacyProRestorePurchaseEmailSuccess: return "m_privacy-pro_app_subscription-restore-using-email_success" diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 2240414f03..8bf3c09b35 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -820,15 +820,16 @@ D668D9272B6937D2008E2FF2 /* SubscriptionITPViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D668D9262B6937D2008E2FF2 /* SubscriptionITPViewModel.swift */; }; D668D9292B69681C008E2FF2 /* IdentityTheftRestorationPagesUserScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = D668D9282B69681C008E2FF2 /* IdentityTheftRestorationPagesUserScript.swift */; }; D668D92B2B696840008E2FF2 /* IdentityTheftRestorationPagesFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = D668D92A2B696840008E2FF2 /* IdentityTheftRestorationPagesFeature.swift */; }; + D66F683D2BB333C100AE93E2 /* SubscriptionContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66F683C2BB333C100AE93E2 /* SubscriptionContainerView.swift */; }; + D670E5BB2BB6A75300941A42 /* SubscriptionNavigationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D670E5BA2BB6A75200941A42 /* SubscriptionNavigationCoordinator.swift */; }; + D670E5BD2BB6AA0000941A42 /* View+AppearModifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D670E5BC2BB6AA0000941A42 /* View+AppearModifiers.swift */; }; D68A21442B7EC08500BB372E /* SubscriptionExternalLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68A21432B7EC08500BB372E /* SubscriptionExternalLinkView.swift */; }; D68A21462B7EC16200BB372E /* SubscriptionExternalLinkViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68A21452B7EC16200BB372E /* SubscriptionExternalLinkViewModel.swift */; }; D68DF81C2B58302E0023DBEA /* SubscriptionRestoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68DF81B2B58302E0023DBEA /* SubscriptionRestoreView.swift */; }; D68DF81E2B5830380023DBEA /* SubscriptionRestoreViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68DF81D2B5830380023DBEA /* SubscriptionRestoreViewModel.swift */; }; - D69DBB502B72B1D300156310 /* View+TopMostController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69DBB4F2B72B1D200156310 /* View+TopMostController.swift */; }; D69FBF762B28BE3600B505F1 /* SettingsSubscriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69FBF752B28BE3600B505F1 /* SettingsSubscriptionView.swift */; }; D6BFCB5F2B7524AA0051FF81 /* SubscriptionPIRView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BFCB5E2B7524AA0051FF81 /* SubscriptionPIRView.swift */; }; D6BFCB612B7525160051FF81 /* SubscriptionPIRViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BFCB602B7525160051FF81 /* SubscriptionPIRViewModel.swift */; }; - D6D95CE12B6D52DA00960317 /* RootPresentationMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D95CE02B6D52DA00960317 /* RootPresentationMode.swift */; }; D6D95CE32B6D9F8800960317 /* AsyncHeadlessWebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D95CE22B6D9F8800960317 /* AsyncHeadlessWebViewModel.swift */; }; D6E0C1832B7A2B1E00D5E1E9 /* DesktopDownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E0C1822B7A2B1E00D5E1E9 /* DesktopDownloadView.swift */; }; D6E0C1852B7A2B9400D5E1E9 /* DesktopDownloadPlatformConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E0C1842B7A2B9400D5E1E9 /* DesktopDownloadPlatformConstants.swift */; }; @@ -2493,15 +2494,16 @@ D668D9262B6937D2008E2FF2 /* SubscriptionITPViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionITPViewModel.swift; sourceTree = ""; }; D668D9282B69681C008E2FF2 /* IdentityTheftRestorationPagesUserScript.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IdentityTheftRestorationPagesUserScript.swift; sourceTree = ""; }; D668D92A2B696840008E2FF2 /* IdentityTheftRestorationPagesFeature.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IdentityTheftRestorationPagesFeature.swift; sourceTree = ""; }; + D66F683C2BB333C100AE93E2 /* SubscriptionContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionContainerView.swift; sourceTree = ""; }; + D670E5BA2BB6A75200941A42 /* SubscriptionNavigationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionNavigationCoordinator.swift; sourceTree = ""; }; + D670E5BC2BB6AA0000941A42 /* View+AppearModifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+AppearModifiers.swift"; sourceTree = ""; }; D68A21432B7EC08500BB372E /* SubscriptionExternalLinkView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionExternalLinkView.swift; sourceTree = ""; }; D68A21452B7EC16200BB372E /* SubscriptionExternalLinkViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionExternalLinkViewModel.swift; sourceTree = ""; }; D68DF81B2B58302E0023DBEA /* SubscriptionRestoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionRestoreView.swift; sourceTree = ""; }; D68DF81D2B5830380023DBEA /* SubscriptionRestoreViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionRestoreViewModel.swift; sourceTree = ""; }; - D69DBB4F2B72B1D200156310 /* View+TopMostController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+TopMostController.swift"; sourceTree = ""; }; D69FBF752B28BE3600B505F1 /* SettingsSubscriptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSubscriptionView.swift; sourceTree = ""; }; D6BFCB5E2B7524AA0051FF81 /* SubscriptionPIRView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionPIRView.swift; sourceTree = ""; }; D6BFCB602B7525160051FF81 /* SubscriptionPIRViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionPIRViewModel.swift; sourceTree = ""; }; - D6D95CE02B6D52DA00960317 /* RootPresentationMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootPresentationMode.swift; sourceTree = ""; }; D6D95CE22B6D9F8800960317 /* AsyncHeadlessWebViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncHeadlessWebViewModel.swift; sourceTree = ""; }; D6E0C1822B7A2B1E00D5E1E9 /* DesktopDownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesktopDownloadView.swift; sourceTree = ""; }; D6E0C1842B7A2B9400D5E1E9 /* DesktopDownloadPlatformConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesktopDownloadPlatformConstants.swift; sourceTree = ""; }; @@ -4656,7 +4658,7 @@ isa = PBXGroup; children = ( D664C7982B289AA000CBFA76 /* WKUserContentController+Handler.swift */, - D69DBB4F2B72B1D200156310 /* View+TopMostController.swift */, + D670E5BC2BB6AA0000941A42 /* View+AppearModifiers.swift */, ); path = Extensions; sourceTree = ""; @@ -4665,6 +4667,7 @@ isa = PBXGroup; children = ( D664C7AD2B289AA000CBFA76 /* PurchaseInProgressView.swift */, + D66F683C2BB333C100AE93E2 /* SubscriptionContainerView.swift */, D664C7AE2B289AA000CBFA76 /* SubscriptionFlowView.swift */, D68DF81B2B58302E0023DBEA /* SubscriptionRestoreView.swift */, D64648AC2B59936B0033090B /* SubscriptionEmailView.swift */, @@ -4672,8 +4675,8 @@ D68A21432B7EC08500BB372E /* SubscriptionExternalLinkView.swift */, D6BFCB5E2B7524AA0051FF81 /* SubscriptionPIRView.swift */, D6F93E3D2B50A8A0004C268D /* SubscriptionSettingsView.swift */, - D6D95CE02B6D52DA00960317 /* RootPresentationMode.swift */, D60B1F262B9DDE5A00AE4760 /* SubscriptionGoogleView.swift */, + D670E5BA2BB6A75200941A42 /* SubscriptionNavigationCoordinator.swift */, ); path = Views; sourceTree = ""; @@ -6646,6 +6649,7 @@ 020108A929A7C1CD00644F9D /* AppTrackerImageCache.swift in Sources */, 4B78074E2B183A1F009DB2CF /* SurveyURLBuilder.swift in Sources */, 3132FA2A27A0788F00DD7A12 /* QuickLookPreviewHelper.swift in Sources */, + D670E5BB2BB6A75300941A42 /* SubscriptionNavigationCoordinator.swift in Sources */, C1D21E2D293A5965006E5A05 /* AutofillLoginSession.swift in Sources */, 4B53648A26718D0E001AA041 /* EmailWaitlist.swift in Sources */, 027F48762A4B5FBE001A1C6C /* AppTPLinkButton.swift in Sources */, @@ -6752,6 +6756,7 @@ B60DFF072872B64B0061E7C2 /* JSAlertController.swift in Sources */, 981FED6E22025151008488D7 /* BlankSnapshotViewController.swift in Sources */, 98F3A1DC217B373E0011A0D4 /* DarkTheme.swift in Sources */, + D66F683D2BB333C100AE93E2 /* SubscriptionContainerView.swift in Sources */, 851B128822200575004781BC /* Onboarding.swift in Sources */, 3151F0EE2735800800226F58 /* VoiceSearchFeedbackView.swift in Sources */, 857EEB752095FFAC008A005C /* HomeRowInstructionsViewController.swift in Sources */, @@ -6783,7 +6788,6 @@ 31C70B5528045E3500FB6AD1 /* SecureVaultErrorReporter.swift in Sources */, F4CE6D1B257EA33C00D0A6AA /* FireButtonAnimator.swift in Sources */, 85582E0029D7409700E9AE35 /* SyncSettingsViewController+PDFRendering.swift in Sources */, - D69DBB502B72B1D300156310 /* View+TopMostController.swift in Sources */, EE0153EF2A70021E002A8B26 /* NetworkProtectionInviteView.swift in Sources */, 9888F77B2224980500C46159 /* FeedbackViewController.swift in Sources */, D6E83C662B23936F006C8AFB /* SettingsDebugView.swift in Sources */, @@ -6913,6 +6917,7 @@ 984D035C24AE15CD0066CFB8 /* TabSwitcherSettings.swift in Sources */, D6E83C562B21ECC1006C8AFB /* SettingsLegacyViewProvider.swift in Sources */, 98B31292218CCB8C00E54DE1 /* AppDependencyProvider.swift in Sources */, + D670E5BD2BB6AA0000941A42 /* View+AppearModifiers.swift in Sources */, C13F3F6A2B7F883A0083BE40 /* AuthConfirmationPromptViewController.swift in Sources */, 02C57C4B2514FEFB009E5129 /* DoNotSellSettingsViewController.swift in Sources */, 02A54A9C2A097C95000C8FED /* AppTPHomeViewSectionRenderer.swift in Sources */, @@ -6970,7 +6975,6 @@ D6E83C682B23B6A3006C8AFB /* FontSettings.swift in Sources */, 31EF52E1281B3BDC0034796E /* AutofillLoginListItemViewModel.swift in Sources */, 1E4FAA6627D8DFC800ADC5B3 /* CompleteDownloadRowViewModel.swift in Sources */, - D6D95CE12B6D52DA00960317 /* RootPresentationMode.swift in Sources */, 83004E862193E5ED00DA013C /* TabViewControllerBrowsingMenuExtension.swift in Sources */, EE01EB402AFBD0000096AAC9 /* NetworkProtectionVPNSettingsViewModel.swift in Sources */, EE72CA852A862D000043B5B3 /* NetworkProtectionDebugViewController.swift in Sources */, diff --git a/DuckDuckGo/SettingsSubscriptionView.swift b/DuckDuckGo/SettingsSubscriptionView.swift index d032e18e81..df5b603b87 100644 --- a/DuckDuckGo/SettingsSubscriptionView.swift +++ b/DuckDuckGo/SettingsSubscriptionView.swift @@ -22,16 +22,16 @@ import UIKit #if SUBSCRIPTION import Subscription +import Core @available(iOS 15.0, *) struct SettingsSubscriptionView: View { @EnvironmentObject var viewModel: SettingsViewModel - @StateObject var subscriptionFlowViewModel = SubscriptionFlowViewModel() - @StateObject var subscriptionRestoreViewModel = SubscriptionRestoreViewModel() - @State var isShowingSubscriptionFlow = false - @State var isShowingSubscriptionRestoreFlow = false + @EnvironmentObject var subscriptionNavigationCoordinator: SubscriptionNavigationCoordinator @State var isShowingDBP = false @State var isShowingITP = false + @State var isShowingRestoreFlow = false + @State var isShowingSubscribeFlow = false enum Constants { static let purchaseDescriptionPadding = 5.0 @@ -51,18 +51,6 @@ struct SettingsSubscriptionView: View { } } - private var learnMoreView: some View { - Text(UserText.settingsPProLearnMore) - .daxBodyRegular() - .foregroundColor(Color.init(designSystemColor: .accent)) - } - - private var iHaveASubscriptionView: some View { - Text(UserText.settingsPProIHaveASubscription) - .daxBodyRegular() - .foregroundColor(Color.init(designSystemColor: .accent)) - } - @ViewBuilder private var restorePurchaseView: some View { let text = !viewModel.isRestoringSubscription ? UserText.subscriptionActivateAppleIDButton : UserText.subscriptionRestoringTitle @@ -90,31 +78,27 @@ struct SettingsSubscriptionView: View { @ViewBuilder private var purchaseSubscriptionView: some View { + Group { SettingsCustomCell(content: { subscriptionDescriptionView }) - SettingsCustomCell(content: { learnMoreView }, - action: { isShowingSubscriptionFlow = true }, - isButton: true ) - // Subscription Purchase - .sheet(isPresented: $isShowingSubscriptionFlow, - onDismiss: { Task { viewModel.onAppear() } }, - content: { - SubscriptionFlowView(viewModel: subscriptionFlowViewModel).interactiveDismissDisabled() - }) - - SettingsCustomCell(content: { iHaveASubscriptionView }, - action: { - isShowingSubscriptionRestoreFlow = true - }, - isButton: true ) + let subscribeView = SubscriptionContainerView(currentView: .subscribe) + .navigationViewStyle(.stack) + .environmentObject(subscriptionNavigationCoordinator) + let restoreView = SubscriptionContainerView(currentView: .restore) + .navigationViewStyle(.stack) + .environmentObject(subscriptionNavigationCoordinator) + .onFirstAppear { + Pixel.fire(pixel: .privacyProRestorePurchaseClick) + } + + NavigationLink(destination: subscribeView, + isActive: $isShowingSubscribeFlow, + label: { SettingsCellView(label: UserText.settingsPProLearnMore ) }) - // Subscription Restore - .sheet(isPresented: $isShowingSubscriptionRestoreFlow, - onDismiss: { Task { viewModel.onAppear() } }, - content: { - SubscriptionRestoreView(viewModel: subscriptionRestoreViewModel).interactiveDismissDisabled() - }) + NavigationLink(destination: restoreView, + isActive: $isShowingRestoreFlow, + label: { SettingsCellView(label: UserText.settingsPProIHaveASubscription ) }) } } @@ -139,7 +123,7 @@ struct SettingsSubscriptionView: View { @ViewBuilder private var subscriptionDetailsView: some View { - Group { + if viewModel.shouldShowNetP { SettingsCellView(label: UserText.settingsPProVPNTitle, subtitle: viewModel.state.networkProtection.status != "" ? viewModel.state.networkProtection.status : nil, @@ -149,31 +133,27 @@ struct SettingsSubscriptionView: View { } if viewModel.shouldShowDBP { - SettingsCellView(label: UserText.settingsPProDBPTitle, - subtitle: UserText.settingsPProDBPSubTitle, - action: { isShowingDBP.toggle() }, isButton: true) - - .sheet(isPresented: $isShowingDBP) { - SubscriptionPIRView() - } + NavigationLink(destination: SubscriptionPIRView(), + isActive: $isShowingDBP, + label: { + SettingsCellView(label: UserText.settingsPProDBPTitle, + subtitle: UserText.settingsPProDBPSubTitle) + }) } - + if viewModel.shouldShowITP { - SettingsCellView(label: UserText.settingsPProITRTitle, - subtitle: UserText.settingsPProITRSubTitle, - action: { isShowingITP.toggle() }, isButton: true) - - .sheet(isPresented: $isShowingITP) { - SubscriptionITPView() - } + NavigationLink(destination: SubscriptionITPView(), + isActive: $isShowingITP, + label: { + SettingsCellView(label: UserText.settingsPProITRTitle, + subtitle: UserText.settingsPProITRSubTitle) + }) } - NavigationLink(destination: SubscriptionSettingsView()) { + NavigationLink(destination: SubscriptionSettingsView().environmentObject(subscriptionNavigationCoordinator)) { SettingsCustomCell(content: { manageSubscriptionView }) - } - } } @@ -182,50 +162,30 @@ struct SettingsSubscriptionView: View { if viewModel.state.subscription.enabled && viewModel.state.subscription.canPurchase { Section(header: Text(UserText.settingsPProSection)) { if viewModel.state.subscription.hasActiveSubscription { - - if !viewModel.isLoadingSubscriptionState { - // Allow managing the subscription if we have some entitlements - if viewModel.shouldShowDBP || viewModel.shouldShowITP || viewModel.shouldShowNetP { - subscriptionDetailsView - - // If no entitlements it should mean the backend is still out of sync - } else { - noEntitlementsAvailableView - } + // Allow managing the subscription if we have some entitlements + if viewModel.shouldShowDBP || viewModel.shouldShowITP || viewModel.shouldShowNetP { + subscriptionDetailsView + + // If no entitlements it should mean the backend is still out of sync + } else { + noEntitlementsAvailableView } + } else if viewModel.state.subscription.isSubscriptionPendingActivation { noEntitlementsAvailableView } else { purchaseSubscriptionView } } - - // Selected Feature handler for Subscription Flow - .onChange(of: subscriptionFlowViewModel.selectedFeature) { value in - guard let value else { return } - viewModel.triggerDeepLinkNavigation(to: value) - } - - // Selected Feature handler for Subscription Restore - .onChange(of: subscriptionRestoreViewModel.emailViewModel.selectedFeature) { value in - guard let value else { return } - viewModel.triggerDeepLinkNavigation(to: value) - } - // Selected Feature handler for SubscriptionActivation - .onChange(of: subscriptionFlowViewModel.state.shouldActivateSubscription) { value in - if value { - viewModel.triggerDeepLinkNavigation(to: .subscriptionRestoreFlow) + .onReceive(subscriptionNavigationCoordinator.$shouldPopToAppSettings) { shouldDismiss in + if shouldDismiss { + isShowingRestoreFlow = false + isShowingSubscribeFlow = false } } - - // Selected Feature handler for Show Plans - .onChange(of: subscriptionRestoreViewModel.state.shouldShowPlans) { value in - if value { - viewModel.triggerDeepLinkNavigation(to: .subscriptionFlow) - } - } + } } } diff --git a/DuckDuckGo/SettingsView.swift b/DuckDuckGo/SettingsView.swift index d3f1a90a99..5d4d6d11db 100644 --- a/DuckDuckGo/SettingsView.swift +++ b/DuckDuckGo/SettingsView.swift @@ -21,11 +21,12 @@ import SwiftUI import UIKit import DesignResourcesKit + struct SettingsView: View { @StateObject var viewModel: SettingsViewModel @Environment(\.presentationMode) var presentationMode - + @State private var subscriptionNavigationCoordinator = SubscriptionNavigationCoordinator() @State private var shouldDisplayDeepLinkSheet: Bool = false @State private var shouldDisplayDeepLinkPush: Bool = false #if SUBSCRIPTION @@ -56,7 +57,7 @@ struct SettingsView: View { SettingsPrivacyView() #if SUBSCRIPTION if #available(iOS 15, *) { - SettingsSubscriptionView() + SettingsSubscriptionView().environmentObject(subscriptionNavigationCoordinator) } #endif SettingsCustomizeView() @@ -109,7 +110,7 @@ struct SettingsView: View { DispatchQueue.main.async { self.shouldDisplayDeepLinkSheet = true } - case .navigation: + case .navigationLink: DispatchQueue.main.async { self.shouldDisplayDeepLinkPush = true } @@ -133,9 +134,9 @@ struct SettingsView: View { case .itr: SubscriptionITPView() case .subscriptionFlow: - SubscriptionFlowView() + SubscriptionContainerView(currentView: .subscribe).environmentObject(subscriptionNavigationCoordinator) case .subscriptionRestoreFlow: - SubscriptionRestoreView() + SubscriptionContainerView(currentView: .restore).environmentObject(subscriptionNavigationCoordinator) default: EmptyView() } diff --git a/DuckDuckGo/SettingsViewModel.swift b/DuckDuckGo/SettingsViewModel.swift index 2f877dd1a9..2ef9137929 100644 --- a/DuckDuckGo/SettingsViewModel.swift +++ b/DuckDuckGo/SettingsViewModel.swift @@ -55,11 +55,15 @@ final class SettingsViewModel: ObservableObject { private var isPrivacyProEnabled: Bool { AppDependencyProvider.shared.subscriptionFeatureAvailability.isFeatureAvailable } + // Cache subscription state in memory to prevent UI glitches + private var cacheSubscriptionState: SettingsState.Subscription = SettingsState.Subscription(enabled: false, + canPurchase: false, + hasActiveSubscription: false, + isSubscriptionPendingActivation: false) // Sheet Presentation & Navigation @Published var isRestoringSubscription: Bool = false @Published var shouldDisplayRestoreSubscriptionError: Bool = false - @Published var isLoadingSubscriptionState: Bool = false @Published var shouldShowNetP = false @Published var shouldShowDBP = false @Published var shouldShowITP = false @@ -266,8 +270,8 @@ extension SettingsViewModel { // other dependencies are observable (Such as AppIcon and netP) // and we can use subscribers (Currently called from the view onAppear) @MainActor - private func initState() async { - self.state = await SettingsState( + private func initState() { + self.state = SettingsState( appTheme: appSettings.currentThemeName, appIcon: AppIconManager.shared.appIcon, fireButtonAnimation: appSettings.currentFireButtonAnimation, @@ -288,11 +292,12 @@ extension SettingsViewModel { speechRecognitionAvailable: AppDependencyProvider.shared.voiceSearchHelper.isSpeechRecognizerAvailable, loginsEnabled: featureFlagger.isFeatureOn(.autofillAccessCredentialManagement), networkProtection: getNetworkProtectionState(), - subscription: getSubscriptionState(), + subscription: cacheSubscriptionState, sync: getSyncState() ) setupSubscribers() + Task { await refreshSubscriptionState() } } @@ -305,6 +310,13 @@ extension SettingsViewModel { #endif return SettingsState.NetworkProtection(enabled: enabled, status: "") } + + private func refreshSubscriptionState() async { + let state = await self.getSubscriptionState() + DispatchQueue.main.async { + self.state.subscription = state + } + } private func getSubscriptionState() async -> SettingsState.Subscription { var enabled = false @@ -322,6 +334,12 @@ extension SettingsViewModel { switch subscriptionResult { case .success(let subscription): hasActiveSubscription = subscription.isActive + + cacheSubscriptionState = SettingsState.Subscription(enabled: enabled, + canPurchase: canPurchase, + hasActiveSubscription: hasActiveSubscription, + isSubscriptionPendingActivation: isSubscriptionPendingActivation) + case .failure: if await PurchaseManager.hasActiveSubscription() { isSubscriptionPendingActivation = true @@ -376,8 +394,7 @@ extension SettingsViewModel { setupSubscriptionPurchaseOptions() return } - - isLoadingSubscriptionState = true + // Fetch available subscriptions from the backend (or sign out) switch await SubscriptionService.getSubscription(accessToken: token) { @@ -418,7 +435,7 @@ extension SettingsViewModel { signOutUser() } } - isLoadingSubscriptionState = false + } @available(iOS 15.0, *) @@ -440,7 +457,7 @@ extension SettingsViewModel { signOutObserver = NotificationCenter.default.addObserver(forName: .accountDidSignOut, object: nil, queue: .main) { [weak self] _ in if #available(iOS 15.0, *) { guard let strongSelf = self else { return } - Task { await strongSelf.setupSubscriptionEnvironment() } + Task { await strongSelf.refreshSubscriptionState() } } } } @@ -657,11 +674,11 @@ extension SettingsViewModel { // Specify cases that require .push presentation // Example: // case .dbp: - // return .push + // return .sheet case .netP: return .UIKitView default: - return .sheet + return .navigationLink } } } @@ -669,7 +686,7 @@ extension SettingsViewModel { // Define DeepLinkType outside the enum if not already defined enum DeepLinkType { case sheet - case navigation + case navigationLink case UIKitView } diff --git a/DuckDuckGo/Subscription/Extensions/View+AppearModifiers.swift b/DuckDuckGo/Subscription/Extensions/View+AppearModifiers.swift new file mode 100644 index 0000000000..df8ce7bda2 --- /dev/null +++ b/DuckDuckGo/Subscription/Extensions/View+AppearModifiers.swift @@ -0,0 +1,70 @@ +// +// View+AppearModifiers.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +public struct OnFirstAppearModifier: ViewModifier { + + private let onFirstAppearAction: () -> Void + @State private var hasAppeared = false + + public init(_ onFirstAppearAction: @escaping () -> Void) { + self.onFirstAppearAction = onFirstAppearAction + } + + public func body(content: Content) -> some View { + content + .onAppear { + guard !hasAppeared else { return } + hasAppeared = true + onFirstAppearAction() + } + } +} + +public struct OnFirstDisappearModifier: ViewModifier { + + private let onFirstDisappearAction: () -> Void + @State private var hasDisappeared = false + + public init(_ onFirstDisappearAction: @escaping () -> Void) { + self.onFirstDisappearAction = onFirstDisappearAction + } + + public func body(content: Content) -> some View { + content + .onDisappear { + guard !hasDisappeared else { return } + hasDisappeared = true + onFirstDisappearAction() + } + } +} + +extension View { + + func onFirstAppear(_ onFirstAppearAction: @escaping () -> Void ) -> some View { + return modifier(OnFirstAppearModifier(onFirstAppearAction)) + } + + func onFirstDisappear(_ onFirstDisappearAction: @escaping () -> Void ) -> some View { + return modifier(OnFirstDisappearModifier(onFirstDisappearAction)) + } + +} diff --git a/DuckDuckGo/Subscription/Extensions/View+TopMostController.swift b/DuckDuckGo/Subscription/Extensions/View+TopMostController.swift deleted file mode 100644 index 2d26949767..0000000000 --- a/DuckDuckGo/Subscription/Extensions/View+TopMostController.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// View+TopMostController.swift -// DuckDuckGo -// -// Copyright © 2024 DuckDuckGo. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import SwiftUI - -extension View { - - // Grabs the topMost controller so we can properly present sheets anywhere - func topMostViewController() -> UIViewController? { - guard let keyWindow = UIApplication.shared.connectedScenes - .filter({ $0.activationState == .foregroundActive }) - .compactMap({ $0 as? UIWindowScene }) - .first?.windows - .filter({ $0.isKeyWindow }).first else { - return nil - } - - var topController = keyWindow.rootViewController - while let presentedController = topController?.presentedViewController { - topController = presentedController - } - return topController - } -} diff --git a/DuckDuckGo/Subscription/ViewModel/SubscriptionEmailViewModel.swift b/DuckDuckGo/Subscription/ViewModel/SubscriptionEmailViewModel.swift index 8b8f283f11..dc46e7e153 100644 --- a/DuckDuckGo/Subscription/ViewModel/SubscriptionEmailViewModel.swift +++ b/DuckDuckGo/Subscription/ViewModel/SubscriptionEmailViewModel.swift @@ -37,24 +37,28 @@ final class SubscriptionEmailViewModel: ObservableObject { var viewTitle = UserText.subscriptionActivateEmailTitle var webViewModel: AsyncHeadlessWebViewViewModel + enum SelectedFeature { + case netP, dbp, itr, none + } + struct State { var subscriptionEmail: String? var managingSubscriptionEmail = false var transactionError: SubscriptionRestoreError? var shouldDisplaynavigationError: Bool = false - var shouldDisplayInactiveError: Bool = false + var isPresentingInactiveError: Bool = false var canNavigateBack: Bool = false var shouldDismissView: Bool = false - var shouldDismissStack: Bool = false var subscriptionActive: Bool = false + var backButtonTitle: String = UserText.backButtonTitle + var selectedFeature: SelectedFeature = .none + var shouldPopToSubscriptionSettings: Bool = false + var shouldPopToAppSettings: Bool = false } // Read only View State - Should only be modified from the VM @Published private(set) var state = State() - // Publish the currently selected feature - @Published var selectedFeature: SettingsViewModel.SettingsDeepLinkSection? - private static let allowedDomains = [ "duckduckgo.com" ] enum SubscriptionRestoreError: Error { @@ -65,8 +69,8 @@ final class SubscriptionEmailViewModel: ObservableObject { private var cancellables = Set() - init(userScript: SubscriptionPagesUserScript = SubscriptionPagesUserScript(), - subFeature: SubscriptionPagesUseSubscriptionFeature = SubscriptionPagesUseSubscriptionFeature(), + init(userScript: SubscriptionPagesUserScript, + subFeature: SubscriptionPagesUseSubscriptionFeature, accountManager: AccountManager = AccountManager()) { self.userScript = userScript self.subFeature = subFeature @@ -76,11 +80,6 @@ final class SubscriptionEmailViewModel: ObservableObject { settings: AsyncHeadlessWebViewSettings(bounces: false, allowedDomains: Self.allowedDomains, contentBlocking: false)) - - Task { - await initializeView() - } - setupObservers() } @MainActor @@ -88,7 +87,13 @@ final class SubscriptionEmailViewModel: ObservableObject { if state.canNavigateBack { await webViewModel.navigationCoordinator.goBack() } else { - state.shouldDismissView = true + // If not in the Welcome page, dismiss the view, otherwise, assume we + // came from Activation, so dismiss the entire stack + if webViewModel.url?.forComparison() != URL.subscriptionPurchase.forComparison() { + state.shouldDismissView = true + } else { + state.shouldPopToAppSettings = true + } } } @@ -96,19 +101,9 @@ final class SubscriptionEmailViewModel: ObservableObject { state.shouldDismissView = false } - func onAppear() { - Task { await initializeView() } - Task { await setupSubscribers() } - webViewModel.navigationCoordinator.navigateTo(url: emailURL ) - } - - func onDissappear() { - cancellables.removeAll() - canGoBackCancellable = nil - } - @MainActor - private func initializeView() { + func onFirstAppear() { + setupObservers() if accountManager.isUserAuthenticated { // If user is authenticated, we want to "Add or manage email" instead of activating emailURL = accountManager.email == nil ? URL.addEmailToSubscription : URL.manageSubscriptionEmail @@ -117,20 +112,25 @@ final class SubscriptionEmailViewModel: ObservableObject { // Also we assume subscription requires managing, and not activation state.managingSubscriptionEmail = true } + if webViewModel.url?.forComparison() != URL.subscriptionActivateSuccess { + self.webViewModel.navigationCoordinator.navigateTo(url: self.emailURL) + } } - private func setupSubscribers() async { + func onFirstDisappear() { + cancellables.removeAll() + canGoBackCancellable = nil + } + + private func setupObservers() { + + // Webview navigation canGoBackCancellable = webViewModel.$canGoBack .receive(on: DispatchQueue.main) .sink { [weak self] value in - self?.state.canNavigateBack = false - if self?.webViewModel.url != URL.activateSubscriptionViaEmail.forComparison() { - self?.state.canNavigateBack = value - } + self?.updateBackButton(canNavigateBack: value) } - } - - private func setupObservers() { + // Feature Callback subFeature.onSetSubscription = { DailyPixel.fireDailyAndCount(pixel: .privacyProRestorePurchaseEmailSuccess) @@ -138,10 +138,11 @@ final class SubscriptionEmailViewModel: ObservableObject { DispatchQueue.main.async { self.state.subscriptionActive = true } + self.dismissStack() } subFeature.onBackToSettings = { - self.dismissView() + self.dismissStack() } subFeature.onFeatureSelected = { feature in @@ -149,19 +150,13 @@ final class SubscriptionEmailViewModel: ObservableObject { switch feature { case .netP: UniquePixel.fire(pixel: .privacyProWelcomeVPN) - self.selectedFeature = .netP + self.state.selectedFeature = .netP case .itr: UniquePixel.fire(pixel: .privacyProWelcomePersonalInformationRemoval) - self.selectedFeature = .itr + self.state.selectedFeature = .itr case .dbp: UniquePixel.fire(pixel: .privacyProWelcomeIdentityRestoration) - self.selectedFeature = .dbp - } - self.state.shouldDismissStack = true - - // Reset shouldDismissStack after dismissal to ensure it can be triggered again - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.state.shouldDismissStack = false + self.state.selectedFeature = .dbp } } @@ -177,7 +172,7 @@ final class SubscriptionEmailViewModel: ObservableObject { } } .store(in: &cancellables) - + webViewModel.$navigationError .receive(on: DispatchQueue.main) .sink { [weak self] error in @@ -190,14 +185,21 @@ final class SubscriptionEmailViewModel: ObservableObject { .store(in: &cancellables) } - func shouldDisplayBackButton() -> Bool { - // Hide the back button after activation - if state.subscriptionActive && - (webViewModel.url == URL.subscriptionActivateSuccess.forComparison() || - webViewModel.url == URL.subscriptionPurchase.forComparison()) { - return false + func updateBackButton(canNavigateBack: Bool) { + + // Disable Browser navigation by default + self.state.canNavigateBack = false + + // If the view is not Activation Success, or Welcome page, allow WebView Back Navigation + if self.webViewModel.url?.forComparison() != URL.subscriptionActivateSuccess.forComparison() && + self.webViewModel.url?.forComparison() != URL.subscriptionPurchase.forComparison() { + self.state.canNavigateBack = canNavigateBack + self.state.backButtonTitle = UserText.backButtonTitle + } else { + self.state.backButtonTitle = UserText.settingsTitle } - return true + + } // MARK: - @@ -210,7 +212,7 @@ final class SubscriptionEmailViewModel: ObservableObject { default: state.transactionError = .generalError } - state.shouldDisplayInactiveError = true + state.isPresentingInactiveError = true } func dismissView() { @@ -219,6 +221,12 @@ final class SubscriptionEmailViewModel: ObservableObject { } } + func dismissStack() { + DispatchQueue.main.async { + self.state.shouldPopToSubscriptionSettings = true + } + } + deinit { cancellables.removeAll() canGoBackCancellable = nil diff --git a/DuckDuckGo/Subscription/ViewModel/SubscriptionExternalLinkViewModel.swift b/DuckDuckGo/Subscription/ViewModel/SubscriptionExternalLinkViewModel.swift index fd1c2557a7..f2f5feb45d 100644 --- a/DuckDuckGo/Subscription/ViewModel/SubscriptionExternalLinkViewModel.swift +++ b/DuckDuckGo/Subscription/ViewModel/SubscriptionExternalLinkViewModel.swift @@ -53,7 +53,7 @@ final class SubscriptionExternalLinkViewModel: ObservableObject { } } - func initializeView() { + func onFirstAppear() { Task { await setupSubscribers() } webViewModel.navigationCoordinator.navigateTo(url: url) diff --git a/DuckDuckGo/Subscription/ViewModel/SubscriptionFlowViewModel.swift b/DuckDuckGo/Subscription/ViewModel/SubscriptionFlowViewModel.swift index 8c61b4f8cb..518a9cd0e9 100644 --- a/DuckDuckGo/Subscription/ViewModel/SubscriptionFlowViewModel.swift +++ b/DuckDuckGo/Subscription/ViewModel/SubscriptionFlowViewModel.swift @@ -43,20 +43,20 @@ final class SubscriptionFlowViewModel: ObservableObject { static let navigationBarHideThreshold = 80.0 } - + enum SelectedFeature { + case netP, dbp, itr, none + } + struct State { var hasActiveSubscription = false var transactionStatus: SubscriptionTransactionStatus = .idle var userTappedRestoreButton = false - var shouldDismissView = false var shouldActivateSubscription = false - var shouldShowNavigationBar: Bool = false var canNavigateBack: Bool = false var transactionError: SubscriptionPurchaseError? + var shouldHideBackButton = false + var selectedFeature: SelectedFeature = .none } - - // Publish the currently selected feature - @Published var selectedFeature: SettingsViewModel.SettingsDeepLinkSection? // Read only View State - Should only be modified from the VM @Published private(set) var state = State() @@ -67,14 +67,13 @@ final class SubscriptionFlowViewModel: ObservableObject { allowedDomains: allowedDomains, contentBlocking: false) - init(userScript: SubscriptionPagesUserScript = SubscriptionPagesUserScript(), - subFeature: SubscriptionPagesUseSubscriptionFeature = SubscriptionPagesUseSubscriptionFeature(), + init(userScript: SubscriptionPagesUserScript, + subFeature: SubscriptionPagesUseSubscriptionFeature, purchaseManager: PurchaseManager = PurchaseManager.shared, selectedFeature: SettingsViewModel.SettingsDeepLinkSection? = nil) { self.userScript = userScript self.subFeature = subFeature self.purchaseManager = purchaseManager - self.selectedFeature = selectedFeature self.webViewModel = AsyncHeadlessWebViewViewModel(userScript: userScript, subFeature: subFeature, settings: webViewSettings) @@ -100,9 +99,7 @@ final class SubscriptionFlowViewModel: ObservableObject { subFeature.onActivateSubscription = { DispatchQueue.main.async { - self.state.shouldDismissView = true self.state.shouldActivateSubscription = true - } } @@ -111,15 +108,14 @@ final class SubscriptionFlowViewModel: ObservableObject { switch feature { case .netP: UniquePixel.fire(pixel: .privacyProWelcomeVPN) - self.selectedFeature = .netP - case .itr: - UniquePixel.fire(pixel: .privacyProWelcomePersonalInformationRemoval) - self.selectedFeature = .itr + self.state.selectedFeature = .netP case .dbp: + UniquePixel.fire(pixel: .privacyProWelcomePersonalInformationRemoval) + self.state.selectedFeature = .dbp + case .itr: UniquePixel.fire(pixel: .privacyProWelcomeIdentityRestoration) - self.selectedFeature = .dbp + self.state.selectedFeature = .itr } - self.state.shouldDismissView = true } } @@ -149,7 +145,6 @@ final class SubscriptionFlowViewModel: ObservableObject { state.transactionError = .purchaseFailed case .missingEntitlements: isBackendError = true - state.shouldDismissView = true state.transactionError = .missingEntitlements case .failedToGetSubscriptionOptions: isStoreError = true @@ -199,16 +194,6 @@ final class SubscriptionFlowViewModel: ObservableObject { // swiftlint:enable cyclomatic_complexity private func setupWebViewObservers() async { - webViewModel.$scrollPosition - .receive(on: DispatchQueue.main) - .sink { [weak self] value in - guard let strongSelf = self else { return } - DispatchQueue.main.async { - strongSelf.state.shouldShowNavigationBar = value.y > Constants.navigationBarHideThreshold - } - } - .store(in: &cancellables) - webViewModel.$navigationError .receive(on: DispatchQueue.main) .sink { [weak self] error in @@ -235,9 +220,25 @@ final class SubscriptionFlowViewModel: ObservableObject { } private func backButtonForURL(currentURL: URL) -> Bool { - return currentURL != URL.subscriptionBaseURL.forComparison() && - currentURL != URL.subscriptionActivateSuccess.forComparison() && - currentURL != URL.subscriptionPurchase.forComparison() + print(currentURL) + return currentURL.forComparison() != URL.subscriptionBaseURL.forComparison() && + currentURL.forComparison() != URL.subscriptionActivateSuccess.forComparison() && + currentURL.forComparison() != URL.subscriptionPurchase.forComparison() + } + + private func cleanUp() { + canGoBackCancellable?.cancel() + subFeature.cleanup() + cancellables.removeAll() + } + + @MainActor + func resetState() { + self.state = State() + } + + deinit { + cleanUp() } @MainActor @@ -249,41 +250,23 @@ final class SubscriptionFlowViewModel: ObservableObject { private func backButtonEnabled(_ enabled: Bool) { state.canNavigateBack = enabled } + + // MARK: - - @MainActor - func onAppear() async { + func onFirstAppear() async { DispatchQueue.main.async { self.resetState() - self.webViewModel.navigationCoordinator.navigateTo(url: self.purchaseURL ) } - Pixel.fire(pixel: .privacyProOfferScreenImpression, debounce: 2) await self.setupTransactionObserver() await self .setupWebViewObservers() - } - - func onDisappear() { - DispatchQueue.main.async { - self.resetState() + if webViewModel.url == nil { + self.webViewModel.navigationCoordinator.navigateTo(url: self.purchaseURL) } - canGoBackCancellable?.cancel() - cancellables.removeAll() - } - - @MainActor - private func resetState() { - self.state = State() + Pixel.fire(pixel: .privacyProOfferScreenImpression) } - - @MainActor - func finalizeSubscriptionFlow() { - self.state.shouldDismissView = true - } - - deinit { - canGoBackCancellable?.cancel() - selectedFeature = nil - subFeature.cleanup() - cancellables.removeAll() + + func onFirstDisappear() async { + cleanUp() } @MainActor @@ -312,6 +295,6 @@ final class SubscriptionFlowViewModel: ObservableObject { func clearTransactionError() { state.transactionError = nil } - + } #endif diff --git a/DuckDuckGo/Subscription/ViewModel/SubscriptionITPViewModel.swift b/DuckDuckGo/Subscription/ViewModel/SubscriptionITPViewModel.swift index 99694a5468..6efa74f56d 100644 --- a/DuckDuckGo/Subscription/ViewModel/SubscriptionITPViewModel.swift +++ b/DuckDuckGo/Subscription/ViewModel/SubscriptionITPViewModel.swift @@ -30,10 +30,9 @@ final class SubscriptionITPViewModel: ObservableObject { var userScript: IdentityTheftRestorationPagesUserScript? var subFeature: IdentityTheftRestorationPagesFeature? var manageITPURL = URL.identityTheftRestoration - var viewTitle = UserText.subscriptionTitle + var viewTitle = UserText.settingsPProITRTitle enum Constants { - static let navigationBarHideThreshold = 15.0 static let downloadableContent = ["application/pdf"] static let blankURL = "about:blank" static let externalSchemes = ["tel", "sms", "facetime"] @@ -41,7 +40,6 @@ final class SubscriptionITPViewModel: ObservableObject { // State variables var itpURL = URL.identityTheftRestoration - @Published var shouldShowNavigationBar: Bool = false @Published var canNavigateBack: Bool = false @Published var isDownloadableContent: Bool = false @Published var activityItems: [Any] = [] @@ -55,11 +53,7 @@ final class SubscriptionITPViewModel: ObservableObject { } private var currentURL: URL? - private static let allowedDomains = [ - "duckduckgo.com", - "microsoftonline.com", - "duosecurity.com", - ] + private static let allowedDomains = [ "duckduckgo.com" ] private var externalLinksViewModel: SubscriptionExternalLinkViewModel? // Limit navigation to these external domains @@ -81,8 +75,7 @@ final class SubscriptionITPViewModel: ObservableObject { subFeature: subFeature, settings: webViewSettings) } - - // swiftlint:disable function_body_length + private func setupSubscribers() async { webViewModel.$navigationError @@ -96,14 +89,6 @@ final class SubscriptionITPViewModel: ObservableObject { } .store(in: &cancellables) - webViewModel.$scrollPosition - .receive(on: DispatchQueue.main) - .throttle(for: .milliseconds(100), scheduler: DispatchQueue.main, latest: true) - .sink { [weak self] value in - self?.shouldShowNavigationBar = (value.y > Constants.navigationBarHideThreshold) - } - .store(in: &cancellables) - webViewModel.$contentType .receive(on: DispatchQueue.main) .sink { [weak self] value in @@ -146,9 +131,9 @@ final class SubscriptionITPViewModel: ObservableObject { self?.canNavigateBack = value } } - // swiftlint:enable function_body_length + - func initializeView() { + func onFirstAppear() { webViewModel.navigationCoordinator.navigateTo(url: manageITPURL ) Task { await setupSubscribers() } Pixel.fire(pixel: .privacyProIdentityRestorationSettings) diff --git a/DuckDuckGo/Subscription/ViewModel/SubscriptionPIRViewModel.swift b/DuckDuckGo/Subscription/ViewModel/SubscriptionPIRViewModel.swift index 75f6cf72ea..658dc2fff4 100644 --- a/DuckDuckGo/Subscription/ViewModel/SubscriptionPIRViewModel.swift +++ b/DuckDuckGo/Subscription/ViewModel/SubscriptionPIRViewModel.swift @@ -24,9 +24,9 @@ import Core @available(iOS 15.0, *) final class SubscriptionPIRViewModel: ObservableObject { - var viewTitle = UserText.subscriptionTitle + var viewTitle = UserText.settingsPProDBPTitle - func onAppear() { + func onFirstAppear() { Pixel.fire(pixel: .privacyProPersonalInformationRemovalSettings) } } diff --git a/DuckDuckGo/Subscription/ViewModel/SubscriptionRestoreViewModel.swift b/DuckDuckGo/Subscription/ViewModel/SubscriptionRestoreViewModel.swift index 8a1678c8f9..3a56f4cc29 100644 --- a/DuckDuckGo/Subscription/ViewModel/SubscriptionRestoreViewModel.swift +++ b/DuckDuckGo/Subscription/ViewModel/SubscriptionRestoreViewModel.swift @@ -43,14 +43,12 @@ final class SubscriptionRestoreViewModel: ObservableObject { var transactionStatus: SubscriptionTransactionStatus = .idle var activationResult: SubscriptionActivationResult = .unknown var subscriptionEmail: String? - var shouldShowWelcomePage = false - var shouldNavigateToActivationFlow = false + var isShowingWelcomePage = false + var isShowingActivationFlow = false var shouldShowPlans = false var shouldDismissView = false - - var viewTitle: String { - isAddingDevice ? UserText.subscriptionAddDeviceTitle : UserText.subscriptionActivate - } + var isLoading = false + var viewTitle: String = "" } // Publish the currently selected feature @@ -58,12 +56,9 @@ final class SubscriptionRestoreViewModel: ObservableObject { // Read only View State - Should only be modified from the VM @Published private(set) var state = State() - - // Email View Model - var emailViewModel = SubscriptionEmailViewModel() - init(userScript: SubscriptionPagesUserScript = SubscriptionPagesUserScript(), - subFeature: SubscriptionPagesUseSubscriptionFeature = SubscriptionPagesUseSubscriptionFeature(), + init(userScript: SubscriptionPagesUserScript, + subFeature: SubscriptionPagesUseSubscriptionFeature, purchaseManager: PurchaseManager = PurchaseManager.shared, accountManager: AccountManager = AccountManager(), isAddingDevice: Bool = false) { @@ -74,36 +69,56 @@ final class SubscriptionRestoreViewModel: ObservableObject { self.state.isAddingDevice = false } - func initializeView() { - Task { await setupTransactionObserver() } + func onFirstAppear() async { + DispatchQueue.main.async { + self.resetState() + } + await setupContent() + await setupTransactionObserver() + } + + func onFirstDisappear() async { + cleanUp() } - @MainActor - func onAppear() { - resetState() - Task { + private func cleanUp() { + cancellables.removeAll() + } + + private func setupContent() async { + if state.isAddingDevice { + DispatchQueue.main.async { + self.state.isLoading = true + } + Pixel.fire(pixel: .privacyProSettingsAddDevice) guard let token = accountManager.accessToken else { return } - if case .success(let details) = await accountManager.fetchAccountDetails(with: token) { + 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: + state.isLoading = false + } + } else { + DispatchQueue.main.async { + self.state.viewTitle = UserText.subscriptionActivate } } - } @MainActor private func resetState() { - state.subscriptionEmail = accountManager.email - state.isAddingDevice = false if accountManager.isUserAuthenticated { state.isAddingDevice = true } - state.shouldNavigateToActivationFlow = false + state.isShowingActivationFlow = false state.shouldShowPlans = false - state.shouldShowWelcomePage = false + state.isShowingWelcomePage = false state.shouldDismissView = false } @@ -169,14 +184,13 @@ final class SubscriptionRestoreViewModel: ObservableObject { @MainActor func showActivationFlow(_ visible: Bool) { if visible != state.shouldDismissView { - self.state.shouldNavigateToActivationFlow = visible + self.state.isShowingActivationFlow = visible } } @MainActor func showPlans() { state.shouldShowPlans = true - state.shouldDismissView = true } @MainActor diff --git a/DuckDuckGo/Subscription/ViewModel/SubscriptionSettingsViewModel.swift b/DuckDuckGo/Subscription/ViewModel/SubscriptionSettingsViewModel.swift index 9b306c7bf3..d7d3134d2c 100644 --- a/DuckDuckGo/Subscription/ViewModel/SubscriptionSettingsViewModel.swift +++ b/DuckDuckGo/Subscription/ViewModel/SubscriptionSettingsViewModel.swift @@ -37,14 +37,14 @@ final class SubscriptionSettingsViewModel: ObservableObject { struct State { var subscriptionDetails: String = "" var subscriptionType: String = "" - var shouldDisplayRemovalNotice: Bool = false + var isShowingRemovalNotice: Bool = false var shouldDismissView: Bool = false - var shouldDisplayGoogleView: Bool = false - var shouldDisplayFAQView: Bool = false + var isShowingGoogleView: Bool = false + var isShowingFAQView: Bool = false // Used to display stripe WebUI var stripeViewModel: SubscriptionExternalLinkViewModel? - var shouldDisplayStripeView: Bool = false + var isShowingStripeView: Bool = false // Used to display the FAQ WebUI var FAQViewModel: SubscriptionExternalLinkViewModel = SubscriptionExternalLinkViewModel(url: URL.subscriptionFAQ) @@ -59,7 +59,6 @@ final class SubscriptionSettingsViewModel: ObservableObject { init(accountManager: AccountManager = AccountManager()) { self.accountManager = accountManager - Task { await fetchAndUpdateSubscriptionDetails() } setupSubscriptionUpdater() setupNotificationObservers() } @@ -70,21 +69,26 @@ final class SubscriptionSettingsViewModel: ObservableObject { return formatter }() - @MainActor - func fetchAndUpdateSubscriptionDetails(cachePolicy: SubscriptionService.CachePolicy = .returnCacheDataElseLoad) { + func onFirstAppear() { + fetchAndUpdateSubscriptionDetails() + } + + private func fetchAndUpdateSubscriptionDetails(cachePolicy: SubscriptionService.CachePolicy = .returnCacheDataElseLoad) { Task { guard let token = self.accountManager.accessToken else { return } let subscriptionResult = await SubscriptionService.getSubscription(accessToken: token, cachePolicy: cachePolicy) switch subscriptionResult { case .success(let subscription): subscriptionInfo = subscription - updateSubscriptionsStatusMessage(status: subscription.status, + await updateSubscriptionsStatusMessage(status: subscription.status, date: subscription.expiresOrRenewsAt, product: subscription.productId, billingPeriod: subscription.billingPeriod) case .failure: AccountManager().signOut() - state.shouldDismissView = true + DispatchQueue.main.async { + self.state.shouldDismissView = true + } } } } @@ -117,12 +121,11 @@ final class SubscriptionSettingsViewModel: ObservableObject { private func setupSubscriptionUpdater() { subscriptionUpdateTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: true) { [weak self] _ in guard let strongSelf = self else { return } - Task { - await strongSelf.fetchAndUpdateSubscriptionDetails(cachePolicy: .reloadIgnoringLocalCacheData) - } + strongSelf.fetchAndUpdateSubscriptionDetails(cachePolicy: .reloadIgnoringLocalCacheData) } } + @MainActor private func updateSubscriptionsStatusMessage(status: Subscription.Status, date: Date, product: String, billingPeriod: Subscription.BillingPeriod) { let statusString = (status == .autoRenewable) ? UserText.subscriptionRenews : UserText.subscriptionExpires state.subscriptionDetails = UserText.subscriptionInfo(status: statusString, expiration: dateFormatter.string(from: date)) @@ -137,26 +140,26 @@ final class SubscriptionSettingsViewModel: ObservableObject { } func displayGoogleView(_ value: Bool) { - if value != state.shouldDisplayGoogleView { - state.shouldDisplayGoogleView = value + if value != state.isShowingGoogleView { + state.isShowingGoogleView = value } } func displayStripeView(_ value: Bool) { - if value != state.shouldDisplayStripeView { - state.shouldDisplayStripeView = value + if value != state.isShowingStripeView { + state.isShowingStripeView = value } } func displayRemovalNotice(_ value: Bool) { - if value != state.shouldDisplayRemovalNotice { - state.shouldDisplayRemovalNotice = value + if value != state.isShowingRemovalNotice { + state.isShowingRemovalNotice = value } } func displayFAQView(_ value: Bool) { - if value != state.shouldDisplayFAQView { - state.shouldDisplayFAQView = value + if value != state.isShowingFAQView { + state.isShowingFAQView = value } } diff --git a/DuckDuckGo/Subscription/Views/RootPresentationMode.swift b/DuckDuckGo/Subscription/Views/RootPresentationMode.swift deleted file mode 100644 index e43fc4bc08..0000000000 --- a/DuckDuckGo/Subscription/Views/RootPresentationMode.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// RootPresentationMode.swift -// DuckDuckGo -// -// Copyright © 2024 DuckDuckGo. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import SwiftUI - -/* - iOS15 does not support NavigationStack navigation so this creates a 'RootPresentationMode' - environment that views can use to create a binding for dismissal of the whole stack of views - See: https://stackoverflow.com/questions/57334455/how-can-i-pop-to-the-root-view-using-swiftui - */ -struct RootPresentationModeKey: EnvironmentKey { - static let defaultValue: Binding = .constant(RootPresentationMode()) -} - -extension EnvironmentValues { - var rootPresentationMode: Binding { - get { return self[RootPresentationModeKey.self] } - set { self[RootPresentationModeKey.self] = newValue } - } -} - -typealias RootPresentationMode = Bool - -extension RootPresentationMode { - - public mutating func dismiss() { - self.toggle() - } -} diff --git a/DuckDuckGo/Subscription/Views/SubscriptionContainerView.swift b/DuckDuckGo/Subscription/Views/SubscriptionContainerView.swift new file mode 100644 index 0000000000..037e36740f --- /dev/null +++ b/DuckDuckGo/Subscription/Views/SubscriptionContainerView.swift @@ -0,0 +1,62 @@ +// +// SubscriptionContainerView.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import SwiftUI + +#if SUBSCRIPTION +@available(iOS 15.0, *) +struct SubscriptionContainerView: View { + + enum CurrentView { + case subscribe, restore + } + + @Environment(\.dismiss) var dismiss + @EnvironmentObject var subscriptionNavigationCoordinator: SubscriptionNavigationCoordinator + @State private var currentViewState: CurrentView + private let flowViewModel: SubscriptionFlowViewModel + private let restoreViewModel: SubscriptionRestoreViewModel + private let emailViewModel: SubscriptionEmailViewModel + + init(currentView: CurrentView) { + _currentViewState = State(initialValue: currentView) + + let userScript = SubscriptionPagesUserScript() + let subFeature = SubscriptionPagesUseSubscriptionFeature() + flowViewModel = SubscriptionFlowViewModel(userScript: userScript, subFeature: subFeature) + restoreViewModel = SubscriptionRestoreViewModel(userScript: userScript, subFeature: subFeature) + emailViewModel = SubscriptionEmailViewModel(userScript: userScript, subFeature: subFeature) + } + + var body: some View { + VStack { + switch currentViewState { + case .subscribe: + SubscriptionFlowView(viewModel: flowViewModel, + currentView: $currentViewState).environmentObject(subscriptionNavigationCoordinator) + case .restore: + SubscriptionRestoreView(viewModel: restoreViewModel, + emailViewModel: emailViewModel, + currentView: $currentViewState).environmentObject(subscriptionNavigationCoordinator) + } + } + } +} +#endif diff --git a/DuckDuckGo/Subscription/Views/SubscriptionEmailView.swift b/DuckDuckGo/Subscription/Views/SubscriptionEmailView.swift index 6371cad8e6..6fc62f7643 100644 --- a/DuckDuckGo/Subscription/Views/SubscriptionEmailView.swift +++ b/DuckDuckGo/Subscription/Views/SubscriptionEmailView.swift @@ -21,18 +21,21 @@ import SwiftUI import Foundation import Core +import Combine @available(iOS 15.0, *) struct SubscriptionEmailView: View { - @StateObject var viewModel = SubscriptionEmailViewModel() + @StateObject var viewModel: SubscriptionEmailViewModel + @EnvironmentObject var subscriptionNavigationCoordinator: SubscriptionNavigationCoordinator @Environment(\.dismiss) var dismiss - - @State var shouldDisplayInactiveError = false - @State var shouldDisplayNavigationError = false - @State var isModal = true - - var onDismissStack: (() -> Void)? + + @State var isPresentingInactiveError = false + @State var isPresentingNavigationError = false + @State var backButtonText = UserText.backButtonTitle + @State private var isShowingITR = false + @State private var isShowingDBP = false + @State private var isShowingNetP = false enum Constants { static let navButtonPadding: CGFloat = 20.0 @@ -40,14 +43,23 @@ struct SubscriptionEmailView: View { } var body: some View { + // Hidden Navigation Links for Onboarding sections + NavigationLink(destination: NetworkProtectionRootView(inviteCompletion: {}).navigationViewStyle(.stack), + isActive: $isShowingNetP, + label: { EmptyView() }) + NavigationLink(destination: SubscriptionITPView().navigationViewStyle(.stack), + isActive: $isShowingITR, + label: { EmptyView() }) + NavigationLink(destination: SubscriptionPIRView().navigationViewStyle(.stack), + isActive: $isShowingDBP, + label: { EmptyView() }) + baseView + .toolbar { ToolbarItemGroup(placement: .navigationBarLeading) { browserBackButton } - ToolbarItem(placement: .navigationBarTrailing) { - closeButton - } } .navigationBarTitleDisplayMode(.inline) .navigationViewStyle(.stack) @@ -55,7 +67,7 @@ struct SubscriptionEmailView: View { .tint(Color.init(designSystemColor: .textPrimary)) .accentColor(Color.init(designSystemColor: .textPrimary)) - .alert(isPresented: $shouldDisplayInactiveError) { + .alert(isPresented: $isPresentingInactiveError) { Alert( title: Text(UserText.subscriptionRestoreEmailInactiveTitle), message: Text(UserText.subscriptionRestoreEmailInactiveMessage), @@ -65,7 +77,7 @@ struct SubscriptionEmailView: View { ) } - .alert(isPresented: $shouldDisplayNavigationError) { + .alert(isPresented: $isPresentingNavigationError) { Alert( title: Text(UserText.subscriptionBackendErrorTitle), message: Text(UserText.subscriptionBackendErrorMessage), @@ -73,53 +85,58 @@ struct SubscriptionEmailView: View { viewModel.dismissView() }) } - - .onAppear { - viewModel.onAppear() - } - .onChange(of: viewModel.state.shouldDisplayInactiveError) { value in - shouldDisplayInactiveError = value + .onChange(of: viewModel.state.isPresentingInactiveError) { value in + isPresentingInactiveError = value } .onChange(of: viewModel.state.shouldDisplaynavigationError) { value in - shouldDisplayNavigationError = value - } - - .onChange(of: viewModel.state.shouldDismissStack) { _ in - onDismissStack?() + isPresentingNavigationError = value } // Observe changes to shouldDismissView .onChange(of: viewModel.state.shouldDismissView) { shouldDismiss in if shouldDismiss { dismiss() - - // Reset shouldDismissView after dismissal to ensure it can trigger again - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - viewModel.resetDismissalState() - } + } + } + + .onChange(of: viewModel.state.shouldPopToSubscriptionSettings) { shouldDismiss in + if shouldDismiss { + subscriptionNavigationCoordinator.shouldPopToSubscriptionSettings = true + } + } + + .onChange(of: viewModel.state.shouldPopToAppSettings) { shouldDismiss in + if shouldDismiss { + subscriptionNavigationCoordinator.shouldPopToAppSettings = true + } + } + + .onChange(of: viewModel.state.selectedFeature) { feature in + switch feature { + case .dbp: + self.isShowingDBP = true + case .itr: + self.isShowingITR = true + case .netP: + self.isShowingNetP = true + default: + break } } .navigationTitle(viewModel.viewTitle) - .onAppear(perform: { + .onFirstAppear { setUpAppearances() - viewModel.onAppear() - }) + viewModel.onFirstAppear() + } } // MARK: - - @ViewBuilder - private var closeButton: some View { - if isModal { - Button(UserText.subscriptionCloseButton) { onDismissStack?() } - } - } - private var baseView: some View { ZStack { VStack { @@ -131,16 +148,14 @@ struct SubscriptionEmailView: View { @ViewBuilder private var browserBackButton: some View { - if viewModel.shouldDisplayBackButton() { - Button(action: { - Task { await viewModel.navigateBack() } - }, label: { - HStack(spacing: 0) { - Image(systemName: Constants.backButtonImage) - Text(UserText.backButtonTitle).foregroundColor(Color(designSystemColor: .textPrimary)) - } - }) - } + Button(action: { + Task { await viewModel.navigateBack() } + }, label: { + HStack(spacing: 0) { + Image(systemName: Constants.backButtonImage) + Text(viewModel.state.backButtonTitle).foregroundColor(Color(designSystemColor: .textPrimary)) + } + }) } private func setUpAppearances() { diff --git a/DuckDuckGo/Subscription/Views/SubscriptionExternalLinkView.swift b/DuckDuckGo/Subscription/Views/SubscriptionExternalLinkView.swift index 5edfcc0155..4b5af9a3d5 100644 --- a/DuckDuckGo/Subscription/Views/SubscriptionExternalLinkView.swift +++ b/DuckDuckGo/Subscription/Views/SubscriptionExternalLinkView.swift @@ -50,10 +50,10 @@ struct SubscriptionExternalLinkView: View { .navigationViewStyle(.stack) .navigationTitle(title ?? "") - .onAppear(perform: { + .onFirstAppear { setUpAppearances() - viewModel.initializeView() - }) + viewModel.onFirstAppear() + } }.tint(Color(designSystemColor: .textPrimary)) } diff --git a/DuckDuckGo/Subscription/Views/SubscriptionFlowView.swift b/DuckDuckGo/Subscription/Views/SubscriptionFlowView.swift index bd11a7d3ea..3f8d3fe006 100644 --- a/DuckDuckGo/Subscription/Views/SubscriptionFlowView.swift +++ b/DuckDuckGo/Subscription/Views/SubscriptionFlowView.swift @@ -25,13 +25,20 @@ import Core @available(iOS 15.0, *) struct SubscriptionFlowView: View { - + @Environment(\.dismiss) var dismiss - @StateObject var viewModel = SubscriptionFlowViewModel() - + @EnvironmentObject var subscriptionNavigationCoordinator: SubscriptionNavigationCoordinator + @StateObject var viewModel: SubscriptionFlowViewModel + + @State private var isPurchaseInProgress = false + @State private var isShowingITR = false + @State private var isShowingDBP = false + @State private var isShowingNetP = false + @Binding var currentView: SubscriptionContainerView.CurrentView + // Local View State @State private var errorMessage: SubscriptionErrorMessage = .general - @State private var shouldPresentError: Bool = false + @State private var isPresentingError: Bool = false enum Constants { static let daxLogo = "Home" @@ -49,38 +56,38 @@ struct SubscriptionFlowView: View { } var body: some View { - NavigationView { - baseView - .toolbar { - ToolbarItemGroup(placement: .navigationBarLeading) { - backButton - } - ToolbarItem(placement: .principal) { - HStack { - Image(Constants.daxLogo) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: Constants.daxLogoSize, height: Constants.daxLogoSize) - Text(viewModel.viewTitle).daxBodyRegular() - } + + // Hidden Navigation Links for Onboarding sections + NavigationLink(destination: NetworkProtectionRootView(inviteCompletion: {}).navigationViewStyle(.stack), + isActive: $isShowingNetP, + label: { EmptyView() }) + NavigationLink(destination: SubscriptionITPView().navigationViewStyle(.stack), + isActive: $isShowingITR, + label: { EmptyView() }) + NavigationLink(destination: SubscriptionPIRView().navigationViewStyle(.stack), + isActive: $isShowingDBP, + label: { EmptyView() }) + + baseView + .toolbar { + ToolbarItemGroup(placement: .navigationBarLeading) { + backButton + } + ToolbarItem(placement: .principal) { + HStack { + Image(Constants.daxLogo) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: Constants.daxLogoSize, height: Constants.daxLogoSize) + Text(viewModel.viewTitle).daxBodyRegular() } } - .edgesIgnoringSafeArea(.top) - .navigationBarTitleDisplayMode(.inline) - .navigationBarHidden(!viewModel.state.shouldShowNavigationBar).animation(.easeOut) - } - .applyInsetGroupedListStyle() - .tint(Color(designSystemColor: .textPrimary)) - } - - @ViewBuilder - private var dismissButton: some View { - Button(action: { - viewModel.finalizeSubscriptionFlow() - }, label: { Text(UserText.subscriptionCloseButton) }) - .padding(Constants.navButtonPadding) - .contentShape(Rectangle()) - .tint(Color(designSystemColor: .textPrimary)) + } + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(viewModel.state.canNavigateBack || viewModel.subFeature.transactionStatus != .idle) + .interactiveDismissDisabled(viewModel.subFeature.transactionStatus != .idle) + .edgesIgnoringSafeArea(.bottom) + .tint(Color(designSystemColor: .textPrimary)) } @ViewBuilder @@ -116,27 +123,32 @@ struct SubscriptionFlowView: View { private var baseView: some View { ZStack(alignment: .top) { webView - - // Show a dismiss button while the bar is not visible - // But it should be hidden while performing a transaction - if !viewModel.state.shouldShowNavigationBar && viewModel.state.transactionStatus == .idle { - HStack { - backButton.padding(.leading, Constants.navButtonPadding) - Spacer() - dismissButton - } + } + + .onChange(of: viewModel.state.selectedFeature) { feature in + switch feature { + case .dbp: + self.isShowingDBP = true + case .itr: + self.isShowingITR = true + case .netP: + self.isShowingNetP = true + default: + break } } - .onChange(of: viewModel.state.shouldDismissView) { result in + .onChange(of: viewModel.state.shouldActivateSubscription) { result in if result { - dismiss() + withAnimation { + currentView = .restore + } } } .onChange(of: viewModel.state.transactionError) { value in - if !shouldPresentError { + if !isPresentingError { let displayError: Bool = { switch value { case .hasActiveSubscription: @@ -154,30 +166,29 @@ struct SubscriptionFlowView: View { }() if displayError { - shouldPresentError = true + isPresentingError = true } } } - .onAppear(perform: { + .onFirstAppear { setUpAppearances() - Task { await viewModel.onAppear() } - - }) + Task { await viewModel.onFirstAppear() } + } + + .onFirstDisappear { + Task { await viewModel.onFirstDisappear() } + } - .onDisappear(perform: { - viewModel.onDisappear() - }) + .onAppear { + Task { await viewModel.onFirstAppear() } + } - .alert(isPresented: $shouldPresentError) { + .alert(isPresented: $isPresentingError) { getAlert(error: self.errorMessage) } - // The trailing close button should be hidden when a transaction is in progress - .navigationBarItems(trailing: viewModel.state.transactionStatus == .idle - ? Button(UserText.subscriptionCloseButton) { viewModel.finalizeSubscriptionFlow() } - : nil) } private func getAlert(error: SubscriptionErrorMessage) -> Alert { @@ -189,7 +200,7 @@ struct SubscriptionFlowView: View { message: Text(UserText.subscriptionFoundText), primaryButton: .cancel(Text(UserText.subscriptionFoundCancel)) { viewModel.clearTransactionError() - viewModel.finalizeSubscriptionFlow() + dismiss() }, secondaryButton: .default(Text(UserText.subscriptionFoundRestore)) { viewModel.restoreAppstoreTransaction() @@ -201,7 +212,7 @@ struct SubscriptionFlowView: View { message: Text(UserText.subscriptionAppStoreErrorMessage), dismissButton: .cancel(Text(UserText.actionOK)) { viewModel.clearTransactionError() - viewModel.finalizeSubscriptionFlow() + dismiss() } ) case .backend, .general: @@ -209,7 +220,8 @@ struct SubscriptionFlowView: View { title: Text(UserText.subscriptionBackendErrorTitle), message: Text(UserText.subscriptionBackendErrorMessage), dismissButton: .cancel(Text(UserText.subscriptionBackendErrorButton)) { - viewModel.finalizeSubscriptionFlow() + viewModel.clearTransactionError() + dismiss() } ) } diff --git a/DuckDuckGo/Subscription/Views/SubscriptionITPView.swift b/DuckDuckGo/Subscription/Views/SubscriptionITPView.swift index e411c109fd..5904854dc2 100644 --- a/DuckDuckGo/Subscription/Views/SubscriptionITPView.swift +++ b/DuckDuckGo/Subscription/Views/SubscriptionITPView.swift @@ -51,40 +51,35 @@ struct SubscriptionITPView: View { } var body: some View { - NavigationView { - baseView - .toolbar { - ToolbarItemGroup(placement: .navigationBarLeading) { - backButton - } - ToolbarItem(placement: .principal) { - HStack { - Image(Constants.daxLogo) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: Constants.daxLogoSize, height: Constants.daxLogoSize) - Text(viewModel.viewTitle).daxBodyRegular() - } - } - ToolbarItem(placement: .navigationBarTrailing) { - shareButton - } - ToolbarItem(placement: .navigationBarTrailing) { - Button(UserText.subscriptionCloseButton) { dismiss() } + + baseView + + .toolbar { + ToolbarItemGroup(placement: .navigationBarLeading) { + backButton + } + ToolbarItem(placement: .principal) { + HStack { + Image(Constants.daxLogo) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: Constants.daxLogoSize, height: Constants.daxLogoSize) + Text(viewModel.viewTitle).daxBodyRegular() } } - .edgesIgnoringSafeArea(.all) - .navigationBarTitleDisplayMode(.inline) - .navigationBarHidden(!viewModel.shouldShowNavigationBar && !viewModel.isDownloadableContent).animation(.snappy) - - .onAppear(perform: { - setUpAppearances() - viewModel.initializeView() - }) - + ToolbarItem(placement: .navigationBarTrailing) { + shareButton + } } + .edgesIgnoringSafeArea(.bottom) + .navigationBarBackButtonHidden(viewModel.canNavigateBack) + .navigationBarTitleDisplayMode(.inline) .tint(Color(designSystemColor: .textPrimary)) + .onFirstAppear { + viewModel.onFirstAppear() + setUpAppearances() + } .alert(isPresented: $viewModel.navigationError) { Alert( @@ -109,17 +104,6 @@ struct SubscriptionITPView: View { private var baseView: some View { ZStack(alignment: .top) { webView - - // Show a dismiss button while the bar is not visible - // But it should be hidden while performing a transaction - if !shouldShowNavigationBar { - HStack { - backButton.padding(.leading, Constants.navButtonPadding) - Spacer() - dismissButton - } - } - } } @@ -157,15 +141,6 @@ struct SubscriptionITPView: View { } } - @ViewBuilder - private var dismissButton: some View { - Button(action: { dismiss() }, label: { Text(UserText.subscriptionCloseButton) }) - .padding(Constants.navButtonPadding) - .contentShape(Rectangle()) - .tint(Color(designSystemColor: .textPrimary)) - } - - private func setUpAppearances() { let navAppearance = UINavigationBar.appearance() navAppearance.backgroundColor = UIColor(designSystemColor: .surface) diff --git a/DuckDuckGo/Subscription/Views/SubscriptionNavigationCoordinator.swift b/DuckDuckGo/Subscription/Views/SubscriptionNavigationCoordinator.swift new file mode 100644 index 0000000000..b8fe1f7867 --- /dev/null +++ b/DuckDuckGo/Subscription/Views/SubscriptionNavigationCoordinator.swift @@ -0,0 +1,25 @@ +// +// SubscriptionNavigationCoordinator.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +final class SubscriptionNavigationCoordinator: ObservableObject { + @Published var shouldPopToSubscriptionSettings: Bool = false + @Published var shouldPopToAppSettings: Bool = false +} diff --git a/DuckDuckGo/Subscription/Views/SubscriptionPIRView.swift b/DuckDuckGo/Subscription/Views/SubscriptionPIRView.swift index 6911051167..9756c040ca 100644 --- a/DuckDuckGo/Subscription/Views/SubscriptionPIRView.swift +++ b/DuckDuckGo/Subscription/Views/SubscriptionPIRView.swift @@ -50,42 +50,32 @@ struct SubscriptionPIRView: View { } var body: some View { - NavigationView { - ZStack { - gradientBackground - ScrollView { - VStack { - header - .padding(.top, Constants.headerPadding) - baseView - .frame(maxWidth: 600) - } + ZStack { + gradientBackground + ScrollView { + VStack { + baseView + .frame(maxWidth: 600) } - } - .edgesIgnoringSafeArea(.all) - }.onAppear(perform: { - viewModel.onAppear() - }) - } - - private var header: some View { - GeometryReader { geometry in - HStack { - Spacer().frame(width: geometry.size.width / 3) - HStack(alignment: .center) { + + } + .toolbar { + ToolbarItem(placement: .principal) { + HStack { Image(Constants.daxLogo) .resizable() .aspectRatio(contentMode: .fit) .frame(width: Constants.daxLogoSize, height: Constants.daxLogoSize) Text(viewModel.viewTitle).daxBodyRegular() } - .frame(width: geometry.size.width / 3, alignment: .center) - dismissButton - .frame(width: geometry.size.width / 3, alignment: .trailing) } } + .onFirstAppear { + viewModel.onFirstAppear() + } } + private var gradientBackground: some View { ZStack { diff --git a/DuckDuckGo/Subscription/Views/SubscriptionRestoreView.swift b/DuckDuckGo/Subscription/Views/SubscriptionRestoreView.swift index a85d37b1ff..f010b8aef0 100644 --- a/DuckDuckGo/Subscription/Views/SubscriptionRestoreView.swift +++ b/DuckDuckGo/Subscription/Views/SubscriptionRestoreView.swift @@ -28,20 +28,17 @@ import Core // swiftlint:disable type_body_length struct SubscriptionRestoreView: View { - enum Source { - case addAnotherDevice - case settings - } - @Environment(\.dismiss) var dismiss - @StateObject var viewModel: SubscriptionRestoreViewModel = SubscriptionRestoreViewModel() - + + @EnvironmentObject var subscriptionNavigationCoordinator: SubscriptionNavigationCoordinator + @StateObject var viewModel: SubscriptionRestoreViewModel + @StateObject var emailViewModel: SubscriptionEmailViewModel + @State private var isAlertVisible = false - @State private var shouldShowWelcomePage = false - @State private var shouldNavigateToActivationFlow = false - @State var isModal = true - var source: SubscriptionRestoreView.Source = .settings - + @State private var isShowingWelcomePage = false + @State private var isShowingActivationFlow = false + @Binding var currentView: SubscriptionContainerView.CurrentView + private enum Constants { static let heroImage = "ManageSubscriptionHero" static let appleIDIcon = "Platform-Apple-16-subscriptions" @@ -76,10 +73,8 @@ struct SubscriptionRestoreView: View { } } else { ZStack { + baseView - NavigationView { - baseView - } if viewModel.state.transactionStatus != .idle { PurchaseInProgressView(status: getTransactionStatus()) } @@ -87,10 +82,8 @@ struct SubscriptionRestoreView: View { } } } - - @ViewBuilder - private var baseView: some View { - + + private var contentView: some View { Group { ScrollView { VStack(spacing: Constants.sectionSpacing) { @@ -100,10 +93,8 @@ struct SubscriptionRestoreView: View { Spacer() // Hidden link to display Email Activation View - NavigationLink(destination: SubscriptionEmailView(viewModel: viewModel.emailViewModel, - isModal: isModal, - onDismissStack: { viewModel.dismissView() }), - isActive: $shouldNavigateToActivationFlow) { + NavigationLink(destination: SubscriptionEmailView(viewModel: emailViewModel).environmentObject(subscriptionNavigationCoordinator), + isActive: $isShowingActivationFlow) { EmptyView() }.isDetailLink(false) @@ -118,57 +109,57 @@ struct SubscriptionRestoreView: View { .navigationBarBackButtonHidden(viewModel.state.transactionStatus != .idle) .navigationBarTitleDisplayMode(.inline) .applyInsetGroupedListStyle() - .navigationBarItems(trailing: closeButton) + .interactiveDismissDisabled(viewModel.subFeature.transactionStatus != .idle) .tint(Color.init(designSystemColor: .textPrimary)) .accentColor(Color.init(designSystemColor: .textPrimary)) } - - .alert(isPresented: $isAlertVisible) { getAlert() } - - .onChange(of: viewModel.state.activationResult) { result in - if result != .unknown { - isAlertVisible = true + } + + @ViewBuilder + private var baseView: some View { + + contentView + .alert(isPresented: $isAlertVisible) { getAlert() } + + .onChange(of: viewModel.state.activationResult) { result in + if result != .unknown { + isAlertVisible = true + } } - } - - // Navigation Flow Binding - .onChange(of: viewModel.state.shouldNavigateToActivationFlow) { result in - shouldNavigateToActivationFlow = result - } - .onChange(of: shouldNavigateToActivationFlow) { result in - viewModel.showActivationFlow(result) - } - .onChange(of: viewModel.state.shouldDismissView) { result in - if result { - dismiss() + + // Navigation Flow Binding + .onChange(of: viewModel.state.isShowingActivationFlow) { result in + isShowingActivationFlow = result } - } - .onChange(of: viewModel.state.shouldShowPlans) { result in - if result { - dismiss() + .onChange(of: isShowingActivationFlow) { result in + viewModel.showActivationFlow(result) } - } - .onAppear { - viewModel.onAppear() - setUpAppearances() - - switch source { - case .addAnotherDevice: - Pixel.fire(pixel: .privacyProSettingsAddDevice, debounce: 2) - default: break + + .onChange(of: viewModel.state.shouldDismissView) { result in + if result { + dismiss() + } } - } + + .onChange(of: viewModel.state.shouldShowPlans) { result in + if result { + currentView = .subscribe + } + } + + .onFirstAppear { + Task { await viewModel.onFirstAppear() } + setUpAppearances() + } + + .onFirstDisappear { + Task { await viewModel.onFirstDisappear() } + } + } // MARK: - - @ViewBuilder - private var closeButton: some View { - if isModal { - Button(UserText.subscriptionCloseButton) { viewModel.dismissView() } - } - } - private var emailView: some View { emailCellContent .padding(Constants.boxPadding) @@ -190,37 +181,39 @@ struct SubscriptionRestoreView: View { .foregroundColor(Color(designSystemColor: .textPrimary)) } - 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, + 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: .privacyProSubscriptionManagementEmail, debounce: 1) + 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) + }) + } } } } diff --git a/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift b/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift index f175232a3c..e93ca2e55f 100644 --- a/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift +++ b/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift @@ -22,23 +22,19 @@ import SwiftUI import DesignResourcesKit import Core -class SceneEnvironment: ObservableObject { - weak var windowScene: UIWindowScene? -} - #if SUBSCRIPTION @available(iOS 15.0, *) struct SubscriptionSettingsView: View { - - @Environment(\.presentationMode) var presentationMode + @Environment(\.dismiss) var dismiss @StateObject var viewModel = SubscriptionSettingsViewModel() - @StateObject var sceneEnvironment = SceneEnvironment() + @EnvironmentObject var subscriptionNavigationCoordinator: SubscriptionNavigationCoordinator - @State var shouldDisplayStripeView = false - @State var shouldDisplayGoogleView = false - @State var shouldDisplayRemovalNotice = false - @State var shouldDisplayFAQView = false + @State var isShowingStripeView = false + @State var isShowingGoogleView = false + @State var isShowingRemovalNotice = false + @State var isShowingFAQView = false + @State var isShowingRestoreView = false var body: some View { optionsView @@ -78,7 +74,7 @@ struct SubscriptionSettingsView: View { Task { viewModel.manageSubscription() } }, isButton: true) - .sheet(isPresented: $shouldDisplayStripeView) { + .sheet(isPresented: $isShowingStripeView) { if let stripeViewModel = viewModel.state.stripeViewModel { SubscriptionExternalLinkView(viewModel: stripeViewModel, title: UserText.subscriptionManagePlan) } @@ -89,12 +85,14 @@ struct SubscriptionSettingsView: View { private var devicesSection: some View { Section(header: Text(UserText.subscriptionManageDevices)) { - NavigationLink(destination: SubscriptionRestoreView(isModal: false, source: .addAnotherDevice)) { + NavigationLink(destination: SubscriptionContainerView(currentView: .restore) + .environmentObject(subscriptionNavigationCoordinator), + isActive: $isShowingRestoreView) { SettingsCustomCell(content: { Text(UserText.subscriptionAddDeviceButton) .daxBodyRegular() }) - } + }.isDetailLink(false) SettingsCustomCell(content: { Text(UserText.subscriptionRemoveFromDevice) @@ -126,7 +124,7 @@ struct SubscriptionSettingsView: View { @ViewBuilder private var optionsView: some View { NavigationLink(destination: SubscriptionGoogleView(), - isActive: $shouldDisplayGoogleView) { + isActive: $isShowingGoogleView) { EmptyView() } @@ -147,40 +145,46 @@ struct SubscriptionSettingsView: View { } // Google Binding - .onChange(of: viewModel.state.shouldDisplayGoogleView) { value in - shouldDisplayGoogleView = value + .onChange(of: viewModel.state.isShowingGoogleView) { value in + isShowingGoogleView = value } - .onChange(of: shouldDisplayGoogleView) { value in + .onChange(of: isShowingGoogleView) { value in viewModel.displayGoogleView(value) } // Stripe Binding - .onChange(of: viewModel.state.shouldDisplayStripeView) { value in - shouldDisplayStripeView = value + .onChange(of: viewModel.state.isShowingStripeView) { value in + isShowingStripeView = value } - .onChange(of: shouldDisplayStripeView) { value in + .onChange(of: isShowingStripeView) { value in viewModel.displayStripeView(value) } // Removal Notice - .onChange(of: viewModel.state.shouldDisplayRemovalNotice) { value in - shouldDisplayRemovalNotice = value + .onChange(of: viewModel.state.isShowingRemovalNotice) { value in + isShowingRemovalNotice = value } - .onChange(of: shouldDisplayRemovalNotice) { value in + .onChange(of: isShowingRemovalNotice) { value in viewModel.displayRemovalNotice(value) } // Removal Notice - .onChange(of: viewModel.state.shouldDisplayFAQView) { value in - shouldDisplayFAQView = value + .onChange(of: viewModel.state.isShowingFAQView) { value in + isShowingFAQView = value } - .onChange(of: shouldDisplayFAQView) { value in + .onChange(of: isShowingFAQView) { value in viewModel.displayFAQView(value) } + + .onReceive(subscriptionNavigationCoordinator.$shouldPopToSubscriptionSettings) { shouldDismiss in + if shouldDismiss { + isShowingRestoreView = false + } + } // Remove subscription - .alert(isPresented: $shouldDisplayRemovalNotice) { + .alert(isPresented: $isShowingRemovalNotice) { Alert( title: Text(UserText.subscriptionRemoveFromDeviceConfirmTitle), message: Text(UserText.subscriptionRemoveFromDeviceConfirmText), @@ -189,17 +193,17 @@ struct SubscriptionSettingsView: View { secondaryButton: .destructive(Text(UserText.subscriptionRemove)) { Pixel.fire(pixel: .privacyProSubscriptionManagementRemoval) viewModel.removeSubscription() - presentationMode.wrappedValue.dismiss() + dismiss() } ) } - .sheet(isPresented: $shouldDisplayFAQView, content: { + .sheet(isPresented: $isShowingFAQView, content: { SubscriptionExternalLinkView(viewModel: viewModel.state.FAQViewModel, title: UserText.subscriptionFAQ) }) - .onAppear { - viewModel.fetchAndUpdateSubscriptionDetails() + .onFirstAppear { + viewModel.onFirstAppear() } } @@ -214,18 +218,6 @@ struct SubscriptionSettingsView: View { } #endif - -#if SUBSCRIPTION && DEBUG -@available(iOS 15.0, *) - -struct SubscriptionSettingsView_Previews: PreviewProvider { - static var previews: some View { - NavigationView { - SubscriptionSettingsView().navigationBarTitleDisplayMode(.inline) - } - } -} - // Commented out because CI fails if a SwiftUI preview is enabled https://app.asana.com/0/414709148257752/1206774081310425/f // @available(iOS 15.0, *) // struct SubscriptionSettingsView_Previews: PreviewProvider { @@ -233,5 +225,3 @@ struct SubscriptionSettingsView_Previews: PreviewProvider { // SubscriptionSettingsView() // } // } - -#endif