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:
@@ -199,6 +199,26 @@ actor APIClient {
|
||||
try await send(try makeRequest("/dashboard/layouts"), as: DashboardList.self)
|
||||
}
|
||||
|
||||
// MARK: - Diagrammdaten
|
||||
|
||||
func expirySplit() async throws -> ExpirySplit {
|
||||
try await send(try makeRequest("/dashboard/expiry-split"), as: ExpirySplit.self)
|
||||
}
|
||||
|
||||
func byCategory() async throws -> [CategoryShare] {
|
||||
try await send(try makeRequest("/dashboard/by-category"), as: [CategoryShare].self)
|
||||
}
|
||||
|
||||
func timeline(days: Int, productId: Int? = nil) async throws -> [TimelinePoint] {
|
||||
var path = "/dashboard/timeline?days=\(days)"
|
||||
if let productId { path += "&product_id=\(productId)" }
|
||||
return try await send(try makeRequest(path), as: [TimelinePoint].self)
|
||||
}
|
||||
|
||||
func activity(days: Int) async throws -> [ActivityPoint] {
|
||||
try await send(try makeRequest("/dashboard/activity?days=\(days)"), as: [ActivityPoint].self)
|
||||
}
|
||||
|
||||
/// Neueste zuerst. `productId` filtert auf einen Artikel.
|
||||
func movements(limit: Int = 100, productId: Int? = nil) async throws -> [Movement] {
|
||||
var path = "/movements?limit=\(limit)"
|
||||
|
||||
120
ios/Sources/AppDashboards.swift
Normal file
120
ios/Sources/AppDashboards.swift
Normal file
@@ -0,0 +1,120 @@
|
||||
import Foundation
|
||||
|
||||
/// Eine **eigene** Übersicht der App - unabhängig von den Web-Dashboards und nur
|
||||
/// auf diesem Gerät gespeichert. Je Server wählbar, ob die Web-Dashboards
|
||||
/// gespiegelt werden oder diese lokale Übersicht gilt.
|
||||
|
||||
/// Modus je Server.
|
||||
enum OverviewMode: String, Codable {
|
||||
case mirror // die Web-Dashboards spiegeln (Standard)
|
||||
case local // eigene App-Übersicht
|
||||
}
|
||||
|
||||
/// Eine Karte der lokalen Übersicht. Einspaltig - die Reihenfolge im Array
|
||||
/// bestimmt die Anordnung, deshalb kein Raster.
|
||||
struct AppCard: Codable, Identifiable, Equatable {
|
||||
let id: UUID
|
||||
var type: String
|
||||
var productId: Int?
|
||||
|
||||
init(id: UUID = UUID(), type: String, productId: Int? = nil) {
|
||||
self.id = id
|
||||
self.type = type
|
||||
self.productId = productId
|
||||
}
|
||||
}
|
||||
|
||||
struct AppDashboard: Codable, Identifiable, Equatable {
|
||||
let id: UUID
|
||||
var name: String
|
||||
var cards: [AppCard]
|
||||
|
||||
init(id: UUID = UUID(), name: String, cards: [AppCard] = []) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.cards = cards
|
||||
}
|
||||
|
||||
/// Sinnvolle Startkarten, wenn jemand die eigene Übersicht zum ersten Mal
|
||||
/// anlegt - eine brauchbare Zusammenstellung statt einer leeren Seite.
|
||||
static func standard() -> AppDashboard {
|
||||
AppDashboard(name: "Übersicht", cards: [
|
||||
AppCard(type: "status"),
|
||||
AppCard(type: "checkin-quick"),
|
||||
AppCard(type: "shopping"),
|
||||
AppCard(type: "expiry-all"),
|
||||
AppCard(type: "movements"),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
/// Was ein Server lokal an Übersicht hat: der Modus und die eigenen Dashboards.
|
||||
struct AppOverviewConfig: Codable, Equatable {
|
||||
var mode: OverviewMode = .mirror
|
||||
var dashboards: [AppDashboard] = []
|
||||
}
|
||||
|
||||
/// Ablage je Profil-UUID in den UserDefaults, wie `NotificationStore`.
|
||||
enum AppDashboardStore {
|
||||
static let key = "app_overviews"
|
||||
|
||||
static func loadAll() -> [String: AppOverviewConfig] {
|
||||
guard let data = UserDefaults.standard.data(forKey: key),
|
||||
let dict = try? JSONDecoder().decode([String: AppOverviewConfig].self, from: data)
|
||||
else { return [:] }
|
||||
return dict
|
||||
}
|
||||
|
||||
static func config(for profileID: UUID) -> AppOverviewConfig {
|
||||
loadAll()[profileID.uuidString] ?? AppOverviewConfig()
|
||||
}
|
||||
|
||||
static func save(_ config: AppOverviewConfig, for profileID: UUID) {
|
||||
var alle = loadAll()
|
||||
alle[profileID.uuidString] = config
|
||||
guard let data = try? JSONEncoder().encode(alle) else { return }
|
||||
UserDefaults.standard.set(data, forKey: key)
|
||||
}
|
||||
|
||||
static func remove(for profileID: UUID) {
|
||||
var alle = loadAll()
|
||||
alle.removeValue(forKey: profileID.uuidString)
|
||||
guard let data = try? JSONEncoder().encode(alle) else { return }
|
||||
UserDefaults.standard.set(data, forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
/// Verfügbare Kartenarten für die lokale Übersicht - Grundlage des Hinzufügen-Menüs.
|
||||
struct CardKind: Identifiable {
|
||||
let type: String
|
||||
let needsProduct: Bool
|
||||
var id: String { type }
|
||||
var title: String { CardCatalog.titel(type) }
|
||||
var isChart: Bool { CardCatalog.istDiagramm(type) }
|
||||
}
|
||||
|
||||
enum CardCatalogList {
|
||||
/// Reihenfolge wie sie im Hinzufügen-Menü erscheinen soll.
|
||||
static let all: [CardKind] = [
|
||||
CardKind(type: "status", needsProduct: false),
|
||||
CardKind(type: "kpi-products", needsProduct: false),
|
||||
CardKind(type: "kpi-units", needsProduct: false),
|
||||
CardKind(type: "checkin-quick", needsProduct: false),
|
||||
CardKind(type: "checkout-quick", needsProduct: false),
|
||||
CardKind(type: "actions", needsProduct: false),
|
||||
CardKind(type: "shopping", needsProduct: false),
|
||||
CardKind(type: "expiry-all", needsProduct: false),
|
||||
CardKind(type: "expiring", needsProduct: false),
|
||||
CardKind(type: "expired", needsProduct: false),
|
||||
CardKind(type: "movements", needsProduct: false),
|
||||
CardKind(type: "product-stock", needsProduct: true),
|
||||
CardKind(type: "product-timeline", needsProduct: true),
|
||||
CardKind(type: "expiry-donut", needsProduct: false),
|
||||
CardKind(type: "category-donut", needsProduct: false),
|
||||
CardKind(type: "category-bars", needsProduct: false),
|
||||
CardKind(type: "stock-timeline", needsProduct: false),
|
||||
CardKind(type: "activity-timeline", needsProduct: false),
|
||||
]
|
||||
|
||||
static func kind(for type: String) -> CardKind? { all.first { $0.type == type } }
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,84 +1,121 @@
|
||||
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 }
|
||||
get: { aktives?.id ?? 0 }, set: { auswahl = $0 }
|
||||
)) {
|
||||
ForEach(dashboards) { d in
|
||||
Text(d.name).tag(d.id)
|
||||
}
|
||||
ForEach(dashboards) { Text($0.name).tag($0.id) }
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(karten) { karte in
|
||||
Section {
|
||||
DashboardCardView(karte: karte, stand: stand) { stand += 1 }
|
||||
DashboardCardView(type: karte.type, productId: karte.props?.productId,
|
||||
stand: stand) { stand += 1 }
|
||||
} header: {
|
||||
Text(titel(fuer: karte))
|
||||
Text(CardCatalog.titel(karte.type))
|
||||
}
|
||||
}
|
||||
|
||||
if karten.isEmpty && !busy {
|
||||
Text("Dieses Dashboard hat keine Karten. Zusammenstellen lässt es sich in der Web-Oberfläche.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.navigationTitle(aktives?.name ?? session.activeProfile?.name ?? "Übersicht")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
ServerMenu(manageShown: $manageShown)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $manageShown) { ServerListView() }
|
||||
.refreshable { await laden() }
|
||||
.task(id: session.activeProfileID) { await laden() }
|
||||
}
|
||||
}
|
||||
|
||||
private func titel(fuer karte: DashboardCard) -> String {
|
||||
CardCatalog.titel(karte.type)
|
||||
}
|
||||
|
||||
private func laden() async {
|
||||
busy = true
|
||||
@@ -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")
|
||||
Text("Unbekannte Karte „\(type)“.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("Unbekannte Karte „\(karte.type)“.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
248
ios/Sources/LocalOverview.swift
Normal file
248
ios/Sources/LocalOverview.swift
Normal file
@@ -0,0 +1,248 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -516,6 +516,69 @@ struct DashboardList: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Diagrammdaten (Übersicht)
|
||||
|
||||
/// Artikeleinheiten je Ablaufzustand (GET /dashboard/expiry-split).
|
||||
struct ExpirySplit: Codable {
|
||||
let ok: Double
|
||||
let soon: Double
|
||||
let expired: Double
|
||||
let noDate: Double
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case ok, soon, expired
|
||||
case noDate = "no_date"
|
||||
}
|
||||
}
|
||||
|
||||
/// Anteil einer Kategorie am Bestand (GET /dashboard/by-category).
|
||||
struct CategoryShare: Codable, Identifiable {
|
||||
let categoryId: Int?
|
||||
let name: String
|
||||
let articleUnits: Double
|
||||
let ok: Double
|
||||
let soon: Double
|
||||
let expired: Double
|
||||
let noDate: Double
|
||||
|
||||
var id: Int { categoryId ?? -1 }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name, ok, soon, expired
|
||||
case categoryId = "category_id"
|
||||
case articleUnits = "article_units"
|
||||
case noDate = "no_date"
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Punkt des Bestandsverlaufs (GET /dashboard/timeline).
|
||||
struct TimelinePoint: Codable, Identifiable {
|
||||
let at: String
|
||||
let articleUnits: Double
|
||||
|
||||
var id: String { at }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case at
|
||||
case articleUnits = "article_units"
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein- und Auslagerungen je Zeitpunkt (GET /dashboard/activity).
|
||||
struct ActivityPoint: Codable, Identifiable {
|
||||
let at: String
|
||||
let checkedIn: Int
|
||||
let checkedOut: Int
|
||||
|
||||
var id: String { at }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case at
|
||||
case checkedIn = "checked_in"
|
||||
case checkedOut = "checked_out"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stammdaten
|
||||
|
||||
/// Gebinde (Packung, Glas, ...) mit Einzahl und Mehrzahl.
|
||||
|
||||
@@ -123,6 +123,7 @@ final class Session: ObservableObject {
|
||||
func removeProfile(id: UUID) {
|
||||
Keychain.delete(account: Session.keychainAccount(for: id))
|
||||
NotificationStore.remove(for: id)
|
||||
AppDashboardStore.remove(for: id)
|
||||
profiles.removeAll { $0.id == id }
|
||||
ProfileStore.save(profiles)
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6785CD9174067EFBB2B9043 /* ListViews.swift */; };
|
||||
7E2FA69E0C3BED650E91A7E9 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 099CCD94907BE0179B7D5742 /* AppIcon.icon */; };
|
||||
80B15C315FB4FEBD385A5EA1 /* ServerProfiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 121D44B9990DA931A9CD5847 /* ServerProfiles.swift */; };
|
||||
82A0C22D4AD228C36868853A /* LocalOverview.swift in Sources */ = {isa = PBXBuildFile; fileRef = C35D34107C8CBB8B7C6C6650 /* LocalOverview.swift */; };
|
||||
83F9AD06177F3BC95421F128 /* DashboardCards.swift in Sources */ = {isa = PBXBuildFile; fileRef = 090485CC54558EB4433376A9 /* DashboardCards.swift */; };
|
||||
87A34A269AAD85A19C3F4359 /* ServerListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA706060734632DE78FA1073 /* ServerListView.swift */; };
|
||||
926451E72FD13708C5DD657B /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6E6139CCD0B6C51B3919EE9 /* DashboardView.swift */; };
|
||||
@@ -34,7 +35,9 @@
|
||||
DFA55EF4ACA34537F445E998 /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2F9406BD0D4F0D30FED345 /* Session.swift */; };
|
||||
E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A75926511854BB8EE316ED3A /* LoginView.swift */; };
|
||||
ED61DAE4824165D6D87F8C1D /* DateScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 717C8EB336170526F5F3E695 /* DateScanView.swift */; };
|
||||
F3F718BFC60BF945CFA32BD2 /* AppDashboards.swift in Sources */ = {isa = PBXBuildFile; fileRef = 660ACEFA22BC77D52FBE736A /* AppDashboards.swift */; };
|
||||
F6C7E913CAB0FAF1826E6BD3 /* CheckOutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC0DDD06332E567E70CA842F /* CheckOutView.swift */; };
|
||||
FFD4DB783F89083C9098F1C4 /* ChartCards.swift in Sources */ = {isa = PBXBuildFile; fileRef = E580BA2815BB8F6341D64B47 /* ChartCards.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
@@ -51,6 +54,7 @@
|
||||
378F1B2DE567B62169E84426 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
|
||||
4741D0E95875919C921945CF /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
|
||||
4BF4E4F5524B15728CEEE116 /* Vorrania.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Vorrania.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
660ACEFA22BC77D52FBE736A /* AppDashboards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDashboards.swift; sourceTree = "<group>"; };
|
||||
6F574168AA0F849D46C384EE /* ProductDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailView.swift; sourceTree = "<group>"; };
|
||||
717C8EB336170526F5F3E695 /* DateScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateScanView.swift; sourceTree = "<group>"; };
|
||||
7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
|
||||
@@ -63,8 +67,10 @@
|
||||
AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BestBeforeText.swift; sourceTree = "<group>"; };
|
||||
AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplaySettings.swift; sourceTree = "<group>"; };
|
||||
AD2F9406BD0D4F0D30FED345 /* Session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Session.swift; sourceTree = "<group>"; };
|
||||
C35D34107C8CBB8B7C6C6650 /* LocalOverview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalOverview.swift; sourceTree = "<group>"; };
|
||||
CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPicker.swift; sourceTree = "<group>"; };
|
||||
DC0DDD06332E567E70CA842F /* CheckOutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckOutView.swift; sourceTree = "<group>"; };
|
||||
E580BA2815BB8F6341D64B47 /* ChartCards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartCards.swift; sourceTree = "<group>"; };
|
||||
E6E6139CCD0B6C51B3919EE9 /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = "<group>"; };
|
||||
EA706060734632DE78FA1073 /* ServerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListView.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
@@ -74,8 +80,10 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
378F1B2DE567B62169E84426 /* APIClient.swift */,
|
||||
660ACEFA22BC77D52FBE736A /* AppDashboards.swift */,
|
||||
AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */,
|
||||
CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */,
|
||||
E580BA2815BB8F6341D64B47 /* ChartCards.swift */,
|
||||
99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */,
|
||||
0365A16FEAE3F2BEC321E68A /* CheckInView.swift */,
|
||||
DC0DDD06332E567E70CA842F /* CheckOutView.swift */,
|
||||
@@ -85,6 +93,7 @@
|
||||
AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */,
|
||||
7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */,
|
||||
A6785CD9174067EFBB2B9043 /* ListViews.swift */,
|
||||
C35D34107C8CBB8B7C6C6650 /* LocalOverview.swift */,
|
||||
A75926511854BB8EE316ED3A /* LoginView.swift */,
|
||||
1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */,
|
||||
314B1BB7220691A7423E8929 /* Models.swift */,
|
||||
@@ -193,8 +202,10 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
39F1F17DCB4E491652D08DEE /* APIClient.swift in Sources */,
|
||||
F3F718BFC60BF945CFA32BD2 /* AppDashboards.swift in Sources */,
|
||||
A7017E6C0532A650439AED7B /* BestBeforeText.swift in Sources */,
|
||||
25FC3913EFFA37D013328FDE /* CategoryPicker.swift in Sources */,
|
||||
FFD4DB783F89083C9098F1C4 /* ChartCards.swift in Sources */,
|
||||
0E0AE6CC6FBA82B190BBC36F /* CheckInFormView.swift in Sources */,
|
||||
4F86620DF8303DB02314DB5A /* CheckInView.swift in Sources */,
|
||||
F6C7E913CAB0FAF1826E6BD3 /* CheckOutView.swift in Sources */,
|
||||
@@ -204,6 +215,7 @@
|
||||
D70CB183C868FF0143A6A5E7 /* DisplaySettings.swift in Sources */,
|
||||
4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */,
|
||||
7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */,
|
||||
82A0C22D4AD228C36868853A /* LocalOverview.swift in Sources */,
|
||||
E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */,
|
||||
96E5A2455B2EC26050E69232 /* MasterDataViews.swift in Sources */,
|
||||
6816C33381DF52A96E5BB303 /* Models.swift in Sources */,
|
||||
|
||||
Reference in New Issue
Block a user