This commit is contained in:
2026-04-12 12:19:53 +02:00
parent 7f312ece18
commit fb33681f0f
6 changed files with 377 additions and 84 deletions
+193
View File
@@ -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 -84
View File
@@ -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 {
+5
View File
@@ -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";
+5
View File
@@ -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";
+5
View File
@@ -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";