iOS: mehrere Server, TabBar, Uebersicht, Verlauf und Stammdaten

Die App konnte fuenf Dinge, die Web-Oberflaeche vierzehn. Alles darueber hinaus
- nachsehen was gestern passiert ist, einen Lagerort anlegen - zwang zurueck an
den Browser.

Mehrere Server: Statt einer Adresse und einem Token kennt die App jetzt
beliebig viele Profile, jedes mit eigenem Token im Keychain. Ein Wechsel ist
damit ein Tipp und meldet an keinem der Server ab. Jedes Profil hat einen frei
waehlbaren Namen ("Zuhause"), der oben in der App steht; ohne Angabe faellt er
auf den Rechnernamen zurueck. Eine bestehende Anmeldung wird beim ersten Start
in ein Profil ueberfuehrt - ohne das waere man nach dem Update abgemeldet.

Statt einer Startseite mit Kacheln gibt es vier Tabs: Start, Scannen, Listen
und - nur fuer Administratoren, wie im Web - Verwaltung. Neu darin sind eine
Uebersicht mit den Kennzahlen des Servers, die per Tipp in die passende Liste
fuehren, der Bewegungsverlauf (auch je Artikel, weil /movements danach filtern
kann) und die Stammdaten: Lagerorte, Einheiten, Gebinde, Kategorien und
Gruppen. Die fuenf teilen sich eine Ansicht und unterscheiden sich nur darin,
wie geladen und geschrieben wird.

Zwei Dinge, die beim Pruefen gegen einen echten Server auffielen: Der Server
schickt Zeitstempel in UTC, haengt hinter SQLite aber keine Zeitzone an. Naiv
gelesen haette die App jede Uhrzeit um die eigene Zeitverschiebung daneben
angezeigt, deshalb wird die nackte Form ausdruecklich als UTC gelesen. Und die
Anzeigeeinstellungen gehoeren dem Server, werden beim Wechsel also neu geladen.

Bewusst nicht dabei: Benutzer, Einstellungen, Branding und Import/Export. Die
bleiben vorerst im Web.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-23 18:24:07 +02:00
parent 794a81f58b
commit ff158a1e5f
13 changed files with 1601 additions and 47 deletions

View File

@@ -179,8 +179,105 @@ actor APIClient {
} }
func deleteLot(id: Int) async throws { func deleteLot(id: Int) async throws {
let request = try makeRequest("/lots/\(id)", method: "DELETE") try await sendNoContent(try makeRequest("/lots/\(id)", method: "DELETE"))
}
/// Fuer Antworten ohne Rumpf (204) - dort gibt es nichts zu dekodieren.
private func sendNoContent(_ request: URLRequest) async throws {
let (data, response) = try await URLSession.shared.data(for: request) let (data, response) = try await URLSession.shared.data(for: request)
try check(response, data: data) // 204: kein Rumpf zum Auswerten try check(response, data: data)
}
// MARK: - Uebersicht und Verlauf
func dashboardStats() async throws -> DashboardStats {
try await send(try makeRequest("/dashboard/stats"), as: DashboardStats.self)
}
/// Neueste zuerst. `productId` filtert auf einen Artikel.
func movements(limit: Int = 100, productId: Int? = nil) async throws -> [Movement] {
var path = "/movements?limit=\(limit)"
if let productId { path += "&product_id=\(productId)" }
return try await send(try makeRequest(path), as: [Movement].self)
}
// MARK: - Stammdaten
func createLocation(_ payload: NewLocationRequest) async throws -> StorageLocation {
var request = try makeRequest("/locations", method: "POST")
try jsonBody(&request, payload)
return try await send(request, as: StorageLocation.self)
}
func renameLocation(id: Int, name: String) async throws -> StorageLocation {
var request = try makeRequest("/locations/\(id)", method: "PATCH")
try jsonBody(&request, RenameRequest(name: name))
return try await send(request, as: StorageLocation.self)
}
func deleteLocation(id: Int) async throws {
try await sendNoContent(try makeRequest("/locations/\(id)", method: "DELETE"))
}
func createUnit(_ payload: NewUnitRequest) async throws -> Unit {
var request = try makeRequest("/units", method: "POST")
try jsonBody(&request, payload)
return try await send(request, as: Unit.self)
}
func renameUnit(id: Int, name: String) async throws -> Unit {
var request = try makeRequest("/units/\(id)", method: "PATCH")
try jsonBody(&request, RenameRequest(name: name))
return try await send(request, as: Unit.self)
}
func deleteUnit(id: Int) async throws {
try await sendNoContent(try makeRequest("/units/\(id)", method: "DELETE"))
}
func packageTypes() async throws -> [PackageType] {
try await send(try makeRequest("/package-types"), as: [PackageType].self)
}
func createPackageType(_ payload: NewPackageTypeRequest) async throws -> PackageType {
var request = try makeRequest("/package-types", method: "POST")
try jsonBody(&request, payload)
return try await send(request, as: PackageType.self)
}
func updatePackageType(id: Int, _ payload: PackageTypeUpdateRequest) async throws -> PackageType {
var request = try makeRequest("/package-types/\(id)", method: "PATCH")
try jsonBody(&request, payload)
return try await send(request, as: PackageType.self)
}
func deletePackageType(id: Int) async throws {
try await sendNoContent(try makeRequest("/package-types/\(id)", method: "DELETE"))
}
func createCategory(_ payload: NewCategoryRequest) async throws -> CategoryItem {
var request = try makeRequest("/categories", method: "POST")
try jsonBody(&request, payload)
return try await send(request, as: CategoryItem.self)
}
func renameCategory(id: Int, name: String) async throws -> CategoryItem {
var request = try makeRequest("/categories/\(id)", method: "PATCH")
try jsonBody(&request, RenameRequest(name: name))
return try await send(request, as: CategoryItem.self)
}
func deleteCategory(id: Int) async throws {
try await sendNoContent(try makeRequest("/categories/\(id)", method: "DELETE"))
}
func renameGroup(id: Int, name: String) async throws -> GroupItem {
var request = try makeRequest("/groups/\(id)", method: "PATCH")
try jsonBody(&request, RenameRequest(name: name))
return try await send(request, as: GroupItem.self)
}
func deleteGroup(id: Int) async throws {
try await sendNoContent(try makeRequest("/groups/\(id)", method: "DELETE"))
} }
} }

View File

@@ -60,6 +60,60 @@ final class DisplaySettings: ObservableObject {
return formatter return formatter
}() }()
// MARK: - Zeitstempel
/// Zeitpunkte der API ("2026-07-23T16:20:59.510258").
///
/// Der Server erzeugt sie mit `datetime.now(timezone.utc)`, haengt an die
/// Antwort aber keine Zeitzone an, solange SQLite dahinter steht - Postgres
/// liefert dagegen ein Offset mit. Deshalb erst der Versuch mit Offset,
/// danach die nackte Form, die **als UTC** gelesen wird. Ohne das zeigte die
/// App die Uhrzeit um die eigene Zeitverschiebung daneben.
static func parseTimestamp(_ raw: String) -> Date? {
for optionen: ISO8601DateFormatter.Options in [
[.withInternetDateTime, .withFractionalSeconds], [.withInternetDateTime],
] {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = optionen
if let date = formatter.date(from: raw) { return date }
}
for muster in ["yyyy-MM-dd'T'HH:mm:ss.SSSSSS", "yyyy-MM-dd'T'HH:mm:ss"] {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(identifier: "UTC")
formatter.dateFormat = muster
if let date = formatter.date(from: raw) { return date }
}
return nil
}
/// Uhrzeit einer Bewegung ("17:22").
func formatTime(_ raw: String) -> String {
guard let date = DisplaySettings.parseTimestamp(raw) else { return "" }
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "de_DE")
formatter.dateFormat = "HH:mm"
return formatter.string(from: date)
}
/// Ueberschrift einer Tagesgruppe. "Heute" und "Gestern" sind schneller zu
/// erfassen als ein Datum.
func formatDay(_ raw: String) -> String {
guard let date = DisplaySettings.parseTimestamp(raw) else { return raw }
let kalender = Calendar.current
if kalender.isDateInToday(date) { return "Heute" }
if kalender.isDateInYesterday(date) { return "Gestern" }
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "de_DE")
formatter.dateFormat = DisplaySettings.pattern(for: dateFormat)
return formatter.string(from: date)
}
/// Tagesschluessel zum Gruppieren - unabhaengig von der Schreibweise.
static func dayKey(_ raw: String) -> String {
String(raw.prefix(10))
}
// MARK: - Einheiten // MARK: - Einheiten
/// Kanonische Basiseinheit der API auf Deutsch. /// Kanonische Basiseinheit der API auf Deutsch.

