230 lines
8.0 KiB
Swift
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)
|
|
}
|
|
}
|