mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-28 00:44:27 +00:00
ui: badges app store integration (#7374)
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
{
|
||||
"appPolicies" : {
|
||||
"eula" : "",
|
||||
"policies" : [
|
||||
{
|
||||
"locale" : "en_US",
|
||||
"policyText" : "",
|
||||
"policyURL" : ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"identifier" : "B4D6E1A0",
|
||||
"nonRenewingSubscriptions" : [
|
||||
|
||||
],
|
||||
"products" : [
|
||||
{
|
||||
"displayPrice" : "7.00",
|
||||
"familyShareable" : false,
|
||||
"internalID" : "3010000001",
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Supporter badge for one month",
|
||||
"displayName" : "SimpleX Supporter Badge",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"productID" : "BADGE_SUPPORTER_01",
|
||||
"referenceName" : "SimpleX Supporter Badge",
|
||||
"type" : "Consumable"
|
||||
},
|
||||
{
|
||||
"displayPrice" : "70.00",
|
||||
"familyShareable" : false,
|
||||
"internalID" : "3010000002",
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Legend badge for one month",
|
||||
"displayName" : "SimpleX Legend Badge",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"productID" : "BADGE_LEGEND_01",
|
||||
"referenceName" : "SimpleX Legend Badge",
|
||||
"type" : "Consumable"
|
||||
}
|
||||
],
|
||||
"settings" : {
|
||||
"_applicationInternalID" : "",
|
||||
"_developerTeamID" : "",
|
||||
"_failTransactionsEnabled" : false,
|
||||
"_lastSynchronizedDate" : 0,
|
||||
"_locale" : "en_US",
|
||||
"_storefront" : "USA",
|
||||
"_storeKitErrors" : [
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Load Products"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Purchase"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Verification"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "App Store Sync"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Subscription Status"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "App Transaction"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Manage Subscriptions Sheet"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Refund Request Sheet"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Offer Code Redeem Sheet"
|
||||
}
|
||||
]
|
||||
},
|
||||
"subscriptionGroups" : [
|
||||
{
|
||||
"id" : "3020000001",
|
||||
"localizations" : [
|
||||
|
||||
],
|
||||
"name" : "SimpleX Badge Subscription",
|
||||
"subscriptions" : [
|
||||
{
|
||||
"adHocOffers" : [
|
||||
|
||||
],
|
||||
"codeOffers" : [
|
||||
|
||||
],
|
||||
"displayPrice" : "7.00",
|
||||
"familyShareable" : false,
|
||||
"groupNumber" : 2,
|
||||
"internalID" : "3020000011",
|
||||
"introductoryOffer" : null,
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Supporter badge, renews monthly",
|
||||
"displayName" : "SimpleX Supporter Badge (Month)",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"productID" : "SUBSCR_BADGE_SUPPORTER_MONTH_01",
|
||||
"recurringSubscriptionPeriod" : "P1M",
|
||||
"referenceName" : "SimpleX Supporter Badge (Month)",
|
||||
"subscriptionGroupID" : "3020000001",
|
||||
"type" : "RecurringSubscription",
|
||||
"winbackOffers" : [
|
||||
|
||||
]
|
||||
},
|
||||
{
|
||||
"adHocOffers" : [
|
||||
|
||||
],
|
||||
"codeOffers" : [
|
||||
|
||||
],
|
||||
"displayPrice" : "42.00",
|
||||
"familyShareable" : false,
|
||||
"groupNumber" : 2,
|
||||
"internalID" : "3020000012",
|
||||
"introductoryOffer" : null,
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Supporter badge, renews yearly",
|
||||
"displayName" : "SimpleX Supporter Badge (Year)",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"productID" : "SUBSCR_BADGE_SUPPORTER_YEAR_01",
|
||||
"recurringSubscriptionPeriod" : "P1Y",
|
||||
"referenceName" : "SimpleX Supporter Badge (Year)",
|
||||
"subscriptionGroupID" : "3020000001",
|
||||
"type" : "RecurringSubscription",
|
||||
"winbackOffers" : [
|
||||
|
||||
]
|
||||
},
|
||||
{
|
||||
"adHocOffers" : [
|
||||
|
||||
],
|
||||
"codeOffers" : [
|
||||
|
||||
],
|
||||
"displayPrice" : "70.00",
|
||||
"familyShareable" : false,
|
||||
"groupNumber" : 1,
|
||||
"internalID" : "3020000013",
|
||||
"introductoryOffer" : null,
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Legend badge, renews monthly",
|
||||
"displayName" : "SimpleX Legend Badge (Month)",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"productID" : "SUBSCR_BADGE_LEGEND_MONTH_01",
|
||||
"recurringSubscriptionPeriod" : "P1M",
|
||||
"referenceName" : "SimpleX Legend Badge (Month)",
|
||||
"subscriptionGroupID" : "3020000001",
|
||||
"type" : "RecurringSubscription",
|
||||
"winbackOffers" : [
|
||||
|
||||
]
|
||||
},
|
||||
{
|
||||
"adHocOffers" : [
|
||||
|
||||
],
|
||||
"codeOffers" : [
|
||||
|
||||
],
|
||||
"displayPrice" : "420.00",
|
||||
"familyShareable" : false,
|
||||
"groupNumber" : 1,
|
||||
"internalID" : "3020000014",
|
||||
"introductoryOffer" : null,
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Legend badge, renews yearly",
|
||||
"displayName" : "SimpleX Legend Badge (Year)",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"productID" : "SUBSCR_BADGE_LEGEND_YEAR_01",
|
||||
"recurringSubscriptionPeriod" : "P1Y",
|
||||
"referenceName" : "SimpleX Legend Badge (Year)",
|
||||
"subscriptionGroupID" : "3020000001",
|
||||
"type" : "RecurringSubscription",
|
||||
"winbackOffers" : [
|
||||
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"version" : {
|
||||
"major" : 4,
|
||||
"minor" : 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//
|
||||
// BadgeStore.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by spaced4ndy on 14.08.2026.
|
||||
// Copyright © 2026 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import StoreKit
|
||||
|
||||
// TODO [badges] product ids will come from app config and prices from the badge service catalog;
|
||||
// hardcoded here so the App Store integration can be tested before the purchase API lands.
|
||||
func badgeProductId(_ level: BadgeLevel, _ period: BadgePeriod) -> String {
|
||||
switch (level, period) {
|
||||
case (.supporter, .oneMonth): "BADGE_SUPPORTER_01"
|
||||
case (.supporter, .monthly): "SUBSCR_BADGE_SUPPORTER_MONTH_01"
|
||||
case (.supporter, .annual): "SUBSCR_BADGE_SUPPORTER_YEAR_01"
|
||||
case (.legend, .oneMonth): "BADGE_LEGEND_01"
|
||||
case (.legend, .monthly): "SUBSCR_BADGE_LEGEND_MONTH_01"
|
||||
case (.legend, .annual): "SUBSCR_BADGE_LEGEND_YEAR_01"
|
||||
}
|
||||
}
|
||||
|
||||
let badgeProductIds: [String] = BadgeLevel.allCases.flatMap { level in
|
||||
BadgePeriod.allCases.map { badgeProductId(level, $0) }
|
||||
}
|
||||
|
||||
// TODO [badges] replaced by APIGetBadgeInvoice, which creates the invoice row and returns its id.
|
||||
// Apple requires a UUID - it is sent as appAccountToken and echoed back in the signed transaction,
|
||||
// which is how the service learns which invoice a store transaction settles.
|
||||
func newBadgeInvoiceId() -> UUID { UUID() }
|
||||
|
||||
enum BadgePrice {
|
||||
case loading
|
||||
case price(String)
|
||||
case unavailable
|
||||
|
||||
var canPurchase: Bool {
|
||||
switch self {
|
||||
case .price: true
|
||||
case .loading, .unavailable: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct BadgeStoreReceipt {
|
||||
// the signed token the badge service verifies - never transaction.jsonRepresentation
|
||||
let jws: String
|
||||
let productId: String
|
||||
let transactionId: UInt64
|
||||
let invoiceId: UUID?
|
||||
let environment: String?
|
||||
let signatureVerified: Bool
|
||||
}
|
||||
|
||||
enum BadgePurchaseOutcome {
|
||||
case purchased(BadgeStoreReceipt)
|
||||
case pending
|
||||
case cancelled
|
||||
}
|
||||
|
||||
enum BadgeStoreError: Error {
|
||||
case productUnavailable(productId: String)
|
||||
case unknownPurchaseResult
|
||||
}
|
||||
|
||||
final class BadgeStore: ObservableObject {
|
||||
static let shared = BadgeStore()
|
||||
|
||||
private enum LoadState { case notLoaded, loading, loaded, failed }
|
||||
|
||||
@Published private var state: LoadState = .notLoaded
|
||||
private var products: [String: Product] = [:]
|
||||
|
||||
private init() {}
|
||||
|
||||
func price(_ level: BadgeLevel, _ period: BadgePeriod) -> BadgePrice {
|
||||
switch state {
|
||||
case .notLoaded, .loading: return .loading
|
||||
case .loaded, .failed:
|
||||
if let p = products[badgeProductId(level, period)] { return .price(compactPrice(p)) }
|
||||
return .unavailable
|
||||
}
|
||||
}
|
||||
|
||||
// percentage the annual subscription saves against 12 monthly payments
|
||||
func annualSavings(_ level: BadgeLevel) -> Int? {
|
||||
guard let monthly = products[badgeProductId(level, .monthly)],
|
||||
let annual = products[badgeProductId(level, .annual)]
|
||||
else { return nil }
|
||||
let year = monthly.price * 12
|
||||
guard year > 0, annual.price < year else { return nil }
|
||||
let saved = (year - annual.price) / year * 100
|
||||
let percent = Int(NSDecimalNumber(decimal: saved).doubleValue.rounded())
|
||||
return percent > 0 ? percent : nil
|
||||
}
|
||||
|
||||
func load() async {
|
||||
guard await startLoading() else { return }
|
||||
do {
|
||||
let loaded = try await Product.products(for: badgeProductIds)
|
||||
let byId = Dictionary(loaded.map { ($0.id, $0) }, uniquingKeysWith: { p, _ in p })
|
||||
let missing = badgeProductIds.filter { byId[$0] == nil }
|
||||
if !missing.isEmpty {
|
||||
// the store drops ids it cannot resolve without saying why, so the storefront and
|
||||
// the bundle the request was made for are logged with them
|
||||
let storefront = await Storefront.current
|
||||
logger.warning("BadgeStore.load: no product returned for \(missing.joined(separator: ", ")) - bundle \(Bundle.main.bundleIdentifier ?? "?"), storefront \(storefront?.countryCode ?? "none"), canMakePayments \(AppStore.canMakePayments)")
|
||||
}
|
||||
await MainActor.run {
|
||||
products = byId
|
||||
state = .loaded
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("BadgeStore.load: \(String(describing: error))")
|
||||
await MainActor.run { state = .failed }
|
||||
}
|
||||
}
|
||||
|
||||
func purchase(_ level: BadgeLevel, _ period: BadgePeriod, invoiceId: UUID) async throws -> BadgePurchaseOutcome {
|
||||
let productId = badgeProductId(level, period)
|
||||
guard let product = await MainActor.run(body: { products[productId] }) else {
|
||||
throw BadgeStoreError.productUnavailable(productId: productId)
|
||||
}
|
||||
switch try await product.purchase(options: [.appAccountToken(invoiceId)]) {
|
||||
case let .success(verification):
|
||||
let transaction: Transaction
|
||||
let signatureVerified: Bool
|
||||
switch verification {
|
||||
case let .verified(t):
|
||||
transaction = t
|
||||
signatureVerified = true
|
||||
case let .unverified(t, _):
|
||||
transaction = t
|
||||
signatureVerified = false
|
||||
}
|
||||
// nothing is delivered in this build, so the transaction is finished right away; once the
|
||||
// service issues credentials it must only be finished after the credential is stored
|
||||
await transaction.finish()
|
||||
return .purchased(storeReceipt(verification.jwsRepresentation, transaction, signatureVerified))
|
||||
case .pending: return .pending
|
||||
case .userCancelled: return .cancelled
|
||||
@unknown default: throw BadgeStoreError.unknownPurchaseResult
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func startLoading() -> Bool {
|
||||
switch state {
|
||||
case .notLoaded, .failed:
|
||||
state = .loading
|
||||
return true
|
||||
case .loading, .loaded:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// drops the fraction from whole amounts ("$7", not "$7.00") in the product's own currency style;
|
||||
// Product.displayPrice remains the exact form for views that need the cents
|
||||
private func compactPrice(_ product: Product) -> String {
|
||||
var whole = Decimal()
|
||||
var price = product.price
|
||||
NSDecimalRound(&whole, &price, 0, .plain)
|
||||
return whole == product.price
|
||||
? product.price.formatted(product.priceFormatStyle.precision(.fractionLength(0)))
|
||||
: product.displayPrice
|
||||
}
|
||||
|
||||
private func storeReceipt(_ jws: String, _ t: Transaction, _ signatureVerified: Bool) -> BadgeStoreReceipt {
|
||||
var environment: String? = nil
|
||||
if #available(iOS 16.0, *) { environment = t.environment.rawValue }
|
||||
return BadgeStoreReceipt(
|
||||
jws: jws,
|
||||
productId: t.productID,
|
||||
transactionId: t.id,
|
||||
invoiceId: t.appAccountToken,
|
||||
environment: environment,
|
||||
signatureVerified: signatureVerified
|
||||
)
|
||||
}
|
||||
@@ -12,29 +12,62 @@ 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
|
||||
case monthly
|
||||
case annual
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .oneMonth: "calendar"
|
||||
case .subscribe: "arrow.clockwise"
|
||||
case .monthly: "arrow.clockwise"
|
||||
case .annual: "arrow.clockwise"
|
||||
}
|
||||
}
|
||||
|
||||
var label: LocalizedStringKey {
|
||||
switch self {
|
||||
case .oneMonth: "1 month"
|
||||
case .subscribe: "Subscribe"
|
||||
case .monthly: "Monthly"
|
||||
case .annual: "Annual"
|
||||
}
|
||||
}
|
||||
|
||||
func priceText(_ price: BadgePrice) -> Text {
|
||||
switch price {
|
||||
case .loading: return Text(verbatim: "…")
|
||||
case .unavailable: return Text(verbatim: "—")
|
||||
case let .price(p):
|
||||
switch self {
|
||||
case .oneMonth: return Text(verbatim: p)
|
||||
case .monthly: return Text("\(p)/month")
|
||||
case .annual: return Text("\(p)/year")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func payText(_ price: BadgePrice) -> Text {
|
||||
switch price {
|
||||
case .loading: return Text("Loading…")
|
||||
case .unavailable: return Text("Not available")
|
||||
case let .price(p):
|
||||
switch self {
|
||||
case .oneMonth: return Text("Pay \(p)")
|
||||
case .monthly: return Text("Pay \(p)/month")
|
||||
case .annual: return Text("Pay \(p)/year")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct BadgesPayView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@ObservedObject private var store = BadgeStore.shared
|
||||
let level: BadgeLevel
|
||||
@State private var selectedPeriod: BadgePeriod = .subscribe
|
||||
@State private var selectedPeriod: BadgePeriod = .monthly
|
||||
@State private var purchasing = false
|
||||
// presented from this view, not AlertManager: its host is behind the sheet these views open in
|
||||
@State private var alert: SomeAlert?
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { g in
|
||||
@@ -59,10 +92,14 @@ struct BadgesPayView: View {
|
||||
|
||||
Spacer(minLength: 20)
|
||||
|
||||
// fixedSize + maxHeight on the cards so all three match the tallest one -
|
||||
// only Annual carries a savings line, and prices wrap at large fonts
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
periodCard(.oneMonth)
|
||||
periodCard(.subscribe)
|
||||
periodCard(.monthly)
|
||||
periodCard(.annual)
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Spacer(minLength: 20)
|
||||
|
||||
@@ -86,6 +123,8 @@ struct BadgesPayView: View {
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { await store.load() }
|
||||
.alert(item: $alert) { $0.alert }
|
||||
}
|
||||
|
||||
private func periodCard(_ period: BadgePeriod) -> some View {
|
||||
@@ -99,13 +138,21 @@ struct BadgesPayView: View {
|
||||
.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)
|
||||
period.priceText(store.price(level, period))
|
||||
.font(.body)
|
||||
if let percent = savingsPercent(period) {
|
||||
Text("Save \(percent)%")
|
||||
.font(.footnote)
|
||||
.foregroundColor(isSelected ? theme.colors.primary : theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.vertical, 25)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.background(Color(uiColor: .secondarySystemGroupedBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.overlay(
|
||||
@@ -116,13 +163,89 @@ struct BadgesPayView: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func savingsPercent(_ period: BadgePeriod) -> Int? {
|
||||
period == .annual ? store.annualSavings(level) : nil
|
||||
}
|
||||
|
||||
private func payButton() -> some View {
|
||||
Button {
|
||||
// TODO [badges] wire to purchase API when it lands.
|
||||
let price = store.price(level, selectedPeriod)
|
||||
let disabled = !price.canPurchase || purchasing
|
||||
return Button {
|
||||
purchase()
|
||||
} label: {
|
||||
Text(selectedPeriod == .subscribe ? "Pay \(level.priceAmount)/month" : "Pay \(level.priceAmount)")
|
||||
selectedPeriod.payText(price)
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
|
||||
.buttonStyle(OnboardingButtonStyle(isDisabled: disabled))
|
||||
.disabled(disabled)
|
||||
}
|
||||
|
||||
private func purchase() {
|
||||
let period = selectedPeriod
|
||||
let invoiceId = newBadgeInvoiceId()
|
||||
purchasing = true
|
||||
Task {
|
||||
do {
|
||||
let outcome = try await store.purchase(level, period, invoiceId: invoiceId)
|
||||
await MainActor.run {
|
||||
purchasing = false
|
||||
switch outcome {
|
||||
case let .purchased(receipt): showPurchasedAlert(receipt, invoiceId)
|
||||
case .pending:
|
||||
alert = SomeAlert(
|
||||
alert: mkAlert(
|
||||
title: "Purchase pending",
|
||||
message: "The purchase is awaiting approval. This build does not deliver purchases approved later."
|
||||
),
|
||||
id: "badgePurchasePending"
|
||||
)
|
||||
case .cancelled: break
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("BadgesPayView.purchase: \(String(describing: error))")
|
||||
await MainActor.run {
|
||||
purchasing = false
|
||||
alert = SomeAlert(
|
||||
alert: Alert(
|
||||
title: Text("Purchase error"),
|
||||
message: Text(verbatim: String(describing: error))
|
||||
),
|
||||
id: "badgePurchaseError"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO [badges] store integration diagnostics - replaced by the issued badge once the service lands.
|
||||
private func showPurchasedAlert(_ receipt: BadgeStoreReceipt, _ invoiceId: UUID) {
|
||||
let returnedInvoice: String
|
||||
if let returned = receipt.invoiceId {
|
||||
returnedInvoice = returned == invoiceId ? "yes" : "mismatch: \(returned.uuidString)"
|
||||
} else {
|
||||
returnedInvoice = "none"
|
||||
}
|
||||
var lines = [
|
||||
"Product: \(receipt.productId)",
|
||||
"Invoice: \(invoiceId.uuidString)",
|
||||
"Invoice returned by Apple: \(returnedInvoice)",
|
||||
"Transaction: \(receipt.transactionId)"
|
||||
]
|
||||
if let environment = receipt.environment { lines.append("Environment: \(environment)") }
|
||||
lines.append("Signature: \(receipt.signatureVerified ? "verified" : "unverified")")
|
||||
lines.append("Token: \(receipt.jws.count) bytes")
|
||||
let summary = lines.joined(separator: "\n")
|
||||
// logged as well as shown: the alert races StoreKit's own sheets, the log always lands
|
||||
logger.debug("badge purchase succeeded\n\(summary)")
|
||||
alert = SomeAlert(
|
||||
alert: Alert(
|
||||
title: Text("Purchase successful"),
|
||||
message: Text(verbatim: summary),
|
||||
primaryButton: .default(Text(verbatim: "Copy token")) { UIPasteboard.general.string = receipt.jws },
|
||||
secondaryButton: .cancel(Text("Ok"))
|
||||
),
|
||||
id: "badgePurchased"
|
||||
)
|
||||
}
|
||||
|
||||
private var billingFooter: LocalizedStringKey {
|
||||
@@ -131,7 +254,7 @@ struct BadgesPayView: View {
|
||||
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 .monthly, .annual: return "Renews on \(date). Cancel anytime."
|
||||
case .oneMonth: return "Ends on \(date)."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,8 @@ struct BadgesSupportSimplexView: View {
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
// preloaded here so the level screen shows store prices without a placeholder pass
|
||||
.task { await BadgeStore.shared.load() }
|
||||
}
|
||||
|
||||
private func whyBuiltButton() -> some View {
|
||||
|
||||
@@ -30,13 +30,6 @@ enum BadgeLevel: String, CaseIterable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
var priceAmount: String {
|
||||
switch self {
|
||||
case .supporter: "$7"
|
||||
case .legend: "$70"
|
||||
}
|
||||
}
|
||||
|
||||
var tagline: LocalizedStringKey {
|
||||
switch self {
|
||||
case .supporter: "Optional profile badge\nand 2GB files"
|
||||
@@ -54,6 +47,7 @@ enum BadgeLevel: String, CaseIterable, Identifiable {
|
||||
|
||||
struct BadgesYourLevelView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@ObservedObject private var store = BadgeStore.shared
|
||||
@State private var selectedLevel: BadgeLevel = .supporter
|
||||
@State private var continueActive = false
|
||||
@State private var howItWorksActive = false
|
||||
@@ -78,10 +72,13 @@ struct BadgesYourLevelView: View {
|
||||
|
||||
Spacer(minLength: 20)
|
||||
|
||||
// fixedSize + maxHeight on the cards so both match the taller one when a
|
||||
// store price wraps in one of them
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
levelCard(.supporter)
|
||||
levelCard(.legend)
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Spacer(minLength: 20)
|
||||
|
||||
@@ -101,6 +98,7 @@ struct BadgesYourLevelView: View {
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { await store.load() }
|
||||
}
|
||||
|
||||
private func levelCard(_ level: BadgeLevel) -> some View {
|
||||
@@ -120,11 +118,13 @@ struct BadgesYourLevelView: View {
|
||||
Text(level.filesDescription)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
Text("\(level.priceAmount)/month")
|
||||
BadgePeriod.monthly.priceText(store.price(level, .monthly))
|
||||
.font(.body)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.background(Color(uiColor: .secondarySystemGroupedBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.overlay(
|
||||
|
||||
@@ -145,13 +145,6 @@
|
||||
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 */; };
|
||||
@@ -186,6 +179,14 @@
|
||||
64A77A022DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A77A012DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift */; };
|
||||
64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; };
|
||||
64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; };
|
||||
64C03BF9302F423300072BDE /* BadgesPayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF1302F423300072BDE /* BadgesPayView.swift */; };
|
||||
64C03BFA302F423300072BDE /* BadgesRedeemCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF2302F423300072BDE /* BadgesRedeemCodeView.swift */; };
|
||||
64C03BFB302F423300072BDE /* BadgesHowItWorksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF0302F423300072BDE /* BadgesHowItWorksView.swift */; };
|
||||
64C03BFC302F423300072BDE /* BadgeUserPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF6302F423300072BDE /* BadgeUserPreview.swift */; };
|
||||
64C03BFD302F423300072BDE /* BadgeStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF4302F423300072BDE /* BadgeStore.swift */; };
|
||||
64C03BFE302F423300072BDE /* SupportSimpleXBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF7302F423300072BDE /* SupportSimpleXBanner.swift */; };
|
||||
64C03BFF302F423300072BDE /* BadgesYourLevelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF5302F423300072BDE /* BadgesYourLevelView.swift */; };
|
||||
64C03C00302F423300072BDE /* BadgesSupportSimplexView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF3302F423300072BDE /* BadgesSupportSimplexView.swift */; };
|
||||
64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; };
|
||||
64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; };
|
||||
64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; };
|
||||
@@ -531,15 +532,9 @@
|
||||
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>"; };
|
||||
642E412B3033084000F04EE1 /* BadgeTestProducts.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = BadgeTestProducts.storekit; sourceTree = "<group>"; };
|
||||
6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupPreferencesView.swift; sourceTree = "<group>"; };
|
||||
643B3B4D2CCFD6400083A2CF /* OperatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperatorView.swift; sourceTree = "<group>"; };
|
||||
6440C9FF288857A10062C672 /* CIEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIEventView.swift; sourceTree = "<group>"; };
|
||||
@@ -573,6 +568,14 @@
|
||||
64A77A012DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextPendingMemberActionsView.swift; sourceTree = "<group>"; };
|
||||
64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = "<group>"; };
|
||||
64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = "<group>"; };
|
||||
64C03BF0302F423300072BDE /* BadgesHowItWorksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesHowItWorksView.swift; sourceTree = "<group>"; };
|
||||
64C03BF1302F423300072BDE /* BadgesPayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesPayView.swift; sourceTree = "<group>"; };
|
||||
64C03BF2302F423300072BDE /* BadgesRedeemCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesRedeemCodeView.swift; sourceTree = "<group>"; };
|
||||
64C03BF3302F423300072BDE /* BadgesSupportSimplexView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesSupportSimplexView.swift; sourceTree = "<group>"; };
|
||||
64C03BF4302F423300072BDE /* BadgeStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeStore.swift; sourceTree = "<group>"; };
|
||||
64C03BF5302F423300072BDE /* BadgesYourLevelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesYourLevelView.swift; sourceTree = "<group>"; };
|
||||
64C03BF6302F423300072BDE /* BadgeUserPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeUserPreview.swift; sourceTree = "<group>"; };
|
||||
64C03BF7302F423300072BDE /* SupportSimpleXBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportSimpleXBanner.swift; sourceTree = "<group>"; };
|
||||
64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = "<group>"; };
|
||||
64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = "<group>"; };
|
||||
64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
@@ -786,6 +789,7 @@
|
||||
5C2E260D27A30E2400F70299 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
64C03BF8302F423300072BDE /* Badges */,
|
||||
B76E6C2F2C5C41C300EC11AA /* Contacts */,
|
||||
5CB0BA8C282711BC00B3292C /* Onboarding */,
|
||||
3C714775281C080100CB4D4B /* Call */,
|
||||
@@ -798,7 +802,6 @@
|
||||
8C7D94982B8894D300B7B9E1 /* Migration */,
|
||||
5CA8D01B2AD9B076001FD661 /* RemoteAccess */,
|
||||
5CB924DF27A8678B00ACCCDD /* UserSettings */,
|
||||
E5BADE0100000000BADE0000 /* Badges */,
|
||||
5C2E261127A30FEA00F70299 /* TerminalView.swift */,
|
||||
);
|
||||
path = Views;
|
||||
@@ -907,6 +910,7 @@
|
||||
5CA059BD279559F40002BEB4 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
642E412B3033084000F04EE1 /* BadgeTestProducts.storekit */,
|
||||
E5C0BBFD2F82BBC000EA7527 /* Debug.xcconfig */,
|
||||
E5C0BBFE2F82BBC900EA7527 /* Release.xcconfig */,
|
||||
5C55A92D283D0FDE00C4E99E /* sounds */,
|
||||
@@ -1201,6 +1205,21 @@
|
||||
path = Group;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
64C03BF8302F423300072BDE /* Badges */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
64C03BF0302F423300072BDE /* BadgesHowItWorksView.swift */,
|
||||
64C03BF1302F423300072BDE /* BadgesPayView.swift */,
|
||||
64C03BF2302F423300072BDE /* BadgesRedeemCodeView.swift */,
|
||||
64C03BF3302F423300072BDE /* BadgesSupportSimplexView.swift */,
|
||||
64C03BF4302F423300072BDE /* BadgeStore.swift */,
|
||||
64C03BF5302F423300072BDE /* BadgesYourLevelView.swift */,
|
||||
64C03BF6302F423300072BDE /* BadgeUserPreview.swift */,
|
||||
64C03BF7302F423300072BDE /* SupportSimpleXBanner.swift */,
|
||||
);
|
||||
path = Badges;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
8C74C3E92C1B909200039E77 /* Theme */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -1253,20 +1272,6 @@
|
||||
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 */
|
||||
@@ -1631,6 +1636,14 @@
|
||||
640417CD2B29B8C200CCB412 /* NewChatMenuButton.swift in Sources */,
|
||||
5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */,
|
||||
5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */,
|
||||
64C03BF9302F423300072BDE /* BadgesPayView.swift in Sources */,
|
||||
64C03BFA302F423300072BDE /* BadgesRedeemCodeView.swift in Sources */,
|
||||
64C03BFB302F423300072BDE /* BadgesHowItWorksView.swift in Sources */,
|
||||
64C03BFC302F423300072BDE /* BadgeUserPreview.swift in Sources */,
|
||||
64C03BFD302F423300072BDE /* BadgeStore.swift in Sources */,
|
||||
64C03BFE302F423300072BDE /* SupportSimpleXBanner.swift in Sources */,
|
||||
64C03BFF302F423300072BDE /* BadgesYourLevelView.swift in Sources */,
|
||||
64C03C00302F423300072BDE /* BadgesSupportSimplexView.swift in Sources */,
|
||||
5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */,
|
||||
5C58BCD6292BEBE600AF9E4F /* CIChatFeatureView.swift in Sources */,
|
||||
5CB346E72868D76D001FD2EF /* NotificationsView.swift in Sources */,
|
||||
@@ -1722,13 +1735,6 @@
|
||||
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 */,
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
package chat.simplex.app
|
||||
|
||||
import chat.simplex.common.platform.Log
|
||||
import chat.simplex.common.views.badges.BadgeProduct
|
||||
import chat.simplex.common.views.badges.BadgePurchaseOutcome
|
||||
import chat.simplex.common.views.badges.BadgeStoreError
|
||||
import chat.simplex.common.views.badges.BadgeStoreProductId
|
||||
|
||||
// Play Billing is only in the google flavor, so the Play country stays unknown here
|
||||
fun loadPlayStoreCountry() {}
|
||||
|
||||
// No store in this flavor: no product is offered, so the purchase screen shows nothing to buy
|
||||
// TODO [badges] this build pays via Stripe/crypto - the badge service catalog replaces these
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
suspend fun loadBadgeProducts(oneTimeIds: List<BadgeStoreProductId>, subscriptionIds: List<BadgeStoreProductId>): List<BadgeProduct> {
|
||||
Log.w(TAG, "loadBadgeProducts: no store in the foss build, use the google variant to buy from Play")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome =
|
||||
throw BadgeStoreError.StoreUnavailable
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
package chat.simplex.app
|
||||
|
||||
import chat.simplex.common.platform.Log
|
||||
import chat.simplex.common.platform.androidAppContext
|
||||
import chat.simplex.common.platform.androidPlayStoreCountry
|
||||
import chat.simplex.common.platform.mainActivity
|
||||
import chat.simplex.common.views.badges.*
|
||||
import com.android.billingclient.api.*
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
// Requests the country of the Google Play account into [androidPlayStoreCountry].
|
||||
// It stays null when Play is unavailable or the user is not signed in.
|
||||
@@ -29,3 +35,187 @@ fun loadPlayStoreCountry() {
|
||||
override fun onBillingServiceDisconnected() = client.endConnection()
|
||||
})
|
||||
}
|
||||
|
||||
// One long-lived client for badges: ProductDetails obtained from it are passed back to it when the
|
||||
// purchase is launched, and the purchase result arrives on its listener rather than as a return value.
|
||||
// volatile: the listener is called on the main thread, the purchase runs on a background dispatcher
|
||||
@Volatile private var badgeBillingClient: BillingClient? = null
|
||||
@Volatile private var badgeOffers: Map<BadgeStoreProductId, BadgeOffer> = emptyMap()
|
||||
@Volatile private var badgePurchase: CompletableDeferred<BadgePurchaseOutcome>? = null
|
||||
|
||||
// offerToken is null for one-time products, which have no base plan to choose
|
||||
private class BadgeOffer(
|
||||
val id: BadgeStoreProductId,
|
||||
val product: BadgeProduct,
|
||||
val details: ProductDetails,
|
||||
val offerToken: String?
|
||||
)
|
||||
|
||||
suspend fun loadBadgeProducts(oneTimeIds: List<BadgeStoreProductId>, subscriptionIds: List<BadgeStoreProductId>): List<BadgeProduct> {
|
||||
val client = connectedBadgeBillingClient()
|
||||
val details = queryBadgeProducts(client, oneTimeIds, BillingClient.ProductType.INAPP) +
|
||||
queryBadgeProducts(client, subscriptionIds, BillingClient.ProductType.SUBS)
|
||||
val detailsByProductId = details.associateBy { it.productId }
|
||||
val ids = oneTimeIds + subscriptionIds
|
||||
val offers = ids.mapNotNull { id ->
|
||||
val productDetails = detailsByProductId[id.productId]
|
||||
if (productDetails == null) {
|
||||
Log.w(TAG, "loadBadgeProducts: Play returned no product ${id.productId}")
|
||||
null
|
||||
} else {
|
||||
productDetails.badgeOffer(id)
|
||||
}
|
||||
}
|
||||
if (offers.size < ids.size) {
|
||||
// a debug build's applicationIdSuffix is a common cause of Play not knowing the package
|
||||
Log.w(TAG, "loadBadgeProducts: ${offers.size} of ${ids.size} resolved - package ${androidAppContext.packageName}, country ${androidPlayStoreCountry.value ?: "none"}")
|
||||
}
|
||||
badgeOffers = offers.associateBy { it.id }
|
||||
return offers.map { it.product }
|
||||
}
|
||||
|
||||
suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome {
|
||||
val activity = mainActivity.get() ?: throw BadgeStoreError.StoreUnavailable
|
||||
val client = connectedBadgeBillingClient()
|
||||
val offer = badgeOffers[id] ?: throw BadgeStoreError.ProductUnavailable(id.productId)
|
||||
val details = offer.details
|
||||
val productParams = BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(details)
|
||||
offer.offerToken?.let { productParams.setOfferToken(it) }
|
||||
val params = BillingFlowParams.newBuilder()
|
||||
.setProductDetailsParamsList(listOf(productParams.build()))
|
||||
.setObfuscatedAccountId(invoiceId)
|
||||
.build()
|
||||
val purchase = CompletableDeferred<BadgePurchaseOutcome>()
|
||||
badgePurchase = purchase
|
||||
try {
|
||||
val launched = withContext(Dispatchers.Main) { client.launchBillingFlow(activity, params) }
|
||||
if (launched.responseCode != BillingClient.BillingResponseCode.OK) {
|
||||
throw BadgeStoreError.BillingError(launched.responseCode, launched.debugMessage)
|
||||
}
|
||||
val outcome = purchase.await()
|
||||
if (outcome is BadgePurchaseOutcome.Purchased) finishBadgePurchase(client, details, outcome.receipt)
|
||||
return outcome
|
||||
} finally {
|
||||
badgePurchase = null
|
||||
}
|
||||
}
|
||||
|
||||
private val badgePurchasesUpdatedListener = PurchasesUpdatedListener { result, purchases ->
|
||||
val pending = badgePurchase ?: return@PurchasesUpdatedListener
|
||||
when {
|
||||
result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null ->
|
||||
pending.complete(badgePurchaseOutcome(purchases))
|
||||
result.responseCode == BillingClient.BillingResponseCode.USER_CANCELED ->
|
||||
pending.complete(BadgePurchaseOutcome.Cancelled)
|
||||
else ->
|
||||
pending.completeExceptionally(BadgeStoreError.BillingError(result.responseCode, result.debugMessage))
|
||||
}
|
||||
}
|
||||
|
||||
private fun badgePurchaseOutcome(purchases: List<Purchase>): BadgePurchaseOutcome {
|
||||
val purchase = purchases.firstOrNull() ?: return BadgePurchaseOutcome.Cancelled
|
||||
if (purchase.purchaseState == Purchase.PurchaseState.PENDING) return BadgePurchaseOutcome.Pending
|
||||
return BadgePurchaseOutcome.Purchased(
|
||||
BadgeStoreReceipt(
|
||||
token = purchase.purchaseToken,
|
||||
productId = purchase.products.firstOrNull() ?: "",
|
||||
orderId = purchase.orderId,
|
||||
invoiceId = purchase.accountIdentifiers?.obfuscatedAccountId
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun connectedBadgeBillingClient(): BillingClient {
|
||||
badgeBillingClient?.let { if (it.isReady) return it }
|
||||
val client = BillingClient.newBuilder(androidAppContext)
|
||||
.setListener(badgePurchasesUpdatedListener)
|
||||
.enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build())
|
||||
.build()
|
||||
val connected = CompletableDeferred<BillingResult>()
|
||||
client.startConnection(object : BillingClientStateListener {
|
||||
override fun onBillingSetupFinished(result: BillingResult) {
|
||||
connected.complete(result)
|
||||
}
|
||||
|
||||
override fun onBillingServiceDisconnected() {
|
||||
badgeBillingClient = null
|
||||
connected.complete(
|
||||
BillingResult.newBuilder().setResponseCode(BillingClient.BillingResponseCode.SERVICE_DISCONNECTED).build()
|
||||
)
|
||||
}
|
||||
})
|
||||
val result = connected.await()
|
||||
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
|
||||
client.endConnection()
|
||||
throw BadgeStoreError.BillingError(result.responseCode, result.debugMessage)
|
||||
}
|
||||
badgeBillingClient = client
|
||||
return client
|
||||
}
|
||||
|
||||
private suspend fun queryBadgeProducts(client: BillingClient, ids: List<BadgeStoreProductId>, productType: String): List<ProductDetails> {
|
||||
// durations of one subscription share a product id, so the same product is queried once
|
||||
val requested = ids.map { it.productId }.distinct()
|
||||
val params = QueryProductDetailsParams.newBuilder()
|
||||
.setProductList(
|
||||
requested.map {
|
||||
QueryProductDetailsParams.Product.newBuilder().setProductId(it).setProductType(productType).build()
|
||||
}
|
||||
)
|
||||
.build()
|
||||
val queried = CompletableDeferred<List<ProductDetails>>()
|
||||
client.queryProductDetailsAsync(params) { result, productDetailsResult ->
|
||||
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
|
||||
queried.complete(productDetailsResult.productDetailsList)
|
||||
} else {
|
||||
Log.w(TAG, "queryBadgeProducts: $productType requested $requested, failed ${result.responseCode} ${result.debugMessage}")
|
||||
queried.complete(emptyList())
|
||||
}
|
||||
}
|
||||
return queried.await()
|
||||
}
|
||||
|
||||
private fun ProductDetails.badgeOffer(id: BadgeStoreProductId): BadgeOffer? {
|
||||
if (id.basePlanId == null) {
|
||||
val purchase = oneTimePurchaseOfferDetails
|
||||
if (purchase == null) {
|
||||
Log.w(TAG, "badgeOffer: ${id.productId} has no one-time purchase price, type is $productType")
|
||||
return null
|
||||
}
|
||||
val product = BadgeProduct(id, purchase.formattedPrice, purchase.priceAmountMicros, purchase.priceCurrencyCode)
|
||||
return BadgeOffer(id, product, this, offerToken = null)
|
||||
}
|
||||
val offer = subscriptionOfferDetails?.firstOrNull { it.basePlanId == id.basePlanId }
|
||||
if (offer == null) {
|
||||
Log.w(TAG, "badgeOffer: ${id.productId} has no base plan ${id.basePlanId}, Play has ${subscriptionOfferDetails?.map { it.basePlanId }}")
|
||||
return null
|
||||
}
|
||||
val phase = offer.pricingPhases.pricingPhaseList.firstOrNull {
|
||||
it.recurrenceMode == ProductDetails.RecurrenceMode.INFINITE_RECURRING
|
||||
}
|
||||
if (phase == null) {
|
||||
Log.w(TAG, "badgeOffer: ${id.productId} base plan ${id.basePlanId} has no recurring price")
|
||||
return null
|
||||
}
|
||||
val product = BadgeProduct(id, phase.formattedPrice, phase.priceAmountMicros, phase.priceCurrencyCode)
|
||||
return BadgeOffer(id, product, this, offer.offerToken)
|
||||
}
|
||||
|
||||
// nothing is delivered in this build, so the purchase is finished right away; once the service issues
|
||||
// credentials it must only be finished after the credential is stored. One-time products are consumed
|
||||
// so they can be bought again, subscriptions are acknowledged - Play refunds an unacknowledged
|
||||
// purchase after 3 days.
|
||||
private suspend fun finishBadgePurchase(client: BillingClient, details: ProductDetails, receipt: BadgeStoreReceipt) {
|
||||
val done = CompletableDeferred<BillingResult>()
|
||||
if (details.productType == BillingClient.ProductType.SUBS) {
|
||||
val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(receipt.token).build()
|
||||
client.acknowledgePurchase(params) { done.complete(it) }
|
||||
} else {
|
||||
val params = ConsumeParams.newBuilder().setPurchaseToken(receipt.token).build()
|
||||
client.consumeAsync(params) { result, _ -> done.complete(result) }
|
||||
}
|
||||
val result = done.await()
|
||||
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
|
||||
Log.e(TAG, "finishBadgePurchase: ${result.responseCode} ${result.debugMessage}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ 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.BadgeProduct
|
||||
import chat.simplex.common.views.badges.BadgePurchaseOutcome
|
||||
import chat.simplex.common.views.badges.BadgeStoreProductId
|
||||
import chat.simplex.common.views.call.*
|
||||
import chat.simplex.common.views.database.deleteOldChatArchive
|
||||
import chat.simplex.common.views.helpers.*
|
||||
@@ -343,6 +346,10 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
|
||||
override fun androidLoadPlayStoreCountry() = loadPlayStoreCountry()
|
||||
|
||||
override suspend fun androidLoadBadgeProducts(oneTimeIds: List<BadgeStoreProductId>, subscriptionIds: List<BadgeStoreProductId>): List<BadgeProduct> = loadBadgeProducts(oneTimeIds, subscriptionIds)
|
||||
|
||||
override suspend fun androidPurchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = purchaseBadge(id, invoiceId)
|
||||
|
||||
@SuppressLint("SourceLockedOrientationActivity")
|
||||
@Composable
|
||||
override fun androidLockPortraitOrientation() {
|
||||
|
||||
+12
@@ -9,6 +9,10 @@ import androidx.compose.ui.graphics.Color
|
||||
import chat.simplex.common.model.ChatId
|
||||
import chat.simplex.common.model.NotificationsMode
|
||||
import chat.simplex.common.ui.theme.CurrentColors
|
||||
import chat.simplex.common.views.badges.BadgeProduct
|
||||
import chat.simplex.common.views.badges.BadgePurchaseOutcome
|
||||
import chat.simplex.common.views.badges.BadgeStoreError
|
||||
import chat.simplex.common.views.badges.BadgeStoreProductId
|
||||
import kotlinx.coroutines.Job
|
||||
import java.io.Closeable
|
||||
|
||||
@@ -31,6 +35,14 @@ interface PlatformInterface {
|
||||
fun androidIsXiaomiDevice(): Boolean = false
|
||||
// Requests the Google Play account country into [androidPlayStoreCountry]
|
||||
fun androidLoadPlayStoreCountry() {}
|
||||
// Play Billing, only implemented in the google flavor
|
||||
// TODO [badges] desktop and foss pay via Stripe/crypto - these defaults leave them without any
|
||||
// product until that path is implemented
|
||||
suspend fun androidLoadBadgeProducts(oneTimeIds: List<BadgeStoreProductId>, subscriptionIds: List<BadgeStoreProductId>): List<BadgeProduct> {
|
||||
Log.w(TAG, "androidLoadBadgeProducts: no store on this platform")
|
||||
return emptyList()
|
||||
}
|
||||
suspend fun androidPurchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = throw BadgeStoreError.StoreUnavailable
|
||||
val androidApiLevel: Int? get() = null
|
||||
// The build distributed via Google Play, which has to follow its policies
|
||||
val androidIsPlayStoreBuild: Boolean get() = false
|
||||
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
package chat.simplex.common.views.badges
|
||||
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import chat.simplex.common.platform.*
|
||||
import java.text.NumberFormat
|
||||
import java.util.Currency
|
||||
import java.util.UUID
|
||||
|
||||
// a subscription is one store product containing a base plan per duration, so a purchasable badge
|
||||
// is identified by both; one-time products have no base plan
|
||||
data class BadgeStoreProductId(val productId: String, val basePlanId: String? = null)
|
||||
|
||||
// TODO [badges] ids will come from app config and prices from the badge service catalog;
|
||||
// hardcoded here so the Play Store integration can be tested before the purchase API lands.
|
||||
fun badgeStoreProductId(level: BadgeLevel, period: BadgePeriod): BadgeStoreProductId = when (level) {
|
||||
BadgeLevel.Supporter -> when (period) {
|
||||
BadgePeriod.OneMonth -> BadgeStoreProductId("badge_supporter_01")
|
||||
BadgePeriod.Monthly -> BadgeStoreProductId("subscr_badge_supporter_01", "subscr-badge-supporter-month-02")
|
||||
BadgePeriod.Annual -> BadgeStoreProductId("subscr_badge_supporter_01", "subscr-badge-supporter-year-01")
|
||||
}
|
||||
BadgeLevel.Legend -> when (period) {
|
||||
BadgePeriod.OneMonth -> BadgeStoreProductId("badge_legend_01")
|
||||
BadgePeriod.Monthly -> BadgeStoreProductId("subscr_badge_legend_01", "subscr-badge-legend-month-01")
|
||||
BadgePeriod.Annual -> BadgeStoreProductId("subscr_badge_legend_01", "subscr-badge-legend-year-01")
|
||||
}
|
||||
}
|
||||
|
||||
val badgeStoreProductIds: List<BadgeStoreProductId> = BadgeLevel.entries.flatMap { level ->
|
||||
BadgePeriod.entries.map { badgeStoreProductId(level, it) }
|
||||
}
|
||||
|
||||
// TODO [badges] replaced by APIGetBadgeInvoice, which creates the invoice row and returns its id.
|
||||
// Sent to Play as obfuscatedAccountId and echoed back on the purchase, which is how the service
|
||||
// learns which invoice a store transaction settles.
|
||||
fun newBadgeInvoiceId(): String = UUID.randomUUID().toString()
|
||||
|
||||
// what the platform store knows about one product; ProductDetails cannot cross into commonMain
|
||||
data class BadgeProduct(
|
||||
val id: BadgeStoreProductId,
|
||||
val displayPrice: String,
|
||||
val priceMicros: Long,
|
||||
val currencyCode: String
|
||||
)
|
||||
|
||||
sealed class BadgePrice {
|
||||
object Loading: BadgePrice()
|
||||
class Price(val price: String): BadgePrice()
|
||||
object Unavailable: BadgePrice()
|
||||
|
||||
val canPurchase: Boolean
|
||||
get() = when (this) {
|
||||
is Price -> true
|
||||
is Loading, is Unavailable -> false
|
||||
}
|
||||
}
|
||||
|
||||
data class BadgeStoreReceipt(
|
||||
// the token the badge service verifies with the Publisher API
|
||||
val token: String,
|
||||
val productId: String,
|
||||
val orderId: String?,
|
||||
val invoiceId: String?,
|
||||
// only set for test products - a real Play purchase has no environment to report
|
||||
val environment: String? = null
|
||||
)
|
||||
|
||||
// TODO [badges] Play Billing has no offline product configuration. Set to true to price the screens
|
||||
// and walk the purchase flow without Play Console products; the purchase is simulated and its
|
||||
// receipt says so.
|
||||
const val useBadgeTestProducts = false
|
||||
|
||||
private fun testProduct(level: BadgeLevel, period: BadgePeriod, priceMicros: Long) =
|
||||
BadgeProduct(badgeStoreProductId(level, period), "\$${priceMicros / 1_000_000}.00", priceMicros, "USD")
|
||||
|
||||
private val testBadgeProducts: List<BadgeProduct> = listOf(
|
||||
testProduct(BadgeLevel.Supporter, BadgePeriod.OneMonth, 7_000_000),
|
||||
testProduct(BadgeLevel.Supporter, BadgePeriod.Monthly, 7_000_000),
|
||||
testProduct(BadgeLevel.Supporter, BadgePeriod.Annual, 42_000_000),
|
||||
testProduct(BadgeLevel.Legend, BadgePeriod.OneMonth, 70_000_000),
|
||||
testProduct(BadgeLevel.Legend, BadgePeriod.Monthly, 70_000_000),
|
||||
testProduct(BadgeLevel.Legend, BadgePeriod.Annual, 420_000_000)
|
||||
)
|
||||
|
||||
sealed class BadgePurchaseOutcome {
|
||||
class Purchased(val receipt: BadgeStoreReceipt): BadgePurchaseOutcome()
|
||||
object Pending: BadgePurchaseOutcome()
|
||||
object Cancelled: BadgePurchaseOutcome()
|
||||
}
|
||||
|
||||
sealed class BadgeStoreError: Exception() {
|
||||
class ProductUnavailable(val productId: String): BadgeStoreError()
|
||||
class BillingError(val responseCode: Int, val debugMessage: String): BadgeStoreError()
|
||||
object StoreUnavailable: BadgeStoreError()
|
||||
|
||||
override val message: String
|
||||
get() = when (this) {
|
||||
is ProductUnavailable -> "productUnavailable(productId: $productId)"
|
||||
is BillingError -> "billingError(responseCode: $responseCode, $debugMessage)"
|
||||
is StoreUnavailable -> "storeUnavailable"
|
||||
}
|
||||
}
|
||||
|
||||
object BadgeStore {
|
||||
private enum class LoadState { NotLoaded, Loading, Loaded, Failed }
|
||||
|
||||
private val state = mutableStateOf(LoadState.NotLoaded)
|
||||
// snapshot state so a composable reading only the products still recomposes when they arrive
|
||||
private val products = mutableStateOf<Map<BadgeStoreProductId, BadgeProduct>>(emptyMap())
|
||||
|
||||
fun price(level: BadgeLevel, period: BadgePeriod): BadgePrice = when (state.value) {
|
||||
LoadState.NotLoaded, LoadState.Loading -> BadgePrice.Loading
|
||||
LoadState.Loaded, LoadState.Failed -> {
|
||||
val p = products.value[badgeStoreProductId(level, period)]
|
||||
if (p != null) BadgePrice.Price(compactPrice(p)) else BadgePrice.Unavailable
|
||||
}
|
||||
}
|
||||
|
||||
fun annualSavings(level: BadgeLevel): Int? {
|
||||
val monthly = products.value[badgeStoreProductId(level, BadgePeriod.Monthly)] ?: return null
|
||||
val annual = products.value[badgeStoreProductId(level, BadgePeriod.Annual)] ?: return null
|
||||
val year = monthly.priceMicros * 12
|
||||
if (year <= 0 || annual.priceMicros >= year) return null
|
||||
val percent = Math.round((year - annual.priceMicros).toDouble() / year * 100).toInt()
|
||||
return if (percent > 0) percent else null
|
||||
}
|
||||
|
||||
suspend fun load() {
|
||||
if (!startLoading()) return
|
||||
try {
|
||||
// TODO [badges] desktop and the foss build will price from the badge service catalog and pay
|
||||
// via Stripe/crypto instead of a store; only the google build reaches the platform store
|
||||
val loaded = if (useBadgeTestProducts) testBadgeProducts else platform.androidLoadBadgeProducts(
|
||||
oneTimeIds = badgeStoreProductIds.filter { it.basePlanId == null },
|
||||
subscriptionIds = badgeStoreProductIds.filter { it.basePlanId != null }
|
||||
)
|
||||
val byId = loaded.associateBy { it.id }
|
||||
val missing = badgeStoreProductIds.filter { !byId.containsKey(it) }
|
||||
if (missing.isNotEmpty()) {
|
||||
Log.w(TAG, "BadgeStore.load: no product returned for ${missing.joinToString(", ")}")
|
||||
}
|
||||
products.value = byId
|
||||
state.value = LoadState.Loaded
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "BadgeStore.load: ${e.stackTraceToString()}")
|
||||
state.value = LoadState.Failed
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun purchase(level: BadgeLevel, period: BadgePeriod, invoiceId: String): BadgePurchaseOutcome {
|
||||
val id = badgeStoreProductId(level, period)
|
||||
if (!products.value.containsKey(id)) throw BadgeStoreError.ProductUnavailable(id.productId)
|
||||
if (useBadgeTestProducts) {
|
||||
return BadgePurchaseOutcome.Purchased(
|
||||
BadgeStoreReceipt(
|
||||
token = "test-${UUID.randomUUID()}",
|
||||
productId = id.productId,
|
||||
orderId = null,
|
||||
invoiceId = invoiceId,
|
||||
environment = "test products"
|
||||
)
|
||||
)
|
||||
}
|
||||
return platform.androidPurchaseBadge(id, invoiceId)
|
||||
}
|
||||
|
||||
private fun startLoading(): Boolean = when (state.value) {
|
||||
LoadState.NotLoaded, LoadState.Failed -> {
|
||||
state.value = LoadState.Loading
|
||||
true
|
||||
}
|
||||
LoadState.Loading, LoadState.Loaded -> false
|
||||
}
|
||||
}
|
||||
|
||||
// drops the fraction from whole amounts ("$7", not "$7.00") in the product's own currency;
|
||||
// BadgeProduct.displayPrice remains the exact form for views that need the cents
|
||||
private fun compactPrice(product: BadgeProduct): String {
|
||||
if (product.priceMicros % 1_000_000L != 0L) return product.displayPrice
|
||||
return try {
|
||||
val format = NumberFormat.getCurrencyInstance()
|
||||
format.currency = Currency.getInstance(product.currencyCode)
|
||||
format.maximumFractionDigits = 0
|
||||
format.format(product.priceMicros / 1_000_000L)
|
||||
} catch (e: Exception) {
|
||||
product.displayPrice
|
||||
}
|
||||
}
|
||||
+124
-19
@@ -10,6 +10,9 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.ClipboardManager
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -25,24 +28,52 @@ import chat.simplex.res.MR
|
||||
// TODO [badges]: replace with types produced by the badge purchase API when it lands.
|
||||
enum class BadgePeriod {
|
||||
OneMonth,
|
||||
Subscribe;
|
||||
Monthly,
|
||||
Annual;
|
||||
|
||||
val icon: dev.icerock.moko.resources.ImageResource
|
||||
get() = when (this) {
|
||||
OneMonth -> MR.images.ic_calendar
|
||||
Subscribe -> MR.images.ic_refresh
|
||||
Monthly -> MR.images.ic_refresh
|
||||
Annual -> MR.images.ic_refresh
|
||||
}
|
||||
|
||||
val label: StringResource
|
||||
get() = when (this) {
|
||||
OneMonth -> MR.strings.badges_period_one_month
|
||||
Subscribe -> MR.strings.badges_period_subscribe
|
||||
Monthly -> MR.strings.badges_period_monthly
|
||||
Annual -> MR.strings.badges_period_annual
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun priceText(price: BadgePrice): String = when (price) {
|
||||
is BadgePrice.Loading -> "…"
|
||||
is BadgePrice.Unavailable -> "—"
|
||||
is BadgePrice.Price -> when (this) {
|
||||
OneMonth -> price.price
|
||||
Monthly -> stringResource(MR.strings.badges_price_monthly).format(price.price)
|
||||
Annual -> stringResource(MR.strings.badges_price_annual).format(price.price)
|
||||
}
|
||||
}
|
||||
|
||||
fun payLabel(price: BadgePrice): Pair<StringResource, String?> = when (price) {
|
||||
is BadgePrice.Loading -> MR.strings.badges_price_loading to null
|
||||
is BadgePrice.Unavailable -> MR.strings.badges_price_unavailable to null
|
||||
is BadgePrice.Price -> when (this) {
|
||||
OneMonth -> MR.strings.badges_pay_once to price.price
|
||||
Monthly -> MR.strings.badges_pay_monthly to price.price
|
||||
Annual -> MR.strings.badges_pay_annual to price.price
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BadgesPayView(level: BadgeLevel) {
|
||||
var selectedPeriod by remember { mutableStateOf(BadgePeriod.Subscribe) }
|
||||
var selectedPeriod by remember { mutableStateOf(BadgePeriod.Monthly) }
|
||||
val purchasing = remember { mutableStateOf(false) }
|
||||
val clipboard = LocalClipboardManager.current
|
||||
|
||||
LaunchedEffect(Unit) { BadgeStore.load() }
|
||||
|
||||
ColumnWithScrollBar(
|
||||
Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp),
|
||||
@@ -69,24 +100,25 @@ fun BadgesPayView(level: BadgeLevel) {
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp)
|
||||
)
|
||||
|
||||
Spacer(Modifier.weight(1f).heightIn(min = 20.dp))
|
||||
Spacer(Modifier.weight(1f).heightIn(min = 8.dp))
|
||||
|
||||
// IntrinsicSize.Max + fillMaxHeight on children so both cards match the taller card's height
|
||||
// when 2-line labels at large fonts would otherwise size them differently.
|
||||
// IntrinsicSize.Max + fillMaxHeight on children so all three cards match the tallest one -
|
||||
// only Annual carries a savings line, and prices wrap at large fonts.
|
||||
Row(
|
||||
Modifier.fillMaxWidth().height(IntrinsicSize.Max),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
PeriodCard(BadgePeriod.OneMonth, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it }
|
||||
PeriodCard(BadgePeriod.Subscribe, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it }
|
||||
PeriodCard(level, BadgePeriod.OneMonth, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it }
|
||||
PeriodCard(level, BadgePeriod.Monthly, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it }
|
||||
PeriodCard(level, BadgePeriod.Annual, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it }
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f).heightIn(min = 20.dp))
|
||||
Spacer(Modifier.weight(1f).heightIn(min = 8.dp))
|
||||
|
||||
// Replicates TextButtonBelowOnboardingButton spacing (7.5dp outer + 5dp inner) without a
|
||||
// TextButton so the footer has no hover/click affordance.
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
PayButton(level, selectedPeriod)
|
||||
PayButton(level, selectedPeriod, purchasing, clipboard)
|
||||
Box(Modifier.padding(top = 7.5.dp, bottom = 7.5.dp).padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Text(
|
||||
stringResource(billingFooter(selectedPeriod)).format(stubBillingDate()),
|
||||
@@ -101,7 +133,7 @@ fun BadgesPayView(level: BadgeLevel) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PeriodCard(period: BadgePeriod, selectedPeriod: BadgePeriod, modifier: Modifier, onSelect: (BadgePeriod) -> Unit) {
|
||||
private fun PeriodCard(level: BadgeLevel, period: BadgePeriod, selectedPeriod: BadgePeriod, modifier: Modifier, onSelect: (BadgePeriod) -> Unit) {
|
||||
val isSelected = period == selectedPeriod
|
||||
val borderColor = if (isSelected) MaterialTheme.colors.primary else MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.92f)
|
||||
// Light: transparent so card matches page background. Dark: subtle gray tint for visible contrast.
|
||||
@@ -114,7 +146,7 @@ private fun PeriodCard(period: BadgePeriod, selectedPeriod: BadgePeriod, modifie
|
||||
.background(cardBackground, shape)
|
||||
.border(2.dp, borderColor, shape)
|
||||
.clickable { onSelect(period) }
|
||||
.padding(vertical = 30.dp, horizontal = 12.dp),
|
||||
.padding(vertical = 20.dp, horizontal = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
@@ -125,24 +157,97 @@ private fun PeriodCard(period: BadgePeriod, selectedPeriod: BadgePeriod, modifie
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
Text(stringResource(period.label), style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center)
|
||||
Text(period.priceText(BadgeStore.price(level, period)), style = MaterialTheme.typography.body1, textAlign = TextAlign.Center)
|
||||
val percent = savingsPercent(level, period)
|
||||
if (percent != null) {
|
||||
Text(
|
||||
stringResource(MR.strings.badges_savings).format("${percent}%"),
|
||||
style = MaterialTheme.typography.body2,
|
||||
color = if (isSelected) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun savingsPercent(level: BadgeLevel, period: BadgePeriod): Int? =
|
||||
if (period == BadgePeriod.Annual) BadgeStore.annualSavings(level) else null
|
||||
|
||||
// TODO [badges] on desktop and the foss build there is no store, so every price is Unavailable and
|
||||
// this button stays disabled - it will offer Stripe/crypto payment instead
|
||||
@Composable
|
||||
private fun PayButton(level: BadgeLevel, selectedPeriod: BadgePeriod) {
|
||||
private fun PayButton(level: BadgeLevel, selectedPeriod: BadgePeriod, purchasing: MutableState<Boolean>, clipboard: ClipboardManager) {
|
||||
val price = BadgeStore.price(level, selectedPeriod)
|
||||
val (labelId, labelArg) = selectedPeriod.payLabel(price)
|
||||
OnboardingActionButton(
|
||||
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp),
|
||||
labelId = if (selectedPeriod == BadgePeriod.Subscribe) MR.strings.badges_pay_monthly else MR.strings.badges_pay_once,
|
||||
labelArg = level.priceAmount,
|
||||
labelId = labelId,
|
||||
labelArg = labelArg,
|
||||
onboarding = null,
|
||||
onclick = {
|
||||
// TODO [badges] wire to purchase API when it lands.
|
||||
enabled = price.canPurchase && !purchasing.value,
|
||||
onclick = { purchase(level, selectedPeriod, purchasing, clipboard) }
|
||||
)
|
||||
}
|
||||
|
||||
private fun purchase(level: BadgeLevel, period: BadgePeriod, purchasing: MutableState<Boolean>, clipboard: ClipboardManager) {
|
||||
val invoiceId = newBadgeInvoiceId()
|
||||
purchasing.value = true
|
||||
// not withBGApi: the purchase waits for the user in the Play sheet and would block chat API calls
|
||||
withLongRunningApi {
|
||||
try {
|
||||
val outcome = BadgeStore.purchase(level, period, invoiceId)
|
||||
purchasing.value = false
|
||||
when (outcome) {
|
||||
is BadgePurchaseOutcome.Purchased -> showPurchasedAlert(outcome.receipt, invoiceId, clipboard)
|
||||
is BadgePurchaseOutcome.Pending -> AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.badges_purchase_pending),
|
||||
text = generalGetString(MR.strings.badges_purchase_pending_desc)
|
||||
)
|
||||
is BadgePurchaseOutcome.Cancelled -> {}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "BadgesPayView.purchase: ${e.stackTraceToString()}")
|
||||
purchasing.value = false
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.badges_purchase_error),
|
||||
text = e.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO [badges] store integration diagnostics - replaced by the issued badge once the service lands.
|
||||
private fun showPurchasedAlert(receipt: BadgeStoreReceipt, invoiceId: String, clipboard: ClipboardManager) {
|
||||
val returnedInvoice = when (receipt.invoiceId) {
|
||||
null -> "none"
|
||||
invoiceId -> "yes"
|
||||
else -> "mismatch: ${receipt.invoiceId}"
|
||||
}
|
||||
val lines = mutableListOf(
|
||||
"Product: ${receipt.productId}",
|
||||
"Invoice: $invoiceId",
|
||||
"Invoice returned by Google: $returnedInvoice",
|
||||
"Order: ${receipt.orderId ?: "none"}"
|
||||
)
|
||||
if (receipt.environment != null) lines.add("Environment: ${receipt.environment}")
|
||||
lines.add("Token: ${receipt.token.length} bytes")
|
||||
val summary = lines.joinToString("\n")
|
||||
Log.d(TAG, "badge purchase succeeded\n$summary")
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.badges_purchase_successful),
|
||||
text = summary,
|
||||
confirmText = "Copy token",
|
||||
onConfirm = {
|
||||
clipboard.setText(AnnotatedString(receipt.token))
|
||||
showToast(generalGetString(MR.strings.copied))
|
||||
},
|
||||
dismissText = generalGetString(MR.strings.ok),
|
||||
parseHtml = false
|
||||
)
|
||||
}
|
||||
|
||||
private fun billingFooter(period: BadgePeriod): StringResource = when (period) {
|
||||
BadgePeriod.Subscribe -> MR.strings.badges_billing_footer_subscribe
|
||||
BadgePeriod.Monthly, BadgePeriod.Annual -> MR.strings.badges_billing_footer_subscribe
|
||||
BadgePeriod.OneMonth -> MR.strings.badges_billing_footer_one_month
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -33,6 +33,9 @@ import chat.simplex.res.MR
|
||||
@Composable
|
||||
fun BadgesSupportSimplexView() {
|
||||
// TODO [badges] gate on user badge status (no badge → this view, active → "Manage your badge")
|
||||
// preloaded here so the level screen shows store prices without a placeholder pass
|
||||
LaunchedEffect(Unit) { BadgeStore.load() }
|
||||
|
||||
ColumnWithScrollBar(
|
||||
Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
|
||||
+3
-7
@@ -40,12 +40,6 @@ enum class BadgeLevel {
|
||||
Legend -> MR.strings.badges_level_legend_files
|
||||
}
|
||||
|
||||
val priceAmount: String
|
||||
get() = when (this) {
|
||||
Supporter -> "$7"
|
||||
Legend -> "$70"
|
||||
}
|
||||
|
||||
val tagline: StringResource
|
||||
get() = when (this) {
|
||||
Supporter -> MR.strings.badges_level_supporter_tagline
|
||||
@@ -63,6 +57,8 @@ enum class BadgeLevel {
|
||||
fun BadgesYourLevelView() {
|
||||
var selectedLevel by remember { mutableStateOf(BadgeLevel.Supporter) }
|
||||
|
||||
LaunchedEffect(Unit) { BadgeStore.load() }
|
||||
|
||||
ColumnWithScrollBar(
|
||||
Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
@@ -139,7 +135,7 @@ private fun LevelCard(level: BadgeLevel, selectedLevel: BadgeLevel, modifier: Mo
|
||||
)
|
||||
Text(stringResource(level.title), style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center)
|
||||
Text(stringResource(level.filesDescription), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center)
|
||||
Text(stringResource(MR.strings.badges_price_monthly).format(level.priceAmount), style = MaterialTheme.typography.body1, textAlign = TextAlign.Center)
|
||||
Text(BadgePeriod.Monthly.priceText(BadgeStore.price(level, BadgePeriod.Monthly)), style = MaterialTheme.typography.body1, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3206,12 +3206,18 @@
|
||||
<string name="badges_level_supporter_files">Send 2GB files</string>
|
||||
<string name="badges_level_legend_files">Send 5GB files</string>
|
||||
<string name="badges_price_monthly">%1$s/month</string>
|
||||
<string name="badges_price_annual">%1$s/year</string>
|
||||
<string name="badges_pay_monthly">Pay %1$s/month</string>
|
||||
<string name="badges_pay_annual">Pay %1$s/year</string>
|
||||
<string name="badges_pay_once">Pay %1$s</string>
|
||||
<string name="badges_price_loading">Loading…</string>
|
||||
<string name="badges_price_unavailable">Not available</string>
|
||||
<string name="badges_savings">Save %1$s</string>
|
||||
<string name="badges_level_supporter_tagline">Optional profile badge\nand 2GB files</string>
|
||||
<string name="badges_level_legend_tagline">Optional profile badge\nand 5GB files</string>
|
||||
<string name="badges_period_one_month">1 month</string>
|
||||
<string name="badges_period_subscribe">Subscribe</string>
|
||||
<string name="badges_period_monthly">Monthly</string>
|
||||
<string name="badges_period_annual">Annual</string>
|
||||
<string name="badges_billing_footer_subscribe">Renews on %1$s. Cancel anytime.</string>
|
||||
<string name="badges_billing_footer_one_month">Ends on %1$s.</string>
|
||||
<string name="badges_your_level_title">Your level</string>
|
||||
@@ -3230,6 +3236,10 @@
|
||||
<string name="badges_banner_title">Support SimpleX</string>
|
||||
<string name="badges_banner_subtitle">Get badge + files up to 5GB</string>
|
||||
<string name="badges_banner_dismiss_message">You can support SimpleX later in Settings.</string>
|
||||
<string name="badges_purchase_successful">Purchase successful</string>
|
||||
<string name="badges_purchase_pending">Purchase pending</string>
|
||||
<string name="badges_purchase_pending_desc">The purchase is awaiting approval. This build does not deliver purchases approved later.</string>
|
||||
<string name="badges_purchase_error">Purchase error</string>
|
||||
<string name="supporter_perks">Supporter perks</string>
|
||||
<string name="v7_1_supporter_badge_title">Supporter badge ❤️</string>
|
||||
<string name="v7_1_supporter_badge_body">Help keep the network running — send files up to 2 GB.</string>
|
||||
|
||||
Reference in New Issue
Block a user