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>
This commit is contained in:
@@ -1,83 +1,120 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Zeigt die Dashboards, die auch die Web-Oberflaeche kennt.
|
||||
///
|
||||
/// Das Raster (x/y/w/h) laesst sich auf einem Telefon nicht sinnvoll
|
||||
/// nachbilden - hier stehen die Karten deshalb untereinander, in der
|
||||
/// Lesereihenfolge des Rasters (erst Zeile, dann Spalte). Zusammengestellt
|
||||
/// werden Dashboards weiterhin am Rechner; die App zeigt sie.
|
||||
/// Der Start-Tab. Je Server wird entweder die Web-Übersicht gespiegelt oder eine
|
||||
/// eigene, lokale App-Übersicht gezeigt - umschaltbar über das Menü oben.
|
||||
struct DashboardView: View {
|
||||
@EnvironmentObject private var session: Session
|
||||
|
||||
@State private var mode: OverviewMode = .mirror
|
||||
@State private var manageShown = false
|
||||
|
||||
private var profileID: UUID? { session.activeProfileID }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if mode == .local, let profileID {
|
||||
LocalOverview(profileID: profileID)
|
||||
} else {
|
||||
MirrorOverview()
|
||||
}
|
||||
}
|
||||
.navigationTitle(session.activeProfile?.name ?? "Übersicht")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Menu {
|
||||
Picker("Übersicht", selection: Binding(
|
||||
get: { mode },
|
||||
set: { neu in setzeModus(neu) }
|
||||
)) {
|
||||
Label("Web-Übersicht spiegeln", systemImage: "arrow.triangle.2.circlepath")
|
||||
.tag(OverviewMode.mirror)
|
||||
Label("Eigene App-Übersicht", systemImage: "square.grid.2x2")
|
||||
.tag(OverviewMode.local)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "rectangle.3.group")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
ServerMenu(manageShown: $manageShown)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $manageShown) { ServerListView() }
|
||||
}
|
||||
.task(id: profileID) { ladeModus() }
|
||||
}
|
||||
|
||||
private func ladeModus() {
|
||||
guard let profileID else { mode = .mirror; return }
|
||||
mode = AppDashboardStore.config(for: profileID).mode
|
||||
}
|
||||
|
||||
private func setzeModus(_ neu: OverviewMode) {
|
||||
guard let profileID else { return }
|
||||
var config = AppDashboardStore.config(for: profileID)
|
||||
// Beim ersten Umschalten auf „eigen" gleich eine sinnvolle Übersicht anlegen.
|
||||
if neu == .local && config.dashboards.isEmpty {
|
||||
config.dashboards = [AppDashboard.standard()]
|
||||
}
|
||||
config.mode = neu
|
||||
AppDashboardStore.save(config, for: profileID)
|
||||
mode = neu
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Web-Übersicht spiegeln
|
||||
|
||||
/// Zeigt die Dashboards der Web-Oberfläche (schreibgeschützt). Das Raster
|
||||
/// (x/y/w/h) lässt sich auf dem Telefon nicht nachbilden - die Karten stehen
|
||||
/// deshalb untereinander, in der Lesereihenfolge des Rasters.
|
||||
struct MirrorOverview: View {
|
||||
@EnvironmentObject private var session: Session
|
||||
|
||||
@State private var dashboards: [Dashboard] = []
|
||||
@State private var auswahl: Int?
|
||||
@State private var busy = true
|
||||
@State private var fehler: String?
|
||||
@State private var manageShown = false
|
||||
/// Zaehler, der die Karten nach einer Buchung neu laden laesst.
|
||||
@State private var stand = 0
|
||||
|
||||
private var aktives: Dashboard? {
|
||||
dashboards.first { $0.id == auswahl } ?? dashboards.first
|
||||
}
|
||||
|
||||
/// Rasterreihenfolge: erst von oben nach unten, dann von links nach rechts.
|
||||
private var karten: [DashboardCard] {
|
||||
(aktives?.layout ?? []).sorted {
|
||||
$0.y == $1.y ? $0.x < $1.x : $0.y < $1.y
|
||||
}
|
||||
(aktives?.layout ?? []).sorted { $0.y == $1.y ? $0.x < $1.x : $0.y < $1.y }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
if let fehler {
|
||||
Section { Text(fehler).foregroundStyle(.red).font(.callout) }
|
||||
}
|
||||
|
||||
// Mehrere Dashboards: oben umschalten. Bei einem einzigen waere
|
||||
// die Auswahl nur Beiwerk.
|
||||
if dashboards.count > 1 {
|
||||
Section {
|
||||
Picker("Dashboard", selection: Binding(
|
||||
get: { aktives?.id ?? 0 },
|
||||
set: { auswahl = $0 }
|
||||
)) {
|
||||
ForEach(dashboards) { d in
|
||||
Text(d.name).tag(d.id)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
List {
|
||||
if let fehler {
|
||||
Section { Text(fehler).foregroundStyle(.red).font(.callout) }
|
||||
}
|
||||
if dashboards.count > 1 {
|
||||
Section {
|
||||
Picker("Dashboard", selection: Binding(
|
||||
get: { aktives?.id ?? 0 }, set: { auswahl = $0 }
|
||||
)) {
|
||||
ForEach(dashboards) { Text($0.name).tag($0.id) }
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(karten) { karte in
|
||||
Section {
|
||||
DashboardCardView(karte: karte, stand: stand) { stand += 1 }
|
||||
} header: {
|
||||
Text(titel(fuer: karte))
|
||||
}
|
||||
}
|
||||
|
||||
if karten.isEmpty && !busy {
|
||||
Text("Dieses Dashboard hat keine Karten. Zusammenstellen lässt es sich in der Web-Oberfläche.")
|
||||
.foregroundStyle(.secondary)
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
}
|
||||
.navigationTitle(aktives?.name ?? session.activeProfile?.name ?? "Übersicht")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
ServerMenu(manageShown: $manageShown)
|
||||
ForEach(karten) { karte in
|
||||
Section {
|
||||
DashboardCardView(type: karte.type, productId: karte.props?.productId,
|
||||
stand: stand) { stand += 1 }
|
||||
} header: {
|
||||
Text(CardCatalog.titel(karte.type))
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $manageShown) { ServerListView() }
|
||||
.refreshable { await laden() }
|
||||
.task(id: session.activeProfileID) { await laden() }
|
||||
if karten.isEmpty && !busy {
|
||||
Text("Dieses Dashboard hat keine Karten. Zusammenstellen lässt es sich in der Web-Oberfläche.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func titel(fuer karte: DashboardCard) -> String {
|
||||
CardCatalog.titel(karte.type)
|
||||
.refreshable { await laden() }
|
||||
.task(id: session.activeProfileID) { await laden() }
|
||||
}
|
||||
|
||||
private func laden() async {
|
||||
@@ -130,14 +167,17 @@ enum CardCatalog {
|
||||
}
|
||||
}
|
||||
|
||||
/// Waehlt anhand der Kartenart den passenden Inhalt.
|
||||
/// Waehlt anhand der Kartenart den passenden Inhalt. Wird sowohl beim Spiegeln
|
||||
/// der Web-Dashboards als auch in der eigenen App-Uebersicht verwendet - daher
|
||||
/// nur ueber Art und (fuer Artikelkarten) Produkt-Id parametrisiert.
|
||||
struct DashboardCardView: View {
|
||||
let karte: DashboardCard
|
||||
let type: String
|
||||
let productId: Int?
|
||||
let stand: Int
|
||||
let onChange: () -> Void
|
||||
|
||||
var body: some View {
|
||||
switch karte.type {
|
||||
switch type {
|
||||
case "checkin-quick":
|
||||
QuickBookingCard(richtung: .ein, onChange: onChange)
|
||||
case "checkout-quick":
|
||||
@@ -145,23 +185,23 @@ struct DashboardCardView: View {
|
||||
case "actions":
|
||||
ActionsCard()
|
||||
case "status", "kpi-products", "kpi-units":
|
||||
StatusCard(art: karte.type, stand: stand)
|
||||
StatusCard(art: type, stand: stand)
|
||||
case "shopping":
|
||||
ShoppingCard(stand: stand)
|
||||
case "expiring", "expired", "expiry-all":
|
||||
ExpiryCard(modus: karte.type, stand: stand)
|
||||
ExpiryCard(modus: type, stand: stand)
|
||||
case "movements":
|
||||
MovementsCard(stand: stand)
|
||||
case "product-stock", "product-timeline":
|
||||
ProductCard(productId: karte.props?.productId, stand: stand)
|
||||
case "product-stock":
|
||||
ProductCard(productId: productId, stand: stand)
|
||||
case "product-timeline":
|
||||
ProductTimelineChart(productId: productId, stand: stand)
|
||||
case "expiry-donut", "category-donut", "category-bars",
|
||||
"stock-timeline", "activity-timeline":
|
||||
ChartCardView(type: type, stand: stand)
|
||||
default:
|
||||
if CardCatalog.istDiagramm(karte.type) {
|
||||
Label("Diagramme gibt es in der Web-Oberfläche.", systemImage: "chart.xyaxis.line")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("Unbekannte Karte „\(karte.type)“.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Text("Unbekannte Karte „\(type)“.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user