Files
Vorrania/ios/Sources/APIClient.swift
Scarriffle 2ad197df42 iOS zeigt dieselben Dashboards und kann direkt buchen
Die Uebersicht in der App war fest verdrahtet: vier Kacheln, unabhaengig
davon, was am Rechner zusammengestellt wurde. Jetzt liest sie dieselben
Dashboards wie die Web-Oberflaeche und stellt sie nativ dar.

Das Raster laesst sich auf einem Telefon nicht sinnvoll nachbilden, deshalb
stehen die Karten untereinander - in der Lesereihenfolge des Rasters, erst
Zeile, dann Spalte. Zusammengestellt wird weiterhin am Rechner; gibt es
mehrere Dashboards, schaltet oben ein Menue um.

Neu ist die Buchungskarte: Artikel suchen, Menge, ein- oder auslagern, ohne
den Scan-Ablauf zu oeffnen. Nach dem Buchen zieht die Karte den Bestand nach
und laesst die uebrigen Karten neu laden - sonst stuende dort weiter die alte
Zahl.

Diagrammkarten bekommen bewusst keinen halbgaren Nachbau, sondern den
ehrlichen Hinweis, dass es sie in der Web-Oberflaeche gibt.

Geprueft mit der echten Antwort eines laufenden Servers: Sie decodiert in die
Modelle der App, samt zweier Karten derselben Art mit verschiedenen Artikeln.
OverviewView faellt weg, DashboardView tritt an ihre Stelle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 19:27:01 +02:00

289 lines
11 KiB
Swift