View File

@@ -0,0 +1,121 @@
import SwiftUI
/// Bewegungsverlauf: wer hat wann was ein- und ausgelagert.
///
/// Dieselbe Ansicht dient dem Gesamtverlauf und dem Verlauf eines einzelnen
/// Artikels - der Unterschied ist nur `productId`.
struct HistoryView: View {
@EnvironmentObject private var display: DisplaySettings
var productId: Int?
@State private var movements: [Movement] = []
@State private var limit = 100
@State private var busy = true
@State private var error: String?
var body: some View {
List {
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
ForEach(tage, id: \.key) { tag in
Section(display.formatDay(tag.eintraege[0].createdAt)) {
ForEach(tag.eintraege) { movement in
MovementRow(movement: movement, showProduct: productId == nil)
}
}
}
if movements.isEmpty && !busy {
Text("Noch keine Bewegungen.").foregroundStyle(.secondary)
}
// Nachladen erst anbieten, wenn die aktuelle Menge auch voll ist.
if movements.count >= limit {
Button("Mehr laden") {
limit += 100
Task { await load() }
}
}
}
.navigationTitle("Verlauf")
.navigationBarTitleDisplayMode(.inline)
.refreshable { await load() }
.task { await load() }
}
private struct Tag {
let key: String
let eintraege: [Movement]
}
/// Nach Tagen gruppiert, neueste zuerst. Die API liefert bereits sortiert,
/// deshalb reicht es, der Reihe nach zu sammeln.
private var tage: [Tag] {
var result: [Tag] = []
for movement in movements {
let key = DisplaySettings.dayKey(movement.createdAt)
if let last = result.last, last.key == key {
result[result.count - 1] = Tag(key: key, eintraege: last.eintraege + [movement])
} else {
result.append(Tag(key: key, eintraege: [movement]))
}
}
return result
}
private func load() async {
busy = true
defer { busy = false }
do {
movements = try await APIClient.shared.movements(limit: limit, productId: productId)
error = nil
} catch {
self.error = error.localizedDescription
}
}
}
struct MovementRow: View {
@EnvironmentObject private var display: DisplaySettings
let movement: Movement
var showProduct = true
var body: some View {
HStack(spacing: 12) {
Image(systemName: movement.isIncoming ? "arrow.down.circle.fill" : "arrow.up.circle.fill")
.font(.title3)
.foregroundStyle(movement.isIncoming ? Color.green : Color.orange)
VStack(alignment: .leading, spacing: 2) {
if showProduct {
Text(movement.productName)
}
Text(menge)
.font(showProduct ? .caption : .body)
.foregroundStyle(showProduct ? .secondary : .primary)
if let note = movement.note, !note.isEmpty {
Text(note).font(.caption2).foregroundStyle(.secondary)
}
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(display.formatTime(movement.createdAt))
.font(.caption).foregroundStyle(.secondary)
if let user = movement.username, !user.isEmpty {
Text(user).font(.caption2).foregroundStyle(.tertiary)
}
}
}
}
/// Vorzeichen macht die Richtung auch ohne Farbe lesbar.
private var menge: String {
let zeichen = movement.isIncoming ? "+" : ""
return zeichen + display.amountText(movement.quantity,
packageSize: movement.packageSize,
baseUnit: movement.baseUnit)
}
}

View File

