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 e1c594cb..cd38d532 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 551c767b..a73657cd 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 c5b82d92..8fcd668c 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 00000000..e97aa97a
--- /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 d7866403..5bea2de4 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 821984fc..cae05d08 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 6217aa64..ef5c1b8a 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