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