@@ -3,12 +3,13 @@ import SwiftUI
struct LoginView: View { struct LoginView: View {
@EnvironmentObject private var session: Session @EnvironmentObject private var session: Session
@State private var server: String = Session.shared.baseURL?.absoluteString ?? "" @State private var server: String = ""
@State private var username = "" @State private var username = ""
@State private var password = "" @State private var password = ""
@State private var error: String? @State private var error: String?
@State private var busy = false @State private var busy = false
@State private var stayLoggedIn = Session.shared.stayLoggedIn @State private var stayLoggedIn = Session.shared.stayLoggedIn
@State private var manageShown = false
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -18,6 +19,15 @@ struct LoginView: View {
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
.keyboardType(.URL) .keyboardType(.URL)
// Mit mehreren Servern ist das hier der Weg zurueck, wenn man
// am falschen gelandet ist.
if session.profiles.count > 1 {
Picker("Ausgewählt", selection: profileSelection) {
ForEach(session.profiles) { profile in
Text(profile.name).tag(profile.id)
}
}
}
} }
Section("Anmeldung") { Section("Anmeldung") {
TextField("Benutzername", text: $username) TextField("Benutzername", text: $username)
@@ -40,8 +50,31 @@ struct LoginView: View {
.disabled(busy || server.isEmpty || username.isEmpty) .disabled(busy || server.isEmpty || username.isEmpty)
} }
} }
.navigationTitle("Vorrania") .navigationTitle(session.activeProfile?.name ?? "Vorrania")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Server verwalten", systemImage: "server.rack") { manageShown = true }
}
}
.sheet(isPresented: $manageShown) { ServerListView() }
} }
// Beim Start und bei jedem Serverwechsel die Felder auf das aktive
// Profil setzen - sonst steht die Adresse des vorigen Servers da.
.task(id: session.activeProfileID) { syncFromProfile() }
}
private var profileSelection: Binding<UUID> {
Binding(
get: { session.activeProfileID ?? session.profiles.first?.id ?? UUID() },
set: { session.switchTo($0) }
)
}
private func syncFromProfile() {
server = session.activeProfile?.urlString ?? ""
username = session.activeProfile?.username ?? ""
password = ""
error = nil
} }
private func login() async { private func login() async {

View File

@@ -0,0 +1,552 @@
import SwiftUI
/// Verwaltungs-Tab: alles, was Stammdaten sind, plus die Serverliste.
struct AdminTabView: View {
@EnvironmentObject private var session: Session
@State private var manageShown = false
var body: some View {
NavigationStack {
List {
Section("Stammdaten") {
NavigationLink { LocationsView() } label: {
Label("Lagerorte", systemImage: "mappin.and.ellipse")
}
NavigationLink { UnitsView() } label: {
Label("Einheiten", systemImage: "ruler")
}
NavigationLink { PackageTypesView() } label: {
Label("Gebinde", systemImage: "shippingbox")
}
NavigationLink { CategoriesView() } label: {
Label("Kategorien", systemImage: "folder")
}
NavigationLink { GroupsView() } label: {
Label("Gruppen", systemImage: "square.stack.3d.up")
}
}
Section("App") {
Button {
manageShown = true
} label: {
Label("Server verwalten", systemImage: "server.rack")
}
}
Section {
Button("Abmelden", role: .destructive) { session.logout() }
} footer: {
Text("Benutzer, Einstellungen und Sicherungen bleiben der Web-Oberfläche vorbehalten.")
}
}
.navigationTitle("Verwaltung")
.sheet(isPresented: $manageShown) { ServerListView() }
}
}
}
// MARK: - Gemeinsamer Bauplan
/// Ein Eintrag, wie ihn die Stammdatenliste braucht - unabhaengig davon, ob
/// dahinter ein Lagerort, eine Einheit oder ein Gebinde steckt.
struct MasterDataItem: Identifiable, Equatable {
let id: Int
let title: String
let subtitle: String
/// Eingebautes laesst sich umbenennen, aber nicht loeschen.
let isBuiltin: Bool
}
/// Liste mit Anlegen, Umbenennen und Loeschen.
///
/// Alle fuenf Stammdatenbereiche sehen gleich aus und unterscheiden sich nur
/// darin, *wie* geladen und geschrieben wird. Genau das wird hier
/// hereingereicht, statt die Ansicht fuenfmal zu schreiben.
struct MasterDataListView<Editor: View>: View {
let title: String
/// Einzahl fuer Meldungen ("Lagerort").
let singular: String
let load: () async throws -> [MasterDataItem]
let delete: ((Int) async throws -> Void)?
/// Baut den Editor zum Anlegen (`nil`) oder Bearbeiten eines Eintrags.
@ViewBuilder let editor: (MasterDataItem?, @escaping () -> Void) -> Editor
@State private var items: [MasterDataItem] = []
@State private var busy = true
@State private var error: String?
@State private var editing: MasterDataItem?
@State private var addShown = false
@State private var pendingDeletion: MasterDataItem?
var body: some View {
List {
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
ForEach(items) { item in
Button {
editing = item
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(item.title).foregroundStyle(.primary)
if !item.subtitle.isEmpty {
Text(item.subtitle).font(.caption).foregroundStyle(.secondary)
}
}
Spacer()
if item.isBuiltin {
Text("eingebaut").font(.caption2).foregroundStyle(.tertiary)
}
}
}
.swipeActions(edge: .trailing) {
// Eingebautes bekommt gar nicht erst einen Loeschknopf.
if delete != nil && !item.isBuiltin {
Button("Löschen", role: .destructive) { pendingDeletion = item }
}
}
}
if items.isEmpty && !busy {
Text("Noch nichts angelegt.").foregroundStyle(.secondary)
}
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Hinzufügen", systemImage: "plus") { addShown = true }
}
}
.sheet(isPresented: $addShown) {
editor(nil) { Task { await reload() } }
}
.sheet(item: $editing) { item in
editor(item) { Task { await reload() } }
}
.confirmationDialog(
pendingDeletion.map { "\($0.title)“ löschen?" } ?? "",
isPresented: Binding(get: { pendingDeletion != nil },
set: { if !$0 { pendingDeletion = nil } }),
titleVisibility: .visible
) {
Button("Löschen", role: .destructive) {
if let item = pendingDeletion { Task { await remove(item) } }
pendingDeletion = nil
}
Button("Abbrechen", role: .cancel) { pendingDeletion = nil }
} message: {
Text("Wird \(singular.lowercased()) noch verwendet, lehnt der Server das Löschen ab.")
}
.refreshable { await reload() }
.task { await reload() }
}
private func reload() async {
busy = true
defer { busy = false }
do {
items = try await load()
error = nil
} catch {
self.error = error.localizedDescription
}
}
private func remove(_ item: MasterDataItem) async {
guard let delete else { return }
do {
try await delete(item.id)
error = nil
await reload()
} catch {
// Der Server begruendet die Ablehnung ("wird noch verwendet") -
// die Meldung unveraendert zeigen.
self.error = error.localizedDescription
}
}
}
/// Kleiner Editor fuer alles, was nur einen Namen hat.
struct NameEditor: View {
@Environment(\.dismiss) private var dismiss
let title: String
let label: String
let initial: String
let save: (String) async throws -> Void
let done: () -> Void
@State private var name: String
@State private var error: String?
@State private var busy = false
init(title: String, label: String = "Name", initial: String = "",
save: @escaping (String) async throws -> Void, done: @escaping () -> Void) {
self.title = title
self.label = label
self.initial = initial
self.save = save
self.done = done
_name = State(initialValue: initial)
}
var body: some View {
NavigationStack {
Form {
Section(label) {
TextField(label, text: $name)
}
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Abbrechen") { dismiss() }
}
ToolbarItem(placement: .topBarTrailing) {
Button(busy ? "Sichern…" : "Sichern") { Task { await submit() } }
.disabled(busy || name.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
}
}
private func submit() async {
busy = true
defer { busy = false }
do {
try await save(name.trimmingCharacters(in: .whitespaces))
done()
dismiss()
} catch {
self.error = error.localizedDescription
}
}
}
// MARK: - Die fuenf Bereiche
struct LocationsView: View {
var body: some View {
MasterDataListView(
title: "Lagerorte",
singular: "Der Lagerort",
load: {
try await APIClient.shared.locations().map {
MasterDataItem(id: $0.id, title: $0.name, subtitle: "", isBuiltin: false)
}
},
delete: { try await APIClient.shared.deleteLocation(id: $0) }
) { item, done in
NameEditor(
title: item == nil ? "Lagerort anlegen" : "Lagerort umbenennen",
initial: item?.title ?? "",
save: { name in
if let item {
_ = try await APIClient.shared.renameLocation(id: item.id, name: name)
} else {
_ = try await APIClient.shared.createLocation(
NewLocationRequest(name: name, parentId: nil))
}
},
done: done
)
}
}
}
struct UnitsView: View {
var body: some View {
MasterDataListView(
title: "Einheiten",
singular: "Die Einheit",
load: {
try await APIClient.shared.units().map {
MasterDataItem(id: $0.id, title: $0.name,
subtitle: "\(UnitsView.artText($0.kind)) · Faktor \(formatAmount($0.factor))",
isBuiltin: $0.isBuiltin)
}
},
delete: { try await APIClient.shared.deleteUnit(id: $0) }
) { item, done in
if let item {
NameEditor(
title: "Einheit umbenennen",
initial: item.title,
save: { _ = try await APIClient.shared.renameUnit(id: item.id, name: $0) },
done: done
)
} else {
NewUnitEditor(done: done)
}
}
}
private static func artText(_ kind: String) -> String {
switch kind {
case "count": return "Anzahl"
case "weight": return "Gewicht"
case "volume": return "Volumen"
default: return kind
}
}
}
/// Einheiten brauchen beim Anlegen Art und Faktor - danach stehen beide fest,
/// weil sie in bereits umgerechneten Bestaenden stecken.
struct NewUnitEditor: View {
@Environment(\.dismiss) private var dismiss
let done: () -> Void
@State private var name = ""
@State private var kind = "weight"
@State private var factor = "1"
@State private var error: String?
@State private var busy = false
private var faktorWert: Double? {
Double(factor.replacingOccurrences(of: ",", with: "."))
}
var body: some View {
NavigationStack {
Form {
Section("Name") {
TextField("z. B. Beutel", text: $name)
}
Section("Art") {
Picker("Art", selection: $kind) {
Text("Anzahl").tag("count")
Text("Gewicht").tag("weight")
Text("Volumen").tag("volume")
}
.pickerStyle(.segmented)
}
Section {
TextField("1", text: $factor).keyboardType(.decimalPad)
} header: {
Text("Faktor")
} footer: {
Text("Wie viele Basiseinheiten ergibt eine Einheit? Ein Kilogramm hat den Faktor 1000, weil die Basiseinheit Gramm ist.")
}
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
}
.navigationTitle("Einheit anlegen")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Abbrechen") { dismiss() }
}
ToolbarItem(placement: .topBarTrailing) {
Button("Sichern") { Task { await submit() } }
.disabled(busy || name.isEmpty || (faktorWert ?? 0) <= 0)
}
}
}
}
private func submit() async {
guard let wert = faktorWert else { return }
busy = true
defer { busy = false }
do {
_ = try await APIClient.shared.createUnit(
NewUnitRequest(name: name.trimmingCharacters(in: .whitespaces),
kind: kind, factor: wert))
done()
dismiss()
} catch {
self.error = error.localizedDescription
}
}
}
struct PackageTypesView: View {
var body: some View {
MasterDataListView(
title: "Gebinde",
singular: "Das Gebinde",
load: {
try await APIClient.shared.packageTypes().map {
MasterDataItem(id: $0.id, title: $0.singular,
subtitle: "Mehrzahl: \($0.plural)", isBuiltin: $0.isBuiltin)
}
},
delete: { try await APIClient.shared.deletePackageType(id: $0) }
) { item, done in
PackageTypeEditor(id: item?.id,
singular: item?.title ?? "",
plural: item.map { PackageTypesView.plural(from: $0.subtitle) } ?? "",
done: done)
}
}
/// Der Untertitel traegt die Mehrzahl - hier wieder heraus.
private static func plural(from subtitle: String) -> String {
subtitle.replacingOccurrences(of: "Mehrzahl: ", with: "")
}
}
/// Gebinde haben Einzahl **und** Mehrzahl (siehe Commit "Gebinde verwalten").
struct PackageTypeEditor: View {
@Environment(\.dismiss) private var dismiss
let id: Int?
let done: () -> Void
@State private var singular: String
@State private var plural: String
@State private var error: String?
@State private var busy = false
init(id: Int?, singular: String, plural: String, done: @escaping () -> Void) {
self.id = id
self.done = done
_singular = State(initialValue: singular)
_plural = State(initialValue: plural)
}
var body: some View {
NavigationStack {
Form {
Section("Einzahl") {
TextField("z. B. Glas", text: $singular)
}
Section {
TextField(singular.isEmpty ? "z. B. Gläser" : singular, text: $plural)
} header: {
Text("Mehrzahl")
} footer: {
Text("Ohne Angabe wird die Einzahl verwendet.")
}
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
}
.navigationTitle(id == nil ? "Gebinde anlegen" : "Gebinde bearbeiten")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Abbrechen") { dismiss() }
}
ToolbarItem(placement: .topBarTrailing) {
Button("Sichern") { Task { await submit() } }
.disabled(busy || singular.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
}
}
private func submit() async {
busy = true
defer { busy = false }
let einzahl = singular.trimmingCharacters(in: .whitespaces)
let mehrzahl = plural.trimmingCharacters(in: .whitespaces).isEmpty
? einzahl : plural.trimmingCharacters(in: .whitespaces)
do {
if let id {
_ = try await APIClient.shared.updatePackageType(
id: id, PackageTypeUpdateRequest(singular: einzahl, plural: mehrzahl))
} else {
_ = try await APIClient.shared.createPackageType(
NewPackageTypeRequest(singular: einzahl, plural: mehrzahl))
}
done()
dismiss()
} catch {
self.error = error.localizedDescription
}
}
}
struct CategoriesView: View {
var body: some View {
MasterDataListView(
title: "Kategorien",
singular: "Die Kategorie",
load: {
let alle = try await APIClient.shared.categories()
// Baumreihenfolge mit Einrueckung, damit die Verschachtelung
// sichtbar bleibt (wie in der Produktliste).
return CategoriesView.tree(alle).map {
MasterDataItem(id: $0.item.id,
title: String(repeating: " ", count: $0.depth) + $0.item.name,
subtitle: "", isBuiltin: false)
}
},
delete: { try await APIClient.shared.deleteCategory(id: $0) }
) { item, done in
NameEditor(
title: item == nil ? "Kategorie anlegen" : "Kategorie umbenennen",
initial: item?.title.trimmingCharacters(in: .whitespaces) ?? "",
save: { name in
if let item {
_ = try await APIClient.shared.renameCategory(id: item.id, name: name)
} else {
_ = try await APIClient.shared.createCategory(
NewCategoryRequest(name: name, parentId: nil))
}
},
done: done
)
}
}
private struct Node {
let item: CategoryItem
let depth: Int
}
private static func tree(_ categories: [CategoryItem]) -> [Node] {
let ids = Set(categories.map(\.id))
var result: [Node] = []
func walk(_ node: CategoryItem, _ depth: Int) {
result.append(Node(item: node, depth: depth))
for child in categories.filter({ $0.parentId == node.id }) {
walk(child, depth + 1)
}
}
// Waisen (Oberkategorie geloescht) gelten als oberste Ebene.
for root in categories.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) {
walk(root, 0)
}
return result
}
}
struct GroupsView: View {
var body: some View {
MasterDataListView(
title: "Gruppen",
singular: "Die Gruppe",
load: {
try await APIClient.shared.groups().map {
MasterDataItem(id: $0.id, title: $0.name, subtitle: "", isBuiltin: false)
}
},
delete: { try await APIClient.shared.deleteGroup(id: $0) }
) { item, done in
NameEditor(
title: item == nil ? "Gruppe anlegen" : "Gruppe umbenennen",
initial: item?.title ?? "",
save: { name in
if let item {
_ = try await APIClient.shared.renameGroup(id: item.id, name: name)
} else {
_ = try await APIClient.shared.createGroup(
NewGroupRequest(name: name, minStock: nil, minStockUnitId: nil))
}
},
done: done
)
}
}
}

