diff --git a/Assets.xcassets/Paywall/Feadback/Contents.json b/Assets.xcassets/Paywall/Feadback/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Assets.xcassets/Paywall/Feadback/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Assets.xcassets/Paywall/Feadback/feedback-checkmark.imageset/Contents.json b/Assets.xcassets/Paywall/Feadback/feedback-checkmark.imageset/Contents.json new file mode 100644 index 0000000..0f1e1b9 --- /dev/null +++ b/Assets.xcassets/Paywall/Feadback/feedback-checkmark.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Vector.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Assets.xcassets/Paywall/Feadback/feedback-checkmark.imageset/Vector.pdf b/Assets.xcassets/Paywall/Feadback/feedback-checkmark.imageset/Vector.pdf new file mode 100644 index 0000000..92cbdd8 --- /dev/null +++ b/Assets.xcassets/Paywall/Feadback/feedback-checkmark.imageset/Vector.pdf @@ -0,0 +1,92 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 3.284912 -3.925781 cm +0.000000 0.678431 0.905882 scn +2.322571 16.495317 m +1.039556 17.778038 -1.040383 17.777800 -2.323103 16.494785 c +-3.605823 15.211771 -3.605585 13.131832 -2.322571 11.849112 c +2.322571 16.495317 l +h +6.963112 7.210699 m +4.640542 4.887596 l +5.923374 3.605057 8.002954 3.605087 9.285749 4.887663 c +6.963112 7.210699 l +h +23.211231 18.810753 m +24.494209 20.093510 24.494387 22.173449 23.211630 23.456427 c +21.928873 24.739403 19.848934 24.739582 18.565956 23.456825 c +23.211231 18.810753 l +h +-2.322571 11.849112 m +4.640542 4.887596 l +9.285683 9.533802 l +2.322571 16.495317 l +-2.322571 11.849112 l +h +9.285749 4.887663 m +23.211231 18.810753 l +18.565956 23.456825 l +4.640475 9.533735 l +9.285749 4.887663 l +h +f +n +Q + +endstream +endobj + +3 0 obj + 794 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 27.458496 20.493164 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000000884 00000 n +0000000906 00000 n +0000001079 00000 n +0000001153 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +1212 +%%EOF \ No newline at end of file diff --git a/Assets.xcassets/Paywall/Feadback/feedback-paywall-arrow.imageset/Arrow Ramp Right.svg b/Assets.xcassets/Paywall/Feadback/feedback-paywall-arrow.imageset/Arrow Ramp Right.svg new file mode 100644 index 0000000..2c3a6f0 --- /dev/null +++ b/Assets.xcassets/Paywall/Feadback/feedback-paywall-arrow.imageset/Arrow Ramp Right.svg @@ -0,0 +1,3 @@ + + + diff --git a/Assets.xcassets/Paywall/Feadback/feedback-paywall-arrow.imageset/Contents.json b/Assets.xcassets/Paywall/Feadback/feedback-paywall-arrow.imageset/Contents.json new file mode 100644 index 0000000..266c3f4 --- /dev/null +++ b/Assets.xcassets/Paywall/Feadback/feedback-paywall-arrow.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Arrow Ramp Right.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Assets.xcassets/Paywall/Feadback/feedback-paywall-banner.imageset/AdobeStock_768605328 1.png b/Assets.xcassets/Paywall/Feadback/feedback-paywall-banner.imageset/AdobeStock_768605328 1.png new file mode 100644 index 0000000..e2a75cd Binary files /dev/null and b/Assets.xcassets/Paywall/Feadback/feedback-paywall-banner.imageset/AdobeStock_768605328 1.png differ diff --git a/Assets.xcassets/Paywall/Feadback/feedback-paywall-banner.imageset/Contents.json b/Assets.xcassets/Paywall/Feadback/feedback-paywall-banner.imageset/Contents.json new file mode 100644 index 0000000..7ddb1d5 --- /dev/null +++ b/Assets.xcassets/Paywall/Feadback/feedback-paywall-banner.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "AdobeStock_768605328 1.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Assets.xcassets/Paywall/Feadback/feedback-promo.imageset/AdobeStock_776091887 2 (1).png b/Assets.xcassets/Paywall/Feadback/feedback-promo.imageset/AdobeStock_776091887 2 (1).png new file mode 100644 index 0000000..d33850c Binary files /dev/null and b/Assets.xcassets/Paywall/Feadback/feedback-promo.imageset/AdobeStock_776091887 2 (1).png differ diff --git a/Assets.xcassets/Paywall/Feadback/feedback-promo.imageset/Contents.json b/Assets.xcassets/Paywall/Feadback/feedback-promo.imageset/Contents.json new file mode 100644 index 0000000..86fb01e --- /dev/null +++ b/Assets.xcassets/Paywall/Feadback/feedback-promo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "AdobeStock_776091887 2 (1).png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Assets.xcassets/Paywall/Feadback/feedback.imageset/AdobeStock_811446718 1.png b/Assets.xcassets/Paywall/Feadback/feedback.imageset/AdobeStock_811446718 1.png new file mode 100644 index 0000000..e28a096 Binary files /dev/null and b/Assets.xcassets/Paywall/Feadback/feedback.imageset/AdobeStock_811446718 1.png differ diff --git a/Assets.xcassets/Paywall/Feadback/feedback.imageset/Contents.json b/Assets.xcassets/Paywall/Feadback/feedback.imageset/Contents.json new file mode 100644 index 0000000..201b4d7 --- /dev/null +++ b/Assets.xcassets/Paywall/Feadback/feedback.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "AdobeStock_811446718 1.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LockdowniOS.xcodeproj/project.pbxproj b/LockdowniOS.xcodeproj/project.pbxproj index c5b20cb..99f4d1f 100644 --- a/LockdowniOS.xcodeproj/project.pbxproj +++ b/LockdowniOS.xcodeproj/project.pbxproj @@ -249,6 +249,11 @@ 40E7A3012A0E1C7A00E0231A /* SplashscreenViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40E7A3002A0E1C7A00E0231A /* SplashscreenViewController.swift */; }; 40FC414329F74C7900BD7396 /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40FC414229F74C7900BD7396 /* String+Extensions.swift */; }; 4A86219093026DE70A097E79 /* Pods-LockdownTests-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 8DA68459884385F76BF86234 /* Pods-LockdownTests-metadata.plist */; }; + 51006F192CBD0E7400F5142C /* ImageBannerWithTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51006F182CBD0E7400F5142C /* ImageBannerWithTitleView.swift */; }; + 510AA2F62CB8222100E53560 /* FeedbackPaywallViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510AA2F52CB8222100E53560 /* FeedbackPaywallViewModel.swift */; }; + 5117DE882CBD4A6600C4A61B /* SectionTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5117DE872CBD4A6600C4A61B /* SectionTitleView.swift */; }; + 5145A19A2CBE37C40074C562 /* FeedbackFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5145A1992CBE37C40074C562 /* FeedbackFlow.swift */; }; + 51DD89E52CB7DA770028B4FE /* FeedbackPaywallViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51DD89E42CB7DA770028B4FE /* FeedbackPaywallViewController.swift */; }; 54F0B1A0273200B0002F3630 /* FirewallController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DCA4F4022F252720017740D /* FirewallController.swift */; }; 5647ACFEBBAB001FAE27CAF9 /* Pods-LockdownTunnel-settings-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 6F089C7008AB8F59DE3EA7BD /* Pods-LockdownTunnel-settings-metadata.plist */; }; 5666ABC4D0064E4669D1943F /* Pods-LockdownTunnel-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = B2AFAE1E2F56A1CA9EC153D4 /* Pods-LockdownTunnel-metadata.plist */; }; @@ -775,6 +780,11 @@ 40FC414229F74C7900BD7396 /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = ""; }; 50F9BE503587CE4933CB7983 /* Pods-Lockdown-settings-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-Lockdown-settings-metadata.plist"; path = "Settings.bundle/Pods-Lockdown-settings-metadata.plist"; sourceTree = ""; }; 50FB8ADA1D444FD9486F2D44 /* Pods-Lockdown Firewall Widget-settings-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-Lockdown Firewall Widget-settings-metadata.plist"; path = "LockdowniOS/Settings.bundle/Pods-Lockdown Firewall Widget-settings-metadata.plist"; sourceTree = ""; }; + 51006F182CBD0E7400F5142C /* ImageBannerWithTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageBannerWithTitleView.swift; sourceTree = ""; }; + 510AA2F52CB8222100E53560 /* FeedbackPaywallViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackPaywallViewModel.swift; sourceTree = ""; }; + 5117DE872CBD4A6600C4A61B /* SectionTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SectionTitleView.swift; sourceTree = ""; }; + 5145A1992CBE37C40074C562 /* FeedbackFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackFlow.swift; sourceTree = ""; }; + 51DD89E42CB7DA770028B4FE /* FeedbackPaywallViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackPaywallViewController.swift; sourceTree = ""; }; 66424506768B2196F870B04C /* Pods-LockdownTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LockdownTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-LockdownTests/Pods-LockdownTests.release.xcconfig"; sourceTree = ""; }; 6F089C7008AB8F59DE3EA7BD /* Pods-LockdownTunnel-settings-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-LockdownTunnel-settings-metadata.plist"; path = "Settings.bundle/Pods-LockdownTunnel-settings-metadata.plist"; sourceTree = ""; }; 71D50056A5E2E1F6486369F9 /* Pods-Lockdown VPN Widget.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Lockdown VPN Widget.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Lockdown VPN Widget/Pods-Lockdown VPN Widget.debug.xcconfig"; sourceTree = ""; }; @@ -1251,6 +1261,7 @@ 40098E1A29FDA61900886474 /* Paywalls */ = { isa = PBXGroup; children = ( + 51DD89E32CB7D9E10028B4FE /* FeedbackFormPaywall */, 40960AEE2A033A16000F82EB /* Models */, 40098E3329FF376900886474 /* FirewallPaywall */, 40098E2029FDA63100886474 /* VPNPaywall */, @@ -1742,6 +1753,15 @@ name = ViewController; sourceTree = ""; }; + 51DD89E32CB7D9E10028B4FE /* FeedbackFormPaywall */ = { + isa = PBXGroup; + children = ( + 51DD89E42CB7DA770028B4FE /* FeedbackPaywallViewController.swift */, + 510AA2F52CB8222100E53560 /* FeedbackPaywallViewModel.swift */, + ); + name = FeedbackFormPaywall; + sourceTree = ""; + }; 7C0D11102473EDFD00A26E04 /* Services */ = { isa = PBXGroup; children = ( @@ -2084,6 +2104,7 @@ B1A01CA32A432826004D43EE /* Questionnaire */ = { isa = PBXGroup; children = ( + 5145A1992CBE37C40074C562 /* FeedbackFlow.swift */, B1A01CA62A432902004D43EE /* Controllers */, B1A01CA72A432919004D43EE /* Models */, B1062A342A45BD5800FA9E8B /* ViewModel */, @@ -2124,6 +2145,8 @@ B1F11C7F2A49E35800A137A3 /* QuestionTitleView.swift */, B1F11C812A49E63400A137A3 /* NavigationLinkView.swift */, B1F11C892A4B050500A137A3 /* CountryView.swift */, + 51006F182CBD0E7400F5142C /* ImageBannerWithTitleView.swift */, + 5117DE872CBD4A6600C4A61B /* SectionTitleView.swift */, ); path = Views; sourceTree = ""; @@ -2935,6 +2958,7 @@ B1F11C7C2A498CBF00A137A3 /* BaseStepViewModel.swift in Sources */, 3D47CDD222F3C3F3003BD7F7 /* NVActivityIndicatorAnimationBallSpinFadeLoader.swift in Sources */, 3D9FC67723E503DF004122D3 /* EmailSignInViewController.swift in Sources */, + 51DD89E52CB7DA770028B4FE /* FeedbackPaywallViewController.swift in Sources */, 3DCA4F3322F22CB40017740D /* HomeViewController.swift in Sources */, 7CE91C962521ED5E009D8269 /* VPNRegion.swift in Sources */, F0A8E0412C64E977001303C6 /* Defaults.swift in Sources */, @@ -2964,6 +2988,7 @@ A1EBEAD42097AE6E002B9087 /* M13CheckboxPathGenerator.swift in Sources */, 40CC817E2A14BB3600F9805E /* UIView+Corners.swift in Sources */, F01CAB7C2C61106F009C19CF /* SUI+Extensions.swift in Sources */, + 5145A19A2CBE37C40074C562 /* FeedbackFlow.swift in Sources */, B1062A2D2A447B2F00FA9E8B /* RadioSwitcher.swift in Sources */, A1EBEACB2097AE6E002B9087 /* M13CheckboxDisclosurePathGenerator.swift in Sources */, 40960AEB2A03396F000F82EB /* ProductPurchasable.swift in Sources */, @@ -3014,6 +3039,7 @@ B1F11C822A49E63400A137A3 /* NavigationLinkView.swift in Sources */, A154A07E215C78180010FFCC /* BlockListCell.swift in Sources */, F0B12AF82C60D602008EF8AA /* OneTimePaywallView.swift in Sources */, + 510AA2F62CB8222100E53560 /* FeedbackPaywallViewModel.swift in Sources */, 3D47CDCE22F3C3F3003BD7F7 /* NVActivityIndicatorAnimationBallScaleRippleMultiple.swift in Sources */, B1BA87012A4C4BC400D141A8 /* QuestionModel.swift in Sources */, 40FC414329F74C7900BD7396 /* String+Extensions.swift in Sources */, @@ -3044,6 +3070,7 @@ 3D47CDC122F3C3F3003BD7F7 /* NVActivityIndicatorAnimationSemiCircleSpin.swift in Sources */, 3DBD57B422FCFF2500DE189F /* SetRegionViewController.swift in Sources */, B1062A382A45BEBE00FA9E8B /* WhatProblemStepViewModel.swift in Sources */, + 51006F192CBD0E7400F5142C /* ImageBannerWithTitleView.swift in Sources */, 402D253129E635CB00A5AB60 /* DomainsBlockedTableViewCell.swift in Sources */, 7C4D9BBB252C8748004175EA /* AccountUI.swift in Sources */, 3D47CDBE22F3C3F3003BD7F7 /* NVActivityIndicatorAnimationBallScale.swift in Sources */, @@ -3059,6 +3086,7 @@ 402BAD362A0CD37C009B8820 /* ConnectivityService.swift in Sources */, 3D0711BB22FE7B5100391C6E /* TitleViewController.swift in Sources */, 3D47CDB222F3C3F3003BD7F7 /* NVActivityIndicatorShape.swift in Sources */, + 5117DE882CBD4A6600C4A61B /* SectionTitleView.swift in Sources */, 7C422E97252796EE007F9C22 /* StaticTableView.swift in Sources */, 7C3EFA0224867DEE00719D96 /* TrackerInfo.swift in Sources */, 40960B152A034400000F82EB /* Keychainable.swift in Sources */, @@ -3605,7 +3633,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 9; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = V8J3Z26F6Z; ENABLE_BITCODE = NO; @@ -3645,7 +3673,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 9; DEVELOPMENT_TEAM = V8J3Z26F6Z; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( diff --git a/LockdowniOS/AccountVC.swift b/LockdowniOS/AccountVC.swift index 97e09fb..37810ed 100644 --- a/LockdowniOS/AccountVC.swift +++ b/LockdowniOS/AccountVC.swift @@ -16,7 +16,8 @@ final class AccountViewController: BaseViewController, Loadable { // MARK: - Properties let tableView = StaticTableView(frame: .zero, style: .grouped) var activePlans: [Subscription.PlanType] = [] - + var feedbackFlow: FeedbackFlow? + override func viewDidLoad() { super.viewDidLoad() @@ -390,16 +391,7 @@ final class AccountViewController: BaseViewController, Loadable { } private func openQuestionnaire() { - let stepsViewController = StepsViewController() - var viewModel = StepsViewModel { [weak self] message in - self?.sendMessage( - message, - subject: "Lockdown Error Reporting Form (iOS \(Bundle.main.versionString))" - ) - } - stepsViewController.viewModel = viewModel - stepsViewController.modalPresentationStyle = .fullScreen - present(stepsViewController, animated: true) + feedbackFlow?.startFlow() } } diff --git a/LockdowniOS/AppDelegate.swift b/LockdowniOS/AppDelegate.swift index 8751330..4e499cc 100644 --- a/LockdowniOS/AppDelegate.swift +++ b/LockdowniOS/AppDelegate.swift @@ -67,7 +67,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Prepare IAP VPNSubscription.cacheLocalizedPrices() Task { - let pids = Set(VPNSubscription.oneTimeProducts.toList()) + let pids = Set(VPNSubscription.oneTimeProducts.toList() + VPNSubscription.feedbackProducts.toList()) await VPNSubscription.shared.loadSubscriptions(productIds: pids) print("") } diff --git a/LockdowniOS/BaseViewController.swift b/LockdowniOS/BaseViewController.swift index 1d4e85d..0cfa433 100644 --- a/LockdowniOS/BaseViewController.swift +++ b/LockdowniOS/BaseViewController.swift @@ -198,7 +198,8 @@ open class BaseViewController: UIViewController, MFMailComposeViewControllerDele let acceptButton = DefaultButton(title: NSLocalizedString("OK", comment: ""), dismissOnTap: true) { completionHandler() } popup.addButtons([acceptButton]) - self.present(popup, animated: true, completion: nil) + let topVC = presentedViewController ?? self + topVC.present(popup, animated: true, completion: nil) } enum PopupButton { @@ -321,7 +322,9 @@ open class BaseViewController: UIViewController, MFMailComposeViewControllerDele attachmentData.append(logFileData as Data) } composeVC.addAttachmentData(attachmentData as Data, mimeType: "text/plain", fileName: "diagnostics.txt") - self.present(composeVC, animated: true, completion: nil) + + let topVC = presentedViewController ?? self + topVC.present(composeVC, animated: true, completion: nil) } else { guard let mailtoURL = Mailto.generateURL(recipient: recipient, subject: subject, body: message) else { diff --git a/LockdowniOS/ConfiguredNavigationView.swift b/LockdowniOS/ConfiguredNavigationView.swift index cc1ecc7..c99a76f 100644 --- a/LockdowniOS/ConfiguredNavigationView.swift +++ b/LockdowniOS/ConfiguredNavigationView.swift @@ -43,7 +43,23 @@ final class ConfiguredNavigationView: UIView { self.accentColor = accentColor configureUI() } - + + var titleView: UIView? { + didSet { + oldValue?.removeFromSuperview() + guard let titleView else { + titleLabel.isHidden = false + return + } + titleLabel.isHidden = true + + addSubview(titleView) + titleView.anchors.leading.marginsPin(inset: 67) + titleView.anchors.trailing.marginsPin(inset: 67) + titleView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true + } + } + @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") diff --git a/LockdowniOS/DescriptionLabel.swift b/LockdowniOS/DescriptionLabel.swift index 1a291d4..6713824 100644 --- a/LockdowniOS/DescriptionLabel.swift +++ b/LockdowniOS/DescriptionLabel.swift @@ -19,6 +19,8 @@ final class DescriptionLabel: UIView { lazy var lockImage: UIImageView = { let image = UIImageView() image.image = UIImage(named: "icn_lock") + image.setContentHuggingPriority(.required, for: .horizontal) + image.setContentCompressionResistancePriority(.required, for: .horizontal) image.contentMode = .left image.layer.masksToBounds = true return image @@ -27,6 +29,8 @@ final class DescriptionLabel: UIView { lazy var checkmarkImage: UIImageView = { let image = UIImageView() image.image = UIImage(named: "icn_checkmark") + image.setContentHuggingPriority(.required, for: .horizontal) + image.setContentCompressionResistancePriority(.required, for: .horizontal) image.contentMode = .left image.layer.masksToBounds = true image.isHidden = true @@ -35,9 +39,10 @@ final class DescriptionLabel: UIView { private lazy var descriptionLabel: UILabel = { let label = UILabel() - label.textColor = .black + label.textColor = .label label.font = fontMedium15 label.textAlignment = .left + label.numberOfLines = 0 return label }() @@ -47,8 +52,8 @@ final class DescriptionLabel: UIView { stackView.addArrangedSubview(checkmarkImage) stackView.addArrangedSubview(descriptionLabel) stackView.axis = .horizontal - stackView.distribution = .fillProportionally - stackView.alignment = .leading + stackView.distribution = .fill + stackView.alignment = .center stackView.spacing = 12 return stackView }() diff --git a/LockdowniOS/FeedbackPaywallViewController.swift b/LockdowniOS/FeedbackPaywallViewController.swift new file mode 100644 index 0000000..1189b8d --- /dev/null +++ b/LockdowniOS/FeedbackPaywallViewController.swift @@ -0,0 +1,410 @@ +// +// FeedbackPaywallViewController.swift +// Lockdown +// +// Created by Fabian Mistoiu on 10.10.2024. +// Copyright © 2024 Confirmed Inc. All rights reserved. +// + +import UIKit +import Combine + +class FeedbackPaywallViewController: UIViewController { + + private let viewModel: FeedbackPaywallViewModel + private var subscriptions = Set() + + init(viewModel: FeedbackPaywallViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - UI + private lazy var closeButton: UIButton = { + let button = UIButton(type: .system) + button.translatesAutoresizingMaskIntoConstraints = false + button.tintColor = .feedbackText + button.setTitle(Copy.close, for: .normal) + button.titleLabel?.font = .close + button.addAction( + UIAction { [weak self] _ in + guard let self else { return } + self.viewModel.onCloseHandler?(self) + }, + for: .touchUpInside) + return button + }() + + private lazy var bannerView: UIImageView = { + let imageView = UIImageView(image: UIImage(named: "feedback-paywall-banner")) + imageView.translatesAutoresizingMaskIntoConstraints = false + + imageView.addSubview(bannerArrowImageView) + NSLayoutConstraint.activate([ + bannerArrowImageView.centerYAnchor.constraint(equalTo: imageView.bottomAnchor), + bannerArrowImageView.centerXAnchor.constraint(equalTo: imageView.rightAnchor, constant: -10), + bannerArrowImageView.widthAnchor.constraint(equalToConstant: 92), + bannerArrowImageView.heightAnchor.constraint(equalToConstant: 92), + imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor, multiplier: 1072 / 687), + imageView.widthAnchor.constraint(lessThanOrEqualToConstant: 420) + ]) + + return imageView + }() + + private lazy var bannerArrowImageView: UIImageView = { + let imageView = UIImageView(image: UIImage(named: "feedback-paywall-arrow")) + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.numberOfLines = 0 + label.textColor = .feedbackText + + let continueRange = (Copy.title as NSString).range(of: Copy.titleHighlight) + let attributedString = NSMutableAttributedString( + string: Copy.title, + attributes: [.font: UIFont.title as Any]) + attributedString.addAttribute(.foregroundColor, value: UIColor.feedbackBlue as Any, range: continueRange) + + label.attributedText = attributedString + return label + }() + + private lazy var descriptionLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.numberOfLines = 0 + label.textColor = .feedbackText + label.font = .description + label.text = Copy.description + return label + }() + + private lazy var bulletPointContainer: UIView = { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.spacing = 5 + + [Copy.bulletPoint1, Copy.bulletPoint2, Copy.bulletPoint3] + .map { createBulletPointView(copy: $0) } + .forEach { stackView.addArrangedSubview($0) } + return stackView + }() + + + private lazy var bottomContainer: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [continueButton, linksContainer]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.alignment = .fill + stackView.distribution = .fill + stackView.spacing = 8 + stackView.setCustomSpacing(17, after: continueButton) + + return stackView + }() + + private lazy var continueButton: UIButton = { + let button = UIButton(type: .system) + button.tintColor = .white + button.setTitle(Copy.continue, for: .normal) + button.titleLabel?.font = .ctaButton + button.backgroundColor = .feedbackBlue + button.layer.cornerRadius = 29 + button.anchors.height.equal(58) + button.addAction( + UIAction { [weak self] _ in + guard let self else { return } + let pID = viewModel.paywallPlans[viewModel.selectedPlanIndex].id + viewModel.onPurchaseHandler?(self, pID) + }, + for: .touchUpInside) + return button + }() + + private lazy var linksContainer: UIView = { + let linkButtons = [ + createLinkButton(title: Copy.terms, url: URL(string: "https://lockdownprivacy.com/terms")!), + createLinkButton(title: Copy.privacy, url: URL(string: "https://lockdownprivacy.com/privacy")!) + ] + + let stackView = UIStackView(arrangedSubviews: linkButtons) + stackView.distribution = .fillEqually + return stackView + }() + + private var planButtons: [PlanContainer] = [] + + // MARK: - + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .background + + view.addSubview(closeButton) + NSLayoutConstraint.activate([ + closeButton.topAnchor.constraint(equalTo: view.topAnchor, constant: 5), + closeButton.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 17) + ]) + + let scrollView = UIScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(scrollView) + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: view.topAnchor, constant: 28 + 16), + scrollView.leftAnchor.constraint(equalTo: view.leftAnchor), + scrollView.centerXAnchor.constraint(equalTo: view.centerXAnchor) + ]) + + let bannerImageContainer = UIView() + bannerImageContainer.translatesAutoresizingMaskIntoConstraints = false + bannerImageContainer.addSubview(bannerView) + NSLayoutConstraint.activate([ + bannerView.topAnchor.constraint(equalTo: bannerImageContainer.topAnchor), + bannerView.bottomAnchor.constraint(equalTo: bannerImageContainer.bottomAnchor), + bannerView.centerXAnchor.constraint(equalTo: bannerImageContainer.centerXAnchor) + ]) + let bannerWidthConstraint = bannerView.widthAnchor.constraint(equalTo: bannerImageContainer.widthAnchor, multiplier: 0.8) + bannerWidthConstraint.priority = .init(rawValue: 999) + bannerWidthConstraint.isActive = true + + let copyStackView = UIStackView(arrangedSubviews: [bannerImageContainer, titleLabel, descriptionLabel, bulletPointContainer]) + copyStackView.translatesAutoresizingMaskIntoConstraints = false + copyStackView.axis = .vertical + copyStackView.alignment = .fill + copyStackView.distribution = .fill + copyStackView.spacing = 0 + copyStackView.setCustomSpacing(22, after: bannerImageContainer) + copyStackView.setCustomSpacing(17, after: descriptionLabel) + scrollView.addSubview(copyStackView) + NSLayoutConstraint.activate([ + copyStackView.topAnchor.constraint(equalTo: scrollView.topAnchor, constant: 0), + copyStackView.centerXAnchor.constraint(equalTo: scrollView.centerXAnchor), + copyStackView.leftAnchor.constraint(equalTo: scrollView.leftAnchor, constant: 39), + copyStackView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor) + ]) + + view.addSubview(bottomContainer) + NSLayoutConstraint.activate([ + bottomContainer.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: 0), + bottomContainer.centerXAnchor.constraint(equalTo: view.centerXAnchor), + bottomContainer.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 45), + bottomContainer.topAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: 10) + ]) + + viewModel.$paywallPlans.sink(receiveValue: { [weak self] plans in + guard let self else { return } + planButtons.forEach { $0.removeFromSuperview() } + + planButtons = plans.map { self.createPlanButton(title: $0.name, price: $0.price, period: $0.pricePeriod, promo: $0.promo) } + planButtons.reversed().forEach { self.bottomContainer.insertArrangedSubview($0, at: 0) } + if let lastButton = planButtons.last { + bottomContainer.setCustomSpacing(30, after: lastButton) + } + + selectButton(at: viewModel.selectedPlanIndex) + }).store(in: &subscriptions) + + viewModel.$selectedPlanIndex.sink(receiveValue: { [weak self] selectedPlanIndex in + guard let self else { return } + selectButton(at: selectedPlanIndex) + }).store(in: &subscriptions) + } + + func selectButton(at selectedIndex: Int) { + for (index, button) in planButtons.map(\.button).enumerated() { + let selected = index == selectedIndex + + button.backgroundColor = selected ? .selectedPlanBackground : .clear + button.titleLabel?.font = selected ? .selectedPlanTitle: .unselectedPlanTitle + button.layer.borderColor = selected ? UIColor.feedbackBlue.cgColor : UIColor.smallGrey.cgColor + } + } + + // MARK: - UI helper + + private func createBulletPointView(copy: String) -> UIView { + let bulletPointImageView = UIImageView(image: UIImage(named: "feedback-checkmark")) + bulletPointImageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + bulletPointImageView.widthAnchor.constraint(equalToConstant: 9), + bulletPointImageView.heightAnchor.constraint(equalToConstant: 6), + ]) + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.numberOfLines = 0 + label.text = copy + label.textColor = .feedbackText + label.font = .bulletPoint + + let spacer = UIView() + spacer.setContentHuggingPriority(.required, for: .horizontal) + NSLayoutConstraint.activate([ + spacer.widthAnchor.constraint(equalToConstant: 5), + spacer.heightAnchor.constraint(equalToConstant: 5), + ]) + + let stackView = UIStackView(arrangedSubviews: [spacer, bulletPointImageView, label]) + stackView.spacing = 8 + stackView.alignment = .center + return stackView + } + + private func createPlanButton(title: String, price: String, period: String?, promo: String?) -> PlanContainer { + let button = UIButton(type: .system) + button.translatesAutoresizingMaskIntoConstraints = false + button.heightAnchor.constraint(equalToConstant: 54).isActive = true + button.layer.cornerRadius = 27 + button.layer.borderWidth = 1 + button.tintColor = .feedbackText + button.setTitle(title, for: .normal) + button.contentHorizontalAlignment = .left + button.contentVerticalAlignment = .center + button.titleEdgeInsets = UIEdgeInsets(top: 0, left: 21, bottom: 0, right: 0) + button.addTarget(self, action: #selector(planSelected), for: .touchUpInside) + + let priceLabel = UILabel() + priceLabel.translatesAutoresizingMaskIntoConstraints = false + priceLabel.textColor = .feedbackText + priceLabel.font = .planPrice + priceLabel.text = price + + let pricePeriodLabel: UILabel? = if period != nil { UILabel() } else { nil } + pricePeriodLabel?.translatesAutoresizingMaskIntoConstraints = false + pricePeriodLabel?.textColor = .feedbackText + pricePeriodLabel?.font = .planPeriod + pricePeriodLabel?.text = period + + let stackView = UIStackView(arrangedSubviews: [priceLabel, pricePeriodLabel].compactMap { $0 }) + stackView.isUserInteractionEnabled = false + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.alignment = .trailing + button.addSubview(stackView) + NSLayoutConstraint.activate([ + stackView.centerYAnchor.constraint(equalTo: button.centerYAnchor), + stackView.rightAnchor.constraint(equalTo: button.rightAnchor, constant: -18) + ]) + + var promoView: UIView? + if let promo { + let gradientView = GradientView() + gradientView.translatesAutoresizingMaskIntoConstraints = false + gradientView.gradient = .custom([UIColor.promoGradientStart.cgColor, UIColor.promoGradientStart.cgColor], .horizontal) + gradientView.layer.cornerRadius = 11.5 + gradientView.clipsToBounds = true + + let promoLabel = UILabel() + promoLabel.translatesAutoresizingMaskIntoConstraints = false + promoLabel.textColor = .feedbackText + promoLabel.font = .selectedPlanTitle + promoLabel.text = promo + + gradientView.addSubview(promoLabel) + NSLayoutConstraint.activate([ + promoLabel.centerYAnchor.constraint(equalTo: gradientView.centerYAnchor), + promoLabel.centerXAnchor.constraint(equalTo: gradientView.centerXAnchor), + promoLabel.leftAnchor.constraint(equalTo: gradientView.leftAnchor, constant: 7), + gradientView.heightAnchor.constraint(equalToConstant: 23) + ]) + promoView = gradientView + } + + return PlanContainer(button: button, promoLabel: promoView) + } + + private func createLinkButton(title: String, url: URL) -> UIButton { + let button = UIButton(type: .system) + button.titleLabel?.font = fontMedium13 + button.setTitle(title, for: .normal) + button.tintColor = .feedbackText + button.addAction( + UIAction { _ in + UIApplication.shared.open(url, options: [:], completionHandler: nil) + }, + for: .touchUpInside) + return button + } + + @objc func planSelected(_ sender: UIButton) { + guard let index = planButtons.map(\.button).firstIndex(of: sender) else { return } + viewModel.selectPlan(at: index) + } +} + +private class PlanContainer: UIView { + let button: UIButton + let promoLabel: UIView? + + init(button: UIButton, promoLabel: UIView?) { + self.button = button + self.promoLabel = promoLabel + super.init(frame: .zero) + + addSubview(button) + NSLayoutConstraint.activate([ + button.topAnchor.constraint(equalTo: topAnchor), + button.bottomAnchor.constraint(equalTo: bottomAnchor), + button.leftAnchor.constraint(equalTo: leftAnchor), + button.rightAnchor.constraint(equalTo: rightAnchor) + ]) + if let promoLabel { + addSubview(promoLabel) + NSLayoutConstraint.activate([ + promoLabel.centerYAnchor.constraint(equalTo: button.topAnchor), + promoLabel.rightAnchor.constraint(equalTo: button.rightAnchor, constant: -18) + ]) + } + + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private enum Copy { + static var close: String = NSLocalizedString("CLOSE", comment: "") + static var title: String = NSLocalizedString("Tap Continue to Activate this ONE TIME Offer", comment: "") + static var titleHighlight: String = NSLocalizedString("Continue", comment: "") + static var `continue`: String = NSLocalizedString("Continue", comment: "") + static let description: String = NSLocalizedString("Private Browsing with Hidden IP and Global Region Switching", comment: "") + static var bulletPoint1: String = NSLocalizedString("Anonymised browsing", comment: "") + static var bulletPoint2: String = NSLocalizedString("Location and IP address hidden", comment: "") + static var bulletPoint3: String = NSLocalizedString("Unlimited bandwidth and data usage & more", comment: "") + static var terms: String = NSLocalizedString("Terms", comment: "") + static var privacy: String = NSLocalizedString("Privacy", comment: "") + +} + +private extension UIFont { + static let close = UIFont(name: "Montserrat-Bold", size: 13) + static let title = UIFont(name: "SFProRounded-Semibold", size: 28) + static let description = UIFont(name: "Montserrat-Regular", size: 14) + static let bulletPoint = UIFont(name: "Montserrat-SemiBold", size: 12) + static let ctaButton = UIFont(name: "Montserrat-SemiBold", size: 20) + static let selectedPlanTitle = UIFont(name: "Montserrat-Bold", size: 12) + static let unselectedPlanTitle = UIFont(name: "Montserrat-Medium", size: 12) + static let planPrice = UIFont(name: "Montserrat-SemiBold", size: 14) + static let planPeriod = UIFont(name: "Montserrat-Medium", size: 14) +} + +private extension UIColor { + static let background = UIColor.panelSecondaryBackground + static let feedbackText = UIColor.label + static let feedbackBlue = UIColor.fromHex("#00ADE7") + static let selectedPlanBackground = feedbackBlue.withAlphaComponent(0.1) + static let unselectedPlanBorder = UIColor.fromHex("#999999") + static let promoGradientStart = UIColor.fromHex("#FB923C") + static let promoGradientEnd = UIColor.fromHex("#EA580C") +} diff --git a/LockdowniOS/FeedbackPaywallViewModel.swift b/LockdowniOS/FeedbackPaywallViewModel.swift new file mode 100644 index 0000000..231bab6 --- /dev/null +++ b/LockdowniOS/FeedbackPaywallViewModel.swift @@ -0,0 +1,64 @@ +// +// FeedbackPaywallViewModel.swift +// Lockdown +// +// Created by Fabian Mistoiu on 10.10.2024. +// Copyright © 2024 Confirmed Inc. All rights reserved. +// + +import Foundation +import StoreKit + +class FeedbackPaywallViewModel { + + struct PaywallPlan { + let id: String + let name: String + let price: String + let pricePeriod: String? + let promo: String? + } + + @Published var paywallPlans: [PaywallPlan] = [] + @Published var selectedPlanIndex: Int = 0 + + private let products: FeedbackProducts + private let subscriptionInfo: [InternalSubscription] + + var onCloseHandler: ((UIViewController) -> Void)? = nil + var onPurchaseHandler: ((UIViewController, String) -> Void)? = nil + + init(products: FeedbackProducts, subscriptionInfo: [InternalSubscription]) { + self.products = products + self.subscriptionInfo = subscriptionInfo + + createPaywallPlans() + } + + public func selectPlan(at index: Int) { + selectedPlanIndex = index + } + + private func createPaywallPlans() { + let currencyFormatter = NumberFormatter() + currencyFormatter.usesGroupingSeparator = true + currencyFormatter.numberStyle = .currency + currencyFormatter.locale = subscriptionInfo.first?.priceLocale + + guard let yearlyPlan = subscriptionInfo.first(where: { $0.productId == products.yearly}), + let weeklyPlan = subscriptionInfo.first(where: { $0.productId == products.weekly}), + let yearlyPrice = currencyFormatter.string(from: yearlyPlan.price), + let weeklyPrice = currencyFormatter.string(from: weeklyPlan.price) else { + return + } + + let yearlyPricePerWeek = yearlyPlan.price.dividing(by: 52) + let saving = 100 - Int(Double(truncating: yearlyPricePerWeek) / Double(truncating: weeklyPlan.price)*100) + + paywallPlans = [ + PaywallPlan(id: products.yearly, name: "Yearly Plan", price: yearlyPrice, pricePeriod: nil, promo: "SAVE \(saving)%"), + PaywallPlan(id: products.weekly, name: "Weekly Plan", price: weeklyPrice, pricePeriod: "per week", promo: nil) + ] + selectedPlanIndex = 0 + } +} diff --git a/LockdowniOS/HomeViewController.swift b/LockdowniOS/HomeViewController.swift index 96d1b60..985c979 100644 --- a/LockdowniOS/HomeViewController.swift +++ b/LockdowniOS/HomeViewController.swift @@ -49,6 +49,8 @@ class HomeViewController: BaseViewController, AwesomeSpotlightViewDelegate, Load let ratingCountKey = "ratingCount" + lastVersionToAskForRating let ratingTriggeredKey = "ratingTriggered" + lastVersionToAskForRating + var feedbackFlow: FeedbackFlow? + private lazy var scrollView: UIScrollView = { let view = UIScrollView() view.isScrollEnabled = true @@ -90,18 +92,12 @@ class HomeViewController: BaseViewController, AwesomeSpotlightViewDelegate, Load return label }() -// lazy var ctaView: CTAView = { -// let view = CTAView() -// -// return view -// }() - private lazy var mainTitle: UILabel = { let label = UILabel() label.text = NSLocalizedString("Get Anonymous protection", comment: "") label.font = fontBold24 label.numberOfLines = 0 - label.textColor = .black + label.textColor = .label return label }() @@ -156,18 +152,20 @@ class HomeViewController: BaseViewController, AwesomeSpotlightViewDelegate, Load private lazy var stackView: UIStackView = { let stackView = UIStackView() stackView.axis = .vertical - stackView.distribution = .fillProportionally + stackView.distribution = .fill stackView.spacing = 10 stackView.layer.cornerRadius = 8 - stackView.backgroundColor = .extraLightGray - stackView.layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 8, right: 8) + stackView.backgroundColor = UIColor.dynamicColor(light: .extraLightGray, dark: .panelSecondaryBackground!) + stackView.layoutMargins = UIEdgeInsets(top: 17, left: 20, bottom: 17, right: 20) stackView.isLayoutMarginsRelativeArrangement = true return stackView }() private lazy var closeButton: UIButton = { let button = UIButton() - button.setImage(UIImage(named: "icn_close_filled"), for: .normal) + let config = UIImage.SymbolConfiguration(pointSize: 23) + button.setImage(UIImage(systemName: "xmark.circle.fill", withConfiguration: config), for: .normal) + button.tintColor = .secondaryLabel button.addTarget(self, action: #selector(closeButtonTapped), for: .touchUpInside) return button }() @@ -276,6 +274,8 @@ class HomeViewController: BaseViewController, AwesomeSpotlightViewDelegate, Load stackView.addArrangedSubview(mainTitle) stackView.addArrangedSubview(descriptionLabel6) stackView.addArrangedSubview(upgradeButton) + stackView.setCustomSpacing(14, after: mainTitle) + stackView.setCustomSpacing(14, after: descriptionLabel6) } else if UserDefaults.hasSeenUniversalPaywall { protectionPlanLabel.text = "Universal protection" stackView.anchors.height.equal(0) @@ -288,6 +288,8 @@ class HomeViewController: BaseViewController, AwesomeSpotlightViewDelegate, Load stackView.addArrangedSubview(mainTitle) stackView.addArrangedSubview(descriptionLabel4) stackView.addArrangedSubview(descriptionLabel5) + stackView.setCustomSpacing(14, after: mainTitle) + stackView.setCustomSpacing(14, after: descriptionLabel5) stackView.addArrangedSubview(upgradeButton) } else { mainTitle.text = "Get Advanced\nprotection" @@ -297,6 +299,8 @@ class HomeViewController: BaseViewController, AwesomeSpotlightViewDelegate, Load stackView.addArrangedSubview(descriptionLabel2) stackView.addArrangedSubview(descriptionLabel3) stackView.addArrangedSubview(upgradeButton) + stackView.setCustomSpacing(14, after: mainTitle) + stackView.setCustomSpacing(14, after: descriptionLabel3) } view.addSubview(upgradeLabel) @@ -322,9 +326,9 @@ class HomeViewController: BaseViewController, AwesomeSpotlightViewDelegate, Load contentView.addSubview(stackView) stackView.anchors.top.marginsPin() - stackView.anchors.leading.pin(inset: 8) - stackView.anchors.trailing.marginsPin(inset: 10) - + stackView.anchors.leading.pin(inset: 22) + stackView.anchors.trailing.pin(inset: 22) + contentView.addSubview(mainStack) mainStack.anchors.top.spacing(8, to: stackView.anchors.bottom) mainStack.anchors.leading.marginsPin() @@ -332,12 +336,12 @@ class HomeViewController: BaseViewController, AwesomeSpotlightViewDelegate, Load mainStack.anchors.bottom.pin() contentView.addSubview(closeButton) - closeButton.anchors.trailing.marginsPin(inset: 20) + closeButton.anchors.trailing.spacing(-8, to: stackView.anchors.trailing) closeButton.anchors.top.marginsPin(inset: 8) + closeButton.anchors.height.equal(40) + closeButton.anchors.width.equal(40) } -// closeButtonTapped - @objc func closeButtonTapped() { stackView.anchors.height.equal(0) upgradeButton.isHidden = true @@ -478,7 +482,6 @@ class HomeViewController: BaseViewController, AwesomeSpotlightViewDelegate, Load } errored: { err in self?.handlePurchaseFailed(error: err) } - } let viewCtrl = UIHostingController(rootView: OneTimePaywallView(model: model)) viewCtrl.modalPresentationStyle = .fullScreen @@ -1329,23 +1332,36 @@ extension NEVPNStatus: CustomStringConvertible { } } +extension HomeViewController: PurchaseHandler { + func purchase(productId: String) { + VPNSubscription.selectedProductId = productId + VPNSubscription.purchase { [weak self] in + self?.handlePurchaseSuccessful() + } errored: { [weak self] err in + self?.handlePurchaseFailed(error: err) + } + } +} + extension HomeViewController: UITabBarControllerDelegate { func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool { if viewController is UINavigationController || viewController is HomeViewController { return true } - - let stepsViewController = StepsViewController() - var viewModel = StepsViewModel { [weak self] message in - self?.sendMessage( - message, - subject: "Lockdown Error Reporting Form (iOS \(Bundle.main.versionString))" - ) - } - stepsViewController.viewModel = viewModel - stepsViewController.modalPresentationStyle = .fullScreen - present(stepsViewController, animated: true) - + + feedbackFlow?.startFlow() return false } + + func showFeedbackPaywall() async { + guard let productInfos = await VPNSubscription.shared.loadSubscriptions(productIds: Set(VPNSubscription.feedbackProducts.toList())) else { return } + + let viewModel = FeedbackPaywallViewModel(products: VPNSubscription.feedbackProducts, subscriptionInfo: productInfos) + viewModel.onCloseHandler = { vc in vc.dismiss(animated: true) } + viewModel.onPurchaseHandler = { [weak self] paywallVC, pid in + self?.purchase(productId: pid) + } + let paywalVC = FeedbackPaywallViewController(viewModel: viewModel) + present(paywalVC, animated: true) + } } diff --git a/LockdowniOS/LockdownGradient.swift b/LockdowniOS/LockdownGradient.swift index 1489d51..df3f878 100644 --- a/LockdowniOS/LockdownGradient.swift +++ b/LockdowniOS/LockdownGradient.swift @@ -14,8 +14,8 @@ enum LockdownGradient { case onboardingPurple case ltoButtonOnHomePage case welcomePurple - case custom([CGColor]) - + case custom([CGColor], NSLayoutConstraint.Axis = .vertical) + var colors: [CGColor] { switch self { case .lightBlue: @@ -33,8 +33,18 @@ enum LockdownGradient { UIColor.fromHex("#FFFFFF4D").withAlphaComponent(0.3).cgColor] case .welcomePurple: return [UIColor.gradientPink1.cgColor, UIColor.gradientPink2.cgColor] - case .custom(let colors): + case .custom(let colors, _): return colors } } + + var points: (start: CGPoint, end: CGPoint) { + switch self { + case .custom(_, .horizontal): + return (CGPoint(x: 0, y: 0), CGPoint(x: 1, y: 0)) + default: + return (CGPoint(x: 0, y: 0), CGPoint(x: 0, y: 1)) + + } + } } diff --git a/LockdowniOS/MainTabBarViewController.swift b/LockdowniOS/MainTabBarViewController.swift index bff40e2..41c5f58 100644 --- a/LockdowniOS/MainTabBarViewController.swift +++ b/LockdowniOS/MainTabBarViewController.swift @@ -15,16 +15,34 @@ final class MainTabBarController: UITabBarController { var vpnViewController: LDVpnViewController? { viewControllers![1] as? LDVpnViewController } var configurationViewController: LDConfigurationViewController? { viewControllers![2] as? LDConfigurationViewController } - -// var homeViewController: HomeViewController { -// return viewControllers![0] as! HomeViewController -// } -// -// var accountViewController: AccountViewController { -// let navigation = viewControllers![1] as! UINavigationController -// return navigation.viewControllers[0] as! AccountViewController -// } - + + var accountViewController: AccountViewController? { + for viewController in viewControllers ?? [] { + if let navigationController = viewController as? UINavigationController, + let accountViewController = navigationController.viewControllers.first as? AccountViewController { + return accountViewController + } + } + return nil + } + + var homeViewController: HomeViewController? { + if let homeVC = viewControllers?.first(where: { $0 is HomeViewController }) { + return homeVC as? HomeViewController + } + return nil + } + + override func viewDidLoad() { + super.viewDidLoad() + + guard let homeViewController else { return } + homeViewController.feedbackFlow = FeedbackFlow(presentingViewController: homeViewController, purchaseHandler: homeViewController) + + guard let accountViewController else { return } + accountViewController.feedbackFlow = FeedbackFlow(presentingViewController: accountViewController, purchaseHandler: homeViewController) + } + var accountTabBarButton: UIView? { // this assumes that "Account" is the last tab. Change the code if this is no longer true return tabBar.subviews.last(where: { String(describing: type(of: $0)) == "UITabBarButton" }) diff --git a/LockdowniOS/Scenes/Questionnaire/Controllers/StepsViewController.swift b/LockdowniOS/Scenes/Questionnaire/Controllers/StepsViewController.swift index 48c6245..4682de9 100644 --- a/LockdowniOS/Scenes/Questionnaire/Controllers/StepsViewController.swift +++ b/LockdowniOS/Scenes/Questionnaire/Controllers/StepsViewController.swift @@ -20,6 +20,7 @@ class StepsViewController: UIViewController, StepsViewProtocol { view.leftNavButton.setImage(UIImage(systemName: "chevron.left"), for: .normal) view.leftNavButton.addTarget(self, action: #selector(backButtonClicked), for: .touchUpInside) view.leftNavButton.tintColor = .label + view.titleView = stepsView return view }() @@ -59,11 +60,6 @@ class StepsViewController: UIViewController, StepsViewProtocol { navigationView.anchors.trailing.pin() navigationView.anchors.top.safeAreaPin() - view.addSubview(stepsView) - stepsView.anchors.top.spacing(0, to: navigationView.anchors.bottom) - stepsView.anchors.leading.pin(inset: 18) - stepsView.anchors.trailing.pin(inset: 18) - view.addSubview(actionButton) actionButton.anchors.leading.pin(inset: 24) actionButton.anchors.trailing.pin(inset: 24) diff --git a/LockdowniOS/Scenes/Questionnaire/FeedbackFlow.swift b/LockdowniOS/Scenes/Questionnaire/FeedbackFlow.swift new file mode 100644 index 0000000..89b5076 --- /dev/null +++ b/LockdowniOS/Scenes/Questionnaire/FeedbackFlow.swift @@ -0,0 +1,60 @@ +// +// FeedbackFlow.swift +// Lockdown +// +// Created by Fabian Mistoiu on 15.10.2024. +// Copyright © 2024 Confirmed Inc. All rights reserved. +// + +import Foundation + +protocol PurchaseHandler: AnyObject { + func purchase(productId: String) +} + +class FeedbackFlow { + + weak var presentingViewController: BaseViewController? + weak var purchaseHandler: PurchaseHandler? + + init(presentingViewController: BaseViewController, purchaseHandler: PurchaseHandler) { + self.presentingViewController = presentingViewController + self.purchaseHandler = purchaseHandler + } + + func startFlow() { + let isPremiumUser = BaseUserService.shared.user.currentSubscription != nil + let viewModel = StepsViewModel(isUserPremium: isPremiumUser) { [weak self] message in + Task { @MainActor [weak self] in + if !isPremiumUser { + await self?.showFeedbackPaywall() + } + self?.presentingViewController?.sendMessage( + message, + subject: "Lockdown Error Reporting Form (iOS \(Bundle.main.versionString))" + ) + } + } + let stepsViewController = StepsViewController() + stepsViewController.viewModel = viewModel + stepsViewController.modalPresentationStyle = .fullScreen + presentingViewController?.present(stepsViewController, animated: true) + } + + @MainActor + private func showFeedbackPaywall() async { + guard let presentingViewController, + let productInfos = await VPNSubscription.shared.loadSubscriptions(productIds: Set(VPNSubscription.feedbackProducts.toList())) else { + return + } + + let viewModel = FeedbackPaywallViewModel(products: VPNSubscription.feedbackProducts, subscriptionInfo: productInfos) + viewModel.onCloseHandler = { vc in vc.dismiss(animated: true) } + viewModel.onPurchaseHandler = { [weak self] _, pid in + guard let purchaseHandler = self?.purchaseHandler else { return } + purchaseHandler.purchase(productId: pid) + } + let paywallVC = FeedbackPaywallViewController(viewModel: viewModel) + presentingViewController.present(paywallVC, animated: true) + } +} diff --git a/LockdowniOS/Scenes/Questionnaire/Models/StepModel.swift b/LockdowniOS/Scenes/Questionnaire/Models/StepModel.swift index 6346a08..4bbf88a 100644 --- a/LockdowniOS/Scenes/Questionnaire/Models/StepModel.swift +++ b/LockdowniOS/Scenes/Questionnaire/Models/StepModel.swift @@ -15,7 +15,7 @@ enum Steps { var actionTitle: String { switch self { case .whatsProblem: return NSLocalizedString("Next", comment: "") - case .questions: return NSLocalizedString("Email Answers", comment: "") + case .questions: return NSLocalizedString("Submit Feedback", comment: "") } } diff --git a/LockdowniOS/Scenes/Questionnaire/ViewModel/BaseStepViewModel.swift b/LockdowniOS/Scenes/Questionnaire/ViewModel/BaseStepViewModel.swift index a4c6ff8..7de60a7 100644 --- a/LockdowniOS/Scenes/Questionnaire/ViewModel/BaseStepViewModel.swift +++ b/LockdowniOS/Scenes/Questionnaire/ViewModel/BaseStepViewModel.swift @@ -52,7 +52,7 @@ class BaseStepViewModel { view.placeholderLabel.isHidden = !(text?.isEmpty ?? true) self.setupClear(cell) cell.addSubview(view) - view.anchors.edges.pin(insets: .init(top: 3, left: 2, bottom: 5, right: 2)) + view.anchors.edges.pin(insets: .init(top: 0, left: 0, bottom: 0, right: 0)) view.textDidChanged = { [weak self] text in didChangeText(text) self?.staticTableView?.beginUpdates() diff --git a/LockdowniOS/Scenes/Questionnaire/ViewModel/QuestionsStepViewModel.swift b/LockdowniOS/Scenes/Questionnaire/ViewModel/QuestionsStepViewModel.swift index f150740..ec7aa15 100644 --- a/LockdowniOS/Scenes/Questionnaire/ViewModel/QuestionsStepViewModel.swift +++ b/LockdowniOS/Scenes/Questionnaire/ViewModel/QuestionsStepViewModel.swift @@ -36,7 +36,7 @@ class QuestionsStepViewModel: BaseStepViewModel, StepViewModelProtocol { staticTableView?.clear() addTitleRow( NSLocalizedString("Questions", comment: ""), - subtitle: NSLocalizedString("Please fill questions answer", comment: ""), + subtitle: NSLocalizedString("Please answer the questions and share as much information as possible", comment: ""), bottomSpacing: 2 ) @@ -141,7 +141,7 @@ class QuestionsStepViewModel: BaseStepViewModel, StepViewModelProtocol { switcher.didSelect = didSelect self.setupClear(cell) cell.addSubview(switcher) - switcher.anchors.edges.pin(insets: .init(top: 37, left: 2, bottom: 15, right: 2)) + switcher.anchors.edges.pin(insets: .init(top: 18, left: 0, bottom: 3, right: 0)) } } @@ -151,7 +151,7 @@ class QuestionsStepViewModel: BaseStepViewModel, StepViewModelProtocol { view.titleLabel.text = title self.setupClear(cell) cell.addSubview(view) - view.anchors.edges.pin(insets: .init(top: 20, left: 2, bottom: 10, right: 2)) + view.anchors.edges.pin(insets: .init(top: 18, left: 0, bottom: 3, right: 0)) } } @@ -172,7 +172,7 @@ class QuestionsStepViewModel: BaseStepViewModel, StepViewModelProtocol { view.didSelect = perform self.setupClear(cell) cell.addSubview(view) - view.anchors.edges.pin(insets: .init(top: 20, left: 2, bottom: 10, right: 2)) + view.anchors.edges.pin(insets: .init(top: 10, left: 23, bottom: 0, right: 23)) } } } diff --git a/LockdowniOS/Scenes/Questionnaire/ViewModel/StepsViewModel.swift b/LockdowniOS/Scenes/Questionnaire/ViewModel/StepsViewModel.swift index 84ba227..76c2757 100644 --- a/LockdowniOS/Scenes/Questionnaire/ViewModel/StepsViewModel.swift +++ b/LockdowniOS/Scenes/Questionnaire/ViewModel/StepsViewModel.swift @@ -24,13 +24,15 @@ protocol StepViewModelProtocol { } class StepsViewModel { + private let isUserPremium: Bool + private lazy var steps: [StepViewModelProtocol] = [ whatProblemStep, questionsStep ] private lazy var whatProblemStep: WhatProblemStepViewModel = { - let viewModel = WhatProblemStepViewModel() { [weak self] _ in + let viewModel = WhatProblemStepViewModel(isUserPremium: isUserPremium) { [weak self] _ in self?.view?.updateNextButton() } return viewModel @@ -67,7 +69,8 @@ class StepsViewModel { steps.reduce(true) { $0 && $1.isFilled } } - init(sendMessage: ((String) -> Void)?) { + init(isUserPremium: Bool, sendMessage: ((String) -> Void)?) { + self.isUserPremium = isUserPremium self.sendMessage = sendMessage } diff --git a/LockdowniOS/Scenes/Questionnaire/ViewModel/WhatProblemStepViewModel.swift b/LockdowniOS/Scenes/Questionnaire/ViewModel/WhatProblemStepViewModel.swift index aae3e47..7f69671 100644 --- a/LockdowniOS/Scenes/Questionnaire/ViewModel/WhatProblemStepViewModel.swift +++ b/LockdowniOS/Scenes/Questionnaire/ViewModel/WhatProblemStepViewModel.swift @@ -9,6 +9,9 @@ import UIKit class WhatProblemStepViewModel: BaseStepViewModel, StepViewModelProtocol { + private let isUserPremium: Bool + let step: Steps = .whatsProblem + private let problemList = [ NSLocalizedString("Internet connection is blocked", comment: ""), NSLocalizedString("VPN not connecting", comment: ""), @@ -37,8 +40,7 @@ class WhatProblemStepViewModel: BaseStepViewModel, StepViewModelProtocol { } return nil } - - var step: Steps = .whatsProblem + var message: String? { guard selectedProblemIndex >= 0 else { return nil } var result = "" @@ -64,17 +66,35 @@ class WhatProblemStepViewModel: BaseStepViewModel, StepViewModelProtocol { var didChangeReady: ((Bool) -> Void)? - init(didChangeReady: ((Bool) -> Void)?) { + init(isUserPremium: Bool, didChangeReady: ((Bool) -> Void)?) { + self.isUserPremium = isUserPremium self.didChangeReady = didChangeReady } override func updateRows() { staticTableView?.clear() - addTitleRow( - NSLocalizedString("What problem are you experiencing?", comment: ""), - subtitle: NSLocalizedString("Select your problem", comment: "") - ) - + + staticTableView?.addRowCell { cell in + let titleView = ImageBannerWithTitleView() + titleView.imageView.image = isUserPremium ? UIImage(named: "feedback") : UIImage(named: "feedback-promo") + titleView.titleLabel.text = isUserPremium ? NSLocalizedString("How can we assist you?", comment: "") : NSLocalizedString("Get a promo Discount", comment: "") + titleView.subtitleLabel.text = isUserPremium ? + NSLocalizedString("Your feedback is valuable to us. By selecting the issue you're facing, we can guide you through troubleshooting or escalate the problem to our support team.", comment: "") : + NSLocalizedString("Let us know your opinion, and as a thank you for your feedback, we’ll have a special offer waiting for you at the end!", comment: "") + titleView.subtitleLabel.textAlignment = isUserPremium ? .left : .center + self.setupClear(cell) + cell.addSubview(titleView) + titleView.anchors.edges.pin(insets: .init(top: 0, left: 0, bottom: 30, right: 0)) + } + staticTableView?.addRowCell { cell in + let titleView = SectionTitleView() + titleView.titleLabel.text = NSLocalizedString("Select your problem", comment: "") + self.setupClear(cell) + cell.addSubview(titleView) + titleView.anchors.edges.pin(insets: .init(top: 0, left: 0, bottom: 5, right: 0)) + + } + for index in 0.. UIView { @@ -93,8 +100,8 @@ class YesNoRadioSwitcherView: UIView { view.addSubview(switcher) switcher.anchors.leading.pin() switcher.anchors.centerY.equal(view.anchors.centerY) - switcher.anchors.size.equal(.init(width: 25, height: 25)) - + switcher.anchors.size.equal(.init(width: 13, height: 13)) + view.addSubview(label) label.anchors.top.pin() label.anchors.leading.spacing(10, to: switcher.anchors.trailing) @@ -108,4 +115,12 @@ class YesNoRadioSwitcherView: UIView { yesSwitcher.isSelected = isSelected ?? false noSwitcher.isSelected = !(isSelected ?? true) } + + @objc private func tappedYes() { + yesSwitcher.toggle() + } + + @objc private func tappedNo() { + noSwitcher.toggle() + } } diff --git a/LockdowniOS/UIView+Ext.swift b/LockdowniOS/UIView+Ext.swift index 641d99b..d2ef658 100644 --- a/LockdowniOS/UIView+Ext.swift +++ b/LockdowniOS/UIView+Ext.swift @@ -17,8 +17,8 @@ extension UIView { func applyGradient(_ gradient: LockdownGradient, corners: Corners = .continuous(0)) -> CAGradientLayer { let gradientLayer = CAGradientLayer() gradientLayer.colors = gradient.colors - gradientLayer.startPoint = CGPoint(x: 0, y: 0) - gradientLayer.endPoint = CGPoint(x: 0, y: 1) + gradientLayer.startPoint = gradient.points.start + gradientLayer.endPoint = gradient.points.end gradientLayer.frame = bounds gradientLayer.corners = corners @@ -146,4 +146,24 @@ extension UIColor { alpha: CGFloat(1.0) ) } + + static func dynamicColor(light: UIColor, dark: UIColor) -> UIColor { + guard #available(iOS 13.0, *) else { return light } + return UIColor { $0.userInterfaceStyle == .dark ? dark : light } + } +} + +class GradientView: UIView { + var gradient: LockdownGradient? { + didSet { + guard let gradient else { return } + gradientLayer = applyGradient(gradient) + } + } + var gradientLayer: CALayer? + + override func layoutSubviews() { + super.layoutSubviews() + gradientLayer?.frame = bounds + } } diff --git a/LockdowniOS/VPNSubscription.swift b/LockdowniOS/VPNSubscription.swift index 11b631a..09626b6 100644 --- a/LockdowniOS/VPNSubscription.swift +++ b/LockdowniOS/VPNSubscription.swift @@ -25,6 +25,18 @@ struct OnetTimeProducts { } } +struct FeedbackProducts { + let weekly: String + let yearly: String + + func toList() -> [String] { + let otherSelf = Mirror(reflecting: self) + return otherSelf.children.compactMap { + $0.value as? String + } + } +} + struct InternalSubscription: Hashable { let productId: String let period: SKProduct.PeriodUnit @@ -72,8 +84,10 @@ actor VPNSubscription: NSObject { weeklyTrial: "lockdown.weekly.1.202408.3_days_free_trial.4hrs_offer", yearly: "lockdown.yearly.40.202408.no_trial.4hrs_offer_", yearlyTrial: "lockdown.yearly.40.202408.3_days_free_trial.4hrs_offer") + static let feedbackProducts = FeedbackProducts(weekly: "lockdown.weekly.1.202409.no_trial.feedback", + yearly: "lockdown.yearly.40.202409.no_trial.feedback") static var selectedProductId = productIdAdvancedMonthly - + // Advanced Level static var defaultPriceStringAdvancedMonthly = "$4.99" static var defaultPriceStringAdvancedYearly = "$29.99"