223 lines
8.0 KiB
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|