Skip to content

Commit b2afcc4

Browse files
authored
Merge pull request #9874 from woocommerce/task/iap-plan-upgrades-initial-UI-flow
[IAP] Basic UI flow to allow upgrades purchase
2 parents f9188e4 + 527dfbd commit b2afcc4

File tree

10 files changed

+522
-266
lines changed

10 files changed

+522
-266
lines changed
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import Foundation
2+
import SwiftUI
3+
4+
/// ViewModel for the Upgrades View
5+
/// Drives the site's available In-App Purchases plan upgrades
6+
///
7+
@MainActor
8+
final class UpgradesViewModel: ObservableObject {
9+
10+
private let inAppPurchasesPlanManager: InAppPurchasesForWPComPlansManager
11+
private let siteID: Int64
12+
13+
@Published var products: [WPComPlanProduct]
14+
@Published var entitledProductIDs: Set<String>
15+
16+
init(siteID: Int64) {
17+
self.siteID = siteID
18+
// TODO: Inject dependencies
19+
// https://github.com/woocommerce/woocommerce-ios/issues/9884
20+
inAppPurchasesPlanManager = InAppPurchasesForWPComPlansManager()
21+
products = []
22+
entitledProductIDs = []
23+
}
24+
25+
/// Iterates through all available products (In-App Purchases WPCom plans) and checks whether the merchant is entitled
26+
///
27+
func loadUserEntitlements() async {
28+
do {
29+
for product in self.products {
30+
if try await inAppPurchasesPlanManager.userIsEntitledToProduct(with: product.id) {
31+
self.entitledProductIDs.insert(product.id)
32+
} else {
33+
self.entitledProductIDs.remove(product.id)
34+
}
35+
}
36+
} catch {
37+
// TODO: Handle errors
38+
// https://github.com/woocommerce/woocommerce-ios/issues/9886
39+
DDLogError("loadEntitlements \(error)")
40+
}
41+
}
42+
43+
/// Retrieves all products (In-App Purchases WPCom plans)
44+
///
45+
func loadProducts() async {
46+
do {
47+
guard await inAppPurchasesPlanManager.inAppPurchasesAreSupported() else {
48+
DDLogError("IAP not supported")
49+
return
50+
}
51+
52+
self.products = try await inAppPurchasesPlanManager.fetchProducts()
53+
await loadUserEntitlements()
54+
} catch {
55+
// TODO: Handle errors
56+
// https://github.com/woocommerce/woocommerce-ios/issues/9886
57+
DDLogError("loadProducts \(error)")
58+
}
59+
}
60+
61+
/// Triggers the purchase of the specified In-App Purchases WPCom plans by the passed product ID
62+
/// linked to the current site ID
63+
///
64+
func purchaseProduct(with productID: String) async {
65+
do {
66+
// TODO: Deal with purchase result
67+
// https://github.com/woocommerce/woocommerce-ios/issues/9886
68+
let _ = try await inAppPurchasesPlanManager.purchaseProduct(with: productID, for: self.siteID)
69+
} catch {
70+
// TODO: Handle errors
71+
DDLogError("purchaseProduct \(error)")
72+
}
73+
}
74+
75+
/// Retrieves a specific In-App Purchase WPCOM plan from the available products
76+
///
77+
func retrievePlanDetailsIfAvailable(_ type: AvailableInAppPurchasesWPComPlans ) -> WPComPlanProduct? {
78+
let match = type.rawValue
79+
guard let wpcomPlanProduct = products.first(where: { $0.id == match }) else {
80+
return nil
81+
}
82+
return wpcomPlanProduct
83+
}
84+
}
85+
86+
extension UpgradesViewModel {
87+
enum AvailableInAppPurchasesWPComPlans: String {
88+
case essentialMonthly = "debug.woocommerce.express.essential.monthly"
89+
}
90+
}

WooCommerce/Classes/ViewRelated/Dashboard/Free Trial/FreeTrialBannerPresenter.swift

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,12 +147,19 @@ private extension FreeTrialBannerPresenter {
147147
self.freeTrialBanner = nil
148148
}
149149