View File

@@ -26,6 +26,13 @@ struct Unit: Codable, Identifiable, Hashable {
let name: String let name: String
let kind: String let kind: String
let factor: Double let factor: Double
/// Eingebaute Einheiten lassen sich umbenennen, aber nicht loeschen.
let isBuiltin: Bool
enum CodingKeys: String, CodingKey {
case id, name, kind, factor
case isBuiltin = "is_builtin"
}
} }
struct BarcodeEntry: Codable, Identifiable, Hashable { struct BarcodeEntry: Codable, Identifiable, Hashable {
@@ -404,6 +411,119 @@ struct CategoryItem: Codable, Identifiable, Hashable {
} }
} }
// MARK: - Uebersicht und Verlauf
/// Kennzahlen der Startseite (GET /dashboard/stats).
struct DashboardStats: Codable {
let productsInStock: Int
let articleUnits: Double
let expiringSoon: Int
let expired: Int
let shoppingItems: Int
let productsTotal: Int
enum CodingKeys: String, CodingKey {
case productsInStock = "products_in_stock"
case articleUnits = "article_units"
case expiringSoon = "expiring_soon"
case expired
case shoppingItems = "shopping_items"
case productsTotal = "products_total"
}
}
/// Ein Eintrag im Bewegungsverlauf (GET /movements).
struct Movement: Codable, Identifiable, Hashable {
let id: Int
let productId: Int
let productName: String
/// "in" oder "out"
let type: String
/// in Basiseinheiten
let quantity: Double
let baseUnit: String
let unitUsed: String
let username: String?
let note: String?
let createdAt: String
let packageSize: Double?
let packageLabel: String?
let unitName: String
let unitFactor: Double
enum CodingKeys: String, CodingKey {
case id, type, quantity, username, note
case productId = "product_id"
case productName = "product_name"
case baseUnit = "base_unit"
case unitUsed = "unit_used"
case createdAt = "created_at"
case packageSize = "package_size"
case packageLabel = "package_label"
case unitName = "unit_name"
case unitFactor = "unit_factor"
}
var isIncoming: Bool { type == "in" }
}
// MARK: - Stammdaten
/// Gebinde (Packung, Glas, ...) mit Einzahl und Mehrzahl.
struct PackageType: Codable, Identifiable, Hashable {
let id: Int
let singular: String
let plural: String
let isBuiltin: Bool
enum CodingKeys: String, CodingKey {
case id, singular, plural
case isBuiltin = "is_builtin"
}
}
struct NewLocationRequest: Codable {
let name: String
let parentId: Int?
enum CodingKeys: String, CodingKey {
case name
case parentId = "parent_id"
}
}
struct NewUnitRequest: Codable {
let name: String
let kind: String
let factor: Double
}
struct NewPackageTypeRequest: Codable {
let singular: String
let plural: String
}
struct NewCategoryRequest: Codable {
let name: String
let parentId: Int?
enum CodingKeys: String, CodingKey {
case name
case parentId = "parent_id"
}
}
/// Umbenennen von Stammdaten. Ein einzelnes Feld reicht, weil das Backend nur
/// mitgeschickte Schluessel auswertet.
struct RenameRequest: Codable {
let name: String
}
struct PackageTypeUpdateRequest: Codable {
let singular: String
let plural: String
}
/// Neue Gruppe anlegen (nur fuer Administratoren). /// Neue Gruppe anlegen (nur fuer Administratoren).
struct NewGroupRequest: Codable { struct NewGroupRequest: Codable {
let name: String let name: String

View File

@@ -0,0 +1,123 @@
import SwiftUI
/// Startseite: Kennzahlen des Servers und die letzten Bewegungen.
///
/// Bewusst ohne Diagramme - die Kennzahl selbst ist die Antwort, und jede
/// fuehrt per Tipp dorthin, wo man etwas tun kann.
struct OverviewView: View {
@EnvironmentObject private var session: Session
@EnvironmentObject private var display: DisplaySettings
@State private var stats: DashboardStats?
@State private var letzte: [Movement] = []
@State private var busy = true
@State private var error: String?
@State private var manageShown = false
private let spalten = [GridItem(.flexible()), GridItem(.flexible())]
var body: some View {
NavigationStack {
List {
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
Section {
LazyVGrid(columns: spalten, spacing: 12) {
NavigationLink { ProductListView() } label: {
StatTile(title: "Artikel mit Bestand",
value: stats.map { String($0.productsInStock) } ?? "",
detail: stats.map { "von \($0.productsTotal)" } ?? "",
systemImage: "shippingbox", tint: .accentColor)
}
NavigationLink { ShoppingListView() } label: {
StatTile(title: "Einzukaufen",
value: stats.map { String($0.shoppingItems) } ?? "",
detail: "unter Mindestbestand",
systemImage: "cart",
tint: (stats?.shoppingItems ?? 0) > 0 ? .orange : .secondary)
}
NavigationLink { ExpiringView() } label: {
StatTile(title: "Läuft bald ab",
value: stats.map { String($0.expiringSoon) } ?? "",
detail: "Chargen",
systemImage: "clock",
tint: (stats?.expiringSoon ?? 0) > 0 ? .orange : .secondary)
}
NavigationLink { ExpiringView() } label: {
StatTile(title: "Abgelaufen",
value: stats.map { String($0.expired) } ?? "",
detail: "Chargen",
systemImage: "exclamationmark.triangle",
tint: (stats?.expired ?? 0) > 0 ? .red : .secondary)
}
}
.buttonStyle(.plain)
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8))
}
Section {
ForEach(letzte) { movement in
MovementRow(movement: movement)
}
if letzte.isEmpty && !busy {
Text("Noch keine Bewegungen.").foregroundStyle(.secondary)
}
NavigationLink("Ganzen Verlauf ansehen") { HistoryView() }
} header: {
Text("Letzte Bewegungen")
}
}
.navigationTitle(session.activeProfile?.name ?? "Vorrania")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
ServerMenu(manageShown: $manageShown)
}
}
.sheet(isPresented: $manageShown) { ServerListView() }
.refreshable { await load() }
.task(id: session.activeProfileID) { await load() }
}
}
private func load() async {
busy = true
defer { busy = false }
do {
stats = try await APIClient.shared.dashboardStats()
letzte = try await APIClient.shared.movements(limit: 10)
error = nil
} catch {
self.error = error.localizedDescription
}
}
}
/// Eine Kennzahl als Kachel.
struct StatTile: View {
let title: String
let value: String
let detail: String
let systemImage: String
let tint: Color
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Image(systemName: systemImage).font(.caption).foregroundStyle(tint)
Text(title).font(.caption).foregroundStyle(.secondary)
.lineLimit(1).minimumScaleFactor(0.8)
}
Text(value).font(.title).bold().foregroundStyle(tint)
if !detail.isEmpty {
Text(detail).font(.caption2).foregroundStyle(.tertiary)
.lineLimit(1).minimumScaleFactor(0.8)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}

