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