From 580256dc888df93f8630ca5c5f8f6cb731ddc3ae Mon Sep 17 00:00:00 2001 From: Alessandro Boron Date: Fri, 19 Jul 2024 07:56:42 +1000 Subject: [PATCH] Onboarding Contextual Tutorial Rendering (#3063) --- DuckDuckGo.xcodeproj/project.pbxproj | 24 ++++ DuckDuckGo/AppDelegate.swift | 3 +- DuckDuckGo/Base.lproj/Tab.storyboard | 21 ++- DuckDuckGo/MainViewController.swift | 27 ++-- ...ontextualOnboardingBackgroundWrapper.swift | 44 ++++++ .../ContextualDaxDialogsFactory.swift | 49 +++++++ .../ContextualOnboardingPresenter.swift | 131 ++++++++++++++++++ DuckDuckGo/TabManager.swift | 11 +- DuckDuckGo/TabViewController.swift | 46 ++++-- ...ViewControllerLongPressMenuExtension.swift | 3 +- .../ContextualOnboardingPresenterTests.swift | 93 +++++++++++++ .../OnboardingNavigationDelegateTests.swift | 3 +- 12 files changed, 422 insertions(+), 33 deletions(-) create mode 100644 DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/ContextualOnboardingBackgroundWrapper.swift create mode 100644 DuckDuckGo/OnboardingExperiment/ContextualOnboarding/ContextualDaxDialogsFactory.swift create mode 100644 DuckDuckGo/OnboardingExperiment/ContextualOnboarding/ContextualOnboardingPresenter.swift create mode 100644 DuckDuckGoTests/ContextualOnboardingPresenterTests.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 6df1f67c5b..a3820c2674 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -629,6 +629,10 @@ 9F23B8032C2BCD0000950875 /* DaxDialogStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F23B8022C2BCD0000950875 /* DaxDialogStyles.swift */; }; 9F23B8062C2BE22700950875 /* OnboardingIntroViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F23B8052C2BE22700950875 /* OnboardingIntroViewModelTests.swift */; }; 9F23B8092C2BE9B700950875 /* MockURLOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F23B8082C2BE9B700950875 /* MockURLOpener.swift */; }; + 9F5E5AAC2C3D0FCD00165F54 /* ContextualDaxDialogsFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F5E5AAB2C3D0FCD00165F54 /* ContextualDaxDialogsFactory.swift */; }; + 9F5E5AAE2C3E44DC00165F54 /* ContextualOnboardingBackgroundWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F5E5AAD2C3E44DC00165F54 /* ContextualOnboardingBackgroundWrapper.swift */; }; + 9F5E5AB02C3E4C6000165F54 /* ContextualOnboardingPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F5E5AAF2C3E4C6000165F54 /* ContextualOnboardingPresenter.swift */; }; + 9F5E5AB22C3E606D00165F54 /* ContextualOnboardingPresenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F5E5AB12C3E606D00165F54 /* ContextualOnboardingPresenterTests.swift */; }; 9F8FE9492BAE50E50071E372 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 9F8FE9482BAE50E50071E372 /* Lottie */; }; 9F9EE4CE2C377D4900D4118E /* OnboardingFirePixelMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9EE4CC2C377D3F00D4118E /* OnboardingFirePixelMock.swift */; }; 9F9EE4D42C37BB1300D4118E /* OnboardingView+Landing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9EE4D32C37BB1300D4118E /* OnboardingView+Landing.swift */; }; @@ -2312,6 +2316,10 @@ 9F23B8052C2BE22700950875 /* OnboardingIntroViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingIntroViewModelTests.swift; sourceTree = ""; }; 9F23B8082C2BE9B700950875 /* MockURLOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockURLOpener.swift; sourceTree = ""; }; 9F9EE4CC2C377D3F00D4118E /* OnboardingFirePixelMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingFirePixelMock.swift; sourceTree = ""; }; + 9F5E5AAB2C3D0FCD00165F54 /* ContextualDaxDialogsFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextualDaxDialogsFactory.swift; sourceTree = ""; }; + 9F5E5AAD2C3E44DC00165F54 /* ContextualOnboardingBackgroundWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextualOnboardingBackgroundWrapper.swift; sourceTree = ""; }; + 9F5E5AAF2C3E4C6000165F54 /* ContextualOnboardingPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextualOnboardingPresenter.swift; sourceTree = ""; }; + 9F5E5AB12C3E606D00165F54 /* ContextualOnboardingPresenterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextualOnboardingPresenterTests.swift; sourceTree = ""; }; 9F9EE4D32C37BB1300D4118E /* OnboardingView+Landing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OnboardingView+Landing.swift"; sourceTree = ""; }; 9FA5E44A2BF1AF3400BDEF02 /* SubscriptionContainerViewFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionContainerViewFactory.swift; sourceTree = ""; }; 9FB027112C2526DD009EA190 /* OnboardingView+IntroDialogContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OnboardingView+IntroDialogContent.swift"; sourceTree = ""; }; @@ -3484,6 +3492,7 @@ 56D060232C35918D003BAEB5 /* ContextualOnboardingList.swift */, 56D060252C359D2E003BAEB5 /* ContextualOnboardingDialogs.swift */, 564DE4522C3ED1B700D23241 /* NewTabDaxDialogFactory.swift */, + 9F5E5AAD2C3E44DC00165F54 /* ContextualOnboardingBackgroundWrapper.swift */, ); path = ContextualDaxDialogs; sourceTree = ""; @@ -4336,6 +4345,7 @@ 564DE45D2C45218500D23241 /* OnboardingNavigationDelegateTests.swift */, 9F9EE4CB2C377D2400D4118E /* Mocks */, 9FE05CEF2C3642F900D9046B /* OnboardingPixelReporterTests.swift */, + 9F5E5AB12C3E606D00165F54 /* ContextualOnboardingPresenterTests.swift */, ); name = Onboarding; sourceTree = ""; @@ -4348,6 +4358,15 @@ name = Mocks; sourceTree = ""; }; + 9F5E5AAA2C3D0FAA00165F54 /* ContextualOnboarding */ = { + isa = PBXGroup; + children = ( + 9F5E5AAB2C3D0FCD00165F54 /* ContextualDaxDialogsFactory.swift */, + 9F5E5AAF2C3E4C6000165F54 /* ContextualOnboardingPresenter.swift */, + ); + path = ContextualOnboarding; + sourceTree = ""; + }; 9FB027102C2526A8009EA190 /* DaxDialogs */ = { isa = PBXGroup; children = ( @@ -4400,6 +4419,7 @@ 9FB027172C26BC0F009EA190 /* BrowsersComparison */, 9FB027102C2526A8009EA190 /* DaxDialogs */, 9F23B7FF2C2BABE000950875 /* OnboardingIntro */, + 9F5E5AAA2C3D0FAA00165F54 /* ContextualOnboarding */, 9F23B8002C2BC94400950875 /* OnboardingBackground.swift */, ); path = OnboardingExperiment; @@ -6783,6 +6803,7 @@ F1BE54581E69DE1000FCF649 /* TutorialSettings.swift in Sources */, 1EE52ABB28FB1D6300B750C1 /* UIImageExtension.swift in Sources */, 858650D12469BCDE00C36F8A /* DaxDialogs.swift in Sources */, + 9F5E5AB02C3E4C6000165F54 /* ContextualOnboardingPresenter.swift in Sources */, 310D091B2799F54900DC0060 /* DownloadManager.swift in Sources */, 98D98A7425ED88D100D8E3DF /* BrowsingMenuEntryViewCell.swift in Sources */, F1564F032B7B915F00D454A6 /* AppDelegate+SKAD4.swift in Sources */, @@ -6797,6 +6818,7 @@ C1D21E2D293A5965006E5A05 /* AutofillLoginSession.swift in Sources */, 4B53648A26718D0E001AA041 /* EmailWaitlist.swift in Sources */, D63677F52BBDB1C300605BA5 /* DaxLogoNavbarTitle.swift in Sources */, + 9F5E5AAE2C3E44DC00165F54 /* ContextualOnboardingBackgroundWrapper.swift in Sources */, 8524CC98246D66E100E59D45 /* String+Markdown.swift in Sources */, CBEFB9142AE0844700DEDE7B /* CriticalAlerts.swift in Sources */, 986B16C425E92DF0007D23E8 /* BrowsingMenuViewController.swift in Sources */, @@ -6845,6 +6867,7 @@ F114C55B1E66EB020018F95F /* NibLoading.swift in Sources */, D6BFCB612B7525160051FF81 /* SubscriptionPIRViewModel.swift in Sources */, D668D9252B693778008E2FF2 /* SubscriptionITPView.swift in Sources */, + 9F5E5AAC2C3D0FCD00165F54 /* ContextualDaxDialogsFactory.swift in Sources */, C10CB5F32A1A5BDF0048E503 /* AutofillViews.swift in Sources */, 6FE127382C20492500EB5724 /* NewTabPage.swift in Sources */, 982E5630222C3D5B008D861B /* FeedbackPickerViewController.swift in Sources */, @@ -7286,6 +7309,7 @@ C1CDA31E2AFBF811006D1476 /* AutofillNeverPromptWebsitesManagerTests.swift in Sources */, B6AD9E3A28D456820019CDE9 /* PrivacyConfigurationManagerMock.swift in Sources */, F189AED71F18F6DE001EBAE1 /* TabTests.swift in Sources */, + 9F5E5AB22C3E606D00165F54 /* ContextualOnboardingPresenterTests.swift in Sources */, F13B4BFB1F18E3D900814661 /* TabsModelPersistenceExtensionTests.swift in Sources */, CB48D3372B90DF2000631D8B /* UserBehaviorMonitorTests.swift in Sources */, 8528AE7E212EF5FF00D0BD74 /* AppRatingPromptTests.swift in Sources */, diff --git a/DuckDuckGo/AppDelegate.swift b/DuckDuckGo/AppDelegate.swift index b8e45bd4cb..88ec75395e 100644 --- a/DuckDuckGo/AppDelegate.swift +++ b/DuckDuckGo/AppDelegate.swift @@ -319,7 +319,8 @@ import WebKit previewsSource: previewsSource, tabsModel: tabsModel, syncPausedStateManager: syncErrorHandler, - variantManager: variantManager) + variantManager: variantManager, + contextualOnboardingPresenter: ContextualOnboardingPresenter(variantManager: variantManager)) main.loadViewIfNeeded() syncErrorHandler.alertPresenter = main diff --git a/DuckDuckGo/Base.lproj/Tab.storyboard b/DuckDuckGo/Base.lproj/Tab.storyboard index 4f3852b007..63df68b5cf 100644 --- a/DuckDuckGo/Base.lproj/Tab.storyboard +++ b/DuckDuckGo/Base.lproj/Tab.storyboard @@ -28,11 +28,15 @@ - - - - - + + + + + + + + + @@ -119,11 +123,15 @@ + + + + @@ -133,6 +141,7 @@ + @@ -153,7 +162,7 @@ - + diff --git a/DuckDuckGo/MainViewController.swift b/DuckDuckGo/MainViewController.swift index ffdf9a3372..0193fe09d2 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -183,7 +183,8 @@ class MainViewController: UIViewController { previewsSource: TabPreviewsSource, tabsModel: TabsModel, syncPausedStateManager: any SyncPausedStateManaging, - variantManager: VariantManager + variantManager: VariantManager, + contextualOnboardingPresenter: ContextualOnboardingPresenting ) { self.bookmarksDatabase = bookmarksDatabase self.bookmarksDatabaseCleaner = bookmarksDatabaseCleaner @@ -201,7 +202,8 @@ class MainViewController: UIViewController { previewsSource: previewsSource, bookmarksDatabase: bookmarksDatabase, historyManager: historyManager, - syncService: syncService) + syncService: syncService, + contextualOnboardingPresenter: contextualOnboardingPresenter) self.syncPausedStateManager = syncPausedStateManager self.homeTabManager = NewTabPageManager() self.variantManager = variantManager @@ -774,18 +776,27 @@ class MainViewController: UIViewController { } @IBAction func onFirePressed() { - Pixel.fire(pixel: .forgetAllPressedBrowsing) - wakeLazyFireButtonAnimator() - - if let spec = DaxDialogs.shared.fireButtonEducationMessage() { - segueToActionSheetDaxDialogWithSpec(spec) - } else { + + func showClearDataAlert() { let alert = ForgetDataAlert.buildAlert(forgetTabsAndDataHandler: { [weak self] in self?.forgetAllWithAnimation {} }) self.present(controller: alert, fromView: self.viewCoordinator.toolbar) } + Pixel.fire(pixel: .forgetAllPressedBrowsing) + wakeLazyFireButtonAnimator() + + if DefaultVariantManager().isSupported(feature: .newOnboardingIntro) { + showClearDataAlert() + } else { + if let spec = DaxDialogs.shared.fireButtonEducationMessage() { + segueToActionSheetDaxDialogWithSpec(spec) + } else { + showClearDataAlert() + } + } + performCancel() } diff --git a/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/ContextualOnboardingBackgroundWrapper.swift b/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/ContextualOnboardingBackgroundWrapper.swift new file mode 100644 index 0000000000..7191b96768 --- /dev/null +++ b/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/ContextualOnboardingBackgroundWrapper.swift @@ -0,0 +1,44 @@ +// +// ContextualOnboardingBackgroundWrapper.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 + +struct ContextualOnboardingBackgroundWrapper: View { + private let content: Content + + init(_ content: Content) { + self.content = content + } + + var body: some View { + content + .padding() + .background(OnboardingBackground()) + } +} + +#Preview { + ContextualOnboardingBackgroundWrapper( + ContextualDaxDialog( + message: .init(string: "Hello World!!!"), + cta: "OK!", + action: {} + ) + ) +} diff --git a/DuckDuckGo/OnboardingExperiment/ContextualOnboarding/ContextualDaxDialogsFactory.swift b/DuckDuckGo/OnboardingExperiment/ContextualOnboarding/ContextualDaxDialogsFactory.swift new file mode 100644 index 0000000000..e589749caf --- /dev/null +++ b/DuckDuckGo/OnboardingExperiment/ContextualOnboarding/ContextualDaxDialogsFactory.swift @@ -0,0 +1,49 @@ +// +// ContextualDaxDialogsFactory.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 + +protocol ContextualDaxDialogsFactory { + func makeView(for spec: DaxDialogs.BrowsingSpec, onActionTapped: (() -> Void)?) -> UIViewController +} + +extension ContextualDaxDialogsFactory { + func makeView(for spec: DaxDialogs.BrowsingSpec) -> UIViewController { + self.makeView(for: spec, onActionTapped: nil) + } +} + +// TODO: This will be replaced with the factory that returns the Contextual Dax Dialogs for the new contextual flow +final class ExistingLogicContextualDaxDialogsFactory: ContextualDaxDialogsFactory { + + func makeView(for spec: DaxDialogs.BrowsingSpec, onActionTapped: (() -> Void)?) -> UIViewController { + let contextualDialog = ContextualDaxDialog( + message: spec.message.attributedStringFromMarkdown(color: ThemeManager.shared.currentTheme.daxDialogTextColor), + cta: spec.cta, + action: onActionTapped + ) + + let hostingController = UIHostingController(rootView: ContextualOnboardingBackgroundWrapper(contextualDialog)) + if #available(iOS 16.0, *) { + hostingController.sizingOptions = [.intrinsicContentSize] + } + + return hostingController + } +} diff --git a/DuckDuckGo/OnboardingExperiment/ContextualOnboarding/ContextualOnboardingPresenter.swift b/DuckDuckGo/OnboardingExperiment/ContextualOnboarding/ContextualOnboardingPresenter.swift new file mode 100644 index 0000000000..a6154f8f46 --- /dev/null +++ b/DuckDuckGo/OnboardingExperiment/ContextualOnboarding/ContextualOnboardingPresenter.swift @@ -0,0 +1,131 @@ +// +// ContextualOnboardingPresenter.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 BrowserServicesKit +import Core + +protocol ContextualOnboardingPresenting { + func presentContextualOnboarding(for spec: DaxDialogs.BrowsingSpec, in vc: TabViewControllerType) +} + +final class ContextualOnboardingPresenter: ContextualOnboardingPresenting { + private let variantManager: VariantManager + private let daxDialogsFactory: ContextualDaxDialogsFactory + + init(variantManager: VariantManager, daxDialogsFactory: ContextualDaxDialogsFactory = ExistingLogicContextualDaxDialogsFactory()) { + self.variantManager = variantManager + self.daxDialogsFactory = daxDialogsFactory + } + + func presentContextualOnboarding(for spec: DaxDialogs.BrowsingSpec, in vc: TabViewControllerType) { + if variantManager.isSupported(feature: .newOnboardingIntro) { + presentExperimentContextualOnboarding(for: spec, in: vc) + } else { + presentControlContextualOnboarding(for: spec, in: vc) + } + } + +} + +// MARK: - Private + +private extension ContextualOnboardingPresenter { + + func presentControlContextualOnboarding(for spec: DaxDialogs.BrowsingSpec, in vc: TabViewControllerType) { + vc.performSegue(withIdentifier: "DaxDialog", sender: spec) + } + + func presentExperimentContextualOnboarding(for spec: DaxDialogs.BrowsingSpec, in vc: TabViewControllerType) { + + func animate(daxController: UIViewController, visible isVisible: Bool, onCompletion: ((Bool) -> Void)? = nil) { + daxController.view.isHidden = !isVisible + UIView.animate( + withDuration: 0.3, + animations: { + daxController.view.alpha = isVisible ? 1 : 0 + daxController.parent?.view.layoutIfNeeded() + }, + completion: onCompletion + ) + } + + // Before presenting a new dialog, remove any existing ones. + vc.daxDialogsStackView.arrangedSubviews.filter({ $0 != vc.webViewContainerView }).forEach { + vc.daxDialogsStackView.removeArrangedSubview($0) + $0.removeFromSuperview() + } + + // Ask the Dax Dialogs Factory for a view for the given spec + let controller = daxDialogsFactory.makeView(for: spec) { [weak vc] in + guard let vc, let daxController = vc.daxContextualOnboardingController else { return } + + // Collapse stack view and remove dax controller + animate(daxController: daxController, visible: false) { _ in + vc.daxDialogsStackView.removeArrangedSubview(daxController.view) + vc.removeChild(daxController) + } + } + controller.view.isHidden = true + controller.view.alpha = 0 + + vc.insertChild(controller, in: vc.daxDialogsStackView, at: 0) + vc.daxContextualOnboardingController = controller + + animate(daxController: controller, visible: true) + } + +} + +// MARK: - Helpers + +private extension UIViewController { + + func insertChild(_ childController: UIViewController, in stackView: UIStackView, at index: Int) { + addChild(childController) + stackView.insertArrangedSubview(childController.view, at: index) + childController.didMove(toParent: self) + } + + func removeChild(_ childController: UIViewController) { + childController.willMove(toParent: nil) + childController.view.removeFromSuperview() + childController.removeFromParent() + } + +} + +// MARK: - TabViewControllerType + +protocol TabViewControllerType: UIViewController { + var daxDialogsStackView: UIStackView { get } + var webViewContainerView: UIView { get } + var daxContextualOnboardingController: UIViewController? { get set } +} + +extension TabViewController: TabViewControllerType { + + var daxDialogsStackView: UIStackView { + containerStackView + } + + var webViewContainerView: UIView { + webViewContainer + } +} diff --git a/DuckDuckGo/TabManager.swift b/DuckDuckGo/TabManager.swift index d0a73362c6..f7b2bfd90b 100644 --- a/DuckDuckGo/TabManager.swift +++ b/DuckDuckGo/TabManager.swift @@ -35,6 +35,7 @@ class TabManager { private let historyManager: HistoryManaging private let syncService: DDGSyncing private var previewsSource: TabPreviewsSource + private let contextualOnboardingPresenter: ContextualOnboardingPresenting weak var delegate: TabDelegate? @@ -46,12 +47,14 @@ class TabManager { previewsSource: TabPreviewsSource, bookmarksDatabase: CoreDataDatabase, historyManager: HistoryManaging, - syncService: DDGSyncing) { + syncService: DDGSyncing, + contextualOnboardingPresenter: ContextualOnboardingPresenting) { self.model = model self.previewsSource = previewsSource self.bookmarksDatabase = bookmarksDatabase self.historyManager = historyManager self.syncService = syncService + self.contextualOnboardingPresenter = contextualOnboardingPresenter registerForNotifications() } @@ -68,7 +71,8 @@ class TabManager { let controller = TabViewController.loadFromStoryboard(model: tab, bookmarksDatabase: bookmarksDatabase, historyManager: historyManager, - syncService: syncService) + syncService: syncService, + contextualOnboardingPresenter: contextualOnboardingPresenter) controller.applyInheritedAttribution(inheritedAttribution) controller.attachWebView(configuration: configuration, andLoadRequest: url == nil ? nil : URLRequest.userInitiated(url!), @@ -140,7 +144,8 @@ class TabManager { let controller = TabViewController.loadFromStoryboard(model: tab, bookmarksDatabase: bookmarksDatabase, historyManager: historyManager, - syncService: syncService) + syncService: syncService, + contextualOnboardingPresenter: contextualOnboardingPresenter) controller.attachWebView(configuration: configCopy, andLoadRequest: request, consumeCookies: !model.hasActiveTabs, diff --git a/DuckDuckGo/TabViewController.swift b/DuckDuckGo/TabViewController.swift index 5e86fcf988..f59f38e4e1 100644 --- a/DuckDuckGo/TabViewController.swift +++ b/DuckDuckGo/TabViewController.swift @@ -54,8 +54,10 @@ class TabViewController: UIViewController { @IBOutlet private(set) weak var errorInfoImage: UIImageView! @IBOutlet private(set) weak var errorHeader: UILabel! @IBOutlet private(set) weak var errorMessage: UILabel! + @IBOutlet weak var containerStackView: UIStackView! @IBOutlet weak var webViewContainer: UIView! - + var daxContextualOnboardingController: UIViewController? + @IBOutlet var showBarsTapGestureRecogniser: UITapGestureRecognizer! private let instrumentation = TabInstrumentation() @@ -293,7 +295,8 @@ class TabViewController: UIViewController { appSettings: AppSettings = AppDependencyProvider.shared.appSettings, bookmarksDatabase: CoreDataDatabase, historyManager: HistoryManaging, - syncService: DDGSyncing) -> TabViewController { + syncService: DDGSyncing, + contextualOnboardingPresenter: ContextualOnboardingPresenting) -> TabViewController { let storyboard = UIStoryboard(name: "Tab", bundle: nil) let controller = storyboard.instantiateViewController(identifier: "TabViewController", creator: { coder in TabViewController(coder: coder, @@ -301,7 +304,8 @@ class TabViewController: UIViewController { appSettings: appSettings, bookmarksDatabase: bookmarksDatabase, historyManager: historyManager, - syncService: syncService) + syncService: syncService, + contextualOnboardingPresenter: contextualOnboardingPresenter) }) return controller } @@ -315,19 +319,23 @@ class TabViewController: UIViewController { var duckPlayer: DuckPlayerProtocol = DuckPlayer() var youtubeNavigationHandler: DuckNavigationHandling? - + + let contextualOnboardingPresenter: ContextualOnboardingPresenting + required init?(coder aDecoder: NSCoder, tabModel: Tab, appSettings: AppSettings, bookmarksDatabase: CoreDataDatabase, historyManager: HistoryManaging, - syncService: DDGSyncing) { + syncService: DDGSyncing, + contextualOnboardingPresenter: ContextualOnboardingPresenting) { self.tabModel = tabModel self.appSettings = appSettings self.bookmarksDatabase = bookmarksDatabase self.historyManager = historyManager self.historyCapture = HistoryCapture(historyManager: historyManager) self.syncService = syncService + self.contextualOnboardingPresenter = contextualOnboardingPresenter super.init(coder: aDecoder) } @@ -452,6 +460,13 @@ class TabViewController: UIViewController { webView.navigationDelegate = self webView.uiDelegate = self webViewContainer.addSubview(webView) + webView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + webView.topAnchor.constraint(equalTo: webViewContainer.topAnchor), + webView.bottomAnchor.constraint(equalTo: webViewContainer.bottomAnchor), + webView.leadingAnchor.constraint(equalTo: webViewContainer.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: webViewContainer.trailingAnchor), + ]) webView.scrollView.refreshControl = refreshControl // Be sure to set `tintColor` after the control is attached to ScrollView otherwise haptics are gone. // We don't have to care about it for this control instance the next time `setRefreshControlEnabled` @@ -1343,25 +1358,30 @@ extension TabViewController: WKNavigationDelegate { return } - isShowingFullScreenDaxDialog = true + if !DefaultVariantManager().isSupported(feature: .newOnboardingIntro) { + isShowingFullScreenDaxDialog = true + } scheduleTrackerNetworksAnimation(collapsing: !spec.highlightAddressBar) let daxDialogSourceURL = self.url DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + guard let self else { return } // https://app.asana.com/0/414709148257752/1201620790053163/f - if self?.url != daxDialogSourceURL { + if self.url != daxDialogSourceURL { DaxDialogs.shared.overrideShownFlagFor(spec, flag: false) - self?.isShowingFullScreenDaxDialog = false + self.isShowingFullScreenDaxDialog = false return } - self?.chromeDelegate?.omniBar.resignFirstResponder() - self?.chromeDelegate?.setBarsHidden(false, animated: true) - self?.performSegue(withIdentifier: "DaxDialog", sender: spec) + self.chromeDelegate?.omniBar.resignFirstResponder() + self.chromeDelegate?.setBarsHidden(false, animated: true) + + // Present the contextual onboarding + contextualOnboardingPresenter.presentContextualOnboarding(for: spec, in: self) if spec == DaxDialogs.BrowsingSpec.withoutTrackers { - self?.woShownRecently = true - self?.fireWoFollowUp = true + self.woShownRecently = true + self.fireWoFollowUp = true } } } diff --git a/DuckDuckGo/TabViewControllerLongPressMenuExtension.swift b/DuckDuckGo/TabViewControllerLongPressMenuExtension.swift index e6a48170cd..bc2ad2474c 100644 --- a/DuckDuckGo/TabViewControllerLongPressMenuExtension.swift +++ b/DuckDuckGo/TabViewControllerLongPressMenuExtension.swift @@ -105,7 +105,8 @@ extension TabViewController { let tabController = TabViewController.loadFromStoryboard(model: tab, bookmarksDatabase: bookmarksDatabase, historyManager: historyManager, - syncService: syncService) + syncService: syncService, + contextualOnboardingPresenter: contextualOnboardingPresenter) tabController.isLinkPreview = true let configuration = WKWebViewConfiguration.nonPersistent() tabController.attachWebView(configuration: configuration, andLoadRequest: URLRequest.userInitiated(url), consumeCookies: false) diff --git a/DuckDuckGoTests/ContextualOnboardingPresenterTests.swift b/DuckDuckGoTests/ContextualOnboardingPresenterTests.swift new file mode 100644 index 0000000000..7401997c5e --- /dev/null +++ b/DuckDuckGoTests/ContextualOnboardingPresenterTests.swift @@ -0,0 +1,93 @@ +// +// ContextualOnboardingPresenterTests.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 XCTest +import SwiftUI +@testable import DuckDuckGo + +final class ContextualOnboardingPresenterTests: XCTestCase { + + func testWhenPresentContextualOnboardingAndVariantDoesNotSupportOnboardingIntroThenOldContextualOnboardingIsPresented() throws { + // GIVEN + var variantManagerMock = MockVariantManager() + variantManagerMock.isSupportedBlock = { feature in + feature != .newOnboardingIntro + } + let sut = ContextualOnboardingPresenter(variantManager: variantManagerMock) + let parent = TabViewControllerMock() + XCTAssertFalse(parent.didCallPerformSegue) + XCTAssertNil(parent.capturedSegueIdentifier) + XCTAssertNil(parent.capturedSender) + + // WHEN + sut.presentContextualOnboarding(for: .afterSearch, in: parent) + + // THEN + XCTAssertTrue(parent.didCallPerformSegue) + XCTAssertEqual(parent.capturedSegueIdentifier, "DaxDialog") + let sender = try XCTUnwrap(parent.capturedSender as? DaxDialogs.BrowsingSpec) + XCTAssertEqual(sender, DaxDialogs.BrowsingSpec.afterSearch) + } + + func testWhenPresentContextualOnboardingAndVariantSupportsNewOnboardingIntroThenThenNewContextualOnboardingIsPresented() { + // GIVEN + var variantManagerMock = MockVariantManager() + variantManagerMock.isSupportedBlock = { feature in + feature == .newOnboardingIntro + } + let sut = ContextualOnboardingPresenter(variantManager: variantManagerMock) + let parent = TabViewControllerMock() + XCTAssertFalse(parent.didCallAddChild) + XCTAssertNil(parent.capturedChild) + + // WHEN + sut.presentContextualOnboarding(for: .afterSearch, in: parent) + + // THEN + XCTAssertTrue(parent.didCallAddChild) + XCTAssertNotNil(parent.capturedChild) + XCTAssertTrue(parent.capturedChild is UIHostingController>) + } + +} + +final class TabViewControllerMock: UIViewController, TabViewControllerType { + var daxDialogsStackView: UIStackView = UIStackView() + var webViewContainerView: UIView = UIView() + var daxContextualOnboardingController: UIViewController? + + private(set) var didCallPerformSegue = false + private(set) var capturedSegueIdentifier: String? + private(set) var capturedSender: Any? + + private(set) var didCallAddChild = false + private(set) var capturedChild: UIViewController? + + override func performSegue(withIdentifier identifier: String, sender: Any?) { + didCallPerformSegue = true + capturedSegueIdentifier = identifier + capturedSender = sender + } + + override func addChild(_ childController: UIViewController) { + didCallAddChild = true + capturedChild = childController + } + +} diff --git a/DuckDuckGoTests/OnboardingNavigationDelegateTests.swift b/DuckDuckGoTests/OnboardingNavigationDelegateTests.swift index db0a9d8e69..fb526dd31d 100644 --- a/DuckDuckGoTests/OnboardingNavigationDelegateTests.swift +++ b/DuckDuckGoTests/OnboardingNavigationDelegateTests.swift @@ -68,7 +68,8 @@ final class OnboardingNavigationDelegateTests: XCTestCase { previewsSource: TabPreviewsSource(), tabsModel: tabsModel, syncPausedStateManager: CapturingSyncPausedStateManager(), - variantManager: MockVariantManager()) + variantManager: MockVariantManager(), + contextualOnboardingPresenter: ContextualOnboardingPresenter(variantManager: DefaultVariantManager())) let window = UIWindow(frame: UIScreen.main.bounds) window.rootViewController = UIViewController() window.makeKeyAndVisible()