Skip to content

Commit 8c1554b

Browse files
nalcalaglmac012
andauthored
Switch plan feature for users (#7040)
Task/Issue URL: https://app.asana.com/1/137249556945/project/72649045549333/task/1210985220483936?focus=true ### Description Implement switch feature in Subscription Settings ### Steps to test this PR _Pre steps_ - [x] Apply patch from https://app.asana.com/1/137249556945/project/1209991789468715/task/1210448620621729?focus=true _Free Trial_ - [x] Install from branch - [x] Purchase a test monthly subscription (Free Trial) - [x] Go to Subscription Settings Screen - [x] Check switch option is not available _Upgrade_ - [x] Cancel and wait until Free Trial expires **OR** wait until free trial is renewed to a paid monthly subscription - [x] If expired after canceled, purchase a monthly subscription again - [x] Go to Subscription Settings - [x] Check Switch option is there with updated text "Switch to Yearly and Save 16%" - [x] Check "Manage Plan and Payment Options" item is also updated - [x] Select Switch option - [x] Check copy/price/currency is correct for upgrade option - [x] Test closing dialog and selecting "Keep monthly plan" dismiss dialog - [x] Select "Switch to Yearly.." - [x] Check switch is done correctly - [x] Check subscription settings screen data is updated as soon as switch is success _Downgrade_ - [x] Now, you see yearly data in Subscription Settings screen - [x] Check Switch updated text is just "Switch plan" - [x] Select Switch option - [x] Check copy/price/currency is correct for downgrade option - [x] Test closing dialog and selecting "Keep Yearly Plan" dismiss dialog - [x] Select "Switch to Monthly.." - [x] Check switch is done correctly - [x] Check subscription settings screen data is updated as soon as switch is success _Dynamic currency_ - [x] Open switch dialog - [x] Check currency matches your Google Play account ($, £, €, etc.) _Expired_ - [x] Cancel subscription and wait for it to expire - [x] Go to Subscription Settings - [x] Check switch option is not available anymore _FF disabled_ - [x] Purchase a new subscription - [x] Go to Settings > Feature Flag Inventory - [x] Disable `supportsSwitchSubscription` FF - [x] Go back to Subscription Settings screen - [x] Check Switch option is not available ### UI changes | Before | After | | ------ | ----- | <img width="378" height="845" alt="Screenshot 2025-10-31 at 13 29 05" src="https://github.com/user-attachments/assets/2fec2709-89a1-477d-9e53-98692e008d41" />|<img width="381" height="845" alt="Screenshot 2025-10-30 at 14 47 37" src="https://github.com/user-attachments/assets/f8d56e39-5c17-44ec-a862-fe0c580553e1" />| | Upgrade | Downgrade | | ------ | ----- | <img width="378" height="835" alt="Screenshot 2025-10-30 at 14 40 11" src="https://github.com/user-attachments/assets/d8ce6907-dd6e-41bd-bf4a-4ff6e7963215" />|<img width="379" height="841" alt="Screenshot 2025-10-30 at 14 47 47" src="https://github.com/user-attachments/assets/19537c2f-c07c-4b09-80eb-e2054a6a9012" />| --------- Co-authored-by: Lukasz Macionczyk <lukasz.macionczyk@gmail.com>
1 parent 44cec09 commit 8c1554b

15 files changed

+1065
-106
lines changed

subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,13 @@ import logcat.asLog
9898
import logcat.logcat
9999
import retrofit2.HttpException
100100
import java.io.IOException
101+
import java.math.BigDecimal
102+
import java.text.NumberFormat
101103
import java.time.Duration
102104
import java.time.Instant
103105
import java.time.Period
104106
import java.time.format.DateTimeParseException
107+
import java.util.Currency
105108
import javax.inject.Inject
106109
import kotlin.time.Duration.Companion.milliseconds
107110

@@ -256,6 +259,14 @@ interface SubscriptionsManager {
256259
offerId: String? = null,
257260
replacementMode: SubscriptionReplacementMode,
258261
)
262+
263+
/**
264+
* Gets pricing information for switching between plans
265+
*
266+
* @param isUpgrade `true` if upgrading from monthly to yearly, `false` if downgrading from yearly to monthly
267+
* @return [SwitchPlanPricingInfo] containing current price, target price, and yearly monthly equivalent, or null if unavailable
268+
*/
269+
suspend fun getSwitchPlanPricing(isUpgrade: Boolean): SwitchPlanPricingInfo?
259270
}
260271

