From 7d1d927c4c1e0d43d1b8112de781ae84198c010d Mon Sep 17 00:00:00 2001 From: Artem Chepurnoy Date: Sun, 1 Sep 2024 17:26:11 +0300 Subject: [PATCH] improvement(Android): Small redesign for Subscriptions to solve Play store issues --- .../copy/SubscriptionServiceAndroid.kt | 20 +- .../composeResources/values/plurals.xml | 24 ++ .../composeResources/values/strings.xml | 2 +- .../keyguard/common/model/DurationSimple.kt | 30 +++ .../keyguard/common/model/Subscription.kt | 5 +- .../component/SettingSubscriptions.kt | 247 +++++++++++------- .../com/artemchep/keyguard/ui/Duration.kt | 46 ++++ 7 files changed, 269 insertions(+), 105 deletions(-) create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/DurationSimple.kt diff --git a/common/src/androidMain/kotlin/com/artemchep/keyguard/copy/SubscriptionServiceAndroid.kt b/common/src/androidMain/kotlin/com/artemchep/keyguard/copy/SubscriptionServiceAndroid.kt index e1c594c..cd38d53 100644 --- a/common/src/androidMain/kotlin/com/artemchep/keyguard/copy/SubscriptionServiceAndroid.kt +++ b/common/src/androidMain/kotlin/com/artemchep/keyguard/copy/SubscriptionServiceAndroid.kt @@ -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 diff --git a/common/src/commonMain/composeResources/values/plurals.xml b/common/src/commonMain/composeResources/values/plurals.xml index 551c767..a73657c 100644 --- a/common/src/commonMain/composeResources/values/plurals.xml +++ b/common/src/commonMain/composeResources/values/plurals.xml @@ -64,4 +64,28 @@ %1$s days %1$s days + + %1$s weeks + %1$s week + %1$s weeks + %1$s weeks + %1$s weeks + %1$s weeks + + + %1$s months + %1$s month + %1$s months + %1$s months + %1$s months + %1$s months + + + %1$s years + %1$s year + %1$s years + %1$s years + %1$s years + %1$s years + diff --git a/common/src/commonMain/composeResources/values/strings.xml b/common/src/commonMain/composeResources/values/strings.xml index c5b82d9..8fcd668 100644 --- a/common/src/commonMain/composeResources/values/strings.xml +++ b/common/src/commonMain/composeResources/values/strings.xml @@ -1083,7 +1083,7 @@ Failed to load a list of subscriptions Failed to load a list of products Active - Free trial + Free %1$s trial Will not renew Manage on Play Store Post notifications diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/DurationSimple.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/DurationSimple.kt new file mode 100644 index 0000000..e97aa97 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/DurationSimple.kt @@ -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()), + ) + } + } +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/Subscription.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/Subscription.kt index d786640..5bea2de 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/Subscription.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/Subscription.kt @@ -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( diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/component/SettingSubscriptions.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/component/SettingSubscriptions.kt index 821984f..cae05d0 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/component/SettingSubscriptions.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/component/SettingSubscriptions.kt @@ -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 diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/ui/Duration.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/ui/Duration.kt index 6217aa6..ef5c1b8 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/ui/Duration.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/ui/Duration.kt @@ -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 { + 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