From 0499255be79c537386ffeffacf848433409d22f1 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Mon, 20 May 2024 12:32:38 -0400 Subject: [PATCH] Add tip jar subscription --- .../Preferences/Tip Jar/TipJarView.swift | 265 ++++++++++++++---- Tusker/Tusker.storekit | 85 +++++- 2 files changed, 298 insertions(+), 52 deletions(-) diff --git a/Tusker/Screens/Preferences/Tip Jar/TipJarView.swift b/Tusker/Screens/Preferences/Tip Jar/TipJarView.swift index f7a0180d..1b9e300d 100644 --- a/Tusker/Screens/Preferences/Tip Jar/TipJarView.swift +++ b/Tusker/Screens/Preferences/Tip Jar/TipJarView.swift @@ -11,18 +11,24 @@ import StoreKit import Combine struct TipJarView: View { - private static let productIDs = [ + private static let tipProductIDs = [ "tusker.tip.small", "tusker.tip.medium", "tusker.tip.large", ] + private static let supporterProductIDs = [ + "tusker.supporter.regular", + ] @State private var isLoaded = false - @State private var products: [(Product, Bool)] = [] + @State private var tipProducts: [(Product, Bool)] = [] + @State private var supporterProducts: [(Product, Bool)] = [] @State private var error: Error? @State private var showConfetti = false @State private var updatesObserver: Task? - @State private var buttonWidth: CGFloat? + @State private var tipButtonWidth: CGFloat? + @State private var supporterButtonWidth: CGFloat? + @State private var supporterStartDate: Date? @StateObject private var observer = UbiquitousKeyValueStoreObserver() var body: some View { @@ -47,9 +53,20 @@ struct TipJarView: View { updatesObserver = Task.detached { @MainActor in await observeTransactionUpdates() } + for await verificationResult in Transaction.currentEntitlements { + if case .verified(let transaction) = verificationResult, + Self.supporterProductIDs.contains(transaction.productID), + await transaction.subscriptionStatus?.state == .subscribed { + supporterStartDate = transaction.originalPurchaseDate + break + } + } do { - products = try await Product.products(for: Self.productIDs).map { ($0, false) } - products.sort(by: { $0.0.price < $1.0.price }) + let allProducts = try await Product.products(for: Self.tipProductIDs + Self.supporterProductIDs).map { ($0, false) } + tipProducts = allProducts.filter { Self.tipProductIDs.contains($0.0.id) } + tipProducts.sort(by: { $0.0.price < $1.0.price }) + supporterProducts = allProducts.filter { Self.supporterProductIDs.contains($0.0.id) } + supporterProducts.sort(by: { $0.0.price < $1.0.price }) isLoaded = true } catch { self.error = .fetchingProducts(error) @@ -67,25 +84,15 @@ struct TipJarView: View { private var productsView: some View { if isLoaded { VStack { - Text("If you're enjoying using Tusker and want to show your gratitude or help support its development, it is greatly appreciated!") - .multilineTextAlignment(.center) - .padding(.horizontal) + supporterSubscriptions - VStack(alignment: .myAlignment) { - ForEach($products, id: \.0.id) { $productAndPurchasing in - TipRow(product: productAndPurchasing.0, buttonWidth: buttonWidth, isPurchasing: $productAndPurchasing.1, showConfetti: $showConfetti) - } - } - .onPreferenceChange(ButtonWidthKey.self) { newValue in - if let buttonWidth { - self.buttonWidth = max(buttonWidth, newValue) - } else { - self.buttonWidth = newValue - } - } + tipPurchases - if let total = getTotalTips(), total > 0 { - Text("You've tipped a total of \(Text(total, format: products[0].0.priceFormatStyle)) 😍") + if let tipStatus { + tipStatus + .multilineTextAlignment(.center) + .padding(.horizontal) + .padding(.top, 16) Text("Thank you!") } } @@ -95,19 +102,92 @@ struct TipJarView: View { } } + @ViewBuilder + private var supporterSubscriptions: some View { + Text("If you want to contribute Tusker's continued development, you can become a supporter. Supporting Tusker is an auto-renewable monthly subscription.") + .multilineTextAlignment(.center) + .padding(.horizontal) + + VStack(alignment: .myAlignment) { + ForEach($supporterProducts, id: \.0.id) { $productAndPurchasing in + TipRow(product: productAndPurchasing.0, buttonWidth: supporterButtonWidth, isPurchasing: $productAndPurchasing.1, showConfetti: $showConfetti) + } + } + .onPreferenceChange(ButtonWidthKey.self) { newValue in + if let supporterButtonWidth { + self.supporterButtonWidth = max(supporterButtonWidth, newValue) + } else { + self.supporterButtonWidth = newValue + } + } + } + + @ViewBuilder + private var tipPurchases: some View { + Text("Or, you can choose to make a one-time tip to show your gratitutde or help support the app's development. It is greatly appreciated!") + .multilineTextAlignment(.center) + .padding(.horizontal) + .padding(.top, 16) + + VStack(alignment: .myAlignment) { + ForEach($tipProducts, id: \.0.id) { $productAndPurchasing in + TipRow(product: productAndPurchasing.0, buttonWidth: tipButtonWidth, isPurchasing: $productAndPurchasing.1, showConfetti: $showConfetti) + } + } + .onPreferenceChange(ButtonWidthKey.self) { newValue in + if let tipButtonWidth { + self.tipButtonWidth = max(tipButtonWidth, newValue) + } else { + self.tipButtonWidth = newValue + } + } + } + + private var tipStatus: Text? { + var text: Text? + if let supporterStartDate { + var months = Calendar.current.dateComponents([.month], from: supporterStartDate, to: Date()).month! + // the user has already paid for n months before the nth month has finished, so reflect that + months += 1 + text = Text("You've been a supporter for ^[\(months) months](inflect: true)") + } + if let total = getTotalTips(), + total > 0 { + if let t = text { + text = Text("\(t) and tipped \(total.formatted(tipProducts[0].0.priceFormatStyle))") + } else { + text = Text("You've tipped \(total.formatted(tipProducts[0].0.priceFormatStyle))") + } + } + if let text { + return Text("\(text) 😍") + } else { + return nil + } + } + @MainActor private func observeTransactionUpdates() async { for await verificationResult in StoreKit.Transaction.updates { - guard let index = products.firstIndex(where: { $0.0.id == verificationResult.unsafePayloadValue.productID }) else { - continue - } - switch verificationResult { - case .verified(let transaction): - await transaction.finish() - self.products[index].1 = false - self.showConfetti = true - case .unverified(_, let error): - self.error = .verifyingTransaction(error) + if let index = tipProducts.firstIndex(where: { $0.0.id == verificationResult.unsafePayloadValue.productID }) { + switch verificationResult { + case .verified(let transaction): + await transaction.finish() + self.tipProducts[index].1 = false + self.showConfetti = true + case .unverified(_, let error): + self.error = .verifyingTransaction(error) + } + } else if let index = supporterProducts.firstIndex(where: { $0.0.id == verificationResult.unsafePayloadValue.productID }) { + switch verificationResult { + case .verified(let transaction): + await transaction.finish() + self.supporterProducts[index].1 = false + self.showConfetti = true + self.supporterStartDate = transaction.originalPurchaseDate + case .unverified(_, let error): + self.error = .verifyingTransaction(error) + } } } } @@ -149,25 +229,11 @@ private struct TipRow: View { Text(product.displayName) .alignmentGuide(.myAlignment, computeValue: { context in context[.trailing] }) - Button { - Task { - await self.purchase() - } - } label: { - if isPurchasing { - ProgressView() - .progressViewStyle(.circular) - .frame(width: buttonWidth, alignment: .center) - } else { - Text(product.displayPrice) - .background(GeometryReader { proxy in - Color.clear - .preference(key: ButtonWidthKey.self, value: proxy.size.width) - }) - .frame(width: buttonWidth) - } + if let subscription = product.subscription { + SubscriptionButton(product: product, subscriptionInfo: subscription, isPurchasing: $isPurchasing, buttonWidth: buttonWidth, purchase: purchase) + } else { + tipButton } - .buttonStyle(.borderedProminent) } .alertWithData("Error", data: $error) { _ in Button("OK") {} @@ -176,6 +242,28 @@ private struct TipRow: View { } } + private var tipButton: some View { + Button { + Task { + await self.purchase() + } + } label: { + if isPurchasing { + ProgressView() + .progressViewStyle(.circular) + .frame(width: buttonWidth, alignment: .center) + } else { + Text(product.displayPrice) + .background(GeometryReader { proxy in + Color.clear + .preference(key: ButtonWidthKey.self, value: proxy.size.width) + }) + .frame(width: buttonWidth) + } + } + .buttonStyle(.borderedProminent) + } + @MainActor private func purchase() async { isPurchasing = true @@ -221,6 +309,71 @@ private struct TipRow: View { } } +private struct SubscriptionButton: View { + let product: Product + let subscriptionInfo: Product.SubscriptionInfo + @Binding var isPurchasing: Bool + let buttonWidth: CGFloat? + let purchase: () async -> Void + @State private var hasPurchased = false + @State private var showManageSheet = false + + var body: some View { + Button { + if #available(iOS 17.0, *), hasPurchased { + showManageSheet = true + } else { + Task { + await purchase() + await updateHasPurchased() + } + } + } label: { + if #available(iOS 17.0, *), hasPurchased { + Text("Manage") + } else if isPurchasing { + ProgressView() + .progressViewStyle(.circular) + .frame(width: buttonWidth, alignment: .center) + } else { + let per: String = if subscriptionInfo.subscriptionPeriod.value == 1, subscriptionInfo.subscriptionPeriod.unit == .month { + "mo" + } else { + subscriptionInfo.subscriptionPeriod.formatted(product.subscriptionPeriodFormatStyle) + } + Text("\(product.displayPrice)/\(per)") + .background(GeometryReader { proxy in + Color.clear + .preference(key: ButtonWidthKey.self, value: proxy.size.width) + }) + .frame(width: buttonWidth) + } + } + .buttonStyle(.borderedProminent) + .task { + await updateHasPurchased() + } + .onChange(of: showManageSheet) { + if !$0 { + Task { + await updateHasPurchased() + } + } + } + .manageSubscriptionsSheetIfAvailable(isPresented: $showManageSheet, subscriptionGroupID: subscriptionInfo.subscriptionGroupID) + } + + private func updateHasPurchased() async { + switch await Transaction.currentEntitlement(for: product.id) { + case .verified(let transaction): + let state = await transaction.subscriptionStatus?.state + hasPurchased = state == .subscribed + default: + break + } + } +} + extension HorizontalAlignment { private enum MyTrailing: AlignmentID { static func defaultValue(in context: ViewDimensions) -> CGFloat { @@ -263,3 +416,15 @@ private class UbiquitousKeyValueStoreObserver: ObservableObject { } } } + +private extension View { + @available(iOS, obsoleted: 17.0) + @ViewBuilder + func manageSubscriptionsSheetIfAvailable(isPresented: Binding, subscriptionGroupID: String) -> some View { + if #available(iOS 17.0, *) { + self.manageSubscriptionsSheet(isPresented: isPresented, subscriptionGroupID: subscriptionGroupID) + } else { + self + } + } +} diff --git a/Tusker/Tusker.storekit b/Tusker/Tusker.storekit index 6e4dd5e9..52a0639d 100644 --- a/Tusker/Tusker.storekit +++ b/Tusker/Tusker.storekit @@ -53,13 +53,94 @@ "settings" : { "_applicationInternalID" : "1498334597", "_developerTeamID" : "V4WK9KR9U2", - "_lastSynchronizedDate" : 696310076.23998904 + "_failTransactionsEnabled" : false, + "_lastSynchronizedDate" : 737914663.21114194, + "_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" : "21490109", + "localizations" : [ + ], + "name" : "Tip Jar", + "subscriptions" : [ + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "1.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "6502909920", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "Support the continued development of Tusker!", + "displayName" : "Tusker Supporter", + "locale" : "en_US" + } + ], + "productID" : "tusker.supporter.regular", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "tusker.supporter.regular", + "subscriptionGroupID" : "21490109", + "type" : "RecurringSubscription" + } + ] + } ], "version" : { - "major" : 2, + "major" : 3, "minor" : 0 } }