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 SwiftUI
|
||||||
import SafariServices
|
import SafariServices
|
||||||
import StoreKit
|
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@AppStorage("onboardingComplete") private var onboardingComplete = false
|
@AppStorage("onboardingComplete") private var onboardingComplete = false
|
||||||
@@ -182,7 +181,7 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Donate section
|
// Donate section
|
||||||
DonationSection()
|
DonationSectionView()
|
||||||
|
|
||||||
// About section
|
// About section
|
||||||
Section(L("settings.about")) {
|
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
|
// MARK: - Safari View
|
||||||
|
|
||||||
struct SafariView: UIViewControllerRepresentable {
|
struct SafariView: UIViewControllerRepresentable {
|
||||||
|
|||||||
@@ -268,6 +268,11 @@
|
|||||||
"settings.donate.book" = "Buch";
|
"settings.donate.book" = "Buch";
|
||||||
"settings.donate.encyclopedia" = "Enzyklopädie";
|
"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.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
|
// MARK: - Common
|
||||||
"common.ok" = "OK";
|
"common.ok" = "OK";
|
||||||
|
|||||||
@@ -268,6 +268,11 @@
|
|||||||
"settings.donate.book" = "Book";
|
"settings.donate.book" = "Book";
|
||||||
"settings.donate.encyclopedia" = "Encyclopedia";
|
"settings.donate.encyclopedia" = "Encyclopedia";
|
||||||
"settings.donate.footer" = "Enjoying BookStax? Your support helps keep the app free and in active development. Thank you!";
|
"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
|
// MARK: - Common
|
||||||
"common.ok" = "OK";
|
"common.ok" = "OK";
|
||||||
|
|||||||
@@ -268,6 +268,11 @@
|
|||||||
"settings.donate.book" = "Libro";
|
"settings.donate.book" = "Libro";
|
||||||
"settings.donate.encyclopedia" = "Enciclopedia";
|
"settings.donate.encyclopedia" = "Enciclopedia";
|
||||||
"settings.donate.footer" = "¿Disfrutas BookStax? Tu apoyo ayuda a mantener la app gratuita y en desarrollo activo. ¡Gracias!";
|
"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
|
// MARK: - Common
|
||||||
"common.ok" = "Aceptar";
|
"common.ok" = "Aceptar";
|
||||||
|
|||||||
Reference in New Issue
Block a user