Files
Vorrania/ios/Sources/Session.swift
Scarriffle 580afcc133 iOS: eigene, gestaltbare App-Uebersicht mit nativen Diagrammen
Der Start-Tab spiegelte bisher fest die Web-Dashboards - dieselbe gespeicherte
Anordnung, und Diagramme gab es nur den Hinweis "im Web". Das war der Punkt,
der geaergert hat.

Jetzt ist der Modus je Server umschaltbar: "Web-Uebersicht spiegeln" (weiter
Standard, damit nach dem Update nichts ueberrascht) oder "Eigene
App-Uebersicht". Die eigene Uebersicht liegt nur auf dem Geraet (pro Server),
unabhaengig vom Web und nicht synchronisiert. Beim ersten Umschalten entsteht
gleich eine sinnvolle Startanordnung.

Sie funktioniert wie im Web, nur fuers Telefon: mehrere Dashboards, Karten
einspaltig untereinander, im Bearbeiten-Modus hinzufuegen, per Ziehen sortieren
und loeschen; Artikelkarten bekommen ihren Artikel zugewiesen. Anzeigen und
Bearbeiten sind getrennt - so laufen Sortieren und Loeschen sauber, statt an
Sektionsgrenzen zu haken.

Die Diagramme sind nativ mit Swift Charts: Ablauf- und Kategorien-Anteile
(Ring ab iOS 17, sonst ein 100-%-Balken - das Ziel ist iOS 16, deshalb kein
stilles Anheben), Kategorien nach Zustand als gestapelte Balken, Bestands- und
Artikelverlauf als Linie, Ein-/Auslagerungen als gruppierte Balken. Dieselben
Endpunkte wie das Web-Dashboard, kein Backend-Eingriff. Der "gibt es nur im
Web"-Hinweis faellt damit weg - auch beim Spiegeln werden die Diagramme jetzt
nativ gezeichnet.

Geprueft: Build; alle vier Diagramm-Modelle gegen echte Serverantworten
dekodiert (auch der Zeitstempel mit Z und Sekundenbruchteilen); refactorte
DashboardCardView ohne alte Aufrufstellen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 21:29:37 +02:00

247 lines
9.4 KiB
Swift

import Foundation
import Security
/// 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 stayKey = "stay_logged_in"
// 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?
/// 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() {
// Ohne bisherige Wahl bleibt man angemeldet - das ist der Alltagsfall.
stayLoggedIn = UserDefaults.standard.object(forKey: stayKey) as? Bool ?? true
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
/// Pfade ("api/...") korrekt aufgelöst werden.
static func normalize(_ raw: String) -> URL? {
var text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return nil }
if !text.contains("://") { text = "http://" + text }
if !text.hasSuffix("/") { text += "/" }
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))
NotificationStore.remove(for: id)
AppDashboardStore.remove(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 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
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: Session.keychainAccount(for: id))
} else {
Keychain.delete(account: Session.keychainAccount(for: id))
}
}
/// Meldet nur vom aktiven Server ab. Andere Profile behalten ihr Token.
func logout() {
token = nil
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.
}
}
}
/// Minimaler Keychain-Zugriff für ein einzelnes Token.
enum Keychain {
private static let service = "com.scarriffle.vorrania"
static func write(_ value: String, account: String) {
delete(account: account)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecValueData as String: Data(value.utf8),
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
]
SecItemAdd(query as CFDictionary, nil)
}
static func read(account: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var item: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
let data = item as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
static func delete(account: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
SecItemDelete(query as CFDictionary)
}
}