Files
scan-receipts/app/ios/ScanReceipts/Features/Settings/ProjectsListView.swift

223 lines
8.0 KiB
Swift

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)
}
}
}
}
}