150-
/// Shows a web view for the merchant to update their site plan.
150+
/// Shows a view for the merchant to upgrade their site's plan.
151151
///
152152
func showUpgradesView() {
153153
guard let viewController else { return }
154-
let upgradeController = UpgradesHostingController(siteID: siteID)
155-
viewController.show(upgradeController, sender: self)
154+
Task { @MainActor in
155+
if inAppPurchasesUpgradeEnabled {
156+
let upgradesController = UpgradesHostingController(siteID: siteID)
157+
viewController.show(upgradesController, sender: self)
158+
} else {
159+
let subscriptionsController = SubscriptionsHostingController(siteID: siteID)
160+
viewController.show(subscriptionsController, sender: self)
161+
}
162+
}
156163
}
157164
}
158165

WooCommerce/Classes/ViewRelated/Dashboard/Onboarding/StoreOnboardingCoordinator.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -148,10 +148,10 @@ private extension StoreOnboardingCoordinator {
148148
}
149149

150150
private extension StoreOnboardingCoordinator {
151-
/// Navigates the user to the plan detail view.
151+
/// Navigates the user to the plan subscription details view.
152152
///
153153
func showPlanView() {
154-
let upgradeController = UpgradesHostingController(siteID: site.siteID)
155-
navigationController.show(upgradeController, sender: self)
154+
let subscriptionController = SubscriptionsHostingController(siteID: site.siteID)
155+
navigationController.show(subscriptionController, sender: self)
156156
}
157157
}

WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,8 @@ final class HubMenuViewModel: ObservableObject {
166166
/// Presents the `Subscriptions` view from the view model's navigation controller property.
167167
///
168168
func presentSubscriptions() {
169-
let upgradesViewController = UpgradesHostingController(siteID: siteID)
170-
navigationController?.show(upgradesViewController, sender: self)
169+
let subscriptionController = SubscriptionsHostingController(siteID: siteID)
170+
navigationController?.show(subscriptionController, sender: self)
171171
}
172172

173173
func showReviewDetails(using parcel: ProductReviewFromNoteParcel) {
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import Foundation
2+
import SwiftUI
3+
4+
/// Main view for the plan subscription settings.
5+
///
6+
final class SubscriptionsHostingController: UIHostingController<SubscriptionsView> {
7+
8+
init(siteID: Int64) {
9+
let viewModel = SubscriptionsViewModel()
10+
super.init(rootView: .init(viewModel: viewModel))
11+
12+
rootView.onReportIssueTapped = { [weak self] in
13+
self?.showContactSupportForm()
14+
}
15+
}
16+
17+
required init?(coder aDecoder: NSCoder) {
18+
fatalError("init(coder:) has not been implemented")
19+
}
20+
21+
private func showContactSupportForm() {
22+
let supportController = SupportFormHostingController(viewModel: .init())
23+
supportController.show(from: self)
24+
}
25+
}
26+
27+
/// Main view for the plan settings.
28+
///
29+
struct SubscriptionsView: View {
30+
31+
/// Drives the view.
32+
///
33+
@StateObject var viewModel: SubscriptionsViewModel
34+
35+
/// Closure to be invoked when the "Report Issue" button is tapped.
36+
///
37+
var onReportIssueTapped: (() -> ())?
38+
39+
var body: some View {
40+
List {
41+
Section(content: {
42+
Text(Localization.currentPlan(viewModel.planName))
43+
.bodyStyle()
44+
45+
}, header: {
46+
Text(Localization.subscriptionStatus)
47+
}, footer: {
48+
Text(viewModel.planInfo)
49+
})
50+
51+
VStack(alignment: .leading) {
52+
Text(Localization.experienceFeatures)
53+
.bold()
54+
.headlineStyle()
55+
56+
ForEach(viewModel.freeTrialFeatures, id: \.title) { feature in
57+
HStack {
58+
Image(uiImage: feature.icon)
59+
.foregroundColor(Color(uiColor: .accent))
60+
61+
Text(feature.title)
62+
.foregroundColor(Color(.text))
63+
.calloutStyle()
64+
}
65+
.listRowSeparator(.hidden)
66+
}
67+
}
68+
.renderedIf(viewModel.shouldShowFreeTrialFeatures)
69+
70+
Button(Localization.cancelTrial) {
71+
print("Cancel Free Trial tapped")
72+
}
73+
.foregroundColor(Color(.systemRed))
74+
.renderedIf(viewModel.shouldShowCancelTrialButton)
75+
76+
Section(Localization.troubleshooting) {
77+
Button(Localization.report) {
78+
onReportIssueTapped?()
79+
}
80+
.linkStyle()
81+
}
82+
}
83+
.notice($viewModel.errorNotice, autoDismiss: false)
84+
.redacted(reason: viewModel.showLoadingIndicator ? .placeholder : [])
85+
.shimmering(active: viewModel.showLoadingIndicator)
86+
.background(Color(.listBackground))
87+
.navigationTitle(Localization.title)
88+
.navigationBarTitleDisplayMode(.inline)
89+
.task {
90+
viewModel.loadPlan()
91+
}
92+
}
93+
}
94+
95+
// Definitions
96+
private extension SubscriptionsView {
97+
enum Localization {
98+
static let title = NSLocalizedString("Subscriptions", comment: "Title for the Subscriptions / Upgrades view")
99+
static let subscriptionStatus = NSLocalizedString("SUBSCRIPTION STATUS", comment: "Title for the plan section on the subscriptions view. Uppercased")
100+
static let experienceFeatures = NSLocalizedString("Experience more of our features and services beyond the app",
101+
comment: "Title for the features list in the Subscriptions Screen")
102+
static let cancelTrial = NSLocalizedString("Cancel Free Trial", comment: "Title for the button to cancel a free trial")
103+
static let troubleshooting = NSLocalizedString("TROUBLESHOOTING",
104+
comment: "Title for the section to contact support on the subscriptions view. Uppercased")
105+
static let report = NSLocalizedString("Report Subscription Issue", comment: "Title for the button to contact support on the Subscriptions view")
106+
107+
static func currentPlan(_ plan: String) -> String {
108+
let format = NSLocalizedString("Current: %@", comment: "Reads like: Current: Free Trial")
109+
return .localizedStringWithFormat(format, plan)
110+
}
111+
}
112+
}
113+
114+
// MARK: Previews
115+
struct UpgradesPreviews: PreviewProvider {
116+
static var previews: some View {
117+
NavigationView {
118+
SubscriptionsView(viewModel: .init())
119+
}
120+
}
121+
}

WooCommerce/Classes/ViewRelated/Upgrades/UpgradesViewModel.swift renamed to WooCommerce/Classes/ViewRelated/Upgrades/SubscriptionsViewModel.swift

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ import Yosemite
33
import Combine
44
import protocol Experiments.FeatureFlagService
55

6-
/// ViewModel for the Upgrades View
6+
/// ViewModel for the Subscriptions View
7+
/// Drives the site's plan subscription
78
///
8-
final class UpgradesViewModel: ObservableObject {
9+
final class SubscriptionsViewModel: ObservableObject {
910

1011
/// Indicates if the view should show an error notice.
1112
///
@@ -19,6 +20,10 @@ final class UpgradesViewModel: ObservableObject {
1920
///
2021
private(set) var planInfo = ""
2122

23+
/// Current store plan details information.
24+
///
25+
private(set) var planDaysLeft = ""
26+
2227
/// Defines if the view should show the Full Plan features..
2328
///
2429
private(set) var shouldShowFreeTrialFeatures = false
@@ -74,7 +79,7 @@ final class UpgradesViewModel: ObservableObject {
7479
}
7580

7681
// MARK: Helpers
77-
private extension UpgradesViewModel {
82+
private extension SubscriptionsViewModel {
7883
/// Observes and reacts to plan changes
7984
///
8085
func observePlan() {
@@ -96,6 +101,7 @@ private extension UpgradesViewModel {
96101
func updateViewProperties(from plan: WPComSitePlan) {
97102
planName = Self.getPlanName(from: plan)
98103
planInfo = Self.getPlanInfo(from: plan)
104+
planDaysLeft = Self.daysLeft(for: plan).formatted()
99105
errorNotice = nil
100106
showLoadingIndicator = false
101107
shouldShowFreeTrialFeatures = plan.isFreeTrial
@@ -197,7 +203,7 @@ private extension UpgradesViewModel {
197203
}
198204

199205
// MARK: Definitions
200-
private extension UpgradesViewModel {
206+
private extension SubscriptionsViewModel {
201207
enum Localization {
202208
static let trialEnded = NSLocalizedString("Trial ended", comment: "Plan name for an expired free trial")
203209
static let trialEndedInfo = NSLocalizedString("Your free trial has ended and you have limited access to all the features. " +

0 commit comments

Comments
 (0)