View File

@@ -120,6 +120,14 @@ struct ProductDetailView: View {
Text("Keine Chargen im Bestand.").foregroundStyle(.secondary) Text("Keine Chargen im Bestand.").foregroundStyle(.secondary)
} }
} }
Section {
// Derselbe Verlauf wie im Listen-Tab, nur auf diesen Artikel
// gefiltert.
NavigationLink { HistoryView(productId: current.id) } label: {
Label("Verlauf dieses Artikels", systemImage: "clock.arrow.circlepath")
}
}
} }
.navigationTitle(current.name) .navigationTitle(current.name)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)

View File

@@ -13,9 +13,14 @@ struct RootView: View {
LoginView() LoginView()
} }
} }
// Anzeigeeinstellungen (Datumsformat) gehoeren dem Server. // Anzeigeeinstellungen (Datumsformat) gehoeren dem Server - bei einem
.task(id: session.isLoggedIn) { // Serverwechsel muessen sie deshalb neu geladen werden, sonst zeigt
if session.isLoggedIn { await display.load() } // Server B die Formate von Server A.
.task(id: session.activeProfileID) {
if session.isLoggedIn {
await display.load()
await session.refreshMe()
}
} }
// Shortcut/URL öffnet den passenden Scan-Bildschirm direkt. // Shortcut/URL öffnet den passenden Scan-Bildschirm direkt.
.fullScreenCover(item: $router.route) { route in .fullScreenCover(item: $router.route) { route in
@@ -35,6 +40,30 @@ extension Route: Identifiable {
struct HomeView: View { struct HomeView: View {
@EnvironmentObject private var session: Session @EnvironmentObject private var session: Session
var body: some View {
TabView {
OverviewView()
.tabItem { Label("Start", systemImage: "house") }
ScanTabView()
.tabItem { Label("Scannen", systemImage: "barcode.viewfinder") }
ListsTabView()
.tabItem { Label("Listen", systemImage: "list.bullet") }
// Stammdaten gehoeren Administratoren - dieselbe Regel wie in der
// Web-Oberflaeche (web/src/App.jsx).
if session.isAdmin {
AdminTabView()
.tabItem { Label("Verwaltung", systemImage: "gearshape") }
}
}
}
}
/// Die beiden Kacheln, die frueher die Startseite waren.
struct ScanTabView: View {
@EnvironmentObject private var router: Router @EnvironmentObject private var router: Router
var body: some View { var body: some View {
@@ -54,6 +83,24 @@ struct HomeView: View {
ActionTile(title: "Auslagern", subtitle: "Barcode scannen und Menge entnehmen", ActionTile(title: "Auslagern", subtitle: "Barcode scannen und Menge entnehmen",
systemImage: "arrow.up.to.line") systemImage: "arrow.up.to.line")
} }
Spacer()
}
.padding()
.navigationTitle("Scannen")
}
}
}
struct ListsTabView: View {
var body: some View {
NavigationStack {
VStack(spacing: 16) {
NavigationLink {
ProductListView()
} label: {
ActionTile(title: "Produkte", subtitle: "Suchen, ansehen und bearbeiten",
systemImage: "shippingbox")
}
NavigationLink { NavigationLink {
ShoppingListView() ShoppingListView()
} label: { } label: {
@@ -67,25 +114,15 @@ struct HomeView: View {
systemImage: "clock") systemImage: "clock")
} }
NavigationLink { NavigationLink {
ProductListView() HistoryView()
} label: { } label: {
ActionTile(title: "Produkte", subtitle: "Suchen, ansehen und bearbeiten", ActionTile(title: "Verlauf", subtitle: "Wer hat wann was ein- und ausgelagert",
systemImage: "list.bullet") systemImage: "clock.arrow.circlepath")
} }
Spacer() Spacer()
} }
.padding() .padding()
.navigationTitle("Vorrania") .navigationTitle("Listen")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Text("Angemeldet als \(session.username)")
Button("Abmelden", role: .destructive) { session.logout() }
} label: {
Image(systemName: "person.circle")
}
}
}
} }
} }
} }

View File

