Verwendungsstatus: in der App umstellen, filtern und nachsehen

Im Einzelstueck steht jetzt ueber dem Verleih ein Abschnitt "Verwendung"
mit vier Symbolen: verfuegbar, in Verwendung, in Reinigung, in Reparatur.
Ein Tipp bucht sofort, ohne "Speichern" - wie beim Verleih, denn alle
Scan-Wege (QR, NFC, vorrania://i/<UID>) landen genau in dieser Ansicht.
Vier Woerter waeren auf einem iPhone zu breit, vier Symbole passen immer;
darunter steht der Name im Klartext samt "seit wann".

Dazu der Verwendungs-Verlauf mit Zeitstempeln und Konto, und in der
Einzelstueck-Liste ein Filter ueber alle vier Zustaende plus die Zeile
unter dem Namen, solange etwas laeuft. Stuecke ohne Verwendungsstatus
fallen aus dem Filter "Verfuegbar" heraus - ein Schraubenzieher ist nicht
verfuegbar, er fuehrt den Zustand gar nicht.

Der Haken je Kategorie sitzt im Kategorie-Editor und gilt beim Bearbeiten
auch fuer die Unterkategorien.

Zwei Dinge, die beim Pruefen am Simulator auffielen: Die Leiste haette
beim Oeffnen jedes Mal einmal ins Leere gebucht, weil onChange auch beim
Laden feuert - dagegen der Abgleich mit dem zuletzt gemeldeten Stand. Und
SQLite liefert Zeitstempel ohne Zeitzone, woran ISO8601DateFormatter
scheitert; in der App stand deshalb der rohe ISO-Text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-20 22:07:27 +02:00
co-authored by Claude Opus 5
parent b9d097fe96
commit 290ff9341a
5 changed files with 343 additions and 4 deletions
+165
View File
@@ -20,6 +20,41 @@ private func anzeigeDatum(_ iso: String?) -> String {
return f.string(from: d)
}
/// ISO-Zeitstempel -> "20.09.2026, 18:42". Anders als bei Daten steht hier ein
/// voller Zeitpunkt: Ein Hemd wechselt mehrmals am Tag den Zustand.
func anzeigeZeitpunkt(_ iso: String?) -> String {
guard let iso else { return "–" }
guard let d = zeitpunktAus(iso) else { return iso }
let f = DateFormatter()
f.locale = Locale(identifier: "de_DE")
f.dateFormat = "dd.MM.yyyy, HH:mm"
return f.string(from: d)
}
/// Zeitstempel des Servers lesen - mit und ohne Zeitzonenangabe.
///
/// Postgres liefert sie mit Zone ("…+00:00"), SQLite laesst sie weg. Ohne
/// Angabe ist es UTC, denn der Server schreibt `datetime.now(UTC)`. Ohne diesen
/// Zweig stuende in der App der rohe ISO-Text.
private func zeitpunktAus(_ iso: String) -> Date? {
let mitZone = ISO8601DateFormatter()
mitZone.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let d = mitZone.date(from: iso) { return d }
mitZone.formatOptions = [.withInternetDateTime]
if let d = mitZone.date(from: iso) { return d }
let ohneZone = DateFormatter()
ohneZone.locale = Locale(identifier: "en_US_POSIX")
ohneZone.timeZone = TimeZone(identifier: "UTC")
for muster in ["yyyy-MM-dd'T'HH:mm:ss.SSSSSS",
"yyyy-MM-dd'T'HH:mm:ss.SSS",
"yyyy-MM-dd'T'HH:mm:ss"] {
ohneZone.dateFormat = muster
if let d = ohneZone.date(from: iso) { return d }
}
return nil
}
/// Verleihzustand in einer Zeile: „Markus · zurueck bis 20.09.2026".
/// Ueberfaelliges kommt beim Aufrufer rot daher.
func verleihZeile(_ loan: Loan) -> String {
@@ -242,6 +277,17 @@ struct ItemEditView: View {
@State private var hasDue = false
@State private var due = Date()
@State private var busyLoan = false
// Verwendung: der laufende Zustand (nil = verfuegbar), nachgeladen wie der
// Verleih. `zeigtVerwendung` kommt vom uebergebenen Stueck und aendert sich
// waehrend der Ansicht nicht.
@State private var verwendung: VerwendungStatus?
// Was der Server zuletzt gemeldet hat. Ohne diesen Abgleich buchte das
// onChange der Leiste auch dann, wenn `verwendung` nur beim Laden gesetzt
// wurde - also bei jedem Oeffnen einmal ins Leere.
@State private var verwendungServer: VerwendungStatus?
@State private var verwendungSeit: String?
@State private var verwendungAnzahl = 0
@State private var busyVerwendung = false
var body: some View {
Form {
@@ -268,6 +314,39 @@ struct ItemEditView: View {
}
}
// Woran ist es gerade? Steht oben, weil alle Scan-Wege hier landen
// und „gerade aus der Waesche geholt" ein Griff sein soll - also
// eine Buchung wie der Verleih, kein Feld, das auf „Speichern"
// wartet. Vier Woerter waeren auf einem iPhone zu breit, vier
// Symbole passen immer.
if item.zeigtVerwendung {
Section {
Picker("Verwendung", selection: $verwendung) {
ForEach(VerwendungStatus.auswahl, id: \.self) { status in
Image(systemName: VerwendungStatus.symbol(status))
.tag(status)
}
}
.pickerStyle(.segmented)
.disabled(busyVerwendung)
.onChange(of: verwendung) { neu in
Task { await setzeVerwendung(neu) }
}
if verwendungAnzahl > 0 {
NavigationLink("Verwendungs-Verlauf (\(verwendungAnzahl))") {
VerwendungHistorieView(uid: item.uid, itemId: item.id)
}
}
} header: {
Text("Verwendung")
} footer: {
Text(busyVerwendung
? "Moment…"
: VerwendungStatus.label(verwendung)
+ (verwendungSeit.map { " · seit \($0)" } ?? ""))
}
}
// Verleih steht vor allem anderen: Ist das Stueck draussen, ist das
// die Antwort auf „wo ist es?". Alle Scan-Wege der App (QR, NFC,
// vorrania://i/<UID>) landen in dieser Ansicht - deshalb genuegt der
@@ -605,6 +684,42 @@ struct ItemEditView: View {
} catch { self.error = error.localizedDescription }
}
/// Verwendungszustand umstellen. Leer = verfuegbar.
///
/// Die Antwort traegt das ganze Stueck, ein zweites Laden entfaellt. Nur
/// der Verlaufszaehler wird nachgeholt.
private func setzeVerwendung(_ neu: VerwendungStatus?) async {
guard neu != verwendungServer, !busyVerwendung else { return }
busyVerwendung = true; defer { busyVerwendung = false }
error = nil
do {
let frisch = try await APIClient.shared.setzeVerwendung(id: item.id, status: neu)
uebernimm(frisch.verwendung)
await ladeVerwendungen()
onChanged?()
} catch {
self.error = error.localizedDescription
// Zurueck auf den Stand des Servers, sonst zeigt die Leiste eine
// Stellung an, die nie gebucht wurde.
await ladeVerwendungen()
}
}
private func uebernimm(_ offen: Verwendung?) {
verwendungServer = offen?.status
verwendung = offen?.status
verwendungSeit = offen.map { anzeigeZeitpunkt($0.startedAt) }
}
/// Verlauf holen - fuer den Zaehler am Verlaufs-Eintrag und um den
/// laufenden Zustand gegen den Server abzugleichen.
private func ladeVerwendungen() async {
guard item.zeigtVerwendung else { return }
let verlauf = (try? await APIClient.shared.itemVerwendungen(id: item.id)) ?? []
verwendungAnzahl = verlauf.count
uebernimm(verlauf.first(where: { $0.laeuft }))
}
/// Offenen Verleih und Historie frisch holen - nach jeder Buchung, damit die
/// Ansicht nicht auf dem Stand des uebergebenen `item` haengen bleibt.
private func ladeVerleihe() async {
@@ -627,7 +742,9 @@ struct ItemEditView: View {
loan = item.loan
teile = item.teile
gehoertZu = item.gehoertZu
uebernimm(item.verwendung)
await ladeVerleihe()
await ladeVerwendungen()
}
private func haengeAn(_ gewaehlt: Item) async {
@@ -1075,6 +1192,54 @@ struct ItemAddSheet: View {
}
}
// MARK: - Verwendungs-Verlauf
/// Alle Verwendungszeitraeume eines Stuecks, neueste zuerst. Abgeschlossene
/// bleiben erhalten - so ist nachvollziehbar, seit wann etwas in der Waesche
/// liegt und wie oft es getragen wurde.
struct VerwendungHistorieView: View {
let uid: String
let itemId: Int
@State private var zeilen: [Verwendung] = []
@State private var geladen = false
var body: some View {
List {
if !geladen {
Text("Wird geladen…").foregroundStyle(.secondary)
} else if zeilen.isEmpty {
Text("War noch nie in Verwendung.").foregroundStyle(.secondary)
}
ForEach(zeilen) { zeile in
VStack(alignment: .leading, spacing: 4) {
HStack {
Label(zeile.status.label, systemImage: zeile.status.symbol)
.font(.body.bold())
Spacer()
if zeile.laeuft {
Text("läuft").font(.caption).foregroundStyle(.secondary)
}
}
Text(anzeigeZeitpunkt(zeile.startedAt)
+ (zeile.endedAt.map { " – " + anzeigeZeitpunkt($0) } ?? ""))
.font(.caption).foregroundStyle(.secondary)
if let von = zeile.username {
Text("von \(von)").font(.caption).foregroundStyle(.secondary)
}
}
.padding(.vertical, 2)
}
}
.navigationTitle("Verwendung \(uid)")
.navigationBarTitleDisplayMode(.inline)
.task {
zeilen = (try? await APIClient.shared.itemVerwendungen(id: itemId)) ?? []
geladen = true
}
}
}
// MARK: - Verleih-Verlauf
/// Alle Verleihe eines Stuecks, neueste zuerst. Zurueckgegebene Vorgaenge bleiben