diff --git a/ios/Sources/APIClient.swift b/ios/Sources/APIClient.swift index b5c963c..892ba6b 100644 --- a/ios/Sources/APIClient.swift +++ b/ios/Sources/APIClient.swift @@ -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")) } } diff --git a/ios/Sources/DisplaySettings.swift b/ios/Sources/DisplaySettings.swift index 4a72fa5..40bcb9c 100644 --- a/ios/Sources/DisplaySettings.swift +++ b/ios/Sources/DisplaySettings.swift @@ -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. diff --git a/ios/Sources/HistoryView.swift b/ios/Sources/HistoryView.swift new file mode 100644 index 0000000..e21d55a --- /dev/null +++ b/ios/Sources/HistoryView.swift @@ -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) + } +} diff --git a/ios/Sources/LoginView.swift b/ios/Sources/LoginView.swift index ca883b1..2446460 100644 --- a/ios/Sources/LoginView.swift +++ b/ios/Sources/LoginView.swift @@ -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 { + 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 { diff --git a/ios/Sources/MasterDataViews.swift b/ios/Sources/MasterDataViews.swift new file mode 100644 index 0000000..3a7919f --- /dev/null +++ b/ios/Sources/MasterDataViews.swift @@ -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: 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 + ) + } + } +} diff --git a/ios/Sources/Models.swift b/ios/Sources/Models.swift index d25c30a..8fe0f01 100644 --- a/ios/Sources/Models.swift +++ b/ios/Sources/Models.swift @@ -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 diff --git a/ios/Sources/OverviewView.swift b/ios/Sources/OverviewView.swift new file mode 100644 index 0000000..9e336d3 --- /dev/null +++ b/ios/Sources/OverviewView.swift @@ -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)) + } +} diff --git a/ios/Sources/ProductDetailView.swift b/ios/Sources/ProductDetailView.swift index 91f2c68..59ec3db 100644 --- a/ios/Sources/ProductDetailView.swift +++ b/ios/Sources/ProductDetailView.swift @@ -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) diff --git a/ios/Sources/RootView.swift b/ios/Sources/RootView.swift index 7c4804b..73bb18c 100644 --- a/ios/Sources/RootView.swift +++ b/ios/Sources/RootView.swift @@ -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") } } } diff --git a/ios/Sources/ServerListView.swift b/ios/Sources/ServerListView.swift new file mode 100644 index 0000000..13dca24 --- /dev/null +++ b/ios/Sources/ServerListView.swift @@ -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() + } +} diff --git a/ios/Sources/ServerProfiles.swift b/ios/Sources/ServerProfiles.swift new file mode 100644 index 0000000..6948430 --- /dev/null +++ b/ios/Sources/ServerProfiles.swift @@ -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) + } + } +} diff --git a/ios/Sources/Session.swift b/ios/Sources/Session.swift index a9b40d7..0a30868 100644 --- a/ios/Sources/Session.swift +++ b/ios/Sources/Session.swift @@ -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. + } } } diff --git a/ios/Vorrania.xcodeproj/project.pbxproj b/ios/Vorrania.xcodeproj/project.pbxproj index 7f09030..8ac5d6e 100644 --- a/ios/Vorrania.xcodeproj/project.pbxproj +++ b/ios/Vorrania.xcodeproj/project.pbxproj @@ -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 = ""; }; 099CCD94907BE0179B7D5742 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = ""; }; + 0BDE41825692274409DDCAC7 /* OverviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewView.swift; sourceTree = ""; }; + 121D44B9990DA931A9CD5847 /* ServerProfiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerProfiles.swift; sourceTree = ""; }; + 1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterDataViews.swift; sourceTree = ""; }; 314B1BB7220691A7423E8929 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; 369B9841E43E727ACA2E2A2A /* ProductViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductViews.swift; sourceTree = ""; }; 378F1B2DE567B62169E84426 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = ""; }; @@ -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 = ""; }; 717C8EB336170526F5F3E695 /* DateScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateScanView.swift; sourceTree = ""; }; + 7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; 8AF19A735AC17451B57BF5BB /* ScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScannerView.swift; sourceTree = ""; }; 9948219CDDC4188EA4298F22 /* VorraniaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VorraniaApp.swift; sourceTree = ""; }; 99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckInFormView.swift; sourceTree = ""; }; @@ -47,6 +56,7 @@ AD2F9406BD0D4F0D30FED345 /* Session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Session.swift; sourceTree = ""; }; CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPicker.swift; sourceTree = ""; }; DC0DDD06332E567E70CA842F /* CheckOutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckOutView.swift; sourceTree = ""; }; + EA706060734632DE78FA1073 /* ServerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListView.swift; sourceTree = ""; }; /* 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 */, );