@@ -0,0 +1,187 @@
import SwiftUI
/// Wechselt den Server aus der Werkzeugleiste heraus - der schnelle Weg.
/// Die vollstaendige Verwaltung liegt in `ServerListView`.
struct ServerMenu: View {
@EnvironmentObject private var session: Session
@Binding var manageShown: Bool
var body: some View {
Menu {
if session.profiles.count > 1 {
Section("Server") {
ForEach(session.profiles) { profile in
Button {
guard profile.id != session.activeProfileID else { return }
session.switchTo(profile.id)
} label: {
// Der Haken zeigt, wo man gerade ist. Ein leerer
// Symbolname wuerde eine kaputte Lucke hinterlassen,
// deshalb zwei getrennte Beschriftungen.
if profile.id == session.activeProfileID {
Label(profile.name, systemImage: "checkmark")
} else {
Text(profile.name)
}
}
}
}
}
Button("Server verwalten…", systemImage: "server.rack") { manageShown = true }
if session.isLoggedIn {
Divider()
Text("Angemeldet als \(session.username)")
Button("Abmelden", role: .destructive) { session.logout() }
}
} label: {
Image(systemName: "person.circle")
}
}
}
struct ServerListView: View {
@EnvironmentObject private var session: Session
@Environment(\.dismiss) private var dismiss
@State private var editing: ServerProfile?
@State private var addShown = false
@State private var pendingDeletion: ServerProfile?
var body: some View {
NavigationStack {
List {
Section {
ForEach(session.profiles) { profile in
Button {
session.switchTo(profile.id)
dismiss()
} label: {
row(for: profile)
}
.swipeActions(edge: .trailing) {
Button("Löschen", role: .destructive) { pendingDeletion = profile }
Button("Bearbeiten") { editing = profile }.tint(.gray)
}
}
} footer: {
Text("Jeder Server merkt sich seine eigene Anmeldung. Ein Wechsel meldet dich nicht ab.")
}
if session.profiles.isEmpty {
Text("Noch kein Server hinterlegt.")
.foregroundStyle(.secondary)
}
}
.navigationTitle("Server")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Hinzufügen", systemImage: "plus") { addShown = true }
}
ToolbarItem(placement: .topBarLeading) {
Button("Fertig") { dismiss() }
}
}
.sheet(isPresented: $addShown) {
ServerEditView(profile: nil)
}
.sheet(item: $editing) { profile in
ServerEditView(profile: profile)
}
.confirmationDialog(
pendingDeletion.map { "\($0.name)“ löschen?" } ?? "",
isPresented: Binding(get: { pendingDeletion != nil },
set: { if !$0 { pendingDeletion = nil } }),
titleVisibility: .visible
) {
Button("Löschen", role: .destructive) {
if let profile = pendingDeletion { session.removeProfile(id: profile.id) }
pendingDeletion = nil
}
Button("Abbrechen", role: .cancel) { pendingDeletion = nil }
} message: {
Text("Die gespeicherte Anmeldung für diesen Server wird mit entfernt. Auf dem Server selbst ändert sich nichts.")
}
}
}
private func row(for profile: ServerProfile) -> some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(profile.name).font(.body)
Text(profile.urlString).font(.caption).foregroundStyle(.secondary)
.lineLimit(1).truncationMode(.middle)
}
Spacer()
if profile.id == session.activeProfileID {
Image(systemName: "checkmark").foregroundStyle(Color.accentColor)
}
}
.foregroundStyle(.primary)
}
}
/// Anlegen und Bearbeiten in einem - der Unterschied ist nur, ob schon ein
/// Profil hereingereicht wurde.
struct ServerEditView: View {
@EnvironmentObject private var session: Session
@Environment(\.dismiss) private var dismiss
let profile: ServerProfile?
@State private var name: String
@State private var urlString: String
init(profile: ServerProfile?) {
self.profile = profile
_name = State(initialValue: profile?.name ?? "")
_urlString = State(initialValue: profile?.urlString ?? "")
}
private var canSave: Bool {
Session.normalize(urlString) != nil
}
var body: some View {
NavigationStack {
Form {
// Der Name steht bewusst oben: Er ist das, was in der App zu
// sehen ist ("Zuhause"), die Adresse braucht man nur einmal.
Section {
TextField("z. B. Zuhause", text: $name)
} header: {
Text("Name")
} footer: {
Text("So heißt der Server in der App. Ohne Angabe wird der Rechnername verwendet meist die IP-Adresse.")
}
Section("Adresse") {
TextField("http://192.168.1.10:8080", text: $urlString)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
}
}
.navigationTitle(profile == nil ? "Server hinzufügen" : "Server bearbeiten")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Abbrechen") { dismiss() }
}
ToolbarItem(placement: .topBarTrailing) {
Button("Sichern") { save() }.disabled(!canSave)
}
}
}
}
private func save() {
if let profile {
session.updateProfile(id: profile.id, name: name, urlString: urlString)
} else {
// Nach dem Anlegen ist der neue Server der aktive; fehlt dort eine
// Anmeldung, erscheint sie von selbst.
session.addProfile(name: name, urlString: urlString)
}
dismiss()
}
}

View File

@@ -0,0 +1,72 @@
import Foundation
/// Ein hinterlegter Server. Die App kann beliebig viele davon kennen und
/// zwischen ihnen umschalten, ohne sich jedes Mal neu anzumelden.
///
/// Das Token liegt **nicht** hier, sondern im Keychain unter einem Konto, das
/// aus der `id` gebildet wird (siehe `Session.keychainAccount`). Diese Struktur
/// landet als JSON in den UserDefaults und darf deshalb nichts Geheimes
/// enthalten.
struct ServerProfile: Codable, Identifiable, Equatable {
let id: UUID
var name: String
var urlString: String
/// Zuletzt angemeldeter Benutzer - nur fuer die Anzeige und damit das
/// Anmeldefeld beim Wechsel schon ausgefuellt ist.
var username: String
/// Zuletzt bekannte Rolle. Entscheidet, ob der Verwaltungs-Tab erscheint,
/// bevor `me()` den Wert frisch bestaetigt hat.
var isAdmin: Bool
init(id: UUID = UUID(), name: String, urlString: String,
username: String = "", isAdmin: Bool = false) {
self.id = id
self.name = name
self.urlString = urlString
self.username = username
self.isAdmin = isAdmin
}
var url: URL? { Session.normalize(urlString) }
/// Ohne eigenen Namen ist der Rechnername die brauchbarste Bezeichnung:
/// aus "http://192.168.1.50:8080/" wird "192.168.1.50".
static func suggestedName(for raw: String) -> String {
guard let url = Session.normalize(raw), let host = url.host, !host.isEmpty else {
return "Server"
}
return host
}
}
/// Liest und schreibt die Profilliste. Bewusst getrennt von `Session`, damit
/// die Ablage fuer sich verstaendlich bleibt.
enum ProfileStore {
static let profilesKey = "server_profiles"
static let activeKey = "active_profile_id"
static func load() -> [ServerProfile] {
guard let data = UserDefaults.standard.data(forKey: profilesKey),
let profiles = try? JSONDecoder().decode([ServerProfile].self, from: data)
else { return [] }
return profiles
}
static func save(_ profiles: [ServerProfile]) {
guard let data = try? JSONEncoder().encode(profiles) else { return }
UserDefaults.standard.set(data, forKey: profilesKey)
}
static func loadActiveID() -> UUID? {
guard let raw = UserDefaults.standard.string(forKey: activeKey) else { return nil }
return UUID(uuidString: raw)
}
static func saveActiveID(_ id: UUID?) {
if let id {
UserDefaults.standard.set(id.uuidString, forKey: activeKey)
} else {
UserDefaults.standard.removeObject(forKey: activeKey)
}
}
}

View File

