196 lines
7.2 KiB
Swift
196 lines
7.2 KiB
Swift
import SwiftUI
|
|
|
|
/// Paywall sheet — shown from `SettingsView`'s "Auf Pro upgraden" button and
|
|
/// from `ProjectsListView` when creating a folder fails with `pro_required`.
|
|
///
|
|
/// Products and prices are loaded from `PurchaseService.loadProducts()` —
|
|
/// real, StoreKit-localized products via `StoreKitPurchaseService` by
|
|
/// default. Nothing here is hardcoded copy; if products fail to load, a
|
|
/// retry affordance is shown instead of guessing at prices.
|
|
struct PaywallView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@EnvironmentObject private var appState: AppState
|
|
@State private var products: [PurchaseProduct] = []
|
|
@State private var isLoadingProducts = false
|
|
@State private var loadErrorMessage: String?
|
|
@State private var purchasingProductID: String?
|
|
@State private var errorMessage: String?
|
|
@State private var purchaseCompletedMessage: String?
|
|
@State private var isRestoring = false
|
|
private let purchaseService: PurchaseService
|
|
|
|
init(purchaseService: PurchaseService = StoreKitPurchaseService()) {
|
|
self.purchaseService = purchaseService
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollView {
|
|
VStack(spacing: ZenithSpacing.md) {
|
|
Text("Unbegrenzt scannen, Ordner anlegen und exportieren.")
|
|
.zenithBodyStyle(color: .zenithMuted)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal)
|
|
|
|
content
|
|
|
|
if !products.isEmpty {
|
|
Button {
|
|
Task { await restore() }
|
|
} label: {
|
|
if isRestoring {
|
|
ProgressView()
|
|
} else {
|
|
Text("Käufe wiederherstellen")
|
|
}
|
|
}
|
|
.buttonStyle(.zenithPlain)
|
|
.disabled(isRestoring || purchasingProductID != nil)
|
|
}
|
|
}
|
|
.padding(ZenithSpacing.sm)
|
|
}
|
|
.background(Color.zenithBg)
|
|
.navigationTitle("ScanReceipts Pro")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Schließen") { dismiss() }
|
|
}
|
|
}
|
|
.task {
|
|
await loadProducts()
|
|
}
|
|
.alert("Kauf nicht möglich", isPresented: errorAlertBinding) {
|
|
Button("OK", role: .cancel) {}
|
|
} message: {
|
|
Text(errorMessage ?? "")
|
|
}
|
|
.alert("Kauf abgeschlossen", isPresented: purchaseCompletedAlertBinding) {
|
|
Button("OK", role: .cancel) { dismiss() }
|
|
} message: {
|
|
Text(purchaseCompletedMessage ?? "")
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var content: some View {
|
|
if isLoadingProducts && products.isEmpty {
|
|
ProgressView()
|
|
.padding(.vertical, ZenithSpacing.lg)
|
|
} else if let loadErrorMessage, products.isEmpty {
|
|
VStack(spacing: ZenithSpacing.xs) {
|
|
Text(loadErrorMessage)
|
|
.zenithBodySmStyle()
|
|
.multilineTextAlignment(.center)
|
|
Button("Erneut versuchen") {
|
|
Task { await loadProducts() }
|
|
}
|
|
.buttonStyle(.zenithPlain)
|
|
}
|
|
.padding(.vertical, ZenithSpacing.lg)
|
|
} else {
|
|
ForEach(products) { product in
|
|
productCard(product)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var errorAlertBinding: Binding<Bool> {
|
|
Binding(
|
|
get: { errorMessage != nil },
|
|
set: { if !$0 { errorMessage = nil } }
|
|
)
|
|
}
|
|
|
|
private var purchaseCompletedAlertBinding: Binding<Bool> {
|
|
Binding(
|
|
get: { purchaseCompletedMessage != nil },
|
|
set: { if !$0 { purchaseCompletedMessage = nil } }
|
|
)
|
|
}
|
|
|
|
private func productCard(_ product: PurchaseProduct) -> some View {
|
|
VStack(alignment: .leading, spacing: ZenithSpacing.xs) {
|
|
Text(product.displayName)
|
|
.zenithHeadlineMdStyle()
|
|
Text(priceLabel(for: product))
|
|
.zenithLabelMdStyle()
|
|
|
|
Button {
|
|
Task { await purchase(product) }
|
|
} label: {
|
|
if purchasingProductID == product.id {
|
|
ProgressView()
|
|
.tint(.white)
|
|
} else {
|
|
Text("Kaufen")
|
|
}
|
|
}
|
|
.buttonStyle(.zenithPrimary)
|
|
.disabled(purchasingProductID != nil)
|
|
.opacity(purchasingProductID != nil && purchasingProductID != product.id ? 0.5 : 1)
|
|
.padding(.top, ZenithSpacing.xs)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.zenithCard()
|
|
}
|
|
|
|
private func priceLabel(for product: PurchaseProduct) -> String {
|
|
if let period = product.periodDescription {
|
|
return "\(product.displayPrice) · \(period)"
|
|
}
|
|
return product.displayPrice
|
|
}
|
|
|
|
private func loadProducts() async {
|
|
isLoadingProducts = true
|
|
loadErrorMessage = nil
|
|
defer { isLoadingProducts = false }
|
|
do {
|
|
products = try await purchaseService.loadProducts()
|
|
} catch {
|
|
loadErrorMessage = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
/// On success (`true`), shows a neutral completion message and dismisses
|
|
/// once the user acknowledges it. `POST /api/webhooks/apple` now exists
|
|
/// server-side and (given a resolvable `appAccountToken`, see
|
|
/// `User.appleAccountToken`) does grant Pro from this purchase — but
|
|
/// that whole path is genuinely unverified end-to-end (no real Apple
|
|
/// Developer account/device available to generate a live transaction
|
|
/// against it), and delivery of Apple's server notification isn't
|
|
/// synchronous with the purchase sheet closing. So: still no "You're Pro
|
|
/// now!" claim here — that would assert something this code hasn't
|
|
/// actually observed happening. `false` means the user cancelled the
|
|
/// StoreKit sheet, not an error, no message needed. A thrown error is
|
|
/// surfaced in the existing error alert.
|
|
private func purchase(_ product: PurchaseProduct) async {
|
|
purchasingProductID = product.id
|
|
defer { purchasingProductID = nil }
|
|
do {
|
|
let completed = try await purchaseService.purchase(
|
|
productID: product.id,
|
|
appAccountToken: appState.currentUser?.appleAccountToken
|
|
)
|
|
if completed {
|
|
purchaseCompletedMessage = "Kauf abgeschlossen."
|
|
}
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
private func restore() async {
|
|
isRestoring = true
|
|
defer { isRestoring = false }
|
|
do {
|
|
try await purchaseService.restorePurchases()
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
}
|
|
}
|