feat: add iOS support and harden receipt scanning
This commit is contained in:
195
app/ios/ScanReceipts/Features/Settings/PaywallView.swift
Normal file
195
app/ios/ScanReceipts/Features/Settings/PaywallView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user