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

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

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

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

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

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

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

View File

@@ -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()
}
}