261272
@SingleInstanceIn(AppScope::class)
@@ -389,8 +400,62 @@ class RealSubscriptionsManager @Inject constructor(
389400
}
390401

391402
override suspend fun isSwitchPlanAvailable(): Boolean = withContext(dispatcherProvider.io()) {
392-
val hasActiveSubscription = authRepository.getSubscription()?.isActive() ?: false
393-
return@withContext hasActiveSubscription && privacyProFeature.get().supportsSwitchSubscription().isEnabled()
403+
val subscription = authRepository.getSubscription()
404+
val hasActiveSubscription = subscription?.isActive() ?: false
405+
val isOnFreeTrial = subscription?.activeOffers?.any { it == ActiveOfferType.TRIAL } ?: false
406+
val isSwitchFeatureEnabled = privacyProFeature.get().supportsSwitchSubscription().isEnabled()
407+
408+
return@withContext hasActiveSubscription && !isOnFreeTrial && isSwitchFeatureEnabled
409+
}
410+
411+
override suspend fun getSwitchPlanPricing(isUpgrade: Boolean): SwitchPlanPricingInfo? = withContext(dispatcherProvider.io()) {
412+
return@withContext try {
413+
val currentSubscription = getSubscription() ?: return@withContext null
414+
val basePlans = getSubscriptionOffer().filter { it.offerId == null }
415+
416+
// Determine current and target plan IDs based on region
417+
val isUS = currentSubscription.productId in listOf(MONTHLY_PLAN_US, YEARLY_PLAN_US)
418+
val (currentPlanId, targetPlanId) = if (isUpgrade) {
419+
val monthly = if (isUS) MONTHLY_PLAN_US else MONTHLY_PLAN_ROW
420+
val yearly = if (isUS) YEARLY_PLAN_US else YEARLY_PLAN_ROW
421+
monthly to yearly
422+
} else {
423+
val yearly = if (isUS) YEARLY_PLAN_US else YEARLY_PLAN_ROW
424+
val monthly = if (isUS) MONTHLY_PLAN_US else MONTHLY_PLAN_ROW
425+
yearly to monthly
426+
}
427+
428+
// Get prices from offers
429+
val currentPrice = basePlans.find { it.planId == currentPlanId }
430+
?.pricingPhases
431+
?.firstOrNull()
432+
?.formattedPrice ?: return@withContext null
433+
434+
val targetPrice = basePlans.find { it.planId == targetPlanId }
435+
?.pricingPhases
436+
?.firstOrNull()
437+
?.formattedPrice ?: return@withContext null
438+
439+
// Calculate monthly equivalent for yearly plan
440+
val (yearlyPriceAmount, yearlyPriceCurrency) = basePlans
441+
.find { it.planId in listOf(YEARLY_PLAN_US, YEARLY_PLAN_ROW) }
442+
?.pricingPhases
443+
?.firstOrNull()
444+
?.let { it.priceAmount to it.priceCurrency } ?: return@withContext null
445+
446+
val yearlyMonthlyEquivalent = NumberFormat.getCurrencyInstance()
447+
.apply { currency = yearlyPriceCurrency }
448+
.format(yearlyPriceAmount / 12.toBigDecimal())
449+
450+
SwitchPlanPricingInfo(
451+
currentPrice = currentPrice,
452+
targetPrice = targetPrice,
453+
yearlyMonthlyEquivalent = yearlyMonthlyEquivalent,
454+
)
455+
} catch (e: Exception) {
456+
logcat { "Subs: Failed to get switch plan pricing: ${e.message}" }
457+
null
458+
}
394459
}
395460

