This commit is contained in:
spaced4ndy
2026-07-31 13:27:29 +04:00
parent 85501e2286
commit c5cc01bb94
17 changed files with 860 additions and 3 deletions
@@ -192,6 +192,7 @@ class AppPreferences {
val showHiddenProfilesNotice = mkBoolPreference(SHARED_PREFS_SHOW_HIDDEN_PROFILES_NOTICE, true)
val oneHandUICardShown = mkBoolPreference(SHARED_PREFS_ONE_HAND_UI_CARD_SHOWN, false)
val addressCreationCardShown = mkBoolPreference(SHARED_PREFS_ADDRESS_CREATION_CARD_SHOWN, false)
val supporterBannerShown = mkBoolPreference(SHARED_PREFS_SUPPORTER_BANNER_SHOWN, false)
val showMuteProfileAlert = mkBoolPreference(SHARED_PREFS_SHOW_MUTE_PROFILE_ALERT, true)
val showReportsInSupportChatAlert = mkBoolPreference(SHARED_PREFS_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT, true)
val appLanguage = mkStrPreference(SHARED_PREFS_APP_LANGUAGE, null)
@@ -273,6 +274,7 @@ class AppPreferences {
hintPref(laNoticeShown, false),
hintPref(oneHandUICardShown, false),
hintPref(addressCreationCardShown, false),
hintPref(supporterBannerShown, false),
hintPref(liveMessageAlertShown, false),
hintPref(signMessageAlertShown, false),
hintPref(showHiddenProfilesNotice, true),
@@ -464,6 +466,7 @@ class AppPreferences {
private const val SHARED_PREFS_SHOW_HIDDEN_PROFILES_NOTICE = "ShowHiddenProfilesNotice"
private const val SHARED_PREFS_ONE_HAND_UI_CARD_SHOWN = "OneHandUICardShown"
private const val SHARED_PREFS_ADDRESS_CREATION_CARD_SHOWN = "AddressCreationCardShown"
private const val SHARED_PREFS_SUPPORTER_BANNER_SHOWN = "SupporterBannerShown"
private const val SHARED_PREFS_SHOW_MUTE_PROFILE_ALERT = "ShowMuteProfileAlert"
private const val SHARED_PREFS_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT = "ShowReportsInSupportChatAlert"
private const val SHARED_PREFS_STORE_DB_PASSPHRASE = "StoreDBPassphrase"
@@ -0,0 +1,44 @@
package chat.simplex.common.views.badges
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.common.model.BadgeInfo
import chat.simplex.common.model.BadgeStatus
import chat.simplex.common.model.LocalBadge
import chat.simplex.common.platform.chatModel
import chat.simplex.common.views.helpers.NameWithBadge
import chat.simplex.common.views.helpers.ProfileImage
import chat.simplex.res.MR
// User's avatar + name with a preview badge at the selected level, rendered via NameWithBadge
// so the preview matches how the badge appears on the name elsewhere. Callers can inject a
// picker affordance (e.g. a chevron) into the name row via `trailing`.
@Composable
fun BadgeUserPreview(level: BadgeLevel, modifier: Modifier = Modifier, trailing: @Composable () -> Unit = {}) {
val user = chatModel.currentUser.value
val displayName = user?.displayName ?: stringResource(MR.strings.badges_preview_my_nickname)
val previewBadge = LocalBadge(
badge = BadgeInfo(badgeType = level.badgeType),
status = BadgeStatus.Active
)
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()
}
}
}
@@ -0,0 +1,35 @@
package chat.simplex.common.views.badges
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.common.platform.ColumnWithScrollBar
import chat.simplex.res.MR
// Draft explanation of how private badges work. TODO [badges]: replace lorem ipsum with the real
// copy once the badge protocol and privacy properties are documented.
@Composable
fun BadgesHowItWorksView() {
ColumnWithScrollBar(
Modifier.padding(horizontal = 25.dp).padding(top = 8.dp),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
stringResource(MR.strings.badges_how_it_works_title),
style = MaterialTheme.typography.h1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.primary,
modifier = Modifier.padding(bottom = 16.dp)
)
Text(stringResource(MR.strings.badges_how_it_works_p1), style = MaterialTheme.typography.body1)
Text(stringResource(MR.strings.badges_how_it_works_p2), style = MaterialTheme.typography.body1)
Text(stringResource(MR.strings.badges_how_it_works_p3), style = MaterialTheme.typography.body1)
}
}
@@ -0,0 +1,136 @@
package chat.simplex.common.views.badges
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import dev.icerock.moko.resources.StringResource
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingActionButton
import chat.simplex.res.MR
// Draft billing periods used by the badges UI while the API/state machine is still being designed.
// TODO [badges]: replace with types produced by the badge purchase API when it lands.
enum class BadgePeriod {
OneMonth,
Subscribe;
val icon: dev.icerock.moko.resources.ImageResource
get() = when (this) {
OneMonth -> MR.images.ic_calendar
Subscribe -> MR.images.ic_refresh
}
val label: StringResource
get() = when (this) {
OneMonth -> MR.strings.badges_period_one_month
Subscribe -> MR.strings.badges_period_subscribe
}
}
@Composable
fun BadgesPayView(level: BadgeLevel) {
var selectedPeriod by remember { mutableStateOf(BadgePeriod.Subscribe) }
ColumnWithScrollBar(
Modifier.padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
maxIntrinsicSize = true,
) {
Text(
stringResource(level.title),
style = MaterialTheme.typography.h1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.primary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
BadgeUserPreview(level = level, modifier = Modifier.padding(top = 4.dp))
Text(
stringResource(level.tagline),
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.secondary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 4.dp)
)
Row(
Modifier.fillMaxWidth().padding(top = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
PeriodCard(BadgePeriod.OneMonth, selectedPeriod, Modifier.weight(1f)) { selectedPeriod = it }
PeriodCard(BadgePeriod.Subscribe, selectedPeriod, Modifier.weight(1f)) { selectedPeriod = it }
}
Spacer(Modifier.weight(1f).heightIn(min = 20.dp))
PayButton(level, selectedPeriod)
Text(
stringResource(billingFooter(selectedPeriod)),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.secondary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 4.dp)
)
}
}
@Composable
private fun PeriodCard(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)
val shape = RoundedCornerShape(16.dp)
Column(
modifier
.clip(shape)
.background(MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f), shape)
.border(2.dp, borderColor, shape)
.clickable { onSelect(period) }
.padding(vertical = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
painterResource(period.icon),
contentDescription = null,
tint = if (isSelected) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
modifier = Modifier.size(32.dp)
)
Text(stringResource(period.label), style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold)
}
}
@Composable
private fun PayButton(level: BadgeLevel, selectedPeriod: BadgePeriod) {
OnboardingActionButton(
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier,
labelId = if (selectedPeriod == BadgePeriod.Subscribe) level.payMonthlyLabel else level.payOnceLabel,
onboarding = null,
onclick = {
// TODO [badges] wire to purchase API when it lands.
}
)
}
// TODO [badges] source the actual renewal/end date from the purchase state machine when wired.
private fun billingFooter(period: BadgePeriod): StringResource = when (period) {
BadgePeriod.Subscribe -> MR.strings.badges_billing_footer_subscribe
BadgePeriod.OneMonth -> MR.strings.badges_billing_footer_one_month
}
@@ -0,0 +1,30 @@
package chat.simplex.common.views.badges
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.common.platform.ColumnWithScrollBar
import chat.simplex.res.MR
// Draft entry-point for redeeming an investor badge code. TODO [badges]: implement input field,
// server verification and success/failure states when the redeem API is defined.
@Composable
fun BadgesRedeemCodeView() {
ColumnWithScrollBar(
Modifier.padding(horizontal = 25.dp).padding(top = 8.dp),
horizontalAlignment = Alignment.Start
) {
Text(
stringResource(MR.strings.badges_redeem_code_button),
style = MaterialTheme.typography.h1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.primary
)
}
}
@@ -0,0 +1,153 @@
package chat.simplex.common.views.badges
import androidx.compose.foundation.*
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.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.common.BuildConfigCommon
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.darkStops
import chat.simplex.common.views.newchat.gradientPoints
import chat.simplex.common.views.newchat.lightStops
import chat.simplex.common.views.onboarding.HowItWorks
import chat.simplex.common.views.onboarding.OnboardingActionButton
import chat.simplex.res.MR
// Entry point for badges management. Subsequent screens push via ModalManager; the enclosing
// modal (settings or the chat-list banner sheet) provides the sliding animation.
@Composable
fun BadgesSupportSimplexView() {
// TODO [badges] gate on user badge status (no badge → this view, active → "Manage your badge")
ColumnWithScrollBar(
Modifier.padding(horizontal = 25.dp).padding(top = 28.dp, bottom = 20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
maxIntrinsicSize = true,
) {
Text(
stringResource(MR.strings.badges_support_simplex_title),
style = MaterialTheme.typography.h1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.primary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Text(
stringResource(MR.strings.badges_support_simplex_body),
style = MaterialTheme.typography.body1,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
val primary = MaterialTheme.colors.primary
TextButton({
ModalManager.start.showModal { HowItWorks(user = chatModel.currentUser.value, onboardingStage = null, titleColor = primary) }
}) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.primary)
Text(stringResource(MR.strings.badges_why_simplex_is_built), color = MaterialTheme.colors.primary, fontWeight = FontWeight.Medium)
}
}
Spacer(Modifier.weight(1f))
PhoneSupporterHero(Modifier.fillMaxWidth(0.55f))
Spacer(Modifier.weight(1f))
ChooseLevelButton()
RedeemCodeButton(Modifier.padding(top = 4.dp))
}
}
@Composable
private fun ChooseLevelButton() {
OnboardingActionButton(
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier,
labelId = MR.strings.badges_choose_your_level,
onboarding = null,
onclick = {
ModalManager.start.showModal { BadgesYourLevelView() }
}
)
}
@Composable
private fun RedeemCodeButton(modifier: Modifier = Modifier) {
TextButton(
onClick = { ModalManager.start.showModal { BadgesRedeemCodeView() } },
modifier = modifier
) {
Text(
stringResource(MR.strings.badges_redeem_code_button),
color = MaterialTheme.colors.primary,
fontWeight = FontWeight.Medium
)
}
}
// Hero image reused across badges views and WhatsNewView v7.1. Fallback (no SIMPLEX_ASSETS) is a
// gradient card carrying the small supporter badge glyph.
@Composable
fun PhoneSupporterHero(modifier: Modifier = Modifier) {
val isDark = isInDarkTheme()
if (BuildConfigCommon.SIMPLEX_ASSETS) {
Image(
painterResource(if (isDark) MR.images.phone_supporter_light else MR.images.phone_supporter),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = modifier.fillMaxWidth()
)
} else {
var size by remember { mutableStateOf(IntSize.Zero) }
val stops = if (isDark) darkStops else lightStops
val scale = if (isDark) 1.5f else 1.2f
val brush = remember(size, isDark) {
if (size.width > 0 && size.height > 0) {
val aspect = size.height.toFloat() / size.width.toFloat()
val gp = gradientPoints(aspect, scale)
Brush.linearGradient(
colorStops = stops,
start = Offset(gp.startX * size.width, gp.startY * size.height),
end = Offset(gp.endX * size.width, gp.endY * size.height)
)
} else {
Brush.linearGradient(colorStops = stops)
}
}
Box(
modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(RoundedCornerShape(24.dp))
.background(brush)
.onSizeChanged { size = it },
contentAlignment = Alignment.Center
) {
Image(
painterResource(MR.images.badge_supporter),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.size(96.dp)
)
}
}
}
@@ -0,0 +1,179 @@
package chat.simplex.common.views.badges
import androidx.compose.foundation.*
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.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import dev.icerock.moko.resources.StringResource
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.common.model.BadgeType
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingActionButton
import chat.simplex.res.MR
// Draft levels used by the badges UI while the API/state machine is still being designed. TODO [badges]:
// replace with types produced by the badge purchase API when it lands.
enum class BadgeLevel {
Supporter,
Legend;
val title: StringResource
get() = when (this) {
Supporter -> MR.strings.badges_level_supporter
Legend -> MR.strings.badges_level_legend
}
val filesDescription: StringResource
get() = when (this) {
Supporter -> MR.strings.badges_level_supporter_files
Legend -> MR.strings.badges_level_legend_files
}
val monthlyPrice: StringResource
get() = when (this) {
Supporter -> MR.strings.badges_level_supporter_monthly
Legend -> MR.strings.badges_level_legend_monthly
}
val oneMonthPrice: StringResource
get() = when (this) {
Supporter -> MR.strings.badges_level_supporter_one_month
Legend -> MR.strings.badges_level_legend_one_month
}
val payMonthlyLabel: StringResource
get() = when (this) {
Supporter -> MR.strings.badges_pay_supporter_monthly
Legend -> MR.strings.badges_pay_legend_monthly
}
val payOnceLabel: StringResource
get() = when (this) {
Supporter -> MR.strings.badges_pay_supporter_once
Legend -> MR.strings.badges_pay_legend_once
}
val tagline: StringResource
get() = when (this) {
Supporter -> MR.strings.badges_level_supporter_tagline
Legend -> MR.strings.badges_level_legend_tagline
}
val badgeAsset: dev.icerock.moko.resources.ImageResource
get() = when (this) {
Supporter -> MR.images.badge_supporter
Legend -> MR.images.badge_legend
}
val badgeType: BadgeType
get() = when (this) {
Supporter -> BadgeType.Supporter
Legend -> BadgeType.Legend
}
}
@Composable
fun BadgesYourLevelView() {
var selectedLevel by remember { mutableStateOf(BadgeLevel.Supporter) }
ColumnWithScrollBar(
Modifier.padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
maxIntrinsicSize = true,
) {
Text(
stringResource(MR.strings.badges_your_level_title),
style = MaterialTheme.typography.h1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.primary,
textAlign = TextAlign.Center,
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
)
}
Row(
Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
LevelCard(BadgeLevel.Supporter, selectedLevel, Modifier.weight(1f)) { selectedLevel = it }
LevelCard(BadgeLevel.Legend, selectedLevel, Modifier.weight(1f)) { selectedLevel = it }
}
Spacer(Modifier.weight(1f).heightIn(min = 20.dp))
ContinueButton(selectedLevel)
HowItWorksButton(Modifier.padding(top = 4.dp))
}
}
@Composable
private fun LevelCard(level: BadgeLevel, selectedLevel: BadgeLevel, modifier: Modifier, onSelect: (BadgeLevel) -> Unit) {
val isSelected = level == selectedLevel
val borderColor = if (isSelected) MaterialTheme.colors.primary else MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.92f)
val shape = RoundedCornerShape(16.dp)
Column(
modifier
.clip(shape)
.background(MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f), shape)
.border(2.dp, borderColor, shape)
.clickable { onSelect(level) }
.padding(vertical = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Image(
painterResource(level.badgeAsset),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.size(60.dp)
)
Text(stringResource(level.title), style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold)
Text(stringResource(level.filesDescription), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.secondary)
Text(stringResource(level.monthlyPrice), style = MaterialTheme.typography.body1)
}
}
@Composable
private fun ContinueButton(selectedLevel: BadgeLevel) {
OnboardingActionButton(
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier,
labelId = MR.strings.badges_continue,
onboarding = null,
onclick = {
ModalManager.start.showModal { BadgesPayView(selectedLevel) }
}
)
}
@Composable
private fun HowItWorksButton(modifier: Modifier = Modifier) {
TextButton(
onClick = { ModalManager.start.showModal { BadgesHowItWorksView() } },
modifier = modifier
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.primary)
Text(stringResource(MR.strings.badges_how_it_works_button), color = MaterialTheme.colors.primary, fontWeight = FontWeight.Medium)
}
}
}
@@ -0,0 +1,153 @@
package chat.simplex.common.views.badges
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
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.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import dev.icerock.moko.resources.compose.painterResource
import chat.simplex.common.BuildConfigCommon
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.darkStops
import chat.simplex.common.views.newchat.lightStops
import chat.simplex.res.MR
// Dumb chat-list card promoting the badges purchase flow. Both the show gate and the dismissal
// persistence live in ChatListView; this view just renders and reports taps.
@Composable
fun SupportSimpleXBanner(onTap: () -> Unit, onDismiss: () -> Unit) {
val cardCornerRadius = 16.dp
val cardHeight = 72.dp
// matches OneHandUICard's segment icon leading so the text aligns with it in the list
val cardLeadingPadding = 16.dp
val cardTrailingPadding = 8.dp
val heroWidth = 110.dp
// shorter than the natural drawn height so ContentScale.Crop slices the phone body at card bottom
val heroVisibleHeight = 108.dp
// matches the OneHandUICard-style X: outer end pad 4 + circle click area 32 = 36 reserved on right
val heroTrailingPadding = 36.dp
val textToHeroGap = 6.dp
val isDark = isInDarkTheme()
var cardSize by remember { mutableStateOf(IntSize.Zero) }
val brush = remember(isDark, cardSize) { gradientBrush(isDark, cardSize) }
// Root Box is sized to the card; hero and X are children so they can render outside the card's
// rounded-corner clip. Hero anchors bottom-end and its 108dp height extends 36dp above the card top.
Box(Modifier.fillMaxWidth().height(cardHeight)) {
Row(
Modifier
.fillMaxSize()
.clip(RoundedCornerShape(cardCornerRadius))
.background(brush)
.clickable(onClick = onTap)
.onSizeChanged { cardSize = it }
.padding(start = cardLeadingPadding, end = cardTrailingPadding + heroWidth + heroTrailingPadding + textToHeroGap),
verticalAlignment = Alignment.CenterVertically
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
generalGetString(MR.strings.badges_banner_title),
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colors.primary
)
Text(
generalGetString(MR.strings.badges_banner_subtitle),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onBackground
)
}
}
HeroThumbnail(
heroWidth = heroWidth,
heroVisibleHeight = heroVisibleHeight,
cardHeight = cardHeight,
trailingPadding = heroTrailingPadding,
modifier = Modifier.align(Alignment.BottomEnd)
)
// Same X pattern as OneHandUICard: circle-clipped clickable region with inner padding for hit area.
Icon(
painterResource(MR.images.ic_close),
contentDescription = generalGetString(MR.strings.icon_descr_close_button),
tint = MaterialTheme.colors.secondary,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(end = 4.dp, top = 4.dp)
.clip(CircleShape)
.clickable {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.badges_banner_title),
text = generalGetString(MR.strings.badges_banner_dismiss_message),
onConfirm = onDismiss
)
}
.padding(8.dp)
.size(16.dp)
)
}
}
@Composable
private fun HeroThumbnail(heroWidth: Dp, heroVisibleHeight: Dp, cardHeight: Dp, trailingPadding: Dp, modifier: Modifier) {
if (BuildConfigCommon.SIMPLEX_ASSETS) {
// draws at natural aspect, top-aligned in a shorter slot; ContentScale.Crop cuts the overflow at card bottom
Image(
painterResource(if (isInDarkTheme()) MR.images.phone_supporter_light else MR.images.phone_supporter),
contentDescription = null,
contentScale = ContentScale.Crop,
alignment = Alignment.TopCenter,
modifier = modifier.padding(end = trailingPadding).size(width = heroWidth, height = heroVisibleHeight)
)
} else {
val badgeSize = 48.dp
Image(
painterResource(MR.images.badge_supporter),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = modifier
.padding(end = trailingPadding + 12.dp, top = (cardHeight - badgeSize) / 2, bottom = (cardHeight - badgeSize) / 2)
.size(badgeSize)
)
}
}
// light: 2-color end-to-end (lightStops' 0.00.5 blue plateau would read as all-blue here).
// dark: full 5-stop darkStops — the intermediate blue-to-navy transitions land nicely.
private fun gradientBrush(isDark: Boolean, size: IntSize): Brush {
if (size.width == 0 || size.height == 0) {
return if (isDark) Brush.linearGradient(colorStops = darkStops)
else Brush.linearGradient(colors = listOf(lightStops.first().second, lightStops.last().second))
}
val w = size.width.toFloat()
val h = size.height.toFloat()
return if (isDark) {
Brush.linearGradient(
colorStops = darkStops,
start = Offset(0f, h),
end = Offset(w, 0f)
)
} else {
Brush.linearGradient(
colors = listOf(lightStops.first().second, lightStops.last().second),
start = Offset(0f, h),
end = Offset(w, 0f)
)
}
}
@@ -41,6 +41,7 @@ import chat.simplex.common.platform.*
import chat.simplex.common.views.call.Call
import chat.simplex.common.views.chat.item.*
import chat.simplex.common.views.chat.topPaddingToContent
import chat.simplex.common.views.badges.*
import chat.simplex.common.views.newchat.*
import chat.simplex.common.views.onboarding.*
import chat.simplex.common.views.usersettings.*
@@ -910,6 +911,7 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
val oneHandUI = remember { appPrefs.oneHandUI.state }
val oneHandUICardShown = remember { appPrefs.oneHandUICardShown.state }
val addressCreationCardShown = remember { appPrefs.addressCreationCardShown.state }
val supporterBannerShown = remember { appPrefs.supporterBannerShown.state }
val activeFilter = remember { chatModel.activeChatTagFilter }
LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) {
@@ -998,6 +1000,18 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
ToggleChatListCard()
}
}
// +1 vs SimpleX Lock's threshold accounts for the always-present SimpleX Directory contact card,
// so this still needs 3 real conversations.
if (!supporterBannerShown.value && chatModel.chats.value.size > 3) {
item {
Box(Modifier.padding(16.dp).zIndex(1f)) {
SupportSimpleXBanner(
onTap = { ModalManager.start.showModal { BadgesSupportSimplexView() } },
onDismiss = { appPrefs.supporterBannerShown.set(true) }
)
}
}
}
itemsIndexed(chats, key = { _, chat -> chat.remoteHostId to chat.id }) { index, chat ->
val nextChatSelected = remember(chat.id, chats) { derivedStateOf {
chatModel.chatId.value != null && chats.getOrNull(index + 1)?.id == chatModel.chatId.value
@@ -7,6 +7,7 @@ import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.*
@@ -22,12 +23,12 @@ import chat.simplex.res.MR
import dev.icerock.moko.resources.StringResource
@Composable
fun HowItWorks(user: User?, onboardingStage: SharedPreference<OnboardingStage>? = null) {
fun HowItWorks(user: User?, onboardingStage: SharedPreference<OnboardingStage>? = null, titleColor: Color = Color.Unspecified) {
Column(Modifier.fillMaxSize().padding(horizontal = if (appPlatform.isDesktop) DEFAULT_PADDING * 2 else DEFAULT_PADDING)) {
Spacer(Modifier.statusBarsPadding().padding(top = AppBarHeight * fontSizeSqrtMultiplier))
val paraPadding = PaddingValues(bottom = if (appPlatform.isDesktop) 10.dp else 12.dp)
Column(Modifier.weight(1f).padding(bottom = DEFAULT_PADDING).verticalScroll(rememberScrollState())) {
Text(stringResource(MR.strings.why_built_heading), style = MaterialTheme.typography.h1, modifier = Modifier.padding(bottom = DEFAULT_PADDING))
Text(stringResource(MR.strings.why_built_heading), style = MaterialTheme.typography.h1, color = titleColor, modifier = Modifier.padding(bottom = DEFAULT_PADDING))
ReadableText(MR.strings.why_built_p1, padding = paraPadding)
ReadableText(MR.strings.why_built_p2, padding = paraPadding)
ReadableText(MR.strings.why_built_p3, padding = paraPadding)
@@ -45,6 +46,9 @@ fun HowItWorks(user: User?, onboardingStage: SharedPreference<OnboardingStage>?
OnboardingActionButton(user, onboardingStage, onclick = { ModalManager.fullscreen.closeModal() })
TextButtonBelowOnboardingButton("", null)
}
} else if (appPlatform.isAndroid) {
// no button below → add nav-bar-inset spacer so text doesn't run under system gesture bar
Spacer(Modifier.navigationBarsPadding())
}
}
}
@@ -8,6 +8,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalUriHandler
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@@ -19,6 +20,7 @@ import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.BuildConfigCommon
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.*
@@ -934,8 +936,54 @@ private val versionDescriptions: List<VersionDescription> = listOf(
),
)
),
// TODO [badges] finalise copy + Read more link before v7.1 ships.
VersionDescription(
version = "v7.1",
post = null,
features = listOf(
VersionFeature.FeatureView(
icon = null,
titleId = MR.strings.whats_new_supporter_badge_short,
view = { SupporterBadgeWhatsNew() }
)
)
),
)
@Composable
private fun SupporterBadgeWhatsNew() {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.Top) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
stringResource(MR.strings.whats_new_supporter_badge_title),
style = MaterialTheme.typography.h3,
fontWeight = FontWeight.Bold
)
Text(
stringResource(MR.strings.whats_new_supporter_badge_body),
style = MaterialTheme.typography.body1,
maxLines = 10
)
}
val isDark = isInDarkTheme()
if (BuildConfigCommon.SIMPLEX_ASSETS) {
Image(
painterResource(if (isDark) MR.images.phone_supporter_light else MR.images.phone_supporter),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.size(110.dp)
)
} else {
Image(
painterResource(MR.images.badge_supporter),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.size(72.dp)
)
}
}
}
private val lastVersion = versionDescriptions.last().version
fun setLastVersionDefault(m: ChatModel) {
@@ -27,6 +27,7 @@ import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.badges.BadgesSupportSimplexView
import chat.simplex.common.views.database.DatabaseView
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.migration.MigrateFromDeviceView
@@ -102,6 +103,15 @@ fun SettingsLayout(
}
SectionDividerSpaced()
SectionView {
SectionItemView(click = showSettingsModal { BadgesSupportSimplexView() }) {
Image(painterResource(MR.images.badge_supporter), stringResource(MR.strings.supporter_perks), Modifier.size(24.dp))
TextIconSpaced()
Text(stringResource(MR.strings.supporter_perks))
}
}
SectionDividerSpaced()
SectionView(stringResource(MR.strings.advanced_settings)) {
SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showCustomModal { _, close -> NetworkAndServersView(close) }, disabled = stopped)
if (appPlatform == AppPlatform.ANDROID) {
@@ -3198,4 +3198,42 @@
<string name="badge_unverified_desc">This badge could not be verified and may not be genuine.</string>
<string name="badge_unknown_key_title">Badge cannot be verified</string>
<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_monthly">$7/month</string>
<string name="badges_level_legend_monthly">$70/month</string>
<string name="badges_level_supporter_one_month">$7</string>
<string name="badges_level_legend_one_month">$70</string>
<string name="badges_pay_supporter_monthly">Pay $7/month</string>
<string name="badges_pay_legend_monthly">Pay $70/month</string>
<string name="badges_pay_supporter_once">Pay $7</string>
<string name="badges_pay_legend_once">Pay $70</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_billing_footer_subscribe">Renews on July 22, 2026. Cancel anytime.</string>
<string name="badges_billing_footer_one_month">Ends on August 22, 2026.</string>
<string name="badges_your_level_title">Your level</string>
<string name="badges_support_simplex_title">Support SimpleX</string>
<string name="badges_support_simplex_body">SimpleX doesn\'t sell ads or data. It\'s funded by its users and by investors who share the mission. You can support the project and show a badge on your profile.</string>
<string name="badges_why_simplex_is_built">Why SimpleX is built.</string>
<string name="badges_choose_your_level">Choose your level</string>
<string name="badges_redeem_code_button">Redeem badge code</string>
<string name="badges_continue">Continue</string>
<string name="badges_how_it_works_button">How private badges work</string>
<string name="badges_how_it_works_title">How private badges work</string>
<string name="badges_how_it_works_p1">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</string>
<string name="badges_how_it_works_p2">Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</string>
<string name="badges_how_it_works_p3">Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</string>
<string name="badges_preview_my_nickname">My nickname</string>
<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="supporter_perks">Supporter perks</string>
<string name="whats_new_supporter_badge_title">Supporter badge ❤️</string>
<string name="whats_new_supporter_badge_body">Help keep the network running — send files up to 2 GB.</string>
<string name="whats_new_supporter_badge_short">Supporter badge</string>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B