feat: add iOS support and harden receipt scanning

This commit is contained in:
Timo
2026-08-20 23:48:26 +02:00
parent f5e06c32b0
commit cf1b799e1b
107 changed files with 11755 additions and 122 deletions

View File

@@ -0,0 +1,195 @@
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
}
}
}