396461
override suspend fun switchSubscriptionPlan(
@@ -935,9 +1000,10 @@ class RealSubscriptionsManager @Inject constructor(
9351000
availablePlans.map { offer ->
9361001
val pricingPhases = offer.pricingPhases.pricingPhaseList.map { phase ->
9371002
PricingPhase(
1003+
priceAmount = BigDecimal.valueOf(phase.priceAmountMicros, 6),
1004+
priceCurrency = Currency.getInstance(phase.priceCurrencyCode),
9381005
formattedPrice = phase.formattedPrice,
9391006
billingPeriod = phase.billingPeriod,
940-
9411007
)
9421008
}
9431009

@@ -1286,6 +1352,8 @@ data class SubscriptionOffer(
12861352
)
12871353

12881354
data class PricingPhase(
1355+
val priceAmount: BigDecimal,
1356+
val priceCurrency: Currency,
12891357
val formattedPrice: String,
12901358
val billingPeriod: String,
12911359

@@ -1301,6 +1369,12 @@ data class PricingPhase(
13011369
}
13021370
}
13031371

1372+
data class SwitchPlanPricingInfo(
1373+
val currentPrice: String,
1374+
val targetPrice: String,
1375+
val yearlyMonthlyEquivalent: String,
1376+
)
1377+
13041378
data class ValidatedTokenPair(
13051379
val accessToken: String,
13061380
val accessTokenClaims: AccessTokenClaims,
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.subscriptions.impl.switch_plan
18+
19+
import android.annotation.SuppressLint
20+
import android.app.Activity
21+
import android.content.Context
22+
import android.content.DialogInterface
23+
import android.view.LayoutInflater
24+
import android.widget.FrameLayout
25+
import androidx.lifecycle.LifecycleOwner
26+
import androidx.lifecycle.lifecycleScope
27+
import com.duckduckgo.common.utils.DispatcherProvider
28+
import com.duckduckgo.subscriptions.impl.CurrentPurchase
29+
import com.duckduckgo.subscriptions.impl.R
30+
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_ROW
31+
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_US
32+
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_ROW
33+
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_US
34+
import com.duckduckgo.subscriptions.impl.SubscriptionsManager
35+
import com.duckduckgo.subscriptions.impl.billing.SubscriptionReplacementMode
36+
import com.duckduckgo.subscriptions.impl.databinding.BottomSheetSwitchPlanBinding
37+
import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsViewModel.SwitchPlanType
38+
import com.google.android.material.bottomsheet.BottomSheetBehavior
39+
import com.google.android.material.bottomsheet.BottomSheetDialog
40+
import com.google.android.material.shape.CornerFamily
41+
import com.google.android.material.shape.MaterialShapeDrawable
42+
import dagger.assisted.Assisted
43+
import dagger.assisted.AssistedInject
44+
import kotlinx.coroutines.launch
45+
import logcat.logcat
46+
import com.duckduckgo.mobile.android.R as CommonR
47+
import com.google.android.material.R as MaterialR
48+
49+
@SuppressLint("NoBottomSheetDialog")
50+
class SwitchPlanBottomSheetDialog @AssistedInject constructor(
51+
@Assisted private val context: Context,
52+
@Assisted private val lifecycleOwner: LifecycleOwner,
53+
@Assisted private val switchType: SwitchPlanType,
54+
@Assisted private val onSwitchSuccess: () -> Unit,
55+
private val subscriptionsManager: SubscriptionsManager,
56+
private val dispatcherProvider: DispatcherProvider,
57+
) : BottomSheetDialog(context) {
58+
59+
private val binding: BottomSheetSwitchPlanBinding = BottomSheetSwitchPlanBinding.inflate(LayoutInflater.from(context))
60+
61+
init {
62+
setContentView(binding.root)
63+
// We need the dialog to always be expanded and not draggable because the content takes up a lot of vertical space and requires a scroll view,
64+
// especially in landscape aspect-ratios. If the dialog started as collapsed, the drag would interfere with internal scroll.
65+
this.behavior.state = BottomSheetBehavior.STATE_EXPANDED
66+
this.behavior.isDraggable = false
67+
68+
setOnShowListener { dialogInterface ->
69+
setRoundCorners(dialogInterface)
70+
}
71+
}
72+
73+
/**
74+
* By default, when bottom sheet dialog is expanded, the corners become squared.
75+
* This function ensures that the bottom sheet dialog will have rounded corners even when in an expanded state.
76+
*/
77+
private fun setRoundCorners(dialogInterface: DialogInterface) {
78+
val bottomSheetDialog = dialogInterface as BottomSheetDialog
79+
val bottomSheet = bottomSheetDialog.findViewById<FrameLayout>(MaterialR.id.design_bottom_sheet)
80+
81+
val shapeDrawable = MaterialShapeDrawable.createWithElevationOverlay(context)
82+
shapeDrawable.shapeAppearanceModel = shapeDrawable.shapeAppearanceModel
83+
.toBuilder()
84+
.setTopLeftCorner(CornerFamily.ROUNDED, context.resources.getDimension(CommonR.dimen.dialogBorderRadius))
85+
.setTopRightCorner(CornerFamily.ROUNDED, context.resources.getDimension(CommonR.dimen.dialogBorderRadius))
86+
.build()
87+
bottomSheet?.background = shapeDrawable
88+
}
89+
90+
override fun onAttachedToWindow() {
91+
super.onAttachedToWindow()
92+
93+
configureViews()
94+
observePurchaseState()
95+
}
96+
97+
private fun configureViews() {
98+
binding.switchBottomSheetDialogCloseButton.setOnClickListener {
99+
dismiss()
100+
}
101+
102+
lifecycleOwner.lifecycleScope.launch(dispatcherProvider.io()) {
103+
val isUpgrade = switchType == SwitchPlanType.UPGRADE_TO_YEARLY
104+
val pricingInfo = subscriptionsManager.getSwitchPlanPricing(isUpgrade)
105+
106+
launch(dispatcherProvider.main()) {
107+
when (switchType) {
108+
SwitchPlanType.UPGRADE_TO_YEARLY -> {
109+
// Configure for upgrade (Monthly → Yearly)
110+
binding.switchBottomSheetDialogTitle.text = context.getString(R.string.switchBottomSheetTitleUpgrade)
111+
binding.switchBottomSheetDialogSubTitle.text = context.getString(
112+
R.string.switchBottomSheetDescriptionUpgrade,
113+
pricingInfo?.yearlyMonthlyEquivalent ?: "",
114+
pricingInfo?.currentPrice ?: "",
115+
)
116+
binding.switchBottomSheetDialogPrimaryButton.text = context.getString(
117+
R.string.switchBottomSheetPrimaryButtonUpgrade,
118+
pricingInfo?.targetPrice ?: "",
119+
)
120+
binding.switchBottomSheetDialogSecondaryButton.text = context.getString(R.string.switchBottomSheetSecondaryButtonUpgrade)
121+
122+
binding.switchBottomSheetDialogPrimaryButton.setOnClickListener {
123+
triggerSwitch(isUpgrade = true)
124+
dismiss()
125+
}
126+
binding.switchBottomSheetDialogSecondaryButton.setOnClickListener {
127+
dismiss()
128+
}
129+
}
130+
131+
SwitchPlanType.DOWNGRADE_TO_MONTHLY -> {
132+
// Configure for downgrade (Yearly → Monthly)
133+
binding.switchBottomSheetDialogTitle.text = context.getString(R.string.switchBottomSheetTitleDowngrade)
134+
binding.switchBottomSheetDialogSubTitle.text = context.getString(
135+
R.string.switchBottomSheetDescriptionDowngrade,
136+
pricingInfo?.yearlyMonthlyEquivalent ?: "",
137+
pricingInfo?.targetPrice ?: "",
138+
)
139+
binding.switchBottomSheetDialogPrimaryButton.text = context.getString(R.string.switchBottomSheetPrimaryButtonDowngrade)
140+
binding.switchBottomSheetDialogSecondaryButton.text = context.getString(
141+
R.string.switchBottomSheetSecondaryButtonDowngrade,
142+
pricingInfo?.targetPrice ?: "",
143+
)
144+
145+
binding.switchBottomSheetDialogPrimaryButton.setOnClickListener {
146+
dismiss()
147+
}
148+
binding.switchBottomSheetDialogSecondaryButton.setOnClickListener {
149+
triggerSwitch(isUpgrade = false)
150+
dismiss()
151+
}
152+
}
153+
}
154+
}
155+
}
156+
}
157+
158+
private fun observePurchaseState() {
159+
lifecycleOwner.lifecycleScope.launch(dispatcherProvider.io()) {
160+
subscriptionsManager.currentPurchaseState.collect {
161+
when (it) {
162+
is CurrentPurchase.Success -> {
163+
logcat { "Switch flow: Successfully switched plans" }
164+
onSwitchSuccess.invoke()
165+
}
166+
167+
is CurrentPurchase.Failure -> {
168+
logcat { "Switch flow: Failed to switch plans. Error: ${it.message}" }
169+
}
170+
171+
is CurrentPurchase.Canceled -> {
172+
logcat { "Switch flow: Canceled switch plans" }
173+
}
174+
175+
else -> {}
176+
}
177+
}
178+
}
179+
}
180+
181+
private fun triggerSwitch(isUpgrade: Boolean) {
182+
lifecycleOwner.lifecycleScope.launch(dispatcherProvider.io()) {
183+
try {
184+
val subscription = subscriptionsManager.getSubscription()
185+
if (subscription == null) {
186+
launch(dispatcherProvider.main()) {
187+
logcat { "Switch flow: Failed to switch plans. No active subscription found" }
188+
dismiss()
189+
}
190+
return@launch
191+
}
192+
193+
// Determine target plan based on current subscription
194+
val isUS = subscription.productId in listOf(MONTHLY_PLAN_US, YEARLY_PLAN_US)
195+
val targetPlanId = if (isUpgrade) {
196+
if (isUS) YEARLY_PLAN_US else YEARLY_PLAN_ROW
197+
} else {
198+
if (isUS) MONTHLY_PLAN_US else MONTHLY_PLAN_ROW
199+
}
200+
201+
launch(dispatcherProvider.main()) {
202+
subscriptionsManager.switchSubscriptionPlan(
203+
activity = context as Activity,
204+
planId = targetPlanId,
205+
offerId = null,
206+
replacementMode = SubscriptionReplacementMode.WITHOUT_PRORATION,
207+
)
208+
}
209+
} catch (e: Exception) {
210+
logcat { "Switch flow: Failed to switch plans. Exception: ${e.message}" }
211+
}
212+
}
213+
}
214+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.subscriptions.impl.switch_plan
18+
19+
import android.content.Context
20+
import androidx.lifecycle.LifecycleOwner
21+
import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsViewModel.SwitchPlanType
22+
import dagger.assisted.AssistedFactory
23+
24+
@AssistedFactory
25+
interface SwitchPlanBottomSheetDialogFactory {
26+
fun create(
27+
context: Context,
28+
lifecycleOwner: LifecycleOwner,
29+
switchType: SwitchPlanType,
30+
onSwitchSuccess: () -> Unit,
31+
): SwitchPlanBottomSheetDialog
32+
}

0 commit comments

Comments
 (0)