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:
@@ -179,8 +179,105 @@ actor APIClient {
|
||||
}
|
||||
|
||||
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)
|
||||
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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,60 @@ final class DisplaySettings: ObservableObject {
|
||||
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
|
||||
|
||||
/// Kanonische Basiseinheit der API auf Deutsch.
|
||||
|
||||
121
ios/Sources/HistoryView.swift
Normal file
121
ios/Sources/HistoryView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,13 @@ import SwiftUI
|
||||
struct LoginView: View {
|
||||
@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 password = ""
|
||||
@State private var error: String?
|
||||
@State private var busy = false
|
||||
@State private var stayLoggedIn = Session.shared.stayLoggedIn
|
||||
@State private var manageShown = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -18,6 +19,15 @@ struct LoginView: View {
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.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") {
|
||||
TextField("Benutzername", text: $username)
|
||||
@@ -40,8 +50,31 @@ struct LoginView: View {
|
||||
.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 {
|
||||
|
||||
552
ios/Sources/MasterDataViews.swift
Normal file
552
ios/Sources/MasterDataViews.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,13 @@ struct Unit: Codable, Identifiable, Hashable {
|
||||
let name: String
|
||||
let kind: String
|
||||
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 {
|
||||
@@ -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).
|
||||
struct NewGroupRequest: Codable {
|
||||
let name: String
|
||||
|
||||
123
ios/Sources/OverviewView.swift
Normal file
123
ios/Sources/OverviewView.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,14 @@ struct ProductDetailView: View {
|
||||
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)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
|
||||
@@ -13,9 +13,14 @@ struct RootView: View {
|
||||
LoginView()
|
||||
}
|
||||
}
|
||||
// Anzeigeeinstellungen (Datumsformat) gehoeren dem Server.
|
||||
.task(id: session.isLoggedIn) {
|
||||
if session.isLoggedIn { await display.load() }
|
||||
// Anzeigeeinstellungen (Datumsformat) gehoeren dem Server - bei einem
|
||||
// Serverwechsel muessen sie deshalb neu geladen werden, sonst zeigt
|
||||
// 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.
|
||||
.fullScreenCover(item: $router.route) { route in
|
||||
@@ -35,6 +40,30 @@ extension Route: Identifiable {
|
||||
|
||||
struct HomeView: View {
|
||||
@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
|
||||
|
||||
var body: some View {
|
||||
@@ -54,6 +83,24 @@ struct HomeView: View {
|
||||
ActionTile(title: "Auslagern", subtitle: "Barcode scannen und Menge entnehmen",
|
||||
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 {
|
||||
ShoppingListView()
|
||||
} label: {
|
||||
@@ -67,25 +114,15 @@ struct HomeView: View {
|
||||
systemImage: "clock")
|
||||
}
|
||||
NavigationLink {
|
||||
ProductListView()
|
||||
HistoryView()
|
||||
} label: {
|
||||
ActionTile(title: "Produkte", subtitle: "Suchen, ansehen und bearbeiten",
|
||||
systemImage: "list.bullet")
|
||||
ActionTile(title: "Verlauf", subtitle: "Wer hat wann was ein- und ausgelagert",
|
||||
systemImage: "clock.arrow.circlepath")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("Vorrania")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Text("Angemeldet als \(session.username)")
|
||||
Button("Abmelden", role: .destructive) { session.logout() }
|
||||
} label: {
|
||||
Image(systemName: "person.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Listen")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
187
ios/Sources/ServerListView.swift
Normal file
187
ios/Sources/ServerListView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
72
ios/Sources/ServerProfiles.swift
Normal file
72
ios/Sources/ServerProfiles.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,81 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Hält Server-Adresse und Anmeldedaten. Das Token liegt im Keychain,
|
||||
/// die Server-URL in den UserDefaults (nicht geheim).
|
||||
/// Haelt die bekannten Server und die Anmeldung am gerade aktiven.
|
||||
///
|
||||
/// 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 {
|
||||
static let shared = Session()
|
||||
|
||||
private let urlKey = "server_url"
|
||||
private let usernameKey = "username"
|
||||
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 var username: String = ""
|
||||
@Published var isAdmin: Bool = false
|
||||
/// Merkt die letzte Wahl, damit der Haken im Login richtig steht.
|
||||
@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 }
|
||||
|
||||
private init() {
|
||||
if let stored = UserDefaults.standard.string(forKey: urlKey) {
|
||||
baseURL = Session.normalize(stored)
|
||||
}
|
||||
// Ohne bisherige Wahl bleibt man angemeldet - das ist der Alltagsfall.
|
||||
stayLoggedIn = UserDefaults.standard.object(forKey: stayKey) as? Bool ?? true
|
||||
token = Keychain.read(account: keychainAccount)
|
||||
username = UserDefaults.standard.string(forKey: usernameKey) ?? ""
|
||||
profiles = ProfileStore.load()
|
||||
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
|
||||
@@ -40,36 +88,118 @@ final class Session: ObservableObject {
|
||||
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) {
|
||||
guard let url = Session.normalize(raw) else { return }
|
||||
baseURL = url
|
||||
UserDefaults.standard.set(url.absoluteString, forKey: urlKey)
|
||||
guard Session.normalize(raw) != nil else { return }
|
||||
if let id = activeProfileID, profiles.contains(where: { $0.id == id }) {
|
||||
updateProfile(id: id, urlString: raw)
|
||||
} else {
|
||||
addProfile(urlString: raw)
|
||||
}
|
||||
}
|
||||
|
||||
/// `persist == false` heisst: die Anmeldung gilt nur, solange die App laeuft.
|
||||
/// Auf dem Geraet bleibt dann nichts zurueck.
|
||||
func store(token newToken: String, username name: String, isAdmin admin: Bool, persist: Bool) {
|
||||
token = newToken
|
||||
username = name
|
||||
isAdmin = admin
|
||||
stayLoggedIn = persist
|
||||
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 {
|
||||
Keychain.write(newToken, account: keychainAccount)
|
||||
UserDefaults.standard.set(name, forKey: usernameKey)
|
||||
Keychain.write(newToken, account: Session.keychainAccount(for: id))
|
||||
} else {
|
||||
Keychain.delete(account: keychainAccount)
|
||||
UserDefaults.standard.removeObject(forKey: usernameKey)
|
||||
Keychain.delete(account: Session.keychainAccount(for: id))
|
||||
}
|
||||
}
|
||||
|
||||
/// Meldet nur vom aktiven Server ab. Andere Profile behalten ihr Token.
|
||||
func logout() {
|
||||
token = nil
|
||||
username = ""
|
||||
isAdmin = false
|
||||
Keychain.delete(account: keychainAccount)
|
||||
UserDefaults.standard.removeObject(forKey: usernameKey)
|
||||
guard let id = activeProfileID else { return }
|
||||
Keychain.delete(account: Session.keychainAccount(for: id))
|
||||
if let index = profiles.firstIndex(where: { $0.id == id }) {
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,14 +9,19 @@
|
||||
/* Begin PBXBuildFile section */
|
||||
0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 369B9841E43E727ACA2E2A2A /* ProductViews.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 */; };
|
||||
39F1F17DCB4E491652D08DEE /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378F1B2DE567B62169E84426 /* APIClient.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 */; };
|
||||
6816C33381DF52A96E5BB303 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B1BB7220691A7423E8929 /* Models.swift */; };
|
||||
75F805F8AE93479933BD811D /* VorraniaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9948219CDDC4188EA4298F22 /* VorraniaApp.swift */; };
|
||||
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 */; };
|
||||
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 */; };
|
||||
A7017E6C0532A650439AED7B /* BestBeforeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */; };
|
||||
AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F574168AA0F849D46C384EE /* ProductDetailView.swift */; };
|
||||
@@ -30,6 +35,9 @@
|
||||
/* Begin PBXFileReference section */
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -37,6 +45,7 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -47,6 +56,7 @@
|
||||
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>"; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@@ -61,13 +71,18 @@
|
||||
DC0DDD06332E567E70CA842F /* CheckOutView.swift */,
|
||||
717C8EB336170526F5F3E695 /* DateScanView.swift */,
|
||||
AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */,
|
||||
7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */,
|
||||
A6785CD9174067EFBB2B9043 /* ListViews.swift */,
|
||||
A75926511854BB8EE316ED3A /* LoginView.swift */,
|
||||
1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */,
|
||||
314B1BB7220691A7423E8929 /* Models.swift */,
|
||||
0BDE41825692274409DDCAC7 /* OverviewView.swift */,
|
||||
6F574168AA0F849D46C384EE /* ProductDetailView.swift */,
|
||||
369B9841E43E727ACA2E2A2A /* ProductViews.swift */,
|
||||
4741D0E95875919C921945CF /* RootView.swift */,
|
||||
8AF19A735AC17451B57BF5BB /* ScannerView.swift */,
|
||||
EA706060734632DE78FA1073 /* ServerListView.swift */,
|
||||
121D44B9990DA931A9CD5847 /* ServerProfiles.swift */,
|
||||
AD2F9406BD0D4F0D30FED345 /* Session.swift */,
|
||||
9948219CDDC4188EA4298F22 /* VorraniaApp.swift */,
|
||||
);
|
||||
@@ -170,13 +185,18 @@
|
||||
F6C7E913CAB0FAF1826E6BD3 /* CheckOutView.swift in Sources */,
|
||||
ED61DAE4824165D6D87F8C1D /* DateScanView.swift in Sources */,
|
||||
D70CB183C868FF0143A6A5E7 /* DisplaySettings.swift in Sources */,
|
||||
4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */,
|
||||
7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */,
|
||||
E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */,
|
||||
96E5A2455B2EC26050E69232 /* MasterDataViews.swift in Sources */,
|
||||
6816C33381DF52A96E5BB303 /* Models.swift in Sources */,
|
||||
191427830389D44062F0A622 /* OverviewView.swift in Sources */,
|
||||
AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */,
|
||||
0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */,
|
||||
9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */,
|
||||
44F08407AF69F5B7FD15F932 /* ScannerView.swift in Sources */,
|
||||
87A34A269AAD85A19C3F4359 /* ServerListView.swift in Sources */,
|
||||
80B15C315FB4FEBD385A5EA1 /* ServerProfiles.swift in Sources */,
|
||||
DFA55EF4ACA34537F445E998 /* Session.swift in Sources */,
|
||||
75F805F8AE93479933BD811D /* VorraniaApp.swift in Sources */,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user