import Foundation
enum APIError: LocalizedError {
case notConfigured
case unauthorized
case server(String)
var errorDescription: String? {
switch self {
case .notConfigured: return "Server-Adresse fehlt. Bitte in den Einstellungen hinterlegen."
case .unauthorized: return "Nicht angemeldet oder Sitzung abgelaufen."
case .server(let message): return message
}
}
}
/// Schlanker Client für die Vorrania-REST-API.
/// Alle Pfade laufen über /api (wie im Web, siehe web/nginx.conf).
actor APIClient {
static let shared = APIClient()
private var baseURL: URL? { Session.shared.baseURL }
private var token: String? { Session.shared.token }
// MARK: - Basis
private func makeRequest(_ path: String, method: String = "GET") throws -> URLRequest {
guard let baseURL else { throw APIError.notConfigured }
guard let url = URL(string: "api" + path, relativeTo: baseURL) else {
throw APIError.notConfigured
}
var request = URLRequest(url: url)
request.httpMethod = method
request.timeoutInterval = 15
if let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
return request
}
private func send<T: Decodable>(_ request: URLRequest, as type: T.Type) async throws -> T {
let (data, response) = try await URLSession.shared.data(for: request)
try check(response, data: data)
return try JSONDecoder().decode(T.self, from: data)
}
private func check(_ response: URLResponse, data: Data) throws {
guard let http = response as? HTTPURLResponse else { return }
guard !(200..<300).contains(http.statusCode) else { return }
if http.statusCode == 401 { throw APIError.unauthorized }
// FastAPI liefert Fehler als {"detail": "..."}
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let detail = object["detail"] as? String {
throw APIError.server(detail)
}
throw APIError.server("Serverfehler (\(http.statusCode))")
}
private func jsonBody<T: Encodable>(_ request: inout URLRequest, _ value: T) throws {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(value)
}
// MARK: - Auth
func login(username: String, password: String) async throws -> TokenResponse {
var request = try makeRequest("/auth/login", method: "POST")
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
var components = URLComponents()
components.queryItems = [
URLQueryItem(name: "username", value: username),
URLQueryItem(name: "password", value: password),
]
request.httpBody = components.percentEncodedQuery?.data(using: .utf8)
return try await send(request, as: TokenResponse.self)
}
func me() async throws -> MeResponse {
try await send(try makeRequest("/auth/me"), as: MeResponse.self)
}
// MARK: - Stammdaten
func units() async throws -> [Unit] {
try await send(try makeRequest("/units"), as: [Unit].self)
}
func locations() async throws -> [StorageLocation] {
try await send(try makeRequest("/locations"), as: [StorageLocation].self)
}
/// `categoryId == 0` liefert Artikel ohne Kategorie; eine echte ID schliesst
/// die Unterkategorien mit ein.
func searchProducts(_ query: String, categoryId: Int? = nil) async throws -> [Product] {
var parts: [String] = []
if !query.isEmpty {
let escaped = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
parts.append("q=\(escaped)")
}
if let categoryId { parts.append("category_id=\(categoryId)") }
let suffix = parts.isEmpty ? "" : "?" + parts.joined(separator: "&")
return try await send(try makeRequest("/products" + suffix), as: [Product].self)
}
func product(id: Int) async throws -> Product {
try await send(try makeRequest("/products/\(id)"), as: Product.self)
}
func lookup(barcode: String) async throws -> LookupResult {
let escaped = barcode.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
return try await send(try makeRequest("/products/lookup?barcode=\(escaped)"), as: LookupResult.self)
}
func createProduct(_ payload: NewProductRequest) async throws -> Product {
var request = try makeRequest("/products", method: "POST")
try jsonBody(&request, payload)
return try await send(request, as: Product.self)
}
func updateProduct(id: Int, _ payload: ProductUpdateRequest) async throws -> Product {
var request = try makeRequest("/products/\(id)", method: "PATCH")
try jsonBody(&request, payload)
return try await send(request, as: Product.self)
}
func groups() async throws -> [GroupItem] {
try await send(try makeRequest("/groups"), as: [GroupItem].self)
}
func categories() async throws -> [CategoryItem] {
try await send(try makeRequest("/categories"), as: [CategoryItem].self)
}
func createGroup(_ payload: NewGroupRequest) async throws -> GroupItem {
var request = try makeRequest("/groups", method: "POST")
try jsonBody(&request, payload)
return try await send(request, as: GroupItem.self)
}
func settings() async throws -> [SettingEntry] {
try await send(try makeRequest("/settings"), as: [SettingEntry].self)
}
// MARK: - Listen
func shoppingList() async throws -> [ShoppingItem] {
try await send(try makeRequest("/shopping-list"), as: [ShoppingItem].self)
}
func shoppingGroups() async throws -> [GroupShoppingItem] {
try await send(try makeRequest("/shopping-list/groups"), as: [GroupShoppingItem].self)
}
func expiring(days: Int? = nil) async throws -> [ExpiringItem] {
let path = days.map { "/expiring?days=\($0)" } ?? "/expiring"
return try await send(try makeRequest(path), as: [ExpiringItem].self)
}
// MARK: - Bestand
func lots(productId: Int) async throws -> [Lot] {
try await send(try makeRequest("/lots?product_id=\(productId)"), as: [Lot].self)
}
func checkInBatch(_ payload: BatchCheckInRequest) async throws -> StockResponse {
var request = try makeRequest("/stock/checkin/batch", method: "POST")
try jsonBody(&request, payload)
return try await send(request, as: StockResponse.self)
}
func checkOut(_ payload: CheckOutRequest) async throws -> StockResponse {
var request = try makeRequest("/stock/checkout", method: "POST")
try jsonBody(&request, payload)
return try await send(request, as: StockResponse.self)
}
func updateLot(id: Int, _ payload: LotUpdateRequest) async throws -> Lot {
var request = try makeRequest("/lots/\(id)", method: "PATCH")
try jsonBody(&request, payload)
return try await send(request, as: Lot.self)
}
func deleteLot(id: Int) async throws {
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)
}
// MARK: - Uebersicht und Verlauf
func dashboardStats() async throws -> DashboardStats {
try await send(try makeRequest("/dashboard/stats"), as: DashboardStats.self)
}
/// Die Dashboards des Benutzers - dieselben wie in der Web-Oberflaeche.
func dashboards() async throws -> DashboardList {
try await send(try makeRequest("/dashboard/layouts"), as: DashboardList.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"))
}
}