Files
bookstax/bookstax/Services/DonationService.swift
T
2026-04-20 09:41:18 +02:00

230 lines
8.0 KiB
Swift

import Foundation
import StoreKit
import Observation
// MARK: - State Types
enum DonationLoadState {
case loading
case loaded([Product])
case empty
case failed(String)
}
enum DonationPurchaseState: Equatable {
case idle
case purchasing(productID: String)
case thankYou(productID: String)
case pending(productID: String)
case failed(productID: String, message: String)
var activePurchasingID: String? {
if case .purchasing(let id) = self { return id }
return nil
}
var thankYouID: String? {
if case .thankYou(let id) = self { return id }
return nil
}
var pendingID: String? {
if case .pending(let id) = self { return id }
return nil
}
func errorMessage(for productID: String) -> String? {
if case .failed(let id, let msg) = self, id == productID { return msg }
return nil
}
var isIdle: Bool {
if case .idle = self { return true }
return false
}
}
// MARK: - DonationService
/// Manages product loading, purchases, and donation history for the Support section.
/// Product IDs must exactly match App Store Connect configuration.
@Observable
@MainActor
final class DonationService {
static let shared = DonationService()
/// The exact product IDs as configured in App Store Connect.
static let productIDs: Set<String> = [
"doneatepage",
"donatebook",
"donateencyclopaedia"
]
private(set) var loadState: DonationLoadState = .loading
private(set) var purchaseState: DonationPurchaseState = .idle
/// Maps productID date of the most recent completed donation.
private(set) var donationHistory: [String: Date] = [:]
private var transactionListenerTask: Task<Void, Never>?
private static let historyDefaultsKey = "bookstax.donationHistory"
private static let nudgeDateKey = "bookstax.lastNudgeDate"
private static let installDateKey = "bookstax.installDate"
/// ~6 months in seconds
private static let nudgeIntervalSeconds: TimeInterval = 182 * 24 * 3600
/// 3-day grace period after install before first nudge
private static let gracePeriodSeconds: TimeInterval = 3 * 24 * 3600
private init() {
donationHistory = Self.loadPersistedHistory()
transactionListenerTask = startTransactionListener()
// Record install date on first launch
if UserDefaults.standard.object(forKey: Self.installDateKey) == nil {
UserDefaults.standard.set(Date(), forKey: Self.installDateKey)
}
}
// MARK: - Nudge & Supporter State
/// True if the user has completed at least one donation.
var hasEverDonated: Bool {
!donationHistory.isEmpty
}
/// True if the nudge sheet should be presented.
/// Returns false immediately once any donation has been made.
var shouldShowNudge: Bool {
guard !hasEverDonated else { return false }
if let last = UserDefaults.standard.object(forKey: Self.nudgeDateKey) as? Date {
return Date().timeIntervalSince(last) >= Self.nudgeIntervalSeconds
}
// Never shown before only show after 3-day grace period
guard let installDate = UserDefaults.standard.object(forKey: Self.installDateKey) as? Date else {
return false
}
return Date().timeIntervalSince(installDate) >= Self.gracePeriodSeconds
}
/// Call when the nudge sheet is dismissed so we know when to show it next.
func recordNudgeSeen() {
UserDefaults.standard.set(Date(), forKey: Self.nudgeDateKey)
}
// MARK: - Product Loading
func loadProducts() async {
loadState = .loading
do {
let fetched = try await Product.products(for: Self.productIDs)
loadState = fetched.isEmpty
? .empty
: .loaded(fetched.sorted { $0.price < $1.price })
} catch {
AppLog(.error, "Product load failed: \(error)", category: "IAP")
loadState = .failed(error.localizedDescription)
}
}
// MARK: - Purchase
func purchase(_ product: Product) async {
guard case .idle = purchaseState else { return }
purchaseState = .purchasing(productID: product.id)
do {
let result = try await product.purchase()
await handlePurchaseResult(result, productID: product.id)
} catch {
AppLog(.error, "Purchase failed for \(product.id): \(error)", category: "IAP")
purchaseState = .failed(productID: product.id, message: error.localizedDescription)
}
}
func dismissError(for productID: String) {
if case .failed(let id, _) = purchaseState, id == productID {
purchaseState = .idle
}
}
// MARK: - Private Purchase Handling
private func handlePurchaseResult(_ result: Product.PurchaseResult, productID: String) async {
switch result {
case .success(let verification):
switch verification {
case .verified(let transaction):
await completeTransaction(transaction)
await showThankYou(for: productID)
case .unverified(_, let error):
AppLog(.error, "Unverified transaction for \(productID): \(error)", category: "IAP")
purchaseState = .failed(productID: productID, message: error.localizedDescription)
}
case .userCancelled:
purchaseState = .idle
case .pending:
// Deferred purchase (e.g. Ask to Buy). Resolved via transaction listener.
purchaseState = .pending(productID: productID)
@unknown default:
purchaseState = .idle
}
}
private func completeTransaction(_ transaction: Transaction) async {
recordDonation(productID: transaction.productID)
await transaction.finish()
AppLog(.info, "Transaction \(transaction.id) finished for \(transaction.productID)", category: "IAP")
}
private func showThankYou(for productID: String) async {
purchaseState = .thankYou(productID: productID)
try? await Task.sleep(for: .seconds(3))
if case .thankYou(let id) = purchaseState, id == productID {
purchaseState = .idle
}
}
// MARK: - Transaction Listener
/// Listens for transaction updates from StoreKit (e.g. Ask to Buy approvals,
/// transactions completed on other devices, interrupted purchases).
private func startTransactionListener() -> Task<Void, Never> {
Task.detached(priority: .background) { [weak self] in
for await result in Transaction.updates {
await self?.handleTransactionUpdate(result)
}
}
}
private func handleTransactionUpdate(_ result: VerificationResult<Transaction>) async {
switch result {
case .verified(let transaction):
await completeTransaction(transaction)
// Resolve a pending state caused by Ask to Buy or interrupted purchase.
if case .pending(let id) = purchaseState, id == transaction.productID {
await showThankYou(for: transaction.productID)
}
case .unverified(let transaction, let error):
AppLog(.warning, "Unverified transaction update for \(transaction.productID): \(error)", category: "IAP")
}
}
// MARK: - Donation History Persistence
private func recordDonation(productID: String) {
donationHistory[productID] = Date()
Self.persistHistory(donationHistory)
}
private static func loadPersistedHistory() -> [String: Date] {
guard
let data = UserDefaults.standard.data(forKey: historyDefaultsKey),
let decoded = try? JSONDecoder().decode([String: Date].self, from: data)
else { return [:] }
return decoded
}
private static func persistHistory(_ history: [String: Date]) {
guard let data = try? JSONEncoder().encode(history) else { return }
UserDefaults.standard.set(data, forKey: historyDefaultsKey)
}
}