Fix IAP
This commit is contained in:
@@ -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<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 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<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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user