From dc67e004b602654e429c1fd8447f6c3e7ca18ecb Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Mon, 28 Oct 2024 15:02:49 +0100 Subject: [PATCH] Show VPN onboarding tips (#3429) Task/Issue URL: https://app.asana.com/0/1206580121312550/1208341440402810/f macOS PR: https://github.com/duckduckgo/macos-browser/pull/3410 BSK PR: https://github.com/duckduckgo/BrowserServicesKit/pull/1024 ## Description Shows VPN onboarding tips. --- Core/UserDefaultsPropertyWrapper.swift | 4 +- DuckDuckGo.xcodeproj/project.pbxproj | 52 ++++++ DuckDuckGo/AppDelegate.swift | 6 + DuckDuckGo/Debug.storyboard | 29 +-- DuckDuckGo/NetworkProtectionStatusView.swift | 168 ++++++++++++++++-- .../NetworkProtectionStatusViewModel.swift | 60 ++++++- DuckDuckGo/RootDebugViewController.swift | 23 ++- DuckDuckGo/TipKit/Logger+TipKit.swift | 28 +++ .../TipKit/TipKitAppEventHandling.swift | 50 ++++++ ...itController+ConvenienceInitializers.swift | 30 ++++ DuckDuckGo/TipKit/TipKitController.swift | 93 ++++++++++ .../TipKitDebugOptionsUIActionHandling.swift | 49 +++++ DuckDuckGo/VPN.xcassets/Contents.json | 6 + .../Contents.json | 12 ++ .../Widget-Add-32 2.pdf | Bin 0 -> 1819 bytes .../Contents.json | 12 ++ .../Location-32.pdf | Bin 0 -> 1570 bytes .../Contents.json | 12 ++ .../Moon-Snooze-32.pdf | Bin 0 -> 1938 bytes DuckDuckGo/VPNAddWidgetTip.swift | 78 ++++++++ DuckDuckGo/VPNGeoswitchingTip.swift | 58 ++++++ DuckDuckGo/VPNSnoozeTip.swift | 71 ++++++++ DuckDuckGo/ViewExtension.swift | 6 + 23 files changed, 812 insertions(+), 35 deletions(-) create mode 100644 DuckDuckGo/TipKit/Logger+TipKit.swift create mode 100644 DuckDuckGo/TipKit/TipKitAppEventHandling.swift create mode 100644 DuckDuckGo/TipKit/TipKitController+ConvenienceInitializers.swift create mode 100644 DuckDuckGo/TipKit/TipKitController.swift create mode 100644 DuckDuckGo/TipKit/TipKitDebugOptionsUIActionHandling.swift create mode 100644 DuckDuckGo/VPN.xcassets/Contents.json create mode 100644 DuckDuckGo/VPN.xcassets/VPNAddWidgetTipIcon.imageset/Contents.json create mode 100644 DuckDuckGo/VPN.xcassets/VPNAddWidgetTipIcon.imageset/Widget-Add-32 2.pdf create mode 100644 DuckDuckGo/VPN.xcassets/VPNChangeLocationTipIcon.imageset/Contents.json create mode 100644 DuckDuckGo/VPN.xcassets/VPNChangeLocationTipIcon.imageset/Location-32.pdf create mode 100644 DuckDuckGo/VPN.xcassets/VPNUseSnoozeTipIcon.imageset/Contents.json create mode 100644 DuckDuckGo/VPN.xcassets/VPNUseSnoozeTipIcon.imageset/Moon-Snooze-32.pdf create mode 100644 DuckDuckGo/VPNAddWidgetTip.swift create mode 100644 DuckDuckGo/VPNGeoswitchingTip.swift create mode 100644 DuckDuckGo/VPNSnoozeTip.swift diff --git a/Core/UserDefaultsPropertyWrapper.swift b/Core/UserDefaultsPropertyWrapper.swift index 2e119e09df..9fbffdcd53 100644 --- a/Core/UserDefaultsPropertyWrapper.swift +++ b/Core/UserDefaultsPropertyWrapper.swift @@ -180,7 +180,9 @@ public struct UserDefaultsWrapper { case duckPlayerPixelExperimentLastDayPixelFired = "com.duckduckgo.ios.duckplayer.pixel.experiment.last.day.pixel.fired.v2" case duckPlayerPixelExperimentLastVideoIDRendered = "com.duckduckgo.ios.duckplayer.pixel.experiment.last.videoID.rendered.v2" case duckPlayerPixelExperimentOverride = "com.duckduckgo.ios.duckplayer.pixel.experiment.override.v2" - + + // TipKit + case resetTipKitOnNextLaunch = "com.duckduckgo.ios.tipKit.resetOnNextLaunch" } private let key: Key diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index ebe3598311..73dc71bf7a 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -365,8 +365,17 @@ 6FEC0B852C999352006B4F6E /* FavoriteItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FEC0B842C999352006B4F6E /* FavoriteItem.swift */; }; 6FEC0B882C999961006B4F6E /* FavoritesListInteractingAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FEC0B872C999961006B4F6E /* FavoritesListInteractingAdapter.swift */; }; 6FF915822B88E07A0042AC87 /* AdAttributionFetcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FF915802B88E0750042AC87 /* AdAttributionFetcherTests.swift */; }; + 7B1604E82CB685B400A44EC6 /* Logger+TipKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1604E72CB685B400A44EC6 /* Logger+TipKit.swift */; }; + 7B1604EC2CB68BDA00A44EC6 /* TipKitController+ConvenienceInitializers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1604EB2CB68BDA00A44EC6 /* TipKitController+ConvenienceInitializers.swift */; }; + 7B1604EE2CB68D2600A44EC6 /* TipKitDebugOptionsUIActionHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1604ED2CB68D2600A44EC6 /* TipKitDebugOptionsUIActionHandling.swift */; }; + 7B8E0EC62CC81B4900B2B722 /* TipKitController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B8E0EC52CC81B4800B2B722 /* TipKitController.swift */; }; 7BC571202BDBB877003B0CCE /* VPNActivationDateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BC5711F2BDBB877003B0CCE /* VPNActivationDateStore.swift */; }; 7BC571212BDBB977003B0CCE /* VPNActivationDateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BC5711F2BDBB877003B0CCE /* VPNActivationDateStore.swift */; }; + 7BDBAD0E2CBFB3F1000379B7 /* VPN.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7BDBAD0D2CBFB3F1000379B7 /* VPN.xcassets */; }; + 7BF78E022CA2CC3E0026A1FC /* TipKitAppEventHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BF78E012CA2CC3E0026A1FC /* TipKitAppEventHandling.swift */; }; + 7BFD5FD52C9DA310000FF959 /* VPNAddWidgetTip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BFD5FD42C9DA310000FF959 /* VPNAddWidgetTip.swift */; }; + 7BFD5FD72C9DB9D7000FF959 /* VPNGeoswitchingTip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BFD5FD62C9DB9D7000FF959 /* VPNGeoswitchingTip.swift */; }; + 7BFD5FD92C9DBC24000FF959 /* VPNSnoozeTip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BFD5FD82C9DBC24000FF959 /* VPNSnoozeTip.swift */; }; 83004E802193BB8200DA013C /* WKNavigationExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83004E7F2193BB8200DA013C /* WKNavigationExtension.swift */; }; 83004E862193E5ED00DA013C /* TabViewControllerBrowsingMenuExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83004E852193E5ED00DA013C /* TabViewControllerBrowsingMenuExtension.swift */; }; 83004E882193E8C700DA013C /* TabViewControllerLongPressMenuExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83004E872193E8C700DA013C /* TabViewControllerLongPressMenuExtension.swift */; }; @@ -1651,7 +1660,16 @@ 6FEC0B842C999352006B4F6E /* FavoriteItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteItem.swift; sourceTree = ""; }; 6FEC0B872C999961006B4F6E /* FavoritesListInteractingAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesListInteractingAdapter.swift; sourceTree = ""; }; 6FF915802B88E0750042AC87 /* AdAttributionFetcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdAttributionFetcherTests.swift; sourceTree = ""; }; + 7B1604E72CB685B400A44EC6 /* Logger+TipKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Logger+TipKit.swift"; sourceTree = ""; }; + 7B1604EB2CB68BDA00A44EC6 /* TipKitController+ConvenienceInitializers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TipKitController+ConvenienceInitializers.swift"; sourceTree = ""; }; + 7B1604ED2CB68D2600A44EC6 /* TipKitDebugOptionsUIActionHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipKitDebugOptionsUIActionHandling.swift; sourceTree = ""; }; + 7B8E0EC52CC81B4800B2B722 /* TipKitController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipKitController.swift; sourceTree = ""; }; 7BC5711F2BDBB877003B0CCE /* VPNActivationDateStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VPNActivationDateStore.swift; sourceTree = ""; }; + 7BDBAD0D2CBFB3F1000379B7 /* VPN.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = VPN.xcassets; sourceTree = ""; }; + 7BF78E012CA2CC3E0026A1FC /* TipKitAppEventHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipKitAppEventHandling.swift; sourceTree = ""; }; + 7BFD5FD42C9DA310000FF959 /* VPNAddWidgetTip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNAddWidgetTip.swift; sourceTree = ""; }; + 7BFD5FD62C9DB9D7000FF959 /* VPNGeoswitchingTip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNGeoswitchingTip.swift; sourceTree = ""; }; + 7BFD5FD82C9DBC24000FF959 /* VPNSnoozeTip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNSnoozeTip.swift; sourceTree = ""; }; 83004E7F2193BB8200DA013C /* WKNavigationExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WKNavigationExtension.swift; sourceTree = ""; }; 83004E832193E14C00DA013C /* UIAlertControllerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = UIAlertControllerExtension.swift; path = ../Core/UIAlertControllerExtension.swift; sourceTree = ""; }; 83004E852193E5ED00DA013C /* TabViewControllerBrowsingMenuExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabViewControllerBrowsingMenuExtension.swift; sourceTree = ""; }; @@ -3728,6 +3746,7 @@ children = ( 4B37E04F2B928CA6009E81CA /* vpn-light-mode.json */, 4B6ED9442B992FE4007F5CAA /* vpn-dark-mode.json */, + 7BDBAD0D2CBFB3F1000379B7 /* VPN.xcassets */, ); name = Resources; sourceTree = ""; @@ -4011,6 +4030,28 @@ name = AdAttribution; sourceTree = ""; }; + 7BF78E002CA2CC100026A1FC /* TipKit */ = { + isa = PBXGroup; + children = ( + 7B1604E72CB685B400A44EC6 /* Logger+TipKit.swift */, + 7BF78E012CA2CC3E0026A1FC /* TipKitAppEventHandling.swift */, + 7B8E0EC52CC81B4800B2B722 /* TipKitController.swift */, + 7B1604EB2CB68BDA00A44EC6 /* TipKitController+ConvenienceInitializers.swift */, + 7B1604ED2CB68D2600A44EC6 /* TipKitDebugOptionsUIActionHandling.swift */, + ); + path = TipKit; + sourceTree = ""; + }; + 7BFD5FD32C9DA235000FF959 /* TipKit */ = { + isa = PBXGroup; + children = ( + 7BFD5FD42C9DA310000FF959 /* VPNAddWidgetTip.swift */, + 7BFD5FD62C9DB9D7000FF959 /* VPNGeoswitchingTip.swift */, + 7BFD5FD82C9DBC24000FF959 /* VPNSnoozeTip.swift */, + ); + name = TipKit; + sourceTree = ""; + }; 830FA79B1F8E81FB00FCE105 /* ContentBlocker */ = { isa = PBXGroup; children = ( @@ -4238,6 +4279,7 @@ F13B4BF41F18C74500814661 /* Tabs */, F1386BA21E6846320062FC3C /* TabSwitcher */, 98F3A1D6217B36EE0011A0D4 /* Themes */, + 7BF78E002CA2CC100026A1FC /* TipKit */, F11CEF581EBB66C80088E4D7 /* Tutorials */, CB48D32F2B90CE8500631D8B /* UserBehaviorMonitor */, F1D796ED1E7AE4090019D451 /* UserInterface */, @@ -5551,6 +5593,7 @@ EECD94B22A28B8580085C66E /* NetworkProtection */ = { isa = PBXGroup; children = ( + 7BFD5FD32C9DA235000FF959 /* TipKit */, 4BD96E072C4DCCD1003BC32C /* LiveActivity */, 4B37E04E2B928C91009E81CA /* Resources */, EE01EB412AFC1DE10096AAC9 /* PreferredLocation */, @@ -6957,6 +7000,7 @@ 85F98F98296F4CB100742F4A /* SyncAssets.xcassets in Resources */, 31BC5F412C2B0B540004DF37 /* DuckPlayer.xcassets in Resources */, AA4D6A9423DE49A5007E8790 /* AppIconBlack29x29@2x.png in Resources */, + 7BDBAD0E2CBFB3F1000379B7 /* VPN.xcassets in Resources */, 98B001B3251EABB40090EC07 /* InfoPlist.strings in Resources */, AA4D6ACE23DE4D27007E8790 /* AppIconPurple60x60@3x.png in Resources */, D65CEA702B6AC6C9008A759B /* Subscription.xcassets in Resources */, @@ -7340,9 +7384,12 @@ EE4FB1882A28D11900E5CBA7 /* NetworkProtectionStatusViewModel.swift in Sources */, 6FB1FE9E2C24D41D0075B68B /* NewTabPageSectionsDebugView.swift in Sources */, 8540BD5623D9E9C20057FDD2 /* PreserveLoginsSettingsViewController.swift in Sources */, + 7B8E0EC62CC81B4900B2B722 /* TipKitController.swift in Sources */, + 7B1604EC2CB68BDA00A44EC6 /* TipKitController+ConvenienceInitializers.swift in Sources */, 851672D12BED1FC900592F24 /* AutocompleteView.swift in Sources */, 3161D13227AC161B00285CF6 /* DownloadMetadata.swift in Sources */, D664C7C72B289AA200CBFA76 /* PurchaseInProgressView.swift in Sources */, + 7BFD5FD72C9DB9D7000FF959 /* VPNGeoswitchingTip.swift in Sources */, F1668BCE1E798081008CBA04 /* BookmarksViewController.swift in Sources */, 8590CB69268A4E190089F6BF /* DebugEtagStorage.swift in Sources */, C1CDA3162AFB9C7F006D1476 /* AutofillNeverPromptWebsitesManager.swift in Sources */, @@ -7415,6 +7462,7 @@ 9F5E5AB02C3E4C6000165F54 /* ContextualOnboardingPresenter.swift in Sources */, 310D091B2799F54900DC0060 /* DownloadManager.swift in Sources */, 98D98A7425ED88D100D8E3DF /* BrowsingMenuEntryViewCell.swift in Sources */, + 7BFD5FD92C9DBC24000FF959 /* VPNSnoozeTip.swift in Sources */, 98F3A1D8217B37010011A0D4 /* Theme.swift in Sources */, 4B2C79612C5B27AC00A240CC /* VPNSnoozeActivityAttributes.swift in Sources */, CB9B873C278C8FEA001F4906 /* WidgetEducationView.swift in Sources */, @@ -7516,6 +7564,7 @@ 3157B43827F4C8490042D3D7 /* FaviconsHelper.swift in Sources */, 85F200042216F5D8006BB258 /* FindInPageView.swift in Sources */, D652498E2B515A6A0056B0DE /* SubscriptionSettingsViewModel.swift in Sources */, + 7B1604E82CB685B400A44EC6 /* Logger+TipKit.swift in Sources */, 8548D95E25262B1B005AAE49 /* ViewHighlighter.swift in Sources */, F4D7221026F29A70007D6193 /* BookmarkDetailsCell.swift in Sources */, F1617C131E572E0300DEDCAF /* TabSwitcherViewController.swift in Sources */, @@ -7528,6 +7577,7 @@ D63FF8982C1B6A45006DE24D /* DuckPlayer.swift in Sources */, 85B9CB8921AEBDD5009001F1 /* FavoriteHomeCell.swift in Sources */, C1935A102C88D131001AD72D /* AutofillSurveyManager.swift in Sources */, + 7B1604EE2CB68D2600A44EC6 /* TipKitDebugOptionsUIActionHandling.swift in Sources */, 98999D5922FDA41500CBBE1B /* BasicAuthenticationAlert.swift in Sources */, C13B32D22A0E750700A59236 /* AutofillSettingStatus.swift in Sources */, 1DDF40202BA049FA006850D9 /* SettingsRootView.swift in Sources */, @@ -7671,6 +7721,7 @@ 31CC224928369B38001654A4 /* AutofillLoginSettingsListViewController.swift in Sources */, F1D796EC1E7AB8930019D451 /* SaveBookmarkActivity.swift in Sources */, F4B0B78C252CAFF700830156 /* OnboardingWidgetsViewController.swift in Sources */, + 7BFD5FD52C9DA310000FF959 /* VPNAddWidgetTip.swift in Sources */, C17B595A2A03AAD30055F2D1 /* PasswordGenerationPromptViewController.swift in Sources */, 8531A08E1F9950E6000484F0 /* UnprotectedSitesViewController.swift in Sources */, CBD4F13C279EBF4A00B20FD7 /* HomeMessage.swift in Sources */, @@ -7818,6 +7869,7 @@ F15D43201E706CC500BF2CDC /* AutocompleteViewController.swift in Sources */, BD862E092B30F63E0073E2EE /* VPNMetadataCollector.swift in Sources */, D6E83C682B23B6A3006C8AFB /* FontSettings.swift in Sources */, + 7BF78E022CA2CC3E0026A1FC /* TipKitAppEventHandling.swift in Sources */, 1DEAADF62BA4809400E25A97 /* CookiePopUpProtectionView.swift in Sources */, 31EF52E1281B3BDC0034796E /* AutofillLoginListItemViewModel.swift in Sources */, C1EA86602C74CB6C00E8604D /* SyncPromoView.swift in Sources */, diff --git a/DuckDuckGo/AppDelegate.swift b/DuckDuckGo/AppDelegate.swift index 0442bb02d9..cc09ced6bb 100644 --- a/DuckDuckGo/AppDelegate.swift +++ b/DuckDuckGo/AppDelegate.swift @@ -90,6 +90,10 @@ import os.log private(set) var subscriptionFeatureAvailability: SubscriptionFeatureAvailability! var privacyProDataReporter: PrivacyProDataReporting! + // MARK: - Feature specific app event handlers + + private let tipKitAppEventsHandler = TipKitAppEventHandler() + // MARK: lifecycle @UserDefaultsWrapper(key: .privacyConfigCustomURL, defaultValue: nil) @@ -394,6 +398,8 @@ import os.log didCrashDuringCrashHandlersSetUp = false } + tipKitAppEventsHandler.appDidFinishLaunching() + return true } diff --git a/DuckDuckGo/Debug.storyboard b/DuckDuckGo/Debug.storyboard index c66b2130dc..6f8d490805 100644 --- a/DuckDuckGo/Debug.storyboard +++ b/DuckDuckGo/Debug.storyboard @@ -383,9 +383,18 @@ - + + + + + + + + + + @@ -393,7 +402,7 @@ - + @@ -1049,34 +1058,34 @@ - + - + - + - + diff --git a/DuckDuckGo/NetworkProtectionStatusView.swift b/DuckDuckGo/NetworkProtectionStatusView.swift index f8508a2482..21e79aa654 100644 --- a/DuckDuckGo/NetworkProtectionStatusView.swift +++ b/DuckDuckGo/NetworkProtectionStatusView.swift @@ -19,11 +19,61 @@ import SwiftUI import NetworkProtection +import TipKit struct NetworkProtectionStatusView: View { + + static let defaultImageSize = CGSize(width: 32, height: 32) + @Environment(\.colorScheme) var colorScheme - @StateObject public var statusModel: NetworkProtectionStatusViewModel + @ObservedObject + public var statusModel: NetworkProtectionStatusViewModel + + // MARK: - Tips + + let geoswitchingTip: VPNGeoswitchingTip = { + let tip = VPNGeoswitchingTip() + + if #available(iOS 17.0, *) { + if tip.shouldDisplay { + Task { + for await status in tip.statusUpdates { + if case .invalidated = status { + await VPNSnoozeTip.geolocationTipDismissedEvent.donate() + await VPNAddWidgetTip.geolocationTipDismissedEvent.donate() + } + } + } + } + } + + return tip + }() + + let snoozeTip: VPNSnoozeTip = { + let tip = VPNSnoozeTip() + + if #available(iOS 17.0, *) { + if tip.shouldDisplay { + Task { + for await status in tip.statusUpdates { + if case .invalidated = status { + await VPNAddWidgetTip.snoozeTipDismissedEvent.donate() + } + } + } + } + } + + return tip + }() + + let widgetTip: VPNAddWidgetTip = { + VPNAddWidgetTip() + }() + + // MARK: - View var body: some View { List { @@ -35,6 +85,7 @@ struct NetworkProtectionStatusView: View { } toggle() + locationDetails() if statusModel.isNetPEnabled && statusModel.hasServerInfo && !statusModel.isSnoozing { @@ -51,6 +102,9 @@ struct NetworkProtectionStatusView: View { .animation(.easeOut, value: statusModel.shouldShowError) }) .applyInsetGroupedListStyle() + .sheet(isPresented: $statusModel.showAddWidgetEducationView) { + widgetEducationSheet() + } } @ViewBuilder @@ -86,11 +140,27 @@ struct NetworkProtectionStatusView: View { .padding([.top, .bottom], 2) snooze() + } header: { header() } .increaseHeaderProminence() .listRowBackground(Color(designSystemColor: .surface)) + + Section { + if #available(iOS 17.0, *) { + widgetTipView() + .tipImageSize(Self.defaultImageSize) + .padding(.horizontal, 3) + } + + if #available(iOS 17.0, *) { + snoozeTipView() + .tipImageSize(Self.defaultImageSize) + .padding(.horizontal, 3) + } + } + .listRowBackground(Color(designSystemColor: .surface)) } @ViewBuilder @@ -151,8 +221,8 @@ struct NetworkProtectionStatusView: View { @ViewBuilder private func locationDetails() -> some View { - if !statusModel.isSnoozing, let location = statusModel.location { - Section { + Section { + if !statusModel.isSnoozing, let location = statusModel.location { var locationAttributedString: AttributedString { var attributedString = AttributedString( statusModel.preferredLocation.isNearest ? "\(location) \(UserText.netPVPNLocationNearest)" : location @@ -164,16 +234,10 @@ struct NetworkProtectionStatusView: View { return attributedString } - NavigationLink(destination: NetworkProtectionVPNLocationView()) { + NavigationLink(destination: locationView()) { NetworkProtectionLocationItemView(title: locationAttributedString, imageName: nil) } - } header: { - Text(statusModel.isNetPEnabled ? UserText.vpnLocationConnected : UserText.vpnLocationSelected) - .foregroundColor(.init(designSystemColor: .textSecondary)) - } - .listRowBackground(Color(designSystemColor: .surface)) - } else { - Section { + } else { let imageName = statusModel.preferredLocation.isNearest ? "VPNLocation" : nil var nearestLocationAttributedString: AttributedString { var attributedString = AttributedString(statusModel.preferredLocation.title) @@ -181,15 +245,32 @@ struct NetworkProtectionStatusView: View { return attributedString } - NavigationLink(destination: NetworkProtectionVPNLocationView()) { + NavigationLink(destination: locationView()) { NetworkProtectionLocationItemView(title: nearestLocationAttributedString, imageName: imageName) } - } header: { - Text(statusModel.isNetPEnabled ? UserText.vpnLocationConnected : UserText.vpnLocationSelected) - .foregroundColor(.init(designSystemColor: .textSecondary)) } - .listRowBackground(Color(designSystemColor: .surface)) + } header: { + Text(statusModel.isNetPEnabled ? UserText.vpnLocationConnected : UserText.vpnLocationSelected) + .foregroundColor(.init(designSystemColor: .textSecondary)) + } + .listRowBackground(Color(designSystemColor: .surface)) + + Section { + if #available(iOS 17.0, *) { + geoswitchingTipView() + .tipImageSize(Self.defaultImageSize) + .padding(.horizontal, 3) + } } + .listRowBackground(Color(designSystemColor: .surface)) + } + + @ViewBuilder + private func locationView() -> some View { + NetworkProtectionVPNLocationView() + .onAppear { + statusModel.handleUserOpenedVPNLocations() + } } @ViewBuilder @@ -267,6 +348,61 @@ struct NetworkProtectionStatusView: View { isAnimating: $statusModel.isNetPEnabled ) } + + // MARK: - Tips + + @available(iOS 17.0, *) + @ViewBuilder + private func geoswitchingTipView() -> some View { + if statusModel.canShowTips { + + TipView(geoswitchingTip) + .removeGroupedListStyleInsets() + .tipCornerRadius(0) + .tipBackground(Color(designSystemColor: .surface)) + } + } + + @available(iOS 17.0, *) + @ViewBuilder + private func snoozeTipView() -> some View { + if statusModel.canShowTips, + statusModel.hasServerInfo { + + TipView(snoozeTip, action: statusModel.snoozeActionHandler(action:)) + .removeGroupedListStyleInsets() + .tipCornerRadius(0) + .tipBackground(Color(designSystemColor: .surface)) + } + } + + @available(iOS 17.0, *) + @ViewBuilder + private func widgetTipView() -> some View { + if statusModel.canShowTips, + !statusModel.isNetPEnabled && !statusModel.isSnoozing { + + TipView(widgetTip, action: statusModel.widgetActionHandler(action:)) + .removeGroupedListStyleInsets() + .tipCornerRadius(0) + .tipBackground(Color(designSystemColor: .surface)) + } + } + + // MARK: - Sheets + + private func widgetEducationSheet() -> some View { + NavigationView { + WidgetEducationView() + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(UserText.navigationTitleDone) { + statusModel.showAddWidgetEducationView = false + } + } + } + } + } } private struct NetworkProtectionErrorView: View { diff --git a/DuckDuckGo/NetworkProtectionStatusViewModel.swift b/DuckDuckGo/NetworkProtectionStatusViewModel.swift index 7e7e5dc51f..becccb79aa 100644 --- a/DuckDuckGo/NetworkProtectionStatusViewModel.swift +++ b/DuckDuckGo/NetworkProtectionStatusViewModel.swift @@ -24,6 +24,7 @@ import WidgetKit import BrowserServicesKit import Core import Subscription +import TipKit struct NetworkProtectionLocationStatusModel { enum LocationIcon { @@ -94,12 +95,24 @@ final class NetworkProtectionStatusViewModel: ObservableObject { return formatter }() + private let featureFlagger = AppDependencyProvider.shared.featureFlagger private let tunnelController: (TunnelController & TunnelSessionProvider) private let statusObserver: ConnectionStatusObserver private let serverInfoObserver: ConnectionServerInfoObserver private let errorObserver: ConnectionErrorObserver private var cancellables: Set = [] + // MARK: - Tips + + var canShowTips: Bool { + featureFlagger.isFeatureOn(.networkProtectionUserTips) + } + + /// Whether the "Add Widget" education sheet should be presented to the user. + /// + @Published + var showAddWidgetEducationView: Bool = false + // MARK: Error struct ErrorItem { @@ -121,7 +134,19 @@ final class NetworkProtectionStatusViewModel: ObservableObject { // MARK: Toggle Item - @Published public var isNetPEnabled = false + @Published public var isNetPEnabled = false { + didSet { + if #available(iOS 17.0, *) { + if isNetPEnabled { + VPNGeoswitchingTip.donateVPNConnectedEvent() + } + + VPNSnoozeTip.vpnEnabled = isNetPEnabled + VPNAddWidgetTip.vpnEnabled = isNetPEnabled + } + } + } + @Published public var isSnoozing = false { didSet { snoozeRequestPending = false @@ -452,6 +477,10 @@ final class NetworkProtectionStatusViewModel: ObservableObject { return } + if #available(iOS 17.0, *) { + VPNSnoozeTip().invalidate(reason: .actionPerformed) + } + let defaultDuration: TimeInterval = .minutes(20) snoozeRequestPending = true try? await activeSession.sendProviderMessage(.startSnooze(defaultDuration)) @@ -546,6 +575,35 @@ final class NetworkProtectionStatusViewModel: ObservableObject { self.downloadTotal = nil } + // MARK: - UI Events handling + + @available(iOS 17.0, *) + func snoozeActionHandler(action: Tips.Action) { + if action.id == VPNSnoozeTip.ActionIdentifiers.learnMore.rawValue { + let url = URL(string: "https://duckduckgo.com/duckduckgo-help-pages/privacy-pro/vpn/troubleshooting/")! + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + } + + @available(iOS 17.0, *) + @MainActor + func widgetActionHandler(action: Tips.Action) { + if action.id == VPNAddWidgetTip.ActionIdentifiers.addWidget.rawValue { + showAddWidgetEducationView = true + + VPNAddWidgetTip().invalidate(reason: .actionPerformed) + } + } + + /// The user opened the VPN locations view + /// + func handleUserOpenedVPNLocations() { + if #available(iOS 17.0, *) { + Task { @MainActor in + VPNGeoswitchingTip().invalidate(reason: .actionPerformed) + } + } + } } private extension ConnectionStatus { diff --git a/DuckDuckGo/RootDebugViewController.swift b/DuckDuckGo/RootDebugViewController.swift index 23500ebf8c..08dea4d8ae 100644 --- a/DuckDuckGo/RootDebugViewController.swift +++ b/DuckDuckGo/RootDebugViewController.swift @@ -50,6 +50,7 @@ class RootDebugViewController: UITableViewController { case resetDuckPlayerExperiment = 678 case overrideDuckPlayerExperiment = 679 case overrideDuckPlayerExperimentControl = 680 + case resetTipKit = 681 } @IBOutlet weak var shareButton: UIBarButtonItem! @@ -64,6 +65,7 @@ class RootDebugViewController: UITableViewController { private var sync: DDGSyncing? private var internalUserDecider: DefaultInternalUserDecider? var tabManager: TabManager? + private var tipKitUIActionHandler: TipKitDebugOptionsUIActionHandling? @UserDefaultsWrapper(key: .lastConfigurationRefreshDate, defaultValue: .distantPast) private var lastConfigurationRefreshDate: Date @@ -72,24 +74,29 @@ class RootDebugViewController: UITableViewController { sync: DDGSyncing, bookmarksDatabase: CoreDataDatabase, internalUserDecider: InternalUserDecider, - tabManager: TabManager) { + tabManager: TabManager, + tipKitUIActionHandler: TipKitDebugOptionsUIActionHandling = TipKitDebugOptionsUIActionHandler()) { self.sync = sync self.bookmarksDatabase = bookmarksDatabase self.internalUserDecider = internalUserDecider as? DefaultInternalUserDecider self.tabManager = tabManager + self.tipKitUIActionHandler = tipKitUIActionHandler + + super.init(coder: coder) + } + + required init?(coder: NSCoder) { super.init(coder: coder) } - - func configure(sync: DDGSyncing, bookmarksDatabase: CoreDataDatabase, internalUserDecider: InternalUserDecider, tabManager: TabManager) { + + func configure(sync: DDGSyncing, bookmarksDatabase: CoreDataDatabase, internalUserDecider: InternalUserDecider, tabManager: TabManager, tipKitUIActionHandler: TipKitDebugOptionsUIActionHandling = TipKitDebugOptionsUIActionHandler()) { + self.sync = sync self.bookmarksDatabase = bookmarksDatabase self.internalUserDecider = internalUserDecider as? DefaultInternalUserDecider self.tabManager = tabManager - } - - required init?(coder: NSCoder) { - super.init(coder: coder) + self.tipKitUIActionHandler = tipKitUIActionHandler } @IBSegueAction func onCreateImageCacheDebugScreen(_ coder: NSCoder) -> ImageCacheDebugViewController? { @@ -187,6 +194,8 @@ class RootDebugViewController: UITableViewController { case .resetDuckPlayerExperiment: DuckPlayerLaunchExperiment().cleanup() ActionMessageView.present(message: "Experiment Settings deleted. You'll be assigned a random cohort") + case .resetTipKit: + tipKitUIActionHandler?.resetTipKitTapped() case .overrideDuckPlayerExperiment: DuckPlayerLaunchExperiment().override() ActionMessageView.present(message: "Overriding experiment. You are now in the 'experiment' group. Restart the app to complete") diff --git a/DuckDuckGo/TipKit/Logger+TipKit.swift b/DuckDuckGo/TipKit/Logger+TipKit.swift new file mode 100644 index 0000000000..1d791692b4 --- /dev/null +++ b/DuckDuckGo/TipKit/Logger+TipKit.swift @@ -0,0 +1,28 @@ +// +// Logger+TipKit.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import os.log + +extension Logger { + + static var tipKit: Logger = { + Logger(subsystem: Bundle.main.bundleIdentifier ?? "DuckDuckGo", category: "TipKit") + }() +} diff --git a/DuckDuckGo/TipKit/TipKitAppEventHandling.swift b/DuckDuckGo/TipKit/TipKitAppEventHandling.swift new file mode 100644 index 0000000000..572427579a --- /dev/null +++ b/DuckDuckGo/TipKit/TipKitAppEventHandling.swift @@ -0,0 +1,50 @@ +// +// TipKitAppEventHandling.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Core +import Foundation +import os.log + +protocol TipKitAppEventHandling { + func appDidFinishLaunching() +} + +struct TipKitAppEventHandler: TipKitAppEventHandling { + + private let controller: TipKitController + private let logger: Logger + + init(controller: TipKitController = .make(), + logger: Logger = .tipKit) { + + self.controller = controller + self.logger = logger + } + + func appDidFinishLaunching() { + if #available(iOS 17.0, *) { + controller.configureTipKit([ + .displayFrequency(.immediate), + .datastoreLocation(.applicationDefault) + ]) + } else { + logger.log("TipKit initialization skipped: iOS 17.0 or later is required.") + } + } +} diff --git a/DuckDuckGo/TipKit/TipKitController+ConvenienceInitializers.swift b/DuckDuckGo/TipKit/TipKitController+ConvenienceInitializers.swift new file mode 100644 index 0000000000..8b39a4ea95 --- /dev/null +++ b/DuckDuckGo/TipKit/TipKitController+ConvenienceInitializers.swift @@ -0,0 +1,30 @@ +// +// TipKitController+ConvenienceInitializers.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import os + +extension TipKitController { + + static func make(logger: Logger = .tipKit, + userDefaults: UserDefaults = .standard) -> Self { + + self.init(logger: logger, userDefaults: userDefaults) + } +} diff --git a/DuckDuckGo/TipKit/TipKitController.swift b/DuckDuckGo/TipKit/TipKitController.swift new file mode 100644 index 0000000000..c91a0b534f --- /dev/null +++ b/DuckDuckGo/TipKit/TipKitController.swift @@ -0,0 +1,93 @@ +// +// TipKitController.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import os.log +import TipKit + +protocol TipKitControlling { + @available(iOS 17.0, *) + func configureTipKit() + + @available(iOS 17.0, *) + func resetTipKitOnNextAppLaunch() +} + +final class TipKitController { + + private let logger: Logger + private let userDefaults: UserDefaults + + private var resetTipKitOnNextLaunch: Bool { + get { + userDefaults.bool(forKey: "resetTipKitOnNextLaunch") + } + + set { + userDefaults.set(newValue, forKey: "resetTipKitOnNextLaunch") + } + } + + init(logger: Logger, + userDefaults: UserDefaults) { + + self.logger = logger + self.userDefaults = userDefaults + } + + @available(iOS 17.0, macOS 14.0, *) + func configureTipKit(_ configuration: [Tips.ConfigurationOption] = []) { + do { + if resetTipKitOnNextLaunch { + resetTipKit() + resetTipKitOnNextLaunch = false + } + + try Tips.configure(configuration) + + logger.debug("TipKit initialized") + } catch { + logger.error("Failed to initialize TipKit: \(error)") + } + } + + @available(iOS 17.0, macOS 14.0, *) + private func resetTipKit() { + do { + try Tips.resetDatastore() + + logger.debug("TipKit reset") + } catch { + logger.debug("Failed to reset TipKit: \(error)") + } + } + + /// Resets TipKit + /// + /// One thing that's not documented as of 2024-10-09 is that resetting TipKit must happen before it's configured. + /// When trying to reset it after it's configured we get `TipKit.TipKitError(value: TipKit.TipKitError.Value.tipsDatastoreAlreadyConfigured)`. + /// In order to make things work for us we set a user defaults value that ensures TipKit will be reset on next + /// app launch instead of directly trying to reset it here. + /// + @available(iOS 17.0, *) + func resetTipKitOnNextAppLaunch() { + resetTipKitOnNextLaunch = true + logger.debug("TipKit will reset on next app launch") + } +} diff --git a/DuckDuckGo/TipKit/TipKitDebugOptionsUIActionHandling.swift b/DuckDuckGo/TipKit/TipKitDebugOptionsUIActionHandling.swift new file mode 100644 index 0000000000..1a12054a98 --- /dev/null +++ b/DuckDuckGo/TipKit/TipKitDebugOptionsUIActionHandling.swift @@ -0,0 +1,49 @@ +// +// TipKitDebugOptionsUIActionHandling.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import os.log + +protocol TipKitDebugOptionsUIActionHandling { + /// Resets TipKit + func resetTipKitTapped() +} + +struct TipKitDebugOptionsUIActionHandler: TipKitDebugOptionsUIActionHandling { + + private let controller: TipKitController + private let logger: Logger + + init(controller: TipKitController = .make(), + logger: Logger = .tipKit) { + + self.controller = controller + self.logger = logger + } + + func resetTipKitTapped() { + if #available(iOS 17.0, *) { + controller.resetTipKitOnNextAppLaunch() + + ActionMessageView.present(message: "TipKit will reset on next app launch.") + } else { + logger.log("TipKit initialization skipped: iOS 17.0 or later is required.") + } + } +} diff --git a/DuckDuckGo/VPN.xcassets/Contents.json b/DuckDuckGo/VPN.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/DuckDuckGo/VPN.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/VPN.xcassets/VPNAddWidgetTipIcon.imageset/Contents.json b/DuckDuckGo/VPN.xcassets/VPNAddWidgetTipIcon.imageset/Contents.json new file mode 100644 index 0000000000..1360a4b9fb --- /dev/null +++ b/DuckDuckGo/VPN.xcassets/VPNAddWidgetTipIcon.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Widget-Add-32 2.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/VPN.xcassets/VPNAddWidgetTipIcon.imageset/Widget-Add-32 2.pdf b/DuckDuckGo/VPN.xcassets/VPNAddWidgetTipIcon.imageset/Widget-Add-32 2.pdf new file mode 100644 index 0000000000000000000000000000000000000000..be3e56356e0de1523b57d1a9c96c23a8f7d1dbf0 GIT binary patch literal 1819 zcmZXVe>9VO9LLLzG}^`G_;Kd;q^s7CH8We=v2+<~D4K3)aeFXpnLTVHzlsyWlFF?) zO{tivP$n#u6kR#x`ZYz3>5^1jWra*~H@~XW=a1)npZEEGe!M@=Ij_gs%h?r$*|-S? z!vHn_6nFpz03;IN-~gkugEg-(+i!KWE0e{ea{#m}3*^zA=|NC1U7ev0vg!asq^`i_ap+(eEP8Qw_WjI$I|G;T34ZaZ=fB7)ENF2$?)|b>;G`lW*|m3)IL2i^ zX}NgmtYyXPc6$l*$FnmJriLfS6Fx^(PrpfpI^G=)P&~UnC{s=v)!|v)>;aeD;ezn+ z`-W8x8)%m+q7w85a%JI_wUM+f{wuQ0$m*H=Agx9ZbBW538-+mJ}TI$2epUub&bj_Yv` z{EneLR5(LEjHHn2^!P z5M6He4P_9$FQ9}?_G5K2i?*kM?9H(;v3~CE0`56U=rgF}MG_tvR>a_WWolzYtc|L~ z^_6a)B%gj2*2*ZW^G2+KiaG_&w_Zs;-}+n@zxVKGX}3z)QlezayQriHWtEB5advl| zqA$cIzf@7Ol>B;kwtkugRW#9Fvb8f#TK2A)jjD#@ZyyYe=)M|TLQ84)3t9(%b-QBh zW3Qu~##lxs$lw&WGfpe^zx1%=g|a8>cklW6RDy$1k%Dn7>O5FnlYc#`#WKBcj-#n`j(PR zKCc}m&EHM&nHuEZF7C>zDW^3U4<3>^=}GPh#K;L>7nG=6>I~g#lZL+^va2P$Hc~a) zi{MjA_()KvDz8?BxYntv9d03#7;@Pq?C_jWtsuigo-w3%8aYjoRgn%ADBI(m9hYGV2t()B{} z>+Ac$-*-r+UX-TPGrI&07@O-@W{U7&i zqiGo#W*rTUqcpUlmYc?W@Ik&cOCgyec6YLCI4W3?z@mwrViHw6cH{S#&P2=V=@W4` zxBa;?$_P7_IP#A2=!w~8Qe(TNW7)AN)S^mG9s*y!$c&Z7af^5RO&5^_v{*Z@J5i6%@XPzXBUhLGfkUj!z7R}ysIp|tKb zo|SgRQtp*xYgLVM-grDLX!LDtsJOgWvXrG`%GKNCV(Dl|cK4`qAm!ve_bMn3GkvoL zvE){=@0J((%cKFaHVwz-Kr~M@4WedAhr!@%7C>jWAgck0c4V_59v7hb&*5!XOgJ4t zdx4C3-fgD`GeIXP8lV9-8*OId>j7&!FMU1DSEACnP$Vaa&IL5c)v!{7Pcp>j(b?*b z8s5%}ULZ%U0k}Eo8!Bt01zMxsnZfE&0gVGHFyDs^MXGZ#Kg`darStqY@ZVL^800QA z)C@LjoDA|n7R2~IHJU>Yf!P3?V6*l$PXLd@VR3+3o(qf(FmnRf3ldg6rzRF8j4ht< zgG5ws=lffB_#YB+*sqB^4#;HDIWW!Fqd2SAf#gDA^tAwz8N7CODQGGL@c@nCnFXW0 bnXz>B^fSLIki(m?gTWCu!K|%aJYD|>(gN^D literal 0 HcmV?d00001 diff --git a/DuckDuckGo/VPN.xcassets/VPNChangeLocationTipIcon.imageset/Contents.json b/DuckDuckGo/VPN.xcassets/VPNChangeLocationTipIcon.imageset/Contents.json new file mode 100644 index 0000000000..cb612d2e7d --- /dev/null +++ b/DuckDuckGo/VPN.xcassets/VPNChangeLocationTipIcon.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Location-32.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/VPN.xcassets/VPNChangeLocationTipIcon.imageset/Location-32.pdf b/DuckDuckGo/VPN.xcassets/VPNChangeLocationTipIcon.imageset/Location-32.pdf new file mode 100644 index 0000000000000000000000000000000000000000..25b89dda779c25b09f31d0062a0dd1de4461d887 GIT binary patch literal 1570 zcmY!laBvK;I`dFTEr~!5FAK2q*+Jp}3?dH8Gc~f^q7U)9X}Cd0d}=6*YBJlC}2t zXY4fm!0CRMsWXpr>yhu9=dAK7YVf@NK<@tB9~0l^*8h0<__{j1@#i?2TmI)7cy_~~<6O>M1Z$-S$`f9z>AEYY?+si`V* zb)K~Vd(Cp6Y^n9i?RRF0#NJ4|vr)|`c%R1P_+S7iD~%HuXQ9yhJqBKxXnv`^YuN%rstwMoaQ^w;l3*w~94eEox!K z!p_@|t^eCxJ# z;k9>8)JKoAUD?aO-gt6FX3KFsrpl$APpek7uym^JST-R`Xt4;Vl4kmT^|hTfi741U3DT1LoLI8)l9u$H-uEpFbJBJ`v^vno7E_k=n;+;Z$a>$I&Cqg$bsc$TIes^v%%N36KUK@F@YBDcLH~qTLvE}2P z#jkjuRtmBSCUNZzdtwxEqOEAI*X9*WD=$p-T77h{YROuYYZvb&bw)7F4gB;$u(M5Q z!=^=AT%YX>joQpF1~U2iJHMQFAy>sYH~jwYLs6Vj7xizbPOyub>Z~cHeNW75oo%78 zZAPzPr@~&hr&r^vCzmCz%bmZvb@JqNYxxX2_SdezSFl_N%$&UVb;$3&+?+g(wG+;8 zRo8UfNINjI%IOCm|CK!l{@u^uzI93O!>OlVdHMUd?M+@5JHzVFYU;kd zm0v|4GdW}!E;(bdd*Pi&^VZMs&5+W`JeS<9Kjo$0v|8>rpI&d-ees8?{izkP-pfng zxYwP~((*6LKG`2SPf+XHr1;li-p6LY@UHVcwrOFPdtEQ*TF-;i{_r@a$Iq$=PJOlK z%JqcjroCax6=hn_&5xZ}|Fq1q|Iqa!>BuRuM?YWuy7kVU86FdIcLtWG@!X#tee&p} z`^hTDii*W%l`>aNlP=jBw6J;7_NUhrry0(=`}Cn;pF*wUX8-)tx~qQ2RUZw{F5&&5 z`|kd(83LBii)KA|^^<3tLerTg`Np^8zHgVDt08v%$DUZpvo9=IA5?emS~%rMN5Ji_ z6Nh%|Jt|s0Phj7N0^h4F*Wa842UEdS^TmOeW%;ICzdbakv)b)Hcng_HIRP4Y60}_jXVhW~E zA*8|yBm^(C^u04vfKF8a`5;IEW{-1zDNwH=hWm>lg%m6>%uq}Md9WC6A;@76k2@!p zBp}^2U0nAd!g9{l0-3bD4AwvTrbXA51 zCcuC|Rb^xd^fj80g(Y05q$n{nC$)$R6nma7z~Im*&d*KNRM5yw(S!tweo%gXi2}&O g-~iSS&a6rWx*l9&Cl-}}y<=!>V!@@V>gw+X0104XMgRZ+ literal 0 HcmV?d00001 diff --git a/DuckDuckGo/VPN.xcassets/VPNUseSnoozeTipIcon.imageset/Contents.json b/DuckDuckGo/VPN.xcassets/VPNUseSnoozeTipIcon.imageset/Contents.json new file mode 100644 index 0000000000..492a76420b --- /dev/null +++ b/DuckDuckGo/VPN.xcassets/VPNUseSnoozeTipIcon.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Moon-Snooze-32.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/VPN.xcassets/VPNUseSnoozeTipIcon.imageset/Moon-Snooze-32.pdf b/DuckDuckGo/VPN.xcassets/VPNUseSnoozeTipIcon.imageset/Moon-Snooze-32.pdf new file mode 100644 index 0000000000000000000000000000000000000000..60ce9c49012d140973421e6610a52799add4e524 GIT binary patch literal 1938 zcmZXVd05hS9LGxy7cnYk;IZmgNrg@U@jg()ux1U!V<~~6{4x|l0nMWxYioqJ1?oER zs?^d{rlwZbvUv{EtqZkmd8|2?W~O;;IncDVK7ah4@Av)uKIiv&ULQ2ojckTDw}C?O z01n^>g+c+q(GegLp&*Z~xk4@et7FL=t_TzWSTdI-0^Pu1J{wefs13UZne9m6jxukhQF@%u%Hq=MiK!d2Cng?Sq^+i# z5w4wBCa$s96?>m%Oj4Sg+h5oD&fJ@MX?J;xTaXQgQPsXn85CNQ7(3ic4+xD(+q(nNW8_@rWh4jL%U3C$UGhgsjpF=BW*scw2Yl6Fh)VecE6a>2 z|0#Z_CwObKQFvi)O3iFPTz>tB*&C-tdu2V*Bidw0XzygsI%0}Z^hi_S1YSc9+BTXd?PPg+AkcL#?>|k^yE5oH`YA8 z>66Tau_7cg?-fv&V2!$o~Sya_u`7Xdm_y(r1of3u7#(|>FRbC zKy)(PL+X5OSJ!pluzMVN@y)Z~zplXca{>YMStnA7Q}>UqQ6+%ynPRbmBKf5JWf|I7 zYjpv5?Gk54D0$^>Q~SnY<&~svi^!w*o@kHB4*Hj0KDwgPv7y8R48v+CdS=9g(VPUP z=e$d*wI3P?sR;Ry@A^x~^IDm4E|uOqu}sgTL>0Po@9*9M3_kvW+<{sV+DZ&)%TX2& zTZZOK=o$6FTUH+torn8<Y6`ue{$2p~S0Z5={EUYbbSbLk~W09LQQ*=7ji`S(e}E zu-`<&Zj!>?eQ+y%{5c*vr4{ixdBriXS9!LB7N3#oo9x#4)@^3JF^8X;edTBfe{v|| zZfp*H9M&yk%Ipg%p2h$|RN$ps!8b;wbf#|Dw-R#E&rok!`z^ZLg4MIE;zpMABlXf8qKaHlW7d@%N(LMkbk49VQN^H7iQ}S`?cCQyeBC;@%z! ziY%{s7-?7`E!Pr{*LL2*<<&iZVQ23ju0_r|xt;uWvQ%o$^>9mtoRWCPZ`;czrf37- z-O&$$rPuQGc}7lySIz&)Eg6U%HfqQ|HuoI^lRuSJWEx)T|J-aqw!S~D{^WDxvm@7V z$FmK;3P`f0m_2%C`8Dh>+DZSQ{dL~a=sI(pIqE}hJwjIp7doFqg6<*evL?Spa zr;66>e=+%2X$wykWjDp#Hh8d|}gNT6tolt(D+AP_;12ugA3wy94TfBTuUuu2Z zXyVr|=CZT;gsYzZB$*0Iuh&VECc}Lh%{gHkS`>`sodKvx$boy}0mjCC{rP#}114`d zsd(3|qXyW|A)`i4TPFfq{mw;>mA=$ndExG#bxAhJGF-zDy=!YFVT>z3(`Y(}%tjI* zHw_+z=r>$gd$ZzoLYe=W`b~!orUF!%h+2A1S(PGGUXxLDp!HD$&A@vDDuMNfa<^+) zUnV>7{#saywP(!LY2)01mp5F{X6aqWTi^^tJNZgccQvP|rl4!2DL5l_1G2JhDC=Bv zBBdSC_33qU-KVQO=F)xse%ca`J+2d2@q1y{+_JF?J~1s7 z3i(ljU{DBXNUuS(hT0@PPXzMR88w(+6satMS_7=!N&i^}8fj@sU_Cf&b*X@+0~)ZH zhs2Lk`{F;UU%0Ro7N=qTK_pFs!li_o!WJ4Qu|zB`KjcH#7y)ueaMx zJ&x@{emIB$wsY8+g`r?+e7*?KG(10GtQRK^R98R0Az1>^d^>m>yfqY!-bo?<3sRvI AF#rGn literal 0 HcmV?d00001 diff --git a/DuckDuckGo/VPNAddWidgetTip.swift b/DuckDuckGo/VPNAddWidgetTip.swift new file mode 100644 index 0000000000..fd6f652415 --- /dev/null +++ b/DuckDuckGo/VPNAddWidgetTip.swift @@ -0,0 +1,78 @@ +// +// VPNAddWidgetTip.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import TipKit + +/// A tip to suggest to the user that they add our VPN widget for quick access to the VPN +/// +struct VPNAddWidgetTip {} + +/// Necessary split to support older iOS versions. +/// +@available(iOS 17.0, *) +extension VPNAddWidgetTip: Tip { + + enum ActionIdentifiers: String { + case addWidget = "com.duckduckgo.vpn.tip.addWidget.action.addWidget" + } + + static let geolocationTipDismissedEvent = Tips.Event(id: "com.duckduckgo.vpn.tip.addWidget.geolocationTipDismissedEvent") + + static let snoozeTipDismissedEvent = Tips.Event(id: "com.duckduckgo.vpn.tip.addWidget.geolocationTipDismissedEvent") + + @Parameter(.transient) + static var vpnEnabled: Bool = false + + private static let vpnDisconnectedEvent = Tips.Event(id: "com.duckduckgo.vpn.tip.addWidget.vpnDisconnectedEvent") + + var id: String { + "com.duckduckgo.vpn.tip.addWidget" + } + + var title: Text { + Text("Add VPN Widget") + } + + var message: Text? { + Text("Turn the VPN on and off right from the Home Screen.") + } + + var image: Image? { + Image(.vpnAddWidgetTipIcon) + } + + var actions: [Action] { + [Action(id: ActionIdentifiers.addWidget.rawValue) { + Text("Add widget") + .foregroundStyle(Color(designSystemColor: .accent)) + }] + } + + var rules: [Rule] { + #Rule(Self.geolocationTipDismissedEvent) { + $0.donations.count > 0 + } + #Rule(Self.snoozeTipDismissedEvent) { + $0.donations.count > 0 + } + #Rule(Self.$vpnEnabled) { + $0 == false + } + } +} diff --git a/DuckDuckGo/VPNGeoswitchingTip.swift b/DuckDuckGo/VPNGeoswitchingTip.swift new file mode 100644 index 0000000000..e8110fa18c --- /dev/null +++ b/DuckDuckGo/VPNGeoswitchingTip.swift @@ -0,0 +1,58 @@ +// +// VPNGeoswitchingTip.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import TipKit + +/// A tip to suggest to the user to change their location using geo-switching +/// +struct VPNGeoswitchingTip {} + +@available(iOS 17.0, *) +extension VPNGeoswitchingTip: Tip { + + private static let vpnConnectedEvent = Tips.Event(id: "com.duckduckgo.vpn.tip.geoswitching.vpnConnectedEvent") + + var id: String { + "com.duckduckgo.vpn.tip.geoswitching" + } + + var title: Text { + Text("Change Your Location") + } + + var message: Text? { + Text("You can customize your VPN location by connecting to any of our servers worldwide.") + } + + var image: Image? { + Image(.vpnChangeLocationTipIcon) + } + + var rules: [Rule] { + #Rule(Self.vpnConnectedEvent) { + $0.donations.donatedWithin(.week).count > 0 + } + } + + static func donateVPNConnectedEvent() { + Task { + await vpnConnectedEvent.donate() + } + } +} diff --git a/DuckDuckGo/VPNSnoozeTip.swift b/DuckDuckGo/VPNSnoozeTip.swift new file mode 100644 index 0000000000..9baa7bacba --- /dev/null +++ b/DuckDuckGo/VPNSnoozeTip.swift @@ -0,0 +1,71 @@ +// +// VPNSnoozeTip.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import TipKit + +/// A tip to suggest to the user to use the snooze feature to momentarily disable the VPN +/// +struct VPNSnoozeTip {} + +/// Necessary split to support older iOS versions. +/// +@available(iOS 17.0, *) +extension VPNSnoozeTip: Tip { + + enum ActionIdentifiers: String { + case learnMore = "com.duckduckgo.vpn.tip.snooze.learnMoreId" + } + + static let geolocationTipDismissedEvent = Tips.Event(id: "com.duckduckgo.vpn.tip.snooze.geolocationTipDismissedEvent") + + @Parameter(.transient) + static var vpnEnabled: Bool = false + + var id: String { + "com.duckduckgo.vpn.tip.snooze" + } + + var title: Text { + Text("Avoid VPN Conflicts") + } + + var message: Text? { + Text("You can use sites or apps that block VPN traffic by snoozing the VPN connection.") + } + + var image: Image? { + Image(.vpnUseSnoozeTipIcon) + } + + var actions: [Action] { + [Action(id: ActionIdentifiers.learnMore.rawValue) { + Text("Learn more") + .foregroundStyle(Color(designSystemColor: .accent)) + }] + } + + var rules: [Rule] { + #Rule(Self.geolocationTipDismissedEvent) { + $0.donations.count > 0 + } + #Rule(Self.$vpnEnabled) { + $0 == true + } + } +} diff --git a/DuckDuckGo/ViewExtension.swift b/DuckDuckGo/ViewExtension.swift index 3c00ddf314..e322db6120 100644 --- a/DuckDuckGo/ViewExtension.swift +++ b/DuckDuckGo/ViewExtension.swift @@ -62,6 +62,12 @@ extension View { .applyBackground() } + /// Removes the grouped list style insets for a single row. + /// + func removeGroupedListStyleInsets() -> some View { + listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + } + @ViewBuilder func applyBackground() -> some View { hideScrollContentBackground()