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

@@ -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.
}
}
}