import SwiftUI /// Die eigene, lokale App-Übersicht eines Servers: mehrere Dashboards, Karten /// untereinander, frei zusammenstellbar. Nur auf diesem Gerät gespeichert. /// /// Anzeige und Bearbeitung sind getrennt: Beim Bearbeiten zeigt eine kompakte, /// sortierbare Liste die Karten (so funktionieren Ziehen und Löschen sauber), /// beim Ansehen die vollen Karten. struct LocalOverview: View { let profileID: UUID @State private var config = AppOverviewConfig() @State private var auswahl: UUID? @State private var bearbeiten = false @State private var addShown = false @State private var produkte: [Product] = [] @State private var stand = 0 @State private var renameText = "" @State private var renameShown = false private var aktives: AppDashboard? { config.dashboards.first { $0.id == auswahl } ?? config.dashboards.first } var body: some View { Group { if bearbeiten { editierListe } else { anzeige } } .toolbar { ToolbarItemGroup(placement: .bottomBar) { werkzeugleiste } } .sheet(isPresented: $addShown) { CardPickerView { neu in fuegeHinzu(neu) } } .alert("Dashboard umbenennen", isPresented: $renameShown) { TextField("Name", text: $renameText) Button("Sichern") { speichereName() } Button("Abbrechen", role: .cancel) {} } .task(id: profileID) { config = AppDashboardStore.config(for: profileID) if auswahl == nil || !config.dashboards.contains(where: { $0.id == auswahl }) { auswahl = config.dashboards.first?.id } produkte = (try? await APIClient.shared.searchProducts("")) ?? [] } } // MARK: - Anzeige private var anzeige: some View { List { if config.dashboards.count > 1 { Section { dashboardPicker } } if let aktives { ForEach(aktives.cards) { karte in Section { DashboardCardView(type: karte.type, productId: karte.productId, stand: stand, detail: karte.detail ?? false, tiefe: karte.tiefe, mitOhneMhd: karte.mitOhneMhd ?? true) { stand += 1 } } header: { Text(titel(karte)) } } if aktives.cards.isEmpty { Text("Noch keine Karten. Über „Bearbeiten“ lassen sich welche hinzufügen.") .foregroundStyle(.secondary) } } } .refreshable { stand += 1 } } // MARK: - Bearbeiten private var editierListe: some View { List { if config.dashboards.count > 1 { Section { dashboardPicker } } Section { ForEach(aktives?.cards ?? []) { karte in VStack(alignment: .leading, spacing: 6) { Text(CardCatalog.titel(karte.type)) if CardCatalogList.kind(for: karte.type)?.needsProduct == true { artikelWahl(fuer: karte) } diagrammEinstellungen(fuer: karte) } } .onDelete(perform: loesche) .onMove(perform: verschiebe) } header: { Text("Karten – ziehen zum Sortieren") } } .environment(\.editMode, .constant(.active)) } private var dashboardPicker: some View { Picker("Dashboard", selection: Binding( get: { aktives?.id ?? UUID() }, set: { auswahl = $0 } )) { ForEach(config.dashboards) { Text($0.name).tag($0.id) } } .pickerStyle(.menu) } @ViewBuilder private var werkzeugleiste: some View { if bearbeiten { Button { addShown = true } label: { Label("Karte", systemImage: "plus") } Spacer() Menu { Button("Dashboard hinzufügen", systemImage: "plus") { neuesDashboard() } if aktives != nil { Button("Umbenennen", systemImage: "pencil") { renameText = aktives?.name ?? ""; renameShown = true } if config.dashboards.count > 1 { Button("Dashboard löschen", systemImage: "trash", role: .destructive) { loescheDashboard() } } } } label: { Label("Dashboards", systemImage: "rectangle.stack") } Spacer() Button("Fertig") { bearbeiten = false } } else { Spacer() Button { bearbeiten = true } label: { Label("Bearbeiten", systemImage: "slider.horizontal.3") } } } private func titel(_ karte: AppCard) -> String { if let pid = karte.productId, let p = produkte.first(where: { $0.id == pid }) { return "\(CardCatalog.titel(karte.type)): \(p.name)" } return CardCatalog.titel(karte.type) } private func artikelWahl(fuer karte: AppCard) -> some View { Menu { Button("– kein Artikel –") { setzeProdukt(karte, nil) } ForEach(produkte) { p in Button(p.name) { setzeProdukt(karte, p.id) } } } label: { Text(produkte.first { $0.id == karte.productId }?.name ?? "Artikel wählen") .font(.caption).foregroundStyle(Color.marke) } } // MARK: - Diagramm-Einstellungen (nur bestimmte Ringe, wie im Web-Editor) private struct TiefeOption: Identifiable { let tiefe: Int? let label: String var id: String { label } } private static let tiefeOptionen: [TiefeOption] = [ .init(tiefe: nil, label: "Feinste (jede Kategorie einzeln)"), .init(tiefe: 1, label: "Nur oberste Stufe"), .init(tiefe: 2, label: "Bis 2. Stufe"), .init(tiefe: 3, label: "Bis 3. Stufe"), ] @ViewBuilder private func diagrammEinstellungen(fuer karte: AppCard) -> some View { switch karte.type { case "expiry-donut": Toggle(isOn: Binding( get: { karte.mitOhneMhd ?? true }, set: { setzeMitOhneMhd(karte, $0) } )) { Text("„Ohne MHD“ einbeziehen").font(.caption) } case "category-donut": Toggle(isOn: Binding( get: { karte.detail ?? false }, set: { setzeDetail(karte, $0) } )) { Text("Detailansicht (alle einzeln)").font(.caption) } Menu { ForEach(Self.tiefeOptionen) { opt in Button(opt.label) { setzeTiefe(karte, opt.tiefe) } } } label: { HStack(spacing: 4) { Text("Tiefe:").font(.caption).foregroundStyle(.secondary) Text(Self.tiefeOptionen.first { $0.tiefe == karte.tiefe }?.label ?? "Feinste") .font(.caption).foregroundStyle(Color.marke) } } default: EmptyView() } } // MARK: - Datenänderungen (alle sofort gespeichert) private func mutiere(_ block: (inout AppDashboard) -> Void) { guard let id = aktives?.id, let index = config.dashboards.firstIndex(where: { $0.id == id }) else { return } block(&config.dashboards[index]) AppDashboardStore.save(config, for: profileID) } private func fuegeHinzu(_ karte: AppCard) { mutiere { $0.cards.append(karte) } } /// Ändert genau eine Karte und lädt die Anzeige neu. private func mutiereKarte(_ karte: AppCard, _ block: (inout AppCard) -> Void) { mutiere { dash in if let i = dash.cards.firstIndex(where: { $0.id == karte.id }) { block(&dash.cards[i]) } } stand += 1 } private func setzeProdukt(_ karte: AppCard, _ pid: Int?) { mutiereKarte(karte) { $0.productId = pid } } private func setzeMitOhneMhd(_ karte: AppCard, _ wert: Bool) { mutiereKarte(karte) { $0.mitOhneMhd = wert } } private func setzeDetail(_ karte: AppCard, _ wert: Bool) { mutiereKarte(karte) { $0.detail = wert } } private func setzeTiefe(_ karte: AppCard, _ wert: Int?) { mutiereKarte(karte) { $0.tiefe = wert } } private func loesche(at offsets: IndexSet) { mutiere { $0.cards.remove(atOffsets: offsets) } } private func verschiebe(from: IndexSet, to: Int) { mutiere { $0.cards.move(fromOffsets: from, toOffset: to) } } private func neuesDashboard() { let neu = AppDashboard(name: "Neues Dashboard") config.dashboards.append(neu) AppDashboardStore.save(config, for: profileID) auswahl = neu.id } private func loescheDashboard() { guard let id = aktives?.id else { return } config.dashboards.removeAll { $0.id == id } AppDashboardStore.save(config, for: profileID) auswahl = config.dashboards.first?.id } private func speichereName() { let name = renameText.trimmingCharacters(in: .whitespaces) guard !name.isEmpty else { return } mutiere { $0.name = name } } } /// Auswahl einer neuen Karte. Diagramme sind eigens gekennzeichnet. struct CardPickerView: View { let onPick: (AppCard) -> Void @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { List { Section("Karten") { ForEach(CardCatalogList.all.filter { !$0.isChart }) { kind in zeile(kind) } } Section("Diagramme") { ForEach(CardCatalogList.all.filter { $0.isChart }) { kind in zeile(kind) } } } .navigationTitle("Karte hinzufügen") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } } } } private func zeile(_ kind: CardKind) -> some View { Button { onPick(AppCard(type: kind.type)) dismiss() } label: { HStack { Text(kind.title) if kind.needsProduct { Text("· Artikel").font(.caption).foregroundStyle(.secondary) } Spacer() Image(systemName: "plus.circle").foregroundStyle(Color.marke) } } .foregroundStyle(.primary) } }