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:
283
ios/Sources/ChartCards.swift
Normal file
283
ios/Sources/ChartCards.swift
Normal file
@@ -0,0 +1,283 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
/// Native Diagrammkarten der Übersicht (Swift Charts). Ersetzt den früheren
|
||||
/// „gibt es nur im Web"-Hinweis.
|
||||
///
|
||||
/// Ringe (SectorMark) gibt es erst ab iOS 17; auf iOS 16 tritt an ihre Stelle
|
||||
/// ein 100-%-Balken, der dieselben Anteile zeigt. Balken und Linien laufen auf
|
||||
/// beiden.
|
||||
|
||||
/// Farben der Ablaufzustände - überall gleich, damit Grün/Orange/Rot dasselbe
|
||||
/// bedeuten.
|
||||
enum ExpiryColor {
|
||||
static let ok = Color.green
|
||||
static let soon = Color.orange
|
||||
static let expired = Color.red
|
||||
static let noDate = Color.gray
|
||||
}
|
||||
|
||||
/// Ein Anteil für Ring bzw. 100-%-Balken.
|
||||
private struct Segment: Identifiable {
|
||||
let id = UUID()
|
||||
let label: String
|
||||
let value: Double
|
||||
let color: Color
|
||||
}
|
||||
|
||||
/// Ring (iOS 17+) oder 100-%-Balken (iOS 16) plus Legende.
|
||||
private struct ProportionChart: View {
|
||||
let segments: [Segment]
|
||||
let einheit: String
|
||||
|
||||
private var sichtbar: [Segment] { segments.filter { $0.value > 0 } }
|
||||
|
||||
var body: some View {
|
||||
if sichtbar.isEmpty {
|
||||
Text("Keine Daten.").font(.caption).foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if #available(iOS 17.0, *) {
|
||||
Chart(sichtbar) { seg in
|
||||
SectorMark(angle: .value("Anteil", seg.value),
|
||||
innerRadius: .ratio(0.62), angularInset: 1.5)
|
||||
.foregroundStyle(seg.color)
|
||||
}
|
||||
.frame(height: 160)
|
||||
} else {
|
||||
Chart(sichtbar) { seg in
|
||||
BarMark(x: .value("Anteil", seg.value), y: .value("", "Bestand"))
|
||||
.foregroundStyle(seg.color)
|
||||
}
|
||||
.chartXAxis(.hidden).chartYAxis(.hidden)
|
||||
.frame(height: 28)
|
||||
}
|
||||
legende
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var legende: some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
ForEach(sichtbar) { seg in
|
||||
HStack(spacing: 6) {
|
||||
Circle().fill(seg.color).frame(width: 8, height: 8)
|
||||
Text(seg.label).font(.caption)
|
||||
Spacer()
|
||||
Text("\(formatAmount(seg.value)) \(einheit)")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Waehlt anhand der Art die passende Diagrammkarte (ohne Artikelbezug).
|
||||
struct ChartCardView: View {
|
||||
let type: String
|
||||
let stand: Int
|
||||
|
||||
var body: some View {
|
||||
switch type {
|
||||
case "expiry-donut": ExpiryDonutCard(stand: stand)
|
||||
case "category-donut": CategoryDonutCard(stand: stand)
|
||||
case "category-bars": CategoryBarsCard(stand: stand)
|
||||
case "stock-timeline": StockTimelineCard(stand: stand)
|
||||
case "activity-timeline": ActivityTimelineCard(stand: stand)
|
||||
default:
|
||||
Text("Unbekanntes Diagramm „\(type)“.").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ladehülle
|
||||
|
||||
/// Nimmt die wiederkehrende Lade-/Fehlerlogik der Diagrammkarten ab.
|
||||
private struct ChartLoader<Data, Inhalt: View>: View {
|
||||
let laden: () async throws -> Data
|
||||
let stand: Int
|
||||
@ViewBuilder let inhalt: (Data) -> Inhalt
|
||||
|
||||
@State private var daten: Data?
|
||||
@State private var fehler: String?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let fehler {
|
||||
Text(fehler).font(.caption).foregroundStyle(.red)
|
||||
} else if let daten {
|
||||
inhalt(daten)
|
||||
} else {
|
||||
Text("Lädt…").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.task(id: stand) {
|
||||
do { daten = try await laden(); fehler = nil }
|
||||
catch { self.fehler = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ablauf-Anteile
|
||||
|
||||
struct ExpiryDonutCard: View {
|
||||
let stand: Int
|
||||
var body: some View {
|
||||
ChartLoader(laden: { try await APIClient.shared.expirySplit() }, stand: stand) { s in
|
||||
ProportionChart(segments: [
|
||||
Segment(label: "In Ordnung", value: s.ok, color: ExpiryColor.ok),
|
||||
Segment(label: "Bald ablaufend", value: s.soon, color: ExpiryColor.soon),
|
||||
Segment(label: "Abgelaufen", value: s.expired, color: ExpiryColor.expired),
|
||||
Segment(label: "Ohne MHD", value: s.noDate, color: ExpiryColor.noDate),
|
||||
], einheit: "AE")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Kategorien-Anteile
|
||||
|
||||
struct CategoryDonutCard: View {
|
||||
let stand: Int
|
||||
|
||||
// Wiederholbare, ruhige Farbfolge fuer die Kategorien.
|
||||
private static let palette: [Color] = [
|
||||
.blue, .green, .orange, .purple, .pink, .teal, .indigo, .brown,
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
ChartLoader(laden: { try await APIClient.shared.byCategory() }, stand: stand) { liste in
|
||||
let sortiert = liste.filter { $0.articleUnits > 0 }
|
||||
.sorted { $0.articleUnits > $1.articleUnits }
|
||||
// Die groessten sechs einzeln, der Rest gebuendelt - sonst wird der
|
||||
// Ring unlesbar.
|
||||
let kopf = sortiert.prefix(6)
|
||||
let restWert = sortiert.dropFirst(6).reduce(0) { $0 + $1.articleUnits }
|
||||
var segmente = kopf.enumerated().map { i, c in
|
||||
Segment(label: c.name, value: c.articleUnits,
|
||||
color: Self.palette[i % Self.palette.count])
|
||||
}
|
||||
if restWert > 0 {
|
||||
segmente.append(Segment(label: "Übrige", value: restWert, color: .gray))
|
||||
}
|
||||
return ProportionChart(segments: segmente, einheit: "AE")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Kategorien nach Zustand (gestapelte Balken)
|
||||
|
||||
struct CategoryBarsCard: View {
|
||||
let stand: Int
|
||||
|
||||
private struct Zeile: Identifiable {
|
||||
let id = UUID()
|
||||
let kategorie: String
|
||||
let zustand: String
|
||||
let wert: Double
|
||||
let farbe: Color
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ChartLoader(laden: { try await APIClient.shared.byCategory() }, stand: stand) { liste in
|
||||
let top = liste.filter { $0.articleUnits > 0 }
|
||||
.sorted { $0.articleUnits > $1.articleUnits }.prefix(8)
|
||||
let zeilen: [Zeile] = top.flatMap { c in
|
||||
[
|
||||
Zeile(kategorie: c.name, zustand: "In Ordnung", wert: c.ok, farbe: ExpiryColor.ok),
|
||||
Zeile(kategorie: c.name, zustand: "Bald", wert: c.soon, farbe: ExpiryColor.soon),
|
||||
Zeile(kategorie: c.name, zustand: "Abgelaufen", wert: c.expired, farbe: ExpiryColor.expired),
|
||||
Zeile(kategorie: c.name, zustand: "Ohne MHD", wert: c.noDate, farbe: ExpiryColor.noDate),
|
||||
]
|
||||
}
|
||||
return Chart(zeilen) { z in
|
||||
BarMark(x: .value("Artikeleinheiten", z.wert),
|
||||
y: .value("Kategorie", z.kategorie))
|
||||
.foregroundStyle(z.farbe)
|
||||
}
|
||||
.chartLegend(.hidden)
|
||||
.frame(height: max(120, CGFloat(top.count) * 34))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bestandsverlauf (Linie)
|
||||
|
||||
struct StockTimelineCard: View {
|
||||
let stand: Int
|
||||
var body: some View {
|
||||
ChartLoader(laden: { try await APIClient.shared.timeline(days: 90) }, stand: stand) { punkte in
|
||||
LinienDiagramm(punkte: punkte)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Verlauf **eines** Artikels - dieselbe Linie, nur gefiltert.
|
||||
struct ProductTimelineChart: View {
|
||||
let productId: Int?
|
||||
let stand: Int
|
||||
var body: some View {
|
||||
if let productId {
|
||||
ChartLoader(laden: { try await APIClient.shared.timeline(days: 90, productId: productId) },
|
||||
stand: stand) { punkte in
|
||||
LinienDiagramm(punkte: punkte)
|
||||
}
|
||||
} else {
|
||||
Text("Kein Artikel gewählt – beim Bearbeiten einstellen.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LinienDiagramm: View {
|
||||
let punkte: [TimelinePoint]
|
||||
|
||||
var body: some View {
|
||||
if punkte.isEmpty {
|
||||
Text("Keine Daten.").font(.caption).foregroundStyle(.secondary)
|
||||
} else {
|
||||
Chart(punkte) { p in
|
||||
if let datum = DisplaySettings.parseTimestamp(p.at) {
|
||||
LineMark(x: .value("Zeit", datum), y: .value("Bestand", p.articleUnits))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
AreaMark(x: .value("Zeit", datum), y: .value("Bestand", p.articleUnits))
|
||||
.foregroundStyle(Color.accentColor.opacity(0.12))
|
||||
}
|
||||
}
|
||||
.frame(height: 170)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ein- und Auslagerungen (gruppierte Balken)
|
||||
|
||||
struct ActivityTimelineCard: View {
|
||||
let stand: Int
|
||||
|
||||
private struct Balken: Identifiable {
|
||||
let id = UUID()
|
||||
let datum: Date
|
||||
let richtung: String
|
||||
let wert: Int
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ChartLoader(laden: { try await APIClient.shared.activity(days: 30) }, stand: stand) { punkte in
|
||||
let balken: [Balken] = punkte.compactMap { p in
|
||||
DisplaySettings.parseTimestamp(p.at).map { d in [
|
||||
Balken(datum: d, richtung: "Eingelagert", wert: p.checkedIn),
|
||||
Balken(datum: d, richtung: "Ausgelagert", wert: p.checkedOut),
|
||||
] } ?? []
|
||||
}.flatMap { $0 }
|
||||
|
||||
return Chart(balken) { b in
|
||||
BarMark(x: .value("Tag", b.datum, unit: .day), y: .value("Anzahl", b.wert))
|
||||
.foregroundStyle(by: .value("Richtung", b.richtung))
|
||||
.position(by: .value("Richtung", b.richtung))
|
||||
}
|
||||
.chartForegroundStyleScale([
|
||||
"Eingelagert": Color.green, "Ausgelagert": Color.orange,
|
||||
])
|
||||
.frame(height: 170)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user