Adds subscription base

multistore
Laurent 2 years ago
parent 595205a9e1
commit 3e9ba52ec5
  1. 36
      PadelClub.xcodeproj/project.pbxproj
  2. 64
      PadelClub/Views/Components/StepperView.swift
  3. 10
      PadelClub/Views/ContentView.swift
  4. 137
      PadelClub/Views/Subscription/Guard.swift
  5. 30
      PadelClub/Views/Subscription/StoreItem.swift
  6. 129
      PadelClub/Views/Subscription/StoreManager.swift
  7. 142
      PadelClub/Views/Subscription/SubscriptionView.swift

@ -23,6 +23,11 @@
C4A47D7B2B73C0F900ADC637 /* TournamentV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D7A2B73C0F900ADC637 /* TournamentV2.swift */; };
C4A47D7D2B73CDC300ADC637 /* ClubV1.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D7C2B73CDC300ADC637 /* ClubV1.swift */; };
C4A47D872B7BA36D00ADC637 /* UserCreationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D862B7BA36D00ADC637 /* UserCreationView.swift */; };
C4A47D8A2B7BBB6500ADC637 /* SubscriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D892B7BBB6500ADC637 /* SubscriptionView.swift */; };
C4A47D902B7BBBEC00ADC637 /* StoreManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D8D2B7BBBEC00ADC637 /* StoreManager.swift */; };
C4A47D912B7BBBEC00ADC637 /* Guard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D8E2B7BBBEC00ADC637 /* Guard.swift */; };
C4A47D922B7BBBEC00ADC637 /* StoreItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D8F2B7BBBEC00ADC637 /* StoreItem.swift */; };
C4A47D9F2B7D0BCE00ADC637 /* StepperView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D9E2B7D0BCE00ADC637 /* StepperView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -76,6 +81,11 @@
C4A47D7A2B73C0F900ADC637 /* TournamentV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TournamentV2.swift; sourceTree = "<group>"; };
C4A47D7C2B73CDC300ADC637 /* ClubV1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClubV1.swift; sourceTree = "<group>"; };
C4A47D862B7BA36D00ADC637 /* UserCreationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCreationView.swift; sourceTree = "<group>"; };
C4A47D892B7BBB6500ADC637 /* SubscriptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionView.swift; sourceTree = "<group>"; };
C4A47D8D2B7BBBEC00ADC637 /* StoreManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StoreManager.swift; sourceTree = "<group>"; };
C4A47D8E2B7BBBEC00ADC637 /* Guard.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Guard.swift; sourceTree = "<group>"; };
C4A47D8F2B7BBBEC00ADC637 /* StoreItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StoreItem.swift; sourceTree = "<group>"; };
C4A47D9E2B7D0BCE00ADC637 /* StepperView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepperView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -193,6 +203,8 @@
C4A47D722B72881500ADC637 /* Views */ = {
isa = PBXGroup;
children = (
C4A47DA02B7D0BD800ADC637 /* Components */,
C4A47D882B7BBB5000ADC637 /* Subscription */,
C4A47D852B7BA33F00ADC637 /* User */,
C425D4022B6D249D002A7B48 /* ContentView.swift */,
C4A47D732B72881F00ADC637 /* ClubView.swift */,
@ -218,6 +230,25 @@
path = User;
sourceTree = "<group>";
};
C4A47D882B7BBB5000ADC637 /* Subscription */ = {
isa = PBXGroup;
children = (
C4A47D892B7BBB6500ADC637 /* SubscriptionView.swift */,
C4A47D8E2B7BBBEC00ADC637 /* Guard.swift */,
C4A47D8D2B7BBBEC00ADC637 /* StoreManager.swift */,
C4A47D8F2B7BBBEC00ADC637 /* StoreItem.swift */,
);
path = Subscription;
sourceTree = "<group>";
};
C4A47DA02B7D0BD800ADC637 /* Components */ = {
isa = PBXGroup;
children = (
C4A47D9E2B7D0BCE00ADC637 /* StepperView.swift */,
);
path = Components;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@ -372,7 +403,10 @@
buildActionMask = 2147483647;
files = (
C4A47D872B7BA36D00ADC637 /* UserCreationView.swift in Sources */,
C4A47D9F2B7D0BCE00ADC637 /* StepperView.swift in Sources */,
C4A47D8A2B7BBB6500ADC637 /* SubscriptionView.swift in Sources */,
C4A47D742B72881F00ADC637 /* ClubView.swift in Sources */,
C4A47D902B7BBBEC00ADC637 /* StoreManager.swift in Sources */,
C4A47D5A2B6D383C00ADC637 /* Tournament.swift in Sources */,
C4A47D7B2B73C0F900ADC637 /* TournamentV2.swift in Sources */,
C4A47D5E2B6D38EC00ADC637 /* DataStore.swift in Sources */,
@ -381,6 +415,8 @@
C425D4032B6D249D002A7B48 /* ContentView.swift in Sources */,
C425D4012B6D249D002A7B48 /* PadelClubApp.swift in Sources */,
C4A47D772B73789100ADC637 /* TournamentV1.swift in Sources */,
C4A47D922B7BBBEC00ADC637 /* StoreItem.swift in Sources */,
C4A47D912B7BBBEC00ADC637 /* Guard.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

@ -0,0 +1,64 @@
//
// StepperView.swift
// PadelClub
//
// Created by Laurent Morvillier on 14/02/2024.
//
import SwiftUI
struct StepperView: View {
@Binding var count: Int
var minimum: Int? = nil
var body: some View {
HStack {
Button(action: {
self._add()
}, label: {
Image(systemName: "plus.circle")
})
Button(action: {
self._subtract()
}, label: {
Image(systemName: "minus.circle")
})
}.padding(4.0)
}
fileprivate func _add() {
self.count += 1
}
fileprivate func _subtract() {
self.count -= 1
if let minimum, self.count < minimum {
self.count = minimum
}
}
}
struct StepperTestView: View {
@State var quantity: Int = 5
var body: some View {
Text(quantity.formatted())
StepperView(count: self.$quantity, minimum: 0)
}
}
#Preview {
StepperTestView()
}
#Preview {
StepperView(count: .constant(1))
}

@ -34,13 +34,19 @@ struct ContentView: View {
}
.toolbar(content: {
ToolbarItem {
NavigationLink {
UserCreationView()
} label: {
Image(systemName: "person.circle.fill")
}
}
ToolbarItem {
NavigationLink {
SubscriptionView()
} label: {
Image(systemName: "tennisball.circle.fill")
}
}
})
.navigationTitle("Home")

@ -0,0 +1,137 @@
//
// Guard.swift
// Poker Analytics 6
//
// Created by Laurent Morvillier on 20/04/2022.
//
import Foundation
import StoreKit
import LeStorage
@available(iOS 15, *)
@objc class Guard : NSObject {
static var main: Guard = Guard()
@Published private(set) var purchasedTransactions = Set<StoreKit.Transaction>()
var currentBestPlan: StoreKit.Transaction? = nil
var updateListenerTask: Task<Void, Error>? = nil
override init() {
super.init()
self.updateListenerTask = self.listenForTransactions()
Task {
do {
try await self.refreshPurchasedProducts()
} catch {
Logger.error(error)
}
}
}
func refreshPurchasedProducts() async throws {
// Iterate through the user's purchased products.
for await verificationResult in Transaction.currentEntitlements {
let transaction = try await self.processTransactionResult(verificationResult)
DispatchQueue.main.async {
NotificationCenter.default.post(name: Notification.Name.StoreEventHappened, object: nil)
}
await transaction.finish()
}
}
func listenForTransactions() -> Task<Void, Error> {
return Task.detached {
//Iterate through any transactions which didn't come from a direct call to `purchase()`.
for await result in Transaction.updates {
do {
let transaction = try self.checkVerified(result)
//Deliver content to the user.
await self.updatePurchasedIdentifiers(transaction)
//Always finish a transaction.
await transaction.finish()
} catch {
Logger.error(error)
//StoreKit has a receipt it can read but it failed verification. Don't deliver content to the user.
print("Transaction failed verification")
}
}
}
}
func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
//Check if the transaction passes StoreKit verification.
switch result {
case .unverified:
//StoreKit has parsed the JWS but failed verification. Don't deliver content to the user.
throw StoreError.failedVerification
case .verified(let safe):
//If the transaction is verified, unwrap and return it.
return safe
}
}
@MainActor
func updatePurchasedIdentifiers(_ transaction: StoreKit.Transaction) async {
if transaction.revocationDate == nil {
// If the App Store has not revoked the transaction, add it to the list of `purchasedIdentifiers`.
purchasedTransactions.insert(transaction)
} else {
// If the App Store has revoked this transaction, remove it from the list of `purchasedIdentifiers`.
purchasedTransactions.remove(transaction)
}
self._updateBestPlan()
}
func processTransactionResult(_ result: VerificationResult<StoreKit.Transaction>) async throws -> StoreKit.Transaction {
let transaction = try checkVerified(result)
// Deliver content to the user.
await updatePurchasedIdentifiers(transaction)
return transaction
}
var currentPlan: StoreItem {
#if DEBUG
return .monthly
#else
if let currentBestPlan = self.currentBestPlan, let plan = StorePlan(rawValue: currentBestPlan.productID) {
return plan
}
if let vf = Preferences.verifiedTransaction(),
vf.expiryDate > Date(), vf.graceDate > Date(),
let plan = StorePlan(rawValue: vf.productId) {
return plan
}
return .none
#endif
}
fileprivate func _updateBestPlan() {
if let yearly = self.purchasedTransactions.first(where: { $0.productID == StoreItem.yearly.rawValue }) {
self.currentBestPlan = yearly
} else if let monthly = self.purchasedTransactions.first(where: { $0.productID == StoreItem.monthly.rawValue }) {
self.currentBestPlan = monthly
} else if let tournamentUnit = self.purchasedTransactions.first(where: { $0.productID == StoreItem.tournament.rawValue }) {
self.currentBestPlan = tournamentUnit
} else {
self.currentBestPlan = nil
}
}
}

@ -0,0 +1,30 @@
//
// StorePlan.swift
// Poker Analytics 6
//
// Created by Laurent Morvillier on 22/04/2022.
//
import Foundation
enum StoreItem: String, Identifiable, CaseIterable {
case tournament = "app.padelclub.tournament.unit"
case monthly = "app.padelclub.tournament.monthly"
case yearly = "app.padelclub.tournament.yearly"
var id: String { return self.rawValue }
var title: String { return "Tournoi illimités" }
var formattedPrice: String { return "119.99€ / an" }
var price: Double { return 19.99 }
var isUnit: Bool {
switch self {
case .tournament: return true
default: return false
}
}
}

@ -0,0 +1,129 @@
//
// Store.swift
// Poker Analytics 6
//
// Created by Laurent Morvillier on 20/04/2022.
//
import Foundation
import StoreKit
import LeStorage
public enum StoreError: Error {
case failedVerification
}
protocol StoreDelegate {
func productsReceived(products: [Product])
func errorDidOccur(error: Error)
}
extension Notification.Name {
static let StoreEventHappened = Notification.Name("storePurchaseSucceeded")
}
@available(iOS 15, *)
class StoreManager {
// @Published private(set) var products: [Product] = []
@Published private(set) var purchasedTransactions = Set<StoreKit.Transaction>()
var delegate: StoreDelegate? = nil
var updateListenerTask: Task<Void, Error>? = nil
init(delegate: StoreDelegate?) {
self.delegate = delegate
self.updateListenerTask = listenForTransactions()
Task {
//Initialize the store by starting a product request.
await self.requestProducts()
}
}
deinit {
self.updateListenerTask?.cancel()
}
// func indexOf(identifier: String) -> Int? {
// return self.products.map { $0.id }.firstIndex(of: identifier)
// }
@MainActor
func requestProducts() async {
do {
let identifiers: [String] = StoreItem.allCases.map { $0.rawValue }
var products = try await Product.products(for: identifiers)
products = products.sorted { p1, p2 in
return p2.price > p1.price
}
Logger.log("products = \(products.count)")
self.delegate?.productsReceived(products: products)
} catch {
self.delegate?.errorDidOccur(error: error)
Logger.error(error)
}
}
func listenForTransactions() -> Task<Void, Error> {
return Task.detached {
//Iterate through any transactions which didn't come from a direct call to `purchase()`.
for await result in Transaction.updates {
do {
let transaction = try await Guard.main.processTransactionResult(result)
//Always finish a transaction.
await transaction.finish()
} catch {
self.delegate?.errorDidOccur(error: error)
//StoreKit has a receipt it can read but it failed verification. Don't deliver content to the user.
print("Transaction failed verification")
}
}
}
}
func purchase(_ product: Product, quantity: Int? = nil) async throws -> StoreKit.Transaction? {
Logger.log("Store purchase started...")
var options: Set<Product.PurchaseOption> = []
let uuid: UUID = try Store.main.currentUserUUID()
let tokenOption = Product.PurchaseOption.appAccountToken(uuid)
options.insert(tokenOption)
if let quantity = quantity {
let quantityOption = Product.PurchaseOption.quantity(quantity)
options.insert(quantityOption)
}
let result = try await product.purchase(options: options)
Logger.log("Store purchase ended with result: \(result)")
switch result {
case .success(let verificationResult):
let transaction = try await Guard.main.processTransactionResult(verificationResult)
// Always finish a transaction.
await transaction.finish()
DispatchQueue.main.asyncAfter(deadline: DispatchTime(uptimeNanoseconds: 100000), execute: {
NotificationCenter.default.post(name: Notification.Name.StoreEventHappened, object: nil)
})
return transaction
case .userCancelled, .pending:
return nil
default:
return nil
}
}
}

@ -0,0 +1,142 @@
//
// SubscriptionView.swift
// PadelClub
//
// Created by Laurent Morvillier on 13/02/2024.
//
import SwiftUI
import StoreKit
class SubscriptionModel: ObservableObject, StoreDelegate {
var storeManager: StoreManager? = nil
@Published var error: Error? = nil
@Published var isLoading: Bool = false
@Published var selectedProduct: Product? = nil
@Published var quantity: Int = 1
@Published var products: [Product] = []
func load() {
self.isLoading = true
self.storeManager = StoreManager(delegate: self)
}
func productsReceived(products: [Product]) {
self.isLoading = false
self.products = products
}
func errorDidOccur(error: Error) {
self.isLoading = false
self.error = error
}
func purchase() {
if let product = self.selectedProduct {
Task {
let quantity: Int? = (product.id == StoreItem.tournament.rawValue) ? self.quantity : nil
let _ = try await self.storeManager?.purchase(product, quantity: quantity)
}
}
}
}
struct SubscriptionView: View {
@ObservedObject var model: SubscriptionModel = SubscriptionModel()
@State var test = 5
var body: some View {
Form {
Section {
Text(test.formatted())
StepperView(count: self.$test)
}
List {
ForEach(self.model.products) { product in
if let item = StoreItem(rawValue: product.id) {
ProductView(product: product,
item: item,
quantity: self.$model.quantity)
.onTapGesture {
self.model.selectedProduct = product
}
} else {
Text("Missing item")
}
}
}
Section {
Button {
self._purchase()
} label: {
VStack {
Text("Purchase")
if let product = self.model.selectedProduct {
Text(product.displayName)
Text(product.displayPrice)
}
}
}
}
}
.navigationTitle("Subscriptions")
.onAppear {
self._load()
}
}
fileprivate func _purchase() {
self.model.purchase()
}
fileprivate func _load() {
}
}
struct ProductView: View {
var product: Product
var item: StoreItem
@Binding var quantity: Int
var body: some View {
HStack {
Image(systemName: "star.circle.fill")
.font(.title)
.foregroundColor(.blue)
VStack(alignment: .leading) {
if item.isUnit == true {
Text("\(self.quantity) \(item.title)")
let total = item.price * Double(self.quantity)
Text(total.formatted()).foregroundColor(.blue)
StepperView(count: self.$quantity, minimum: 1)
} else {
Text(item.title)
Text(item.formattedPrice).foregroundColor(.blue)
}
}
Spacer()
Image(systemName: "checkmark").foregroundColor(.blue)
}
}
}
#Preview {
NavigationStack {
SubscriptionView()
}
}
Loading…
Cancel
Save