@@ -1,33 +1,81 @@
import Foundation import Foundation
import Security import Security
/// Hält Server-Adresse und Anmeldedaten. Das Token liegt im Keychain, /// Haelt die bekannten Server und die Anmeldung am gerade aktiven.
/// die Server-URL in den UserDefaults (nicht geheim). ///
/// Jedes Profil hat ein eigenes Token im Keychain. Dadurch bleibt man an
/// mehreren Servern gleichzeitig angemeldet und der Wechsel ist ein Tipp.
/// Die Adressen liegen in den UserDefaults (nicht geheim), die Token im
/// Keychain.
final class Session: ObservableObject { final class Session: ObservableObject {
static let shared = Session() static let shared = Session()
private let urlKey = "server_url"
private let usernameKey = "username"
private let stayKey = "stay_logged_in" private let stayKey = "stay_logged_in"
private let keychainAccount = "vorrania-token"
@Published private(set) var baseURL: URL? // Schluessel aus der Zeit mit genau einem Server. Werden beim ersten Start
// nach dem Update in ein Profil ueberfuehrt und danach entfernt.
private let legacyURLKey = "server_url"
private let legacyUsernameKey = "username"
private let legacyAccount = "vorrania-token"
@Published private(set) var profiles: [ServerProfile] = []
@Published private(set) var activeProfileID: UUID?
@Published private(set) var token: String? @Published private(set) var token: String?
@Published var username: String = ""
@Published var isAdmin: Bool = false
/// Merkt die letzte Wahl, damit der Haken im Login richtig steht. /// Merkt die letzte Wahl, damit der Haken im Login richtig steht.
@Published private(set) var stayLoggedIn: Bool = true @Published private(set) var stayLoggedIn: Bool = true
var activeProfile: ServerProfile? {
profiles.first { $0.id == activeProfileID }
}
var baseURL: URL? { activeProfile?.url }
var username: String { activeProfile?.username ?? "" }
var isAdmin: Bool { activeProfile?.isAdmin ?? false }
var isLoggedIn: Bool { token != nil && baseURL != nil } var isLoggedIn: Bool { token != nil && baseURL != nil }
private init() { private init() {
if let stored = UserDefaults.standard.string(forKey: urlKey) {
baseURL = Session.normalize(stored)
}
// Ohne bisherige Wahl bleibt man angemeldet - das ist der Alltagsfall. // Ohne bisherige Wahl bleibt man angemeldet - das ist der Alltagsfall.
stayLoggedIn = UserDefaults.standard.object(forKey: stayKey) as? Bool ?? true stayLoggedIn = UserDefaults.standard.object(forKey: stayKey) as? Bool ?? true
token = Keychain.read(account: keychainAccount) profiles = ProfileStore.load()
username = UserDefaults.standard.string(forKey: usernameKey) ?? "" migrateSingleServerIfNeeded()
activeProfileID = ProfileStore.loadActiveID() ?? profiles.first?.id
loadTokenForActiveProfile()
}
/// Uebernimmt eine Anmeldung aus der Zeit vor den Profilen. Ohne das waere
/// man nach dem Update abgemeldet und muesste die Serveradresse neu tippen.
private func migrateSingleServerIfNeeded() {
guard profiles.isEmpty,
let stored = UserDefaults.standard.string(forKey: legacyURLKey),
!stored.isEmpty
else { return }
let name = ServerProfile.suggestedName(for: stored)
let profile = ServerProfile(
name: name,
urlString: stored,
username: UserDefaults.standard.string(forKey: legacyUsernameKey) ?? ""
)
profiles = [profile]
ProfileStore.save(profiles)
ProfileStore.saveActiveID(profile.id)
// Token auf das profilbezogene Konto umziehen.
if let old = Keychain.read(account: legacyAccount) {
Keychain.write(old, account: Session.keychainAccount(for: profile.id))
Keychain.delete(account: legacyAccount)
}
UserDefaults.standard.removeObject(forKey: legacyURLKey)
UserDefaults.standard.removeObject(forKey: legacyUsernameKey)
}
static func keychainAccount(for id: UUID) -> String {
"vorrania-token-\(id.uuidString)"
}
private func loadTokenForActiveProfile() {
guard let id = activeProfileID else { token = nil; return }
token = Keychain.read(account: Session.keychainAccount(for: id))
} }
/// Sorgt für eine URL mit Schema und abschließendem "/", damit relative /// Sorgt für eine URL mit Schema und abschließendem "/", damit relative
@@ -40,36 +88,118 @@ final class Session: ObservableObject {
return URL(string: text) return URL(string: text)
} }
// MARK: - Profile
/// Legt ein Profil an und macht es zum aktiven. Ohne Namen wird der
/// Rechnername genommen.
@discardableResult
func addProfile(name: String = "", urlString: String) -> ServerProfile? {
guard Session.normalize(urlString) != nil else { return nil }
let title = name.trimmingCharacters(in: .whitespacesAndNewlines)
let profile = ServerProfile(
name: title.isEmpty ? ServerProfile.suggestedName(for: urlString) : title,
urlString: urlString
)
profiles.append(profile)
ProfileStore.save(profiles)
switchTo(profile.id)
return profile
}
func updateProfile(id: UUID, name: String? = nil, urlString: String? = nil) {
guard let index = profiles.firstIndex(where: { $0.id == id }) else { return }
if let name {
let title = name.trimmingCharacters(in: .whitespacesAndNewlines)
if !title.isEmpty { profiles[index].name = title }
}
if let urlString, Session.normalize(urlString) != nil {
profiles[index].urlString = urlString
}
ProfileStore.save(profiles)
}
/// Entfernt das Profil samt Token. Das Geheimnis darf nicht zurueckbleiben,
/// wenn der Server aus der Liste verschwindet.
func removeProfile(id: UUID) {
Keychain.delete(account: Session.keychainAccount(for: id))
profiles.removeAll { $0.id == id }
ProfileStore.save(profiles)
if activeProfileID == id {
switchTo(profiles.first?.id)
}
}
/// Wechselt den Server. Ist fuer das Ziel ein Token hinterlegt, ist man
/// sofort angemeldet, sonst erscheint die Anmeldung fuer dieses Profil.
func switchTo(_ id: UUID?) {
activeProfileID = id
ProfileStore.saveActiveID(id)
loadTokenForActiveProfile()
}
// MARK: - Anmeldung
/// Legt die Adresse fuer die Anmeldung fest: aktualisiert das aktive Profil
/// oder legt das erste an, wenn die App noch keinen Server kennt.
func setServer(_ raw: String) { func setServer(_ raw: String) {
guard let url = Session.normalize(raw) else { return } guard Session.normalize(raw) != nil else { return }
baseURL = url if let id = activeProfileID, profiles.contains(where: { $0.id == id }) {
UserDefaults.standard.set(url.absoluteString, forKey: urlKey) updateProfile(id: id, urlString: raw)
} else {
addProfile(urlString: raw)
}
} }
/// `persist == false` heisst: die Anmeldung gilt nur, solange die App laeuft. /// `persist == false` heisst: die Anmeldung gilt nur, solange die App laeuft.
/// Auf dem Geraet bleibt dann nichts zurueck. /// Auf dem Geraet bleibt dann nichts zurueck.
func store(token newToken: String, username name: String, isAdmin admin: Bool, persist: Bool) { func store(token newToken: String, username name: String, isAdmin admin: Bool, persist: Bool) {
token = newToken token = newToken
username = name
isAdmin = admin
stayLoggedIn = persist stayLoggedIn = persist
UserDefaults.standard.set(persist, forKey: stayKey) UserDefaults.standard.set(persist, forKey: stayKey)
guard let id = activeProfileID,
let index = profiles.firstIndex(where: { $0.id == id }) else { return }
profiles[index].isAdmin = admin
profiles[index].username = persist ? name : ""
ProfileStore.save(profiles)
if persist { if persist {
Keychain.write(newToken, account: keychainAccount) Keychain.write(newToken, account: Session.keychainAccount(for: id))
UserDefaults.standard.set(name, forKey: usernameKey)
} else { } else {
Keychain.delete(account: keychainAccount) Keychain.delete(account: Session.keychainAccount(for: id))
UserDefaults.standard.removeObject(forKey: usernameKey)
} }
} }
/// Meldet nur vom aktiven Server ab. Andere Profile behalten ihr Token.
func logout() { func logout() {
token = nil token = nil
username = "" guard let id = activeProfileID else { return }
isAdmin = false Keychain.delete(account: Session.keychainAccount(for: id))
Keychain.delete(account: keychainAccount) if let index = profiles.firstIndex(where: { $0.id == id }) {
UserDefaults.standard.removeObject(forKey: usernameKey) profiles[index].username = ""
profiles[index].isAdmin = false
ProfileStore.save(profiles)
}
}
/// Prueft nach einem Wechsel, ob das hinterlegte Token noch gilt, und holt
/// die aktuelle Rolle. Ein abgelaufenes Token fuehrt zur Anmeldung.
@MainActor
func refreshMe() async {
guard isLoggedIn, let id = activeProfileID else { return }
do {
let me = try await APIClient.shared.me()
guard let index = profiles.firstIndex(where: { $0.id == id }) else { return }
profiles[index].username = me.username
profiles[index].isAdmin = me.role == "admin"
ProfileStore.save(profiles)
} catch APIError.unauthorized {
logout()
} catch {
// Server gerade nicht erreichbar: angemeldet bleiben, damit ein
// kurzer Netzausfall einen nicht aus der App wirft.
}
} }
} }

View File

