ui: badges WIP views (#7321)

This commit is contained in:
spaced4ndy authored and GitHub committed 2026-08-14 07:36:02 +00:00
1 parent 4217c9ee84
commit cd670548b9
31 files changed
+1750 -14

No files matched your search

@@ -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,41 @@
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
@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,34 @@
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
// 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,154 @@
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
// 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.background(MaterialTheme.colors.background).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.onBackground,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 4.dp)
)
Spacer(Modifier.weight(1f).heightIn(min = 20.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.
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 }
}
Spacer(Modifier.weight(1f).heightIn(min = 20.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)
Box(Modifier.padding(top = 7.5.dp, bottom = 7.5.dp).padding(horizontal = 16.dp, vertical = 8.dp)) {
Text(
stringResource(billingFooter(selectedPeriod)).format(stubBillingDate()),
Modifier.padding(vertical = 5.dp),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.secondary,
textAlign = TextAlign.Center
)
}
}
}
}
@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)
// 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)
else MaterialTheme.colors.background
val shape = RoundedCornerShape(16.dp)
Column(
modifier
.clip(shape)
.background(cardBackground, shape)
.border(2.dp, borderColor, shape)
.clickable { onSelect(period) }
.padding(vertical = 30.dp, horizontal = 12.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, textAlign = TextAlign.Center)
}
}
@Composable
private fun PayButton(level: BadgeLevel, selectedPeriod: BadgePeriod) {
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,
onboarding = null,
onclick = {
// TODO [badges] wire to purchase API when it lands.
}
)
}
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
}
// 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)
val formatter = java.time.format.DateTimeFormatter.ofLocalizedDate(java.time.format.FormatStyle.LONG)
return date.format(formatter)
}
@@ -0,0 +1,29 @@
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
// 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,141 @@
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.common.views.onboarding.TextButtonBelowOnboardingButton
import chat.simplex.res.MR
@Composable
fun BadgesSupportSimplexView() {
// TODO [badges] gate on user badge status (no badge → this view, active → "Manage your badge")
ColumnWithScrollBar(
Modifier.background(MaterialTheme.colors.background).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_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))
Column(horizontalAlignment = Alignment.CenterHorizontally) {
ChooseLevelButton()
TextButtonBelowOnboardingButton(
text = stringResource(MR.strings.badges_redeem_code_button),
onClick = { ModalManager.start.showModal { BadgesRedeemCodeView() } }
)
}
}
}
@Composable
private fun ChooseLevelButton() {
OnboardingActionButton(
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp),
labelId = MR.strings.badges_choose_your_level,
onboarding = null,
onclick = {
ModalManager.start.showModal { BadgesYourLevelView() }
}
)
}
@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,157 @@
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.common.views.onboarding.TextButtonBelowOnboardingButton
import chat.simplex.res.MR
// 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 priceAmount: String
get() = when (this) {
Supporter -> "$7"
Legend -> "$70"
}
val tagline: StringResource
get() = when (this) {
Supporter -> MR.strings.badges_level_supporter_tagline
Legend -> MR.strings.badges_level_legend_tagline
}
val badgeType: BadgeType
get() = when (this) {
Supporter -> BadgeType.Supporter
Legend -> BadgeType.Legend
}
}
@Composable
fun BadgesYourLevelView() {
var selectedLevel by remember { mutableStateOf(BadgeLevel.Supporter) }
ColumnWithScrollBar(
Modifier.background(MaterialTheme.colors.background).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
)
}
Spacer(Modifier.weight(1f).heightIn(min = 20.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.
Row(
Modifier.fillMaxWidth().height(IntrinsicSize.Max),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
LevelCard(BadgeLevel.Supporter, selectedLevel, Modifier.weight(1f).fillMaxHeight()) { selectedLevel = it }
LevelCard(BadgeLevel.Legend, selectedLevel, Modifier.weight(1f).fillMaxHeight()) { selectedLevel = it }
}
Spacer(Modifier.weight(1f).heightIn(min = 20.dp))
// Nested Column with no spacing so the TextButtonBelowOnboardingButton sits directly under
// the action button (matches onboarding pattern where its own 7.5dp top padding is the gap).
Column(horizontalAlignment = Alignment.CenterHorizontally) {
ContinueButton(selectedLevel)
TextButtonBelowOnboardingButton(
text = stringResource(MR.strings.badges_how_it_works_button),
icon = painterResource(MR.images.ic_info),
onClick = { ModalManager.start.showModal { BadgesHowItWorksView() } }
)
}
}
}
@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)
// 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)
else MaterialTheme.colors.background
val shape = RoundedCornerShape(16.dp)
Column(
modifier
.clip(shape)
.background(cardBackground, shape)
.border(2.dp, borderColor, shape)
.clickable { onSelect(level) }
.padding(vertical = 20.dp, horizontal = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Image(
painterResource(badgeImage(level.badgeType)),
contentDescription = null,
contentScale = ContentScale.Fit,
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)
Text(stringResource(MR.strings.badges_price_monthly).format(level.priceAmount), style = MaterialTheme.typography.body1, textAlign = TextAlign.Center)
}
}
@Composable
private fun ContinueButton(selectedLevel: BadgeLevel) {
OnboardingActionButton(
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp),
labelId = MR.strings.badges_continue,
onboarding = null,
onclick = {
ModalManager.start.showModal { BadgesPayView(selectedLevel) }
}
)
}
@@ -0,0 +1,173 @@
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.Layout
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
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 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.res.MR
@Composable
fun SupportSimpleXBanner(onTap: () -> Unit, onDismiss: () -> Unit) {
val cardCornerRadius = 16.dp
// grows linearly with system font but never shrinks below the default so small-font users see the
// same baseline; the card Row uses heightIn(min = cardHeight) and grows further when 2-line text
// wraps at very large fonts. Hero stays fixed so its above-card overhang shrinks at very large fonts.
val cardHeight = (72.dp * fontSizeMultiplier).coerceAtLeast(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
// hero right edge sits at the dismiss X's icon left edge (X: outer 4pt + inner-pad 8 + half of 16pt icon)
val heroTrailingPadding = 28.dp
val textToHeroGap = 6.dp
val isDark = isInDarkTheme()
var cardSize by remember { mutableStateOf(IntSize.Zero) }
val brush = remember(isDark, cardSize) { gradientBrush(isDark, cardSize) }
// Layout sizes to the card; hero is placed at y = cardHeight - heroHeight (negative → hero
// overhangs above card at normal fonts, 0/positive → hero fits inside card at large fonts).
Layout(content = {
Box(Modifier.fillMaxWidth()) {
Row(
Modifier
.fillMaxWidth()
.heightIn(min = cardHeight)
.clip(RoundedCornerShape(cardCornerRadius))
.background(brush)
.clickable(onClick = onTap)
.onSizeChanged { cardSize = it }
.padding(
start = cardLeadingPadding,
end = cardTrailingPadding + heroWidth + heroTrailingPadding + textToHeroGap,
top = 12.dp,
bottom = 12.dp
),
verticalAlignment = Alignment.CenterVertically
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
stringResource(MR.strings.badges_banner_title),
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colors.primary,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
stringResource(MR.strings.badges_banner_subtitle),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onBackground,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
// Same X pattern as OneHandUICard: circle-clipped clickable region with inner padding for hit area.
Icon(
painterResource(MR.images.ic_close),
contentDescription = stringResource(MR.strings.icon_descr_close_button),
tint = if (isDark) MaterialTheme.colors.onBackground else 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)
)
}
HeroThumbnail(
heroWidth = heroWidth,
heroVisibleHeight = heroVisibleHeight,
cardHeight = cardHeight,
trailingPadding = heroTrailingPadding
)
}) { measurables, constraints ->
val cardPlaceable = measurables[0].measure(constraints)
val heroPlaceable = measurables[1].measure(constraints.copy(minWidth = 0, minHeight = 0))
layout(cardPlaceable.width, cardPlaceable.height) {
cardPlaceable.place(0, 0)
heroPlaceable.place(cardPlaceable.width - heroPlaceable.width, cardPlaceable.height - heroPlaceable.height)
}
}
}
@Composable
private fun HeroThumbnail(heroWidth: Dp, heroVisibleHeight: Dp, cardHeight: Dp, trailingPadding: Dp) {
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)
)
}
}
// Geometry-aware gradient with asymmetric scale: start (dark) pushed further below the card than
// end (warm) is above, so card-middle lands at the bright/mid-transition stop, not the dark region.
private fun gradientBrush(isDark: Boolean, size: IntSize): Brush {
val stops = if (isDark) darkStops else lightStops
if (size.width == 0 || size.height == 0) return Brush.linearGradient(colorStops = stops)
val w = size.width.toFloat()
val h = size.height.toFloat()
val startScale = if (isDark) 3.0f else 2.5f
val endScale = if (isDark) 2.1f else 1.7f
val gp = gradientPoints(h / w, 1.0f)
val sx = 0.5f + (gp.startX - 0.5f) * startScale
val sy = 0.5f + (gp.startY - 0.5f) * startScale
val ex = 0.5f + (gp.endX - 0.5f) * endScale
val ey = 0.5f + (gp.endY - 0.5f) * endScale
return Brush.linearGradient(
colorStops = stops,
start = Offset(sx * w, sy * h),
end = Offset(ex * w, ey * h)
)
}
@@ -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.*
@@ -912,6 +913,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) {
@@ -1000,6 +1002,16 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
ToggleChatListCard()
}
}
if (!supporterBannerShown.value && chatModel.chats.value.size > 3) {
item {
Box(Modifier.zIndex(1f).padding(16.dp)) {
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
@@ -1025,13 +1037,15 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
} else {
NavigationBarBackground(oneHandUI.value, true)
}
if (!oneHandUICardShown.value) {
LaunchedEffect(chats.size) {
if (chats.size >= 3) {
appPrefs.oneHandUICardShown.set(true)
}
}
}
// TEMP-DISABLED-FOR-BADGES-QA: auto-hide of ToggleChatListCard at 3+ chats blocks visual QA of
// the SupportSimpleXBanner alongside it. Restore before merging.
// if (!oneHandUICardShown.value) {
// LaunchedEffect(chats.size) {
// if (chats.size >= 3) {
// appPrefs.oneHandUICardShown.set(true)
// }
// }
// }
LaunchedEffect(activeFilter.value) {
searchText.value = TextFieldValue("")
@@ -245,7 +245,7 @@ fun showBadgeInfoAlert(name: String, badge: LocalBadge, uriHandler: UriHandler)
}
}
private fun badgeImage(t: BadgeType): ImageResource = when (t) {
fun badgeImage(t: BadgeType): ImageResource = when (t) {
is BadgeType.Legend -> MR.images.badge_legend
is BadgeType.Investor -> MR.images.badge_investor
else -> MR.images.badge_supporter // Supporter + Unknown
@@ -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,11 @@ fun HowItWorks(user: User?, onboardingStage: SharedPreference<OnboardingStage>?
OnboardingActionButton(user, onboardingStage, onclick = { ModalManager.fullscreen.closeModal() })
TextButtonBelowOnboardingButton("", null)
}
} else {
// No button below — add breathing room at the bottom on both platforms.
// Android also gets nav-bar inset so content doesn't run under the gesture bar.
Spacer(Modifier.height(DEFAULT_PADDING))
if (appPlatform.isAndroid) Spacer(Modifier.navigationBarsPadding())
}
}
}
@@ -190,12 +190,14 @@ expect fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference
fun OnboardingActionButton(
modifier: Modifier = Modifier,
labelId: StringResource,
labelArg: String? = null,
onboarding: OnboardingStage?,
enabled: Boolean = true,
icon: Painter? = null,
iconColor: Color = Color.White,
onclick: (() -> Unit)?
) {
val label = if (labelArg != null) stringResource(labelId).format(labelArg) else stringResource(labelId)
Button(
onClick = {
onclick?.invoke()
@@ -211,9 +213,9 @@ fun OnboardingActionButton(
colors = ButtonDefaults.buttonColors(MaterialTheme.colors.primary, disabledBackgroundColor = MaterialTheme.colors.secondary)
) {
if (icon != null) {
Icon(icon, stringResource(labelId), Modifier.padding(end = DEFAULT_PADDING_HALF), tint = iconColor)
Icon(icon, label, Modifier.padding(end = DEFAULT_PADDING_HALF), tint = iconColor)
}
Text(stringResource(labelId), style = MaterialTheme.typography.h2, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Medium)
Text(label, style = MaterialTheme.typography.h2, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Medium)
}
}
@@ -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
@@ -940,8 +941,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.v7_1_supporter_badge_title,
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.v7_1_supporter_badge_title),
style = MaterialTheme.typography.h3,
fontWeight = FontWeight.Bold
)
Text(
stringResource(MR.strings.v7_1_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) {
@@ -26,6 +26,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
@@ -101,6 +102,17 @@ fun SettingsLayout(
}
SectionDividerSpaced()
SectionView {
// Direct showModal (no settings / cardScreen flags) — settings-style card chrome would render
// a gray top bar / back button that badges views don't want (they have their own inline titles).
SectionItemView(click = { ModalManager.start.showModal { 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) {
@@ -3201,4 +3201,36 @@
<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_price_monthly">%1$s/month</string>
<string name="badges_pay_monthly">Pay %1$s/month</string>
<string name="badges_pay_once">Pay %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_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>
<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="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>
</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