Files
Vorrania/ios/Sources/LocalOverview.swift
Scarriffle 580afcc133 iOS: eigene, gestaltbare App-Uebersicht mit nativen Diagrammen
Der Start-Tab spiegelte bisher fest die Web-Dashboards - dieselbe gespeicherte
Anordnung, und Diagramme gab es nur den Hinweis "im Web". Das war der Punkt,
der geaergert hat.

Jetzt ist der Modus je Server umschaltbar: "Web-Uebersicht spiegeln" (weiter
Standard, damit nach dem Update nichts ueberrascht) oder "Eigene
App-Uebersicht". Die eigene Uebersicht liegt nur auf dem Geraet (pro Server),
unabhaengig vom Web und nicht synchronisiert. Beim ersten Umschalten entsteht
gleich eine sinnvolle Startanordnung.

Sie funktioniert wie im Web, nur fuers Telefon: mehrere Dashboards, Karten
einspaltig untereinander, im Bearbeiten-Modus hinzufuegen, per Ziehen sortieren
und loeschen; Artikelkarten bekommen ihren Artikel zugewiesen. Anzeigen und
Bearbeiten sind getrennt - so laufen Sortieren und Loeschen sauber, statt an
Sektionsgrenzen zu haken.

Die Diagramme sind nativ mit Swift Charts: Ablauf- und Kategorien-Anteile
(Ring ab iOS 17, sonst ein 100-%-Balken - das Ziel ist iOS 16, deshalb kein
stilles Anheben), Kategorien nach Zustand als gestapelte Balken, Bestands- und
Artikelverlauf als Linie, Ein-/Auslagerungen als gruppierte Balken. Dieselben
Endpunkte wie das Web-Dashboard, kein Backend-Eingriff. Der "gibt es nur im
Web"-Hinweis faellt damit weg - auch beim Spiegeln werden die Diagramme jetzt
nativ gezeichnet.

Geprueft: Build; alle vier Diagramm-Modelle gegen echte Serverantworten
dekodiert (auch der Zeitstempel mit Z und Sekundenbruchteilen); refactorte
DashboardCardView ohne alte Aufrufstellen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 21:29:37 +02:00

249 lines
8.6 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) { 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
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(CardCatalog.titel(karte.type))
if CardCatalogList.kind(for: karte.type)?.needsProduct == true {
artikelWahl(fuer: karte)
}
}
Spacer()
}
}
.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.accentColor)
}
}
// 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) } }
private func setzeProdukt(_ karte: AppCard, _ pid: Int?) {
mutiere { dash in
if let i = dash.cards.firstIndex(where: { $0.id == karte.id }) {
dash.cards[i].productId = pid
}
}
stand += 1
}
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.accentColor)
}
}
.foregroundStyle(.primary)
}
}