@@ -9,14 +9,19 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 369B9841E43E727ACA2E2A2A /* ProductViews.swift */; }; 0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 369B9841E43E727ACA2E2A2A /* ProductViews.swift */; };
0E0AE6CC6FBA82B190BBC36F /* CheckInFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */; }; 0E0AE6CC6FBA82B190BBC36F /* CheckInFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */; };
191427830389D44062F0A622 /* OverviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BDE41825692274409DDCAC7 /* OverviewView.swift */; };
25FC3913EFFA37D013328FDE /* CategoryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */; }; 25FC3913EFFA37D013328FDE /* CategoryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */; };
39F1F17DCB4E491652D08DEE /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378F1B2DE567B62169E84426 /* APIClient.swift */; }; 39F1F17DCB4E491652D08DEE /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378F1B2DE567B62169E84426 /* APIClient.swift */; };
44F08407AF69F5B7FD15F932 /* ScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF19A735AC17451B57BF5BB /* ScannerView.swift */; }; 44F08407AF69F5B7FD15F932 /* ScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF19A735AC17451B57BF5BB /* ScannerView.swift */; };
4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */; };
4F86620DF8303DB02314DB5A /* CheckInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0365A16FEAE3F2BEC321E68A /* CheckInView.swift */; }; 4F86620DF8303DB02314DB5A /* CheckInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0365A16FEAE3F2BEC321E68A /* CheckInView.swift */; };
6816C33381DF52A96E5BB303 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B1BB7220691A7423E8929 /* Models.swift */; }; 6816C33381DF52A96E5BB303 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B1BB7220691A7423E8929 /* Models.swift */; };
75F805F8AE93479933BD811D /* VorraniaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9948219CDDC4188EA4298F22 /* VorraniaApp.swift */; }; 75F805F8AE93479933BD811D /* VorraniaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9948219CDDC4188EA4298F22 /* VorraniaApp.swift */; };
7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6785CD9174067EFBB2B9043 /* ListViews.swift */; }; 7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6785CD9174067EFBB2B9043 /* ListViews.swift */; };
7E2FA69E0C3BED650E91A7E9 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 099CCD94907BE0179B7D5742 /* AppIcon.icon */; }; 7E2FA69E0C3BED650E91A7E9 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 099CCD94907BE0179B7D5742 /* AppIcon.icon */; };
80B15C315FB4FEBD385A5EA1 /* ServerProfiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 121D44B9990DA931A9CD5847 /* ServerProfiles.swift */; };
87A34A269AAD85A19C3F4359 /* ServerListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA706060734632DE78FA1073 /* ServerListView.swift */; };
96E5A2455B2EC26050E69232 /* MasterDataViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */; };
9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4741D0E95875919C921945CF /* RootView.swift */; }; 9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4741D0E95875919C921945CF /* RootView.swift */; };
A7017E6C0532A650439AED7B /* BestBeforeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */; }; A7017E6C0532A650439AED7B /* BestBeforeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */; };
AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F574168AA0F849D46C384EE /* ProductDetailView.swift */; }; AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F574168AA0F849D46C384EE /* ProductDetailView.swift */; };
@@ -30,6 +35,9 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
0365A16FEAE3F2BEC321E68A /* CheckInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckInView.swift; sourceTree = "<group>"; }; 0365A16FEAE3F2BEC321E68A /* CheckInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckInView.swift; sourceTree = "<group>"; };
099CCD94907BE0179B7D5742 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = "<group>"; }; 099CCD94907BE0179B7D5742 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = "<group>"; };
0BDE41825692274409DDCAC7 /* OverviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewView.swift; sourceTree = "<group>"; };
121D44B9990DA931A9CD5847 /* ServerProfiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerProfiles.swift; sourceTree = "<group>"; };
1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterDataViews.swift; sourceTree = "<group>"; };
314B1BB7220691A7423E8929 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; }; 314B1BB7220691A7423E8929 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
369B9841E43E727ACA2E2A2A /* ProductViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductViews.swift; sourceTree = "<group>"; }; 369B9841E43E727ACA2E2A2A /* ProductViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductViews.swift; sourceTree = "<group>"; };
378F1B2DE567B62169E84426 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; }; 378F1B2DE567B62169E84426 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
@@ -37,6 +45,7 @@
4BF4E4F5524B15728CEEE116 /* Vorrania.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Vorrania.app; sourceTree = BUILT_PRODUCTS_DIR; }; 4BF4E4F5524B15728CEEE116 /* Vorrania.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Vorrania.app; sourceTree = BUILT_PRODUCTS_DIR; };
6F574168AA0F849D46C384EE /* ProductDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailView.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>"; }; 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>"; };
8AF19A735AC17451B57BF5BB /* ScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScannerView.swift; sourceTree = "<group>"; }; 8AF19A735AC17451B57BF5BB /* ScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScannerView.swift; sourceTree = "<group>"; };
9948219CDDC4188EA4298F22 /* VorraniaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VorraniaApp.swift; sourceTree = "<group>"; }; 9948219CDDC4188EA4298F22 /* VorraniaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VorraniaApp.swift; sourceTree = "<group>"; };
99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckInFormView.swift; sourceTree = "<group>"; }; 99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckInFormView.swift; sourceTree = "<group>"; };
@@ -47,6 +56,7 @@
AD2F9406BD0D4F0D30FED345 /* Session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Session.swift; sourceTree = "<group>"; }; AD2F9406BD0D4F0D30FED345 /* Session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Session.swift; sourceTree = "<group>"; };
CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPicker.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>"; }; DC0DDD06332E567E70CA842F /* CheckOutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckOutView.swift; sourceTree = "<group>"; };
EA706060734632DE78FA1073 /* ServerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
@@ -61,13 +71,18 @@
DC0DDD06332E567E70CA842F /* CheckOutView.swift */, DC0DDD06332E567E70CA842F /* CheckOutView.swift */,
717C8EB336170526F5F3E695 /* DateScanView.swift */, 717C8EB336170526F5F3E695 /* DateScanView.swift */,
AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */, AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */,
7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */,
A6785CD9174067EFBB2B9043 /* ListViews.swift */, A6785CD9174067EFBB2B9043 /* ListViews.swift */,
A75926511854BB8EE316ED3A /* LoginView.swift */, A75926511854BB8EE316ED3A /* LoginView.swift */,
1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */,
314B1BB7220691A7423E8929 /* Models.swift */, 314B1BB7220691A7423E8929 /* Models.swift */,
0BDE41825692274409DDCAC7 /* OverviewView.swift */,
6F574168AA0F849D46C384EE /* ProductDetailView.swift */, 6F574168AA0F849D46C384EE /* ProductDetailView.swift */,
369B9841E43E727ACA2E2A2A /* ProductViews.swift */, 369B9841E43E727ACA2E2A2A /* ProductViews.swift */,
4741D0E95875919C921945CF /* RootView.swift */, 4741D0E95875919C921945CF /* RootView.swift */,
8AF19A735AC17451B57BF5BB /* ScannerView.swift */, 8AF19A735AC17451B57BF5BB /* ScannerView.swift */,
EA706060734632DE78FA1073 /* ServerListView.swift */,
121D44B9990DA931A9CD5847 /* ServerProfiles.swift */,
AD2F9406BD0D4F0D30FED345 /* Session.swift */, AD2F9406BD0D4F0D30FED345 /* Session.swift */,
9948219CDDC4188EA4298F22 /* VorraniaApp.swift */, 9948219CDDC4188EA4298F22 /* VorraniaApp.swift */,
); );
@@ -170,13 +185,18 @@
F6C7E913CAB0FAF1826E6BD3 /* CheckOutView.swift in Sources */, F6C7E913CAB0FAF1826E6BD3 /* CheckOutView.swift in Sources */,
ED61DAE4824165D6D87F8C1D /* DateScanView.swift in Sources */, ED61DAE4824165D6D87F8C1D /* DateScanView.swift in Sources */,
D70CB183C868FF0143A6A5E7 /* DisplaySettings.swift in Sources */, D70CB183C868FF0143A6A5E7 /* DisplaySettings.swift in Sources */,
4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */,
7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */, 7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */,
E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */, E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */,
96E5A2455B2EC26050E69232 /* MasterDataViews.swift in Sources */,
6816C33381DF52A96E5BB303 /* Models.swift in Sources */, 6816C33381DF52A96E5BB303 /* Models.swift in Sources */,
191427830389D44062F0A622 /* OverviewView.swift in Sources */,
AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */, AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */,
0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */, 0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */,
9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */, 9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */,
44F08407AF69F5B7FD15F932 /* ScannerView.swift in Sources */, 44F08407AF69F5B7FD15F932 /* ScannerView.swift in Sources */,
87A34A269AAD85A19C3F4359 /* ServerListView.swift in Sources */,
80B15C315FB4FEBD385A5EA1 /* ServerProfiles.swift in Sources */,
DFA55EF4ACA34537F445E998 /* Session.swift in Sources */, DFA55EF4ACA34537F445E998 /* Session.swift in Sources */,
75F805F8AE93479933BD811D /* VorraniaApp.swift in Sources */, 75F805F8AE93479933BD811D /* VorraniaApp.swift in Sources */,
); );