From 6d7f95cd52c3127ddeb0551a37505431fe2ca06b Mon Sep 17 00:00:00 2001 From: Daniele Bogo Date: Tue, 26 Nov 2024 14:21:38 +0000 Subject: [PATCH 1/7] Add new plans view --- podcasts.xcodeproj/project.pbxproj | 24 ++++++++++++++++-- .../CancelSubscriptionView.swift | 0 .../CancelSubscriptionViewRow.swift | 0 .../CancelSubscriptionPlansView.swift | 25 +++++++++++++++++++ .../CancelSubscriptionViewModel.swift | 5 +++- podcasts/IAPHelper.swift | 10 ++++---- 6 files changed, 56 insertions(+), 8 deletions(-) rename podcasts/Cancel Subscription/{ => Cancel Subscription}/CancelSubscriptionView.swift (100%) rename podcasts/Cancel Subscription/{ => Cancel Subscription}/CancelSubscriptionViewRow.swift (100%) create mode 100644 podcasts/Cancel Subscription/Cancel Subscription/Plans View/CancelSubscriptionPlansView.swift diff --git a/podcasts.xcodeproj/project.pbxproj b/podcasts.xcodeproj/project.pbxproj index 5c3dba45a8..1484479fa4 100644 --- a/podcasts.xcodeproj/project.pbxproj +++ b/podcasts.xcodeproj/project.pbxproj @@ -671,6 +671,7 @@ 8BF9CC0C2ADDA90F004E9B65 /* YearOverYearStory2023.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF9CC0B2ADDA90F004E9B65 /* YearOverYearStory2023.swift */; }; 8BFB434E2A1FFB4B00F3D409 /* StatusPageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BFB434D2A1FFB4B00F3D409 /* StatusPageViewModel.swift */; }; 91319B0B2C171F69000220A4 /* GravatarSafariViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91319B0A2C171F69000220A4 /* GravatarSafariViewController.swift */; }; + 9A156AAD2CF60973007BA8D9 /* CancelSubscriptionPlansView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A156AAC2CF60973007BA8D9 /* CancelSubscriptionPlansView.swift */; }; BD001B892174260B00504DD3 /* FilterManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD001B882174260B00504DD3 /* FilterManager.swift */; }; BD00CB2B24BD20CD00A10257 /* TimeStepperCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD00CB2924BD20CD00A10257 /* TimeStepperCell.swift */; }; BD00CB2C24BD20CD00A10257 /* TimeStepperCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = BD00CB2A24BD20CD00A10257 /* TimeStepperCell.xib */; }; @@ -2641,6 +2642,7 @@ 8BF9CC0B2ADDA90F004E9B65 /* YearOverYearStory2023.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YearOverYearStory2023.swift; sourceTree = ""; }; 8BFB434D2A1FFB4B00F3D409 /* StatusPageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPageViewModel.swift; sourceTree = ""; }; 91319B0A2C171F69000220A4 /* GravatarSafariViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GravatarSafariViewController.swift; sourceTree = ""; }; + 9A156AAC2CF60973007BA8D9 /* CancelSubscriptionPlansView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancelSubscriptionPlansView.swift; sourceTree = ""; }; 9A9517324EFFD2C905890193 /* Pods-podcasts.appstore.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-podcasts.appstore.xcconfig"; path = "Pods/Target Support Files/Pods-podcasts/Pods-podcasts.appstore.xcconfig"; sourceTree = ""; }; 9E6FEF10644B78313185D080 /* Pods-PocketCastsTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-PocketCastsTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-PocketCastsTests/Pods-PocketCastsTests.release.xcconfig"; sourceTree = ""; }; A251FE0E92E432C4EF0C8207 /* Pods-PocketCastsTests.staging.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-PocketCastsTests.staging.xcconfig"; path = "Pods/Target Support Files/Pods-PocketCastsTests/Pods-PocketCastsTests.staging.xcconfig"; sourceTree = ""; }; @@ -3910,8 +3912,7 @@ children = ( 1035FFEC2CEE5F4E00C1E80D /* CancelSubscriptionOption.swift */, 109446752CEB3A7E00977161 /* CancelSubscriptionViewModel.swift */, - 100C99542CE7B801008D73E9 /* CancelSubscriptionView.swift */, - 102864A82CECEDB20098B2A9 /* CancelSubscriptionViewRow.swift */, + 9A156AAE2CF61016007BA8D9 /* Cancel Subscription */, ); path = "Cancel Subscription"; sourceTree = ""; @@ -5032,6 +5033,24 @@ path = Folders; sourceTree = ""; }; + 9A156AAE2CF61016007BA8D9 /* Cancel Subscription */ = { + isa = PBXGroup; + children = ( + 100C99542CE7B801008D73E9 /* CancelSubscriptionView.swift */, + 102864A82CECEDB20098B2A9 /* CancelSubscriptionViewRow.swift */, + 9A156AAF2CF61025007BA8D9 /* Plans View */, + ); + path = "Cancel Subscription"; + sourceTree = ""; + }; + 9A156AAF2CF61025007BA8D9 /* Plans View */ = { + isa = PBXGroup; + children = ( + 9A156AAC2CF60973007BA8D9 /* CancelSubscriptionPlansView.swift */, + ); + path = "Plans View"; + sourceTree = ""; + }; BD03B383173A1D68000A419B /* Video Player */ = { isa = PBXGroup; children = ( @@ -10197,6 +10216,7 @@ FF91A0FC2B6BC1D2002A0590 /* UpgradePrompt.swift in Sources */, BDCCBC8824BC444F009B4D1D /* CustomTimeStepper.swift in Sources */, BDFB53CB2362B9080001806E /* UpNextViewController.swift in Sources */, + 9A156AAD2CF60973007BA8D9 /* CancelSubscriptionPlansView.swift in Sources */, C7B3C60C2919DCC800054145 /* ActionView.swift in Sources */, BD240C3F231E8BE000FB2CDD /* PCSearchBarController.swift in Sources */, C7DC40242A67054000883D03 /* ToastTheme.swift in Sources */, diff --git a/podcasts/Cancel Subscription/CancelSubscriptionView.swift b/podcasts/Cancel Subscription/Cancel Subscription/CancelSubscriptionView.swift similarity index 100% rename from podcasts/Cancel Subscription/CancelSubscriptionView.swift rename to podcasts/Cancel Subscription/Cancel Subscription/CancelSubscriptionView.swift diff --git a/podcasts/Cancel Subscription/CancelSubscriptionViewRow.swift b/podcasts/Cancel Subscription/Cancel Subscription/CancelSubscriptionViewRow.swift similarity index 100% rename from podcasts/Cancel Subscription/CancelSubscriptionViewRow.swift rename to podcasts/Cancel Subscription/Cancel Subscription/CancelSubscriptionViewRow.swift diff --git a/podcasts/Cancel Subscription/Cancel Subscription/Plans View/CancelSubscriptionPlansView.swift b/podcasts/Cancel Subscription/Cancel Subscription/Plans View/CancelSubscriptionPlansView.swift new file mode 100644 index 0000000000..f3abb213bc --- /dev/null +++ b/podcasts/Cancel Subscription/Cancel Subscription/Plans View/CancelSubscriptionPlansView.swift @@ -0,0 +1,25 @@ +import SwiftUI + +struct CancelSubscriptionPlansView: View { + @EnvironmentObject var theme: Theme + + @ObservedObject var viewModel: CancelSubscriptionViewModel + + init(viewModel: CancelSubscriptionViewModel) { + self.viewModel = viewModel + } + + var body: some View { + Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + .onAppear { + viewModel.pricingInfo.products.forEach { + print($0.id) + } + } + } +} + +#Preview { + CancelSubscriptionPlansView(viewModel: CancelSubscriptionViewModel(navigationController: UINavigationController())) + .environmentObject(Theme.sharedTheme) +} diff --git a/podcasts/Cancel Subscription/CancelSubscriptionViewModel.swift b/podcasts/Cancel Subscription/CancelSubscriptionViewModel.swift index b75e0b56f3..a6706e62cf 100644 --- a/podcasts/Cancel Subscription/CancelSubscriptionViewModel.swift +++ b/podcasts/Cancel Subscription/CancelSubscriptionViewModel.swift @@ -37,7 +37,10 @@ class CancelSubscriptionViewModel: PlusPurchaseModel { } func showPlans() { - //TODO: Show plans + let view = CancelSubscriptionPlansView(viewModel: self) + let controller = OnboardingHostingViewController(rootView: view) + controller.navBarIsHidden = true + navigationController.pushViewController(controller, animated: true) } func showHelp() { diff --git a/podcasts/IAPHelper.swift b/podcasts/IAPHelper.swift index 06ec7d060e..1e8cb26f38 100644 --- a/podcasts/IAPHelper.swift +++ b/podcasts/IAPHelper.swift @@ -84,7 +84,7 @@ class IAPHelper: NSObject { return nil } - func findLastSubscriptionPurchased() async -> [StoreKit.Transaction] { + func findLastSubscriptionsPurchased() async -> [StoreKit.Transaction] { var transactions: [StoreKit.Transaction] = [] for await result in Transaction.currentEntitlements { guard case .verified(let transaction) = result else { @@ -97,8 +97,8 @@ class IAPHelper: NSObject { return transactions } - func findLastSubscriptionPurchasedGroupID() async -> String? { - return await findLastSubscriptionPurchased() + func findLastSubscriptionPurchased() async -> StoreKit.Transaction? { + return await findLastSubscriptionsPurchased() .filter { $0.expirationDate != nil } .sorted { if let t0 = $0.expirationDate, let t1 = $1.expirationDate { @@ -106,11 +106,11 @@ class IAPHelper: NSObject { } return false } - .first?.subscriptionGroupID + .first } func showManageSubscriptions(in windowScene: UIWindowScene) async throws { - if let groupID = await findLastSubscriptionPurchasedGroupID(), #available(iOS 17.0, *) { + if let groupID = await findLastSubscriptionPurchased()?.subscriptionGroupID, #available(iOS 17.0, *) { FileLog.shared.console("[CancelConfirmationViewModel] Last subscription purchased group ID: \(groupID)") try await StoreKit.AppStore.showManageSubscriptions(in: windowScene, subscriptionGroupID: groupID) From 4440ea19576e6f0b4ee4b805e17f47ab9fa600df Mon Sep 17 00:00:00 2001 From: Daniele Bogo Date: Thu, 28 Nov 2024 15:07:44 +0000 Subject: [PATCH 2/7] Prepare view model and view to load products --- .../CancelSubscriptionView.swift | 1 + .../CancelSubscriptionPlansView.swift | 18 ++++-- .../CancelSubscriptionViewModel.swift | 64 ++++++++++++++----- 3 files changed, 63 insertions(+), 20 deletions(-) diff --git a/podcasts/Cancel Subscription/Cancel Subscription/CancelSubscriptionView.swift b/podcasts/Cancel Subscription/Cancel Subscription/CancelSubscriptionView.swift index 37d8e8e7d9..97a9dcf06d 100644 --- a/podcasts/Cancel Subscription/Cancel Subscription/CancelSubscriptionView.swift +++ b/podcasts/Cancel Subscription/Cancel Subscription/CancelSubscriptionView.swift @@ -50,6 +50,7 @@ struct CancelSubscriptionView: View { .padding(.bottom, 58.0) case .loading, .unknown: ProgressView() + .foregroundStyle(theme.primaryUi01) case .failed: Text(L10n.cancelSubscriptionGenericError) .font(size: 18.0, style: .body, weight: .bold) diff --git a/podcasts/Cancel Subscription/Cancel Subscription/Plans View/CancelSubscriptionPlansView.swift b/podcasts/Cancel Subscription/Cancel Subscription/Plans View/CancelSubscriptionPlansView.swift index f3abb213bc..2d00d87c8f 100644 --- a/podcasts/Cancel Subscription/Cancel Subscription/Plans View/CancelSubscriptionPlansView.swift +++ b/podcasts/Cancel Subscription/Cancel Subscription/Plans View/CancelSubscriptionPlansView.swift @@ -10,11 +10,19 @@ struct CancelSubscriptionPlansView: View { } var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) - .onAppear { - viewModel.pricingInfo.products.forEach { - print($0.id) - } + VStack(spacing: 0) { + switch viewModel.currentProductAvailability { + case .loading: + ProgressView() + .foregroundStyle(theme.primaryUi01) + default: + Text(viewModel.currentPricingProduct?.id ?? "No product") + } + } + .onAppear { + Task { + await viewModel.loadCurrentProduct() + } } } } diff --git a/podcasts/Cancel Subscription/CancelSubscriptionViewModel.swift b/podcasts/Cancel Subscription/CancelSubscriptionViewModel.swift index a6706e62cf..cd7b24bd16 100644 --- a/podcasts/Cancel Subscription/CancelSubscriptionViewModel.swift +++ b/podcasts/Cancel Subscription/CancelSubscriptionViewModel.swift @@ -1,5 +1,6 @@ import SwiftUI import PocketCastsServer +import PocketCastsUtils class CancelSubscriptionViewModel: PlusPurchaseModel { let navigationController: UINavigationController @@ -8,6 +9,9 @@ class CancelSubscriptionViewModel: PlusPurchaseModel { purchaseHandler.isEligibleForOffer } + @Published var currentPricingProduct: PlusPricingInfoModel.PlusProductPricingInfo? + @State var currentProductAvailability: CurrentProductAvailability = .idle + init(purchaseHandler: IAPHelper = .shared, navigationController: UINavigationController) { self.navigationController = navigationController @@ -16,6 +20,27 @@ class CancelSubscriptionViewModel: PlusPurchaseModel { self.loadPrices() } + override func didAppear() { + //TODO: Implement analytics + } + + override func didDismiss(type: OnboardingDismissType) { + // Since the view can only be dismissed via swipe, only check for that + guard type == .swipe else { return } + + //TODO: Implement analytics + } + + enum CurrentProductAvailability { + case idle + case loading + case available + case unavailable + } +} + +// IAP +extension CancelSubscriptionViewModel { func monthlyPrice() -> String? { switch SubscriptionHelper.activeTier { case .plus: @@ -27,17 +52,36 @@ class CancelSubscriptionViewModel: PlusPurchaseModel { } } - func cancelSubscriptionTap() { - let viewController = CancelConfirmationViewModel.make(in: navigationController) - navigationController.pushViewController(viewController, animated: true) + func loadCurrentProduct() async { + if currentProductAvailability == .loading { return } + + currentProductAvailability = .loading + if let transaction = await purchaseHandler.findLastSubscriptionPurchased(), + let productID = IAPProductID(rawValue: transaction.productID) { + await MainActor.run { + currentProductAvailability = .available + currentPricingProduct = pricingInfo.products.first { $0.identifier == productID } + } + } else { + currentProductAvailability = .unavailable + FileLog.shared.console("[CancelSubscriptionViewModel] Could not find last subscription purchased") + } } func claimOffer() { //TODO: Apply one month free } +} + +// Navigation +extension CancelSubscriptionViewModel { + func cancelSubscriptionTap() { + let viewController = CancelConfirmationViewModel.make(in: navigationController) + navigationController.pushViewController(viewController, animated: true) + } func showPlans() { - let view = CancelSubscriptionPlansView(viewModel: self) + let view = CancelSubscriptionPlansView(viewModel: self).setupDefaultEnvironment() let controller = OnboardingHostingViewController(rootView: view) controller.navBarIsHidden = true navigationController.pushViewController(controller, animated: true) @@ -48,19 +92,9 @@ class CancelSubscriptionViewModel: PlusPurchaseModel { navigationController.navigationBar.isHidden = false navigationController.pushViewController(controller, animated: true) } - - override func didAppear() { - //TODO: Implement analytics - } - - override func didDismiss(type: OnboardingDismissType) { - // Since the view can only be dismissed via swipe, only check for that - guard type == .swipe else { return } - - //TODO: Implement analytics - } } +// Making vew controller extension CancelSubscriptionViewModel { /// Make the view, and allow it to be shown by itself or within another navigation flow static func make() -> UIViewController { From 2185e3af21d68e6db76e746f80d6cfbb9f2b1454 Mon Sep 17 00:00:00 2001 From: Daniele Bogo Date: Fri, 29 Nov 2024 11:40:25 +0000 Subject: [PATCH 3/7] Working on rows --- podcasts.xcodeproj/project.pbxproj | 4 ++ .../CancelSubscriptionPlanRow.swift | 58 +++++++++++++++++++ .../CancelSubscriptionPlansView.swift | 9 ++- .../CancelSubscriptionViewModel.swift | 6 ++ podcasts/IAPHelper.swift | 7 +-- 5 files changed, 77 insertions(+), 7 deletions(-) create mode 100644 podcasts/Cancel Subscription/Cancel Subscription/Plans View/CancelSubscriptionPlanRow.swift diff --git a/podcasts.xcodeproj/project.pbxproj b/podcasts.xcodeproj/project.pbxproj index 1484479fa4..9773d08cfc 100644 --- a/podcasts.xcodeproj/project.pbxproj +++ b/podcasts.xcodeproj/project.pbxproj @@ -672,6 +672,7 @@ 8BFB434E2A1FFB4B00F3D409 /* StatusPageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BFB434D2A1FFB4B00F3D409 /* StatusPageViewModel.swift */; }; 91319B0B2C171F69000220A4 /* GravatarSafariViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91319B0A2C171F69000220A4 /* GravatarSafariViewController.swift */; }; 9A156AAD2CF60973007BA8D9 /* CancelSubscriptionPlansView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A156AAC2CF60973007BA8D9 /* CancelSubscriptionPlansView.swift */; }; + 9A156AB32CF8BEE3007BA8D9 /* CancelSubscriptionPlanRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A156AB22CF8BEE3007BA8D9 /* CancelSubscriptionPlanRow.swift */; }; BD001B892174260B00504DD3 /* FilterManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD001B882174260B00504DD3 /* FilterManager.swift */; }; BD00CB2B24BD20CD00A10257 /* TimeStepperCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD00CB2924BD20CD00A10257 /* TimeStepperCell.swift */; }; BD00CB2C24BD20CD00A10257 /* TimeStepperCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = BD00CB2A24BD20CD00A10257 /* TimeStepperCell.xib */; }; @@ -2643,6 +2644,7 @@ 8BFB434D2A1FFB4B00F3D409 /* StatusPageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPageViewModel.swift; sourceTree = ""; }; 91319B0A2C171F69000220A4 /* GravatarSafariViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GravatarSafariViewController.swift; sourceTree = ""; }; 9A156AAC2CF60973007BA8D9 /* CancelSubscriptionPlansView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancelSubscriptionPlansView.swift; sourceTree = ""; }; + 9A156AB22CF8BEE3007BA8D9 /* CancelSubscriptionPlanRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancelSubscriptionPlanRow.swift; sourceTree = ""; }; 9A9517324EFFD2C905890193 /* Pods-podcasts.appstore.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-podcasts.appstore.xcconfig"; path = "Pods/Target Support Files/Pods-podcasts/Pods-podcasts.appstore.xcconfig"; sourceTree = ""; }; 9E6FEF10644B78313185D080 /* Pods-PocketCastsTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-PocketCastsTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-PocketCastsTests/Pods-PocketCastsTests.release.xcconfig"; sourceTree = ""; }; A251FE0E92E432C4EF0C8207 /* Pods-PocketCastsTests.staging.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-PocketCastsTests.staging.xcconfig"; path = "Pods/Target Support Files/Pods-PocketCastsTests/Pods-PocketCastsTests.staging.xcconfig"; sourceTree = ""; }; @@ -5047,6 +5049,7 @@ isa = PBXGroup; children = ( 9A156AAC2CF60973007BA8D9 /* CancelSubscriptionPlansView.swift */, + 9A156AB22CF8BEE3007BA8D9 /* CancelSubscriptionPlanRow.swift */, ); path = "Plans View"; sourceTree = ""; @@ -9774,6 +9777,7 @@ 10756F8B2C5945C00089D34F /* KidsProfileSubmitScreen.swift in Sources */, C7080C5F29233BA000D7A432 /* PlusAccountPromptViewModel.swift in Sources */, 40FFAD8A2147831400024FCF /* PlaylistViewController+CollectionView.swift in Sources */, + 9A156AB32CF8BEE3007BA8D9 /* CancelSubscriptionPlanRow.swift in Sources */, BDA1E8161EDD28700098FB9D /* SonosLinkController.swift in Sources */, BD79EACD20D3805900E96572 /* ThemeSecondaryButton.swift in Sources */, F52B4F8E2BB4A9ED00E87BE4 /* CategoriesSelectorView.swift in Sources */, diff --git a/podcasts/Cancel Subscription/Cancel Subscription/Plans View/CancelSubscriptionPlanRow.swift b/podcasts/Cancel Subscription/Cancel Subscription/Plans View/CancelSubscriptionPlanRow.swift new file mode 100644 index 0000000000..f7eddcee61 --- /dev/null +++ b/podcasts/Cancel Subscription/Cancel Subscription/Plans View/CancelSubscriptionPlanRow.swift @@ -0,0 +1,58 @@ +import SwiftUI + +struct CancelSubscriptionPlanRow: View { + @EnvironmentObject var theme: Theme + + let product: PlusPricingInfoModel.PlusProductPricingInfo + var selected: Bool + let onTap: (PlusPricingInfoModel.PlusProductPricingInfo) -> Void + + var body: some View { + ZStack { + Rectangle() + .foregroundStyle(.clear) + .background(theme.primaryUi01) + .cornerRadius(8.0) + .frame(height: 64) + VStack(alignment: .leading, spacing: 0) { + Text(product.identifier.rawValue) + } + } + .overlay( + RoundedRectangle(cornerRadius: 8.0) + .stroke(theme.primaryField03Active, + lineWidth: selected ? 2 : 0) + ) + .padding(.horizontal, 20.0) + .onTapGesture { + onTap(product) + } + } +} + +struct CancelSubscriptionPlanRow_Preview: PreviewProvider { + static var previews: some View { + VStack(spacing: 16.0) { + CancelSubscriptionPlanRow( + product: .init( + identifier: .yearly, + price: "", + rawPrice: "$39.99", + offer: nil), + selected: true + ) { _ in } + .environmentObject(Theme.sharedTheme) + CancelSubscriptionPlanRow( + product: .init( + identifier: .monthly, + price: "", + rawPrice: "$3.99", + offer: nil), + selected: false + ) { _ in } + .environmentObject(Theme.sharedTheme) + } + .background(.gray) + .previewLayout(.fixed(width: 393, height: 250)) + } +} diff --git a/podcasts/Cancel Subscription/Cancel Subscription/Plans View/CancelSubscriptionPlansView.swift b/podcasts/Cancel Subscription/Cancel Subscription/Plans View/CancelSubscriptionPlansView.swift index 2d00d87c8f..7dfe111484 100644 --- a/podcasts/Cancel Subscription/Cancel Subscription/Plans View/CancelSubscriptionPlansView.swift +++ b/podcasts/Cancel Subscription/Cancel Subscription/Plans View/CancelSubscriptionPlansView.swift @@ -10,13 +10,18 @@ struct CancelSubscriptionPlansView: View { } var body: some View { - VStack(spacing: 0) { + VStack(spacing: 16.0) { switch viewModel.currentProductAvailability { case .loading: ProgressView() .foregroundStyle(theme.primaryUi01) default: - Text(viewModel.currentPricingProduct?.id ?? "No product") + ForEach(viewModel.pricingInfo.products, id: \.id) { product in + CancelSubscriptionPlanRow(product: product, + selected: product.identifier == viewModel.currentPricingProduct?.identifier) { selectedProduct in + viewModel.purchase(product: selectedProduct) + } + } } } .onAppear { diff --git a/podcasts/Cancel Subscription/CancelSubscriptionViewModel.swift b/podcasts/Cancel Subscription/CancelSubscriptionViewModel.swift index cd7b24bd16..00e8344cdb 100644 --- a/podcasts/Cancel Subscription/CancelSubscriptionViewModel.swift +++ b/podcasts/Cancel Subscription/CancelSubscriptionViewModel.swift @@ -68,6 +68,11 @@ extension CancelSubscriptionViewModel { } } + func purchase(product: PlusPricingInfoModel.PlusProductPricingInfo) { + currentPricingProduct = product + purchase(product: product.identifier) + } + func claimOffer() { //TODO: Apply one month free } @@ -101,6 +106,7 @@ extension CancelSubscriptionViewModel { // If we're not being presented within another nav controller then wrap ourselves in one let navController = UINavigationController() let viewModel = CancelSubscriptionViewModel(navigationController: navController) + viewModel.parentController = navController // Wrap the SwiftUI view in the hosting view controller let swiftUIView = CancelSubscriptionView(viewModel: viewModel).setupDefaultEnvironment() diff --git a/podcasts/IAPHelper.swift b/podcasts/IAPHelper.swift index 1e8cb26f38..7d52857e5e 100644 --- a/podcasts/IAPHelper.swift +++ b/podcasts/IAPHelper.swift @@ -101,12 +101,9 @@ class IAPHelper: NSObject { return await findLastSubscriptionsPurchased() .filter { $0.expirationDate != nil } .sorted { - if let t0 = $0.expirationDate, let t1 = $1.expirationDate { - return t0 > t1 - } - return false + return $0.purchaseDate < $1.purchaseDate } - .first + .last } func showManageSubscriptions(in windowScene: UIWindowScene) async throws { From 4dbdf9385a848bd559b779e25d799f307bbc86cd Mon Sep 17 00:00:00 2001 From: Daniele Bogo Date: Mon, 2 Dec 2024 11:01:26 +0000 Subject: [PATCH 4/7] Complete plans layout --- .../CancelSubscriptionPlanRow.swift | 50 +++++++++++++++++- .../CancelSubscriptionPlansView.swift | 52 ++++++++++++++++--- .../CancelSubscriptionViewModel.swift | 8 +++ .../cs-app-icon.imageset/Contents.json | 12 +++++ .../cs-app-icon.imageset/pc-app-icon.svg | 4 ++ podcasts/Strings+Generated.swift | 2 + podcasts/en.lproj/Localizable.strings | 3 ++ 7 files changed, 123 insertions(+), 8 deletions(-) create mode 100644 podcasts/CancelSubscription.xcassets/cs-app-icon.imageset/Contents.json create mode 100644 podcasts/CancelSubscription.xcassets/cs-app-icon.imageset/pc-app-icon.svg diff --git a/podcasts/Cancel Subscription/Cancel Subscription/Plans View/CancelSubscriptionPlanRow.swift b/podcasts/Cancel Subscription/Cancel Subscription/Plans View/CancelSubscriptionPlanRow.swift index f7eddcee61..672021ea52 100644 --- a/podcasts/Cancel Subscription/Cancel Subscription/Plans View/CancelSubscriptionPlanRow.swift +++ b/podcasts/Cancel Subscription/Cancel Subscription/Plans View/CancelSubscriptionPlanRow.swift @@ -15,8 +15,32 @@ struct CancelSubscriptionPlanRow: View { .cornerRadius(8.0) .frame(height: 64) VStack(alignment: .leading, spacing: 0) { - Text(product.identifier.rawValue) + HStack(spacing: 0) { + Text(product.planTitle) + .font(size: 18.0, style: .body, weight: .bold) + .foregroundStyle(theme.primaryText01) + Spacer() + if selected { + ZStack { + Circle() + .fill(theme.primaryField03Active) + .frame(width: 24, height: 24) + Image("small-tick") + .resizable() + .frame(width: 24, height: 24) + .foregroundColor(theme.primaryInteractive02) + } + } + } + HStack(spacing: 0) { + Text(product.frequencyPrice) + .font(size: 15.0, style: .body, weight: .regular) + .foregroundStyle(theme.primaryText01) + Spacer() + } } + .padding(.leading, 20.0) + .padding(.trailing, 10.0) } .overlay( RoundedRectangle(cornerRadius: 8.0) @@ -30,6 +54,30 @@ struct CancelSubscriptionPlanRow: View { } } +extension PlusPricingInfoModel.PlusProductPricingInfo { + fileprivate var planTitle: String { + switch identifier { + case .yearly, .yearlyReferral: + return "Plus \(L10n.yearly.capitalized)" + case .monthly: + return "Plus \(L10n.monthly.capitalized)" + case .patronMonthly: + return "Patron \(L10n.monthly.capitalized)" + case .patronYearly: + return "Patron \(L10n.yearly.capitalized)" + } + } + + fileprivate var frequencyPrice: String { + switch identifier { + case .yearly, .yearlyReferral, .patronYearly: + return L10n.plusYearlyFrequencyPricingFormat(rawPrice) + case .monthly, .patronMonthly: + return L10n.plusMonthlyFrequencyPricingFormat(rawPrice) + } + } +} + struct CancelSubscriptionPlanRow_Preview: PreviewProvider { static var previews: some View { VStack(spacing: 16.0) { diff --git a/podcasts/Cancel Subscription/Cancel Subscription/Plans View/CancelSubscriptionPlansView.swift b/podcasts/Cancel Subscription/Cancel Subscription/Plans View/CancelSubscriptionPlansView.swift index 7dfe111484..7000843d55 100644 --- a/podcasts/Cancel Subscription/Cancel Subscription/Plans View/CancelSubscriptionPlansView.swift +++ b/podcasts/Cancel Subscription/Cancel Subscription/Plans View/CancelSubscriptionPlansView.swift @@ -10,18 +10,14 @@ struct CancelSubscriptionPlansView: View { } var body: some View { - VStack(spacing: 16.0) { + ZStack { switch viewModel.currentProductAvailability { case .loading: ProgressView() .foregroundStyle(theme.primaryUi01) default: - ForEach(viewModel.pricingInfo.products, id: \.id) { product in - CancelSubscriptionPlanRow(product: product, - selected: product.identifier == viewModel.currentPricingProduct?.identifier) { selectedProduct in - viewModel.purchase(product: selectedProduct) - } - } + closeButton + mainView } } .onAppear { @@ -29,6 +25,48 @@ struct CancelSubscriptionPlansView: View { await viewModel.loadCurrentProduct() } } + .background(theme.primaryUi04) + } + + var closeButton: some View { + VStack { + HStack { + Spacer() + Button(action: { + viewModel.closePlans() + }) { + Image(systemName: "xmark") + .font(.system(size: 14).weight(.bold)) + .frame(width: 30, height: 30) + .foregroundColor(theme.primaryIcon02Active) + .background(theme.primaryUi05) + .clipShape(Circle()) + } + } + .padding(.trailing, 16.0) + .padding(.top, 16.0) + Spacer() + } + } + + var mainView: some View { + VStack(spacing: 16.0) { + Image("cs-app-icon") + .frame(width: 100.0, height: 100.0) + .padding(.top, 88.0) + .padding(.bottom, 20.0) + Text(L10n.cancelSubscriptionAvailablePlansTitle) + .font(size: 28.0, style: .body, weight: .bold) + .foregroundStyle(theme.primaryText01) + .padding(.bottom, 24.0) + ForEach(viewModel.pricingInfo.products, id: \.id) { product in + CancelSubscriptionPlanRow(product: product, + selected: product.identifier == viewModel.currentPricingProduct?.identifier) { selectedProduct in + viewModel.purchase(product: selectedProduct) + } + } + Spacer() + } } } diff --git a/podcasts/Cancel Subscription/CancelSubscriptionViewModel.swift b/podcasts/Cancel Subscription/CancelSubscriptionViewModel.swift index 00e8344cdb..1471f3800d 100644 --- a/podcasts/Cancel Subscription/CancelSubscriptionViewModel.swift +++ b/podcasts/Cancel Subscription/CancelSubscriptionViewModel.swift @@ -81,11 +81,13 @@ extension CancelSubscriptionViewModel { // Navigation extension CancelSubscriptionViewModel { func cancelSubscriptionTap() { + //TODO: Implement analytics let viewController = CancelConfirmationViewModel.make(in: navigationController) navigationController.pushViewController(viewController, animated: true) } func showPlans() { + //TODO: Implement analytics let view = CancelSubscriptionPlansView(viewModel: self).setupDefaultEnvironment() let controller = OnboardingHostingViewController(rootView: view) controller.navBarIsHidden = true @@ -93,10 +95,16 @@ extension CancelSubscriptionViewModel { } func showHelp() { + //TODO: Implement analytics let controller = OnlineSupportController() navigationController.navigationBar.isHidden = false navigationController.pushViewController(controller, animated: true) } + + func closePlans() { + //TODO: Implement analytics + navigationController.dismiss(animated: true) + } } // Making vew controller diff --git a/podcasts/CancelSubscription.xcassets/cs-app-icon.imageset/Contents.json b/podcasts/CancelSubscription.xcassets/cs-app-icon.imageset/Contents.json new file mode 100644 index 0000000000..e70b31bb2b --- /dev/null +++ b/podcasts/CancelSubscription.xcassets/cs-app-icon.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "pc-app-icon.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/podcasts/CancelSubscription.xcassets/cs-app-icon.imageset/pc-app-icon.svg b/podcasts/CancelSubscription.xcassets/cs-app-icon.imageset/pc-app-icon.svg new file mode 100644 index 0000000000..6c882d875a --- /dev/null +++ b/podcasts/CancelSubscription.xcassets/cs-app-icon.imageset/pc-app-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/podcasts/Strings+Generated.swift b/podcasts/Strings+Generated.swift index 3f55834e35..19277c036b 100644 --- a/podcasts/Strings+Generated.swift +++ b/podcasts/Strings+Generated.swift @@ -386,6 +386,8 @@ internal enum L10n { internal static var cancelFailed: String { return L10n.tr("Localizable", "cancel_failed") } /// Cancel Subscription internal static var cancelSubscription: String { return L10n.tr("Localizable", "cancel_subscription") } + /// Available Plans + internal static var cancelSubscriptionAvailablePlansTitle: String { return L10n.tr("Localizable", "cancel_subscription_available_plans_title") } /// Claim offer internal static var cancelSubscriptionClaimOfferButton: String { return L10n.tr("Localizable", "cancel_subscription_claim_offer_button") } /// Continue to Cancellation diff --git a/podcasts/en.lproj/Localizable.strings b/podcasts/en.lproj/Localizable.strings index 4bde852ede..93591a96f0 100644 --- a/podcasts/en.lproj/Localizable.strings +++ b/podcasts/en.lproj/Localizable.strings @@ -4684,6 +4684,9 @@ /* Title for the claim offer button */ "cancel_subscription_claim_offer_button" = "Claim offer"; +/* Title of the Available Plans screen accessible from the Cancel Subscription view */ +"cancel_subscription_available_plans_title" = "Available Plans"; + /* Referrals - Share Pass message. `%1$@' is a placeholder for the duration of free period offered on the Plus subscription*/ "referrals_share_pass_long_message" = "Hi there!\n\nHere is a %1$@ guest pass for Pocket Casts Plus–my favorite podcast player. It's packed with unique features like bookmarks, folders, and more that you won't find anywhere else. I think you'll love it too!\n"; From e63d79aa22b80f1835fc57f946738372a03670e5 Mon Sep 17 00:00:00 2001 From: Daniele Bogo Date: Mon, 2 Dec 2024 11:22:00 +0000 Subject: [PATCH 5/7] Add loading --- .../CancelSubscriptionPlansView.swift | 22 +++++++++++++++++-- .../CancelSubscriptionViewModel.swift | 1 + 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/podcasts/Cancel Subscription/Cancel Subscription/Plans View/CancelSubscriptionPlansView.swift b/podcasts/Cancel Subscription/Cancel Subscription/Plans View/CancelSubscriptionPlansView.swift index 7000843d55..9805fd1ea8 100644 --- a/podcasts/Cancel Subscription/Cancel Subscription/Plans View/CancelSubscriptionPlansView.swift +++ b/podcasts/Cancel Subscription/Cancel Subscription/Plans View/CancelSubscriptionPlansView.swift @@ -13,11 +13,13 @@ struct CancelSubscriptionPlansView: View { ZStack { switch viewModel.currentProductAvailability { case .loading: - ProgressView() - .foregroundStyle(theme.primaryUi01) + showLoading() default: closeButton mainView + if viewModel.state == .purchasing { + showLoading(fullScreen: true) + } } } .onAppear { @@ -68,6 +70,22 @@ struct CancelSubscriptionPlansView: View { Spacer() } } + + @ViewBuilder + func showLoading(fullScreen: Bool = false) -> some View { + if fullScreen { + ZStack { + theme.primaryUi05Selected + .edgesIgnoringSafeArea(.all) + .opacity(0.7) + ProgressView() + .tint(theme.primaryText01) + } + } else { + ProgressView() + .tint(theme.primaryUi01) + } + } } #Preview { diff --git a/podcasts/Cancel Subscription/CancelSubscriptionViewModel.swift b/podcasts/Cancel Subscription/CancelSubscriptionViewModel.swift index 1471f3800d..41813fa2af 100644 --- a/podcasts/Cancel Subscription/CancelSubscriptionViewModel.swift +++ b/podcasts/Cancel Subscription/CancelSubscriptionViewModel.swift @@ -69,6 +69,7 @@ extension CancelSubscriptionViewModel { } func purchase(product: PlusPricingInfoModel.PlusProductPricingInfo) { + guard currentPricingProduct?.identifier != product.identifier else { return } currentPricingProduct = product purchase(product: product.identifier) } From 5643495c5e90b1f0090c2d64329772feb0f0f6e0 Mon Sep 17 00:00:00 2001 From: Daniele Bogo Date: Tue, 3 Dec 2024 11:17:03 +0000 Subject: [PATCH 6/7] Override up next method --- .../CancelSubscriptionViewModel.swift | 9 +++ .../Onboarding/Models/PlusPurchaseModel.swift | 74 +++++++++---------- 2 files changed, 45 insertions(+), 38 deletions(-) diff --git a/podcasts/Cancel Subscription/CancelSubscriptionViewModel.swift b/podcasts/Cancel Subscription/CancelSubscriptionViewModel.swift index 41813fa2af..c1af50b64d 100644 --- a/podcasts/Cancel Subscription/CancelSubscriptionViewModel.swift +++ b/podcasts/Cancel Subscription/CancelSubscriptionViewModel.swift @@ -31,6 +31,15 @@ class CancelSubscriptionViewModel: PlusPurchaseModel { //TODO: Implement analytics } + override func handleNext() { + if SubscriptionHelper.activeTier == .patron { + let controller = PatronWelcomeViewModel.make(in: navigationController) + navigationController.pushViewController(controller, animated: true) + } else { + navigationController.dismiss(animated: true) + } + } + enum CurrentProductAvailability { case idle case loading diff --git a/podcasts/Onboarding/Models/PlusPurchaseModel.swift b/podcasts/Onboarding/Models/PlusPurchaseModel.swift index 5ef90cc1be..b5e9d8802d 100644 --- a/podcasts/Onboarding/Models/PlusPurchaseModel.swift +++ b/podcasts/Onboarding/Models/PlusPurchaseModel.swift @@ -74,6 +74,42 @@ class PlusPurchaseModel: PlusPricingInfoModel, OnboardingModel { controller.present(alert, animated: true) } + func handleNext() { + guard let parentController else { return } + + if OnboardingFlow.shared.currentFlow.shouldDismissAfterPurchase { + parentController.dismiss(animated: true) + return + } + + let navigationController = parentController as? UINavigationController + + let controller: UIViewController + if SubscriptionHelper.activeTier == .patron { + controller = PatronWelcomeViewModel.make(in: navigationController) + } else { + controller = WelcomeViewModel.make(in: navigationController, displayType: .plus) + } + + let presentNextBlock: () -> Void = { + guard let navigationController else { + // Present the welcome flow + parentController.present(controller, animated: true) + return + } + + // Reset the nav flow to only show the welcome controller + navigationController.setViewControllers([controller], animated: true) + } + + // Dismiss the current flow + if parentController.presentedViewController != nil { + parentController.dismiss(animated: true, completion: presentNextBlock) + } else { + presentNextBlock() + } + } + // Our internal state enum PurchaseState { case ready @@ -139,44 +175,6 @@ private extension PlusPurchaseModel { } } -private extension PlusPurchaseModel { - private func handleNext() { - guard let parentController else { return } - - if OnboardingFlow.shared.currentFlow.shouldDismissAfterPurchase { - parentController.dismiss(animated: true) - return - } - - let navigationController = parentController as? UINavigationController - - let controller: UIViewController - if SubscriptionHelper.activeTier == .patron { - controller = PatronWelcomeViewModel.make(in: navigationController) - } else { - controller = WelcomeViewModel.make(in: navigationController, displayType: .plus) - } - - let presentNextBlock: () -> Void = { - guard let navigationController else { - // Present the welcome flow - parentController.present(controller, animated: true) - return - } - - // Reset the nav flow to only show the welcome controller - navigationController.setViewControllers([controller], animated: true) - } - - // Dismiss the current flow - if parentController.presentedViewController != nil { - parentController.dismiss(animated: true, completion: presentNextBlock) - } else { - presentNextBlock() - } - } -} - // MARK: - Purchase Notification handlers private extension PlusPurchaseModel { func handlePurchaseCompleted(_ notification: Notification) { From 1bdbb43b0441cfe7ad4e69b81f40c4b7bee1e4d0 Mon Sep 17 00:00:00 2001 From: Daniele Bogo Date: Wed, 4 Dec 2024 17:20:57 +0000 Subject: [PATCH 7/7] Avoid re-purchase the same product --- .../CancelSubscriptionViewModel.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/podcasts/Cancel Subscription/CancelSubscriptionViewModel.swift b/podcasts/Cancel Subscription/CancelSubscriptionViewModel.swift index c1af50b64d..9404ab8ea6 100644 --- a/podcasts/Cancel Subscription/CancelSubscriptionViewModel.swift +++ b/podcasts/Cancel Subscription/CancelSubscriptionViewModel.swift @@ -9,6 +9,8 @@ class CancelSubscriptionViewModel: PlusPurchaseModel { purchaseHandler.isEligibleForOffer } + private var lastPurchasedProductID: IAPProductID? + @Published var currentPricingProduct: PlusPricingInfoModel.PlusProductPricingInfo? @State var currentProductAvailability: CurrentProductAvailability = .idle @@ -68,6 +70,7 @@ extension CancelSubscriptionViewModel { if let transaction = await purchaseHandler.findLastSubscriptionPurchased(), let productID = IAPProductID(rawValue: transaction.productID) { await MainActor.run { + lastPurchasedProductID = productID currentProductAvailability = .available currentPricingProduct = pricingInfo.products.first { $0.identifier == productID } } @@ -78,9 +81,11 @@ extension CancelSubscriptionViewModel { } func purchase(product: PlusPricingInfoModel.PlusProductPricingInfo) { - guard currentPricingProduct?.identifier != product.identifier else { return } currentPricingProduct = product - purchase(product: product.identifier) + + if currentPricingProduct?.identifier != lastPurchasedProductID { + purchase(product: product.identifier) + } } func claimOffer() {