Skip to content

Commit

Permalink
iOS: Mobile only subscription annual plan (#1179)
Browse files Browse the repository at this point in the history
^ALTAPPS-1345
  • Loading branch information
ivan-magda authored Sep 13, 2024
1 parent 119eaea commit e5e0c0c
Show file tree
Hide file tree
Showing 18 changed files with 203 additions and 130 deletions.
4 changes: 4 additions & 0 deletions iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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>"; };
Expand Down Expand Up @@ -2539,6 +2541,7 @@
2C9320F42B68F14100999992 /* PaywallContentView.swift */,
2C7C0D622B6B45A20093609D /* PaywallFeaturesView.swift */,
2C7271272B6B92AD005628B0 /* PaywallFooterView.swift */,
2C2F7CFA2C94023100C300B9 /* PaywallSubscriptionProductsView.swift */,
);
path = Content;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 -
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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()))
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import shared
import SwiftUI

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

let subscriptionProducts: [PaywallFeatureViewStateContentSubscriptionProduct]
let buyButtonText: String
let buyFootnoteText: String?

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

Expand Down Expand Up @@ -50,14 +52,20 @@ struct PaywallContentView: View {
PaywallFeaturesView(
appearance: .init(spacing: appearance.interitemSpacing)
)

PaywallSubscriptionProductsView(
appearance: .init(spacing: appearance.interitemSpacing),
subscriptionProducts: subscriptionProducts,
onTap: onSubscriptionProductTap
)
.padding(.top)
}
.padding(appearance.padding)
}
.safeAreaInsetBottomCompatibility(
PaywallFooterView(
appearance: .init(spacing: appearance.interitemSpacing),
buyButtonText: buyButtonText,
buyFootnoteText: buyFootnoteText,
onBuyButtonTap: onBuyButtonTap,
onTermsOfServiceButtonTap: onTermsOfServiceButtonTap
)
Expand All @@ -68,17 +76,24 @@ struct PaywallContentView: View {
#if DEBUG
#Preview {
PaywallContentView(
buyButtonText: "Subscribe for $11.99/month",
buyFootnoteText: nil,
onBuyButtonTap: {},
onTermsOfServiceButtonTap: {}
)
}

#Preview {
PaywallContentView(
buyButtonText: "Subscribe for $11.99/month",
buyFootnoteText: "Then $11.99 per 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: {}
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -42,6 +43,7 @@ private struct PaywallFeatureView: View {
Label(
title: {
Text(title)
.foregroundColor(.newPrimaryText)
.offset(x: !animateTitle ? -width : 0)
.clipped()
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,13 @@ import SwiftUI
extension PaywallFooterView {
struct Appearance {
var spacing = LayoutInsets.defaultInset
var interitemSpacing = LayoutInsets.smallInset
}
}

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

let buyButtonText: String
let buyFootnoteText: String?

let onBuyButtonTap: () -> Void
let onTermsOfServiceButtonTap: () -> Void
Expand All @@ -30,23 +28,15 @@ struct PaywallFooterView: View {

@MainActor private var content: some View {
VStack(alignment: .center, spacing: appearance.spacing) {
VStack(alignment: .center, spacing: appearance.interitemSpacing) {
Button(
buyButtonText,
action: {
feedbackGenerator.triggerFeedback()
onBuyButtonTap()
}
)
.buttonStyle(.primary)
.shineEffect()

if let buyFootnoteText {
Text(buyFootnoteText)
.font(.footnote.bold())
.foregroundColor(.newSecondaryText)
Button(
buyButtonText,
action: {
feedbackGenerator.triggerFeedback()
onBuyButtonTap()
}
}
)
.buttonStyle(.primary)
.shineEffect()

Button(
Strings.Paywall.termsOfServiceButton,
Expand All @@ -63,20 +53,10 @@ struct PaywallFooterView: View {

#if DEBUG
#Preview {
VStack {
PaywallFooterView(
buyButtonText: "Subscribe for $11.99/month",
buyFootnoteText: nil,
onBuyButtonTap: {},
onTermsOfServiceButtonTap: {}
)

PaywallFooterView(
buyButtonText: "Subscribe for $11.99/month",
buyFootnoteText: "Then $11.99 per month",
onBuyButtonTap: {},
onTermsOfServiceButtonTap: {}
)
}
PaywallFooterView(
buyButtonText: "Subscribe for $11.99/month",
onBuyButtonTap: {},
onTermsOfServiceButtonTap: {}
)
}
#endif
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
Expand Up @@ -53,8 +53,9 @@ struct PaywallView: View {
)
case .content(let content):
PaywallContentView(
subscriptionProducts: content.subscriptionProducts,
buyButtonText: content.buyButtonText,
buyFootnoteText: content.trialText,
onSubscriptionProductTap: viewModel.doSubscriptionProductAction(product:),
onBuyButtonTap: viewModel.doBuySubscription,
onTermsOfServiceButtonTap: viewModel.doTermsOfServicePresentation
)
Expand Down
Loading

0 comments on commit e5e0c0c

Please sign in to comment.