From 69920be75e4b1f096035509dfe4a61d0f244ba55 Mon Sep 17 00:00:00 2001 From: spacedandy-agents-simplex <342056592+spacedandy-agents-simplex@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:03:57 +0000 Subject: [PATCH] ios: sell badge subscriptions and show their state on Your badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/ios/Shared/Model/ChatModel.swift | 3 +- apps/ios/Shared/Views/Badges/BadgeStore.swift | 6 +- .../Views/Badges/BadgesCheckOrderView.swift | 67 ++++++++++++++++ .../Views/Badges/BadgesHowLongView.swift | 17 +++- .../Views/Badges/BadgesYourBadgeView.swift | 72 +++++++++++++++-- .../Shared/Views/ChatList/ChatListView.swift | 78 ++++++++++++------- 6 files changed, 198 insertions(+), 45 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 7c1e8e65ab..b9e827185c 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -384,8 +384,7 @@ class BadgeModel: ObservableObject { } enum ChatListBanner { - case badgeExpired - case badgeIssueFailed + case badgeAlert case badgePitch case getStake } diff --git a/apps/ios/Shared/Views/Badges/BadgeStore.swift b/apps/ios/Shared/Views/Badges/BadgeStore.swift index 9c7da80b2b..d63a481bbb 100644 --- a/apps/ios/Shared/Views/Badges/BadgeStore.swift +++ b/apps/ios/Shared/Views/Badges/BadgeStore.swift @@ -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? = 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 } } diff --git a/apps/ios/Shared/Views/Badges/BadgesCheckOrderView.swift b/apps/ios/Shared/Views/Badges/BadgesCheckOrderView.swift index 2c0dd851ed..119c8739c6 100644 --- a/apps/ios/Shared/Views/Badges/BadgesCheckOrderView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesCheckOrderView.swift @@ -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 { diff --git a/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift b/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift index bc5eca963e..91bdeaa672 100644 --- a/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift @@ -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) diff --git a/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift b/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift index 60c6a0ab77..1fcef82c25 100644 --- a/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift @@ -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) } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 5c5021b79f..dd856371d0 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -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 {