Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ALTAPPS-1345: iOS mobile only subscription annual plan #1179

Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Render subscription products
  • Loading branch information
ivan-magda committed Sep 13, 2024
commit 26b1674c4b6ea91656a34d51a79076848b250d27
4 changes: 4 additions & 0 deletions iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
@@ -144,6 +144,7 @@
2C2D73442B1736E000CBB1DA /* AppTabItemsAvailabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C2D73432B1736E000CBB1DA /* AppTabItemsAvailabilityService.swift */; };
2C2ECCA5288C0661008DDCBA /* StepQuizRetryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C2ECCA4288C0661008DDCBA /* StepQuizRetryButton.swift */; };
2C2ECCA7288C0BF7008DDCBA /* View+ConditionalViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C2ECCA6288C0BF7008DDCBA /* View+ConditionalViewModifier.swift */; };
2C2F7CFB2C94023100C300B9 /* PaywallSubscriptionProductsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C2F7CFA2C94023100C300B9 /* PaywallSubscriptionProductsView.swift */; };
2C2FD61E28191EC0004E7AF6 /* SentryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C2FD61D28191EC0004E7AF6 /* SentryManager.swift */; };
2C2FD62028191FFE004E7AF6 /* Sentry-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 2C2FD61F28191FFE004E7AF6 /* Sentry-Info.plist */; };
2C2FD622281920B1004E7AF6 /* SentryInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C2FD621281920B1004E7AF6 /* SentryInfo.swift */; };
@@ -946,6 +947,7 @@
2C2D73432B1736E000CBB1DA /* AppTabItemsAvailabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTabItemsAvailabilityService.swift; sourceTree = "<group>"; };
2C2ECCA4288C0661008DDCBA /* StepQuizRetryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizRetryButton.swift; sourceTree = "<group>"; };
2C2ECCA6288C0BF7008DDCBA /* View+ConditionalViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ConditionalViewModifier.swift"; sourceTree = "<group>"; };
2C2F7CFA2C94023100C300B9 /* PaywallSubscriptionProductsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallSubscriptionProductsView.swift; sourceTree = "<group>"; };
2C2FD61D28191EC0004E7AF6 /* SentryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryManager.swift; sourceTree = "<group>"; };
2C2FD61F28191FFE004E7AF6 /* Sentry-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Sentry-Info.plist"; sourceTree = "<group>"; };
2C2FD621281920B1004E7AF6 /* SentryInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryInfo.swift; sourceTree = "<group>"; };
@@ -2539,6 +2541,7 @@
2C9320F42B68F14100999992 /* PaywallContentView.swift */,
2C7C0D622B6B45A20093609D /* PaywallFeaturesView.swift */,
2C7271272B6B92AD005628B0 /* PaywallFooterView.swift */,
2C2F7CFA2C94023100C300B9 /* PaywallSubscriptionProductsView.swift */,
);
path = Content;
sourceTree = "<group>";
@@ -5528,6 +5531,7 @@
E9FB89AC2893EA580011EFFB /* NotificationPermissionStatus.swift in Sources */,
2C4FBD8C2876C39C00ACA5C8 /* ProfileAboutView.swift in Sources */,
2C023C88285D928100D2D5A9 /* StepQuizTableViewModel.swift in Sources */,
2C2F7CFB2C94023100C300B9 /* PaywallSubscriptionProductsView.swift in Sources */,
E99B21872887E9C5006A6154 /* StepQuizSortingSkeletonView.swift in Sources */,
E98BE36D2A374394000B430F /* StreakRecoveryModalView.swift in Sources */,
2CAE8D0C2805829A00E6C83D /* StepViewData.swift in Sources */,
Original file line number Diff line number Diff line change
@@ -646,6 +646,9 @@ enum Strings {
static let subscriptionFeature1 = sharedStrings.mobile_only_subscription_feature_1.localized()
static let subscriptionFeature2 = sharedStrings.mobile_only_subscription_feature_2.localized()
static let subscriptionFeature3 = sharedStrings.mobile_only_subscription_feature_3.localized()
static let subscriptionFeature4 = sharedStrings.mobile_only_subscription_feature_4.localized()

static let bestValueBadge = sharedStrings.paywall_best_value_label.localized()
}

