Vernissage/Vernissage/EnvironmentObjects/TipsStore.swift

147 lines
4.5 KiB
Swift

//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the Apache License 2.0.
//
import Foundation
import StoreKit
import ServicesKit
import OSLog
import EnvironmentKit
@Observable final class TipsStore {
/// Products are registered in AppStore connect (and for development in InAppPurchaseStoreKitConfiguration.storekit file).
private(set) var items = [Product]()
/// Status of the purchase.
private(set) var status: ActionStatus? {
didSet {
switch status {
case .failed:
self.hasError = true
default:
self.hasError = false
}
}
}
/// True when error during purchase occures.
var hasError = false
/// Error during purchase.
var error: PurchaseError? {
switch status {
case .failed(let error):
return error
default:
return nil
}
}
/// Listener responsible for waiting for new events from AppStore (when transaction didn't finish during the purchase).
private var transactionListener: Task<Void, Error>?
init() {
transactionListener = configureTransactionListener()
Task { [weak self] in
await self?.retrieve()
}
}
deinit {
transactionListener?.cancel()
}
/// Purchase new product.
public func purchase(_ product: Product) async {
do {
let result = try await product.purchase()
try await self.handlePurchase(from: result)
} catch {
self.status = .failed(.system(error))
ErrorService.shared.handle(error, message: "global.error.purchaseFailed", showToastr: false)
}
}
/// Reset status of the purchase/action.
public func reset() {
self.status = nil
}
/// Handle purchase result.
private func handlePurchase(from result: Product.PurchaseResult) async throws {
switch result {
case .success(let verificationResult):
let transaction = try self.checkVerified(verificationResult)
self.status = .successful
await transaction.finish()
case .userCancelled:
Logger.main.warning("User click cancel before their transaction started.")
case .pending:
Logger.main.warning("User needs to complete some action on their account before their complete the purchase.")
default:
break
}
}
/// We have to verify if transaction ends successfuly.
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified:
throw PurchaseError.failedVerification
case .verified(let signedType):
return signedType
}
}
/// Configure listener of interrupted transactions.
private func configureTransactionListener() -> Task<Void, Error> {
Task.detached(priority: .background) { @MainActor [weak self] in
do {
for await result in Transaction.updates {
let transaction = try self?.checkVerified(result)
self?.status = .successful
await transaction?.finish()
}
} catch {
self?.status = .failed(.system(error))
ErrorService.shared.handle(error, message: "global.error.cannotConfigureTransactionListener", showToastr: false)
}
}
}
/// Retrieve products from Apple store.
private func retrieve() async {
do {
let products = try await Product.products(for: ProductIdentifiers.allCases.map({ $0.rawValue }))
.sorted(by: { $0.price < $1.price })
self.items = products
} catch {
self.status = .failed(.system(error))
ErrorService.shared.handle(error, message: "global.error.cannotDownloadInAppProducts", showToastr: false)
}
}
}
extension TipsStore {
public enum ActionStatus: Equatable {
case successful
case failed(PurchaseError)
public static func == (lhs: TipsStore.ActionStatus, rhs: TipsStore.ActionStatus) -> Bool {
switch (lhs, rhs) {
case (.successful, .successful):
return true
case (let .failed(lhsError), let .failed(rhsError)):
return lhsError.localizedDescription == rhsError.localizedDescription
default:
return false
}
}
}
}