ios: sell badge subscriptions and show their state on Your badge

Monthly and annual subscriptions are offered on How long and Check your
order, with the App Store's subscription disclosure, Terms of use and
Privacy policy links, and Restore purchases. Your badge shows a
subscription's state - Renews {date} · {price}, Ends {date}, Payment
problem, Renewing or Ended - with why another badge cannot be bought or
redeemed while it waits for its charge, Manage subscription and Restore
purchases. The chat list shows one banner for the current badge alert of
any kind: renewal soon, payment problem, cancelled, ended, as well as the
expired and renewal-failed ones it had.

Run: 003-badge-subscriptions-ui, slice 10
This commit is contained in:
spacedandy-agents-simplex committed 2026-10-15 07:03:57 +00:00
1 parent be89654a62
commit 69920be75e
6 files changed
+198 -45

No files matched your search

+1 -2
View File
@@ -384,8 +384,7 @@ class BadgeModel: ObservableObject {
}
enum ChatListBanner {
case badgeExpired
case badgeIssueFailed
case badgeAlert
case badgePitch
case getStake
}
@@ -104,8 +104,8 @@ final class BadgeStore: ObservableObject {
// closed, or a purchase it died before handing over, are both unknown, so canBuy refuses to buy again
@Published private var reconciledOnce = false
// Apple folds a new subscription of an Apple ID that subscribed before into the earlier one, whose record
// here is already credited or refused, so the buyer would pay for nothing; true until load() has checked
@Published private var subscribedBefore = true
// here is already credited or refused, so the buyer would pay for nothing; nil until load() has checked
@Published private(set) var subscribedBefore: Bool? = nil
// the sweep has no buyer waiting and runs at four triggers, so a failure that cannot clear is told once a run
private var sweepFailureShown = false
private var transactionUpdates: Task<Void, Never>? = nil
@@ -141,7 +141,7 @@ final class BadgeStore: ObservableObject {
func forSale(_ period: BadgePeriod) -> Bool {
switch period {
case .oneMonth: true
case .monthly, .annual: !subscribedBefore
case .monthly, .annual: subscribedBefore == false
}
}
@@ -7,6 +7,7 @@
//
import SwiftUI
import StoreKit
import SimpleXChat
struct BadgesCheckOrderView: View {
@@ -44,12 +45,32 @@ struct BadgesCheckOrderView: View {
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(.top, 20)
if case let .price(p) = store.price(level, period, compact: false) {
switch period {
case .monthly: subscriptionDisclosure("Payment is charged to your Apple ID when you confirm the purchase. The subscription renews automatically at \(p) per month unless you cancel it at least 24 hours before the end of the current period. You can manage and cancel it in your App Store account settings.")
case .annual: subscriptionDisclosure("Payment is charged to your Apple ID when you confirm the purchase. The subscription renews automatically at \(p) per year unless you cancel it at least 24 hours before the end of the current period. You can manage and cancel it in your App Store account settings.")
case .oneMonth: EmptyView()
}
}
Spacer(minLength: 20)
VStack(spacing: 10) {
payButton()
.padding(.vertical, 10)
BadgeBillingFooter(period: period)
HStack(spacing: 20) {
Link("Terms of use", destination: appleStandardEULAURL)
Link("Privacy policy", destination: conditionsURL)
}
.font(.footnote)
RestorePurchasesButton()
.padding(.top, 10)
Text("Adds an App Store purchase of this Apple ID that no profile here has yet. A purchase still being added on another device may be added here instead.")
.font(.footnote)
.foregroundColor(theme.colors.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0)
}
@@ -65,6 +86,14 @@ struct BadgesCheckOrderView: View {
.alert(item: $alert) { $0.alert }
}
private func subscriptionDisclosure(_ text: LocalizedStringKey) -> some View {
Text(text)
.font(.footnote)
.foregroundColor(theme.colors.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
private func orderRow(_ title: LocalizedStringKey, _ value: Text) -> some View {
HStack {
Text(title)
@@ -122,6 +151,44 @@ struct BadgesCheckOrderView: View {
}
}
// Apple's standard licence agreement, which an app selling subscriptions links as its terms of use
let appleStandardEULAURL = URL(string: "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/")!
struct RestorePurchasesButton: View {
@State private var restoring = false
var body: some View {
Button("Restore purchases") {
restoring = true
Task {
await restore()
await MainActor.run { restoring = false }
}
}
.disabled(restoring)
}
private func restore() async {
do {
let found = try await BadgeStore.shared.restorePurchases()
if !found {
await MainActor.run {
showAlert(
NSLocalizedString("Nothing to restore", comment: "alert title"),
message: NSLocalizedString("No App Store purchase waiting to be added was found.", comment: "alert message")
)
}
}
} catch let error {
logger.error("RestorePurchasesButton: \(String(describing: error))")
if case .userCancelled? = error as? StoreKitError { return }
await MainActor.run {
showAlert(NSLocalizedString("Restore failed", comment: "alert title"), message: redeemErrorText(error, purchase: true))
}
}
}
}
struct BadgesCheckOrderView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
@@ -72,9 +72,14 @@ struct BadgesHowLongView: View {
@EnvironmentObject var theme: AppTheme
@ObservedObject private var store = BadgeStore.shared
let level: BadgeLevel
@State private var selectedPeriod: BadgePeriod = .oneMonth
@State private var chosenPeriod: BadgePeriod? = nil
@State private var continueActive = false
// monthly once subscriptions turn out to be for sale, unless a period was chosen before that
private var selectedPeriod: BadgePeriod {
chosenPeriod ?? (store.forSale(.monthly) ? .monthly : .oneMonth)
}
var body: some View {
GeometryReader { g in
ScrollView {
@@ -104,6 +109,14 @@ struct BadgesHowLongView: View {
}
.fixedSize(horizontal: false, vertical: true)
if store.subscribedBefore == true {
Text("This Apple ID subscribed before. Subscribing again is not available in the app yet.")
.font(.footnote)
.foregroundColor(theme.colors.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 20)
VStack(spacing: 10) {
@@ -128,7 +141,7 @@ struct BadgesHowLongView: View {
let isSelected = period == selectedPeriod
let forSale = store.forSale(period)
return Button {
selectedPeriod = period
chosenPeriod = period
} label: {
VStack(spacing: 12) {
Image(systemName: period.icon)
@@ -14,6 +14,7 @@ struct BadgesYourBadgeView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) private var dismiss
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@ObservedObject private var store = BadgeStore.shared
let badgeState: BadgeState
var showsAsSheet: Bool = false
@@ -34,14 +35,45 @@ struct BadgesYourBadgeView: View {
Section {
BadgeSummary(badgeState: badgeState)
}
Section {
Text(badgeState.paidThroughText)
} header: {
Text("Ends")
.foregroundColor(theme.colors.secondary)
} footer: {
Text("Prepaid months have no billing date. The badge is reissued each month from the balance you already paid for, and ends when it runs out.")
.foregroundColor(theme.colors.secondary)
if let subscription = badgeState.subscription {
let ended = badgeState.paidThrough <= Date.now
let date = badgeState.paidThroughText
switch subscription.status {
case .billingRetry:
subscriptionSection(
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle")
.foregroundColor(.red)
Text("Payment problem")
},
footer: "The App Store could not charge your payment method and is retrying. Update it to keep your badge. Until the payment goes through, you cannot buy or redeem another badge."
)
case .cancelled where !ended:
subscriptionSection(Text("Ends \(date)"), footer: "Your subscription is cancelled. The badge stays until \(date).")
case .ended, .cancelled:
subscriptionSection(Text("Ended"), footer: "Your subscription has ended. The badge will be removed from your profile.")
case .renewing, .unknown, nil:
if ended {
subscriptionSection(Text("Renewing"), footer: "The next month is added once the App Store charges your subscription. Until then you cannot buy or redeem another badge.")
} else if let price = store.subscriptionPrice(badgeState.badgeType, planMonths: subscription.planMonths) {
subscriptionSection(Text("Renews \(date) · \(price)"), footer: "Your subscription renews automatically. You can cancel it in the App Store.")
} else {
subscriptionSection(Text("Renews \(date)"), footer: "Your subscription renews automatically. You can cancel it in the App Store.")
}
}
} else {
Section {
Text(badgeState.paidThroughText)
} header: {
Text("Ends")
.foregroundColor(theme.colors.secondary)
} footer: {
Text("Prepaid months have no billing date. The badge is reissued each month from the balance you already paid for, and ends when it runs out.")
.foregroundColor(theme.colors.secondary)
}
Section {
restorePurchasesRow()
}
}
Section {
NavigationLink {
@@ -110,6 +142,30 @@ struct BadgesYourBadgeView: View {
.modifier(ThemedBackground(grouped: true))
}
private func subscriptionSection(_ status: some View, footer: LocalizedStringKey) -> some View {
Section {
status
settingsRow("creditcard", color: theme.colors.secondary) {
Button("Manage subscription") {
Task { await BadgeStore.shared.manageSubscriptions() }
}
}
restorePurchasesRow()
} header: {
Text("Subscription")
.foregroundColor(theme.colors.secondary)
} footer: {
Text(footer)
.foregroundColor(theme.colors.secondary)
}
}
private func restorePurchasesRow() -> some View {
settingsRow("arrow.clockwise", color: theme.colors.secondary) {
RestorePurchasesButton()
}
}
private func badgeTimestamp(_ date: Date) -> String {
DateFormatter.localizedString(from: date, dateStyle: .medium, timeStyle: .short)
}
@@ -379,18 +379,34 @@ struct ChatListView: View {
}
}
// the onboarding cards replace the whole chat list, and the support-ended banner lives in the
// list - a lapsed supporter is not a newcomer, and must be told even with no conversations yet
// the onboarding cards replace the whole chat list, and the badge alert banner lives in the
// list - a supporter is not a newcomer, and must be told even with no conversations yet
private var shouldShowOnboarding: Bool {
!addressCreationCardShown && !chatModel.chats.isEmpty && !hasConversations && !supportEnded && !badgeIssueFailed
!addressCreationCardShown && !chatModel.chats.isEmpty && !hasConversations && bannerBadgeAlert == nil
}
private var supportEnded: Bool {
badgeModel.alert?.kind == .supportEnded && badgeModel.userId == chatModel.currentUser?.userId
// the current profile's badge alert, if it is a kind the chat list has a banner for
private var bannerBadgeAlert: BadgeAlert? {
guard let alert = badgeModel.alert, badgeModel.userId == chatModel.currentUser?.userId else { return nil }
switch alert.kind {
case .supportEnded, .issueFailed, .subscriptionEnded, .paymentIssue, .subscriptionCancelled, .renewalApproaching: return alert
case .prepaidEnding, .unknown: return nil
}
}
private var badgeIssueFailed: Bool {
badgeModel.alert?.kind == .issueFailed && badgeModel.userId == chatModel.currentUser?.userId
private var renewalPrice: String? {
guard let badgeState = badgeModel.badgeState, let subscription = badgeState.subscription else { return nil }
return BadgeStore.shared.subscriptionPrice(badgeState.badgeType, planMonths: subscription.planMonths)
}
private func badgeAlertBanner(_ title: LocalizedStringKey, _ subtitle: LocalizedStringKey, warning: Bool = false, dismissTitle: String) -> some View {
SupportSimpleXBanner(
title: title,
subtitle: subtitle,
warning: warning,
onTap: { showBadgesSheet = true },
onDismiss: { showBadgeAlertDismissAlert(dismissTitle) }
)
}
private func showBadgeAlertDismissAlert(_ title: String) {
@@ -481,34 +497,36 @@ struct ChatListView: View {
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
// one slot: a badge the user paid for ending outranks the pitch to get one
if supportEnded, let alert = badgeModel.alert {
SupportSimpleXBanner(
title: "Your badge expired",
subtitle: "Your badge expired on \(alert.dateText).",
onTap: { showBadgesSheet = true },
onDismiss: { showBadgeAlertDismissAlert(NSLocalizedString("Your badge expired", comment: "alert title")) }
)
// one slot: an alert about a badge the user paid for outranks the pitch to get one
if let alert = bannerBadgeAlert {
Group {
switch alert.kind {
case .supportEnded:
badgeAlertBanner("Your badge expired", "Your badge expired on \(alert.dateText).", dismissTitle: NSLocalizedString("Your badge expired", comment: "alert title"))
case .issueFailed:
badgeAlertBanner("Badge renewal failed", "Tap for details", warning: true, dismissTitle: NSLocalizedString("Badge renewal failed", comment: "alert title"))
case .subscriptionEnded:
badgeAlertBanner("Your subscription ended", "Your badge subscription ended on \(alert.dateText).", dismissTitle: NSLocalizedString("Your subscription ended", comment: "alert title"))
case .paymentIssue:
badgeAlertBanner("Badge payment problem", "Tap for details", warning: true, dismissTitle: NSLocalizedString("Badge payment problem", comment: "alert title"))
case .subscriptionCancelled:
badgeAlertBanner("Subscription cancelled", "Your badge stays until \(alert.dateText).", dismissTitle: NSLocalizedString("Subscription cancelled", comment: "alert title"))
case .renewalApproaching:
if let price = renewalPrice {
badgeAlertBanner("Your badge renews soon", "Renews on \(alert.dateText) for \(price).", dismissTitle: NSLocalizedString("Your badge renews soon", comment: "alert title"))
} else {
badgeAlertBanner("Your badge renews soon", "Renews on \(alert.dateText).", dismissTitle: NSLocalizedString("Your badge renews soon", comment: "alert title"))
}
case .prepaidEnding, .unknown:
EmptyView()
}
}
.padding(.vertical, 3)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
.zIndex(1)
.onAppear { chatModel.chatListBanner = .badgeExpired }
} else if badgeIssueFailed {
SupportSimpleXBanner(
title: "Badge renewal failed",
subtitle: "Tap for details",
warning: true,
onTap: { showBadgesSheet = true },
onDismiss: { showBadgeAlertDismissAlert(NSLocalizedString("Badge renewal failed", comment: "alert title")) }
)
.padding(.vertical, 3)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
.zIndex(1)
.onAppear { chatModel.chatListBanner = .badgeIssueFailed }
.onAppear { chatModel.chatListBanner = .badgeAlert }
// noShownBadge is false until the badge state loads: the pitch must not lock the slot and then
// be hidden by a supporter's badge arriving a moment later, leaving the slot empty for the session
} else if chatModel.bannerSlotFree(for: .badgePitch) && !supporterBannerShown && noShownBadge() && chatModel.chats.count > 3 {