// MARK: - ManageSubscription -
Original file line number Diff line number Diff line change
@@ -6,6 +6,8 @@ final class PaywallViewModel: FeatureViewModel<
PaywallFeatureMessage,
PaywallFeatureActionViewAction
> {
private let selectionFeedbackGenerator = FeedbackGenerator(feedbackType: .selection)

var contentStateKs: PaywallFeatureViewStateContentKs { .init(state.contentState) }

init(feature: Presentation_reduxFeature) {
@@ -33,6 +35,12 @@ final class PaywallViewModel: FeatureViewModel<
onNewMessage(PaywallFeatureMessageRetryContentLoading())
}

@MainActor
func doSubscriptionProductAction(product: PaywallFeatureViewStateContentSubscriptionProduct) {
selectionFeedbackGenerator.triggerFeedback()
onNewMessage(PaywallFeatureMessageProductClicked(productId: product.productId))
}

func doBuySubscription() {
onNewMessage(PaywallFeatureMessageBuySubscriptionClicked(purchaseParams: PlatformPurchaseParams()))
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import shared
import SwiftUI

extension PaywallContentView {
@@ -14,8 +15,10 @@ extension PaywallContentView {
struct PaywallContentView: View {
private(set) var appearance = Appearance()

let subscriptionProducts: [PaywallFeatureViewStateContentSubscriptionProduct]
let buyButtonText: String

let onSubscriptionProductTap: (PaywallFeatureViewStateContentSubscriptionProduct) -> Void
let onBuyButtonTap: () -> Void
let onTermsOfServiceButtonTap: () -> Void

@@ -49,6 +52,13 @@ struct PaywallContentView: View {
PaywallFeaturesView(
appearance: .init(spacing: appearance.interitemSpacing)
)

PaywallSubscriptionProductsView(
appearance: .init(spacing: appearance.interitemSpacing),
subscriptionProducts: subscriptionProducts,
onTap: onSubscriptionProductTap
)
.padding(.top)
}
.padding(appearance.padding)
}
@@ -66,7 +76,24 @@ struct PaywallContentView: View {
#if DEBUG
#Preview {
PaywallContentView(
buyButtonText: "Subscribe for $11.99/month",
subscriptionProducts: [
.init(
productId: "1",
title: "Monthly Subscription",
subtitle: "$11.99 / month",
isBestValue: false,
isSelected: false
),
.init(
productId: "2",
title: "Yearly Subscription",
subtitle: "$99.99 / year",
isBestValue: true,
isSelected: true
)
],
buyButtonText: "Start now",
onSubscriptionProductTap: { _ in },
onBuyButtonTap: {},
onTermsOfServiceButtonTap: {}
)
Original file line number Diff line number Diff line change
@@ -10,7 +10,8 @@ struct PaywallFeaturesView: View {
private static let features = [
Strings.Paywall.subscriptionFeature1,
Strings.Paywall.subscriptionFeature2,
Strings.Paywall.subscriptionFeature3
Strings.Paywall.subscriptionFeature3,
Strings.Paywall.subscriptionFeature4
]

private(set) var appearance = Appearance()
@@ -42,6 +43,7 @@ private struct PaywallFeatureView: View {
Label(
title: {
Text(title)
.foregroundColor(.newPrimaryText)
.offset(x: !animateTitle ? -width : 0)
.clipped()
},
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import shared
import SwiftUI

extension PaywallSubscriptionProductsView {
struct Appearance {
var spacing = LayoutInsets.smallInset

let padding = LayoutInsets.defaultInset

let badgeInsets = LayoutInsets(horizontal: 8, vertical: 4)
let badgeFont = UIFont.preferredFont(forTextStyle: .footnote)

func badgeTopOffset() -> CGFloat {
badgeFont.pointSize / 2.0 + badgeInsets.top
}
}
}

struct PaywallSubscriptionProductsView: View {
private(set) var appearance = Appearance()

let subscriptionProducts: [PaywallFeatureViewStateContentSubscriptionProduct]

let onTap: (PaywallFeatureViewStateContentSubscriptionProduct) -> Void

var body: some View {
VStack(alignment: .center, spacing: appearance.spacing) {
ForEach(
Array(subscriptionProducts.enumerated()),
id: \.element.productId
) { index, product in
buildProductView(
product: product,
action: {
onTap(product)
}
)
.padding(.top, product.isBestValue && index > 0 ? appearance.spacing : 0)
}
}
}

private var bestValueBadgeView: some View {
Text(Strings.Paywall.bestValueBadge)
.font(Font(appearance.badgeFont))
.foregroundColor(Color(ColorPalette.onPrimary))
.padding(appearance.badgeInsets.edgeInsets)
.background(Color(ColorPalette.primary))
.clipShape(Capsule())
.fixedSize()
}

private func buildProductView(
product: PaywallFeatureViewStateContentSubscriptionProduct,
action: @escaping () -> Void
) -> some View {
Button(
action: action,
label: {
HStack(alignment: .center, spacing: 0) {
Text(product.title)
.font(.body.bold())

Spacer()

Text(product.subtitle)
.font(.body)
}
.foregroundColor(.newPrimaryText)
.padding(.horizontal, appearance.padding)
.padding(.vertical, product.isSelected ? appearance.padding * 2 : appearance.padding)
.conditionalOpacity(isEnabled: product.isSelected)
.addBorder(
color: product.isSelected ? Color(ColorPalette.primary) : .border,
width: product.isSelected ? 2 : 1
)
.animation(.default, value: product.isSelected)
.overlay(
bestValueBadgeView
.opacity(product.isBestValue ? 1 : 0)
.alignmentGuide(.top, computeValue: { dimension in
dimension[.top] + appearance.badgeTopOffset()
})
.alignmentGuide(.trailing, computeValue: { dimension in
dimension[.trailing] - appearance.badgeInsets.trailing
})
,
alignment: .init(horizontal: .trailing, vertical: .top)
)
}
)
}
}

#if DEBUG
#Preview {
VStack {
PaywallSubscriptionProductsView(
subscriptionProducts: [
.init(
productId: "1",
title: "Monthly Subscription",
subtitle: "$11.99 / month",
isBestValue: false,
isSelected: false
),
.init(
productId: "2",
title: "Yearly Subscription",
subtitle: "$99.99 / year",
isBestValue: true,
isSelected: true
)
],
onTap: { _ in }
)
}
.padding()
}
#endif
Original file line number Diff line number Diff line change
@@ -53,7 +53,9 @@ struct PaywallView: View {
)
case .content(let content):
PaywallContentView(
subscriptionProducts: content.subscriptionProducts,
buyButtonText: content.buyButtonText,
onSubscriptionProductTap: viewModel.doSubscriptionProductAction(product:),
onBuyButtonTap: viewModel.doBuySubscription,
onTermsOfServiceButtonTap: viewModel.doTermsOfServicePresentation
)
Original file line number Diff line number Diff line change
@@ -16,8 +16,7 @@ import org.hyperskill.app.purchases.domain.model.SubscriptionPeriod
import org.hyperskill.app.purchases.domain.model.SubscriptionProduct

internal class PaywallViewStateMapper(
private val resourceProvider: ResourceProvider/*,
private val platformType: PlatformType*/
private val resourceProvider: ResourceProvider
) {
fun map(
state: State,
Original file line number Diff line number Diff line change
@@ -66,14 +66,13 @@ internal class IosPurchaseManagerImpl(
}
}

@Suppress("VariableNaming")
private fun mapOfferingsToSubscriptionProducts(rcOfferings: RCOfferings): List<SubscriptionProduct> {
val currentOffering = rcOfferings.current() ?: return emptyList()
return currentOffering
.availablePackages()
.mapNotNull {
val _package = it as? RCPackage ?: return@mapNotNull null
val rcStoreProduct = _package.storeProduct()
val rcPackage = it as? RCPackage ?: return@mapNotNull null
val rcStoreProduct = rcPackage.storeProduct()
SubscriptionProduct(
id = rcStoreProduct.productIdentifier(),
period = when (rcStoreProduct.subscriptionPeriod()?.unit()) {