diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme
index 36f46def7..e98a86f81 100644
--- a/.swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme
@@ -455,6 +455,20 @@
ReferencedContainer = "container:">
+
+
+
+
Void)?
+ let imageName: String?
+ let customActionView: AnyView?
+ let orientation: Orientation
+
+ private let itemsToAnimate: [DisplayableTypes]
+
+ public init(
+ orientation: Orientation = .verticalStack,
+ title: String? = nil,
+ titleFont: Font? = nil,
+ message: NSAttributedString,
+ messageFont: Font? = nil,
+ list: [ContextualOnboardingListItem] = [],
+ listAction: ((_: ContextualOnboardingListItem) -> Void)? = nil,
+ imageName: String? = nil,
+ customActionView: AnyView? = nil
+ ) {
+ self.title = title
+ self.titleFont = titleFont
+ self.message = message
+ self.messageFont = messageFont
+ self.list = list
+ self.listAction = listAction
+ self.imageName = imageName
+ self.customActionView = customActionView
+ self.orientation = orientation
+
+ var itemsToAnimate: [DisplayableTypes] = []
+ if title != nil {
+ itemsToAnimate.append(.title)
+ }
+ itemsToAnimate.append(.message)
+ if !list.isEmpty {
+ itemsToAnimate.append(.list)
+ }
+ if imageName != nil {
+ itemsToAnimate.append(.image)
+ }
+ if customActionView != nil {
+ itemsToAnimate.append(.button)
+ }
+
+ self.itemsToAnimate = itemsToAnimate
+ }
+
+ @State private var startTypingTitle: Bool = false
+ @State private var startTypingMessage: Bool = false
+ @State private var nonTypingAnimatableItems: NonTypingAnimatableItems = []
+
+ public var body: some View {
+ Group {
+ if orientation == .verticalStack {
+ VStack {
+ typingElements
+ nonTypingElements
+ }
+ } else if case .horizontalStack(let alignment) = orientation {
+ HStack(alignment: alignment, spacing: 10) {
+ typingElements
+ Spacer()
+ nonTypingElements
+ }
+ }
+ }
+ .onAppear {
+ Task { @MainActor in
+ try await Task.sleep(interval: 0.3)
+ startAnimating()
+ }
+ }
+ }
+
+ @ViewBuilder var typingElements: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ titleView
+ messageView
+ }
+ }
+
+ @ViewBuilder var nonTypingElements: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ listView
+ .visibility(nonTypingAnimatableItems.contains(.list) ? .visible : .invisible)
+ imageView
+ .visibility(nonTypingAnimatableItems.contains(.image) ? .visible : .invisible)
+ actionView
+ .visibility(nonTypingAnimatableItems.contains(.button) ? .visible : .invisible)
+ }
+ }
+
+ @ViewBuilder
+ private var titleView: some View {
+ if let title {
+ let animatingText = AnimatableTypingText(title, startAnimating: $startTypingTitle, onTypingFinished: {
+ startTypingMessage = true
+ })
+
+ if let titleFont {
+ animatingText.font(titleFont)
+ } else {
+ animatingText
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var messageView: some View {
+ let animatingText = AnimatableTypingText(message, startAnimating: $startTypingMessage, onTypingFinished: {
+ animateNonTypingItems()
+ })
+ if let messageFont {
+ animatingText.font(messageFont)
+ } else {
+ animatingText
+ }
+ }
+
+ @ViewBuilder
+ private var listView: some View {
+ if let listAction {
+ ContextualOnboardingListView(list: list, action: listAction)
+ }
+ }
+
+ @ViewBuilder
+ private var imageView: some View {
+ if let imageName {
+ HStack {
+ Spacer()
+ Image(imageName)
+ Spacer()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var actionView: some View {
+ if let customActionView {
+ customActionView
+ }
+ }
+
+ enum DisplayableTypes {
+ case title
+ case message
+ case list
+ case image
+ case button
+ }
+}
+
+struct NonTypingAnimatableItems: OptionSet {
+ let rawValue: Int
+
+ static let list = NonTypingAnimatableItems(rawValue: 1 << 0)
+ static let image = NonTypingAnimatableItems(rawValue: 1 << 1)
+ static let button = NonTypingAnimatableItems(rawValue: 1 << 2)
+}
+
+// MARK: - Auxiliary Functions
+
+extension ContextualDaxDialogContent {
+
+ private func startAnimating() {
+ if itemsToAnimate.contains(.title) {
+ startTypingTitle = true
+ } else if itemsToAnimate.contains(.message) {
+ startTypingMessage = true
+ }
+ }
+
+ private func animateNonTypingItems() {
+ // Remove typing items and animate sequentially non typing items
+ let nonTypingItems = itemsToAnimate.filter { $0 != .title && $0 != .message }
+
+ nonTypingItems.enumerated().forEach { index, item in
+ let delayForItem = Metrics.animationDelay * Double(index + 1)
+ withAnimation(.easeIn(duration: Metrics.animationDuration).delay(delayForItem)) {
+ switch item {
+ case .title, .message:
+ // Typing items. they don't need to animate sequentially.
+ break
+ case .list:
+ nonTypingAnimatableItems.insert(.list)
+ case .image:
+ nonTypingAnimatableItems.insert(.image)
+ case .button:
+ nonTypingAnimatableItems.insert(.button)
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Metrics
+
+enum Metrics {
+ static let animationDuration = 0.25
+ static let animationDelay = 0.3
+}
+
+// MARK: - Preview
+
+#Preview("Intro Dialog - text") {
+ let fullString = "Instantly clear your browsing activity with the Fire Button.\n\n Give it a try! ☝️"
+ let boldString = "Fire Button."
+
+ let attributedString = NSMutableAttributedString(string: fullString)
+ let boldFontAttribute: [NSAttributedString.Key: Any] = [
+ .font: PlatformFont.systemFont(ofSize: 15, weight: .bold)
+ ]
+
+ if let boldRange = fullString.range(of: boldString) {
+ let nsBoldRange = NSRange(boldRange, in: fullString)
+ attributedString.addAttributes(boldFontAttribute, range: nsBoldRange)
+ }
+
+ return ContextualDaxDialogContent(message: attributedString)
+ .padding()
+ .preferredColorScheme(.light)
+}
+
+#Preview("Intro Dialog - text and button") {
+ let contextualText = NSMutableAttributedString(string: "Sabrina is the best\n\nBelieve me! ☝️")
+ return ContextualDaxDialogContent(
+ message: contextualText,
+ customActionView: AnyView(Button("Got it!", action: {})))
+ .padding()
+ .preferredColorScheme(.light)
+}
+
+#Preview("Intro Dialog - title, text, image and button") {
+ let contextualText = NSMutableAttributedString(string: "Sabrina is the best\n\nBelieve me! ☝️")
+ return ContextualDaxDialogContent(
+ title: "Who is the best?",
+ message: contextualText,
+ imageName: "Sync-Desktop-New-128",
+ customActionView: AnyView(Button("Got it!", action: {})))
+ .padding()
+ .preferredColorScheme(.light)
+}
+
+#Preview("Intro Dialog - title, text, list") {
+ let contextualText = NSMutableAttributedString(string: "Sabrina is the best!\n\n Alessandro is ok I guess...")
+ let list = [
+ ContextualOnboardingListItem.search(title: "Search"),
+ ContextualOnboardingListItem.site(title: "Website"),
+ ContextualOnboardingListItem.surprise(title: "Surprise", visibleTitle: "Surpeise me!"),
+ ]
+ return ContextualDaxDialogContent(
+ title: "Who is the best?",
+ message: contextualText,
+ list: list,
+ listAction: { _ in })
+ .padding()
+ .preferredColorScheme(.light)
+}
+
+#Preview("en_GB list") {
+ ContextualDaxDialogContent(title: "title",
+ message: NSAttributedString(string: "this is a message"),
+ list: OnboardingSuggestedSitesProvider(countryProvider: Locale(identifier: "en_GB"), surpriseItemTitle: "surperise").list,
+ listAction: { _ in })
+ .padding()
+}
+
+#Preview("en_US list") {
+ ContextualDaxDialogContent(title: "title",
+ message: NSAttributedString(string: "this is a message"),
+ list: OnboardingSuggestedSitesProvider(countryProvider: Locale(identifier: "en_US"), surpriseItemTitle: "surprise").list,
+ listAction: { _ in })
+ .padding()
+}
diff --git a/Sources/Onboarding/ContextualDaxDialogs/ContextualOnboardingList.swift b/Sources/Onboarding/ContextualDaxDialogs/ContextualOnboardingList.swift
new file mode 100644
index 000000000..1d7cd44c2
--- /dev/null
+++ b/Sources/Onboarding/ContextualDaxDialogs/ContextualOnboardingList.swift
@@ -0,0 +1,119 @@
+//
+// ContextualOnboardingList.swift
+//
+// 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
+
+public enum ContextualOnboardingListItem: Equatable {
+ case search(title: String)
+ case site(title: String)
+ case surprise(title: String, visibleTitle: String)
+
+ var visibleTitle: String {
+ switch self {
+ case .search(let title):
+ return title
+ case .site(let title):
+ return title.replacingOccurrences(of: "https://", with: "")
+ case .surprise(_, let visibleTitle):
+ return visibleTitle
+ }
+ }
+
+ public var title: String {
+ switch self {
+ case .search(let title):
+ return title
+ .replacingOccurrences(of: "”", with: "")
+ .replacingOccurrences(of: "“", with: "")
+ case .site(let title):
+ return title
+ case .surprise(let title, _):
+ return title
+ }
+ }
+
+ var imageName: String {
+ switch self {
+ case .search:
+ return "SuggestLoupe"
+ case .site:
+ return "SuggestGlobe"
+ case .surprise:
+ return "Wand-16"
+ }
+ }
+
+}
+
+public struct ContextualOnboardingListView: View {
+ @Environment(\.colorScheme) private var colorScheme
+ private let list: [ContextualOnboardingListItem]
+ private var action: (_ item: ContextualOnboardingListItem) -> Void
+ private let iconSize: CGFloat
+
+#if os(macOS)
+private var strokeColor: Color {
+ return (colorScheme == .dark) ? Color.white.opacity(0.09) : Color.black.opacity(0.09)
+}
+#else
+private let strokeColor = Color.blue
+#endif
+
+ public init(list: [ContextualOnboardingListItem], action: @escaping (ContextualOnboardingListItem) -> Void, iconSize: CGFloat = 16.0) {
+ self.list = list
+ self.action = action
+ self.iconSize = iconSize
+ }
+
+ public var body: some View {
+ VStack {
+ ForEach(list.indices, id: \.self) { index in
+ Button(action: {
+ action(list[index])
+ }, label: {
+ HStack {
+ Image(list[index].imageName, bundle: bundle)
+ .frame(width: iconSize, height: iconSize)
+ Text(list[index].visibleTitle)
+ .frame(alignment: .leading)
+ Spacer()
+ }
+ })
+ .buttonStyle(OnboardingStyles.ListButtonStyle())
+ .overlay(
+ RoundedRectangle(cornerRadius: 8)
+ .inset(by: 0.5)
+ .stroke(strokeColor, lineWidth: 1)
+ )
+ }
+ }
+ }
+}
+
+// MARK: - Preview
+
+#Preview("List") {
+ let list = [
+ ContextualOnboardingListItem.search(title: "Search"),
+ ContextualOnboardingListItem.site(title: "Website"),
+ ContextualOnboardingListItem.surprise(title: "Surprise", visibleTitle: "Surpeise me!"),
+ ]
+ return ContextualOnboardingListView(list: list) { _ in }
+ .padding()
+}
diff --git a/Sources/Onboarding/ContextualDaxDialogs/OnboardingSuggestionsViewModel.swift b/Sources/Onboarding/ContextualDaxDialogs/OnboardingSuggestionsViewModel.swift
new file mode 100644
index 000000000..d10fecd56
--- /dev/null
+++ b/Sources/Onboarding/ContextualDaxDialogs/OnboardingSuggestionsViewModel.swift
@@ -0,0 +1,87 @@
+//
+// OnboardingSuggestionsViewModel.swift
+//
+// 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
+
+public protocol OnboardingNavigationDelegate: AnyObject {
+ func searchFor(_ query: String)
+ func navigateTo(url: URL)
+}
+
+public protocol OnboardingSearchSuggestionsPixelReporting {
+ func trackSearchSuggetionOptionTapped()
+}
+
+public protocol OnboardingSiteSuggestionsPixelReporting {
+ func trackSiteSuggetionOptionTapped()
+}
+
+public struct OnboardingSearchSuggestionsViewModel {
+ let suggestedSearchesProvider: OnboardingSuggestionsItemsProviding
+ public weak var delegate: OnboardingNavigationDelegate?
+ private let pixelReporter: OnboardingSearchSuggestionsPixelReporting
+
+ public init(
+ suggestedSearchesProvider: OnboardingSuggestionsItemsProviding,
+ delegate: OnboardingNavigationDelegate? = nil,
+ pixelReporter: OnboardingSearchSuggestionsPixelReporting
+ ) {
+ self.suggestedSearchesProvider = suggestedSearchesProvider
+ self.delegate = delegate
+ self.pixelReporter = pixelReporter
+ }
+
+ public var itemsList: [ContextualOnboardingListItem] {
+ suggestedSearchesProvider.list
+ }
+
+ public func listItemPressed(_ item: ContextualOnboardingListItem) {
+ pixelReporter.trackSearchSuggetionOptionTapped()
+ delegate?.searchFor(item.title)
+ }
+}
+
+public struct OnboardingSiteSuggestionsViewModel {
+ let suggestedSitesProvider: OnboardingSuggestionsItemsProviding
+ public weak var delegate: OnboardingNavigationDelegate?
+ private let pixelReporter: OnboardingSiteSuggestionsPixelReporting
+
+ public init(
+ title: String,
+ suggestedSitesProvider: OnboardingSuggestionsItemsProviding,
+ delegate: OnboardingNavigationDelegate? = nil,
+ pixelReporter: OnboardingSiteSuggestionsPixelReporting
+ ) {
+ self.title = title
+ self.suggestedSitesProvider = suggestedSitesProvider
+ self.delegate = delegate
+ self.pixelReporter = pixelReporter
+ }
+
+ public let title: String
+
+ public var itemsList: [ContextualOnboardingListItem] {
+ suggestedSitesProvider.list
+ }
+
+ public func listItemPressed(_ item: ContextualOnboardingListItem) {
+ guard let url = URL(string: item.title) else { return }
+ pixelReporter.trackSiteSuggetionOptionTapped()
+ delegate?.navigateTo(url: url)
+ }
+}
diff --git a/Sources/Onboarding/DaxDialogs/DaxDialogView.swift b/Sources/Onboarding/DaxDialogs/DaxDialogView.swift
new file mode 100644
index 000000000..1d660197a
--- /dev/null
+++ b/Sources/Onboarding/DaxDialogs/DaxDialogView.swift
@@ -0,0 +1,193 @@
+//
+// DaxDialogView.swift
+//
+// 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
+
+// MARK: - Metrics
+
+private enum DaxDialogMetrics {
+ static let contentPadding: CGFloat = 24.0
+ static let shadowRadius: CGFloat = 5.0
+ static let stackSpacing: CGFloat = 8
+
+ enum DaxLogo {
+ static let size: CGFloat = 54.0
+ static let horizontalPadding: CGFloat = 10
+ }
+}
+
+// MARK: - DaxDialog
+
+public enum DaxDialogLogoPosition {
+ case top
+ case left
+}
+
+public struct DaxDialogView: View {
+
+ @Environment(\.colorScheme) var colorScheme
+
+ @State private var logoPosition: DaxDialogLogoPosition
+
+ private let matchLogoAnimation: (id: String, namespace: Namespace.ID)?
+ private let showDialogBox: Binding
+ private let cornerRadius: CGFloat
+ private let arrowSize: CGSize
+ private let onTapGesture: (() -> Void)?
+ private let content: Content
+
+ public init(
+ logoPosition: DaxDialogLogoPosition,
+ matchLogoAnimation: (String, Namespace.ID)? = nil,
+ showDialogBox: Binding = .constant(true),
+ cornerRadius: CGFloat = 16.0,
+ arrowSize: CGSize = .init(width: 16.0, height: 8.0),
+ onTapGesture: (() -> Void)? = nil,
+ @ViewBuilder content: () -> Content
+ ) {
+ _logoPosition = State(initialValue: logoPosition)
+ self.matchLogoAnimation = matchLogoAnimation
+ self.showDialogBox = showDialogBox
+ self.cornerRadius = cornerRadius
+ self.arrowSize = arrowSize
+ self.onTapGesture = onTapGesture
+ self.content = content()
+ }
+
+ public var body: some View {
+ Group {
+ switch logoPosition {
+ case .top:
+ topLogoViewContentView
+ case .left:
+ leftLogoContentView
+ }
+ }
+ .onTapGesture {
+ onTapGesture?()
+ }
+ }
+
+ private var topLogoViewContentView: some View {
+ VStack(alignment: .leading, spacing: stackSpacing) {
+ daxLogo
+ .padding(.leading, DaxDialogMetrics.DaxLogo.horizontalPadding)
+
+ wrappedContent
+ .visibility(showDialogBox.wrappedValue ? .visible : .invisible)
+ }
+ }
+
+ private var leftLogoContentView: some View {
+ HStack(alignment: .top, spacing: stackSpacing) {
+ daxLogo
+
+ wrappedContent
+ .visibility(showDialogBox.wrappedValue ? .visible : .invisible)
+ }
+
+ }
+
+ private var stackSpacing: CGFloat {
+ DaxDialogMetrics.stackSpacing + arrowSize.height
+ }
+
+ @ViewBuilder
+ private var daxLogo: some View {
+ let icon = Image("DaxIconExperiment", bundle: bundle)
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ .frame(width: DaxDialogMetrics.DaxLogo.size, height: DaxDialogMetrics.DaxLogo.size)
+
+ if let matchLogoAnimation {
+ icon.matchedGeometryEffect(id: matchLogoAnimation.id, in: matchLogoAnimation.namespace)
+ } else {
+ icon
+ }
+ }
+
+ private var wrappedContent: some View {
+ let backgroundColor = Color("surface", bundle: bundle)
+ let shadowColors: (Color, Color) = colorScheme == .light ?
+ (.black.opacity(0.08), .black.opacity(0.1)) :
+ (.black.opacity(0.20), .black.opacity(0.16))
+
+ return content
+ .padding(.all, DaxDialogMetrics.contentPadding)
+ .background(backgroundColor)
+ .clipShape(RoundedRectangle(cornerRadius: cornerRadius))
+ .shadow(color: shadowColors.0, radius: 16, x: 0, y: 8)
+ .shadow(color: shadowColors.1, radius: 6, x: 0, y: 2)
+ .overlay(
+ Triangle()
+ .frame(width: arrowSize.width, height: arrowSize.height)
+ .foregroundColor(backgroundColor)
+ .rotationEffect(Angle(degrees: logoPosition == .top ? 0 : -90), anchor: .bottom)
+ .offset(arrowOffset)
+ ,
+ alignment: .topLeading
+ )
+ }
+
+ private var arrowOffset: CGSize {
+ switch logoPosition {
+ case .top:
+ let leadingOffset = DaxDialogMetrics.DaxLogo.horizontalPadding + DaxDialogMetrics.DaxLogo.size / 2 - arrowSize.width / 2
+ return CGSize(width: leadingOffset, height: -arrowSize.height)
+ case .left:
+ let topOffset = DaxDialogMetrics.DaxLogo.size / 2 - arrowSize.width / 2
+ return CGSize(width: -arrowSize.height, height: topOffset)
+ }
+ }
+}
+
+// MARK: - Preview
+
+#Preview("Dax Dialog Top Logo") {
+ ZStack {
+ Color.green.ignoresSafeArea()
+
+ DaxDialogView(logoPosition: .top) {
+ VStack(alignment: .leading, spacing: 24) {
+ VStack(alignment: .leading, spacing: 20) {
+ Text(verbatim: "Hi there.")
+
+ Text(verbatim: "Ready for a better, more private internet?")
+ }
+ }
+ }
+ .padding()
+ }
+}
+
+#Preview("Dax Dialog Left Logo") {
+ ZStack {
+ Color.green.ignoresSafeArea()
+
+ DaxDialogView(logoPosition: .left) {
+ VStack(alignment: .leading, spacing: 24) {
+ VStack(alignment: .leading, spacing: 20) {
+ Text(verbatim: "Hi there.")
+
+ Text(verbatim: "Ready for a better, more private internet?")
+ }
+ }
+ }
+ .padding()
+ }
+}
diff --git a/Sources/Onboarding/FoundationExtensions/TimerInterface.swift b/Sources/Onboarding/FoundationExtensions/TimerInterface.swift
new file mode 100644
index 000000000..4341d8dad
--- /dev/null
+++ b/Sources/Onboarding/FoundationExtensions/TimerInterface.swift
@@ -0,0 +1,51 @@
+//
+// TimerInterface.swift
+//
+// 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
+
+public protocol TimerInterface: AnyObject {
+ var isValid: Bool { get }
+ func invalidate()
+ func fire()
+}
+
+extension Timer: TimerInterface {}
+
+public protocol TimerCreating: AnyObject {
+ func makeTimer(withTimeInterval interval: TimeInterval, repeats: Bool, on runLoop: RunLoop, block: @escaping @Sendable (TimerInterface) -> Void) -> TimerInterface
+}
+
+public extension TimerCreating {
+
+ func makeTimer(withTimeInterval interval: TimeInterval, repeats: Bool, block: @escaping @Sendable (TimerInterface) -> Void) -> TimerInterface {
+ makeTimer(withTimeInterval: interval, repeats: repeats, on: .main, block: block)
+ }
+
+}
+
+public final class TimerFactory: TimerCreating {
+
+ public init() {}
+
+ public func makeTimer(withTimeInterval interval: TimeInterval, repeats: Bool, on runLoop: RunLoop, block: @escaping @Sendable (TimerInterface) -> Void) -> TimerInterface {
+ let timer = Timer(timeInterval: interval, repeats: repeats, block: block)
+ runLoop.add(timer, forMode: .common)
+ return timer
+ }
+
+}
diff --git a/Sources/Onboarding/OnboardingGradient.swift b/Sources/Onboarding/OnboardingGradient.swift
new file mode 100644
index 000000000..3ad81f026
--- /dev/null
+++ b/Sources/Onboarding/OnboardingGradient.swift
@@ -0,0 +1,75 @@
+//
+// OnboardingGradient.swift
+//
+// 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 OnboardingGradient: View {
+ @Environment(\.colorScheme) private var colorScheme
+
+ public init() {}
+
+ public var body: some View {
+ switch colorScheme {
+ case .light:
+ lightGradient
+ case .dark:
+ darkGradient
+ @unknown default:
+ lightGradient
+ }
+ }
+
+ private var lightGradient: some View {
+ gradient(colorStops: [
+ .init(color: Color(red: 1, green: 0.9, blue: 0.87), location: 0.00),
+ .init(color: Color(red: 0.99, green: 0.89, blue: 0.87), location: 0.28),
+ .init(color: Color(red: 0.99, green: 0.89, blue: 0.87), location: 0.46),
+ .init(color: Color(red: 0.96, green: 0.87, blue: 0.87), location: 0.72),
+ .init(color: Color(red: 0.9, green: 0.84, blue: 0.92), location: 1.00),
+ ])
+ }
+
+ private var darkGradient: some View {
+ gradient(colorStops: [
+ .init(color: Color(red: 0.29, green: 0.19, blue: 0.25), location: 0.00),
+ .init(color: Color(red: 0.35, green: 0.23, blue: 0.32), location: 0.28),
+ .init(color: Color(red: 0.37, green: 0.25, blue: 0.38), location: 0.46),
+ .init(color: Color(red: 0.2, green: 0.15, blue: 0.32), location: 0.72),
+ .init(color: Color(red: 0.16, green: 0.15, blue: 0.34), location: 1.00),
+ ])
+ }
+
+ private func gradient(colorStops: [SwiftUI.Gradient.Stop]) -> some View {
+ LinearGradient(
+ stops: colorStops,
+ startPoint: UnitPoint(x: 0.5, y: 0),
+ endPoint: UnitPoint(x: 0.5, y: 1)
+ )
+ }
+
+}
+
+#Preview("Light Mode") {
+ OnboardingGradient()
+ .preferredColorScheme(.light)
+}
+
+#Preview("Dark Mode") {
+ OnboardingGradient()
+ .preferredColorScheme(.dark)
+}
diff --git a/Sources/Onboarding/OnboardingSuggestedSitesProvider.swift b/Sources/Onboarding/OnboardingSuggestedSitesProvider.swift
new file mode 100644
index 000000000..732830309
--- /dev/null
+++ b/Sources/Onboarding/OnboardingSuggestedSitesProvider.swift
@@ -0,0 +1,124 @@
+//
+// OnboardingSuggestedSitesProvider.swift
+//
+// 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
+
+public protocol OnboardingRegionAndLanguageProvider {
+ var regionCode: String? { get }
+ var languageCode: String? { get }
+}
+
+extension Locale: OnboardingRegionAndLanguageProvider {}
+
+public protocol OnboardingSuggestionsItemsProviding {
+ var list: [ContextualOnboardingListItem] { get }
+}
+
+public struct OnboardingSuggestedSitesProvider: OnboardingSuggestionsItemsProviding {
+ private let countryProvider: OnboardingRegionAndLanguageProvider
+ private let surpriseItemTitle: String
+
+ public init(countryProvider: OnboardingRegionAndLanguageProvider = Locale.current,
+ surpriseItemTitle: String) {
+ self.countryProvider = countryProvider
+ self.surpriseItemTitle = surpriseItemTitle
+ }
+
+ private let scheme = "https://"
+
+ enum Countries: String {
+ case indonesia = "ID"
+ case gb = "GB"
+ case germany = "DE"
+ case canada = "CA"
+ case netherlands = "NL"
+ case australia = "AU"
+ case sweden = "SE"
+ case ireland = "IE"
+ }
+
+ public var list: [ContextualOnboardingListItem] {
+ return [
+ option1,
+ option2,
+ option3,
+ surpriseMe
+ ]
+ }
+
+ private var country: String {
+ countryProvider.regionCode ?? ""
+ }
+
+ private var option1: ContextualOnboardingListItem {
+ let site: String
+ switch Countries(rawValue: country) {
+ case .indonesia: site = "bolasport.com"
+ case .gb: site = "skysports.com"
+ case .germany: site = "bundesliga.de"
+ case .canada: site = "tsn.ca"
+ case .netherlands: site = "voetbalprimeur.nl"
+ case .australia: site = "afl.com.au"
+ case .sweden: site = "svenskafans.com"
+ case .ireland: site = "skysports.com"
+ default: site = "ESPN.com"
+ }
+ return ContextualOnboardingListItem.site(title: scheme + site)
+ }
+
+ private var option2: ContextualOnboardingListItem {
+ let site: String
+ switch Countries(rawValue: country) {
+ case .indonesia: site = "kompas.com"
+ case .gb: site = "bbc.co.uk"
+ case .germany: site = "tagesschau.de"
+ case .canada: site = "ctvnews.ca"
+ case .netherlands: site = "nu.nl"
+ case .australia: site = "yahoo.com"
+ case .sweden: site = "dn.se"
+ case .ireland: site = "bbc.co.uk"
+ default: site = "yahoo.com"
+ }
+ return ContextualOnboardingListItem.site(title: scheme + site)
+ }
+
+ private var option3: ContextualOnboardingListItem {
+ let site: String
+ switch Countries(rawValue: country) {
+ case .indonesia: site = "tokopedia.com"
+ case .gb, .australia, .ireland: site = "eBay.com"
+ case .germany: site = "galeria.de"
+ case .canada: site = "canadiantire.ca"
+ case .netherlands: site = "bol.com"
+ case .sweden: site = "tradera.com"
+ default: site = "eBay.com"
+ }
+ return ContextualOnboardingListItem.site(title: scheme + site)
+ }
+
+ private var surpriseMe: ContextualOnboardingListItem {
+ let site: String
+ switch Countries(rawValue: country) {
+ case .germany: site = "duden.de"
+ case .netherlands: site = "www.woorden.org/woord/eend"
+ case .sweden: site = "www.synonymer.se/sv-syn/anka"
+ default: site = "britannica.com/animal/duck"
+ }
+ return ContextualOnboardingListItem.surprise(title: scheme + site, visibleTitle: surpriseItemTitle)
+ }
+}
diff --git a/Sources/Onboarding/Resources/OnboardingAssets.xcassets/Contents.json b/Sources/Onboarding/Resources/OnboardingAssets.xcassets/Contents.json
new file mode 100644
index 000000000..73c00596a
--- /dev/null
+++ b/Sources/Onboarding/Resources/OnboardingAssets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sources/Onboarding/Resources/OnboardingAssets.xcassets/DaxIconExperiment.imageset/Contents.json b/Sources/Onboarding/Resources/OnboardingAssets.xcassets/DaxIconExperiment.imageset/Contents.json
new file mode 100644
index 000000000..70de8cfe2
--- /dev/null
+++ b/Sources/Onboarding/Resources/OnboardingAssets.xcassets/DaxIconExperiment.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "DaxLogo.pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sources/Onboarding/Resources/OnboardingAssets.xcassets/DaxIconExperiment.imageset/DaxLogo.pdf b/Sources/Onboarding/Resources/OnboardingAssets.xcassets/DaxIconExperiment.imageset/DaxLogo.pdf
new file mode 100644
index 000000000..05bd06d83
Binary files /dev/null and b/Sources/Onboarding/Resources/OnboardingAssets.xcassets/DaxIconExperiment.imageset/DaxLogo.pdf differ
diff --git a/Sources/Onboarding/Resources/OnboardingAssets.xcassets/SuggestGlobe.imageset/Contents.json b/Sources/Onboarding/Resources/OnboardingAssets.xcassets/SuggestGlobe.imageset/Contents.json
new file mode 100644
index 000000000..574b7d043
--- /dev/null
+++ b/Sources/Onboarding/Resources/OnboardingAssets.xcassets/SuggestGlobe.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "websiteAlt.pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/Onboarding/Resources/OnboardingAssets.xcassets/SuggestGlobe.imageset/websiteAlt.pdf b/Sources/Onboarding/Resources/OnboardingAssets.xcassets/SuggestGlobe.imageset/websiteAlt.pdf
new file mode 100644
index 000000000..d7b847fd0
Binary files /dev/null and b/Sources/Onboarding/Resources/OnboardingAssets.xcassets/SuggestGlobe.imageset/websiteAlt.pdf differ
diff --git a/Sources/Onboarding/Resources/OnboardingAssets.xcassets/SuggestLoupe.imageset/Contents.json b/Sources/Onboarding/Resources/OnboardingAssets.xcassets/SuggestLoupe.imageset/Contents.json
new file mode 100644
index 000000000..602a08fdd
--- /dev/null
+++ b/Sources/Onboarding/Resources/OnboardingAssets.xcassets/SuggestLoupe.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "loupe.pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/Onboarding/Resources/OnboardingAssets.xcassets/SuggestLoupe.imageset/loupe.pdf b/Sources/Onboarding/Resources/OnboardingAssets.xcassets/SuggestLoupe.imageset/loupe.pdf
new file mode 100644
index 000000000..a4345efc0
Binary files /dev/null and b/Sources/Onboarding/Resources/OnboardingAssets.xcassets/SuggestLoupe.imageset/loupe.pdf differ
diff --git a/Sources/Onboarding/Resources/OnboardingAssets.xcassets/Wand-16.imageset/Contents.json b/Sources/Onboarding/Resources/OnboardingAssets.xcassets/Wand-16.imageset/Contents.json
new file mode 100644
index 000000000..ed0e24e6e
--- /dev/null
+++ b/Sources/Onboarding/Resources/OnboardingAssets.xcassets/Wand-16.imageset/Contents.json
@@ -0,0 +1,16 @@
+{
+ "images" : [
+ {
+ "filename" : "Wand-16.pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true,
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/Onboarding/Resources/OnboardingAssets.xcassets/Wand-16.imageset/Wand-16.pdf b/Sources/Onboarding/Resources/OnboardingAssets.xcassets/Wand-16.imageset/Wand-16.pdf
new file mode 100644
index 000000000..9664d77a6
Binary files /dev/null and b/Sources/Onboarding/Resources/OnboardingAssets.xcassets/Wand-16.imageset/Wand-16.pdf differ
diff --git a/Sources/Onboarding/Resources/OnboardingAssets.xcassets/surface.colorset/Contents.json b/Sources/Onboarding/Resources/OnboardingAssets.xcassets/surface.colorset/Contents.json
new file mode 100644
index 000000000..3439f0279
--- /dev/null
+++ b/Sources/Onboarding/Resources/OnboardingAssets.xcassets/surface.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xFF",
+ "green" : "0xFF",
+ "red" : "0xFE"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x2F",
+ "green" : "0x2F",
+ "red" : "0x2F"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sources/Onboarding/Styles/DaxDialogStyles.swift b/Sources/Onboarding/Styles/DaxDialogStyles.swift
new file mode 100644
index 000000000..91de562f1
--- /dev/null
+++ b/Sources/Onboarding/Styles/DaxDialogStyles.swift
@@ -0,0 +1,98 @@
+//
+// DaxDialogStyles.swift
+//
+// 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
+
+enum OnboardingStyles {}
+
+extension OnboardingStyles {
+
+ struct ListButtonStyle: ButtonStyle {
+ @Environment(\.colorScheme) private var colorScheme
+
+#if os(macOS)
+ private let maxHeight = 32.0
+#else
+ private let maxHeight = 40.0
+#endif
+
+#if os(macOS)
+ private let fontSize = 12.0
+#else
+ private let fontSize = 15.0
+#endif
+
+ init() {}
+
+ func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .font(.system(size: fontSize, weight: .bold))
+ .fixedSize(horizontal: false, vertical: true)
+ .multilineTextAlignment(.center)
+ .lineLimit(nil)
+ .foregroundColor(foregroundColor(configuration.isPressed))
+ .padding()
+ .frame(minWidth: 0, maxWidth: .infinity, maxHeight: maxHeight)
+ .background(backgroundColor(configuration.isPressed))
+ .cornerRadius(8)
+ .contentShape(Rectangle()) // Makes whole button area tappable, when there's no background
+ }
+
+ private func foregroundColor(_ isPressed: Bool) -> Color {
+ switch (colorScheme, isPressed) {
+ case (.dark, false):
+ return .blue30
+ case (.dark, true):
+ return .blue20
+ case (_, false):
+ return .blueBase
+ case (_, true):
+ return .blue70
+ }
+ }
+
+ private func backgroundColor(_ isPressed: Bool) -> Color {
+ switch (colorScheme, isPressed) {
+ case (.light, true):
+ return .blueBase.opacity(0.2)
+ case (.dark, true):
+ return .blue30.opacity(0.2)
+ default:
+ return .clear
+ }
+ }
+ }
+
+}
+
+extension Color {
+ static let blue70 = Color(0x1E42A4)
+ static let blueBase = Color(0x3969EF)
+ static let blue30 = Color(0x7295F6)
+ static let blue20 = Color(0x8FABF9)
+
+ init(_ hex: UInt, alpha: Double = 1) {
+ self.init(
+ .sRGB,
+ red: Double((hex >> 16) & 0xFF) / 255,
+ green: Double((hex >> 8) & 0xFF) / 255,
+ blue: Double(hex & 0xFF) / 255,
+ opacity: alpha
+ )
+ }
+}
diff --git a/Sources/Onboarding/SwiftUIExtensions/AnimatableTypingText.swift b/Sources/Onboarding/SwiftUIExtensions/AnimatableTypingText.swift
new file mode 100644
index 000000000..a2fb3d87e
--- /dev/null
+++ b/Sources/Onboarding/SwiftUIExtensions/AnimatableTypingText.swift
@@ -0,0 +1,177 @@
+//
+// AnimatableTypingText.swift
+//
+// 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.
+//
+
+#if canImport(UIKit)
+typealias PlatformColor = UIColor
+#elseif canImport(AppKit)
+typealias PlatformColor = NSColor
+#endif
+
+import SwiftUI
+import Combine
+
+// MARK: - View
+public struct AnimatableTypingText: View {
+ private let text: NSAttributedString
+ private var startAnimating: Binding
+ private var onTypingFinished: (() -> Void)?
+
+ @StateObject private var model: AnimatableTypingTextModel
+
+ public init(
+ _ text: NSAttributedString,
+ startAnimating: Binding = .constant(true),
+ onTypingFinished: (() -> Void)? = nil
+ ) {
+ self.text = text
+ _model = StateObject(wrappedValue: AnimatableTypingTextModel(text: text, onTypingFinished: onTypingFinished))
+ self.startAnimating = startAnimating
+ self.onTypingFinished = onTypingFinished
+ }
+
+ public init(
+ _ text: String,
+ startAnimating: Binding = .constant(true),
+ onTypingFinished: (() -> Void)? = nil
+ ) {
+ let attributesText = NSAttributedString(string: text)
+ self.text = attributesText
+ _model = StateObject(wrappedValue: AnimatableTypingTextModel(text: attributesText, onTypingFinished: onTypingFinished))
+ self.startAnimating = startAnimating
+ self.onTypingFinished = onTypingFinished
+ }
+
+ public var body: some View {
+ Group {
+ if #available(iOS 15, macOS 12, *) {
+ Text(AttributedString(model.typedAttributedText))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ } else {
+ Text(model.typedAttributedText.string)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+ .onChange(of: startAnimating.wrappedValue, perform: { shouldAnimate in
+ if shouldAnimate {
+ model.startAnimating()
+ } else {
+ model.stopAnimating()
+ }
+ })
+ .onAppear {
+ if startAnimating.wrappedValue {
+ model.startAnimating()
+ }
+ }
+ }
+}
+
+// MARK: - Model
+
+final class AnimatableTypingTextModel: ObservableObject {
+ private var timer: TimerInterface?
+
+ @Published private(set) var typedAttributedText: NSAttributedString = .init(string: "")
+
+ private var typingIndex = 0
+ private let text: NSAttributedString
+ private let onTypingFinished: (() -> Void)?
+ private let timerFactory: TimerCreating
+
+ init(text: NSAttributedString, onTypingFinished: (() -> Void)?, timerFactory: TimerCreating = TimerFactory()) {
+ self.text = text
+ self.onTypingFinished = onTypingFinished
+ self.timerFactory = timerFactory
+ typedAttributedText = createAttributedString(original: text, visibleLength: 0)
+ }
+
+ func startAnimating() {
+ timer = timerFactory.makeTimer(withTimeInterval: 0.02, repeats: true, block: { [weak self] timer in
+ guard timer.isValid else { return }
+ self?.handleTimerEvent()
+ })
+ }
+
+ func stopAnimating() {
+ timer?.invalidate()
+ timer = nil
+
+ stopTyping()
+ }
+
+ deinit {
+ timer?.invalidate()
+ timer = nil
+ }
+
+ private func handleTimerEvent() {
+ if typingIndex >= text.length {
+ onTypingFinished?()
+ stopAnimating()
+ return
+ }
+
+ showCharacter()
+ }
+
+ private func stopTyping() {
+ typedAttributedText = text
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
+ self?.onTypingFinished?()
+ }
+ }
+
+ private func showCharacter() {
+ typingIndex = min(typingIndex + 1, text.length)
+ typedAttributedText = createAttributedString(original: text, visibleLength: typingIndex)
+ }
+
+ private func createAttributedString(original: NSAttributedString, visibleLength: Int) -> NSAttributedString {
+ let totalRange = NSRange(location: 0, length: original.length)
+ let visibleRange = NSRange(location: 0, length: min(visibleLength, original.length))
+
+ // Make the entire text transparent
+ let transparentText = original.applyingColor(.clear, to: totalRange)
+
+ #if os(iOS)
+ let visibleTextColor = UIColor.label
+ #else
+ let visibleTextColor = NSColor.labelColor
+ #endif
+
+ // Change the color to standard for the visible range
+ let visibleText = transparentText.applyingColor(visibleTextColor, to: visibleRange)
+
+ return visibleText
+ }
+}
+
+// Extension to apply color to NSAttributedString
+extension NSAttributedString {
+ func applyingColor(_ color: PlatformColor, to range: NSRange) -> NSAttributedString {
+ let mutableAttributedString = NSMutableAttributedString(attributedString: self)
+
+ mutableAttributedString.enumerateAttributes(in: range, options: []) { attributes, range, _ in
+ var newAttributes = attributes
+ newAttributes[.foregroundColor] = color
+ mutableAttributedString.setAttributes(newAttributes, range: range)
+ }
+
+ return mutableAttributedString
+ }
+}
diff --git a/Sources/Onboarding/SwiftUIExtensions/Triangle.swift b/Sources/Onboarding/SwiftUIExtensions/Triangle.swift
new file mode 100644
index 000000000..b04a77ca7
--- /dev/null
+++ b/Sources/Onboarding/SwiftUIExtensions/Triangle.swift
@@ -0,0 +1,34 @@
+//
+// Triangle.swift
+//
+// 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
+
+/// Triangle shape contained within the rectangular frame. It is an isosceles triangle with the base at the bottom of the frame and pointing up.
+struct Triangle: Shape {
+
+ func path(in rect: CGRect) -> Path {
+ var path = Path()
+
+ path.move(to: CGPoint(x: rect.minX, y: rect.maxY))
+ path.addLine(to: CGPoint(x: rect.midX, y: rect.minY))
+ path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
+ path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
+
+ return path
+ }
+}
diff --git a/Sources/Onboarding/SwiftUIExtensions/ViewVisibility.swift b/Sources/Onboarding/SwiftUIExtensions/ViewVisibility.swift
new file mode 100644
index 000000000..aacae67ab
--- /dev/null
+++ b/Sources/Onboarding/SwiftUIExtensions/ViewVisibility.swift
@@ -0,0 +1,44 @@
+//
+// ViewVisibility.swift
+//
+// 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
+
+// https://swiftuirecipes.com/blog/how-to-hide-a-swiftui-view-visible-invisible-gone
+enum ViewVisibility: CaseIterable {
+
+ case visible, // view is fully visible
+ invisible, // view is hidden but takes up space
+ gone // view is fully removed from the view hierarchy
+
+}
+
+extension View {
+
+ // https://swiftuirecipes.com/blog/how-to-hide-a-swiftui-view-visible-invisible-gone
+ @ViewBuilder func visibility(_ visibility: ViewVisibility) -> some View {
+ if visibility != .gone {
+ if visibility == .visible {
+ self
+ } else {
+ hidden()
+ }
+ }
+ }
+
+}
diff --git a/Sources/Onboarding/TestSupport/MockOnboardingRegionAndLanguageProvider.swift b/Sources/Onboarding/TestSupport/MockOnboardingRegionAndLanguageProvider.swift
new file mode 100644
index 000000000..efcd17028
--- /dev/null
+++ b/Sources/Onboarding/TestSupport/MockOnboardingRegionAndLanguageProvider.swift
@@ -0,0 +1,31 @@
+//
+// MockOnboardingRegionAndLanguageProvider.swift
+//
+// 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.
+//
+
+#if DEBUG
+import Foundation
+
+public class MockOnboardingRegionAndLanguageProvider: OnboardingRegionAndLanguageProvider {
+ public var regionCode: String?
+ public var languageCode: String?
+
+ public init(regionCode: String?, languageCode: String?) {
+ self.regionCode = regionCode
+ self.languageCode = languageCode
+ }
+}
+#endif
diff --git a/Sources/Onboarding/global.swift b/Sources/Onboarding/global.swift
new file mode 100644
index 000000000..02434a88b
--- /dev/null
+++ b/Sources/Onboarding/global.swift
@@ -0,0 +1,22 @@
+//
+// global.swift
+//
+// 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
+
+/// After importing Onboarding you can access this with `Onboarding.bundle`
+public let bundle = Bundle.module
diff --git a/Tests/OnboardingTests/AnimatableTypingTextModelTests.swift b/Tests/OnboardingTests/AnimatableTypingTextModelTests.swift
new file mode 100644
index 000000000..7e1ece86a
--- /dev/null
+++ b/Tests/OnboardingTests/AnimatableTypingTextModelTests.swift
@@ -0,0 +1,187 @@
+//
+// AnimatableTypingTextModelTests.swift
+//
+// 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 Combine
+@testable import Onboarding
+
+#if canImport(UIKit)
+typealias PlatformColor = UIColor
+#else
+typealias PlatformColor = NSColor
+#endif
+
+final class AnimatableTypingTextModelTests: XCTestCase {
+ private var factoryMock: MockTimerFactory!
+ private var cancellables: Set!
+
+ override func setUpWithError() throws {
+ try super.setUpWithError()
+
+ factoryMock = MockTimerFactory()
+ cancellables = []
+ }
+
+ override func tearDownWithError() throws {
+ factoryMock = nil
+ cancellables = nil
+ try super.tearDownWithError()
+ }
+
+ func testWhenStartAnimatingIsCalledThenTimerIsStarted() {
+ // GIVEN
+ let sut = AnimatableTypingTextModel(text: NSAttributedString(string: "Hello World!!!"), onTypingFinished: nil, timerFactory: factoryMock)
+ XCTAssertFalse(factoryMock.didCallMakeTimer)
+ XCTAssertNil(factoryMock.capturedInterval)
+ XCTAssertNil(factoryMock.capturedRepeats)
+
+ // WHEN
+ sut.startAnimating()
+
+ // THEN
+ XCTAssertTrue(factoryMock.didCallMakeTimer)
+ XCTAssertEqual(factoryMock.capturedInterval, 0.02)
+ XCTAssertEqual(factoryMock.capturedRepeats, true)
+ }
+
+ func testWhenStopAnimatingIsCalledThenTimerIsInvalidate() throws {
+ // GIVEN
+ let sut = AnimatableTypingTextModel(text: NSAttributedString(string: "Hello World!!!"), onTypingFinished: nil, timerFactory: factoryMock)
+ sut.startAnimating()
+ let timerMock = try XCTUnwrap(factoryMock.createdTimer)
+ XCTAssertFalse(timerMock.didCallInvalidate)
+
+ // WHEN
+ sut.stopAnimating()
+
+ // THEN
+ XCTAssertTrue(timerMock.didCallInvalidate)
+ }
+
+ func testWhenTimerFiresThenTypedTextIsPublished_iOS15() throws {
+ // GIVEN
+ let text = NSAttributedString(string: "Hello World!!!")
+ var typedText: NSAttributedString = .init(string: "")
+ let sut = AnimatableTypingTextModel(text: text, onTypingFinished: nil, timerFactory: factoryMock)
+ sut.startAnimating()
+ let timerMock = try XCTUnwrap(factoryMock.createdTimer)
+ sut.$typedAttributedText
+ .dropFirst()
+ .sink { attributedString in
+ typedText = attributedString
+ }
+ .store(in: &cancellables)
+ XCTAssertTrue(typedText.string.isEmpty)
+
+ for i in 0 ..< text.length {
+ // WHEN
+ timerMock.fire()
+
+ // THEN
+ XCTAssertTrue(isAttributedStringColorsCorrect(typedText, visibleLength: i + 1))
+ }
+ }
+
+ func testWhenStopAnimatingIsCalledThenWholeTextIsPublished_iOS15() throws {
+ // GIVEN
+ let text = NSAttributedString(string: "Hello World!!!")
+ var typedText: NSAttributedString = .init(string: "")
+ let sut = AnimatableTypingTextModel(text: text, onTypingFinished: nil, timerFactory: factoryMock)
+ sut.startAnimating()
+ let timerMock = try XCTUnwrap(factoryMock.createdTimer)
+ sut.$typedAttributedText
+ .dropFirst()
+ .sink { attributedString in
+ typedText = attributedString
+ }
+ .store(in: &cancellables)
+ XCTAssertTrue(typedText.string.isEmpty)
+ timerMock.fire()
+
+ // WHEN
+ sut.stopAnimating()
+
+ // THEN the string does not have any clear character
+ XCTAssertEqual(typedText, text)
+ let attributes = typedText.attributes(at: 0, effectiveRange: nil)
+ let foregroundcColor = attributes[.foregroundColor] as? PlatformColor
+ XCTAssertNil(foregroundcColor)
+ }
+
+ func testWhenTimerFiresLastCharOfTextThenTimerIsInvalidated() throws {
+ // GIVEN
+ let text = NSAttributedString(string: "Hello World!!!")
+ let sut = AnimatableTypingTextModel(text: text, onTypingFinished: nil, timerFactory: factoryMock)
+ sut.startAnimating()
+ let timerMock = try XCTUnwrap(factoryMock.createdTimer)
+ XCTAssertFalse(timerMock.didCallInvalidate)
+
+ // WHEN
+ text.string.forEach { _ in
+ timerMock.fire()
+ }
+ timerMock.fire() // Simulate timer firing after whole text shown
+
+ // THEN
+ XCTAssertTrue(timerMock.didCallInvalidate)
+ }
+
+ func testWhenTimerFinishesThenOnTypingFinishedBlockIsCalled() throws {
+ // GIVEN
+ let expectation = self.expectation(description: #function)
+ let text = NSAttributedString(string: "Hello World!!!")
+ let sut = AnimatableTypingTextModel(text: text, onTypingFinished: { expectation.fulfill() }, timerFactory: factoryMock)
+ sut.startAnimating()
+ let timerMock = try XCTUnwrap(factoryMock.createdTimer)
+
+ // WHEN
+ text.string.forEach { _ in
+ timerMock.fire()
+ }
+ timerMock.fire() // Simulate timer firing after whole text shown
+
+ // THEN
+ waitForExpectations(timeout: 2.0)
+ }
+
+}
+
+private extension AnimatableTypingTextModelTests {
+
+ func isAttributedStringColorsCorrect(_ attributedString: NSAttributedString, visibleLength: Int) -> Bool {
+ var isCorrect = true
+ let range = NSRange(location: 0, length: attributedString.length)
+ attributedString.enumerateAttribute(.foregroundColor, in: range, options: []) { value, range, _ in
+ guard let color = value as? PlatformColor else {
+ isCorrect = false
+ return
+ }
+ if range.location < visibleLength {
+ if color == .clear {
+ isCorrect = false
+ }
+ } else {
+ if color != .clear {
+ isCorrect = false
+ }
+ }
+ }
+ return isCorrect
+ }
+
+}
diff --git a/Tests/OnboardingTests/MockTimer.swift b/Tests/OnboardingTests/MockTimer.swift
new file mode 100644
index 000000000..d8f027f6e
--- /dev/null
+++ b/Tests/OnboardingTests/MockTimer.swift
@@ -0,0 +1,62 @@
+//
+// MockTimer.swift
+//
+// 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
+@testable import Onboarding
+
+final class MockTimer: TimerInterface {
+ var isValid: Bool = true
+ private(set) var didCallInvalidate = false
+
+ let timeInterval: TimeInterval
+ let repeats: Bool
+ private let block: (TimerInterface) -> Void
+
+ init(timeInterval: TimeInterval, repeats: Bool, block: @escaping (TimerInterface) -> Void) {
+ self.timeInterval = timeInterval
+ self.repeats = repeats
+ self.block = block
+ }
+
+ func invalidate() {
+ didCallInvalidate = true
+ }
+
+ func fire() {
+ block(self)
+ }
+}
+
+final class MockTimerFactory: TimerCreating {
+ private(set) var didCallMakeTimer = false
+ private(set) var capturedInterval: TimeInterval?
+ private(set) var capturedRepeats: Bool?
+
+ private(set) var createdTimer: MockTimer?
+
+ func makeTimer(withTimeInterval interval: TimeInterval, repeats: Bool, on runLoop: RunLoop, block: @escaping @Sendable (TimerInterface) -> Void) -> TimerInterface {
+ didCallMakeTimer = true
+ capturedInterval = interval
+ capturedRepeats = repeats
+
+ let timer = MockTimer(timeInterval: interval, repeats: repeats, block: block)
+ createdTimer = timer
+ return timer
+ }
+
+}
diff --git a/Tests/OnboardingTests/OnboardingSuggestedSitesProviderTests.swift b/Tests/OnboardingTests/OnboardingSuggestedSitesProviderTests.swift
new file mode 100644
index 000000000..ab5697eb7
--- /dev/null
+++ b/Tests/OnboardingTests/OnboardingSuggestedSitesProviderTests.swift
@@ -0,0 +1,175 @@
+//
+// OnboardingSuggestedSitesProviderTests.swift
+//
+// 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
+@testable import Onboarding
+
+class OnboardingSuggestedSitesProviderTests: XCTestCase {
+ let scheme = "https://"
+ let surpriseMeTitle = "Suprise me!"
+
+ func testSuggestedSitesForIndonesia() {
+ // GIVEN
+ let mockProvider = MockOnboardingRegionAndLanguageProvider(regionCode: "ID", languageCode: "")
+ let sut = OnboardingSuggestedSitesProvider(countryProvider: mockProvider, surpriseItemTitle: surpriseMeTitle)
+
+ // WHEN
+ let sitesList = sut.list
+
+ // THEN
+ XCTAssertEqual(sitesList[0], ContextualOnboardingListItem.site(title: scheme + "bolasport.com"))
+ XCTAssertEqual(sitesList[1], ContextualOnboardingListItem.site(title: scheme + "kompas.com"))
+ XCTAssertEqual(sitesList[2], ContextualOnboardingListItem.site(title: scheme + "tokopedia.com"))
+ XCTAssertEqual(sitesList[3], ContextualOnboardingListItem.surprise(title: scheme + "britannica.com/animal/duck", visibleTitle: surpriseMeTitle))
+ }
+
+ func testSuggestedSitesForGB() {
+ // GIVEN
+ let mockProvider = MockOnboardingRegionAndLanguageProvider(regionCode: "GB", languageCode: "")
+ let sut = OnboardingSuggestedSitesProvider(countryProvider: mockProvider, surpriseItemTitle: "Suprise me!")
+
+ // WHEN
+ let sitesList = sut.list
+
+ // THEN
+ XCTAssertEqual(sitesList[0], ContextualOnboardingListItem.site(title: scheme + "skysports.com"))
+ XCTAssertEqual(sitesList[1], ContextualOnboardingListItem.site(title: scheme + "bbc.co.uk"))
+ XCTAssertEqual(sitesList[2], ContextualOnboardingListItem.site(title: scheme + "eBay.com"))
+ XCTAssertEqual(sitesList[3], ContextualOnboardingListItem.surprise(title: scheme + "britannica.com/animal/duck", visibleTitle: surpriseMeTitle))
+ }
+
+ func testSuggestedSitesForGermany() {
+ // GIVEN
+ let mockProvider = MockOnboardingRegionAndLanguageProvider(regionCode: "DE", languageCode: "")
+ let sut = OnboardingSuggestedSitesProvider(countryProvider: mockProvider, surpriseItemTitle: surpriseMeTitle)
+
+ // WHEN
+ let sitesList = sut.list
+
+ // THEN
+ XCTAssertEqual(sitesList[0], ContextualOnboardingListItem.site(title: scheme + "bundesliga.de"))
+ XCTAssertEqual(sitesList[1], ContextualOnboardingListItem.site(title: scheme + "tagesschau.de"))
+ XCTAssertEqual(sitesList[2], ContextualOnboardingListItem.site(title: scheme + "galeria.de"))
+ XCTAssertEqual(sitesList[3], ContextualOnboardingListItem.surprise(title: scheme + "duden.de", visibleTitle: surpriseMeTitle))
+ }
+
+ func testSuggestedSitesForCanada() {
+ // GIVEN
+ let mockProvider = MockOnboardingRegionAndLanguageProvider(regionCode: "CA", languageCode: "")
+ let sut = OnboardingSuggestedSitesProvider(countryProvider: mockProvider, surpriseItemTitle: surpriseMeTitle)
+
+ // WHEN
+ let sitesList = sut.list
+
+ // THEN
+ XCTAssertEqual(sitesList[0], ContextualOnboardingListItem.site(title: scheme + "tsn.ca"))
+ XCTAssertEqual(sitesList[1], ContextualOnboardingListItem.site(title: scheme + "ctvnews.ca"))
+ XCTAssertEqual(sitesList[2], ContextualOnboardingListItem.site(title: scheme + "canadiantire.ca"))
+ XCTAssertEqual(sitesList[3], ContextualOnboardingListItem.surprise(title: scheme + "britannica.com/animal/duck", visibleTitle: surpriseMeTitle))
+ }
+
+ func testSuggestedSitesForNetherlands() {
+ // GIVEN
+ let mockProvider = MockOnboardingRegionAndLanguageProvider(regionCode: "NL", languageCode: "")
+ let sut = OnboardingSuggestedSitesProvider(countryProvider: mockProvider, surpriseItemTitle: surpriseMeTitle)
+
+ // WHEN
+ let sitesList = sut.list
+
+ // THEN
+ XCTAssertEqual(sitesList[0], ContextualOnboardingListItem.site(title: scheme + "voetbalprimeur.nl"))
+ XCTAssertEqual(sitesList[1], ContextualOnboardingListItem.site(title: scheme + "nu.nl"))
+ XCTAssertEqual(sitesList[2], ContextualOnboardingListItem.site(title: scheme + "bol.com"))
+ XCTAssertEqual(sitesList[3], ContextualOnboardingListItem.surprise(title: scheme + "www.woorden.org/woord/eend", visibleTitle: surpriseMeTitle))
+ }
+
+ func testSuggestedSitesForAustralia() {
+ // GIVEN
+ let mockProvider = MockOnboardingRegionAndLanguageProvider(regionCode: "AU", languageCode: "")
+ let sut = OnboardingSuggestedSitesProvider(countryProvider: mockProvider, surpriseItemTitle: surpriseMeTitle)
+
+ // WHEN
+ let sitesList = sut.list
+
+ // THEN
+ XCTAssertEqual(sitesList[0], ContextualOnboardingListItem.site(title: scheme + "afl.com.au"))
+ XCTAssertEqual(sitesList[1], ContextualOnboardingListItem.site(title: scheme + "yahoo.com"))
+ XCTAssertEqual(sitesList[2], ContextualOnboardingListItem.site(title: scheme + "eBay.com"))
+ XCTAssertEqual(sitesList[3], ContextualOnboardingListItem.surprise(title: scheme + "britannica.com/animal/duck", visibleTitle: surpriseMeTitle))
+ }
+
+ func testSuggestedSitesForSweden() {
+ // GIVEN
+ let mockProvider = MockOnboardingRegionAndLanguageProvider(regionCode: "SE", languageCode: "")
+ let sut = OnboardingSuggestedSitesProvider(countryProvider: mockProvider, surpriseItemTitle: surpriseMeTitle)
+
+ // WHEN
+ let sitesList = sut.list
+
+ // THEN
+ XCTAssertEqual(sitesList[0], ContextualOnboardingListItem.site(title: scheme + "svenskafans.com"))
+ XCTAssertEqual(sitesList[1], ContextualOnboardingListItem.site(title: scheme + "dn.se"))
+ XCTAssertEqual(sitesList[2], ContextualOnboardingListItem.site(title: scheme + "tradera.com"))
+ XCTAssertEqual(sitesList[3], ContextualOnboardingListItem.surprise(title: scheme + "www.synonymer.se/sv-syn/anka", visibleTitle: surpriseMeTitle))
+ }
+
+ func testSuggestedSitesForIreland() {
+ // GIVEN
+ let mockProvider = MockOnboardingRegionAndLanguageProvider(regionCode: "IE", languageCode: "")
+ let sut = OnboardingSuggestedSitesProvider(countryProvider: mockProvider, surpriseItemTitle: surpriseMeTitle)
+
+ // WHEN
+ let sitesList = sut.list
+
+ // THEN
+ XCTAssertEqual(sitesList[0], ContextualOnboardingListItem.site(title: scheme + "skysports.com"))
+ XCTAssertEqual(sitesList[1], ContextualOnboardingListItem.site(title: scheme + "bbc.co.uk"))
+ XCTAssertEqual(sitesList[2], ContextualOnboardingListItem.site(title: scheme + "eBay.com"))
+ XCTAssertEqual(sitesList[3], ContextualOnboardingListItem.surprise(title: scheme + "britannica.com/animal/duck", visibleTitle: surpriseMeTitle))
+ }
+
+ func testSuggestedSitesForUS() {
+ // GIVEN
+ let mockProvider = MockOnboardingRegionAndLanguageProvider(regionCode: "US", languageCode: "")
+ let sut = OnboardingSuggestedSitesProvider(countryProvider: mockProvider, surpriseItemTitle: surpriseMeTitle)
+
+ // WHEN
+ let sitesList = sut.list
+
+ // THEN
+ XCTAssertEqual(sitesList[0], ContextualOnboardingListItem.site(title: scheme + "ESPN.com"))
+ XCTAssertEqual(sitesList[1], ContextualOnboardingListItem.site(title: scheme + "yahoo.com"))
+ XCTAssertEqual(sitesList[2], ContextualOnboardingListItem.site(title: scheme + "eBay.com"))
+ XCTAssertEqual(sitesList[3], ContextualOnboardingListItem.surprise(title: scheme + "britannica.com/animal/duck", visibleTitle: surpriseMeTitle))
+ }
+
+ func testSuggestedSitesForUnknownCountry() {
+ // GIVEN
+ let mockProvider = MockOnboardingRegionAndLanguageProvider(regionCode: "UNKNOWN", languageCode: "")
+ let sut = OnboardingSuggestedSitesProvider(countryProvider: mockProvider, surpriseItemTitle: surpriseMeTitle)
+
+ // WHEN
+ let sitesList = sut.list
+
+ // THEN
+ XCTAssertEqual(sitesList[0], ContextualOnboardingListItem.site(title: scheme + "ESPN.com"))
+ XCTAssertEqual(sitesList[1], ContextualOnboardingListItem.site(title: scheme + "yahoo.com"))
+ XCTAssertEqual(sitesList[2], ContextualOnboardingListItem.site(title: scheme + "eBay.com"))
+ XCTAssertEqual(sitesList[3], ContextualOnboardingListItem.surprise(title: scheme + "britannica.com/animal/duck", visibleTitle: surpriseMeTitle))
+ }
+}
diff --git a/Tests/OnboardingTests/OnboardingSuggestionsViewModelsTests.swift b/Tests/OnboardingTests/OnboardingSuggestionsViewModelsTests.swift
new file mode 100644
index 000000000..ed7927f4f
--- /dev/null
+++ b/Tests/OnboardingTests/OnboardingSuggestionsViewModelsTests.swift
@@ -0,0 +1,171 @@
+//
+// OnboardingSuggestionsViewModelsTests.swift
+//
+// 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
+@testable import Onboarding
+
+final class OnboardingSuggestionsViewModelsTests: XCTestCase {
+ var suggestionsProvider: MockOnboardingSuggestionsProvider!
+ var navigationDelegate: CapturingOnboardingNavigationDelegate!
+ var searchSuggestionsVM: OnboardingSearchSuggestionsViewModel!
+ var siteSuggestionsVM: OnboardingSiteSuggestionsViewModel!
+ var pixelReporterMock: MockOnboaringSuggestionsPixelReporting!
+
+ override func setUp() {
+ suggestionsProvider = MockOnboardingSuggestionsProvider()
+ navigationDelegate = CapturingOnboardingNavigationDelegate()
+ pixelReporterMock = MockOnboaringSuggestionsPixelReporting()
+ searchSuggestionsVM = OnboardingSearchSuggestionsViewModel(suggestedSearchesProvider: suggestionsProvider, delegate: navigationDelegate, pixelReporter: pixelReporterMock)
+ siteSuggestionsVM = OnboardingSiteSuggestionsViewModel(title: "", suggestedSitesProvider: suggestionsProvider, delegate: navigationDelegate, pixelReporter: pixelReporterMock)
+ }
+
+ override func tearDown() {
+ suggestionsProvider = nil
+ navigationDelegate = nil
+ pixelReporterMock = nil
+ searchSuggestionsVM = nil
+ siteSuggestionsVM = nil
+ }
+
+ func testSearchSuggestionsViewModelReturnsExpectedSuggestionsList() {
+ // GIVEN
+ let expectedSearchList = [
+ ContextualOnboardingListItem.search(title: "search something"),
+ ContextualOnboardingListItem.surprise(title: "search something else", visibleTitle: "Surprise Me!")
+ ]
+ suggestionsProvider.list = expectedSearchList
+
+ // THEN
+ XCTAssertEqual(searchSuggestionsVM.itemsList, expectedSearchList)
+ }
+
+ func testSearchSuggestionsViewModelOnListItemPressed_AsksDelegateToSearchForQuery() {
+ // GIVEN
+ let item1 = ContextualOnboardingListItem.search(title: "search something")
+ let item2 = ContextualOnboardingListItem.surprise(title: "search something else", visibleTitle: "Surprise Me!")
+ suggestionsProvider.list = [item1, item2]
+ let randomItem = [item1, item2].randomElement()!
+
+ // WHEN
+ searchSuggestionsVM.listItemPressed(randomItem)
+
+ // THEN
+ XCTAssertEqual(navigationDelegate.suggestedSearchQuery, randomItem.title)
+ }
+
+ func testSiteSuggestionsViewModelReturnsExpectedSuggestionsList() {
+ // GIVEN
+ let expectedSiteList = [
+ ContextualOnboardingListItem.site(title: "somesite.com"),
+ ContextualOnboardingListItem.surprise(title: "someothersite.com", visibleTitle: "Surprise Me!")
+ ]
+ suggestionsProvider.list = expectedSiteList
+
+ // THEN
+ XCTAssertEqual(siteSuggestionsVM.itemsList, expectedSiteList)
+ }
+
+ func testSiteSuggestionsViewModelOnListItemPressed_AsksDelegateToNavigateToURL() {
+ // GIVEN
+ let item1 = ContextualOnboardingListItem.site(title: "somesite.com")
+ let item2 = ContextualOnboardingListItem.surprise(title: "someothersite.com", visibleTitle: "Surprise Me!")
+ suggestionsProvider.list = [item1, item2]
+ let randomItem = [item1, item2].randomElement()!
+
+ // WHEN
+ siteSuggestionsVM.listItemPressed(randomItem)
+
+ // THEN
+ XCTAssertNotNil(navigationDelegate.urlToNavigateTo)
+ XCTAssertEqual(navigationDelegate.urlToNavigateTo, URL(string: randomItem.title))
+ }
+
+ // MARK: - Pixels
+
+ func testWhenSearchSuggestionsTapped_ThenPixelReporterIsCalled() {
+ // GIVEN
+ let searches: [ContextualOnboardingListItem] = [
+ ContextualOnboardingListItem.search(title: "First"),
+ ContextualOnboardingListItem.search(title: "Second"),
+ ContextualOnboardingListItem.search(title: "Third"),
+ ContextualOnboardingListItem.surprise(title: "Surprise", visibleTitle: "Surprise Me!"),
+ ]
+ suggestionsProvider.list = searches
+ XCTAssertFalse(pixelReporterMock.didCallTrackSearchOptionTapped)
+
+ // WHEN
+ searches.forEach { searchItem in
+ searchSuggestionsVM.listItemPressed(searchItem)
+ }
+
+ // THEN
+ XCTAssertTrue(pixelReporterMock.didCallTrackSearchOptionTapped)
+ }
+
+ func testWhenSiteSuggestionsTapped_ThenPixelReporterIsCalled() {
+ // GIVEN
+ let searches: [ContextualOnboardingListItem] = [
+ ContextualOnboardingListItem.site(title: "First"),
+ ContextualOnboardingListItem.site(title: "Second"),
+ ContextualOnboardingListItem.site(title: "Third"),
+ ContextualOnboardingListItem.surprise(title: "Surprise", visibleTitle: "Surprise Me!"),
+ ]
+ suggestionsProvider.list = searches
+ XCTAssertFalse(pixelReporterMock.didCallTrackSiteOptionTapped)
+
+ // WHEN
+ searches.forEach { searchItem in
+ siteSuggestionsVM.listItemPressed(searchItem)
+ }
+
+ // THEN
+ XCTAssertTrue(pixelReporterMock.didCallTrackSiteOptionTapped)
+ }
+
+}
+
+class MockOnboardingSuggestionsProvider: OnboardingSuggestionsItemsProviding {
+ var list: [ContextualOnboardingListItem] = []
+}
+
+class CapturingOnboardingNavigationDelegate: OnboardingNavigationDelegate {
+ var suggestedSearchQuery: String?
+ var urlToNavigateTo: URL?
+
+ func searchFor(_ query: String) {
+ suggestedSearchQuery = query
+ }
+
+ func navigateTo(url: URL) {
+ urlToNavigateTo = url
+ }
+}
+
+final class MockOnboaringSuggestionsPixelReporting: OnboardingSearchSuggestionsPixelReporting, OnboardingSiteSuggestionsPixelReporting {
+ private(set) var didCallTrackSearchOptionTapped = false
+ private(set) var didCallTrackSiteOptionTapped = false
+
+ func trackSearchSuggetionOptionTapped() {
+ didCallTrackSearchOptionTapped = true
+ }
+
+ func trackSiteSuggetionOptionTapped() {
+ didCallTrackSiteOptionTapped = true
+ }
+
+}