Skip to content

Commit

Permalink
iOS: Support 1 week free trial state on paywall screen (#1157)
Browse files Browse the repository at this point in the history
^ALTAPPS-1332
  • Loading branch information
ivan-magda authored Aug 15, 2024
1 parent 8ccb713 commit a1eefa6
Show file tree
Hide file tree
Showing 16 changed files with 158 additions and 54 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -192,14 +192,16 @@ private class PaywallPreviewProvider : PreviewParameterProvider<ViewState> {
isToolbarVisible = true,
contentState = ViewStateContent.Content(
buyButtonText = PaywallPreviewDefaults.BUY_BUTTON_TEXT,
priceText = "$11.99 / month"
priceText = "$11.99 / month",
trialText = null
)
),
ViewState(
isToolbarVisible = false,
contentState = ViewStateContent.Content(
buyButtonText = PaywallPreviewDefaults.BUY_BUTTON_TEXT,
priceText = PaywallPreviewDefaults.PRICE_TEXT
priceText = PaywallPreviewDefaults.PRICE_TEXT,
trialText = null
)
),
ViewState(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ struct PaywallContentView: View {
private(set) var appearance = Appearance()

let buyButtonText: String
let buyFootnoteText: String?

let onBuyButtonTap: () -> Void
let onTermsOfServiceButtonTap: () -> Void
Expand Down Expand Up @@ -56,6 +57,7 @@ struct PaywallContentView: View {
PaywallFooterView(
appearance: .init(spacing: appearance.interitemSpacing),
buyButtonText: buyButtonText,
buyFootnoteText: buyFootnoteText,
onBuyButtonTap: onBuyButtonTap,
onTermsOfServiceButtonTap: onTermsOfServiceButtonTap
)
Expand All @@ -67,6 +69,16 @@ struct PaywallContentView: View {
#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",
onBuyButtonTap: {},
onTermsOfServiceButtonTap: {}
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,23 @@ 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

private let feedbackGenerator = FeedbackGenerator(feedbackType: .selection)

var body: some View {
actionButtons
content
.padding()
.background(
TransparentBlurView()
Expand All @@ -26,17 +28,25 @@ struct PaywallFooterView: View {
.fixedSize(horizontal: false, vertical: true)
}

@MainActor private var actionButtons: some View {
@MainActor private var content: some View {
VStack(alignment: .center, spacing: appearance.spacing) {
Button(
buyButtonText,
action: {
feedbackGenerator.triggerFeedback()
onBuyButtonTap()
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)
}
)
.buttonStyle(.primary)
.shineEffect()
}

Button(
Strings.Paywall.termsOfServiceButton,
Expand All @@ -56,12 +66,14 @@ struct PaywallFooterView: View {
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: {}
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ struct PaywallView: View {
case .content(let content):
PaywallContentView(
buyButtonText: content.buyButtonText,
buyFootnoteText: content.trialText,
onBuyButtonTap: viewModel.doBuySubscription,
onTermsOfServiceButtonTap: viewModel.doTermsOfServicePresentation
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,22 @@ localizedPriceString: \(storeProduct.localizedPriceString)
}
}

func checkTrialOrIntroDiscountEligibility(
productId: String,
completionHandler: @escaping (KotlinBoolean?, (any Error)?) -> Void
) {
Purchases.shared.checkTrialOrIntroDiscountEligibility(
productIdentifiers: [productId]
) { eligibilities in
if let eligibility = eligibilities[productId] {
let isEligible = eligibility.status == .eligible
completionHandler(KotlinBoolean(value: isEligible), nil)
} else {
completionHandler(KotlinBoolean(value: false), nil)
}
}
}

private func getProduct(
id: String,
completionHandler: @escaping (StoreProduct?) -> Void
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ class AndroidPurchaseManager(
fetchProduct(productId)?.price?.formatted
}

override suspend fun checkTrialEligibility(productId: String): Boolean = false

private suspend fun fetchProduct(productId: String): StoreProduct? =
Purchases.sharedInstance
.awaitGetProducts(listOf(productId))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package org.hyperskill.app.paywall.presentation

import co.touchlab.kermit.Logger
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import org.hyperskill.app.core.presentation.ActionDispatcherOptions
import org.hyperskill.app.paywall.presentation.PaywallFeature.Action
import org.hyperskill.app.paywall.presentation.PaywallFeature.InternalAction
Expand Down Expand Up @@ -47,15 +49,26 @@ internal class PaywallActionDispatcher(
InternalMessage.FetchMobileOnlyPriceError
}
) {
val price = purchaseInteractor
.getFormattedMobileOnlySubscriptionPrice()
.getOrThrow()
coroutineScope {
val priceDeferred = async {
purchaseInteractor.getFormattedMobileOnlySubscriptionPrice()
}
val trialEligibilityDeferred = async {
purchaseInteractor.checkTrialEligibilityForMobileOnlySubscription()
}

if (price != null) {
InternalMessage.FetchMobileOnlyPriceSuccess(price)
} else {
logger.e { "Receive null instead of formatted mobile-only subscription price" }
InternalMessage.FetchMobileOnlyPriceError
val price = priceDeferred.await().getOrThrow()
val isTrialEligible = trialEligibilityDeferred.await()

if (price != null) {
InternalMessage.FetchMobileOnlyPriceSuccess(
formattedPrice = price,
isTrialEligible = isTrialEligible
)
} else {
logger.e { "Receive null instead of formatted mobile-only subscription price" }
InternalMessage.FetchMobileOnlyPriceError
}
}
}.let(onNewMessage)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ import org.hyperskill.app.subscriptions.domain.model.SubscriptionType

object PaywallFeature {
internal sealed interface State {
object Idle : State
object Loading : State
object Error : State
data object Idle : State
data object Loading : State
data object Error : State
data class Content(
val formattedPrice: String,
val isTrialEligible: Boolean,
val isPurchaseSyncLoadingShowed: Boolean = false
) : State
}
Expand All @@ -25,52 +26,56 @@ object PaywallFeature {
)

sealed interface ViewStateContent {
object Idle : ViewStateContent
object Loading : ViewStateContent
object Error : ViewStateContent
data object Idle : ViewStateContent
data object Loading : ViewStateContent
data object Error : ViewStateContent
data class Content(
val buyButtonText: String,
val priceText: String?
val priceText: String?,
val trialText: String?
) : ViewStateContent

object SubscriptionSyncLoading : ViewStateContent
data object SubscriptionSyncLoading : ViewStateContent
}

sealed interface Message {
object Initialize : Message
data object Initialize : Message

object RetryContentLoading : Message
data object RetryContentLoading : Message

object CloseClicked : Message
data object CloseClicked : Message

data class BuySubscriptionClicked(
val purchaseParams: PlatformPurchaseParams
) : Message

object ClickedTermsOfServiceAndPrivacyPolicy : Message
data object ClickedTermsOfServiceAndPrivacyPolicy : Message

object ScreenShowed : Message
object ScreenHidden : Message
data object ScreenShowed : Message
data object ScreenHidden : Message

object ViewedEventMessage : Message
data object ViewedEventMessage : Message
}

internal sealed interface InternalMessage : Message {
object FetchMobileOnlyPriceError : InternalMessage
data class FetchMobileOnlyPriceSuccess(val formattedPrice: String) : InternalMessage
data object FetchMobileOnlyPriceError : InternalMessage
data class FetchMobileOnlyPriceSuccess(
val formattedPrice: String,
val isTrialEligible: Boolean
) : InternalMessage

object MobileOnlySubscriptionPurchaseError : InternalMessage
data object MobileOnlySubscriptionPurchaseError : InternalMessage
data class MobileOnlySubscriptionPurchaseSuccess(
val purchaseResult: PurchaseResult
) : InternalMessage

object SubscriptionSyncError : InternalMessage
data object SubscriptionSyncError : InternalMessage
data class SubscriptionSyncSuccess(val subscription: Subscription) : InternalMessage
}

sealed interface Action {
sealed interface ViewAction : Action {
object ClosePaywall : ViewAction
data object ClosePaywall : ViewAction

data class ShowMessage(
val messageKind: MessageKind
Expand All @@ -81,8 +86,8 @@ object PaywallFeature {
data class NotifyPaywallIsShown(val isPaywallShown: Boolean) : ViewAction

sealed interface NavigateTo : ViewAction {
object Back : NavigateTo
object BackToProfileSettings : NavigateTo
data object Back : NavigateTo
data object BackToProfileSettings : NavigateTo
}
}
}
Expand All @@ -96,13 +101,13 @@ object PaywallFeature {
}

internal sealed interface InternalAction : Action {
object FetchMobileOnlyPrice : InternalAction
data object FetchMobileOnlyPrice : InternalAction

data class StartMobileOnlySubscriptionPurchase(
val purchaseParams: PlatformPurchaseParams
) : InternalAction

object SyncSubscription : InternalAction
data object SyncSubscription : InternalAction

data class LogWrongSubscriptionTypeAfterSync(
val expectedSubscriptionType: SubscriptionType,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,10 @@ internal class PaywallReducer(
private fun handleFetchMobileOnlyPriceSuccess(
message: InternalMessage.FetchMobileOnlyPriceSuccess
): ReducerResult =
State.Content(message.formattedPrice) to emptySet()
State.Content(
formattedPrice = message.formattedPrice,
isTrialEligible = message.isTrialEligible
) to emptySet()

private fun handleFetchMobileOnlyPriceError(): ReducerResult =
State.Error to setOf()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,24 +39,43 @@ internal class PaywallViewStateMapper(
ViewStateContent.SubscriptionSyncLoading
} else {
ViewStateContent.Content(
buyButtonText = resourceProvider.getString(
if (platformType == PlatformType.IOS) {
SharedResources.strings.paywall_ios_mobile_only_buy_btn
} else {
SharedResources.strings.paywall_android_mobile_only_buy_btn
},
state.formattedPrice
),
buyButtonText = getBuyButtonText(state),
priceText = if (platformType == PlatformType.ANDROID) {
resourceProvider.getString(
SharedResources.strings.paywall_android_explicit_subscription_price,
state.formattedPrice
)
} else {
null
},
trialText = if (platformType == PlatformType.IOS && state.isTrialEligible) {
resourceProvider.getString(
SharedResources.strings.paywall_ios_mobile_only_trial_description,
state.formattedPrice
)
} else {
null
}
)
}
}
)

private fun getBuyButtonText(state: State.Content): String =
when (platformType) {
PlatformType.IOS ->
if (state.isTrialEligible) {
resourceProvider.getString(SharedResources.strings.paywall_ios_mobile_only_trial_buy_btn)
} else {
resourceProvider.getString(
SharedResources.strings.paywall_ios_mobile_only_buy_btn,
state.formattedPrice
)
}
PlatformType.ANDROID ->
resourceProvider.getString(
SharedResources.strings.paywall_android_mobile_only_buy_btn,
state.formattedPrice
)
}
}
Loading

0 comments on commit a1eefa6

Please sign in to comment.