parent
595205a9e1
commit
3e9ba52ec5
@ -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)) |
||||
} |
||||
@ -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…
Reference in new issue