diff --git a/firefox-ios/Client.xcodeproj/project.pbxproj b/firefox-ios/Client.xcodeproj/project.pbxproj index 6920661307d1..3222be3d27f6 100644 --- a/firefox-ios/Client.xcodeproj/project.pbxproj +++ b/firefox-ios/Client.xcodeproj/project.pbxproj @@ -293,6 +293,7 @@ 28E91E751B443AD5009DF274 /* SyncConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28E91E741B443AD5009DF274 /* SyncConstants.swift */; }; 28ECD9BF1BA1F19900D829DA /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = E6231C001B90A44F005ABB0D /* libz.tbd */; }; 2C1298AF2BF602D3005AE4E4 /* DefaultSuggestedSites.swift in Sources */ = {isa = PBXBuildFile; fileRef = 394CF6CE1BAA493C00906917 /* DefaultSuggestedSites.swift */; }; + 2C2EA77F2DA3F2990085F5BC /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 2C2EA77E2DA3F2990085F5BC /* Lottie */; }; 2C2F31F52D46612F00977F55 /* TopSitesHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A5BD9582878871B000FE773 /* TopSitesHelperTests.swift */; }; 2C2F31F62D4663D800977F55 /* DependencyHelperMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A70EF18295E2E1600790249 /* DependencyHelperMock.swift */; }; 2C2F31F72D4664CB00977F55 /* MockThemeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AA75A622A46272000533F8D /* MockThemeManager.swift */; }; @@ -323,6 +324,7 @@ 2C6C908F2C614A6C007D9B43 /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 2C6C908E2C614A6C007D9B43 /* SnapshotTesting */; }; 2C7DC2402B1648BA00C049C8 /* LegacyTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB9A179A20E69A7E00B12184 /* LegacyTheme.swift */; }; 2C7DC2462B16493C00C049C8 /* UIConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2816EFFF1B33E05400522243 /* UIConstants.swift */; }; + 2CC0C2AD2DA804EA006FE9B7 /* ViewInspector in Frameworks */ = {isa = PBXBuildFile; productRef = 2CC0C2AC2DA804EA006FE9B7 /* ViewInspector */; }; 2CC246602D520EF90098467A /* EcosiaLightTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CC2465E2D520EF90098467A /* EcosiaLightTheme.swift */; }; 2CC246612D520EF90098467A /* EcosiaColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CC2465C2D520EF90098467A /* EcosiaColor.swift */; }; 2CC246632D520EF90098467A /* EcosiaDarkTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CC2465D2D520EF90098467A /* EcosiaDarkTheme.swift */; }; @@ -9705,6 +9707,7 @@ 2CFE9FCE2D45363500B25CE0 /* BrazeUI in Frameworks */, 2CFE9FD22D45364600B25CE0 /* Common in Frameworks */, 2CFE9FEA2D45404800B25CE0 /* SwiftSoup in Frameworks */, + 2C2EA77F2DA3F2990085F5BC /* Lottie in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -9714,6 +9717,7 @@ files = ( 2CFE9FC02D45348700B25CE0 /* RustMozillaAppServices.framework in Frameworks */, 2CFE9FBF2D45348200B25CE0 /* SnowplowTracker in Frameworks */, + 2CC0C2AD2DA804EA006FE9B7 /* ViewInspector in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -14893,6 +14897,7 @@ 2CFE9FCF2D45364100B25CE0 /* SnowplowTracker */, 2CFE9FD12D45364600B25CE0 /* Common */, 2CFE9FE92D45404800B25CE0 /* SwiftSoup */, + 2C2EA77E2DA3F2990085F5BC /* Lottie */, ); productName = Ecosia; productReference = 2CFE99662D45329200B25CE0 /* Ecosia.framework */; @@ -14917,6 +14922,7 @@ name = EcosiaTests; packageProductDependencies = ( 2CFE9FBE2D45348200B25CE0 /* SnowplowTracker */, + 2CC0C2AC2DA804EA006FE9B7 /* ViewInspector */, ); productName = EcosiaTests; productReference = 2CFE996F2D45329300B25CE0 /* EcosiaTests.xctest */; @@ -15464,6 +15470,7 @@ 126509832CD925B30011BA36 /* XCRemoteSwiftPackageReference "braze-swift-sdk" */, 12C25BEB2D27EBFF0048BADA /* XCRemoteSwiftPackageReference "snowplow-ios-tracker" */, 2CFE9FE82D453F9600B25CE0 /* XCRemoteSwiftPackageReference "SwiftSoup" */, + 2CC0C2A72DA802CD006FE9B7 /* XCRemoteSwiftPackageReference "ViewInspector" */, ); productRefGroup = F84B21BF1A090F8100AAB793 /* Products */; projectDirPath = ""; @@ -27139,6 +27146,14 @@ minimumVersion = 1.17.3; }; }; + 2CC0C2A72DA802CD006FE9B7 /* XCRemoteSwiftPackageReference "ViewInspector" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/nalexn/ViewInspector.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.10.1; + }; + }; 2CCFB3D82C0FC4DC00BEDCA0 /* XCRemoteSwiftPackageReference "rust-components-swift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/ecosia/rust-components-swift/"; @@ -27270,6 +27285,11 @@ isa = XCSwiftPackageProductDependency; productName = Redux; }; + 2C2EA77E2DA3F2990085F5BC /* Lottie */ = { + isa = XCSwiftPackageProductDependency; + package = 8AB30EC62B6C038600BD9A9B /* XCRemoteSwiftPackageReference "lottie-ios" */; + productName = Lottie; + }; 2C69DA7A2C6225C400D7F69F /* Common */ = { isa = XCSwiftPackageProductDependency; productName = Common; @@ -27284,6 +27304,11 @@ package = 2CCFB3D82C0FC4DC00BEDCA0 /* XCRemoteSwiftPackageReference "rust-components-swift" */; productName = MozillaAppServices; }; + 2CC0C2AC2DA804EA006FE9B7 /* ViewInspector */ = { + isa = XCSwiftPackageProductDependency; + package = 2CC0C2A72DA802CD006FE9B7 /* XCRemoteSwiftPackageReference "ViewInspector" */; + productName = ViewInspector; + }; 2CFE9FBE2D45348200B25CE0 /* SnowplowTracker */ = { isa = XCSwiftPackageProductDependency; package = 12C25BEB2D27EBFF0048BADA /* XCRemoteSwiftPackageReference "snowplow-ios-tracker" */; diff --git a/firefox-ios/Client.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/firefox-ios/Client.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5cd970e11673..211eed62993b 100644 --- a/firefox-ios/Client.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/firefox-ios/Client.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -225,6 +225,15 @@ "version" : "2.0.0" } }, + { + "identity" : "viewinspector", + "kind" : "remoteSourceControl", + "location" : "https://github.com/nalexn/ViewInspector.git", + "state" : { + "revision" : "788e7879d38a839c4e348ab0762dcc0364e646a2", + "version" : "0.10.1" + } + }, { "identity" : "xctest-dynamic-overlay", "kind" : "remoteSourceControl", diff --git a/firefox-ios/Client/Ecosia/Extensions/AppSettingsTableViewController+Ecosia.swift b/firefox-ios/Client/Ecosia/Extensions/AppSettingsTableViewController+Ecosia.swift index 815659d20fa7..a69a907c97d5 100644 --- a/firefox-ios/Client/Ecosia/Extensions/AppSettingsTableViewController+Ecosia.swift +++ b/firefox-ios/Client/Ecosia/Extensions/AppSettingsTableViewController+Ecosia.swift @@ -11,7 +11,6 @@ extension AppSettingsTableViewController { func getEcosiaSettingsSectionsShowingDebug(_ isDebugSectionEnabled: Bool) -> [SettingSection] { var sections = [ - getEcosiaDefaultBrowserSection(), getSearchSection(), getCustomizationSection(), getEcosiaGeneralSection(), @@ -20,6 +19,10 @@ extension AppSettingsTableViewController { getEcosiaAboutSection() ] + if User.shared.shouldShowDefaultBrowserSettingNudgeCard { + sections.insert(getEcosiaDefaultBrowserSection(), at: 0) + } + if isDebugSectionEnabled { sections.append(getEcosiaDebugSupportSection()) } @@ -30,14 +33,15 @@ extension AppSettingsTableViewController { extension AppSettingsTableViewController { + // We need this section as a placeholder for the default browser nudge card. private func getEcosiaDefaultBrowserSection() -> SettingSection { - .init(footerTitle: .init(string: .localized(.linksFromWebsites)), - children: [DefaultBrowserSetting(theme: themeManager.getCurrentTheme(for: windowUUID))]) + .init(children: [DefaultBrowserSetting(theme: themeManager.getCurrentTheme(for: windowUUID))]) } private func getSearchSection() -> SettingSection { - var settings: [Setting] = [ + let settings: [Setting] = [ + EcosiaDefaultBrowserSettings(), SearchAreaSetting(settings: self), SafeSearchSettings(settings: self), AutoCompleteSettings(prefs: profile.prefs, theme: themeManager.getCurrentTheme(for: windowUUID)), @@ -149,6 +153,7 @@ extension AppSettingsTableViewController { AddClaim(settings: self), ChangeSearchCount(settings: self), ResetSearchCount(settings: self), + ResetDefaultBrowserNudgeCard(settings: self), AnalyticsIdentifierSetting(settings: self), FasterInactiveTabs(settings: self, settingsDelegate: self), UnleashBrazeIntegrationSetting(settings: self), @@ -170,3 +175,30 @@ extension AppSettingsTableViewController { return SettingSection(title: NSAttributedString(string: "Debug"), children: hiddenDebugSettings) } } + +// MARK: - Default Browser Nudge Card helpers + +extension AppSettingsTableViewController { + + func isDefaultBrowserCell(_ section: Int) -> Bool { + settings[section].children.first?.accessibilityIdentifier == AccessibilityIdentifiers.Settings.DefaultBrowser.defaultBrowser + } + + func shouldShowDefaultBrowserNudgeCardInSection(_ section: Int) -> Bool { + isDefaultBrowserCell(section) && + User.shared.shouldShowDefaultBrowserSettingNudgeCard + } + + func hideDefaultBrowserNudgeCardInSection(_ section: Int) { + guard section < settings.count else { return } + self.settings.remove(at: section) + self.tableView.deleteSections(IndexSet(integer: section), with: .automatic) + } + + func showDefaultBrowserDetailView() { + DefaultBrowserCoordinator.makeDefaultCoordinatorAndShowDetailViewFrom(navigationController, + analyticsLabel: .settingsNudgeCard, + topViewContentBackground: EcosiaColor.DarkGreen50.color, + with: themeManager.getCurrentTheme(for: windowUUID)) + } +} diff --git a/firefox-ios/Client/Ecosia/Settings/EcosiaDebugSettings.swift b/firefox-ios/Client/Ecosia/Settings/EcosiaDebugSettings.swift index 939c794f9a9e..21b2153de36d 100644 --- a/firefox-ios/Client/Ecosia/Settings/EcosiaDebugSettings.swift +++ b/firefox-ios/Client/Ecosia/Settings/EcosiaDebugSettings.swift @@ -200,6 +200,25 @@ final class ChangeSearchCount: HiddenSetting { } } +final class ResetDefaultBrowserNudgeCard: HiddenSetting { + override var title: NSAttributedString? { + return NSAttributedString(string: "Debug: Makes the Default Browser nudge card visible again", attributes: [NSAttributedString.Key.foregroundColor: theme.colors.ecosia.tableViewRowText]) + } + + override var status: NSAttributedString? { + let status = "\(User.shared.shouldShowDefaultBrowserSettingNudgeCard)" + let suggestion = User.shared.shouldShowDefaultBrowserSettingNudgeCard ? "" : " (Click to show)" + return NSAttributedString(string: "Card visible: \(status)\(suggestion)", attributes: [NSAttributedString.Key.foregroundColor: theme.colors.ecosia.tableViewRowText]) + } + + override func onClick(_ navigationController: UINavigationController?) { + guard !User.shared.shouldShowDefaultBrowserSettingNudgeCard else { return } + User.shared.showDefaultBrowserSettingNudgeCard() + self.settings.settings = self.settings.generateSettings() + self.settings.tableView.reloadData() + } +} + class UnleashVariantResetSetting: HiddenSetting { var titleName: String? { return nil } var variant: Unleash.Variant? { return nil } diff --git a/firefox-ios/Client/Ecosia/Settings/EcosiaSettings.swift b/firefox-ios/Client/Ecosia/Settings/EcosiaSettings.swift index 1925c8bfe521..8575f5723b8e 100644 --- a/firefox-ios/Client/Ecosia/Settings/EcosiaSettings.swift +++ b/firefox-ios/Client/Ecosia/Settings/EcosiaSettings.swift @@ -16,6 +16,22 @@ func ecosiaDisclosureIndicator(theme: Theme) -> UIImageView { return disclosureIndicator } +final class EcosiaDefaultBrowserSettings: Setting { + + override var accessoryView: UIImageView? { ecosiaDisclosureIndicator(theme: theme) } + + override var title: NSAttributedString? { + NSAttributedString(string: .localized(.defaultBrowserSettingTitle), attributes: [NSAttributedString.Key.foregroundColor: theme.colors.ecosia.tableViewRowText]) + } + + override func onClick(_ navigationController: UINavigationController?) { + DefaultBrowserCoordinator.makeDefaultCoordinatorAndShowDetailViewFrom(navigationController, + analyticsLabel: .settings, + topViewContentBackground: EcosiaColor.DarkGreen50.color, + with: theme) + } +} + final class SearchAreaSetting: Setting { override var title: NSAttributedString? { NSAttributedString(string: .localized(.searchRegion), attributes: [NSAttributedString.Key.foregroundColor: theme.colors.ecosia.tableViewRowText]) diff --git a/firefox-ios/Client/Ecosia/UI/Ecosia.xcassets/closeButtonStandard.imageset/close.pdf b/firefox-ios/Client/Ecosia/UI/Ecosia.xcassets/closeButtonStandard.imageset/close.pdf deleted file mode 100644 index 42bc285ba7c2..000000000000 Binary files a/firefox-ios/Client/Ecosia/UI/Ecosia.xcassets/closeButtonStandard.imageset/close.pdf and /dev/null differ diff --git a/firefox-ios/Client/Ecosia/UI/NTP/NudgeCards/NTPConfigurableNudgeCardCell.swift b/firefox-ios/Client/Ecosia/UI/NTP/NudgeCards/NTPConfigurableNudgeCardCell.swift index 8751b299440d..34cdf94ca8ad 100644 --- a/firefox-ios/Client/Ecosia/UI/NTP/NudgeCards/NTPConfigurableNudgeCardCell.swift +++ b/firefox-ios/Client/Ecosia/UI/NTP/NudgeCards/NTPConfigurableNudgeCardCell.swift @@ -3,111 +3,25 @@ // file, You can obtain one at http://mozilla.org/MPL/2.0/ import UIKit +import SwiftUI import Common +import Ecosia /// Reusable Nudge Card Cell that can be configured with any view model. class NTPConfigurableNudgeCardCell: UICollectionViewCell, ThemeApplicable, ReusableCell { - // MARK: - UX Constants - private enum UX { - static let cornerRadius: CGFloat = 10 - static let closeButtonWidthHeight: CGFloat = 48 - static let insetMargin: CGFloat = 16 - static let textSpacing: CGFloat = 4 - static let mainContainerSpacing: CGFloat = 4 - static let buttonAdditionalSpacing: CGFloat = 8 - static let imageWidthHeight: CGFloat = 48 - } - - // MARK: - UI Components - - private let mainContainerStackView: UIStackView = { - let stackView = UIStackView() - stackView.translatesAutoresizingMaskIntoConstraints = false - stackView.layer.cornerRadius = UX.cornerRadius - stackView.spacing = UX.mainContainerSpacing - stackView.axis = .horizontal - stackView.alignment = .leading - stackView.isLayoutMarginsRelativeArrangement = true - stackView.directionalLayoutMargins = .init(top: UX.insetMargin, - leading: UX.insetMargin, - bottom: UX.insetMargin, - trailing: UX.insetMargin) - stackView.spacing = UX.textSpacing - return stackView - }() - - private let labelsAndActionButtonStackView: UIStackView = { - let stackView = UIStackView() - stackView.translatesAutoresizingMaskIntoConstraints = false - stackView.axis = .vertical - stackView.alignment = .leading - stackView.spacing = UX.textSpacing - return stackView - }() - - private let closeButton: UIButton = { - let button = UIButton() - button.translatesAutoresizingMaskIntoConstraints = false - button.setImage(.init(named: "closeButtonStandard"), for: .normal) - button.contentMode = .top - button.imageView?.contentMode = .scaleAspectFill - button.addTarget(self, action: #selector(closeAction), for: .touchUpInside) - button.setContentHuggingPriority(.required, for: .horizontal) - return button - }() - - private lazy var imageView: UIImageView = { - let image = UIImageView() - image.translatesAutoresizingMaskIntoConstraints = false - image.contentMode = .scaleAspectFit - return image - }() - - private let titleLabel: UILabel = { - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - label.font = .preferredFont(forTextStyle: .headline).bold() - label.adjustsFontForContentSizeCategory = true - label.lineBreakMode = .byWordWrapping - label.numberOfLines = 0 - return label - }() - - private let descriptionLabel: UILabel = { - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - label.font = .preferredFont(forTextStyle: .subheadline) - label.adjustsFontForContentSizeCategory = true - label.lineBreakMode = .byWordWrapping - label.numberOfLines = 0 - return label - }() - - private let actionButton: UIButton = { - let button = UIButton() - button.translatesAutoresizingMaskIntoConstraints = false - button.titleLabel?.adjustsFontForContentSizeCategory = true - button.titleLabel?.font = .preferredFont(forTextStyle: .subheadline) - button.setContentCompressionResistancePriority(.required, for: .horizontal) - button.addTarget(self, action: #selector(actionButtonTapped), for: .touchUpInside) - button.setInsets(forContentPadding: .init(top: UX.buttonAdditionalSpacing, left: 0, bottom: 0, right: 0), imageTitlePadding: 0) - return button - }() - // MARK: - Properties private var viewModel: NTPConfigurableNudgeCardCellViewModel? - - // MARK: - Delegate - weak var delegate: NTPConfigurableNudgeCardCellDelegate? + var theme: Theme! + private var hostingController: UIHostingController? // MARK: - Initializer override init(frame: CGRect) { super.init(frame: frame) - setup() + setupHostingController() } required init?(coder: NSCoder) { @@ -116,77 +30,63 @@ class NTPConfigurableNudgeCardCell: UICollectionViewCell, ThemeApplicable, Reusa // MARK: - Setup - private func setup() { - contentView.addSubview(mainContainerStackView) - - labelsAndActionButtonStackView.addArrangedSubview(titleLabel) - labelsAndActionButtonStackView.addArrangedSubview(descriptionLabel) - labelsAndActionButtonStackView.addArrangedSubview(actionButton) + private func setupHostingController() { + let view = ConfigurableNudgeCardView() + let controller = UIHostingController(rootView: view) + controller.view.translatesAutoresizingMaskIntoConstraints = false + controller.view.backgroundColor = .clear - mainContainerStackView.addArrangedSubview(imageView) - mainContainerStackView.addArrangedSubview(labelsAndActionButtonStackView) - mainContainerStackView.addArrangedSubview(closeButton) + contentView.addSubview(controller.view) NSLayoutConstraint.activate([ - mainContainerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - mainContainerStackView.topAnchor.constraint(equalTo: contentView.topAnchor), - mainContainerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - mainContainerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - closeButton.widthAnchor.constraint(equalToConstant: UX.closeButtonWidthHeight), - closeButton.heightAnchor.constraint(equalToConstant: UX.closeButtonWidthHeight), - imageView.heightAnchor.constraint(equalToConstant: UX.imageWidthHeight), - imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor), + controller.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + controller.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + controller.view.topAnchor.constraint(equalTo: contentView.topAnchor), + controller.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) ]) + + hostingController = controller } // MARK: - Configuration Method /// Configures the Nudge Card Cell using the ViewModel. - func configure(with viewModel: NTPConfigurableNudgeCardCellViewModel) { - - titleLabel.text = viewModel.title - descriptionLabel.text = viewModel.description - actionButton.setTitle(viewModel.buttonText, for: .normal) - - if let image = viewModel.image { - imageView.image = image - imageView.isHidden = false - } else { - imageView.isHidden = true - } - - closeButton.isHidden = !viewModel.showsCloseButton + func configure(with viewModel: NTPConfigurableNudgeCardCellViewModel, theme: Theme?) { self.viewModel = viewModel + self.theme = theme delegate = viewModel.delegate - - // Apply accessibility updates - configureAccessibility() - } - - private func configureAccessibility() { - // Set accessibility labels and traits based on the ViewModel - titleLabel.accessibilityLabel = viewModel?.title - descriptionLabel.accessibilityLabel = viewModel?.description - actionButton.accessibilityLabel = viewModel?.buttonText - closeButton.accessibilityLabel = .localized(.configurableNudgeCardCloseButtonAccessibilityLabel) + guard let theme else { return } + let nudgeCardStyle = NudgeCardStyle(backgroundColor: theme.colors.ecosia.backgroundSecondary.color, + textPrimaryColor: theme.colors.ecosia.textPrimary.color, + textSecondaryColor: theme.colors.ecosia.textSecondary.color, + closeButtonTextColor: theme.colors.ecosia.iconDecorative.color, + actionButtonTextColor: theme.colors.ecosia.buttonBackgroundPrimary.color) + let configurableCardViewModel = NudgeCardViewModel(title: viewModel.title, + description: viewModel.description, + buttonText: viewModel.buttonText, + image: viewModel.image, + style: nudgeCardStyle) + hostingController?.rootView = ConfigurableNudgeCardView(viewModel: configurableCardViewModel, delegate: self) } // MARK: - Theming func applyTheme(theme: Theme) { - mainContainerStackView.backgroundColor = theme.colors.ecosia.backgroundSecondary - closeButton.tintColor = theme.colors.ecosia.iconDecorative - titleLabel.textColor = theme.colors.ecosia.textPrimary - descriptionLabel.textColor = theme.colors.ecosia.textSecondary - actionButton.setTitleColor(theme.colors.ecosia.buttonBackgroundPrimary, for: .normal) + guard let viewModel else { return } + configure(with: viewModel, theme: theme) } +} - @objc private func closeAction() { - guard let cardSectionType = viewModel?.cardSectionType else { return } - delegate?.nudgeCardRequestToDimiss(for: cardSectionType) +extension NTPConfigurableNudgeCardCell: ConfigurableNudgeCardActionDelegate { + + func nudgeCardRequestToPerformAction() { + guard let sectionType = viewModel?.sectionType else { return } + delegate?.nudgeCardRequestToPerformAction(for: sectionType) } - @objc private func actionButtonTapped() { - guard let cardSectionType = viewModel?.cardSectionType else { return } - delegate?.nudgeCardRequestToPerformAction(for: cardSectionType) + func nudgeCardRequestToDimiss() { + guard let sectionType = viewModel?.sectionType else { return } + delegate?.nudgeCardRequestToDimiss(for: sectionType) } + + func nudgeCardTapped() {} } diff --git a/firefox-ios/Client/Ecosia/UI/NTP/NudgeCards/NTPConfigurableNudgeCardCellViewModel.swift b/firefox-ios/Client/Ecosia/UI/NTP/NudgeCards/NTPConfigurableNudgeCardCellViewModel.swift index 9e1c95c7bafe..b645d64a0ed4 100644 --- a/firefox-ios/Client/Ecosia/UI/NTP/NudgeCards/NTPConfigurableNudgeCardCellViewModel.swift +++ b/firefox-ios/Client/Ecosia/UI/NTP/NudgeCards/NTPConfigurableNudgeCardCellViewModel.swift @@ -89,8 +89,7 @@ extension NTPConfigurableNudgeCardCellViewModel: HomepageSectionHandler { guard let cell = cell as? NTPConfigurableNudgeCardCell else { return UICollectionViewCell() } - cell.configure(with: self) - cell.applyTheme(theme: theme) + cell.configure(with: self, theme: theme) return cell } } diff --git a/firefox-ios/Client/Frontend/Settings/Main/AppSettingsTableViewController.swift b/firefox-ios/Client/Frontend/Settings/Main/AppSettingsTableViewController.swift index 176eef8eda3b..f6f53b31f665 100644 --- a/firefox-ios/Client/Frontend/Settings/Main/AppSettingsTableViewController.swift +++ b/firefox-ios/Client/Frontend/Settings/Main/AppSettingsTableViewController.swift @@ -5,6 +5,7 @@ import Common import UIKit import Shared +import Ecosia // MARK: - Settings Flow Delegate Protocol @@ -85,6 +86,12 @@ class AppSettingsTableViewController: SettingsTableViewController, setupNavigationBar() configureAccessibilityIdentifiers() + + // Ecosia: Register Nudge Card if needed + if User.shared.shouldShowDefaultBrowserSettingNudgeCard { + tableView.register(DefaultBrowserSettingsNudgeCardHeaderView.self, + forHeaderFooterViewReuseIdentifier: DefaultBrowserSettingsNudgeCardHeaderView.cellIdentifier) + } } /* Ecosia: Move settings reload to `viewWillAppear` @@ -471,6 +478,8 @@ class AppSettingsTableViewController: SettingsTableViewController, // MARK: - UITableViewDelegate + /* Ecosia: Set the header view for the table view with custom handling for the default browser nudge card + Adds other overrides after this one to modify the UI logic override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { let headerView = super.tableView( tableView, @@ -478,4 +487,74 @@ class AppSettingsTableViewController: SettingsTableViewController, ) as! ThemedTableSectionHeaderFooterView return headerView } + */ + override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + if shouldShowDefaultBrowserNudgeCardInSection(section), + let header = tableView.dequeueReusableHeaderFooterView(withIdentifier: DefaultBrowserSettingsNudgeCardHeaderView.cellIdentifier) + as? DefaultBrowserSettingsNudgeCardHeaderView { + header.onDismiss = { [weak self] in + User.shared.hideDefaultBrowserSettingNudgeCard() + Analytics.shared.defaultBrowserSettingsViaNudgeCardDismiss() + self?.hideDefaultBrowserNudgeCardInSection(section) + } + header.onTap = { [weak self] in + User.shared.hideDefaultBrowserSettingNudgeCard() + self?.showDefaultBrowserDetailView() + } + header.applyTheme(theme: themeManager.getCurrentTheme(for: windowUUID)) + return header + } else if let headerView = super.tableView( + tableView, + viewForHeaderInSection: section + ) as? ThemedTableSectionHeaderFooterView { + return headerView + } + return nil + } + + override func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + guard shouldShowDefaultBrowserNudgeCardInSection(section) else { + return super.tableView(tableView, viewForFooterInSection: section) + } + return nil + } + + override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + guard shouldShowDefaultBrowserNudgeCardInSection(section) else { + return super.tableView(tableView, heightForFooterInSection: section) + } + return 0 + } + + override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + if shouldShowDefaultBrowserNudgeCardInSection(section) { + return UITableView.automaticDimension + } + return super.tableView(tableView, heightForHeaderInSection: section) + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + if shouldShowDefaultBrowserNudgeCardInSection(section) { + return 1 + } + return super.tableView(tableView, numberOfRowsInSection: section) + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + if shouldShowDefaultBrowserNudgeCardInSection(indexPath.section) { + let cell = UITableViewCell() + cell.isUserInteractionEnabled = false + cell.backgroundColor = .clear + cell.contentView.isHidden = true + return cell + } + return super.tableView(tableView, cellForRowAt: indexPath) + } + + override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + if shouldShowDefaultBrowserNudgeCardInSection(indexPath.section) { + return .leastNonzeroMagnitude + } + return super.tableView(tableView, heightForRowAt: indexPath) + } } diff --git a/firefox-ios/Client/Frontend/Settings/Main/DefaultBrowserSetting.swift b/firefox-ios/Client/Frontend/Settings/Main/DefaultBrowserSetting.swift index d2e336ac1ff9..9d183eca62b8 100644 --- a/firefox-ios/Client/Frontend/Settings/Main/DefaultBrowserSetting.swift +++ b/firefox-ios/Client/Frontend/Settings/Main/DefaultBrowserSetting.swift @@ -5,37 +5,19 @@ import Common import Foundation import Shared -import Ecosia class DefaultBrowserSetting: Setting { override var accessibilityIdentifier: String? { return "DefaultBrowserSettings" } init(theme: Theme) { - /* Ecosia: Update Title - super.init( - title: NSAttributedString( - string: String.DefaultBrowserMenuItem, - attributes: [NSAttributedString.Key.foregroundColor: theme.colors.actionPrimary] - ) - ) - */ - super.init(title: .init(string: .localized(.setAsDefaultBrowser), attributes: [NSAttributedString.Key.foregroundColor: theme.colors.ecosia.tableViewRowText])) - } - - // Ecosia: Override cell config to add image - override func onConfigureCell(_ cell: UITableViewCell, theme: Theme) { - super.onConfigureCell(cell, theme: theme) - cell.imageView?.image = .init(named: "yourImpact") + super.init(title: NSAttributedString(string: String.DefaultBrowserMenuItem, + attributes: [NSAttributedString.Key.foregroundColor: theme.colors.actionPrimary])) } override func onClick(_ navigationController: UINavigationController?) { TelemetryWrapper.gleanRecordEvent(category: .action, method: .open, object: .settingsMenuSetAsDefaultBrowser) - - // Ecosia: Track default browser setting click - Analytics.shared.defaultBrowserSettings() - DefaultApplicationHelper().openSettings() } } diff --git a/firefox-ios/Ecosia/Analytics/Analytics.Values.swift b/firefox-ios/Ecosia/Analytics/Analytics.Values.swift index 4adf5efdcfb7..d13cada33f3e 100644 --- a/firefox-ios/Ecosia/Analytics/Analytics.Values.swift +++ b/firefox-ios/Ecosia/Analytics/Analytics.Values.swift @@ -41,7 +41,8 @@ extension Analytics { case deeplink = "default_browser_deeplink", promo = "default_browser_promo", - settings = "default_browser_settings" + settings = "default_browser_settings", + settingsNudgeCard = "default_browser_settings_nudge_card" } public enum Menu: String { @@ -205,7 +206,9 @@ extension Analytics { case enable, disable, - home + home, + detail, + nativeSettings = "native_settings" public enum APNConsent: String { case diff --git a/firefox-ios/Ecosia/Analytics/Analytics.swift b/firefox-ios/Ecosia/Analytics/Analytics.swift index 790146981e21..33867a690610 100644 --- a/firefox-ios/Ecosia/Analytics/Analytics.swift +++ b/firefox-ios/Ecosia/Analytics/Analytics.swift @@ -126,10 +126,30 @@ open class Analytics { track(event) } - public func defaultBrowserSettings() { + public func defaultBrowserSettingsShowsDetailViewVia(_ label: Label.DefaultBrowser) { track(Structured(category: Category.browser.rawValue, action: Action.open.rawValue) - .label(Label.DefaultBrowser.settings.rawValue)) + .label(label.rawValue)) + } + + public func defaultBrowserSettingsViaNudgeCardDismiss() { + track(Structured(category: Category.browser.rawValue, + action: Action.dismiss.rawValue) + .label(Label.DefaultBrowser.settingsNudgeCard.rawValue)) + } + + public func defaultBrowserSettingsOpenNativeSettingsVia(_ label: Label.DefaultBrowser) { + track(Structured(category: Category.browser.rawValue, + action: Action.click.rawValue) + .label(label.rawValue) + .property(Property.nativeSettings.rawValue)) + } + + public func defaultBrowserSettingsDismissDetailViewVia(_ label: Label.DefaultBrowser) { + track(Structured(category: Category.browser.rawValue, + action: Action.dismiss.rawValue) + .label(label.rawValue) + .property(Property.detail.rawValue)) } // MARK: Menu diff --git a/firefox-ios/Ecosia/Core/User.swift b/firefox-ios/Ecosia/Core/User.swift index 7b52888ac81f..ea6eb4bf331c 100644 --- a/firefox-ios/Ecosia/Core/User.swift +++ b/firefox-ios/Ecosia/Core/User.swift @@ -211,13 +211,26 @@ extension User { state[Key.bookmarksImportExportTooltipShown.rawValue] = "\(false)" } + public var shouldShowDefaultBrowserSettingNudgeCard: Bool { + state[Key.isDefaultBrowserSettingNudgeCardShown.rawValue].map(Bool.init) != true + } + + public mutating func showDefaultBrowserSettingNudgeCard() { + state[Key.isDefaultBrowserSettingNudgeCardShown.rawValue] = "\(false)" + } + + public mutating func hideDefaultBrowserSettingNudgeCard() { + state[Key.isDefaultBrowserSettingNudgeCardShown.rawValue] = "\(true)" + } + enum Key: String { case referralSpotlight, impactIntro = "counterIntro", // Reusing previous key inactiveTabsTooltip, bookmarksImportExportTooltipShown, - isNewUserSinceBookmarksImportExportHasBeenShipped + isNewUserSinceBookmarksImportExportHasBeenShipped, + isDefaultBrowserSettingNudgeCardShown } } diff --git a/firefox-ios/Ecosia/L10N/String.swift b/firefox-ios/Ecosia/L10N/String.swift index 2a0115d2ba31..d4024fd7012a 100644 --- a/firefox-ios/Ecosia/L10N/String.swift +++ b/firefox-ios/Ecosia/L10N/String.swift @@ -117,8 +117,6 @@ extension String { case weAreMomentarilyUnable = "We are momentarily unable to load all of your settings." case continueMessage = "Continue" case retryMessage = "Retry" - case setAsDefaultBrowser = "Set Ecosia as default browser" - case linksFromWebsites = "Links from websites, emails or messages will automatically open in Ecosia." case showTopSites = "Show Top Sites" case helpYourFriendsBecome = "Help your friends become climate active and plant trees together" case friendsJoined = "%d friend(s) joined" @@ -234,5 +232,13 @@ extension String { case newsletterNTPCardExperimentTitle = "Be the first to know" case newsletterNTPCardExperimentDescription = "Subscribe to our monthly newsletter for updates on your climate impact." case newsletterNTPCardExperimentButton = "Sign up" + case defaultBrowserSettingTitle = "Default browser" + case defaultBrowserCardTitle = "Make Ecosia your default browser app" + case defaultBrowserCardDescription = "Safely open all links from other apps in Ecosia" + case defaultBrowserCardDetailTitle = "Use Ecosia as default" + case defaultBrowserCardDetailInstructionStep1 = "Open **Settings**" + case defaultBrowserCardDetailInstructionStep2 = "Select **Default Browser App**" + case defaultBrowserCardDetailInstructionStep3 = "Choose **Ecosia**" + case defaultBrowserCardDetailButton = "Make default in settings" } } diff --git a/firefox-ios/Ecosia/L10N/en.lproj/Ecosia.strings b/firefox-ios/Ecosia/L10N/en.lproj/Ecosia.strings index e256981094f9..bb3e5de76d3f 100644 --- a/firefox-ios/Ecosia/L10N/en.lproj/Ecosia.strings +++ b/firefox-ios/Ecosia/L10N/en.lproj/Ecosia.strings @@ -46,8 +46,6 @@ "We are momentarily unable to load all of your settings." = "We are momentarily unable to load all of your settings."; "Continue" = "Continue"; "Retry" = "Retry"; -"Set Ecosia as default browser" = "Set Ecosia as default browser"; -"Links from websites, emails or messages will automatically open in Ecosia." = "Links from websites, emails or messages will automatically open in Ecosia."; "Show Top Sites" = "Show Top Sites"; "All regions" = "All regions"; "Refresh" = "Refresh"; @@ -229,3 +227,11 @@ "Make Ecosia your default browser" = "Make Ecosia your default browser"; "Set Ecosia as default" = "Set Ecosia as default"; "Make all your browsing green" = "Make all your browsing green"; +"Default browser" = "Default browser"; +"Make Ecosia your default browser app" = "Make Ecosia your default browser app"; +"Safely open all links from other apps in Ecosia" = "Safely open all links from other apps in Ecosia"; +"Use Ecosia as default" = "Use Ecosia as default"; +"Open **Settings**" = "Open **Settings**"; +"Select **Default Browser App**" = "Select **Default Browser App**"; +"Choose **Ecosia**" = "Choose **Ecosia**"; +"Make default in settings" = "Make default in settings"; diff --git a/firefox-ios/Client/Ecosia/UI/Ecosia.xcassets/closeButtonStandard.imageset/Contents.json b/firefox-ios/Ecosia/UI/Common.xcassets/close.imageset/Contents.json similarity index 57% rename from firefox-ios/Client/Ecosia/UI/Ecosia.xcassets/closeButtonStandard.imageset/Contents.json rename to firefox-ios/Ecosia/UI/Common.xcassets/close.imageset/Contents.json index c234ec3a1dc4..3aeefbd81d47 100644 --- a/firefox-ios/Client/Ecosia/UI/Ecosia.xcassets/closeButtonStandard.imageset/Contents.json +++ b/firefox-ios/Ecosia/UI/Common.xcassets/close.imageset/Contents.json @@ -8,9 +8,5 @@ "info" : { "author" : "xcode", "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true, - "template-rendering-intent" : "template" } } diff --git a/firefox-ios/Ecosia/UI/Common.xcassets/close.imageset/close.pdf b/firefox-ios/Ecosia/UI/Common.xcassets/close.imageset/close.pdf new file mode 100644 index 000000000000..edc3ad3b4721 Binary files /dev/null and b/firefox-ios/Ecosia/UI/Common.xcassets/close.imageset/close.pdf differ diff --git a/firefox-ios/Ecosia/UI/Common.xcassets/default-browser-card-side-image-koto-illustrations.imageset/Contents.json b/firefox-ios/Ecosia/UI/Common.xcassets/default-browser-card-side-image-koto-illustrations.imageset/Contents.json new file mode 100644 index 000000000000..a4be09603598 --- /dev/null +++ b/firefox-ios/Ecosia/UI/Common.xcassets/default-browser-card-side-image-koto-illustrations.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "default-browser-card-side-image-koto-illustrations.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/firefox-ios/Ecosia/UI/Common.xcassets/default-browser-card-side-image-koto-illustrations.imageset/default-browser-card-side-image-koto-illustrations.pdf b/firefox-ios/Ecosia/UI/Common.xcassets/default-browser-card-side-image-koto-illustrations.imageset/default-browser-card-side-image-koto-illustrations.pdf new file mode 100644 index 000000000000..bb589d188bd6 Binary files /dev/null and b/firefox-ios/Ecosia/UI/Common.xcassets/default-browser-card-side-image-koto-illustrations.imageset/default-browser-card-side-image-koto-illustrations.pdf differ diff --git a/firefox-ios/Ecosia/UI/Common.xcassets/wave-forms-horizontal-1.imageset/Contents.json b/firefox-ios/Ecosia/UI/Common.xcassets/wave-forms-horizontal-1.imageset/Contents.json new file mode 100644 index 000000000000..61e9f63aa743 --- /dev/null +++ b/firefox-ios/Ecosia/UI/Common.xcassets/wave-forms-horizontal-1.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "wave-forms-horizontal.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/firefox-ios/Ecosia/UI/Common.xcassets/wave-forms-horizontal-1.imageset/wave-forms-horizontal.pdf b/firefox-ios/Ecosia/UI/Common.xcassets/wave-forms-horizontal-1.imageset/wave-forms-horizontal.pdf new file mode 100644 index 000000000000..1651645dc384 Binary files /dev/null and b/firefox-ios/Ecosia/UI/Common.xcassets/wave-forms-horizontal-1.imageset/wave-forms-horizontal.pdf differ diff --git a/firefox-ios/Ecosia/UI/ConfigurableNudgeCardView.swift b/firefox-ios/Ecosia/UI/ConfigurableNudgeCardView.swift new file mode 100644 index 000000000000..afbf63a113e1 --- /dev/null +++ b/firefox-ios/Ecosia/UI/ConfigurableNudgeCardView.swift @@ -0,0 +1,177 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import SwiftUI +import Common + +/// A protocol defining actions that can be triggered from a Nudge Card. +public protocol ConfigurableNudgeCardActionDelegate: AnyObject { + func nudgeCardRequestToPerformAction() + func nudgeCardRequestToDimiss() + func nudgeCardTapped() +} + +/// A style configuration object for `ConfigurableNudgeCardView`, defining color values for rendering. +public struct NudgeCardStyle { + let backgroundColor: Color + let textPrimaryColor: Color + let textSecondaryColor: Color + let closeButtonTextColor: Color + let actionButtonTextColor: Color + + public init(backgroundColor: Color, + textPrimaryColor: Color, + textSecondaryColor: Color, + closeButtonTextColor: Color, + actionButtonTextColor: Color) { + self.backgroundColor = backgroundColor + self.textPrimaryColor = textPrimaryColor + self.textSecondaryColor = textSecondaryColor + self.closeButtonTextColor = closeButtonTextColor + self.actionButtonTextColor = actionButtonTextColor + } +} + +/// A view model containing the content and style information used to render a `ConfigurableNudgeCardView`. +public struct NudgeCardViewModel { + /// A card must have a title. + let title: String + /// Pass `nil` to hide the description text. + let description: String? + /// Pass `nil` to hide the bottom action button. + let buttonText: String? + /// Pass `nil` to hide the image. + let image: UIImage? + let showsCloseButton: Bool + var style: NudgeCardStyle + + public init(title: String, + description: String? = nil, + buttonText: String? = nil, + image: UIImage? = nil, + showsCloseButton: Bool = true, + style: NudgeCardStyle) { + self.title = title + self.description = description + self.buttonText = buttonText + self.image = image + self.showsCloseButton = showsCloseButton + self.style = style + } +} + +/// A SwiftUI view representing a configurable card with optional image, text, action button, and close button. +/// Used in collection view cells like NTP Cards or the Default Browser Card. +public struct ConfigurableNudgeCardView: View { + var viewModel: NudgeCardViewModel? + weak var delegate: ConfigurableNudgeCardActionDelegate? + + public init(viewModel: NudgeCardViewModel? = nil, + delegate: ConfigurableNudgeCardActionDelegate? = nil) { + self.viewModel = viewModel + self.delegate = delegate + } + + public var body: some View { + HStack(alignment: .top, spacing: .ecosia.space._2s) { + // Image + if let image = viewModel?.image { + Image(uiImage: image) + .resizable() + .frame(width: UX.imageWidthHeight, height: UX.imageWidthHeight) + .accessibilityHidden(true) + } + + // Text and Action Stack + VStack(alignment: .leading, spacing: .ecosia.space._2s) { + if let title = viewModel?.title { + Text(title) + .font(.headline.bold()) + .foregroundColor(viewModel?.style.textPrimaryColor) + .multilineTextAlignment(.leading) + .accessibilityLabel(title) + .accessibilityIdentifier("nudge_card_title") + } + + if let description = viewModel?.description { + Text(description) + .font(.subheadline) + .foregroundColor(viewModel?.style.textSecondaryColor) + .multilineTextAlignment(.leading) + .accessibilityLabel(description) + .accessibilityIdentifier("nudge_card_description") + } + + if let buttonText = viewModel?.buttonText { + Button(action: { + delegate?.nudgeCardRequestToPerformAction() + }) { + Text(buttonText) + .font(.subheadline) + .foregroundColor(viewModel?.style.actionButtonTextColor) + } + .padding(.top, .ecosia.space._1s) + .accessibilityLabel(buttonText) + .accessibilityIdentifier("nudge_card_cta_button") + .accessibilityAddTraits(.isButton) + } + } + + // Close button + if viewModel?.showsCloseButton == true { + Button(action: { + delegate?.nudgeCardRequestToDimiss() + }) { + Image("close", bundle: .ecosia) + .renderingMode(.template) + .resizable() + .frame(width: UX.closeButtonWidthHeight, + height: UX.closeButtonWidthHeight) + .foregroundStyle(viewModel?.style.closeButtonTextColor ?? .primaryText) + .accessibilityLabel(String.localized(.configurableNudgeCardCloseButtonAccessibilityLabel)) + .accessibilityIdentifier("nudge_card_close_button") + .accessibilityAddTraits(.isButton) + } + } + } + .onTapGesture { + delegate?.nudgeCardTapped() + } + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isButton) + .padding(.ecosia.space._m) + .background(viewModel?.style.backgroundColor) + .clipShape(RoundedRectangle(cornerRadius: .ecosia.borderRadius._l)) + .overlay( + RoundedRectangle(cornerRadius: .ecosia.borderRadius._l) + .stroke(.border, lineWidth: 1) + ) + } + + // MARK: - UX Constants + + private enum UX { + static let closeButtonWidthHeight: CGFloat = 15 + static let imageWidthHeight: CGFloat = 48 + } +} + +#Preview{ + let mockViewModel = NudgeCardViewModel( + title: "Make ecosia your default browser app", + description: "Safely open all links from other apps in Ecosia", + buttonText: "Take Action", + image: .init(named: "default-browser-card-side-image-koto-illustrations", + in: .ecosia, + with: nil), + style: NudgeCardStyle(backgroundColor: .primaryBackground, + textPrimaryColor: .primaryText, + textSecondaryColor: .primaryText, + closeButtonTextColor: .primaryText, + actionButtonTextColor: .primaryBrand) + ) + + ConfigurableNudgeCardView(viewModel: mockViewModel, delegate: nil) + .padding() +} diff --git a/firefox-ios/Ecosia/UI/EcosiaText.swift b/firefox-ios/Ecosia/UI/EcosiaText.swift new file mode 100644 index 000000000000..e900734bca43 --- /dev/null +++ b/firefox-ios/Ecosia/UI/EcosiaText.swift @@ -0,0 +1,31 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import SwiftUI + +/// A view that displays localized text based on a given key. +struct EcosiaText: View { + /// The key used to retrieve the localized string. + let key: String.Key + /// An optional comment that provides additional context for the localization. + let comment: String + + /// Initializes a new instance of `EcosiaText` with the specified localization key and optional comment. + /// + /// - Parameters: + /// - key: The key used to retrieve the localized string. + /// - comment: An optional comment that provides additional context for the localization. Default is an empty string. + init(_ key: String.Key, comment: String = "") { + self.key = key + self.comment = comment + } + + var body: some View { + if let parsed = try? AttributedString(markdown: .localized(key)) { + Text(parsed) + } else { + Text(verbatim: .localized(key)) + } + } +} diff --git a/firefox-ios/Ecosia/UI/InstructionStepsView.swift b/firefox-ios/Ecosia/UI/InstructionStepsView.swift new file mode 100644 index 000000000000..c8c0822e4c68 --- /dev/null +++ b/firefox-ios/Ecosia/UI/InstructionStepsView.swift @@ -0,0 +1,215 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import SwiftUI +import Lottie + +private struct InstructionStepsViewLayout { + static let stepNumberWidthHeight: CGFloat = 24 + static let stepsContainerCornerRadius: CGFloat = 10 + static let wavesHeight: CGFloat = 11 +} + +public struct InstructionStepsViewStyle { + let backgroundPrimaryColor: Color + let topContentBackgroundColor: Color + let stepsBackgroundColor: Color + let textPrimaryColor: Color + let textSecondaryColor: Color + let buttonBackgroundColor: Color + let buttonTextColor: Color + let stepRowStyle: StepRowStyle + + public init(backgroundPrimaryColor: Color, + topContentBackgroundColor: Color, + stepsBackgroundColor: Color, + textPrimaryColor: Color, + textSecondaryColor: Color, + buttonBackgroundColor: Color, + buttonTextColor: Color, + stepRowStyle: StepRowStyle) { + self.backgroundPrimaryColor = backgroundPrimaryColor + self.topContentBackgroundColor = topContentBackgroundColor + self.stepsBackgroundColor = stepsBackgroundColor + self.textPrimaryColor = textPrimaryColor + self.textSecondaryColor = textSecondaryColor + self.buttonBackgroundColor = buttonBackgroundColor + self.buttonTextColor = buttonTextColor + self.stepRowStyle = stepRowStyle + } +} + +/// A reusable instruction screen with a title, steps, and a CTA button. +struct InstructionStepsView: View { + let title: String.Key + let topContentView: TopContentView + let steps: [InstructionStep] + let buttonTitle: String.Key + let onButtonTap: () -> Void + let style: InstructionStepsViewStyle + + init(title: String.Key, + steps: [InstructionStep], + buttonTitle: String.Key, + onButtonTap: @escaping () -> Void, + style: InstructionStepsViewStyle, + @ViewBuilder topContentView: () -> TopContentView) { + self.title = title + self.steps = steps + self.buttonTitle = buttonTitle + self.onButtonTap = onButtonTap + self.style = style + self.topContentView = topContentView() + } + + var body: some View { + ZStack { + style.backgroundPrimaryColor + .ignoresSafeArea() + VStack(spacing: .ecosia.space._1l) { + ZStack(alignment: .bottom) { + style.topContentBackgroundColor + .ignoresSafeArea(edges: .top) + topContentView + Image("wave-forms-horizontal-1", bundle: .ecosia) + .resizable() + .renderingMode(.template) + .frame(height: InstructionStepsViewLayout.wavesHeight) + .foregroundStyle(style.backgroundPrimaryColor) + .accessibilityHidden(true) + } + + VStack(spacing: .ecosia.space._1l) { + VStack(alignment: .leading, + spacing: .ecosia.space._s) { + EcosiaText(title) + .font(.title2.bold()) + .foregroundColor(style.textPrimaryColor) + .accessibilityIdentifier("instruction_title") + .frame(maxWidth: .infinity, alignment: .leading) + + VStack(alignment: .leading, + spacing: .ecosia.space._s) { + renderedSteps + } + } + .frame(maxWidth: .infinity) + .padding(.ecosia.space._m) + .background(style.stepsBackgroundColor) + .cornerRadius(.ecosia.borderRadius._l) + + Button(action: onButtonTap) { + EcosiaText(buttonTitle) + .font(.body) + .foregroundColor(style.buttonTextColor) + .frame(maxWidth: .infinity) + .padding() + .background(style.buttonBackgroundColor) + } + .clipShape(Capsule()) + .accessibilityIdentifier("instruction_cta_button") + .accessibilityLabel(Text(buttonTitle.rawValue)) + .accessibilityAddTraits(.isButton) + } + .padding([.bottom, .leading, .trailing], .ecosia.space._1l) + } + } + } + + private var renderedSteps: some View { + ForEach(Array(steps.enumerated()), id: \.offset) { pair in + let index = pair.offset + let step = pair.element + StepRow(index: index, step: step, style: style.stepRowStyle) + } + } +} + +public struct StepRowStyle { + let stepNumberColor: Color + let stepNumberBackgroundColor: Color + let stepTextColor: Color + + public init(stepNumberColor: Color, + stepNumberBackgroundColor: Color = .clear, + stepTextColor: Color) { + self.stepNumberColor = stepNumberColor + self.stepNumberBackgroundColor = stepNumberBackgroundColor + self.stepTextColor = stepTextColor + } +} + +private struct StepRow: View { + let index: Int + let step: InstructionStep + let style: StepRowStyle + + var body: some View { + HStack(alignment: .center, + spacing: .ecosia.space._s) { + Text("\(index + 1)") + .font(.subheadline.bold()) + .foregroundColor(style.stepNumberColor) + .frame(width: InstructionStepsViewLayout.stepNumberWidthHeight, + height: InstructionStepsViewLayout.stepNumberWidthHeight) + .background(style.stepNumberBackgroundColor) + .clipShape(Circle()) + .accessibilityIdentifier("instruction_step_number") + + EcosiaText(step.text) + .font(.subheadline) + .foregroundColor(style.stepTextColor) + .multilineTextAlignment(.leading) + .accessibilityIdentifier("instruction_step_\(index + 1)_text") + } + .accessibilityElement(children: .combine) + } +} + +/// A single instruction step with its text. +struct InstructionStep { + let text: String.Key +} + +// MARK: - Preview + +#Preview { + InstructionStepsView( + title: .defaultBrowserCardDetailTitle, + steps: [ + InstructionStep(text: .defaultBrowserCardDetailInstructionStep1), + InstructionStep(text: .defaultBrowserCardDetailInstructionStep2), + InstructionStep(text: .defaultBrowserCardDetailInstructionStep3) + ], + buttonTitle: .defaultBrowserCardDetailButton, + onButtonTap: {}, + style: InstructionStepsViewStyle( + backgroundPrimaryColor: .tertiaryBackground, + topContentBackgroundColor: Color(UIColor(rgb: 0x275243)), + stepsBackgroundColor: .primaryBackground, + textPrimaryColor: .primaryText, + textSecondaryColor: .primaryText, + buttonBackgroundColor: .primaryBrand, + buttonTextColor: .primaryBackground, + stepRowStyle: StepRowStyle(stepNumberColor: .primary, + stepNumberBackgroundColor: .secondary, + stepTextColor: .primaryText) + ) + ) { + GeometryReader { geometry in + VStack { + Spacer() + LottieView { + try await DotLottieFile.named("default_browser_setup_animation", bundle: .ecosia) + } + .configuration(LottieConfiguration(renderingEngine: .mainThread)) + .looping() + .offset(y: UIDevice.current.userInterfaceIdiom == .pad ? 40 : 18) + .aspectRatio(contentMode: .fit) + .frame(width: geometry.size.width) + .clipped() + } + } + } +} diff --git a/firefox-ios/Ecosia/UI/Settings/DefaultBrowser/Animations/default_browser_setup_animation.lottie b/firefox-ios/Ecosia/UI/Settings/DefaultBrowser/Animations/default_browser_setup_animation.lottie new file mode 100644 index 000000000000..d754406fb93c Binary files /dev/null and b/firefox-ios/Ecosia/UI/Settings/DefaultBrowser/Animations/default_browser_setup_animation.lottie differ diff --git a/firefox-ios/Ecosia/UI/Settings/DefaultBrowser/DefaultBrowserCoordinator.swift b/firefox-ios/Ecosia/UI/Settings/DefaultBrowser/DefaultBrowserCoordinator.swift new file mode 100644 index 000000000000..861ff39a57db --- /dev/null +++ b/firefox-ios/Ecosia/UI/Settings/DefaultBrowser/DefaultBrowserCoordinator.swift @@ -0,0 +1,118 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import SwiftUI +import Lottie +import Common + +public struct DefaultBrowserCoordinator { + let navigationController: UINavigationController + let style: InstructionStepsViewStyle + + public init(navigationController: UINavigationController, + style: InstructionStepsViewStyle) { + self.navigationController = navigationController + self.style = style + } + + public func showDetailView(from analyticsLabel: Analytics.Label.DefaultBrowser) { + let steps = [ + InstructionStep(text: .defaultBrowserCardDetailInstructionStep1), + InstructionStep(text: .defaultBrowserCardDetailInstructionStep2), + InstructionStep(text: .defaultBrowserCardDetailInstructionStep3) + ] + + let lottieViewYOffset: CGFloat = UIDevice.current.userInterfaceIdiom == .pad ? 40 : 18 + + let view = InstructionStepsView( + title: .defaultBrowserCardDetailTitle, + steps: steps, + buttonTitle: .defaultBrowserCardDetailButton, + onButtonTap: { + Analytics.shared.defaultBrowserSettingsOpenNativeSettingsVia(analyticsLabel) + if let settingsURL = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(settingsURL, options: [:]) + } + }, + style: style + ) { + GeometryReader { geometry in + VStack { + Spacer() + LottieView { + try await DotLottieFile.named("default_browser_setup_animation", bundle: .ecosia) + } + .configuration(LottieConfiguration(renderingEngine: .mainThread)) + .looping() + .offset(y: lottieViewYOffset) + .aspectRatio(contentMode: .fit) + .frame(width: geometry.size.width) + .clipped() + } + } + } + .onAppear { + Analytics.shared.defaultBrowserSettingsShowsDetailViewVia(analyticsLabel) + } + .onDisappear { + Analytics.shared.defaultBrowserSettingsDismissDetailViewVia(analyticsLabel) + } + + let hostingController = UIHostingController(rootView: view) + hostingController.title = .localized(.defaultBrowserSettingTitle) + hostingController.navigationItem.largeTitleDisplayMode = .never + let doneHandler = DetailViewDoneHandler { + self.navigationController.dismiss(animated: true) + } + objc_setAssociatedObject(hostingController, "detailViewDoneHandler", doneHandler, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + hostingController.navigationItem.rightBarButtonItem = UIBarButtonItem( + title: .localized(.done), + style: .done, + target: doneHandler, + action: #selector(DetailViewDoneHandler.handleDone) + ) + navigationController.pushViewController(hostingController, animated: true) + } +} + +extension DefaultBrowserCoordinator { + + public static func makeDefaultCoordinatorAndShowDetailViewFrom(_ navigationController: UINavigationController?, + analyticsLabel: Analytics.Label.DefaultBrowser, + topViewContentBackground: Color, + with theme: Theme) { + + guard let navigationController = navigationController else { return } + + let style = InstructionStepsViewStyle( + backgroundPrimaryColor: Color(theme.colors.ecosia.backgroundSecondary), + topContentBackgroundColor: topViewContentBackground, + stepsBackgroundColor: Color(theme.colors.ecosia.backgroundPrimary), + textPrimaryColor: Color(theme.colors.ecosia.textPrimary), + textSecondaryColor: Color(theme.colors.ecosia.textSecondary), + buttonBackgroundColor: Color(theme.colors.ecosia.buttonBackgroundPrimary), + buttonTextColor: Color(theme.colors.ecosia.textInversePrimary), + stepRowStyle: StepRowStyle( + stepNumberColor: Color(theme.colors.ecosia.textPrimary), + stepNumberBackgroundColor: Color(theme.colors.ecosia.backgroundSecondary), + stepTextColor: Color(theme.colors.ecosia.textPrimary) + ) + ) + + let coordinator = DefaultBrowserCoordinator(navigationController: navigationController, + style: style) + coordinator.showDetailView(from: analyticsLabel) + } +} + +final class DetailViewDoneHandler: NSObject { + let onDone: () -> Void + init(onDone: @escaping () -> Void) { + self.onDone = onDone + } + + @objc func handleDone() { + onDone() + } +} diff --git a/firefox-ios/Ecosia/UI/Settings/DefaultBrowser/DefaultBrowserSettingsNudgeCardHeaderView.swift b/firefox-ios/Ecosia/UI/Settings/DefaultBrowser/DefaultBrowserSettingsNudgeCardHeaderView.swift new file mode 100644 index 000000000000..20cc963a052b --- /dev/null +++ b/firefox-ios/Ecosia/UI/Settings/DefaultBrowser/DefaultBrowserSettingsNudgeCardHeaderView.swift @@ -0,0 +1,97 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import UIKit +import SwiftUI +import Common + +/// Reusable Nudge Card Header View that can be configured with any view model. +public final class DefaultBrowserSettingsNudgeCardHeaderView: UITableViewHeaderFooterView, ThemeApplicable, ReusableCell { + + // MARK: - Properties + var theme: Theme! + private var hostingController: UIHostingController? + public var onDismiss: (() -> Void)? + public var onTap: (() -> Void)? + + // MARK: - UX Constants + + private enum UX { + static let paddingTop: CGFloat = 24 + } + + // MARK: - Initializer + + override public init(reuseIdentifier: String?) { + super.init(reuseIdentifier: reuseIdentifier) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupHostingControllerForView(_ view: ConfigurableNudgeCardView) { + let controller = UIHostingController(rootView: AnyView( + VStack(spacing: 0) { + view + .padding(.top, UX.paddingTop) + } + )) + controller.view.translatesAutoresizingMaskIntoConstraints = false + controller.view.backgroundColor = .clear + controller.view.isAccessibilityElement = false + contentView.addSubview(controller.view) + + NSLayoutConstraint.activate([ + controller.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + controller.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + controller.view.topAnchor.constraint(equalTo: contentView.topAnchor), + controller.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) + + hostingController = controller + } + + // MARK: - Configuration Method + + /// Configures the Nudge Card Header View using the ViewModel. + public func configure(theme: Theme?) { + self.theme = theme + guard let theme else { return } + let nudgeCardStyle = NudgeCardStyle(backgroundColor: Color(theme.colors.ecosia.barBackground), + textPrimaryColor: Color(theme.colors.ecosia.textPrimary), + textSecondaryColor: Color(theme.colors.ecosia.textSecondary), + closeButtonTextColor: Color(theme.colors.ecosia.iconDecorative), + actionButtonTextColor: Color(theme.colors.ecosia.buttonBackgroundPrimary)) + let configurableCardViewModel = NudgeCardViewModel(title: .localized(.defaultBrowserCardTitle), + description: .localized(.defaultBrowserCardDescription), + image: .init(named: "default-browser-card-side-image-koto-illustrations", + in: .ecosia, + with: nil), + style: nudgeCardStyle) + let view = ConfigurableNudgeCardView(viewModel: configurableCardViewModel, + delegate: self) + setupHostingControllerForView(view) + } + + // MARK: - Theming + public func applyTheme(theme: Theme) { + configure(theme: theme) + } +} + +extension DefaultBrowserSettingsNudgeCardHeaderView: ConfigurableNudgeCardActionDelegate { + + public func nudgeCardRequestToPerformAction() {} + + public func nudgeCardRequestToDimiss() { + onDismiss?() + } + + public func nudgeCardTapped() { + onTap?() + } +} diff --git a/firefox-ios/EcosiaTests/Analytics/AnalyticsSpyTests.swift b/firefox-ios/EcosiaTests/Analytics/AnalyticsSpyTests.swift index 6ca96ea06ac9..308b77c4d280 100644 --- a/firefox-ios/EcosiaTests/Analytics/AnalyticsSpyTests.swift +++ b/firefox-ios/EcosiaTests/Analytics/AnalyticsSpyTests.swift @@ -6,6 +6,8 @@ import XCTest import Storage import SnowplowTracker import Common +import SwiftUI +import ViewInspector @testable import Client @testable import Ecosia @@ -129,6 +131,26 @@ final class AnalyticsSpy: Analytics { override func clearsDataFromSection(_ section: Analytics.Property.SettingsPrivateDataSection) { clearAllPrivateDataSectionCalled = section } + + var defaultBrowserSettingsShowsDetailViewLabelCalled: Analytics.Label.DefaultBrowser? + override func defaultBrowserSettingsShowsDetailViewVia(_ label: Analytics.Label.DefaultBrowser) { + defaultBrowserSettingsShowsDetailViewLabelCalled = label + } + + var defaultBrowserSettingsViaNudgeCardDismissCalled = false + override func defaultBrowserSettingsViaNudgeCardDismiss() { + defaultBrowserSettingsViaNudgeCardDismissCalled = true + } + + var defaultBrowserSettingsOpenNativeSettingsLabelCalled: Analytics.Label.DefaultBrowser? + override func defaultBrowserSettingsOpenNativeSettingsVia(_ label: Analytics.Label.DefaultBrowser) { + defaultBrowserSettingsOpenNativeSettingsLabelCalled = label + } + + var defaultBrowserSettingsDismissDetailViewLabelCalled: Analytics.Label.DefaultBrowser? + override func defaultBrowserSettingsDismissDetailViewVia(_ label: Analytics.Label.DefaultBrowser) { + defaultBrowserSettingsDismissDetailViewLabelCalled = label + } } // MARK: - AnalyticsSpyTests @@ -839,6 +861,72 @@ final class AnalyticsSpyTests: XCTestCase { // Assert XCTAssertEqual(analyticsSpy.clearAllPrivateDataSectionCalled, .websites, "Analytics should track clearAllPrivateDataSectionCalled as .websites because we are simulating the click on Clear Websiste Data") } + + // MARK: Analytics Default Browser + + func testShowInstructionStepsTriggersAnalyticsEvent() throws { + User.shared.showDefaultBrowserSettingNudgeCard() + DependencyHelperMock().bootstrapDependencies() + LegacyFeatureFlagsManager.shared.initializeDeveloperFeatures(with: AppContainer.shared.resolve()) + + let view = makeInstructionsViewSUT() + .onAppear { + Analytics.shared.defaultBrowserSettingsDismissDetailViewVia(.settingsNudgeCard) + } + + try view.inspect().callOnAppear() + + XCTAssertEqual(analyticsSpy.defaultBrowserSettingsDismissDetailViewLabelCalled, .settingsNudgeCard) + } + + func testTappingDismissButtonOnNudgeCardTriggersAnalyticsEvent() { + DependencyHelperMock().bootstrapDependencies() + LegacyFeatureFlagsManager.shared.initializeDeveloperFeatures(with: AppContainer.shared.resolve()) + User.shared.showDefaultBrowserSettingNudgeCard() + let vc = AppSettingsTableViewController(with: profileMock, + and: tabManagerMock) + vc.loadViewIfNeeded() + vc.viewWillAppear(false) + + guard let header = vc.tableView(vc.tableView, viewForHeaderInSection: 0) as? DefaultBrowserSettingsNudgeCardHeaderView else { + XCTFail("Expected nudge card header") + return + } + + header.onDismiss?() + + XCTAssertTrue(analyticsSpy.defaultBrowserSettingsViaNudgeCardDismissCalled) + } + + func testDismissInstructionStepsTriggersAnalyticsEvent() throws { + User.shared.showDefaultBrowserSettingNudgeCard() + DependencyHelperMock().bootstrapDependencies() + LegacyFeatureFlagsManager.shared.initializeDeveloperFeatures(with: AppContainer.shared.resolve()) + + let view = makeInstructionsViewSUT() + .onDisappear { + Analytics.shared.defaultBrowserSettingsDismissDetailViewVia(.settingsNudgeCard) + } + + try view.inspect().callOnDisappear() + + XCTAssertEqual(analyticsSpy.defaultBrowserSettingsDismissDetailViewLabelCalled, .settingsNudgeCard) + } + + func testDefaultBrowserSettingsOpenNativeSettingsTracksLabelAndProperty() throws { + User.shared.showDefaultBrowserSettingNudgeCard() + DependencyHelperMock().bootstrapDependencies() + LegacyFeatureFlagsManager.shared.initializeDeveloperFeatures(with: AppContainer.shared.resolve()) + + let view = makeInstructionsViewSUT(onButtonTap: { + Analytics.shared.defaultBrowserSettingsOpenNativeSettingsVia(.settings) + }) + + try view.inspect().find(button: String.Key.defaultBrowserCardDetailButton.rawValue).tap() + + analyticsSpy.defaultBrowserSettingsOpenNativeSettingsVia(.settings) + XCTAssertEqual(analyticsSpy.defaultBrowserSettingsOpenNativeSettingsLabelCalled, .settings, "Expected label 'default_browser_settings' to be tracked.") + } } // MARK: - Helper SUTs @@ -858,6 +946,29 @@ extension AnalyticsSpyTests { func makeWelcome() -> Welcome { Welcome(delegate: MockWelcomeDelegate(), windowUUID: .XCTestDefaultUUID) } + + func makeInstructionsViewSUT(onButtonTap: @escaping () -> Void = {}) -> InstructionStepsView { + let style = InstructionStepsViewStyle( + backgroundPrimaryColor: .blue, + topContentBackgroundColor: .blue, + stepsBackgroundColor: .blue, + textPrimaryColor: .blue, + textSecondaryColor: .blue, + buttonBackgroundColor: .blue, + buttonTextColor: .blue, + stepRowStyle: StepRowStyle(stepNumberColor: .blue, stepTextColor: .blue) + ) + + return InstructionStepsView( + title: .defaultBrowserCardDetailTitle, + steps: [InstructionStep(text: .defaultBrowserCardDetailInstructionStep1)], + buttonTitle: .defaultBrowserCardDetailButton, + onButtonTap: onButtonTap, + style: style + ) { + EmptyView() + } + } } // MARK: - Helper Classes