mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-23 08:33:09 +00:00
ui: badges app store integration (#7374)
This commit is contained in:
1 parent
cd670548b9
commit
9c845222e4
15 files changed
+1157
-85
No files matched your search
+12
@@ -9,6 +9,10 @@ import androidx.compose.ui.graphics.Color
|
||||
import chat.simplex.common.model.ChatId
|
||||
import chat.simplex.common.model.NotificationsMode
|
||||
import chat.simplex.common.ui.theme.CurrentColors
|
||||
import chat.simplex.common.views.badges.BadgeProduct
|
||||
import chat.simplex.common.views.badges.BadgePurchaseOutcome
|
||||
import chat.simplex.common.views.badges.BadgeStoreError
|
||||
import chat.simplex.common.views.badges.BadgeStoreProductId
|
||||
import kotlinx.coroutines.Job
|
||||
import java.io.Closeable
|
||||
|
||||
@@ -31,6 +35,14 @@ interface PlatformInterface {
|
||||
fun androidIsXiaomiDevice(): Boolean = false
|
||||
// Requests the Google Play account country into [androidPlayStoreCountry]
|
||||
fun androidLoadPlayStoreCountry() {}
|
||||
// Play Billing, only implemented in the google flavor
|
||||
// TODO [badges] desktop and foss pay via Stripe/crypto - these defaults leave them without any
|
||||
// product until that path is implemented
|
||||
suspend fun androidLoadBadgeProducts(oneTimeIds: List<BadgeStoreProductId>, subscriptionIds: List<BadgeStoreProductId>): List<BadgeProduct> {
|
||||
Log.w(TAG, "androidLoadBadgeProducts: no store on this platform")
|
||||
return emptyList()
|
||||
}
|
||||
suspend fun androidPurchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = throw BadgeStoreError.StoreUnavailable
|
||||
val androidApiLevel: Int? get() = null
|
||||
// The build distributed via Google Play, which has to follow its policies
|
||||
val androidIsPlayStoreBuild: Boolean get() = false
|
||||
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
package chat.simplex.common.views.badges
|
||||
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import chat.simplex.common.platform.*
|
||||
import java.text.NumberFormat
|
||||
import java.util.Currency
|
||||
import java.util.UUID
|
||||
|
||||
// a subscription is one store product containing a base plan per duration, so a purchasable badge
|
||||
// is identified by both; one-time products have no base plan
|
||||
data class BadgeStoreProductId(val productId: String, val basePlanId: String? = null)
|
||||
|
||||
// TODO [badges] ids will come from app config and prices from the badge service catalog;
|
||||
// hardcoded here so the Play Store integration can be tested before the purchase API lands.
|
||||
fun badgeStoreProductId(level: BadgeLevel, period: BadgePeriod): BadgeStoreProductId = when (level) {
|
||||
BadgeLevel.Supporter -> when (period) {
|
||||
BadgePeriod.OneMonth -> BadgeStoreProductId("badge_supporter_01")
|
||||
BadgePeriod.Monthly -> BadgeStoreProductId("subscr_badge_supporter_01", "subscr-badge-supporter-month-02")
|
||||
BadgePeriod.Annual -> BadgeStoreProductId("subscr_badge_supporter_01", "subscr-badge-supporter-year-01")
|
||||
}
|
||||
BadgeLevel.Legend -> when (period) {
|
||||
BadgePeriod.OneMonth -> BadgeStoreProductId("badge_legend_01")
|
||||
BadgePeriod.Monthly -> BadgeStoreProductId("subscr_badge_legend_01", "subscr-badge-legend-month-01")
|
||||
BadgePeriod.Annual -> BadgeStoreProductId("subscr_badge_legend_01", "subscr-badge-legend-year-01")
|
||||
}
|
||||
}
|
||||
|
||||
val badgeStoreProductIds: List<BadgeStoreProductId> = BadgeLevel.entries.flatMap { level ->
|
||||
BadgePeriod.entries.map { badgeStoreProductId(level, it) }
|
||||
}
|
||||
|
||||
// TODO [badges] replaced by APIGetBadgeInvoice, which creates the invoice row and returns its id.
|
||||
// Sent to Play as obfuscatedAccountId and echoed back on the purchase, which is how the service
|
||||
// learns which invoice a store transaction settles.
|
||||
fun newBadgeInvoiceId(): String = UUID.randomUUID().toString()
|
||||
|
||||
// what the platform store knows about one product; ProductDetails cannot cross into commonMain
|
||||
data class BadgeProduct(
|
||||
val id: BadgeStoreProductId,
|
||||
val displayPrice: String,
|
||||
val priceMicros: Long,
|
||||
val currencyCode: String
|
||||
)
|
||||
|
||||
sealed class BadgePrice {
|
||||
object Loading: BadgePrice()
|
||||
class Price(val price: String): BadgePrice()
|
||||
object Unavailable: BadgePrice()
|
||||
|
||||
val canPurchase: Boolean
|
||||
get() = when (this) {
|
||||
is Price -> true
|
||||
is Loading, is Unavailable -> false
|
||||
}
|
||||
}
|
||||
|
||||
data class BadgeStoreReceipt(
|
||||
// the token the badge service verifies with the Publisher API
|
||||
val token: String,
|
||||
val productId: String,
|
||||
val orderId: String?,
|
||||
val invoiceId: String?,
|
||||
// only set for test products - a real Play purchase has no environment to report
|
||||
val environment: String? = null
|
||||
)
|
||||
|
||||
// TODO [badges] Play Billing has no offline product configuration. Set to true to price the screens
|
||||
// and walk the purchase flow without Play Console products; the purchase is simulated and its
|
||||
// receipt says so.
|
||||
const val useBadgeTestProducts = false
|
||||
|
||||
private fun testProduct(level: BadgeLevel, period: BadgePeriod, priceMicros: Long) =
|
||||
BadgeProduct(badgeStoreProductId(level, period), "\$${priceMicros / 1_000_000}.00", priceMicros, "USD")
|
||||
|
||||
private val testBadgeProducts: List<BadgeProduct> = listOf(
|
||||
testProduct(BadgeLevel.Supporter, BadgePeriod.OneMonth, 7_000_000),
|
||||
testProduct(BadgeLevel.Supporter, BadgePeriod.Monthly, 7_000_000),
|
||||
testProduct(BadgeLevel.Supporter, BadgePeriod.Annual, 42_000_000),
|
||||
testProduct(BadgeLevel.Legend, BadgePeriod.OneMonth, 70_000_000),
|
||||
testProduct(BadgeLevel.Legend, BadgePeriod.Monthly, 70_000_000),
|
||||
testProduct(BadgeLevel.Legend, BadgePeriod.Annual, 420_000_000)
|
||||
)
|
||||
|
||||
sealed class BadgePurchaseOutcome {
|
||||
class Purchased(val receipt: BadgeStoreReceipt): BadgePurchaseOutcome()
|
||||
object Pending: BadgePurchaseOutcome()
|
||||
object Cancelled: BadgePurchaseOutcome()
|
||||
}
|
||||
|
||||
sealed class BadgeStoreError: Exception() {
|
||||
class ProductUnavailable(val productId: String): BadgeStoreError()
|
||||
class BillingError(val responseCode: Int, val debugMessage: String): BadgeStoreError()
|
||||
object StoreUnavailable: BadgeStoreError()
|
||||
|
||||
override val message: String
|
||||
get() = when (this) {
|
||||
is ProductUnavailable -> "productUnavailable(productId: $productId)"
|
||||
is BillingError -> "billingError(responseCode: $responseCode, $debugMessage)"
|
||||
is StoreUnavailable -> "storeUnavailable"
|
||||
}
|
||||
}
|
||||
|
||||
object BadgeStore {
|
||||
private enum class LoadState { NotLoaded, Loading, Loaded, Failed }
|
||||
|
||||
private val state = mutableStateOf(LoadState.NotLoaded)
|
||||
// snapshot state so a composable reading only the products still recomposes when they arrive
|
||||
private val products = mutableStateOf<Map<BadgeStoreProductId, BadgeProduct>>(emptyMap())
|
||||
|
||||
fun price(level: BadgeLevel, period: BadgePeriod): BadgePrice = when (state.value) {
|
||||
LoadState.NotLoaded, LoadState.Loading -> BadgePrice.Loading
|
||||
LoadState.Loaded, LoadState.Failed -> {
|
||||
val p = products.value[badgeStoreProductId(level, period)]
|
||||
if (p != null) BadgePrice.Price(compactPrice(p)) else BadgePrice.Unavailable
|
||||
}
|
||||
}
|
||||
|
||||
fun annualSavings(level: BadgeLevel): Int? {
|
||||
val monthly = products.value[badgeStoreProductId(level, BadgePeriod.Monthly)] ?: return null
|
||||
val annual = products.value[badgeStoreProductId(level, BadgePeriod.Annual)] ?: return null
|
||||
val year = monthly.priceMicros * 12
|
||||
if (year <= 0 || annual.priceMicros >= year) return null
|
||||
val percent = Math.round((year - annual.priceMicros).toDouble() / year * 100).toInt()
|
||||
return if (percent > 0) percent else null
|
||||
}
|
||||
|
||||
suspend fun load() {
|
||||
if (!startLoading()) return
|
||||
try {
|
||||
// TODO [badges] desktop and the foss build will price from the badge service catalog and pay
|
||||
// via Stripe/crypto instead of a store; only the google build reaches the platform store
|
||||
val loaded = if (useBadgeTestProducts) testBadgeProducts else platform.androidLoadBadgeProducts(
|
||||
oneTimeIds = badgeStoreProductIds.filter { it.basePlanId == null },
|
||||
subscriptionIds = badgeStoreProductIds.filter { it.basePlanId != null }
|
||||
)
|
||||
val byId = loaded.associateBy { it.id }
|
||||
val missing = badgeStoreProductIds.filter { !byId.containsKey(it) }
|
||||
if (missing.isNotEmpty()) {
|
||||
Log.w(TAG, "BadgeStore.load: no product returned for ${missing.joinToString(", ")}")
|
||||
}
|
||||
products.value = byId
|
||||
state.value = LoadState.Loaded
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "BadgeStore.load: ${e.stackTraceToString()}")
|
||||
state.value = LoadState.Failed
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun purchase(level: BadgeLevel, period: BadgePeriod, invoiceId: String): BadgePurchaseOutcome {
|
||||
val id = badgeStoreProductId(level, period)
|
||||
if (!products.value.containsKey(id)) throw BadgeStoreError.ProductUnavailable(id.productId)
|
||||
if (useBadgeTestProducts) {
|
||||
return BadgePurchaseOutcome.Purchased(
|
||||
BadgeStoreReceipt(
|
||||
token = "test-${UUID.randomUUID()}",
|
||||
productId = id.productId,
|
||||
orderId = null,
|
||||
invoiceId = invoiceId,
|
||||
environment = "test products"
|
||||
)
|
||||
)
|
||||
}
|
||||
return platform.androidPurchaseBadge(id, invoiceId)
|
||||
}
|
||||
|
||||
private fun startLoading(): Boolean = when (state.value) {
|
||||
LoadState.NotLoaded, LoadState.Failed -> {
|
||||
state.value = LoadState.Loading
|
||||
true
|
||||
}
|
||||
LoadState.Loading, LoadState.Loaded -> false
|
||||
}
|
||||
}
|
||||
|
||||
// drops the fraction from whole amounts ("$7", not "$7.00") in the product's own currency;
|
||||
// BadgeProduct.displayPrice remains the exact form for views that need the cents
|
||||
private fun compactPrice(product: BadgeProduct): String {
|
||||
if (product.priceMicros % 1_000_000L != 0L) return product.displayPrice
|
||||
return try {
|
||||
val format = NumberFormat.getCurrencyInstance()
|
||||
format.currency = Currency.getInstance(product.currencyCode)
|
||||
format.maximumFractionDigits = 0
|
||||
format.format(product.priceMicros / 1_000_000L)
|
||||
} catch (e: Exception) {
|
||||
product.displayPrice
|
||||
}
|
||||
}
|
||||
+124
-19
@@ -10,6 +10,9 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.ClipboardManager
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -25,24 +28,52 @@ import chat.simplex.res.MR
|
||||
// TODO [badges]: replace with types produced by the badge purchase API when it lands.
|
||||
enum class BadgePeriod {
|
||||
OneMonth,
|
||||
Subscribe;
|
||||
Monthly,
|
||||
Annual;
|
||||
|
||||
val icon: dev.icerock.moko.resources.ImageResource
|
||||
get() = when (this) {
|
||||
OneMonth -> MR.images.ic_calendar
|
||||
Subscribe -> MR.images.ic_refresh
|
||||
Monthly -> MR.images.ic_refresh
|
||||
Annual -> MR.images.ic_refresh
|
||||
}
|
||||
|
||||
val label: StringResource
|
||||
get() = when (this) {
|
||||
OneMonth -> MR.strings.badges_period_one_month
|
||||
Subscribe -> MR.strings.badges_period_subscribe
|
||||
Monthly -> MR.strings.badges_period_monthly
|
||||
Annual -> MR.strings.badges_period_annual
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun priceText(price: BadgePrice): String = when (price) {
|
||||
is BadgePrice.Loading -> "…"
|
||||
is BadgePrice.Unavailable -> "—"
|
||||
is BadgePrice.Price -> when (this) {
|
||||
OneMonth -> price.price
|
||||
Monthly -> stringResource(MR.strings.badges_price_monthly).format(price.price)
|
||||
Annual -> stringResource(MR.strings.badges_price_annual).format(price.price)
|
||||
}
|
||||
}
|
||||
|
||||
fun payLabel(price: BadgePrice): Pair<StringResource, String?> = when (price) {
|
||||
is BadgePrice.Loading -> MR.strings.badges_price_loading to null
|
||||
is BadgePrice.Unavailable -> MR.strings.badges_price_unavailable to null
|
||||
is BadgePrice.Price -> when (this) {
|
||||
OneMonth -> MR.strings.badges_pay_once to price.price
|
||||
Monthly -> MR.strings.badges_pay_monthly to price.price
|
||||
Annual -> MR.strings.badges_pay_annual to price.price
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BadgesPayView(level: BadgeLevel) {
|
||||
var selectedPeriod by remember { mutableStateOf(BadgePeriod.Subscribe) }
|
||||
var selectedPeriod by remember { mutableStateOf(BadgePeriod.Monthly) }
|
||||
val purchasing = remember { mutableStateOf(false) }
|
||||
val clipboard = LocalClipboardManager.current
|
||||
|
||||
LaunchedEffect(Unit) { BadgeStore.load() }
|
||||
|
||||
ColumnWithScrollBar(
|
||||
Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp),
|
||||
@@ -69,24 +100,25 @@ fun BadgesPayView(level: BadgeLevel) {
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp)
|
||||
)
|
||||
|
||||
Spacer(Modifier.weight(1f).heightIn(min = 20.dp))
|
||||
Spacer(Modifier.weight(1f).heightIn(min = 8.dp))
|
||||
|
||||
// IntrinsicSize.Max + fillMaxHeight on children so both cards match the taller card's height
|
||||
// when 2-line labels at large fonts would otherwise size them differently.
|
||||
// IntrinsicSize.Max + fillMaxHeight on children so all three cards match the tallest one -
|
||||
// only Annual carries a savings line, and prices wrap at large fonts.
|
||||
Row(
|
||||
Modifier.fillMaxWidth().height(IntrinsicSize.Max),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
PeriodCard(BadgePeriod.OneMonth, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it }
|
||||
PeriodCard(BadgePeriod.Subscribe, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it }
|
||||
PeriodCard(level, BadgePeriod.OneMonth, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it }
|
||||
PeriodCard(level, BadgePeriod.Monthly, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it }
|
||||
PeriodCard(level, BadgePeriod.Annual, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it }
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f).heightIn(min = 20.dp))
|
||||
Spacer(Modifier.weight(1f).heightIn(min = 8.dp))
|
||||
|
||||
// Replicates TextButtonBelowOnboardingButton spacing (7.5dp outer + 5dp inner) without a
|
||||
// TextButton so the footer has no hover/click affordance.
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
PayButton(level, selectedPeriod)
|
||||
PayButton(level, selectedPeriod, purchasing, clipboard)
|
||||
Box(Modifier.padding(top = 7.5.dp, bottom = 7.5.dp).padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Text(
|
||||
stringResource(billingFooter(selectedPeriod)).format(stubBillingDate()),
|
||||
@@ -101,7 +133,7 @@ fun BadgesPayView(level: BadgeLevel) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PeriodCard(period: BadgePeriod, selectedPeriod: BadgePeriod, modifier: Modifier, onSelect: (BadgePeriod) -> Unit) {
|
||||
private fun PeriodCard(level: BadgeLevel, period: BadgePeriod, selectedPeriod: BadgePeriod, modifier: Modifier, onSelect: (BadgePeriod) -> Unit) {
|
||||
val isSelected = period == selectedPeriod
|
||||
val borderColor = if (isSelected) MaterialTheme.colors.primary else MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.92f)
|
||||
// Light: transparent so card matches page background. Dark: subtle gray tint for visible contrast.
|
||||
@@ -114,7 +146,7 @@ private fun PeriodCard(period: BadgePeriod, selectedPeriod: BadgePeriod, modifie
|
||||
.background(cardBackground, shape)
|
||||
.border(2.dp, borderColor, shape)
|
||||
.clickable { onSelect(period) }
|
||||
.padding(vertical = 30.dp, horizontal = 12.dp),
|
||||
.padding(vertical = 20.dp, horizontal = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
@@ -125,24 +157,97 @@ private fun PeriodCard(period: BadgePeriod, selectedPeriod: BadgePeriod, modifie
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
Text(stringResource(period.label), style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center)
|
||||
Text(period.priceText(BadgeStore.price(level, period)), style = MaterialTheme.typography.body1, textAlign = TextAlign.Center)
|
||||
val percent = savingsPercent(level, period)
|
||||
if (percent != null) {
|
||||
Text(
|
||||
stringResource(MR.strings.badges_savings).format("${percent}%"),
|
||||
style = MaterialTheme.typography.body2,
|
||||
color = if (isSelected) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun savingsPercent(level: BadgeLevel, period: BadgePeriod): Int? =
|
||||
if (period == BadgePeriod.Annual) BadgeStore.annualSavings(level) else null
|
||||
|
||||
// TODO [badges] on desktop and the foss build there is no store, so every price is Unavailable and
|
||||
// this button stays disabled - it will offer Stripe/crypto payment instead
|
||||
@Composable
|
||||
private fun PayButton(level: BadgeLevel, selectedPeriod: BadgePeriod) {
|
||||
private fun PayButton(level: BadgeLevel, selectedPeriod: BadgePeriod, purchasing: MutableState<Boolean>, clipboard: ClipboardManager) {
|
||||
val price = BadgeStore.price(level, selectedPeriod)
|
||||
val (labelId, labelArg) = selectedPeriod.payLabel(price)
|
||||
OnboardingActionButton(
|
||||
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp),
|
||||
labelId = if (selectedPeriod == BadgePeriod.Subscribe) MR.strings.badges_pay_monthly else MR.strings.badges_pay_once,
|
||||
labelArg = level.priceAmount,
|
||||
labelId = labelId,
|
||||
labelArg = labelArg,
|
||||
onboarding = null,
|
||||
onclick = {
|
||||
// TODO [badges] wire to purchase API when it lands.
|
||||
enabled = price.canPurchase && !purchasing.value,
|
||||
onclick = { purchase(level, selectedPeriod, purchasing, clipboard) }
|
||||
)
|
||||
}
|
||||
|
||||
private fun purchase(level: BadgeLevel, period: BadgePeriod, purchasing: MutableState<Boolean>, clipboard: ClipboardManager) {
|
||||
val invoiceId = newBadgeInvoiceId()
|
||||
purchasing.value = true
|
||||
// not withBGApi: the purchase waits for the user in the Play sheet and would block chat API calls
|
||||
withLongRunningApi {
|
||||
try {
|
||||
val outcome = BadgeStore.purchase(level, period, invoiceId)
|
||||
purchasing.value = false
|
||||
when (outcome) {
|
||||
is BadgePurchaseOutcome.Purchased -> showPurchasedAlert(outcome.receipt, invoiceId, clipboard)
|
||||
is BadgePurchaseOutcome.Pending -> AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.badges_purchase_pending),
|
||||
text = generalGetString(MR.strings.badges_purchase_pending_desc)
|
||||
)
|
||||
is BadgePurchaseOutcome.Cancelled -> {}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "BadgesPayView.purchase: ${e.stackTraceToString()}")
|
||||
purchasing.value = false
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.badges_purchase_error),
|
||||
text = e.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO [badges] store integration diagnostics - replaced by the issued badge once the service lands.
|
||||
private fun showPurchasedAlert(receipt: BadgeStoreReceipt, invoiceId: String, clipboard: ClipboardManager) {
|
||||
val returnedInvoice = when (receipt.invoiceId) {
|
||||
null -> "none"
|
||||
invoiceId -> "yes"
|
||||
else -> "mismatch: ${receipt.invoiceId}"
|
||||
}
|
||||
val lines = mutableListOf(
|
||||
"Product: ${receipt.productId}",
|
||||
"Invoice: $invoiceId",
|
||||
"Invoice returned by Google: $returnedInvoice",
|
||||
"Order: ${receipt.orderId ?: "none"}"
|
||||
)
|
||||
if (receipt.environment != null) lines.add("Environment: ${receipt.environment}")
|
||||
lines.add("Token: ${receipt.token.length} bytes")
|
||||
val summary = lines.joinToString("\n")
|
||||
Log.d(TAG, "badge purchase succeeded\n$summary")
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.badges_purchase_successful),
|
||||
text = summary,
|
||||
confirmText = "Copy token",
|
||||
onConfirm = {
|
||||
clipboard.setText(AnnotatedString(receipt.token))
|
||||
showToast(generalGetString(MR.strings.copied))
|
||||
},
|
||||
dismissText = generalGetString(MR.strings.ok),
|
||||
parseHtml = false
|
||||
)
|
||||
}
|
||||
|
||||
private fun billingFooter(period: BadgePeriod): StringResource = when (period) {
|
||||
BadgePeriod.Subscribe -> MR.strings.badges_billing_footer_subscribe
|
||||
BadgePeriod.Monthly, BadgePeriod.Annual -> MR.strings.badges_billing_footer_subscribe
|
||||
BadgePeriod.OneMonth -> MR.strings.badges_billing_footer_one_month
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -33,6 +33,9 @@ import chat.simplex.res.MR
|
||||
@Composable
|
||||
fun BadgesSupportSimplexView() {
|
||||
// TODO [badges] gate on user badge status (no badge → this view, active → "Manage your badge")
|
||||
// preloaded here so the level screen shows store prices without a placeholder pass
|
||||
LaunchedEffect(Unit) { BadgeStore.load() }
|
||||
|
||||
ColumnWithScrollBar(
|
||||
Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
|
||||
+3
-7
@@ -40,12 +40,6 @@ enum class BadgeLevel {
|
||||
Legend -> MR.strings.badges_level_legend_files
|
||||
}
|
||||
|
||||
val priceAmount: String
|
||||
get() = when (this) {
|
||||
Supporter -> "$7"
|
||||
Legend -> "$70"
|
||||
}
|
||||
|
||||
val tagline: StringResource
|
||||
get() = when (this) {
|
||||
Supporter -> MR.strings.badges_level_supporter_tagline
|
||||
@@ -63,6 +57,8 @@ enum class BadgeLevel {
|
||||
fun BadgesYourLevelView() {
|
||||
var selectedLevel by remember { mutableStateOf(BadgeLevel.Supporter) }
|
||||
|
||||
LaunchedEffect(Unit) { BadgeStore.load() }
|
||||
|
||||
ColumnWithScrollBar(
|
||||
Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
@@ -139,7 +135,7 @@ private fun LevelCard(level: BadgeLevel, selectedLevel: BadgeLevel, modifier: Mo
|
||||
)
|
||||
Text(stringResource(level.title), style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center)
|
||||
Text(stringResource(level.filesDescription), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center)
|
||||
Text(stringResource(MR.strings.badges_price_monthly).format(level.priceAmount), style = MaterialTheme.typography.body1, textAlign = TextAlign.Center)
|
||||
Text(BadgePeriod.Monthly.priceText(BadgeStore.price(level, BadgePeriod.Monthly)), style = MaterialTheme.typography.body1, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3206,12 +3206,18 @@
|
||||
<string name="badges_level_supporter_files">Send 2GB files</string>
|
||||
<string name="badges_level_legend_files">Send 5GB files</string>
|
||||
<string name="badges_price_monthly">%1$s/month</string>
|
||||
<string name="badges_price_annual">%1$s/year</string>
|
||||
<string name="badges_pay_monthly">Pay %1$s/month</string>
|
||||
<string name="badges_pay_annual">Pay %1$s/year</string>
|
||||
<string name="badges_pay_once">Pay %1$s</string>
|
||||
<string name="badges_price_loading">Loading…</string>
|
||||
<string name="badges_price_unavailable">Not available</string>
|
||||
<string name="badges_savings">Save %1$s</string>
|
||||
<string name="badges_level_supporter_tagline">Optional profile badge\nand 2GB files</string>
|
||||
<string name="badges_level_legend_tagline">Optional profile badge\nand 5GB files</string>
|
||||
<string name="badges_period_one_month">1 month</string>
|
||||
<string name="badges_period_subscribe">Subscribe</string>
|
||||
<string name="badges_period_monthly">Monthly</string>
|
||||
<string name="badges_period_annual">Annual</string>
|
||||
<string name="badges_billing_footer_subscribe">Renews on %1$s. Cancel anytime.</string>
|
||||
<string name="badges_billing_footer_one_month">Ends on %1$s.</string>
|
||||
<string name="badges_your_level_title">Your level</string>
|
||||
@@ -3230,6 +3236,10 @@
|
||||
<string name="badges_banner_title">Support SimpleX</string>
|
||||
<string name="badges_banner_subtitle">Get badge + files up to 5GB</string>
|
||||
<string name="badges_banner_dismiss_message">You can support SimpleX later in Settings.</string>
|
||||
<string name="badges_purchase_successful">Purchase successful</string>
|
||||
<string name="badges_purchase_pending">Purchase pending</string>
|
||||
<string name="badges_purchase_pending_desc">The purchase is awaiting approval. This build does not deliver purchases approved later.</string>
|
||||
<string name="badges_purchase_error">Purchase error</string>
|
||||
<string name="supporter_perks">Supporter perks</string>
|
||||
<string name="v7_1_supporter_badge_title">Supporter badge ❤️</string>
|
||||
<string name="v7_1_supporter_badge_body">Help keep the network running — send files up to 2 GB.</string>
|
||||
|
||||
Reference in new issue
Block a user