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.timeout
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.RichResult
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.service.subscription.SubscriptionService
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.GlobalScope
import kotlinx.coroutines.flow.Flow
@ -37,6 +40,7 @@ import org.kodein.di.DirectDI
import org.kodein.di.instance
class SubscriptionServiceAndroid(
private val context: LeContext,
private val billingManager: BillingManager,
) : SubscriptionService {
companion object {
@ -53,6 +57,7 @@ class SubscriptionServiceAndroid(
constructor(
directDI: DirectDI,
) : this(
context = directDI.instance(),
billingManager = directDI.instance(),
)
@ -92,16 +97,23 @@ class SubscriptionServiceAndroid(
.pricingPhaseList
.last()
val period = DurationSimple.parse(finalPrice.billingPeriod)
val periodFormatted = period.format(context)
val status = kotlin.run {
val skuReceipt =
receipts?.firstOrNull { purchase -> it.productId in purchase.products }
?: return@run kotlin.run {
val hasTrialAvailable = bestOffer?.pricingPhases
val trialPeriodStr = bestOffer?.pricingPhases
?.pricingPhaseList
?.firstOrNull()
?.priceAmountMicros == 0L
?.takeIf { it.priceAmountMicros == 0L }
?.billingPeriod
val trialPeriod = trialPeriodStr
?.let { DurationSimple.parse(it) }
val trialPeriodFormatted = trialPeriod?.format(context)
Subscription.Status.Inactive(
hasTrialAvailable = hasTrialAvailable,
trialPeriod = trialPeriod,
trialPeriodFormatted = trialPeriodFormatted,
)
}
Subscription.Status.Active(
@ -114,6 +126,8 @@ class SubscriptionServiceAndroid(
description = it.description,
price = finalPrice.formattedPrice,
status = status,
period = period,
periodFormatted = periodFormatted,
purchase = { context ->
val activity = context.context.closestActivityOrNull
?: return@Subscription

View File

@ -64,4 +64,28 @@
<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>
</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>

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_products">Failed to load a list of products</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_manage_subscription_on_play_store_title">Manage on Play Store</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 price: String,
val status: Status,
val period: DurationSimple,
val periodFormatted: String,
val purchase: (LeContext) -> Unit,
) {
sealed interface Status {
data class Inactive(
val hasTrialAvailable: Boolean,
val trialPeriod: DurationSimple?,
val trialPeriodFormatted: String?,
) : Status
data class Active(

View File

@ -1,19 +1,21 @@
package com.artemchep.keyguard.feature.home.settings.component
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
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.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
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.sp
import com.artemchep.keyguard.common.model.Loadable
import com.artemchep.keyguard.common.model.Product
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.feature.auth.common.TextFieldModel2
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.onboardingItemsPremium
import com.artemchep.keyguard.platform.LocalLeContext
import com.artemchep.keyguard.res.Res
import com.artemchep.keyguard.res.*
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.FlatItemLayout
import com.artemchep.keyguard.ui.FlatItemTextContent
import com.artemchep.keyguard.ui.FlatSimpleNote
import com.artemchep.keyguard.ui.FlatTextFieldBadge
import com.artemchep.keyguard.ui.GridLayout
import com.artemchep.keyguard.ui.MediumEmphasisAlpha
import com.artemchep.keyguard.ui.SimpleNote
import com.artemchep.keyguard.ui.icons.ChevronIcon
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.combineAlpha
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))
loadableSubscriptions.fold(
ifLoading = {
repeat(SubscriptionsCountDefault) {
SettingSubscriptionSkeletonItem()
GridLayout(
modifier = Modifier
.padding(horizontal = 8.dp),
columns = 2,
mainAxisSpacing = 8.dp,
crossAxisSpacing = 8.dp,
) {
repeat(SubscriptionsCountDefault) {
SettingSubscriptionSkeletonItem(
modifier = Modifier,
)
}
}
},
ifOk = { subscriptions ->
@ -164,10 +186,19 @@ private fun SettingSubscriptions(
)
return@fold
}
subscriptions.forEach { subscription ->
SettingSubscriptionItem(
subscription = subscription,
)
GridLayout(
modifier = Modifier
.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,
color = LocalContentColor.current
.combineAlpha(MediumEmphasisAlpha),
fontSize = 12.sp,
)
Section(text = stringResource(Res.string.pref_item_premium_membership_section_products_title))
loadableProducts.fold(
@ -206,112 +238,127 @@ private fun SettingSubscriptions(
}
@Composable
private fun SettingSubscriptionSkeletonItem() {
val contentColor =
LocalContentColor.current.copy(alpha = DisabledEmphasisAlpha)
FlatItemLayout(
modifier = Modifier
private fun SettingSubscriptionSkeletonItem(
modifier: Modifier = Modifier,
) {
SettingSubscriptionContentItem(
modifier = modifier
.shimmer(),
leading = {
Box(
isActive = false,
) {
Column(
modifier = Modifier
.padding(8.dp),
) {
SkeletonText(
modifier = Modifier
.size(24.dp)
.background(contentColor, CircleShape),
.fillMaxWidth(0.45f),
style = MaterialTheme.typography.titleSmall,
)
},
content = {
Box(
Modifier
.height(18.dp)
.fillMaxWidth(0.45f)
.clip(MaterialTheme.shapes.medium)
.background(contentColor),
Spacer(modifier = Modifier.height(8.dp))
SkeletonText(
modifier = Modifier
.fillMaxWidth(0.45f),
style = MaterialTheme.typography.titleMedium,
)
Box(
Modifier
.padding(top = 4.dp)
.height(15.dp)
.fillMaxWidth(0.35f)
.clip(MaterialTheme.shapes.medium)
.background(contentColor.copy(alpha = 0.2f)),
)
},
)
}
}
}
@Composable
private fun SettingSubscriptionItem(
modifier: Modifier = Modifier,
subscription: Subscription,
) {
val context by rememberUpdatedState(LocalLeContext)
FlatItemLayout(
leading = {
val backgroundColor = MaterialTheme.colorScheme.secondaryContainer
Box(
modifier = Modifier
.size(24.dp)
.background(backgroundColor, CircleShape)
.padding(4.dp),
) {
val targetContentColor = kotlin.run {
val active = subscription.status is Subscription.Status.Active
if (active) {
MaterialTheme.colorScheme.primary
} else {
contentColorFor(backgroundColor)
}
SettingSubscriptionContentItem(
modifier = modifier,
isActive = subscription.status is Subscription.Status.Active,
) {
Column(
modifier = Modifier
.clickable(role = Role.Button) {
subscription.purchase(context)
}
val contentColor by animateColorAsState(targetValue = targetContentColor)
Icon(
Icons.Outlined.Star,
contentDescription = null,
tint = contentColor,
)
}
},
elevation = 2.dp,
trailing = {
ChevronIcon()
},
content = {
FlatItemTextContent(
title = {
Text(subscription.price)
},
text = {
Text(subscription.title)
},
.padding(8.dp),
) {
val localEmphasis = DefaultEmphasisAlpha
val localTextStyle = TextStyle(
color = LocalContentColor.current
.combineAlpha(localEmphasis),
)
val statusOrNull = subscription.status as? Subscription.Status.Active
ExpandedIfNotEmpty(statusOrNull) { status ->
Row(
modifier = Modifier
.padding(top = 4.dp),
) {
Ah(
score = 1f,
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),
)
}
}
CompositionLocalProvider(
LocalTextStyle provides MaterialTheme.typography.titleSmall
.merge(localTextStyle),
) {
Text(
subscription.title,
color = LocalContentColor.current
.combineAlpha(MediumEmphasisAlpha),
)
}
},
onClick = {
subscription.purchase(context)
},
)
Spacer(modifier = Modifier.height(8.dp))
CompositionLocalProvider(
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

View File

@ -1,5 +1,6 @@
package com.artemchep.keyguard.ui
import com.artemchep.keyguard.common.model.DurationSimple
import com.artemchep.keyguard.feature.localization.textResource
import com.artemchep.keyguard.platform.LeContext
import com.artemchep.keyguard.res.Res
@ -62,6 +63,51 @@ suspend fun Duration.format(context: LeContext): String {
?: 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 hoursToMinutes(hours: Long) = hours * 60