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 + } + +}