ios, android: show the real end date and full price at badge checkout, and disable subscriptions

This commit is contained in:
spaced4ndy
2026-10-02 13:39:36 +04:00
parent 172f70a0e6
commit a70ab3bac5
11 changed files with 133 additions and 98 deletions
@@ -141,11 +141,11 @@ final class BadgeStore: ObservableObject {
return storePurchases.purchases
}
func price(_ level: BadgeLevel, _ period: BadgePeriod) -> BadgePrice {
func price(_ level: BadgeLevel, _ period: BadgePeriod, compact: Bool = true) -> BadgePrice {
switch state {
case .notLoaded, .loading: return .loading
case .loaded, .failed:
if let p = products[badgeProductId(level, period)] { return .price(compactPrice(p)) }
if let p = products[badgeProductId(level, period)] { return .price(compact ? compactPrice(p) : p.displayPrice) }
return .unavailable
}
}
@@ -9,15 +9,9 @@
import SwiftUI
import SimpleXChat
struct BadgeUserPreview<Trailing: View>: View {
struct BadgeUserPreview: View {
@EnvironmentObject var chatModel: ChatModel
let level: BadgeLevel
let trailing: () -> Trailing
init(level: BadgeLevel, @ViewBuilder trailing: @escaping () -> Trailing = { EmptyView() }) {
self.level = level
self.trailing = trailing
}
var body: some View {
let user = chatModel.currentUser
@@ -29,12 +23,9 @@ struct BadgeUserPreview<Trailing: View>: View {
)
return VStack(spacing: 12) {
ProfileImage(imageStr: user?.image, size: 128)
HStack(alignment: .center, spacing: 6) {
NameWithBadge(Text(displayName).font(.largeTitle), previewBadge, .largeTitle)
.lineLimit(1)
.minimumScaleFactor(0.75)
trailing()
}
NameWithBadge(Text(displayName).font(.largeTitle), previewBadge, .largeTitle)
.lineLimit(1)
.minimumScaleFactor(0.75)
}
}
}
@@ -30,19 +30,19 @@ struct BadgesCheckOrderView: View {
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
VStack(spacing: 12) {
VStack(spacing: 0) {
orderRow("Your badge", Text(level.title))
orderRow("Duration", Text(period.label))
orderRow("Total", period.priceText(store.price(level, period)))
Divider()
.padding(.top, 8)
orderRow("Total", period.priceText(store.price(level, period, compact: false)), total: true)
}
.padding(.horizontal, 24)
.padding(.vertical, 12)
.background(Color(uiColor: .secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(.top, 20)
Text("Charged by the App Store to the account on this device.")
.font(.footnote)
.foregroundColor(theme.colors.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 20)
VStack(spacing: 10) {
@@ -64,18 +64,22 @@ struct BadgesCheckOrderView: View {
.alert(item: $alert) { $0.alert }
}
private func orderRow(_ title: LocalizedStringKey, _ value: Text) -> some View {
private func orderRow(_ title: LocalizedStringKey, _ value: Text, total: Bool = false) -> some View {
HStack {
Text(title)
.foregroundColor(theme.colors.secondary)
.fontWeight(total ? .semibold : .medium)
.foregroundColor(total ? nil : theme.colors.secondary)
Spacer()
value
.fontWeight(.semibold)
}
.font(.body)
.font(total ? .title3 : .body)
.padding(.top, total ? 14 : 8)
.padding(.bottom, 8)
}
private func payButton() -> some View {
let price = store.price(level, period)
let price = store.price(level, period, compact: false)
let disabled = !price.canPurchase || purchasing || !store.canBuy(chatModel.currentUser?.userId)
return Button {
purchase()
@@ -23,10 +23,17 @@ enum BadgeLevel: String, CaseIterable, Identifiable {
}
}
var filesDescription: LocalizedStringKey {
var fileSize: LocalizedStringKey {
switch self {
case .supporter: "Send 2GB files"
case .legend: "Send 5GB files"
case .supporter: "Files up to 2 GB"
case .legend: "Files up to 5 GB"
}
}
var fileStorage: LocalizedStringKey {
switch self {
case .supporter: "Stored for 7 days"
case .legend: "Stored for 21 days"
}
}
@@ -68,12 +75,8 @@ struct BadgesChooseBadgeView: View {
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
BadgeUserPreview(level: selectedLevel) {
Image(systemName: "chevron.down")
.font(.body)
.foregroundColor(theme.colors.primary)
}
.padding(.top, 4)
BadgeUserPreview(level: selectedLevel)
.padding(.top, 4)
Spacer(minLength: 20)
@@ -127,9 +130,12 @@ struct BadgesChooseBadgeView: View {
Text(level.title)
.font(.title3)
.fontWeight(.bold)
Text(level.filesDescription)
.font(.subheadline)
.foregroundColor(theme.colors.secondary)
VStack(spacing: 2) {
Text(level.fileSize)
Text(level.fileStorage)
}
.font(.subheadline)
.foregroundColor(theme.colors.secondary)
BadgePeriod.monthly.priceText(store.price(level, .monthly))
.font(.body)
.padding(.bottom, 20)
@@ -33,6 +33,14 @@ enum BadgePeriod: String, CaseIterable, Identifiable {
}
}
var months: Int {
switch self {
case .oneMonth: 1
case .monthly: 1
case .annual: 12
}
}
func priceText(_ price: BadgePrice) -> Text {
switch price {
case .loading: return Text(verbatim: "…")
@@ -92,7 +100,7 @@ struct BadgesHowLongView: View {
// fixedSize + maxHeight on the cards so they all match the tallest one -
// only Annual carries a savings line, and prices wrap at large fonts
HStack(alignment: .top, spacing: 12) {
ForEach(badgePeriodsForSale) { periodCard($0) }
ForEach(BadgePeriod.allCases) { periodCard($0) }
}
.fixedSize(horizontal: false, vertical: true)
@@ -118,6 +126,7 @@ struct BadgesHowLongView: View {
private func periodCard(_ period: BadgePeriod) -> some View {
let isSelected = period == selectedPeriod
let forSale = badgePeriodsForSale.contains(period)
return Button {
selectedPeriod = period
} label: {
@@ -127,17 +136,19 @@ struct BadgesHowLongView: View {
.scaledToFit()
.frame(width: 32, height: 32)
.foregroundColor(isSelected ? theme.colors.primary : theme.colors.secondary)
.opacity(forSale ? 1 : 0.4)
Text(period.label)
.font(.title3)
.fontWeight(.bold)
period.priceText(store.price(level, period))
.font(.body)
period.priceText(store.price(level, period))
.font(.title3)
.fontWeight(.semibold)
if let percent = savingsPercent(period) {
Text("Save \(percent)%")
.font(.footnote)
.foregroundColor(isSelected ? theme.colors.primary : theme.colors.secondary)
}
}
.foregroundColor(forSale ? nil : theme.colors.secondary)
.multilineTextAlignment(.center)
.padding(.vertical, 25)
.padding(.horizontal, 12)
@@ -150,6 +161,7 @@ struct BadgesHowLongView: View {
)
}
.buttonStyle(.plain)
.disabled(!forSale)
}
private func savingsPercent(_ period: BadgePeriod) -> Int? {
@@ -191,10 +203,10 @@ struct BadgeBillingFooter: View {
}
private var billingFooter: LocalizedStringKey {
// TODO [badges] source the actual date from the purchase state machine when wired.
var comps = DateComponents(); comps.year = 2026; comps.month = 7; comps.day = 22
let stubDate = Calendar.current.date(from: comps) ?? Date()
let date = DateFormatter.localizedString(from: stubDate, dateStyle: .long, timeStyle: .none)
// TODO [badges] from now only because a purchase is refused while a badge is held (refuseWhileBadgeHeld);
// a top-up must count from the end of the existing balance
let endDate = Calendar.current.date(byAdding: .month, value: period.months, to: Date()) ?? Date()
let date = DateFormatter.localizedString(from: endDate, dateStyle: .long, timeStyle: .none)
switch period {
case .monthly, .annual: return "Renews on \(date). Cancel anytime."
case .oneMonth: return "Ends on \(date)."
@@ -184,11 +184,11 @@ object BadgeStore {
return if (rhId == chatModel.remoteHostId() && readFor == userId) purchases else emptyList()
}
fun price(level: BadgeLevel, period: BadgePeriod): BadgePrice = when (state.value) {
fun price(level: BadgeLevel, period: BadgePeriod, compact: Boolean = true): 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
if (p != null) BadgePrice.Price(if (compact) compactPrice(p) else p.displayPrice) else BadgePrice.Unavailable
}
}
@@ -19,7 +19,7 @@ import chat.simplex.res.MR
import kotlinx.datetime.Instant
@Composable
fun BadgeUserPreview(level: BadgeLevel, modifier: Modifier = Modifier, trailing: @Composable () -> Unit = {}) {
fun BadgeUserPreview(level: BadgeLevel, modifier: Modifier = Modifier) {
val user = chatModel.currentUser.value
val displayName = user?.displayName ?: stringResource(MR.strings.badges_preview_my_nickname)
val previewBadge = LocalBadge(
@@ -29,15 +29,12 @@ fun BadgeUserPreview(level: BadgeLevel, modifier: Modifier = Modifier, trailing:
)
Column(modifier, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
ProfileImage(size = 128.dp, image = user?.image)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
NameWithBadge(
name = displayName,
badge = previewBadge,
style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
trailing()
}
NameWithBadge(
name = displayName,
badge = previewBadge,
style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@@ -2,10 +2,13 @@ package chat.simplex.common.views.badges
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -39,20 +42,20 @@ fun BadgesCheckOrderView(level: BadgeLevel, period: BadgePeriod, modalManager: M
modifier = Modifier.fillMaxWidth()
)
Column(Modifier.fillMaxWidth().padding(top = 20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Column(
Modifier
.fillMaxWidth()
.padding(top = 20.dp)
.clip(RoundedCornerShape(16.dp))
.background(sectionCardColor())
.padding(horizontal = 24.dp, vertical = 12.dp)
) {
OrderRow(MR.strings.badges_your_badge, stringResource(level.title))
OrderRow(MR.strings.badges_order_duration, stringResource(period.label))
OrderRow(MR.strings.badges_order_total, period.priceText(BadgeStore.price(level, period)))
Divider(Modifier.padding(top = 8.dp))
OrderRow(MR.strings.badges_order_total, period.priceText(BadgeStore.price(level, period, compact = false)), total = true)
}
Text(
stringResource(MR.strings.badges_charged_by_play),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.secondary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.weight(1f).heightIn(min = 20.dp))
Column(horizontalAlignment = Alignment.CenterHorizontally) {
@@ -63,17 +66,23 @@ fun BadgesCheckOrderView(level: BadgeLevel, period: BadgePeriod, modalManager: M
}
@Composable
private fun OrderRow(title: StringResource, value: String) {
Row(Modifier.fillMaxWidth()) {
Text(stringResource(title), style = MaterialTheme.typography.body1, color = MaterialTheme.colors.secondary)
private fun OrderRow(title: StringResource, value: String, total: Boolean = false) {
val style = if (total) MaterialTheme.typography.h3 else MaterialTheme.typography.body1
Row(Modifier.fillMaxWidth().padding(top = if (total) 14.dp else 8.dp, bottom = 8.dp)) {
Text(
stringResource(title),
style = style,
fontWeight = if (total) FontWeight.SemiBold else FontWeight.Medium,
color = if (total) Color.Unspecified else MaterialTheme.colors.secondary
)
Spacer(Modifier.weight(1f))
Text(value, style = MaterialTheme.typography.body1)
Text(value, style = style, fontWeight = FontWeight.SemiBold)
}
}
@Composable
private fun PayButton(level: BadgeLevel, period: BadgePeriod, purchasing: MutableState<Boolean>) {
val price = BadgeStore.price(level, period)
val price = BadgeStore.price(level, period, compact = false)
val (labelId, labelArg) = period.payLabel(price)
OnboardingActionButton(
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp),
@@ -34,10 +34,16 @@ enum class BadgeLevel {
Legend -> MR.strings.badges_level_legend
}
val filesDescription: StringResource
val fileSize: StringResource
get() = when (this) {
Supporter -> MR.strings.badges_level_supporter_files
Legend -> MR.strings.badges_level_legend_files
Supporter -> MR.strings.badges_level_supporter_file_size
Legend -> MR.strings.badges_level_legend_file_size
}
val fileStorage: StringResource
get() = when (this) {
Supporter -> MR.strings.badges_level_supporter_file_storage
Legend -> MR.strings.badges_level_legend_file_storage
}
val summary: StringResource
@@ -83,13 +89,7 @@ fun BadgesChooseBadgeView(modalManager: ModalManager) {
modifier = Modifier.fillMaxWidth()
)
BadgeUserPreview(level = selectedLevel, modifier = Modifier.padding(top = 4.dp)) {
Icon(
painterResource(MR.images.ic_keyboard_arrow_down),
contentDescription = null,
tint = MaterialTheme.colors.primary
)
}
BadgeUserPreview(level = selectedLevel, modifier = Modifier.padding(top = 4.dp))
Spacer(Modifier.weight(1f).heightIn(min = 20.dp))
@@ -144,7 +144,10 @@ private fun LevelCard(level: BadgeLevel, selectedLevel: BadgeLevel, modifier: Mo
modifier = Modifier.size(60.dp)
)
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)
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(stringResource(level.fileSize), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center)
Text(stringResource(level.fileStorage), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center)
}
Text(BadgePeriod.Monthly.priceText(BadgeStore.price(level, BadgePeriod.Monthly)), style = MaterialTheme.typography.body1, textAlign = TextAlign.Center)
}
}
@@ -9,7 +9,9 @@ import androidx.compose.material.*
import androidx.compose.runtime.*
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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -42,6 +44,13 @@ enum class BadgePeriod {
Annual -> MR.strings.badges_period_annual
}
val months: Int
get() = when (this) {
OneMonth -> 1
Monthly -> 1
Annual -> 12
}
@Composable
fun priceText(price: BadgePrice): String = when (price) {
is BadgePrice.Loading -> "…"
@@ -104,7 +113,7 @@ fun BadgesHowLongView(level: BadgeLevel, modalManager: ModalManager) {
Modifier.fillMaxWidth().height(IntrinsicSize.Max),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
badgePeriodsForSale.forEach { period ->
BadgePeriod.entries.forEach { period ->
PeriodCard(level, period, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it }
}
}
@@ -124,7 +133,7 @@ fun BadgesHowLongView(level: BadgeLevel, modalManager: ModalManager) {
fun BadgeBillingFooter(period: BadgePeriod) {
Box(Modifier.padding(top = 7.5.dp, bottom = 7.5.dp).padding(horizontal = 16.dp, vertical = 8.dp)) {
Text(
stringResource(billingFooter(period)).format(stubBillingDate()),
stringResource(billingFooter(period)).format(billingDate(period)),
Modifier.padding(vertical = 5.dp),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.secondary,
@@ -136,6 +145,8 @@ fun BadgeBillingFooter(period: BadgePeriod) {
@Composable
private fun PeriodCard(level: BadgeLevel, period: BadgePeriod, selectedPeriod: BadgePeriod, modifier: Modifier, onSelect: (BadgePeriod) -> Unit) {
val isSelected = period == selectedPeriod
val forSale = period in badgePeriodsForSale
val textColor = if (forSale) Color.Unspecified else MaterialTheme.colors.secondary
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.
val cardBackground = if (isInDarkTheme()) MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f)
@@ -146,7 +157,7 @@ private fun PeriodCard(level: BadgeLevel, period: BadgePeriod, selectedPeriod: B
.clip(shape)
.background(cardBackground, shape)
.border(2.dp, borderColor, shape)
.clickable { onSelect(period) }
.clickable(enabled = forSale) { onSelect(period) }
.padding(vertical = 20.dp, horizontal = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
@@ -155,10 +166,10 @@ private fun PeriodCard(level: BadgeLevel, period: BadgePeriod, selectedPeriod: B
painterResource(period.icon),
contentDescription = null,
tint = if (isSelected) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
modifier = Modifier.size(32.dp)
modifier = Modifier.size(32.dp).alpha(if (forSale) 1f else 0.4f)
)
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)
Text(stringResource(period.label), style = MaterialTheme.typography.body1, color = textColor, textAlign = TextAlign.Center)
Text(period.priceText(BadgeStore.price(level, period)), style = MaterialTheme.typography.h3, fontWeight = FontWeight.SemiBold, color = textColor, textAlign = TextAlign.Center)
val percent = savingsPercent(level, period)
if (percent != null) {
Text(
@@ -191,9 +202,10 @@ private fun billingFooter(period: BadgePeriod): StringResource = when (period) {
BadgePeriod.OneMonth -> MR.strings.badges_billing_footer_one_month
}
// TODO [badges] source the actual date from the purchase state machine when wired.
private fun stubBillingDate(): String {
val date = java.time.LocalDate.of(2026, 7, 22)
// TODO [badges] from now only because a purchase is refused while a badge is held (refuseWhileBadgeHeld);
// a top-up must count from the end of the existing balance
private fun billingDate(period: BadgePeriod): String {
val date = java.time.LocalDate.now().plusMonths(period.months.toLong())
val formatter = java.time.format.DateTimeFormatter.ofLocalizedDate(java.time.format.FormatStyle.LONG)
return date.format(formatter)
}
@@ -3142,8 +3142,10 @@
<string name="badge_unknown_key_desc">The badge is signed with a key that this version of the app does not recognize. Update the app to verify this badge.</string>
<string name="badges_level_supporter">Supporter</string>
<string name="badges_level_legend">Legend</string>
<string name="badges_level_supporter_files">Send 2GB files</string>
<string name="badges_level_legend_files">Send 5GB files</string>
<string name="badges_level_supporter_file_size">Files up to 2 GB</string>
<string name="badges_level_supporter_file_storage">Stored for 7 days</string>
<string name="badges_level_legend_file_size">Files up to 5 GB</string>
<string name="badges_level_legend_file_storage">Stored for 21 days</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>
@@ -3184,7 +3186,6 @@
<string name="badges_check_your_order_title">Check your order</string>
<string name="badges_order_duration">Duration</string>
<string name="badges_order_total">Total</string>
<string name="badges_charged_by_play">Charged by Google Play to the account on this device.</string>
<string name="badges_issuing_title">Issuing your badge</string>
<string name="badges_issuing_body">The store has taken the payment. Keeps trying if you close the app.</string>
<string name="badges_waiting_for_approval_title">Waiting for approval</string>