diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 425c8d8966..cf066c0c8b 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -712,6 +712,7 @@ 9FB893F82C784A1700332E5E /* Onboarding in Frameworks */ = {isa = PBXBuildFile; productRef = 9FB893F72C784A1700332E5E /* Onboarding */; }; 9FCFCD802C6AF56D006EB7A0 /* LaunchOptionsHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCFCD7F2C6AF56D006EB7A0 /* LaunchOptionsHandlerTests.swift */; }; 9FCFCD812C6B020D006EB7A0 /* LaunchOptionsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCFCD7D2C6AF52A006EB7A0 /* LaunchOptionsHandler.swift */; }; + 9FCFCD852C75C91A006EB7A0 /* ProgressBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCFCD842C75C91A006EB7A0 /* ProgressBarView.swift */; }; 9FE05CEE2C36424E00D9046B /* OnboardingPixelReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FE05CED2C36424E00D9046B /* OnboardingPixelReporter.swift */; }; 9FE05CF12C36468A00D9046B /* OnboardingPixelReporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FE05CEF2C3642F900D9046B /* OnboardingPixelReporterTests.swift */; }; 9FE08BD32C2A5B88001D5EBC /* OnboardingTextStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FE08BD22C2A5B88001D5EBC /* OnboardingTextStyles.swift */; }; @@ -2483,6 +2484,7 @@ 9FB0271C2C293619009EA190 /* OnboardingIntroViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingIntroViewModel.swift; sourceTree = ""; }; 9FCFCD7D2C6AF52A006EB7A0 /* LaunchOptionsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LaunchOptionsHandler.swift; path = ../DuckDuckGo/LaunchOptionsHandler.swift; sourceTree = ""; }; 9FCFCD7F2C6AF56D006EB7A0 /* LaunchOptionsHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchOptionsHandlerTests.swift; sourceTree = ""; }; + 9FCFCD842C75C91A006EB7A0 /* ProgressBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressBarView.swift; sourceTree = ""; }; 9FE05CED2C36424E00D9046B /* OnboardingPixelReporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingPixelReporter.swift; sourceTree = ""; }; 9FE05CEF2C3642F900D9046B /* OnboardingPixelReporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingPixelReporterTests.swift; sourceTree = ""; }; 9FE08BD22C2A5B88001D5EBC /* OnboardingTextStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTextStyles.swift; sourceTree = ""; }; @@ -4756,6 +4758,7 @@ 9F23B7FF2C2BABE000950875 /* OnboardingIntro */, 9F5E5AAA2C3D0FAA00165F54 /* ContextualOnboarding */, 9F23B8002C2BC94400950875 /* OnboardingBackground.swift */, + 9FCFCD842C75C91A006EB7A0 /* ProgressBarView.swift */, ); path = OnboardingExperiment; sourceTree = ""; @@ -7578,6 +7581,7 @@ 8505836A219F424500ED4EDB /* UIAlertControllerExtension.swift in Sources */, C12726F22A5FF8CB00215B02 /* EmailSignupPromptViewController.swift in Sources */, 983EABB8236198F6003948D1 /* DatabaseMigration.swift in Sources */, + 9FCFCD852C75C91A006EB7A0 /* ProgressBarView.swift in Sources */, 6F3537A42C4AC140009F8717 /* NewTabPageDaxLogoView.swift in Sources */, 314C92B827C3DD660042EC96 /* QuickLookPreviewView.swift in Sources */, 6F5345AF2C53F2DE00424A43 /* NewTabPageSettingsPersistentStorage.swift in Sources */, diff --git a/DuckDuckGo/OnboardingExperiment/OnboardingIntro/OnboardingIntroViewModel.swift b/DuckDuckGo/OnboardingExperiment/OnboardingIntro/OnboardingIntroViewModel.swift index ffda3bda2b..15839ee5ab 100644 --- a/DuckDuckGo/OnboardingExperiment/OnboardingIntro/OnboardingIntroViewModel.swift +++ b/DuckDuckGo/OnboardingExperiment/OnboardingIntro/OnboardingIntroViewModel.swift @@ -25,6 +25,8 @@ final class OnboardingIntroViewModel: ObservableObject { @Published private(set) var state: OnboardingView.ViewState = .landing var onCompletingOnboardingIntro: (() -> Void)? + private var introSteps: [OnboardingIntroStep] + private let pixelReporter: OnboardingIntroPixelReporting private let onboardingManager: OnboardingHighlightsManaging private let urlOpener: URLOpener @@ -37,15 +39,16 @@ final class OnboardingIntroViewModel: ObservableObject { self.pixelReporter = pixelReporter self.onboardingManager = onboardingManager self.urlOpener = urlOpener + introSteps = onboardingManager.isOnboardingHighlightsEnabled ? OnboardingIntroStep.highlightsFlow : OnboardingIntroStep.defaultFlow } func onAppear() { - state = .onboarding(.startOnboardingDialog) + state = makeViewState(for: .introDialog) pixelReporter.trackOnboardingIntroImpression() } func startOnboardingAction() { - state = .onboarding(.browsersComparisonDialog) + state = makeViewState(for: .browserComparison) pixelReporter.trackBrowserComparisonImpression() } @@ -63,7 +66,10 @@ final class OnboardingIntroViewModel: ObservableObject { } func appIconPickerContinueAction() { - // TODO: Remove below and implement proper logic + state = makeViewState(for: .addressBarPositionSelection) + } + + func selectAddressBarPositionAction() { onCompletingOnboardingIntro?() } @@ -73,12 +79,52 @@ final class OnboardingIntroViewModel: ObservableObject { private extension OnboardingIntroViewModel { + func makeViewState(for introStep: OnboardingIntroStep) -> OnboardingView.ViewState { + + func stepInfo() -> OnboardingView.ViewState.Intro.StepInfo { + guard + let currentStepIndex = introSteps.firstIndex(of: introStep), + onboardingManager.isOnboardingHighlightsEnabled + else { + return .hidden + } + + // Remove startOnboardingDialog from the count of total steps since we don't show the progress for that step. + return OnboardingView.ViewState.Intro.StepInfo(currentStep: currentStepIndex, totalSteps: introSteps.count - 1) + } + + let viewState = switch introStep { + case .introDialog: + OnboardingView.ViewState.onboarding(.init(type: .startOnboardingDialog, step: .hidden)) + case .browserComparison: + OnboardingView.ViewState.onboarding(.init(type: .browsersComparisonDialog, step: stepInfo())) + case .appIconSelection: + OnboardingView.ViewState.onboarding(.init(type: .chooseAppIconDialog, step: stepInfo())) + case .addressBarPositionSelection: + OnboardingView.ViewState.onboarding(.init(type: .chooseAddressBarPositionDialog, step: stepInfo())) + } + + return viewState + } + func handleSetDefaultBrowserAction() { if onboardingManager.isOnboardingHighlightsEnabled { - state = .onboarding(.chooseAppIconDialog) + state = makeViewState(for: .appIconSelection) } else { onCompletingOnboardingIntro?() } } } + +// MARK: - OnboardingIntroStep + +private enum OnboardingIntroStep { + case introDialog + case browserComparison + case appIconSelection + case addressBarPositionSelection + + static let defaultFlow: [OnboardingIntroStep] = [.introDialog, .browserComparison] + static let highlightsFlow: [OnboardingIntroStep] = [.introDialog, .browserComparison, .appIconSelection, .addressBarPositionSelection] +} diff --git a/DuckDuckGo/OnboardingExperiment/OnboardingIntro/OnboardingView.swift b/DuckDuckGo/OnboardingExperiment/OnboardingIntro/OnboardingView.swift index 2af2121b7a..02f280319f 100644 --- a/DuckDuckGo/OnboardingExperiment/OnboardingIntro/OnboardingView.swift +++ b/DuckDuckGo/OnboardingExperiment/OnboardingIntro/OnboardingView.swift @@ -19,6 +19,7 @@ import SwiftUI import Onboarding +import struct DuckUI.PrimaryButtonStyle // MARK: - OnboardingView @@ -64,11 +65,11 @@ struct OnboardingView: View { showDialogBox: $showDaxDialogBox, onTapGesture: { withAnimation { - switch model.state { - case .onboarding(.startOnboardingDialog): + switch model.state.intro?.type { + case .startOnboardingDialog: showIntroButton = true animateIntroText = false - case .onboarding(.browsersComparisonDialog): + case .browsersComparisonDialog: showComparisonButton = true animateComparisonText = false default: break @@ -77,17 +78,20 @@ struct OnboardingView: View { }, content: { VStack { - switch state { + switch state.type { case .startOnboardingDialog: introView case .browsersComparisonDialog: browsersComparisonView case .chooseAppIconDialog: appIconPickerView + case .chooseAddressBarPositionDialog: + addressBarPreferenceSelectionView } } } ) + .onboardingProgressIndicator(currentStep: state.step.currentStep, totalSteps: state.step.totalSteps) } .frame(width: geometry.size.width, alignment: .center) .offset(y: geometry.size.height * Metrics.dialogVerticalOffsetPercentage.build(v: verticalSizeClass, h: horizontalSizeClass)) @@ -136,13 +140,27 @@ struct OnboardingView: View { } private var appIconPickerView: some View { - // TODO: Implement AppIconPicker + // TODO: Implement View VStack(spacing: 30) { Text(verbatim: "Choose App Icon") Button(action: model.appIconPickerContinueAction) { - Text(verbatim: "Continue") + Text(verbatim: "Next") } + .buttonStyle(PrimaryButtonStyle()) + } + .onboardingDaxDialogStyle() + } + + private var addressBarPreferenceSelectionView: some View { + // TODO: Implement View + VStack(spacing: 30) { + Text(verbatim: "Choose Address Bar Position") + + Button(action: model.selectAddressBarPositionAction) { + Text(verbatim: "Next") + } + .buttonStyle(PrimaryButtonStyle()) } .onboardingDaxDialogStyle() } @@ -181,18 +199,44 @@ extension OnboardingView { enum ViewState: Equatable { case landing case onboarding(Intro) + + var intro: Intro? { + switch self { + case .landing: + return nil + case let .onboarding(intro): + return intro + } + } } } extension OnboardingView.ViewState { + + struct Intro: Equatable { + let type: IntroType + let step: StepInfo + } - enum Intro: Equatable { +} + +extension OnboardingView.ViewState.Intro { + + enum IntroType: Equatable { case startOnboardingDialog case browsersComparisonDialog case chooseAppIconDialog + case chooseAddressBarPositionDialog } - + + struct StepInfo: Equatable { + let currentStep: Int + let totalSteps: Int + + static let hidden = StepInfo(currentStep: 0, totalSteps: 0) + } + } // MARK: - Metrics @@ -202,6 +246,24 @@ private enum Metrics { static let daxDialogVisibilityDelay: TimeInterval = 0.5 static let comparisonChartAnimationDuration = 0.25 static let dialogVerticalOffsetPercentage = MetricBuilder(value: 0.1).smallIphone(0.01) + static let progressBarTrailingPadding: CGFloat = 16.0 + static let progressBarTopPadding: CGFloat = 12.0 +} + +// MARK: - Helpers + +private extension View { + + func onboardingProgressIndicator(currentStep: Int, totalSteps: Int) -> some View { + overlay(alignment: .topTrailing) { + OnboardingProgressIndicator(stepInfo: .init(currentStep: currentStep, totalSteps: totalSteps)) + .padding(.trailing, Metrics.progressBarTrailingPadding) + .padding(.top, Metrics.progressBarTopPadding) + .transition(.identity) + .visibility(totalSteps == 0 ? .invisible : .visible) + } + } + } // MARK: - Preview diff --git a/DuckDuckGo/OnboardingExperiment/ProgressBarView.swift b/DuckDuckGo/OnboardingExperiment/ProgressBarView.swift new file mode 100644 index 0000000000..cebcc13db3 --- /dev/null +++ b/DuckDuckGo/OnboardingExperiment/ProgressBarView.swift @@ -0,0 +1,159 @@ +// +// ProgressBarView.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 OnboardingProgressIndicator: View { + + struct StepInfo { + let currentStep: Int + let totalSteps: Int + + fileprivate var percentage: Double { + guard totalSteps > 0 else { return 0 } + return Double(currentStep) / Double(totalSteps) * 100 + } + } + + let stepInfo: StepInfo + + var body: some View { + VStack(spacing: OnboardingProgressMetrics.verticalSpacing) { + HStack { + Spacer() + Text("\(stepInfo.currentStep) / \(stepInfo.totalSteps)") + .onboardingProgressTitleStyle() + .padding(.trailing, OnboardingProgressMetrics.textPadding) + } + ProgressBarView(progress: stepInfo.percentage) + .frame(width: OnboardingProgressMetrics.progressBarSize.width, height: OnboardingProgressMetrics.progressBarSize.height) + } + .fixedSize() + } +} + +private enum OnboardingProgressMetrics { + static let verticalSpacing: CGFloat = 8 + static let textPadding: CGFloat = 4 + static let progressBarSize = CGSize(width: 64, height: 4) +} + +struct ProgressBarView: View { + @Environment(\.colorScheme) private var colorScheme + + let progress: Double + + var body: some View { + Capsule() + .foregroundStyle(backgroundColor) + .overlay( + GeometryReader { proxy in + ProgressBarGradient() + .clipShape(Capsule().inset(by: ProgressBarMetrics.strokeWidth / 2)) + .frame(width: progress * proxy.size.width / 100) + .animation(.easeInOut, value: progress) + } + ) + .overlay( + Capsule() + .stroke(borderColor, lineWidth: ProgressBarMetrics.strokeWidth) + ) + } + + private var backgroundColor: Color { + colorScheme == .light ? ProgressBarMetrics.backgroundLight : ProgressBarMetrics.backgroundDark + } + + private var borderColor: Color { + colorScheme == .light ? ProgressBarMetrics.borderLight : ProgressBarMetrics.borderDark + } + +} + +private enum ProgressBarMetrics { + static let backgroundLight: Color = .black.opacity(0.06) + static let borderLight: Color = .black.opacity(0.18) + static let backgroundDark: Color = .white.opacity(0.09) + static let borderDark: Color = .white.opacity(0.18) + static let strokeWidth: CGFloat = 1 +} + +struct ProgressBarGradient: View { + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + let colors: [Color] + switch colorScheme { + case .light: + colors = lightGradientColors + case .dark: + colors = darkGradientColors + @unknown default: + colors = lightGradientColors + } + + return LinearGradient( + colors: colors, + startPoint: .leading, + endPoint: .trailing + ) + } + + private var lightGradientColors: [Color] { + [ + .init(0x3969EF, alpha: 1.0), + .init(0x6B4EBA, alpha: 1.0), + .init(0xDE5833, alpha: 1.0), + ] + } + + private var darkGradientColors: [Color] { + [ + .init(0x3969EF, alpha: 1.0), + .init(0x6B4EBA, alpha: 1.0), + .init(0xDE5833, alpha: 1.0), + ] + } +} + +#Preview("Onboarding Progress Indicator") { + struct PreviewWrapper: View { + @State var stepInfo = OnboardingProgressIndicator.StepInfo(currentStep: 1, totalSteps: 3) + + var body: some View { + VStack(spacing: 100) { + OnboardingProgressIndicator(stepInfo: stepInfo) + + Button(action: { + let nextStep = stepInfo.currentStep < stepInfo.totalSteps ? stepInfo.currentStep + 1 : 1 + stepInfo = OnboardingProgressIndicator.StepInfo(currentStep: nextStep, totalSteps: stepInfo.totalSteps) + }, label: { + Text("Update Progress") + }) + } + } + } + + return PreviewWrapper() +} + +#Preview("Progress Bar") { + ProgressBarView(progress: 80) + .frame(width: 200, height: 8) +} diff --git a/DuckDuckGo/OnboardingExperiment/Styles/OnboardingTextStyles.swift b/DuckDuckGo/OnboardingExperiment/Styles/OnboardingTextStyles.swift index 283c8cff3a..113ea7c39e 100644 --- a/DuckDuckGo/OnboardingExperiment/Styles/OnboardingTextStyles.swift +++ b/DuckDuckGo/OnboardingExperiment/Styles/OnboardingTextStyles.swift @@ -30,7 +30,7 @@ extension OnboardingStyles { func body(content: Content) -> some View { let view = content .font(.system(size: fontSize, weight: .bold)) - .foregroundColor(.primary) + .foregroundStyle(Color.primary) .multilineTextAlignment(.center) if #available(iOS 16, *) { @@ -42,6 +42,22 @@ extension OnboardingStyles { } + struct ProgressBarTitleStyle: ViewModifier { + + func body(content: Content) -> some View { + let view = content + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(Color.secondary) + + if #available(iOS 16, *) { + return view.kerning(0.06) + } else { + return view + } + } + + } + } extension View { @@ -49,5 +65,9 @@ extension View { func onboardingTitleStyle(fontSize: CGFloat) -> some View { modifier(OnboardingStyles.TitleStyle(fontSize: fontSize)) } - + + func onboardingProgressTitleStyle() -> some View { + modifier(OnboardingStyles.ProgressBarTitleStyle()) + } + } diff --git a/DuckDuckGoTests/OnboardingIntroViewModelTests.swift b/DuckDuckGoTests/OnboardingIntroViewModelTests.swift index f3ad300002..0d12240db5 100644 --- a/DuckDuckGoTests/OnboardingIntroViewModelTests.swift +++ b/DuckDuckGoTests/OnboardingIntroViewModelTests.swift @@ -21,12 +21,23 @@ import XCTest @testable import DuckDuckGo final class OnboardingIntroViewModelTests: XCTestCase { + private var onboardingManager: OnboardingHighlightsManagerMock! + + override func setUpWithError() throws { + try super.setUpWithError() + onboardingManager = OnboardingHighlightsManagerMock() + } + + override func tearDownWithError() throws { + onboardingManager = nil + try super.tearDownWithError() + } // MARK: - State + Actions func testWhenSubscribeToViewStateThenShouldSendLanding() { // GIVEN - let sut = OnboardingIntroViewModel(pixelReporter: OnboardingIntroPixelReporterMock(), urlOpener: MockURLOpener()) + let sut = OnboardingIntroViewModel(pixelReporter: OnboardingIntroPixelReporterMock(), onboardingManager: onboardingManager, urlOpener: MockURLOpener()) // WHEN let result = sut.state @@ -37,32 +48,32 @@ final class OnboardingIntroViewModelTests: XCTestCase { func testWhenOnAppearIsCalledThenViewStateChangesToStartOnboardingDialog() { // GIVEN - let sut = OnboardingIntroViewModel(pixelReporter: OnboardingIntroPixelReporterMock(), urlOpener: MockURLOpener()) + let sut = OnboardingIntroViewModel(pixelReporter: OnboardingIntroPixelReporterMock(), onboardingManager: onboardingManager, urlOpener: MockURLOpener()) XCTAssertEqual(sut.state, .landing) // WHEN sut.onAppear() // THEN - XCTAssertEqual(sut.state, .onboarding(.startOnboardingDialog)) + XCTAssertEqual(sut.state, .onboarding(.init(type: .startOnboardingDialog, step: .hidden))) } func testWhenStartOnboardingActionIsCalledThenViewStateChangesToBrowsersComparisonDialog() { // GIVEN - let sut = OnboardingIntroViewModel(pixelReporter: OnboardingIntroPixelReporterMock()) + let sut = OnboardingIntroViewModel(pixelReporter: OnboardingIntroPixelReporterMock(), onboardingManager: onboardingManager) XCTAssertEqual(sut.state, .landing) // WHEN sut.startOnboardingAction() // THEN - XCTAssertEqual(sut.state, .onboarding(.browsersComparisonDialog)) + XCTAssertEqual(sut.state, .onboarding(.init(type: .browsersComparisonDialog, step: .hidden))) } func testWhenSetDefaultBrowserActionIsCalledThenURLOpenerOpensSettingsURL() { // GIVEN let urlOpenerMock = MockURLOpener() - let sut = OnboardingIntroViewModel(pixelReporter: OnboardingIntroPixelReporterMock(), urlOpener: urlOpenerMock) + let sut = OnboardingIntroViewModel(pixelReporter: OnboardingIntroPixelReporterMock(), onboardingManager: onboardingManager, urlOpener: urlOpenerMock) XCTAssertFalse(urlOpenerMock.didCallOpenURL) XCTAssertNil(urlOpenerMock.capturedURL) @@ -77,7 +88,7 @@ final class OnboardingIntroViewModelTests: XCTestCase { func testWhenSetDefaultBrowserActionIsCalledThenOnCompletingOnboardingIntroIsCalled() { // GIVEN var didCallOnCompletingOnboardingIntro = false - let sut = OnboardingIntroViewModel(pixelReporter: OnboardingIntroPixelReporterMock(), urlOpener: MockURLOpener()) + let sut = OnboardingIntroViewModel(pixelReporter: OnboardingIntroPixelReporterMock(), onboardingManager: onboardingManager, urlOpener: MockURLOpener()) sut.onCompletingOnboardingIntro = { didCallOnCompletingOnboardingIntro = true } @@ -93,7 +104,7 @@ final class OnboardingIntroViewModelTests: XCTestCase { func testWhenCancelSetDefaultBrowserActionIsCalledThenOnCompletingOnboardingIntroIsCalled() { // GIVEN var didCallOnCompletingOnboardingIntro = false - let sut = OnboardingIntroViewModel(pixelReporter: OnboardingIntroPixelReporterMock(), urlOpener: MockURLOpener()) + let sut = OnboardingIntroViewModel(pixelReporter: OnboardingIntroPixelReporterMock(), onboardingManager: onboardingManager, urlOpener: MockURLOpener()) sut.onCompletingOnboardingIntro = { didCallOnCompletingOnboardingIntro = true } @@ -106,12 +117,108 @@ final class OnboardingIntroViewModelTests: XCTestCase { XCTAssertTrue(didCallOnCompletingOnboardingIntro) } + // MARK: - Highlights State + Actions + + func testWhenSubscribeToViewStateAndIsHighlightsFlowThenShouldSendLanding() { + // GIVEN + onboardingManager.isOnboardingHighlightsEnabled = true + let sut = OnboardingIntroViewModel(pixelReporter: OnboardingIntroPixelReporterMock(), onboardingManager: onboardingManager, urlOpener: MockURLOpener()) + + // WHEN + let result = sut.state + + // THEN + XCTAssertEqual(result, .landing) + } + + func testWhenOnAppearIsCalledAndIsHighlightsFlowThenViewStateChangesToStartOnboardingDialogAndProgressIsHidden() { + // GIVEN + onboardingManager.isOnboardingHighlightsEnabled = true + let sut = OnboardingIntroViewModel(pixelReporter: OnboardingIntroPixelReporterMock(), onboardingManager: onboardingManager, urlOpener: MockURLOpener()) + XCTAssertEqual(sut.state, .landing) + + // WHEN + sut.onAppear() + + // THEN + XCTAssertEqual(sut.state, .onboarding(.init(type: .startOnboardingDialog, step: .hidden))) + } + + func testWhenStartOnboardingActionIsCalledAndIsHighlightsFlowThenViewStateChangesToBrowsersComparisonDialogAndProgressIs1Of3() { + // GIVEN + onboardingManager.isOnboardingHighlightsEnabled = true + let sut = OnboardingIntroViewModel(pixelReporter: OnboardingIntroPixelReporterMock(), onboardingManager: onboardingManager) + XCTAssertEqual(sut.state, .landing) + + // WHEN + sut.startOnboardingAction() + + // THEN + XCTAssertEqual(sut.state, .onboarding(.init(type: .browsersComparisonDialog, step: .init(currentStep: 1, totalSteps: 3)))) + } + + func testWhenSetDefaultBrowserActionIsCalledAndIsHighlightsFlowThenViewStateChangesToChooseAppIconDialogAndProgressIs2Of3() { + // GIVEN + onboardingManager.isOnboardingHighlightsEnabled = true + let sut = OnboardingIntroViewModel(pixelReporter: OnboardingIntroPixelReporterMock(), onboardingManager: onboardingManager, urlOpener: MockURLOpener()) + XCTAssertEqual(sut.state, .landing) + + // WHEN + sut.setDefaultBrowserAction() + + // THEN + XCTAssertEqual(sut.state, .onboarding(.init(type: .chooseAppIconDialog, step: .init(currentStep: 2, totalSteps: 3)))) + } + + func testWhenCancelSetDefaultBrowserActionIsCalledAndIsHighlightsFlowThenViewStateChangesToChooseAppIconDialogAndProgressIs2Of3() { + // GIVEN + onboardingManager.isOnboardingHighlightsEnabled = true + let sut = OnboardingIntroViewModel(pixelReporter: OnboardingIntroPixelReporterMock(), onboardingManager: onboardingManager, urlOpener: MockURLOpener()) + XCTAssertEqual(sut.state, .landing) + + // WHEN + sut.cancelSetDefaultBrowserAction() + + // THEN + XCTAssertEqual(sut.state, .onboarding(.init(type: .chooseAppIconDialog, step: .init(currentStep: 2, totalSteps: 3)))) + } + + func testWhenAppIconPickerContinueActionIsCalledAndIsHighlightsFlowThenViewStateChangesToChooseAddressBarPositionDialogAndProgressIs3Of3() { + // GIVEN + onboardingManager.isOnboardingHighlightsEnabled = true + let sut = OnboardingIntroViewModel(pixelReporter: OnboardingIntroPixelReporterMock(), onboardingManager: onboardingManager) + XCTAssertEqual(sut.state, .landing) + + // WHEN + sut.appIconPickerContinueAction() + + // THEN + XCTAssertEqual(sut.state, .onboarding(.init(type: .chooseAddressBarPositionDialog, step: .init(currentStep: 3, totalSteps: 3)))) + } + + func testWhenSelectAddressBarPositionActionIsCalledAndIsHighlightsFlowThenOnCompletingOnboardingIntroIsCalled() { + // GIVEN + onboardingManager.isOnboardingHighlightsEnabled = true + var didCallOnCompletingOnboardingIntro = false + let sut = OnboardingIntroViewModel(pixelReporter: OnboardingIntroPixelReporterMock(), onboardingManager: onboardingManager, urlOpener: MockURLOpener()) + sut.onCompletingOnboardingIntro = { + didCallOnCompletingOnboardingIntro = true + } + XCTAssertFalse(didCallOnCompletingOnboardingIntro) + + // WHEN + sut.selectAddressBarPositionAction() + + // THEN + XCTAssertTrue(didCallOnCompletingOnboardingIntro) + } + // MARK: - Pixels func testWhenOnAppearIsCalledThenPixelReporterTrackOnboardingIntroImpression() { // GIVEN let pixelReporterMock = OnboardingIntroPixelReporterMock() - let sut = OnboardingIntroViewModel(pixelReporter: pixelReporterMock, urlOpener: MockURLOpener()) + let sut = OnboardingIntroViewModel(pixelReporter: pixelReporterMock, onboardingManager: onboardingManager, urlOpener: MockURLOpener()) XCTAssertFalse(pixelReporterMock.didCallTrackOnboardingIntroImpression) // WHEN @@ -124,7 +231,7 @@ final class OnboardingIntroViewModelTests: XCTestCase { func testWhenStartOnboardingActionIsCalledThenPixelReporterTrackBrowserComparisonImpression() { // GIVEN let pixelReporterMock = OnboardingIntroPixelReporterMock() - let sut = OnboardingIntroViewModel(pixelReporter: pixelReporterMock, urlOpener: MockURLOpener()) + let sut = OnboardingIntroViewModel(pixelReporter: pixelReporterMock, onboardingManager: onboardingManager, urlOpener: MockURLOpener()) XCTAssertFalse(pixelReporterMock.didCallTrackBrowserComparisonImpression) // WHEN @@ -137,7 +244,7 @@ final class OnboardingIntroViewModelTests: XCTestCase { func testWhenChooseBrowserIsCalledThenPixelReporterTrackChooseBrowserCTAAction() { // GIVEN let pixelReporterMock = OnboardingIntroPixelReporterMock() - let sut = OnboardingIntroViewModel(pixelReporter: pixelReporterMock, urlOpener: MockURLOpener()) + let sut = OnboardingIntroViewModel(pixelReporter: pixelReporterMock, onboardingManager: onboardingManager, urlOpener: MockURLOpener()) XCTAssertFalse(pixelReporterMock.didCallTrackChooseBrowserCTAAction) // WHEN @@ -166,3 +273,7 @@ private final class OnboardingIntroPixelReporterMock: OnboardingIntroPixelReport didCallTrackChooseBrowserCTAAction = true } } + +private class OnboardingHighlightsManagerMock: OnboardingHighlightsManaging { + var isOnboardingHighlightsEnabled: Bool = false +}