feat: add iOS support and harden receipt scanning
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user