feat: add iOS support and harden receipt scanning
This commit is contained in:
107
app/ios/ScanReceipts/Features/Settings/ChangePasswordView.swift
Normal file
107
app/ios/ScanReceipts/Features/Settings/ChangePasswordView.swift
Normal file
@@ -0,0 +1,107 @@
|
||||
import SwiftUI
|
||||
|
||||
/// `POST /api/auth/change-password` (see `Networking/AccountAPI.swift`)
|
||||
/// rotates EVERY session for the account on success, including this
|
||||
/// device's — and the new session is only delivered as a Set-Cookie, which
|
||||
/// this Bearer-token client never sees. There is no fresh token to recover
|
||||
/// from that response, so trying to "stay logged in" here would leave the
|
||||
/// app holding a dead token. Instead: show a brief confirmation, then log
|
||||
/// out locally and let the user sign back in with the new password. This is
|
||||
/// a deliberate consequence of how the backend's session rotation works,
|
||||
/// not an arbitrary UX choice — don't "fix" this into a silent success.
|
||||
struct ChangePasswordView: View {
|
||||
@EnvironmentObject private var appState: AppState
|
||||
@State private var currentPassword = ""
|
||||
@State private var newPassword = ""
|
||||
@State private var confirmPassword = ""
|
||||
@State private var isSubmitting = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var showSuccessAlert = false
|
||||
|
||||
private var passwordsMatch: Bool {
|
||||
!newPassword.isEmpty && newPassword == confirmPassword
|
||||
}
|
||||
|
||||
private var canSubmit: Bool {
|
||||
!currentPassword.isEmpty && passwordsMatch && !isSubmitting
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: ZenithSpacing.md) {
|
||||
ZenithTextField(
|
||||
label: "Aktuelles Passwort",
|
||||
text: $currentPassword,
|
||||
placeholder: "Aktuelles Passwort",
|
||||
isSecure: true,
|
||||
textContentType: .password
|
||||
)
|
||||
|
||||
ZenithDivider()
|
||||
|
||||
ZenithTextField(
|
||||
label: "Neues Passwort",
|
||||
text: $newPassword,
|
||||
placeholder: "Neues Passwort",
|
||||
isSecure: true,
|
||||
textContentType: .newPassword
|
||||
)
|
||||
|
||||
ZenithTextField(
|
||||
label: "Neues Passwort bestätigen",
|
||||
text: $confirmPassword,
|
||||
placeholder: "Neues Passwort bestätigen",
|
||||
isSecure: true,
|
||||
textContentType: .newPassword
|
||||
)
|
||||
|
||||
if !confirmPassword.isEmpty && !passwordsMatch {
|
||||
Text("Die Passwörter stimmen nicht überein.")
|
||||
.zenithBodySmStyle(color: .zenithError)
|
||||
}
|
||||
|
||||
if let errorMessage {
|
||||
Text(errorMessage)
|
||||
.zenithBodySmStyle(color: .zenithError)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await submit() }
|
||||
} label: {
|
||||
if isSubmitting {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
} else {
|
||||
Text("Passwort ändern")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.zenithPrimary)
|
||||
.disabled(!canSubmit)
|
||||
.padding(.top, ZenithSpacing.sm)
|
||||
}
|
||||
.padding(ZenithSpacing.sm)
|
||||
}
|
||||
.background(Color.zenithBg)
|
||||
.navigationTitle("Passwort ändern")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.alert("Passwort geändert", isPresented: $showSuccessAlert) {
|
||||
Button("OK") {
|
||||
Task { await appState.logout() }
|
||||
}
|
||||
} message: {
|
||||
Text("Passwort geändert. Bitte melde dich mit dem neuen Passwort erneut an.")
|
||||
}
|
||||
}
|
||||
|
||||
private func submit() async {
|
||||
errorMessage = nil
|
||||
isSubmitting = true
|
||||
defer { isSubmitting = false }
|
||||
do {
|
||||
try await AccountAPI.changePassword(currentPassword: currentPassword, newPassword: newPassword)
|
||||
showSuccessAlert = true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
121
app/ios/ScanReceipts/Features/Settings/DeleteAccountView.swift
Normal file
121
app/ios/ScanReceipts/Features/Settings/DeleteAccountView.swift
Normal file
@@ -0,0 +1,121 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Permanent account deletion. Requires typing the account's email exactly
|
||||
/// (compared against `appState.currentUser?.email`) plus the password before
|
||||
/// the destructive action becomes enabled, then a second confirmation
|
||||
/// dialog before the call actually fires.
|
||||
struct DeleteAccountView: View {
|
||||
@EnvironmentObject private var appState: AppState
|
||||
@State private var confirmEmail = ""
|
||||
@State private var password = ""
|
||||
@State private var isSubmitting = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var showConfirmDialog = false
|
||||
|
||||
// TODO: `Models/User.swift` doesn't currently expose whether an account
|
||||
// is Google-only (i.e. has no password to check).
|
||||
// `AccountAPI.deleteAccount(password:confirmEmail:)` accepts `password:
|
||||
// nil` specifically for that case, but there's no signal here to detect
|
||||
// it, so this screen always shows and requires the password field. Once
|
||||
// the `User` model exposes something like `hasPassword`, make this field
|
||||
// optional/hidden for Google-only accounts and pass `password: nil`
|
||||
// instead of always requiring input here.
|
||||
|
||||
private var emailMatches: Bool {
|
||||
guard let accountEmail = appState.currentUser?.email, !accountEmail.isEmpty else { return false }
|
||||
return confirmEmail == accountEmail
|
||||
}
|
||||
|
||||
private var canSubmit: Bool {
|
||||
emailMatches && !password.isEmpty && !isSubmitting
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: ZenithSpacing.md) {
|
||||
Text("Diese Aktion ist unwiderruflich. Dein Konto sowie alle Belege und Ordner werden dauerhaft gelöscht.")
|
||||
.zenithBodyStyle(color: .zenithError)
|
||||
|
||||
ZenithDivider()
|
||||
|
||||
VStack(alignment: .leading, spacing: ZenithSpacing.sm) {
|
||||
Text("Bestätigung").zenithLabelCapsStyle()
|
||||
|
||||
if let email = appState.currentUser?.email {
|
||||
Text("Gib zur Bestätigung deine E-Mail-Adresse ein: \(email)")
|
||||
.zenithBodySmStyle()
|
||||
}
|
||||
|
||||
ZenithTextField(
|
||||
label: "E-Mail-Adresse",
|
||||
text: $confirmEmail,
|
||||
placeholder: "E-Mail-Adresse",
|
||||
keyboardType: .emailAddress,
|
||||
textContentType: .emailAddress,
|
||||
autocapitalization: .never,
|
||||
autocorrectionDisabled: true
|
||||
)
|
||||
|
||||
ZenithTextField(
|
||||
label: "Passwort",
|
||||
text: $password,
|
||||
placeholder: "Passwort",
|
||||
isSecure: true,
|
||||
textContentType: .password
|
||||
)
|
||||
}
|
||||
|
||||
if let errorMessage {
|
||||
Text(errorMessage)
|
||||
.zenithBodySmStyle(color: .zenithError)
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
showConfirmDialog = true
|
||||
} label: {
|
||||
if isSubmitting {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
} else {
|
||||
Text("Konto endgültig löschen")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.zenithPrimaryDestructive)
|
||||
.disabled(!canSubmit)
|
||||
.padding(.top, ZenithSpacing.sm)
|
||||
}
|
||||
.padding(ZenithSpacing.sm)
|
||||
}
|
||||
.background(Color.zenithBg)
|
||||
.navigationTitle("Konto löschen")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.confirmationDialog(
|
||||
"Konto wirklich endgültig löschen?",
|
||||
isPresented: $showConfirmDialog,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Endgültig löschen", role: .destructive) {
|
||||
Task { await deleteAccount() }
|
||||
}
|
||||
Button("Abbrechen", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Diese Aktion kann nicht rückgängig gemacht werden.")
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteAccount() async {
|
||||
errorMessage = nil
|
||||
isSubmitting = true
|
||||
defer { isSubmitting = false }
|
||||
do {
|
||||
try await AccountAPI.deleteAccount(password: password, confirmEmail: confirmEmail)
|
||||
// The account and its session are already gone server-side —
|
||||
// this just resets local state. RootView (App/RootView.swift)
|
||||
// switches to the login screen automatically once
|
||||
// AppState.phase flips to .signedOut.
|
||||
await appState.logout()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
222
app/ios/ScanReceipts/Features/Settings/ProjectsListView.swift
Normal file
222
app/ios/ScanReceipts/Features/Settings/ProjectsListView.swift
Normal file
@@ -0,0 +1,222 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Maps a stored `Project.color` key (see `Models/Project.swift`'s
|
||||
/// `colorPalette`) to a display color. This is purely a presentation choice
|
||||
/// and intentionally lives here rather than in `Models/Project.swift`, which
|
||||
/// is foundation code shared with other features.
|
||||
extension Project {
|
||||
static func displayColor(for key: String?) -> Color {
|
||||
switch key {
|
||||
case "blue": return .blue
|
||||
case "emerald": return .green
|
||||
case "amber": return .orange
|
||||
case "rose": return .pink
|
||||
case "violet": return .purple
|
||||
case "slate": return .gray
|
||||
default: return .gray
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/projects` isn't Pro-gated, so this list is shown to every user —
|
||||
/// only creating (and, per the backend, renaming/deleting) requires Pro. A
|
||||
/// free-plan user can therefore see their existing folders read-only-ish;
|
||||
/// tapping "+" and trying to create one surfaces the paywall instead of a
|
||||
/// generic error (see `createProject`).
|
||||
struct ProjectsListView: View {
|
||||
@State private var projects: [Project] = []
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var showAddSheet = false
|
||||
@State private var showPaywall = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if projects.isEmpty && !isLoading {
|
||||
Text("Noch keine Ordner vorhanden.")
|
||||
.zenithBodySmStyle()
|
||||
.listRowBackground(Color.zenithSurface)
|
||||
.listRowSeparatorTint(Color.zenithBorder)
|
||||
}
|
||||
ForEach(projects) { project in
|
||||
HStack {
|
||||
Rectangle()
|
||||
.fill(Project.displayColor(for: project.color))
|
||||
.frame(width: 12, height: 12)
|
||||
Text(project.name)
|
||||
.zenithBodyStyle()
|
||||
Spacer()
|
||||
Text("\(project.receiptCount)")
|
||||
.zenithLabelMdStyle(color: .zenithMuted)
|
||||
}
|
||||
.listRowBackground(Color.zenithSurface)
|
||||
.listRowSeparatorTint(Color.zenithBorder)
|
||||
}
|
||||
.onDelete(perform: deleteProjects)
|
||||
}
|
||||
.zenithListBackground()
|
||||
.navigationTitle("Ordner")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
showAddSheet = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.foregroundStyle(Color.zenithBlack)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await loadProjects()
|
||||
}
|
||||
.refreshable {
|
||||
await loadProjects()
|
||||
}
|
||||
.alert("Fehler", isPresented: errorAlertBinding) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(errorMessage ?? "")
|
||||
}
|
||||
.sheet(isPresented: $showAddSheet) {
|
||||
AddProjectSheet(onCreate: createProject)
|
||||
}
|
||||
.sheet(isPresented: $showPaywall) {
|
||||
PaywallView()
|
||||
}
|
||||
}
|
||||
|
||||
private var errorAlertBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { errorMessage != nil },
|
||||
set: { if !$0 { errorMessage = nil } }
|
||||
)
|
||||
}
|
||||
|
||||
private func loadProjects() async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
projects = try await ProjectsAPI.list()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` on success so `AddProjectSheet` knows to dismiss
|
||||
/// itself. On a `pro_required` failure this dismisses the add sheet
|
||||
/// (`showAddSheet = false`) and presents the paywall instead of a
|
||||
/// generic error alert — the whole reason this isn't just a plain
|
||||
/// `catch { errorMessage = ... }`.
|
||||
private func createProject(name: String, color: String?) async -> Bool {
|
||||
do {
|
||||
let project = try await ProjectsAPI.create(name: name, color: color)
|
||||
projects.append(project)
|
||||
return true
|
||||
} catch let APIError.server(code, _, _) where code == "pro_required" {
|
||||
showAddSheet = false
|
||||
showPaywall = true
|
||||
return false
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteProjects(at offsets: IndexSet) {
|
||||
let toDelete = offsets.map { projects[$0] }
|
||||
projects.remove(atOffsets: offsets)
|
||||
Task {
|
||||
for project in toDelete {
|
||||
do {
|
||||
try await ProjectsAPI.delete(id: project.id)
|
||||
} catch {
|
||||
// Reload from the server so the list reflects reality
|
||||
// rather than guessing the removed row's original index.
|
||||
await loadProjects()
|
||||
errorMessage = error.localizedDescription
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple add-folder sheet: name + a row of tappable color circles built
|
||||
/// from `Project.colorPalette`. `onCreate` returns whether the create
|
||||
/// succeeded — on `false` the sheet stays open only if the parent left
|
||||
/// `showAddSheet` true (it won't, for `pro_required`, but does for any other
|
||||
/// error so the user can retry without losing their typed name).
|
||||
private struct AddProjectSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var name = ""
|
||||
@State private var selectedColor: String? = Project.colorPalette.first
|
||||
@State private var isSaving = false
|
||||
let onCreate: (String, String?) async -> Bool
|
||||
|
||||
private var trimmedName: String {
|
||||
name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
ZenithTextField(
|
||||
label: "Name",
|
||||
text: $name,
|
||||
placeholder: "Ordnername"
|
||||
)
|
||||
}
|
||||
.listRowBackground(Color.zenithSurface)
|
||||
.listRowSeparatorTint(Color.zenithBorder)
|
||||
|
||||
Section {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(Project.colorPalette, id: \.self) { colorKey in
|
||||
Rectangle()
|
||||
.fill(Project.displayColor(for: colorKey))
|
||||
.frame(width: 28, height: 28)
|
||||
.overlay {
|
||||
if selectedColor == colorKey {
|
||||
Rectangle().strokeBorder(Color.zenithBlack, lineWidth: 2)
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
selectedColor = colorKey
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
} header: {
|
||||
Text("Farbe").zenithLabelCapsStyle()
|
||||
}
|
||||
.listRowBackground(Color.zenithSurface)
|
||||
.listRowSeparatorTint(Color.zenithBorder)
|
||||
}
|
||||
.zenithListBackground()
|
||||
.navigationTitle("Neuer Ordner")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Abbrechen") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button {
|
||||
Task {
|
||||
isSaving = true
|
||||
let success = await onCreate(trimmedName, selectedColor)
|
||||
isSaving = false
|
||||
if success { dismiss() }
|
||||
}
|
||||
} label: {
|
||||
if isSaving {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text("Erstellen")
|
||||
}
|
||||
}
|
||||
.disabled(trimmedName.isEmpty || isSaving)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
97
app/ios/ScanReceipts/Features/Settings/PurchaseService.swift
Normal file
97
app/ios/ScanReceipts/Features/Settings/PurchaseService.swift
Normal file
@@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
|
||||
/// Abstraction over "load Pro plans and buy one" so `PaywallView` doesn't
|
||||
/// need to know whether it's talking to real StoreKit or a stub/preview
|
||||
/// implementation.
|
||||
///
|
||||
/// Apple requires (App Store Review Guideline 3.1.1) that any digital
|
||||
/// feature unlocked *inside* the app (here: Pro status — more scans,
|
||||
/// folders, export) be sold via Apple's own In-App Purchase, not an
|
||||
/// external payment link, when the purchase is initiated from inside the
|
||||
/// app. This protocol is deliberately StoreKit-shaped for that reason.
|
||||
///
|
||||
/// Product identifiers (reverse-DNS, matching the app's bundle ID
|
||||
/// `app.scan-receipts.ios` from project.yml) — these must be created with
|
||||
/// EXACTLY these identifiers in App Store Connect (real submission) and/or
|
||||
/// `Configuration.storekit` (local Simulator testing, see that file) before
|
||||
/// `StoreKitPurchaseService.loadProducts()` will return anything:
|
||||
/// - `app.scan-receipts.ios.pro.weekly` — auto-renewable subscription
|
||||
/// - `app.scan-receipts.ios.pro.annual` — auto-renewable subscription
|
||||
/// - `app.scan-receipts.ios.pro.lifetime` — non-consumable
|
||||
enum PurchaseProductID {
|
||||
static let weekly = "app.scan-receipts.ios.pro.weekly"
|
||||
static let annual = "app.scan-receipts.ios.pro.annual"
|
||||
static let lifetime = "app.scan-receipts.ios.pro.lifetime"
|
||||
static let all = [weekly, annual, lifetime]
|
||||
}
|
||||
|
||||
/// One purchasable plan, already localized/priced by StoreKit (or filled in
|
||||
/// with placeholder copy by `StubPurchaseService`). `planID` is the
|
||||
/// backend's own plan vocabulary (`"weekly" | "annual" | "lifetime"` — see
|
||||
/// `users.plan` in `src/lib/schema/db.ts`), kept separate from the StoreKit
|
||||
/// product identifier since they're different namespaces that happen to be
|
||||
/// related, not the same thing.
|
||||
struct PurchaseProduct: Identifiable, Equatable {
|
||||
let id: String // StoreKit product identifier (PurchaseProductID.*)
|
||||
let planID: String
|
||||
let displayName: String
|
||||
let displayPrice: String
|
||||
/// e.g. "per week" — nil for the one-time lifetime product.
|
||||
let periodDescription: String?
|
||||
}
|
||||
|
||||
protocol PurchaseService {
|
||||
/// Fetches the current products with their StoreKit-localized prices.
|
||||
/// Call before showing `PaywallView`'s plan list — never hardcode prices,
|
||||
/// Apple requires displaying the actual localized App Store price.
|
||||
func loadProducts() async throws -> [PurchaseProduct]
|
||||
|
||||
/// Initiates a purchase for the given StoreKit product identifier.
|
||||
/// Returns `true` only once the transaction is verified and finished;
|
||||
/// `false` means the user cancelled the sheet (not an error). Throws for
|
||||
/// an actual failure (network, StoreKit error, failed verification).
|
||||
///
|
||||
/// `appAccountToken` should be the signed-in user's own
|
||||
/// `User.appleAccountToken` — StoreKit attaches it to the resulting
|
||||
/// transaction, and `POST /api/webhooks/apple` on the backend uses it to
|
||||
/// know which account to grant Pro to (see that property's doc comment).
|
||||
/// Pass `nil` only if genuinely no user is signed in (shouldn't happen —
|
||||
/// the paywall is only ever shown to a signed-in account — but a
|
||||
/// purchase with no attributable owner is still better than none at
|
||||
/// all, since `restorePurchases()`/a later manual reconciliation can
|
||||
/// recover it).
|
||||
func purchase(productID: String, appAccountToken: UUID?) async throws -> Bool
|
||||
|
||||
/// Re-syncs already-owned entitlements — wired to a "Käufe
|
||||
/// wiederherstellen" button (required by App Store guidelines for
|
||||
/// non-consumable/subscription products) and worth calling once at
|
||||
/// launch too, since Apple doesn't otherwise notify a fresh install
|
||||
/// about prior purchases made on the same Apple ID.
|
||||
func restorePurchases() async throws
|
||||
}
|
||||
|
||||
/// Stub/preview implementation — returns illustrative placeholder products
|
||||
/// (so `PaywallView` has something to render in SwiftUI previews and before
|
||||
/// `StoreKitPurchaseService` is wired in) and always throws on an actual
|
||||
/// purchase attempt, since there is nothing real behind it.
|
||||
final class StubPurchaseService: PurchaseService {
|
||||
func loadProducts() async throws -> [PurchaseProduct] {
|
||||
[
|
||||
PurchaseProduct(id: PurchaseProductID.weekly, planID: "weekly", displayName: "Wochen-Pass", displayPrice: "4,99 €", periodDescription: "pro Woche"),
|
||||
PurchaseProduct(id: PurchaseProductID.annual, planID: "annual", displayName: "Jahres-Pass", displayPrice: "39,99 €", periodDescription: "pro Jahr"),
|
||||
PurchaseProduct(id: PurchaseProductID.lifetime, planID: "lifetime", displayName: "Lifetime", displayPrice: "59,99 €", periodDescription: nil),
|
||||
]
|
||||
}
|
||||
|
||||
func purchase(productID: String, appAccountToken: UUID?) async throws -> Bool {
|
||||
throw NSError(
|
||||
domain: "ScanReceipts.Purchase",
|
||||
code: -1,
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey: "In-App-Käufe sind in dieser Vorschau-Version noch nicht verfügbar."
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func restorePurchases() async throws {}
|
||||
}
|
||||
162
app/ios/ScanReceipts/Features/Settings/SettingsView.swift
Normal file
162
app/ios/ScanReceipts/Features/Settings/SettingsView.swift
Normal file
@@ -0,0 +1,162 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Tab root for "Konto" (see `App/MainTabView.swift`, which references
|
||||
/// `SettingsView()` with a zero-arg initializer as its third tab). Wrapped in
|
||||
/// its own `NavigationStack` — every push (folders, change password, delete
|
||||
/// account) and the Pro-upgrade sheet originate from here.
|
||||
struct SettingsView: View {
|
||||
@EnvironmentObject private var appState: AppState
|
||||
@State private var showPaywall = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
profileSection
|
||||
foldersSection
|
||||
accountSection
|
||||
}
|
||||
.zenithListBackground()
|
||||
.navigationTitle("Konto")
|
||||
.sheet(isPresented: $showPaywall) {
|
||||
PaywallView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Profil
|
||||
|
||||
@ViewBuilder
|
||||
private var profileSection: some View {
|
||||
Section {
|
||||
if let user = appState.currentUser {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(user.name?.isEmpty == false ? user.name! : "Unbenannt")
|
||||
.zenithHeadlineMdStyle()
|
||||
if let email = user.email {
|
||||
Text(email)
|
||||
.zenithBodySmStyle()
|
||||
}
|
||||
}
|
||||
.padding(.vertical, ZenithSpacing.unit)
|
||||
.listRowBackground(Color.zenithSurface)
|
||||
.listRowSeparatorTint(Color.zenithBorder)
|
||||
|
||||
if user.isPro {
|
||||
proStatusRow(for: user)
|
||||
.listRowBackground(Color.zenithSurface)
|
||||
.listRowSeparatorTint(Color.zenithBorder)
|
||||
} else {
|
||||
freeStatusRow
|
||||
.listRowBackground(Color.zenithSurface)
|
||||
.listRowSeparatorTint(Color.zenithBorder)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func proStatusRow(for user: User) -> some View {
|
||||
VStack(alignment: .leading, spacing: ZenithSpacing.xs) {
|
||||
HStack(spacing: ZenithSpacing.xs) {
|
||||
Text("PRO")
|
||||
.zenithLabelCapsStyle(color: .white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.zenithBlack)
|
||||
ZenithChip(text: planDisplayName(user.plan))
|
||||
}
|
||||
if let expiresAt = user.expiresAt, let date = ISO8601.parse(expiresAt) {
|
||||
let formatted = date.formatted(date: .abbreviated, time: .omitted)
|
||||
Text(user.cancelAtPeriodEnd ? "Läuft aus am \(formatted)" : "Verlängert sich am \(formatted)")
|
||||
.zenithLabelMdStyle(color: .zenithMuted)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, ZenithSpacing.unit)
|
||||
}
|
||||
|
||||
private var freeStatusRow: some View {
|
||||
VStack(alignment: .leading, spacing: ZenithSpacing.xs) {
|
||||
Text("Kostenlos")
|
||||
.zenithBodyStyle(color: .zenithMuted)
|
||||
Button("Auf Pro upgraden") {
|
||||
showPaywall = true
|
||||
}
|
||||
.buttonStyle(.zenithPrimary)
|
||||
}
|
||||
.padding(.vertical, ZenithSpacing.unit)
|
||||
}
|
||||
|
||||
private func planDisplayName(_ plan: String) -> String {
|
||||
switch plan {
|
||||
case "weekly": return "Wochen-Pass"
|
||||
case "annual": return "Jahres-Pass"
|
||||
case "lifetime": return "Lifetime"
|
||||
case "free": return "Kostenlos"
|
||||
default: return plan.capitalized
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ordner
|
||||
|
||||
private var foldersSection: some View {
|
||||
Section {
|
||||
NavigationLink {
|
||||
ProjectsListView()
|
||||
} label: {
|
||||
Label {
|
||||
Text("Ordner verwalten").zenithBodyStyle()
|
||||
} icon: {
|
||||
Image(systemName: "folder").foregroundStyle(Color.zenithMuted)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.zenithSurface)
|
||||
.listRowSeparatorTint(Color.zenithBorder)
|
||||
} header: {
|
||||
Text("Ordner").zenithLabelCapsStyle()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Konto
|
||||
|
||||
private var accountSection: some View {
|
||||
Section {
|
||||
NavigationLink {
|
||||
ChangePasswordView()
|
||||
} label: {
|
||||
Label {
|
||||
Text("Passwort ändern").zenithBodyStyle()
|
||||
} icon: {
|
||||
Image(systemName: "lock").foregroundStyle(Color.zenithMuted)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.zenithSurface)
|
||||
.listRowSeparatorTint(Color.zenithBorder)
|
||||
|
||||
Button {
|
||||
Task { await appState.logout() }
|
||||
} label: {
|
||||
Label {
|
||||
Text("Abmelden")
|
||||
} icon: {
|
||||
Image(systemName: "arrow.backward.square")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.zenithPlain)
|
||||
.listRowBackground(Color.zenithSurface)
|
||||
.listRowSeparatorTint(Color.zenithBorder)
|
||||
|
||||
NavigationLink {
|
||||
DeleteAccountView()
|
||||
} label: {
|
||||
Label {
|
||||
Text("Konto löschen").zenithBodyStyle(color: .zenithError)
|
||||
} icon: {
|
||||
Image(systemName: "trash").foregroundStyle(Color.zenithError)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.zenithSurface)
|
||||
.listRowSeparatorTint(Color.zenithBorder)
|
||||
} header: {
|
||||
Text("Konto").zenithLabelCapsStyle()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import Foundation
|
||||
import StoreKit
|
||||
|
||||
/// Errors raised locally by `StoreKitPurchaseService` itself (as opposed to
|
||||
/// errors StoreKit hands back verbatim, e.g. from `VerificationResult`'s
|
||||
/// `.unverified` case, which are re-thrown as-is). Kept in the same
|
||||
/// `Error, LocalizedError` + German `errorDescription` shape as `APIError`
|
||||
/// in `Networking/APIError.swift` so both surface consistently in the UI.
|
||||
enum StoreKitPurchaseError: Error, LocalizedError {
|
||||
/// `Product.products(for:)` returned nothing for the requested
|
||||
/// identifier — misconfigured product ID, or not yet propagated in App
|
||||
/// Store Connect / the local `Configuration.storekit` file.
|
||||
case productNotFound
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .productNotFound:
|
||||
return "Dieses Produkt ist aktuell nicht verfügbar. Bitte versuche es später erneut."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Real StoreKit 2 implementation of `PurchaseService` (see
|
||||
/// `Features/Settings/PurchaseService.swift` for the protocol and the
|
||||
/// App Store Review Guideline 3.1.1 background on why this exists at all).
|
||||
///
|
||||
/// NOTE ON SERVER-SIDE ENTITLEMENT SYNC: this class only talks to StoreKit /
|
||||
/// Apple's servers. It does **not** update `users.plan` on the backend — see
|
||||
/// the long comment on `updatesTask` below, this is the important caveat to
|
||||
/// understand before assuming a successful purchase here means the account
|
||||
/// is actually Pro.
|
||||
final class StoreKitPurchaseService: PurchaseService {
|
||||
|
||||
/// Background listener for transactions that complete outside the
|
||||
/// synchronous `purchase()` call (renewals, refunds, Ask-to-Buy
|
||||
/// approvals, purchases made on another device). Started once, for the
|
||||
/// lifetime of this service instance.
|
||||
private let updatesTask: Task<Void, Never>
|
||||
|
||||
init() {
|
||||
updatesTask = Task.detached {
|
||||
for await result in Transaction.updates {
|
||||
await StoreKitPurchaseService.handleUpdatedTransaction(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
updatesTask.cancel()
|
||||
}
|
||||
|
||||
// MARK: - PurchaseService
|
||||
|
||||
func loadProducts() async throws -> [PurchaseProduct] {
|
||||
let products = try await Product.products(for: PurchaseProductID.all)
|
||||
|
||||
let mapped: [PurchaseProduct] = products.compactMap { product in
|
||||
let planID: String
|
||||
switch product.id {
|
||||
case PurchaseProductID.weekly:
|
||||
planID = "weekly"
|
||||
case PurchaseProductID.annual:
|
||||
planID = "annual"
|
||||
case PurchaseProductID.lifetime:
|
||||
planID = "lifetime"
|
||||
default:
|
||||
// Unknown identifier (shouldn't happen — we only asked for
|
||||
// PurchaseProductID.all — but never silently invent a plan
|
||||
// for something we don't recognise).
|
||||
return nil
|
||||
}
|
||||
|
||||
let periodDescription: String?
|
||||
if product.type == .nonConsumable {
|
||||
periodDescription = nil
|
||||
} else if let subscription = product.subscription {
|
||||
periodDescription = StoreKitPurchaseService.periodDescription(for: subscription.subscriptionPeriod)
|
||||
} else {
|
||||
periodDescription = nil
|
||||
}
|
||||
|
||||
return PurchaseProduct(
|
||||
id: product.id,
|
||||
planID: planID,
|
||||
displayName: product.displayName,
|
||||
displayPrice: product.displayPrice,
|
||||
periodDescription: periodDescription
|
||||
)
|
||||
}
|
||||
|
||||
// Render in a fixed, sensible order regardless of what the store
|
||||
// returns — PaywallView renders in the order we give back.
|
||||
let order = [PurchaseProductID.weekly, PurchaseProductID.annual, PurchaseProductID.lifetime]
|
||||
return mapped.sorted { lhs, rhs in
|
||||
let lhsIndex = order.firstIndex(of: lhs.id) ?? order.count
|
||||
let rhsIndex = order.firstIndex(of: rhs.id) ?? order.count
|
||||
return lhsIndex < rhsIndex
|
||||
}
|
||||
}
|
||||
|
||||
func purchase(productID: String, appAccountToken: UUID?) async throws -> Bool {
|
||||
guard let product = try await Product.products(for: [productID]).first else {
|
||||
throw StoreKitPurchaseError.productNotFound
|
||||
}
|
||||
|
||||
// Ties the resulting transaction back to this app's own user id so
|
||||
// the backend (POST /api/webhooks/apple) knows whose account to
|
||||
// grant Pro to — see `User.appleAccountToken`'s doc comment for
|
||||
// exactly how that round-trip works. Purchasing without it (nil)
|
||||
// still completes the purchase on Apple's side, it just can't be
|
||||
// reconciled server-side without a manual look-up later.
|
||||
var options: Set<Product.PurchaseOption> = []
|
||||
if let appAccountToken {
|
||||
options.insert(.appAccountToken(appAccountToken))
|
||||
}
|
||||
|
||||
let result = try await product.purchase(options: options)
|
||||
|
||||
switch result {
|
||||
case .success(let verificationResult):
|
||||
switch verificationResult {
|
||||
case .verified(let transaction):
|
||||
await transaction.finish()
|
||||
return true
|
||||
case .unverified(_, let error):
|
||||
// StoreKit couldn't verify the transaction's signature (e.g.
|
||||
// jailbroken device, tampered receipt) — surface the
|
||||
// underlying error rather than treating it as a success.
|
||||
throw error
|
||||
}
|
||||
|
||||
case .userCancelled:
|
||||
// Not an error — the user backed out of the purchase sheet.
|
||||
return false
|
||||
|
||||
case .pending:
|
||||
// Ask-to-Buy / family approval pending. Nothing more to do
|
||||
// synchronously; the eventual approval (or denial) arrives later
|
||||
// via Transaction.updates, handled by `updatesTask` below.
|
||||
return false
|
||||
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func restorePurchases() async throws {
|
||||
try await AppStore.sync()
|
||||
}
|
||||
|
||||
// MARK: - Transaction updates listener
|
||||
|
||||
/// Verifies and finishes a transaction observed via `Transaction.updates`.
|
||||
///
|
||||
/// IMPORTANT — this is genuinely incomplete, documented honestly rather
|
||||
/// than hidden: finishing a transaction here only tells StoreKit "this
|
||||
/// purchase has been dealt with, stop re-presenting it" — it does
|
||||
/// **not** unlock Pro server-side. The backend has no
|
||||
/// `/api/webhooks/apple` endpoint yet to reconcile a StoreKit purchase
|
||||
/// into `users.plan` (see `app/ios/README.md`'s "What's deliberately NOT
|
||||
/// done yet" section) — that reconciliation is separate, not-yet-built
|
||||
/// backend work driven by Apple's server-to-server notifications. Do
|
||||
/// NOT be tempted to set some local "is Pro" flag here as a substitute:
|
||||
/// the account's real Pro status lives on the server (`users.plan`),
|
||||
/// and nothing in this file can change that yet.
|
||||
private static func handleUpdatedTransaction(_ result: VerificationResult<Transaction>) async {
|
||||
switch result {
|
||||
case .verified(let transaction):
|
||||
await transaction.finish()
|
||||
case .unverified:
|
||||
// Can't verify it; leave it alone rather than finishing a
|
||||
// transaction we couldn't authenticate. It will be re-delivered.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Formatting helpers
|
||||
|
||||
/// Turns a `Product.SubscriptionPeriod` into a short German phrase, e.g.
|
||||
/// `(unit: .week, value: 1)` -> "pro Woche", `(unit: .year, value: 1)`
|
||||
/// -> "pro Jahr", `(unit: .month, value: 3)` -> "alle 3 Monate". This app
|
||||
/// currently only ever configures value == 1 periods (see
|
||||
/// `Configuration.storekit`), but the multi-value phrasing is filled in
|
||||
/// so this doesn't silently misrender if that ever changes.
|
||||
private static func periodDescription(for period: Product.SubscriptionPeriod) -> String {
|
||||
let value = period.value
|
||||
|
||||
if value == 1 {
|
||||
switch period.unit {
|
||||
case .day:
|
||||
return "pro Tag"
|
||||
case .week:
|
||||
return "pro Woche"
|
||||
case .month:
|
||||
return "pro Monat"
|
||||
case .year:
|
||||
return "pro Jahr"
|
||||
@unknown default:
|
||||
return "pro Abrechnungszeitraum"
|
||||
}
|
||||
}
|
||||
|
||||
switch period.unit {
|
||||
case .day:
|
||||
return "alle \(value) Tage"
|
||||
case .week:
|
||||
return "alle \(value) Wochen"
|
||||
case .month:
|
||||
return "alle \(value) Monate"
|
||||
case .year:
|
||||
return "alle \(value) Jahre"
|
||||
@unknown default:
|
||||
return "alle \(value) Abrechnungszeiträume"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user