ui: badges WIP views (#7321)

This commit is contained in:
spaced4ndy
2026-08-14 07:36:02 +00:00
committed by GitHub
parent 4217c9ee84
commit cd670548b9
31 changed files with 1750 additions and 14 deletions
@@ -0,0 +1,39 @@
//
// BadgeUserPreview.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 30.07.2026.
// Copyright © 2026 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct BadgeUserPreview<Trailing: View>: 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
let displayName = user?.displayName ?? NSLocalizedString("My nickname", comment: "badges preview placeholder")
let previewBadge = LocalBadge(
badge: BadgeInfo(badgeType: level.badgeType),
status: .active
)
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()
}
}
}
}
@@ -0,0 +1,46 @@
//
// BadgesHowItWorksView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 28.07.2026.
// Copyright © 2026 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
// TODO [badges]: replace lorem ipsum with the real copy once the badge protocol and privacy properties are documented.
struct BadgesHowItWorksView: View {
@EnvironmentObject var theme: AppTheme
var body: some View {
VStack(alignment: .leading) {
Text("How private badges work")
.font(.largeTitle)
.bold()
.foregroundColor(theme.colors.primary)
.padding(.top, 8)
.padding(.bottom, 16)
ScrollView {
VStack(alignment: .leading, spacing: 12) {
Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.")
Text("Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.")
Text("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.")
}
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
}
.padding(.horizontal, 25)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
struct BadgesHowItWorksView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
BadgesHowItWorksView()
}
}
}
@@ -0,0 +1,146 @@
//
// BadgesPayView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 28.07.2026.
// Copyright © 2026 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
// TODO [badges]: replace with types produced by the badge purchase API when it lands.
enum BadgePeriod: String, CaseIterable, Identifiable {
case oneMonth
case subscribe
var id: String { rawValue }
var icon: String {
switch self {
case .oneMonth: "calendar"
case .subscribe: "arrow.clockwise"
}
}
var label: LocalizedStringKey {
switch self {
case .oneMonth: "1 month"
case .subscribe: "Subscribe"
}
}
}
struct BadgesPayView: View {
@EnvironmentObject var theme: AppTheme
let level: BadgeLevel
@State private var selectedPeriod: BadgePeriod = .subscribe
var body: some View {
GeometryReader { g in
ScrollView {
VStack(alignment: .center, spacing: 16) {
Text(level.title)
.font(.largeTitle)
.bold()
.foregroundColor(theme.colors.primary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
BadgeUserPreview(level: level)
.padding(.top, 4)
Text(level.tagline)
.font(.body)
.foregroundColor(theme.colors.onBackground)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 4)
Spacer(minLength: 20)
HStack(alignment: .top, spacing: 12) {
periodCard(.oneMonth)
periodCard(.subscribe)
}
Spacer(minLength: 20)
VStack(spacing: 10) {
payButton()
.padding(.vertical, 10)
Text(billingFooter)
.font(.footnote)
.foregroundColor(theme.colors.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.frame(height: 22)
}
.padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0)
}
.padding(.horizontal, 25)
.padding(.top, 0)
.padding(.bottom, 20)
.frame(minHeight: g.size.height)
}
}
.frame(maxHeight: .infinity)
.navigationBarTitleDisplayMode(.inline)
}
private func periodCard(_ period: BadgePeriod) -> some View {
let isSelected = period == selectedPeriod
return Button {
selectedPeriod = period
} label: {
VStack(spacing: 12) {
Image(systemName: period.icon)
.resizable()
.scaledToFit()
.frame(width: 32, height: 32)
.foregroundColor(isSelected ? theme.colors.primary : theme.colors.secondary)
.padding(.top, 30)
Text(period.label)
.font(.title3)
.fontWeight(.bold)
.padding(.bottom, 30)
}
.frame(maxWidth: .infinity)
.background(Color(uiColor: .secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 16))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(isSelected ? theme.colors.primary : Color(uiColor: .secondarySystemFill), lineWidth: 2)
)
}
.buttonStyle(.plain)
}
private func payButton() -> some View {
Button {
// TODO [badges] wire to purchase API when it lands.
} label: {
Text(selectedPeriod == .subscribe ? "Pay \(level.priceAmount)/month" : "Pay \(level.priceAmount)")
}
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
}
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)
switch selectedPeriod {
case .subscribe: return "Renews on \(date). Cancel anytime."
case .oneMonth: return "Ends on \(date)."
}
}
}
struct BadgesPayView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
BadgesPayView(level: .supporter)
}
}
}
@@ -0,0 +1,36 @@
//
// BadgesRedeemCodeView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 28.07.2026.
// Copyright © 2026 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
// TODO [badges]: implement input field, server verification and success/failure states when the redeem API is defined.
struct BadgesRedeemCodeView: View {
@EnvironmentObject var theme: AppTheme
var body: some View {
VStack(alignment: .leading) {
Text("Redeem badge code")
.font(.largeTitle)
.bold()
.foregroundColor(theme.colors.primary)
.padding(.top, 8)
Spacer()
}
.padding(.horizontal, 25)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
struct BadgesRedeemCodeView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
BadgesRedeemCodeView()
}
}
}
@@ -0,0 +1,165 @@
//
// BadgesSupportSimplexView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 28.07.2026.
// Copyright © 2026 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct BadgesSupportSimplexView: View {
@EnvironmentObject var theme: AppTheme
// set true when presented as a sheet root (from the chat-list banner) that path doesn't
// reserve nav-bar space like a NavigationLink push does, so the title lands too close to the top
var showsAsSheet: Bool = false
@State private var whyBuiltActive = false
@State private var chooseLevelActive = false
@State private var redeemCodeActive = false
var body: some View {
// TODO [badges] gate on user badge status (no badge this view, active "Manage your badge")
GeometryReader { g in
VStack(alignment: .center, spacing: 16) {
Text("Support SimpleX")
.font(.largeTitle)
.bold()
.foregroundColor(theme.colors.primary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
Text("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.")
.font(.body)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
whyBuiltButton()
Spacer(minLength: 0)
PhoneSupporterHero()
.frame(maxWidth: g.size.width * 0.55)
.layoutPriority(-1)
Spacer(minLength: 0)
// Onboarding pattern: nested VStack(spacing: 10) + action button vertical padding 10.
VStack(spacing: 10) {
chooseLevelButton()
.padding(.vertical, 10)
redeemCodeButton()
.frame(height: 22)
}
.padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0)
}
.padding(.horizontal, 25)
.padding(.top, showsAsSheet ? 48 : 0)
.padding(.bottom, 20)
// .frame(height:) not minHeight inside the banner sheet's NavigationView minHeight
// would let the VStack expand past the visible area and inflate the hero.
.frame(height: g.size.height)
}
.frame(maxHeight: .infinity)
.navigationBarTitleDisplayMode(.inline)
}
private func whyBuiltButton() -> some View {
ZStack {
Button { whyBuiltActive = true } label: {
HStack(spacing: 4) {
Image(systemName: "info.circle")
Text("Why SimpleX is built.").fontWeight(.medium)
}
.font(.body)
}
NavigationLink(isActive: $whyBuiltActive) {
WhySimpleX(onboarding: false, titleColor: theme.colors.primary, createProfileNavLinkActive: .constant(false))
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
}
}
private func chooseLevelButton() -> some View {
ZStack {
Button {
chooseLevelActive = true
} label: {
Text("Choose your level")
}
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
NavigationLink(isActive: $chooseLevelActive) {
BadgesYourLevelView()
.modifier(ThemedBackground())
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
}
}
private func redeemCodeButton() -> some View {
ZStack {
Button {
redeemCodeActive = true
} label: {
Text("Redeem badge code")
.font(.body)
.fontWeight(.medium)
.foregroundColor(theme.colors.primary)
}
NavigationLink(isActive: $redeemCodeActive) {
BadgesRedeemCodeView()
.modifier(ThemedBackground())
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
}
}
}
struct PhoneSupporterHero: View {
@EnvironmentObject var theme: AppTheme
@Environment(\.colorScheme) var colorScheme: ColorScheme
var body: some View {
#if SIMPLEX_ASSETS
Image(colorScheme == .light ? "phone-supporter" : "phone-supporter-light")
.resizable()
.scaledToFit()
.frame(maxWidth: .infinity)
#else
ZStack {
let gp = OnboardingCardView.gradientPoints(aspectRatio: 1.0, scale: colorScheme == .light ? 1.2 : 1.5)
LinearGradient(
stops: colorScheme == .light ? OnboardingCardView.lightStops : OnboardingCardView.darkStops,
startPoint: gp.start,
endPoint: gp.end
)
Image("badge-supporter")
.resizable()
.scaledToFit()
.frame(width: 96)
}
.aspectRatio(1.0, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 24))
.frame(maxWidth: .infinity)
#endif
}
}
struct BadgesSupportSimplexView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
BadgesSupportSimplexView()
}
}
}
@@ -0,0 +1,188 @@
//
// BadgesYourLevelView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 28.07.2026.
// Copyright © 2026 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
// TODO [badges]: replace with types produced by the badge purchase API when it lands.
enum BadgeLevel: String, CaseIterable, Identifiable {
case supporter
case legend
var id: String { rawValue }
var title: LocalizedStringKey {
switch self {
case .supporter: "Supporter"
case .legend: "Legend"
}
}
var filesDescription: LocalizedStringKey {
switch self {
case .supporter: "Send 2GB files"
case .legend: "Send 5GB files"
}
}
var priceAmount: String {
switch self {
case .supporter: "$7"
case .legend: "$70"
}
}
var tagline: LocalizedStringKey {
switch self {
case .supporter: "Optional profile badge\nand 2GB files"
case .legend: "Optional profile badge\nand 5GB files"
}
}
var badgeType: BadgeType {
switch self {
case .supporter: .supporter
case .legend: .legend
}
}
}
struct BadgesYourLevelView: View {
@EnvironmentObject var theme: AppTheme
@State private var selectedLevel: BadgeLevel = .supporter
@State private var continueActive = false
@State private var howItWorksActive = false
var body: some View {
GeometryReader { g in
ScrollView {
VStack(alignment: .center, spacing: 16) {
Text("Your level")
.font(.largeTitle)
.bold()
.foregroundColor(theme.colors.primary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
BadgeUserPreview(level: selectedLevel) {
Image(systemName: "chevron.down")
.font(.body)
.foregroundColor(theme.colors.primary)
}
.padding(.top, 4)
Spacer(minLength: 20)
HStack(alignment: .top, spacing: 12) {
levelCard(.supporter)
levelCard(.legend)
}
Spacer(minLength: 20)
VStack(spacing: 10) {
continueButton()
.padding(.vertical, 10)
howItWorksButton()
.frame(height: 22)
}
.padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0)
}
.padding(.horizontal, 25)
.padding(.top, 0)
.padding(.bottom, 20)
.frame(minHeight: g.size.height)
}
}
.frame(maxHeight: .infinity)
.navigationBarTitleDisplayMode(.inline)
}
private func levelCard(_ level: BadgeLevel) -> some View {
let isSelected = level == selectedLevel
return Button {
selectedLevel = level
} label: {
VStack(spacing: 10) {
Image(badgeImageName(level.badgeType))
.resizable()
.scaledToFit()
.frame(width: 60, height: 60)
.padding(.top, 20)
Text(level.title)
.font(.title3)
.fontWeight(.bold)
Text(level.filesDescription)
.font(.subheadline)
.foregroundColor(theme.colors.secondary)
Text("\(level.priceAmount)/month")
.font(.body)
.padding(.bottom, 20)
}
.frame(maxWidth: .infinity)
.background(Color(uiColor: .secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 16))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(isSelected ? theme.colors.primary : Color(uiColor: .secondarySystemFill), lineWidth: 2)
)
}
.buttonStyle(.plain)
}
private func continueButton() -> some View {
ZStack {
Button {
continueActive = true
} label: {
Text("Continue")
}
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
NavigationLink(isActive: $continueActive) {
BadgesPayView(level: selectedLevel)
.modifier(ThemedBackground())
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
}
}
private func howItWorksButton() -> some View {
ZStack {
Button {
howItWorksActive = true
} label: {
HStack(spacing: 4) {
Image(systemName: "info.circle")
Text("How private badges work").fontWeight(.medium)
}
.font(.body)
}
NavigationLink(isActive: $howItWorksActive) {
BadgesHowItWorksView()
.modifier(ThemedBackground())
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
}
}
}
struct BadgesYourLevelView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
BadgesYourLevelView()
}
}
}
@@ -0,0 +1,129 @@
//
// SupportSimpleXBanner.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 28.07.2026.
// Copyright © 2026 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct SupportSimpleXBanner: View {
@EnvironmentObject var theme: AppTheme
@Environment(\.colorScheme) var colorScheme: ColorScheme
@State private var showDismissAlert = false
let onTap: () -> Void
let onDismiss: () -> Void
private let cardCornerRadius: CGFloat = 16
// grows with Dynamic Type but never shrinks below the default so small-font users see the same
// banner as today; hero stays fixed so its above-card overhang shrinks at very large fonts
@ScaledMetric(relativeTo: .body) private var scaledCardHeight: CGFloat = 72
private var cardHeight: CGFloat { max(72, scaledCardHeight) }
// matches OneHandUICard's segment icon leading so the text aligns with it in the list
private let cardLeadingPadding: CGFloat = 16
private let cardTrailingPadding: CGFloat = 8
private let heroWidth: CGFloat = 110
// shorter than the natural drawn height so .clipped() slices the phone body at card bottom
private let heroVisibleHeight: CGFloat = 108
// hero right edge sits exactly at the dismiss X's left edge (X trailing 16 + width 12)
private let heroTrailingPadding: CGFloat = 28
private let textToHeroGap: CGFloat = 6
var body: some View {
// Card is the Button; hero is an overlay so it can extend above the card top without
// affecting layout size. Dismiss X is a ZStack sibling anchored to the card's top-right.
ZStack(alignment: .topTrailing) {
Button(action: onTap) {
HStack(spacing: 0) {
VStack(alignment: .leading, spacing: 4) {
Text("Support SimpleX")
.font(.headline)
.foregroundColor(theme.colors.primary)
.lineLimit(2)
Text("Get badge + files up to 5GB")
.font(.subheadline)
.foregroundColor(theme.colors.onBackground)
.lineLimit(2)
}
Spacer(minLength: heroWidth + heroTrailingPadding + textToHeroGap)
}
.padding(.leading, cardLeadingPadding)
.padding(.trailing, cardTrailingPadding)
.padding(.vertical, 12)
.frame(minHeight: cardHeight)
.background(gradientBackground())
.clipShape(RoundedRectangle(cornerRadius: cardCornerRadius))
}
.buttonStyle(.plain)
.overlay(alignment: .bottomTrailing) {
heroThumbnail()
.padding(.trailing, heroTrailingPadding)
.allowsHitTesting(false)
}
Image(systemName: "multiply")
.foregroundColor(colorScheme == .dark ? theme.colors.onBackground : theme.colors.secondary)
.frame(width: 12, height: 12)
.padding(.top, 12)
.padding(.bottom, 4)
.padding(.trailing, 16)
.padding(.leading, 4)
.contentShape(Rectangle())
.onTapGesture { showDismissAlert = true }
}
.alert(isPresented: $showDismissAlert) {
Alert(
title: Text("Support SimpleX"),
message: Text("You can support SimpleX later in Settings."),
dismissButton: .default(Text("Ok"), action: onDismiss)
)
}
}
@ViewBuilder
private func heroThumbnail() -> some View {
#if SIMPLEX_ASSETS
// draws at natural aspect, top-aligned in a shorter slot; .clipped() cuts the overflow at card bottom
Image(colorScheme == .light ? "phone-supporter" : "phone-supporter-light")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: heroWidth, height: heroVisibleHeight, alignment: .top)
.clipped()
#else
Image("badge-supporter")
.resizable()
.scaledToFit()
.frame(width: 48, height: 48)
.padding(.vertical, (cardHeight - 48) / 2)
.padding(.trailing, 12)
#endif
}
private func gradientBackground() -> some View {
// Asymmetric scale: start (dark end) pushed further below the card than the end (warm) is
// above, so the card's middle lands at the bright/mid-transition stop instead of the dark
// navy region. Keeps the small warm accent at top-right.
GeometryReader { geo in
let aspect = max(geo.size.height, 1) / max(geo.size.width, 1)
let startScale: CGFloat = colorScheme == .light ? 2.5 : 3.0
let endScale: CGFloat = colorScheme == .light ? 1.7 : 2.1
let gp = OnboardingCardView.gradientPoints(aspectRatio: aspect, scale: 1.0)
let start = UnitPoint(x: 0.5 + (gp.start.x - 0.5) * startScale, y: 0.5 + (gp.start.y - 0.5) * startScale)
let end = UnitPoint(x: 0.5 + (gp.end.x - 0.5) * endScale, y: 0.5 + (gp.end.y - 0.5) * endScale)
return LinearGradient(
stops: colorScheme == .light ? OnboardingCardView.lightStops : OnboardingCardView.darkStops,
startPoint: start,
endPoint: end
)
}
}
}
struct SupportSimpleXBanner_Previews: PreviewProvider {
static var previews: some View {
SupportSimpleXBanner(onTap: {}, onDismiss: {})
.padding()
}
}
@@ -174,7 +174,9 @@ struct ChatListView: View {
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
@AppStorage(DEFAULT_ONE_HAND_UI_CARD_SHOWN) private var oneHandUICardShown = false
@AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false
@AppStorage(DEFAULT_SUPPORTER_BANNER_SHOWN) private var supporterBannerShown = false
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
@State private var showBadgesSheet = false
// Spec: spec/client/chat-list.md#body
var body: some View {
@@ -211,6 +213,12 @@ struct ChatListView: View {
NewChatSheet()
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
}
.appSheet(isPresented: $showBadgesSheet) {
NavigationView {
BadgesSupportSimplexView(showsAsSheet: true)
.modifier(ThemedBackground())
}
}
.onChange(of: activeUserPickerSheet) {
if $0 != nil {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
@@ -418,6 +426,17 @@ struct ChatListView: View {
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
if !supporterBannerShown && chatModel.chats.count > 3 {
SupportSimpleXBanner(
onTap: { showBadgesSheet = true },
onDismiss: { withAnimation { supporterBannerShown = true } }
)
.padding(.vertical, 3)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
.zIndex(1)
}
if #available(iOS 16.0, *) {
ForEach(cs, id: \.viewId) { chat in
ChatListNavLink(chat: chat, parentSheet: $sheet)
@@ -95,7 +95,7 @@ struct NameBadge: View {
}
}
private func badgeImageName(_ t: BadgeType) -> String {
func badgeImageName(_ t: BadgeType) -> String {
switch t {
case .legend: "badge-legend"
case .investor: "badge-investor"
@@ -65,6 +65,7 @@ struct WhySimpleX: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var m: ChatModel
var onboarding: Bool
var titleColor: Color? = nil
@Binding var createProfileNavLinkActive: Bool
var body: some View {
@@ -74,6 +75,7 @@ struct WhySimpleX: View {
Text("You were born without an account")
.font(.title)
.bold()
.foregroundColor(titleColor)
.padding(.top)
Text("Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life.")
Text("Then we moved online, and every platform asked for a piece of you - your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way - telephone, email, messengers, social media. It seemed the only way possible.")
@@ -695,6 +695,18 @@ private let versionDescriptions: [VersionDescription] = [
))
]
),
// TODO [badges] finalise copy + Read more link before v7.1 ships.
VersionDescription(
version: "v7.1",
post: nil,
features: [
.view(FeatureView(
icon: nil,
title: "Supporter badge",
view: { SupporterBadgeWhatsNew() }
))
]
),
]
private let lastVersion = versionDescriptions.last!.version
@@ -709,6 +721,42 @@ func shouldShowWhatsNew() -> Bool {
return v != lastVersion
}
fileprivate struct SupporterBadgeWhatsNew: View {
@Environment(\.colorScheme) var colorScheme: ColorScheme
@EnvironmentObject var theme: AppTheme
var body: some View {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 6) {
Text("Supporter badge ❤️")
.font(.title3)
.bold()
Text("Help keep the network running — send files up to 2 GB.")
.multilineTextAlignment(.leading)
.lineLimit(10)
}
.frame(maxWidth: .infinity, alignment: .leading)
heroThumbnail()
}
}
@ViewBuilder
private func heroThumbnail() -> some View {
#if SIMPLEX_ASSETS
Image(colorScheme == .light ? "phone-supporter" : "phone-supporter-light")
.resizable()
.scaledToFit()
.frame(width: 110, height: 110)
#else
Image("badge-supporter")
.resizable()
.scaledToFit()
.frame(width: 72, height: 72)
#endif
}
}
fileprivate struct NewOperatorsView: View {
var body: some View {
VStack(alignment: .leading) {
@@ -56,6 +56,7 @@ let DEFAULT_CHAT_ITEM_ROUNDNESS = "chatItemRoundness"
let DEFAULT_CHAT_ITEM_TAIL = "chatItemTail"
let DEFAULT_ONE_HAND_UI_CARD_SHOWN = "oneHandUICardShown"
let DEFAULT_ADDRESS_CREATION_CARD_SHOWN = "addressCreationCardShown"
let DEFAULT_SUPPORTER_BANNER_SHOWN = "supporterBannerShown"
let DEFAULT_TOOLBAR_MATERIAL = "toolbarMaterial"
let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab"
let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown"
@@ -117,6 +118,7 @@ let appDefaults: [String: Any] = [
DEFAULT_CHAT_ITEM_TAIL: true,
DEFAULT_ONE_HAND_UI_CARD_SHOWN: false,
DEFAULT_ADDRESS_CREATION_CARD_SHOWN: false,
DEFAULT_SUPPORTER_BANNER_SHOWN: false,
DEFAULT_TOOLBAR_MATERIAL: ToolbarMaterial.defaultMaterial,
DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue,
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false,
@@ -148,6 +150,7 @@ let hintDefaults = [
DEFAULT_LA_NOTICE_SHOWN,
DEFAULT_ONE_HAND_UI_CARD_SHOWN,
DEFAULT_ADDRESS_CREATION_CARD_SHOWN,
DEFAULT_SUPPORTER_BANNER_SHOWN,
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN,
DEFAULT_SIGN_MESSAGE_ALERT_SHOWN,
DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE,
@@ -341,6 +344,21 @@ struct SettingsView: View {
}
}
Section {
NavigationLink {
BadgesSupportSimplexView()
.modifier(ThemedBackground())
} label: {
ZStack(alignment: .leading) {
Image("badge-supporter")
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
Text("Supporter perks").padding(.leading, indent)
}
}
}
Section(header: Text("Advanced settings").foregroundColor(theme.colors.secondary)) {
NavigationLink {
NetworkAndServers()
@@ -375,7 +393,7 @@ struct SettingsView: View {
NavigationLink {
VersionView()
.navigationBarTitle("App version")
.modifier(ThemedBackground())
.modifier(ThemedBackground(grouped: true))
} label: {
Text(verbatim: "v\(appVersion ?? "?")")
}
@@ -145,6 +145,13 @@
640417CE2B29B8C200CCB412 /* NewChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640417CC2B29B8C200CCB412 /* NewChatView.swift */; };
640743612CD360E600158442 /* ChooseServerOperators.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640743602CD360E600158442 /* ChooseServerOperators.swift */; };
6407BA83295DA85D0082BA18 /* CIInvalidJSONView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */; };
641378013020A5AD0056E083 /* BadgesRedeemCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641377FC3020A5AD0056E083 /* BadgesRedeemCodeView.swift */; };
641378023020A5AD0056E083 /* SupportSimpleXBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641378003020A5AD0056E083 /* SupportSimpleXBanner.swift */; };
641378033020A5AD0056E083 /* BadgesHowItWorksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641377FA3020A5AD0056E083 /* BadgesHowItWorksView.swift */; };
641378043020A5AD0056E083 /* BadgesSupportSimplexView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641377FD3020A5AD0056E083 /* BadgesSupportSimplexView.swift */; };
641378053020A5AD0056E083 /* BadgesYourLevelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641377FE3020A5AD0056E083 /* BadgesYourLevelView.swift */; };
641378063020A5AD0056E083 /* BadgeUserPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641377FF3020A5AD0056E083 /* BadgeUserPreview.swift */; };
641378073020A5AD0056E083 /* BadgesPayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641377FB3020A5AD0056E083 /* BadgesPayView.swift */; };
6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */; };
642BA82D2CE50495005E9412 /* NewServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 642BA82C2CE50495005E9412 /* NewServerView.swift */; };
6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */; };
@@ -524,6 +531,13 @@
640417CC2B29B8C200CCB412 /* NewChatView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewChatView.swift; sourceTree = "<group>"; };
640743602CD360E600158442 /* ChooseServerOperators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChooseServerOperators.swift; sourceTree = "<group>"; };
6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIInvalidJSONView.swift; sourceTree = "<group>"; };
641377FA3020A5AD0056E083 /* BadgesHowItWorksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesHowItWorksView.swift; sourceTree = "<group>"; };
641377FB3020A5AD0056E083 /* BadgesPayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesPayView.swift; sourceTree = "<group>"; };
641377FC3020A5AD0056E083 /* BadgesRedeemCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesRedeemCodeView.swift; sourceTree = "<group>"; };
641377FD3020A5AD0056E083 /* BadgesSupportSimplexView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesSupportSimplexView.swift; sourceTree = "<group>"; };
641377FE3020A5AD0056E083 /* BadgesYourLevelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesYourLevelView.swift; sourceTree = "<group>"; };
641377FF3020A5AD0056E083 /* BadgeUserPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeUserPreview.swift; sourceTree = "<group>"; };
641378003020A5AD0056E083 /* SupportSimpleXBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportSimpleXBanner.swift; sourceTree = "<group>"; };
6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMemberCreatedContactView.swift; sourceTree = "<group>"; };
642BA82C2CE50495005E9412 /* NewServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewServerView.swift; sourceTree = "<group>"; };
6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupPreferencesView.swift; sourceTree = "<group>"; };
@@ -784,6 +798,7 @@
8C7D94982B8894D300B7B9E1 /* Migration */,
5CA8D01B2AD9B076001FD661 /* RemoteAccess */,
5CB924DF27A8678B00ACCCDD /* UserSettings */,
E5BADE0100000000BADE0000 /* Badges */,
5C2E261127A30FEA00F70299 /* TerminalView.swift */,
);
path = Views;
@@ -1238,6 +1253,20 @@
path = "SimpleX SE";
sourceTree = "<group>";
};
E5BADE0100000000BADE0000 /* Badges */ = {
isa = PBXGroup;
children = (
641377FA3020A5AD0056E083 /* BadgesHowItWorksView.swift */,
641377FB3020A5AD0056E083 /* BadgesPayView.swift */,
641377FC3020A5AD0056E083 /* BadgesRedeemCodeView.swift */,
641377FD3020A5AD0056E083 /* BadgesSupportSimplexView.swift */,
641377FE3020A5AD0056E083 /* BadgesYourLevelView.swift */,
641377FF3020A5AD0056E083 /* BadgeUserPreview.swift */,
641378003020A5AD0056E083 /* SupportSimpleXBanner.swift */,
);
path = Badges;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXHeadersBuildPhase section */
@@ -1693,6 +1722,13 @@
1841538E296606C74533367C /* UserPicker.swift in Sources */,
18415B0585EB5A9A0A7CA8CD /* PressedButtonStyle.swift in Sources */,
1841560FD1CD447955474C1D /* UserProfilesView.swift in Sources */,
641378013020A5AD0056E083 /* BadgesRedeemCodeView.swift in Sources */,
641378023020A5AD0056E083 /* SupportSimpleXBanner.swift in Sources */,
641378033020A5AD0056E083 /* BadgesHowItWorksView.swift in Sources */,
641378043020A5AD0056E083 /* BadgesSupportSimplexView.swift in Sources */,
641378053020A5AD0056E083 /* BadgesYourLevelView.swift in Sources */,
641378063020A5AD0056E083 /* BadgeUserPreview.swift in Sources */,
641378073020A5AD0056E083 /* BadgesPayView.swift in Sources */,
64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */,
8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */,
64A779F62DBFB9F200FDEF2F /* MemberAdmissionView.swift in Sources */,
+11
View File
@@ -302,11 +302,22 @@ public struct BadgeInfo: Codable, Hashable {
public var badgeType: BadgeType
public var badgeExpiry: Date?
public var badgeExtra: String
public init(badgeType: BadgeType, badgeExpiry: Date? = nil, badgeExtra: String = "") {
self.badgeType = badgeType
self.badgeExpiry = badgeExpiry
self.badgeExtra = badgeExtra
}
}
public struct LocalBadge: Codable, Hashable {
public var badge: BadgeInfo
public var status: BadgeStatus
public init(badge: BadgeInfo, status: BadgeStatus) {
self.badge = badge
self.status = status
}
}
// the wire proof carried on a profile - opaque to the UI, only round-tripped back to the core (apiPrepareContact)
@@ -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