Der Start-Tab liegt in einer TabView; deren Tab-Balken verdeckte die .bottomBar-Werkzeugleiste, deshalb war 'Bearbeiten' nicht auffindbar. Jetzt eine eigene Leiste per safeAreaInset direkt ueber dem Tab-Balken. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
325 lines
12 KiB
Swift
325 lines
12 KiB
Swift
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
|
||
}
|
||
}
|
||
// Eigene Leiste statt `.bottomBar`: der Start-Tab liegt in einer TabView,
|
||
// deren Tab-Balken die `.bottomBar` verdecken würde. `safeAreaInset` legt
|
||
// die Leiste sichtbar direkt über den Tab-Balken.
|
||
.safeAreaInset(edge: .bottom) { 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)
|
||
}
|
||
|
||
private var werkzeugleiste: some View {
|
||
VStack(spacing: 0) {
|
||
Divider()
|
||
HStack {
|
||
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 }.fontWeight(.semibold)
|
||
} else {
|
||
Spacer()
|
||
Button { bearbeiten = true } label: {
|
||
Label("Bearbeiten", systemImage: "slider.horizontal.3")
|
||
}
|
||
}
|
||
}
|
||
.padding(.horizontal)
|
||
.padding(.vertical, 10)
|
||
}
|
||
.background(.bar)
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|