mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-11 20:27:39 +00:00
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:
1 parent
be89654a62
commit
69920be75e
6 files changed
+198
-45
No files matched your search
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user