improvement(Android): Small redesign for Subscriptions to solve Play store issues

This commit is contained in:
Artem Chepurnoy 2024-09-01 17:26:11 +03:00
parent bca90b296d
commit 7d1d927c4c
No known key found for this signature in database
GPG Key ID: FAC37D0CF674043E
7 changed files with 269 additions and 105 deletions

View File

@ -16,6 +16,7 @@ import com.artemchep.keyguard.common.io.effectMap
import com.artemchep.keyguard.common.io.launchIn import com.artemchep.keyguard.common.io.launchIn
import com.artemchep.keyguard.common.io.timeout import com.artemchep.keyguard.common.io.timeout
import com.artemchep.keyguard.common.io.toIO import com.artemchep.keyguard.common.io.toIO
import com.artemchep.keyguard.common.model.DurationSimple
import com.artemchep.keyguard.common.model.Product import com.artemchep.keyguard.common.model.Product
import com.artemchep.keyguard.common.model.RichResult import com.artemchep.keyguard.common.model.RichResult
import com.artemchep.keyguard.common.model.Subscription import com.artemchep.keyguard.common.model.Subscription
@ -24,6 +25,8 @@ import com.artemchep.keyguard.common.model.map
import com.artemchep.keyguard.common.model.orNull import com.artemchep.keyguard.common.model.orNull
import com.artemchep.keyguard.common.service.subscription.SubscriptionService import com.artemchep.keyguard.common.service.subscription.SubscriptionService
import com.artemchep.keyguard.feature.crashlytics.crashlyticsTap import com.artemchep.keyguard.feature.crashlytics.crashlyticsTap
import com.artemchep.keyguard.platform.LeContext
import com.artemchep.keyguard.ui.format
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -37,6 +40,7 @@ import org.kodein.di.DirectDI
import org.kodein.di.instance import org.kodein.di.instance
class SubscriptionServiceAndroid( class SubscriptionServiceAndroid(
private val context: LeContext,
private val billingManager: BillingManager, private val billingManager: BillingManager,
) : SubscriptionService { ) : SubscriptionService {
companion object { companion object {
@ -53,6 +57,7 @@ class SubscriptionServiceAndroid(
constructor( constructor(
directDI: DirectDI, directDI: DirectDI,
) : this( ) : this(
context = directDI.instance(),
billingManager = directDI.instance(), billingManager = directDI.instance(),
) )
@ -92,16 +97,23 @@ class SubscriptionServiceAndroid(
.pricingPhaseList .pricingPhaseList
.last() .last()
val period = DurationSimple.parse(finalPrice.billingPeriod)
val periodFormatted = period.format(context)
val status = kotlin.run { val status = kotlin.run {
val skuReceipt = val skuReceipt =
receipts?.firstOrNull { purchase -> it.productId in purchase.products } receipts?.firstOrNull { purchase -> it.productId in purchase.products }
?: return@run kotlin.run { ?: return@run kotlin.run {
val hasTrialAvailable = bestOffer?.pricingPhases val trialPeriodStr = bestOffer?.pricingPhases
?.pricingPhaseList ?.pricingPhaseList
?.firstOrNull() ?.firstOrNull()
?.priceAmountMicros == 0L ?.takeIf { it.priceAmountMicros == 0L }
?.billingPeriod
val trialPeriod = trialPeriodStr
?.let { DurationSimple.parse(it) }
val trialPeriodFormatted = trialPeriod?.format(context)
Subscription.Status.Inactive( Subscription.Status.Inactive(
hasTrialAvailable = hasTrialAvailable, trialPeriod = trialPeriod,
trialPeriodFormatted = trialPeriodFormatted,
) )
} }
Subscription.Status.Active( Subscription.Status.Active(
@ -114,6 +126,8 @@ class SubscriptionServiceAndroid(
description = it.description, description = it.description,
price = finalPrice.formattedPrice, price = finalPrice.formattedPrice,
status = status, status = status,
period = period,
periodFormatted = periodFormatted,
purchase = { context -> purchase = { context ->
val activity = context.context.closestActivityOrNull val activity = context.context.closestActivityOrNull
?: return@Subscription ?: return@Subscription

View File

@ -64,4 +64,28 @@
<item quantity="many"><xliff:g id="number">%1$s</xliff:g> days</item> <item quantity="many"><xliff:g id="number">%1$s</xliff:g> days</item>
<item quantity="other"><xliff:g id="number">%1$s</xliff:g> days</item> <item quantity="other"><xliff:g id="number">%1$s</xliff:g> days</item>
</plurals> </plurals>
<plurals name="weeks_plural">
<item quantity="zero"><xliff:g id="number" example="0">%1$s</xliff:g> weeks</item>
<item quantity="one"><xliff:g id="number" example="1">%1$s</xliff:g> week</item>
<item quantity="two"><xliff:g id="number" example="2">%1$s</xliff:g> weeks</item>
<item quantity="few"><xliff:g id="number">%1$s</xliff:g> weeks</item>
<item quantity="many"><xliff:g id="number">%1$s</xliff:g> weeks</item>
<item quantity="other"><xliff:g id="number">%1$s</xliff:g> weeks</item>
</plurals>
<plurals name="months_plural">
<item quantity="zero"><xliff:g id="number" example="0">%1$s</xliff:g> months</item>
<item quantity="one"><xliff:g id="number" example="1">%1$s</xliff:g> month</item>
<item quantity="two"><xliff:g id="number" example="2">%1$s</xliff:g> months</item>
<item quantity="few"><xliff:g id="number">%1$s</xliff:g> months</item>
<item quantity="many"><xliff:g id="number">%1$s</xliff:g> months</item>
<item quantity="other"><xliff:g id="number">%1$s</xliff:g> months</item>
</plurals>
<plurals name="years_plural">
<item quantity="zero"><xliff:g id="number" example="0">%1$s</xliff:g> years</item>
<item quantity="one"><xliff:g id="number" example="1">%1$s</xliff:g> year</item>
<item quantity="two"><xliff:g id="number" example="2">%1$s</xliff:g> years</item>
<item quantity="few"><xliff:g id="number">%1$s</xliff:g> years</item>
<item quantity="many"><xliff:g id="number">%1$s</xliff:g> years</item>
<item quantity="other"><xliff:g id="number">%1$s</xliff:g> years</item>
</plurals>
</resources> </resources>

View File

@ -1083,7 +1083,7 @@
<string name="pref_item_premium_membership_failed_to_load_subscriptions">Failed to load a list of subscriptions</string> <string name="pref_item_premium_membership_failed_to_load_subscriptions">Failed to load a list of subscriptions</string>
<string name="pref_item_premium_membership_failed_to_load_products">Failed to load a list of products</string> <string name="pref_item_premium_membership_failed_to_load_products">Failed to load a list of products</string>
<string name="pref_item_premium_status_active">Active</string> <string name="pref_item_premium_status_active">Active</string>
<string name="pref_item_premium_status_free_trial">Free trial</string> <string name="pref_item_premium_status_free_trial_n">Free <xliff:g id="period" example="1 month">%1$s</xliff:g> trial</string>
<string name="pref_item_premium_status_will_not_renew">Will not renew</string> <string name="pref_item_premium_status_will_not_renew">Will not renew</string>
<string name="pref_item_premium_manage_subscription_on_play_store_title">Manage on Play Store</string> <string name="pref_item_premium_manage_subscription_on_play_store_title">Manage on Play Store</string>
<string name="pref_item_permission_post_notifications_title">Post notifications</string> <string name="pref_item_permission_post_notifications_title">Post notifications</string>

View File

@ -0,0 +1,30 @@
package com.artemchep.keyguard.common.model
data class DurationSimple(
val years: Int,
val months: Int,
val weeks: Int,
val days: Int,
) {
companion object {
// The implementation assumes that there's no
// time provided in the period.
// https://www.digi.com/resources/documentation/digidocs/90001488-13/reference/r_iso_8601_duration_format.htm
fun parse(iso8601: String): DurationSimple {
fun extractAsInt(regex: Regex): Int {
val result = regex.find(iso8601)
?: return 0
return result.groupValues.getOrNull(1)
?.toIntOrNull()
?: 0
}
return DurationSimple(
years = extractAsInt("(\\d+)Y".toRegex()),
months = extractAsInt("(\\d+)M".toRegex()),
weeks = extractAsInt("(\\d+)W".toRegex()),
days = extractAsInt("(\\d+)D".toRegex()),
)
}
}
}

View File

@ -8,11 +8,14 @@ data class Subscription(
val description: String?, val description: String?,
val price: String, val price: String,
val status: Status, val status: Status,
val period: DurationSimple,
val periodFormatted: String,
val purchase: (LeContext) -> Unit, val purchase: (LeContext) -> Unit,
) { ) {
sealed interface Status { sealed interface Status {
data class Inactive( data class Inactive(
val hasTrialAvailable: Boolean, val trialPeriod: DurationSimple?,
val trialPeriodFormatted: String?,
) : Status ) : Status
data class Active( data class Active(

View File

@ -1,19 +1,21 @@
package com.artemchep.keyguard.feature.home.settings.component package com.artemchep.keyguard.feature.home.settings.component
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@ -21,15 +23,22 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Star import androidx.compose.material.icons.outlined.Star
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.contentColorFor import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.artemchep.keyguard.common.model.Loadable import com.artemchep.keyguard.common.model.Loadable
import com.artemchep.keyguard.common.model.Product import com.artemchep.keyguard.common.model.Product
import com.artemchep.keyguard.common.model.Subscription import com.artemchep.keyguard.common.model.Subscription
@ -38,22 +47,25 @@ import com.artemchep.keyguard.common.usecase.GetProducts
import com.artemchep.keyguard.common.usecase.GetSubscriptions import com.artemchep.keyguard.common.usecase.GetSubscriptions
import com.artemchep.keyguard.feature.auth.common.TextFieldModel2 import com.artemchep.keyguard.feature.auth.common.TextFieldModel2
import com.artemchep.keyguard.feature.home.vault.component.Section import com.artemchep.keyguard.feature.home.vault.component.Section
import com.artemchep.keyguard.feature.home.vault.component.surfaceColorAtElevationSemi
import com.artemchep.keyguard.feature.onboarding.OnboardingCard import com.artemchep.keyguard.feature.onboarding.OnboardingCard
import com.artemchep.keyguard.feature.onboarding.onboardingItemsPremium import com.artemchep.keyguard.feature.onboarding.onboardingItemsPremium
import com.artemchep.keyguard.platform.LocalLeContext import com.artemchep.keyguard.platform.LocalLeContext
import com.artemchep.keyguard.res.Res import com.artemchep.keyguard.res.Res
import com.artemchep.keyguard.res.* import com.artemchep.keyguard.res.*
import com.artemchep.keyguard.ui.Ah import com.artemchep.keyguard.ui.Ah
import com.artemchep.keyguard.ui.DisabledEmphasisAlpha import com.artemchep.keyguard.ui.DefaultEmphasisAlpha
import com.artemchep.keyguard.ui.ExpandedIfNotEmpty import com.artemchep.keyguard.ui.ExpandedIfNotEmpty
import com.artemchep.keyguard.ui.FlatItemLayout import com.artemchep.keyguard.ui.FlatItemLayout
import com.artemchep.keyguard.ui.FlatItemTextContent import com.artemchep.keyguard.ui.FlatItemTextContent
import com.artemchep.keyguard.ui.FlatSimpleNote import com.artemchep.keyguard.ui.FlatSimpleNote
import com.artemchep.keyguard.ui.FlatTextFieldBadge import com.artemchep.keyguard.ui.FlatTextFieldBadge
import com.artemchep.keyguard.ui.GridLayout
import com.artemchep.keyguard.ui.MediumEmphasisAlpha import com.artemchep.keyguard.ui.MediumEmphasisAlpha
import com.artemchep.keyguard.ui.SimpleNote import com.artemchep.keyguard.ui.SimpleNote
import com.artemchep.keyguard.ui.icons.ChevronIcon import com.artemchep.keyguard.ui.icons.ChevronIcon
import com.artemchep.keyguard.ui.shimmer.shimmer import com.artemchep.keyguard.ui.shimmer.shimmer
import com.artemchep.keyguard.ui.skeleton.SkeletonText
import com.artemchep.keyguard.ui.theme.Dimens import com.artemchep.keyguard.ui.theme.Dimens
import com.artemchep.keyguard.ui.theme.combineAlpha import com.artemchep.keyguard.ui.theme.combineAlpha
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@ -152,8 +164,18 @@ private fun SettingSubscriptions(
Section(text = stringResource(Res.string.pref_item_premium_membership_section_subscriptions_title)) Section(text = stringResource(Res.string.pref_item_premium_membership_section_subscriptions_title))
loadableSubscriptions.fold( loadableSubscriptions.fold(
ifLoading = { ifLoading = {
repeat(SubscriptionsCountDefault) { GridLayout(
SettingSubscriptionSkeletonItem() modifier = Modifier
.padding(horizontal = 8.dp),
columns = 2,
mainAxisSpacing = 8.dp,
crossAxisSpacing = 8.dp,
) {
repeat(SubscriptionsCountDefault) {
SettingSubscriptionSkeletonItem(
modifier = Modifier,
)
}
} }
}, },
ifOk = { subscriptions -> ifOk = { subscriptions ->
@ -164,10 +186,19 @@ private fun SettingSubscriptions(
) )
return@fold return@fold
} }
subscriptions.forEach { subscription -> GridLayout(
SettingSubscriptionItem( modifier = Modifier
subscription = subscription, .padding(horizontal = 8.dp),
) columns = 2,
mainAxisSpacing = 8.dp,
crossAxisSpacing = 8.dp,
) {
subscriptions.forEach { subscription ->
SettingSubscriptionItem(
modifier = Modifier,
subscription = subscription,
)
}
} }
}, },
) )
@ -179,6 +210,7 @@ private fun SettingSubscriptions(
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = LocalContentColor.current color = LocalContentColor.current
.combineAlpha(MediumEmphasisAlpha), .combineAlpha(MediumEmphasisAlpha),
fontSize = 12.sp,
) )
Section(text = stringResource(Res.string.pref_item_premium_membership_section_products_title)) Section(text = stringResource(Res.string.pref_item_premium_membership_section_products_title))
loadableProducts.fold( loadableProducts.fold(
@ -206,112 +238,127 @@ private fun SettingSubscriptions(
} }
@Composable @Composable
private fun SettingSubscriptionSkeletonItem() { private fun SettingSubscriptionSkeletonItem(
val contentColor = modifier: Modifier = Modifier,
LocalContentColor.current.copy(alpha = DisabledEmphasisAlpha) ) {
FlatItemLayout( SettingSubscriptionContentItem(
modifier = Modifier modifier = modifier
.shimmer(), .shimmer(),
leading = { isActive = false,
Box( ) {
Column(
modifier = Modifier
.padding(8.dp),
) {
SkeletonText(
modifier = Modifier modifier = Modifier
.size(24.dp) .fillMaxWidth(0.45f),
.background(contentColor, CircleShape), style = MaterialTheme.typography.titleSmall,
) )
}, Spacer(modifier = Modifier.height(8.dp))
content = { SkeletonText(
Box( modifier = Modifier
Modifier .fillMaxWidth(0.45f),
.height(18.dp) style = MaterialTheme.typography.titleMedium,
.fillMaxWidth(0.45f)
.clip(MaterialTheme.shapes.medium)
.background(contentColor),
) )
Box( }
Modifier }
.padding(top = 4.dp)
.height(15.dp)
.fillMaxWidth(0.35f)
.clip(MaterialTheme.shapes.medium)
.background(contentColor.copy(alpha = 0.2f)),
)
},
)
} }
@Composable @Composable
private fun SettingSubscriptionItem( private fun SettingSubscriptionItem(
modifier: Modifier = Modifier,
subscription: Subscription, subscription: Subscription,
) { ) {
val context by rememberUpdatedState(LocalLeContext) val context by rememberUpdatedState(LocalLeContext)
FlatItemLayout( SettingSubscriptionContentItem(
leading = { modifier = modifier,
val backgroundColor = MaterialTheme.colorScheme.secondaryContainer isActive = subscription.status is Subscription.Status.Active,
Box( ) {
modifier = Modifier Column(
.size(24.dp) modifier = Modifier
.background(backgroundColor, CircleShape) .clickable(role = Role.Button) {
.padding(4.dp), subscription.purchase(context)
) {
val targetContentColor = kotlin.run {
val active = subscription.status is Subscription.Status.Active
if (active) {
MaterialTheme.colorScheme.primary
} else {
contentColorFor(backgroundColor)
}
} }
val contentColor by animateColorAsState(targetValue = targetContentColor) .padding(8.dp),
Icon( ) {
Icons.Outlined.Star, val localEmphasis = DefaultEmphasisAlpha
contentDescription = null, val localTextStyle = TextStyle(
tint = contentColor, color = LocalContentColor.current
) .combineAlpha(localEmphasis),
}
},
elevation = 2.dp,
trailing = {
ChevronIcon()
},
content = {
FlatItemTextContent(
title = {
Text(subscription.price)
},
text = {
Text(subscription.title)
},
) )
val statusOrNull = subscription.status as? Subscription.Status.Active CompositionLocalProvider(
ExpandedIfNotEmpty(statusOrNull) { status -> LocalTextStyle provides MaterialTheme.typography.titleSmall
Row( .merge(localTextStyle),
modifier = Modifier ) {
.padding(top = 4.dp), Text(
) { subscription.title,
Ah( color = LocalContentColor.current
score = 1f, .combineAlpha(MediumEmphasisAlpha),
text = stringResource(Res.string.pref_item_premium_status_active), )
)
val isCancelled = !status.willRenew
AnimatedVisibility(
modifier = Modifier
.padding(start = 4.dp),
visible = isCancelled,
) {
Ah(
score = 0f,
text = stringResource(Res.string.pref_item_premium_status_will_not_renew),
)
}
}
} }
}, Spacer(modifier = Modifier.height(8.dp))
onClick = { CompositionLocalProvider(
subscription.purchase(context) LocalTextStyle provides MaterialTheme.typography.titleMedium
}, .merge(localTextStyle),
) ) {
val text = "${subscription.price} / ${subscription.periodFormatted}"
Text(text)
}
Spacer(modifier = Modifier.height(8.dp))
val status = subscription.status
if (status is Subscription.Status.Inactive && status.trialPeriodFormatted != null) {
FlatTextFieldBadge(
type = TextFieldModel2.Vl.Type.INFO,
text = stringResource(
Res.string.pref_item_premium_status_free_trial_n,
status.trialPeriodFormatted,
),
)
}
}
}
}
@Composable
private fun SettingSubscriptionContentItem(
modifier: Modifier = Modifier,
isActive: Boolean,
content: @Composable BoxScope.() -> Unit,
) {
val backgroundModifier = run {
val tintColor = MaterialTheme.colorScheme
.surfaceColorAtElevationSemi(1.dp)
Modifier
.background(tintColor)
}
val borderModifier = if (isActive) {
Modifier
.border(1.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.medium)
} else Modifier
Box(
modifier = modifier
.clip(MaterialTheme.shapes.medium)
.then(backgroundModifier)
.then(borderModifier),
propagateMinConstraints = true,
) {
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.TopEnd,
) {
Icon(
Icons.Outlined.Star,
modifier = Modifier
.size(128.dp)
.alpha(0.035f),
contentDescription = null,
)
}
content()
}
} }
@Composable @Composable

View File

@ -1,5 +1,6 @@
package com.artemchep.keyguard.ui package com.artemchep.keyguard.ui
import com.artemchep.keyguard.common.model.DurationSimple
import com.artemchep.keyguard.feature.localization.textResource import com.artemchep.keyguard.feature.localization.textResource
import com.artemchep.keyguard.platform.LeContext import com.artemchep.keyguard.platform.LeContext
import com.artemchep.keyguard.res.Res import com.artemchep.keyguard.res.Res
@ -62,6 +63,51 @@ suspend fun Duration.format(context: LeContext): String {
?: toString() ?: toString()
} }
suspend fun DurationSimple.format(context: LeContext): String {
return flow<String> {
if (years > 0) {
val yearsFormatted = textResource(
res = Res.plurals.years_plural,
context = context,
quantity = years,
years.toString(),
)
emit(yearsFormatted)
}
if (months > 0) {
val monthsFormatted = textResource(
res = Res.plurals.months_plural,
context = context,
quantity = months,
months.toString(),
)
emit(monthsFormatted)
}
if (weeks > 0) {
val weeksFormatted = textResource(
res = Res.plurals.weeks_plural,
context = context,
quantity = weeks,
weeks.toString(),
)
emit(weeksFormatted)
}
if (days > 0) {
val daysFormatted = textResource(
res = Res.plurals.days_plural,
context = context,
quantity = days,
days.toString(),
)
emit(daysFormatted)
}
}
.toList()
.joinToString(separator = " ")
.takeIf { it.isNotEmpty() }
?: toString()
}
private inline fun daysToHours(days: Long) = days * 24 private inline fun daysToHours(days: Long) = days * 24
private inline fun hoursToMinutes(hours: Long) = hours * 60 private inline fun hoursToMinutes(hours: Long) = hours * 60