diff --git a/bookstax/Services/DonationService.swift b/bookstax/Services/DonationService.swift new file mode 100644 index 0000000..7312c4f --- /dev/null +++ b/bookstax/Services/DonationService.swift @@ -0,0 +1,193 @@ +import Foundation +import StoreKit +import Observation + +// MARK: - State Types + +enum DonationLoadState { + case loading + case loaded([Product]) + case empty + case failed(String) +} + +enum DonationPurchaseState { + 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 = [ + "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? + private static let historyDefaultsKey = "bookstax.donationHistory" + + private init() { + donationHistory = Self.loadPersistedHistory() + transactionListenerTask = startTransactionListener() + } + + // 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 { + Task.detached(priority: .background) { [weak self] in + for await result in Transaction.updates { + await self?.handleTransactionUpdate(result) + } + } + } + + private func handleTransactionUpdate(_ result: VerificationResult) 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) + } +} diff --git a/bookstax/Views/Settings/DonationSectionView.swift b/bookstax/Views/Settings/DonationSectionView.swift new file mode 100644 index 0000000..cb61f94 --- /dev/null +++ b/bookstax/Views/Settings/DonationSectionView.swift @@ -0,0 +1,168 @@ +import SwiftUI +import StoreKit + +// MARK: - DonationSectionView + +/// The "Support BookStax" section embedded in SettingsView. +/// Shows real products fetched from App Store Connect. Never displays +/// placeholder content — all states (loading, empty, error) are explicit. +struct DonationSectionView: View { + @State private var service = DonationService.shared + + var body: some View { + Section { + sectionContent + } header: { + Text(L("settings.donate")) + } footer: { + sectionFooter + } + .task { await service.loadProducts() } + } + + // MARK: - Content + + @ViewBuilder + private var sectionContent: some View { + switch service.loadState { + case .loading: + loadingRow + + case .loaded(let products): + ForEach(products, id: \.id) { product in + DonationProductRow(product: product, service: service) + } + + case .empty: + Text(L("settings.donate.empty")) + .foregroundStyle(.secondary) + .font(.subheadline) + + case .failed: + HStack { + Label(L("settings.donate.error"), systemImage: "exclamationmark.triangle") + .foregroundStyle(.secondary) + .font(.subheadline) + Spacer() + Button(L("common.retry")) { + Task { await service.loadProducts() } + } + .buttonStyle(.borderless) + .foregroundStyle(.tint) + } + } + } + + private var loadingRow: some View { + HStack { + Text(L("settings.donate.loading")) + .foregroundStyle(.secondary) + Spacer() + ProgressView() + .controlSize(.small) + } + } + + @ViewBuilder + private var sectionFooter: some View { + if case .failed = service.loadState { + EmptyView() + } else { + Text(L("settings.donate.footer")) + } + } +} + +// MARK: - DonationProductRow + +private struct DonationProductRow: View { + let product: Product + let service: DonationService + + private var isPurchasing: Bool { + service.purchaseState.activePurchasingID == product.id + } + private var isAnyPurchaseInProgress: Bool { + service.purchaseState.activePurchasingID != nil + } + private var isThankYou: Bool { + service.purchaseState.thankYouID == product.id + } + private var isPending: Bool { + service.purchaseState.pendingID == product.id + } + private var errorMessage: String? { + service.purchaseState.errorMessage(for: product.id) + } + private var lastDonatedDate: Date? { + service.donationHistory[product.id] + } + private var isDisabled: Bool { + isAnyPurchaseInProgress || isPending + } + + var body: some View { + Button { + guard !isDisabled else { return } + if errorMessage != nil { + service.dismissError(for: product.id) + } else { + Task { await service.purchase(product) } + } + } label: { + HStack(spacing: 12) { + productInfo + Spacer() + trailingIndicator + } + } + .disabled(isDisabled) + } + + // MARK: - Subviews + + private var productInfo: some View { + VStack(alignment: .leading, spacing: 3) { + Text(product.displayName) + .foregroundStyle(.primary) + + if let date = lastDonatedDate { + Text(String(format: L("settings.donate.donated.on"), + date.formatted(date: .abbreviated, time: .omitted))) + .font(.caption) + .foregroundStyle(.pink) + } + + if isPending { + Text(L("settings.donate.pending")) + .font(.caption) + .foregroundStyle(.orange) + } + + if let message = errorMessage { + Text(message) + .font(.caption) + .foregroundStyle(.red) + } + } + } + + @ViewBuilder + private var trailingIndicator: some View { + if isPurchasing { + ProgressView() + .controlSize(.small) + } else if isThankYou { + Image(systemName: "heart.fill") + .foregroundStyle(.pink) + } else if errorMessage != nil { + Image(systemName: "arrow.counterclockwise") + .foregroundStyle(.secondary) + } else { + Text(product.displayPrice) + .foregroundStyle(.tint) + .bold() + .monospacedDigit() + } + } +} diff --git a/bookstax/Views/Settings/SettingsView.swift b/bookstax/Views/Settings/SettingsView.swift index 95ca0f8..0bbf040 100644 --- a/bookstax/Views/Settings/SettingsView.swift +++ b/bookstax/Views/Settings/SettingsView.swift @@ -1,6 +1,5 @@ import SwiftUI import SafariServices -import StoreKit struct SettingsView: View { @AppStorage("onboardingComplete") private var onboardingComplete = false @@ -182,7 +181,7 @@ struct SettingsView: View { } // Donate section - DonationSection() + DonationSectionView() // About section Section(L("settings.about")) { @@ -279,88 +278,6 @@ struct SettingsView: View { } } -// MARK: - Donation Section - -@MainActor -private struct DonationSection: View { - private struct Tier: Identifiable { - let id: String - let titleKey: String - let icon: String - let fallbackPrice: String - } - - private let tiers: [Tier] = [ - Tier(id: "doneatepage", titleKey: "settings.donate.page", icon: "doc.text.fill", fallbackPrice: "€0.99"), - Tier(id: "donatebook", titleKey: "settings.donate.book", icon: "book.closed.fill", fallbackPrice: "€4.99"), - Tier(id: "donateencyclopaedia", titleKey: "settings.donate.encyclopedia", icon: "books.vertical.fill", fallbackPrice: "€49.99"), - ] - - @State private var products: [String: Product] = [:] - @State private var purchasingID: String? = nil - @State private var thankYouID: String? = nil - - var body: some View { - Section { - ForEach(tiers) { tier in - Button { - guard purchasingID == nil else { return } - Task { await buy(tier.id) } - } label: { - HStack { - Label(L(tier.titleKey), systemImage: tier.icon) - .foregroundStyle(.primary) - Spacer() - Group { - if purchasingID == tier.id { - ProgressView().controlSize(.small) - } else if thankYouID == tier.id { - Image(systemName: "heart.fill").foregroundStyle(.pink) - } else { - Text(products[tier.id]?.displayPrice ?? tier.fallbackPrice) - .foregroundStyle(.tint) - .bold() - } - } - } - } - .disabled(purchasingID != nil && purchasingID != tier.id) - } - } header: { - Text(L("settings.donate")) - } footer: { - Text(L("settings.donate.footer")) - } - .task { await loadProducts() } - } - - private func loadProducts() async { - let ids = tiers.map(\.id) - if let loaded = try? await Product.products(for: ids), !loaded.isEmpty { - products = Dictionary(uniqueKeysWithValues: loaded.map { ($0.id, $0) }) - } - } - - private func buy(_ productID: String) async { - purchasingID = productID - defer { purchasingID = nil } - guard let product = products[productID] else { return } - do { - let result = try await product.purchase() - if case .success(let verification) = result { - if case .verified(let transaction) = verification { - await transaction.finish() - } - thankYouID = productID - try? await Task.sleep(for: .seconds(3)) - if thankYouID == productID { thankYouID = nil } - } - } catch { - print("[DonationSection] Purchase error: \(error)") - } - } -} - // MARK: - Safari View struct SafariView: UIViewControllerRepresentable { diff --git a/bookstax/de.lproj/Localizable.strings b/bookstax/de.lproj/Localizable.strings index 728d62a..825cf17 100644 --- a/bookstax/de.lproj/Localizable.strings +++ b/bookstax/de.lproj/Localizable.strings @@ -268,6 +268,11 @@ "settings.donate.book" = "Buch"; "settings.donate.encyclopedia" = "Enzyklopädie"; "settings.donate.footer" = "Gefällt dir BookStax? Deine Unterstützung hilft, die App kostenlos und in aktiver Entwicklung zu halten. Danke!"; +"settings.donate.loading" = "Lädt…"; +"settings.donate.error" = "Spendenoptionen konnten nicht geladen werden."; +"settings.donate.empty" = "Keine Spendenoptionen verfügbar."; +"settings.donate.donated.on" = "Gespendet am %@"; +"settings.donate.pending" = "Ausstehende Bestätigung…"; // MARK: - Common "common.ok" = "OK"; diff --git a/bookstax/en.lproj/Localizable.strings b/bookstax/en.lproj/Localizable.strings index a93e4c3..5d2942a 100644 --- a/bookstax/en.lproj/Localizable.strings +++ b/bookstax/en.lproj/Localizable.strings @@ -268,6 +268,11 @@ "settings.donate.book" = "Book"; "settings.donate.encyclopedia" = "Encyclopedia"; "settings.donate.footer" = "Enjoying BookStax? Your support helps keep the app free and in active development. Thank you!"; +"settings.donate.loading" = "Loading…"; +"settings.donate.error" = "Could not load donation options."; +"settings.donate.empty" = "No donation options available."; +"settings.donate.donated.on" = "Donated on %@"; +"settings.donate.pending" = "Pending confirmation…"; // MARK: - Common "common.ok" = "OK"; diff --git a/bookstax/es.lproj/Localizable.strings b/bookstax/es.lproj/Localizable.strings index cfb6d69..644a66e 100644 --- a/bookstax/es.lproj/Localizable.strings +++ b/bookstax/es.lproj/Localizable.strings @@ -268,6 +268,11 @@ "settings.donate.book" = "Libro"; "settings.donate.encyclopedia" = "Enciclopedia"; "settings.donate.footer" = "¿Disfrutas BookStax? Tu apoyo ayuda a mantener la app gratuita y en desarrollo activo. ¡Gracias!"; +"settings.donate.loading" = "Cargando…"; +"settings.donate.error" = "No se pudieron cargar las opciones de donación."; +"settings.donate.empty" = "No hay opciones de donación disponibles."; +"settings.donate.donated.on" = "Donado el %@"; +"settings.donate.pending" = "Confirmación pendiente…"; // MARK: - Common "common.ok" = "Aceptar";