iOS-App angefangen; Import-Modi; MHD-Formatierung; Gebinde in Ablauf-Ansichten
iOS (neu, ios/):
- SwiftUI-App: Login (Server + Keychain-Token), Kamera-Scanner (EAN/UPC/Code128),
Einlagern mit mehreren Chargen und eigenem MHD, Auslagern mit Chargenauswahl
oder FEFO, Artikelsuche, Anlegen mit Open-Food-Facts-Vorbefuellung.
- Home-Screen-Shortcuts: Schnellaktionen (langer Druck) und URL-Schema
projectgood://checkin | ://checkout fuer eigene Symbole via Kurzbefehle.
- project.yml (XcodeGen) + README mit Build-Anleitung. NICHT kompiliert - auf
diesem Rechner ist kein Xcode vorhanden.
Import-Modi (wie besprochen sinnvoll):
- add (Standard, nichts loeschen), replace_listed (Bestaende der in der Datei
genannten Produkte ersetzen - fuer Inventur), replace_all (alles ersetzen).
Geleerte Bestaende werden als Korrektur-Bewegung protokolliert; die Oberflaeche
fragt bei den zerstoerenden Modi nach.
Anzeige:
- "Bald ablaufend"/"Abgelaufen" zeigen jetzt das Gebinde ("1 Glas") mit der
Basiseinheit klein darunter (ExpiringItem liefert die Einheiten mit).
- MHD als Zeitspanne mit korrektem Numerus: "in 3 Tagen", "in 1 Woche",
"vor 2 Wochen", dazu heute/morgen/gestern.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
127
ios/Sources/APIClient.swift
Normal file
127
ios/Sources/APIClient.swift
Normal file
@@ -0,0 +1,127 @@
|
||||
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 Project-Good-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)
|
||||
}
|
||||
|
||||
func searchProducts(_ query: String) async throws -> [Product] {
|
||||
let escaped = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
|
||||
return try await send(try makeRequest("/products?q=\(escaped)"), 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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
149
ios/Sources/CheckInFormView.swift
Normal file
149
ios/Sources/CheckInFormView.swift
Normal file
@@ -0,0 +1,149 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Mengen-Erfassung nach dem Scan: mehrere Chargen mit eigenem MHD.
|
||||
struct CheckInFormView: View {
|
||||
let product: Product
|
||||
let units: [Unit]
|
||||
let locations: [StorageLocation]
|
||||
var onDone: (String) -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
struct Line: Identifiable {
|
||||
let id = UUID()
|
||||
var quantity: String = "1"
|
||||
var hasDate: Bool = false
|
||||
var bestBefore: Date = Date()
|
||||
}
|
||||
|
||||
@State private var lines: [Line] = [Line()]
|
||||
@State private var unit: String = ""
|
||||
@State private var locationId: Int?
|
||||
@State private var busy = false
|
||||
@State private var error: String?
|
||||
|
||||
/// Einheiten der passenden Art plus das Gebinde des Artikels.
|
||||
private var unitOptions: [String] {
|
||||
var options = units.filter { $0.kind == product.kind }.map(\.name)
|
||||
if let size = product.packageSize, size > 0 {
|
||||
options.append(product.packageLabel ?? "Packung")
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
private var packageOptionName: String? {
|
||||
guard let size = product.packageSize, size > 0 else { return nil }
|
||||
return product.packageLabel ?? "Packung"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(product.name).font(.headline)
|
||||
Text("Bestand: \(format(product.stockInArticleUnits)) \(product.articleUnitLabel)")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
Section("Einheit") {
|
||||
Picker("Einheit", selection: $unit) {
|
||||
ForEach(unitOptions, id: \.self) { Text($0).tag($0) }
|
||||
}
|
||||
if !locations.isEmpty {
|
||||
Picker("Lagerort", selection: $locationId) {
|
||||
Text("– keiner –").tag(Int?.none)
|
||||
ForEach(locations) { location in
|
||||
Text(location.name).tag(Int?.some(location.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Chargen") {
|
||||
ForEach($lines) { $line in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
TextField("Menge", text: $line.quantity)
|
||||
.keyboardType(.decimalPad)
|
||||
if lines.count > 1 {
|
||||
Button(role: .destructive) {
|
||||
lines.removeAll { $0.id == line.id }
|
||||
} label: { Image(systemName: "trash") }
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
Toggle("MHD angeben", isOn: $line.hasDate)
|
||||
if line.hasDate {
|
||||
DatePicker("MHD", selection: $line.bestBefore, displayedComponents: .date)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
Button {
|
||||
lines.append(Line())
|
||||
} label: {
|
||||
Label("Weitere Charge (anderes MHD)", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
|
||||
if let error {
|
||||
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(busy ? "Speichern…" : "Einlagern") { Task { await submit() } }
|
||||
.disabled(busy)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Einlagern")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
|
||||
}
|
||||
.onAppear {
|
||||
// Gibt es ein Gebinde, ist das die naheliegende Eingabe.
|
||||
unit = packageOptionName ?? product.unitName
|
||||
}
|
||||
}
|
||||
|
||||
private func format(_ value: Double) -> String {
|
||||
value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value)
|
||||
}
|
||||
|
||||
private func submit() async {
|
||||
error = nil
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
|
||||
let payloadLines: [CheckInLine] = lines.compactMap { line in
|
||||
guard let quantity = Double(line.quantity.replacingOccurrences(of: ",", with: ".")),
|
||||
quantity > 0 else { return nil }
|
||||
return CheckInLine(
|
||||
quantity: quantity,
|
||||
bestBefore: line.hasDate ? formatter.string(from: line.bestBefore) : nil,
|
||||
locationId: locationId
|
||||
)
|
||||
}
|
||||
guard !payloadLines.isEmpty else {
|
||||
error = "Bitte mindestens eine Menge angeben."
|
||||
return
|
||||
}
|
||||
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
do {
|
||||
let response = try await APIClient.shared.checkInBatch(
|
||||
BatchCheckInRequest(productId: product.id, unit: unit, lines: payloadLines)
|
||||
)
|
||||
let total = response.productStock / product.articleUnitFactor
|
||||
onDone("Eingelagert. Neuer Bestand: \(format(total)) \(product.articleUnitLabel)")
|
||||
dismiss()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
175
ios/Sources/CheckInView.swift
Normal file
175
ios/Sources/CheckInView.swift
Normal file
@@ -0,0 +1,175 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CheckInView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var paused = false
|
||||
@State private var status: String?
|
||||
@State private var error: String?
|
||||
|
||||
@State private var product: Product?
|
||||
@State private var suggestion: LookupResult.Suggestion?
|
||||
@State private var suggestedGroupId: Int?
|
||||
@State private var unknownCode: String?
|
||||
@State private var manualCodeShown = false
|
||||
@State private var manualCode = ""
|
||||
@State private var units: [Unit] = []
|
||||
@State private var locations: [StorageLocation] = []
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
ScannerView(onCode: { code in Task { await resolve(code) } }, isPaused: $paused)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 10) {
|
||||
if let status { banner(status, color: .green) }
|
||||
if let error { banner(error, color: .red) }
|
||||
|
||||
if let suggestion {
|
||||
suggestionCard(suggestion)
|
||||
} else if let unknownCode {
|
||||
unknownCard(unknownCode)
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Button {
|
||||
paused = true
|
||||
manualCodeShown = true
|
||||
} label: {
|
||||
Label("EAN manuell", systemImage: "keyboard")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
NavigationLink {
|
||||
ProductFormView(prefillBarcode: nil, groupId: nil) { created in
|
||||
product = created
|
||||
}
|
||||
} label: {
|
||||
Label("Artikel anlegen", systemImage: "plus")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.padding(.bottom, 4)
|
||||
.background(.ultraThinMaterial.opacity(0.001))
|
||||
}
|
||||
.navigationTitle("Einlagern")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Fertig") { dismiss() }
|
||||
}
|
||||
}
|
||||
.task {
|
||||
units = (try? await APIClient.shared.units()) ?? []
|
||||
locations = (try? await APIClient.shared.locations()) ?? []
|
||||
}
|
||||
.alert("EAN eingeben", isPresented: $manualCodeShown) {
|
||||
TextField("z.B. 8076809572569", text: $manualCode)
|
||||
.keyboardType(.numberPad)
|
||||
Button("Suchen") {
|
||||
let code = manualCode
|
||||
manualCode = ""
|
||||
Task { await resolve(code) }
|
||||
}
|
||||
Button("Abbrechen", role: .cancel) { paused = false }
|
||||
}
|
||||
.sheet(item: $product) { item in
|
||||
NavigationStack {
|
||||
CheckInFormView(product: item, units: units, locations: locations) { message in
|
||||
status = message
|
||||
product = nil
|
||||
paused = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bausteine
|
||||
|
||||
private func banner(_ text: String, color: Color) -> some View {
|
||||
Text(text)
|
||||
.font(.callout)
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(color.opacity(0.9))
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
private func suggestionCard(_ item: LookupResult.Suggestion) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(item.name).font(.headline)
|
||||
Text([item.brand, item.quantityText].compactMap { $0 }.joined(separator: " · "))
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
Text("Bei Open Food Facts gefunden, noch nicht im Katalog.")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
NavigationLink {
|
||||
ProductFormView(prefillBarcode: item.barcode, groupId: suggestedGroupId,
|
||||
suggestion: item) { created in
|
||||
suggestion = nil
|
||||
product = created
|
||||
}
|
||||
} label: {
|
||||
Label("Anlegen & einlagern", systemImage: "plus.circle.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding()
|
||||
.background(.thinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
private func unknownCard(_ code: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Unbekannter Code \(code)").font(.headline)
|
||||
Text("Weder im Katalog noch bei Open Food Facts.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
NavigationLink {
|
||||
ProductFormView(prefillBarcode: code, groupId: suggestedGroupId) { created in
|
||||
unknownCode = nil
|
||||
product = created
|
||||
}
|
||||
} label: {
|
||||
Label("Artikel anlegen", systemImage: "plus")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding()
|
||||
.background(.thinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
// MARK: - Logik
|
||||
|
||||
private func resolve(_ code: String) async {
|
||||
paused = true
|
||||
error = nil
|
||||
status = nil
|
||||
suggestion = nil
|
||||
unknownCode = nil
|
||||
do {
|
||||
let result = try await APIClient.shared.lookup(barcode: code)
|
||||
suggestedGroupId = result.groupId
|
||||
if let existing = result.existingProduct {
|
||||
product = existing
|
||||
} else if let hint = result.suggestion {
|
||||
suggestion = hint
|
||||
} else {
|
||||
unknownCode = code
|
||||
}
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
paused = false
|
||||
}
|
||||
}
|
||||
}
|
||||
210
ios/Sources/CheckOutView.swift
Normal file
210
ios/Sources/CheckOutView.swift
Normal file
@@ -0,0 +1,210 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CheckOutView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var paused = false
|
||||
@State private var status: String?
|
||||
@State private var error: String?
|
||||
@State private var product: Product?
|
||||
@State private var units: [Unit] = []
|
||||
@State private var manualCodeShown = false
|
||||
@State private var manualCode = ""
|
||||
@State private var searchShown = false
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
ScannerView(onCode: { code in Task { await resolve(code) } }, isPaused: $paused)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 10) {
|
||||
if let status { banner(status, color: .green) }
|
||||
if let error { banner(error, color: .red) }
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Button {
|
||||
paused = true
|
||||
searchShown = true
|
||||
} label: {
|
||||
Label("Artikel suchen", systemImage: "magnifyingglass")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button {
|
||||
paused = true
|
||||
manualCodeShown = true
|
||||
} label: {
|
||||
Label("EAN manuell", systemImage: "keyboard")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Auslagern")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } }
|
||||
}
|
||||
.task { units = (try? await APIClient.shared.units()) ?? [] }
|
||||
.alert("EAN eingeben", isPresented: $manualCodeShown) {
|
||||
TextField("z.B. 8076809572569", text: $manualCode)
|
||||
.keyboardType(.numberPad)
|
||||
Button("Suchen") {
|
||||
let code = manualCode
|
||||
manualCode = ""
|
||||
Task { await resolve(code) }
|
||||
}
|
||||
Button("Abbrechen", role: .cancel) { paused = false }
|
||||
}
|
||||
.sheet(isPresented: $searchShown) {
|
||||
NavigationStack {
|
||||
ProductSearchView { picked in
|
||||
searchShown = false
|
||||
product = picked
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(item: $product) { item in
|
||||
NavigationStack {
|
||||
CheckOutFormView(product: item, units: units) { message in
|
||||
status = message
|
||||
product = nil
|
||||
paused = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func banner(_ text: String, color: Color) -> some View {
|
||||
Text(text)
|
||||
.font(.callout)
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(color.opacity(0.9))
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
private func resolve(_ code: String) async {
|
||||
paused = true
|
||||
error = nil
|
||||
status = nil
|
||||
do {
|
||||
let result = try await APIClient.shared.lookup(barcode: code)
|
||||
if let existing = result.existingProduct {
|
||||
product = existing
|
||||
} else {
|
||||
error = "Kein bekanntes Produkt zu diesem Code im Lager."
|
||||
paused = false
|
||||
}
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
paused = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Auslagern: Menge, Einheit und optional eine bestimmte Charge (sonst FEFO).
|
||||
struct CheckOutFormView: View {
|
||||
let product: Product
|
||||
let units: [Unit]
|
||||
var onDone: (String) -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var quantity = "1"
|
||||
@State private var unit = ""
|
||||
@State private var lots: [Lot] = []
|
||||
@State private var lotId: Int?
|
||||
@State private var busy = false
|
||||
@State private var error: String?
|
||||
|
||||
private var unitOptions: [String] {
|
||||
var options = units.filter { $0.kind == product.kind }.map(\.name)
|
||||
if let size = product.packageSize, size > 0 {
|
||||
options.append(product.packageLabel ?? "Packung")
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
VStack(alignment: .leading) {
|
||||
Text(product.name).font(.headline)
|
||||
Text("Verfügbar: \(format(product.stockInArticleUnits)) \(product.articleUnitLabel)")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Charge") {
|
||||
Picker("Charge", selection: $lotId) {
|
||||
Text("Automatisch (zuerst ablaufende)").tag(Int?.none)
|
||||
ForEach(lots) { lot in
|
||||
Text(label(for: lot)).tag(Int?.some(lot.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Menge") {
|
||||
TextField("Menge", text: $quantity).keyboardType(.decimalPad)
|
||||
Picker("Einheit", selection: $unit) {
|
||||
ForEach(unitOptions, id: \.self) { Text($0).tag($0) }
|
||||
}
|
||||
}
|
||||
|
||||
if let error {
|
||||
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(busy ? "Speichern…" : "Auslagern") { Task { await submit() } }
|
||||
.disabled(busy)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Auslagern")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
|
||||
}
|
||||
.task {
|
||||
unit = (product.packageSize ?? 0) > 0 ? (product.packageLabel ?? "Packung") : product.unitName
|
||||
lots = (try? await APIClient.shared.lots(productId: product.id)) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
private func label(for lot: Lot) -> String {
|
||||
let amount = format(lot.quantity / product.articleUnitFactor)
|
||||
let date = lot.bestBefore ?? "ohne MHD"
|
||||
return "\(date) · \(amount) \(product.articleUnitLabel)"
|
||||
}
|
||||
|
||||
private func format(_ value: Double) -> String {
|
||||
value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value)
|
||||
}
|
||||
|
||||
private func submit() async {
|
||||
error = nil
|
||||
guard let amount = Double(quantity.replacingOccurrences(of: ",", with: ".")), amount > 0 else {
|
||||
error = "Bitte eine Menge größer 0 angeben."
|
||||
return
|
||||
}
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
do {
|
||||
let response = try await APIClient.shared.checkOut(
|
||||
CheckOutRequest(productId: product.id, quantity: amount, unit: unit, lotId: lotId)
|
||||
)
|
||||
let total = response.productStock / product.articleUnitFactor
|
||||
onDone("Ausgelagert. Neuer Bestand: \(format(total)) \(product.articleUnitLabel)")
|
||||
dismiss()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
59
ios/Sources/Info.plist
Normal file
59
ios/Sources/Info.plist
Normal file
@@ -0,0 +1,59 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Project-Good</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
|
||||
<!-- Kamera für den Barcode-Scan -->
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Die Kamera wird zum Scannen von Barcodes beim Ein- und Auslagern verwendet.</string>
|
||||
|
||||
<!-- Lokaler Server ohne HTTPS (Heimnetz) -->
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
|
||||
<!-- Schnellaktionen beim langen Druck auf das App-Symbol -->
|
||||
<key>UIApplicationShortcutItems</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UIApplicationShortcutItemType</key>
|
||||
<string>com.scarriffle.projectgood.checkin</string>
|
||||
<key>UIApplicationShortcutItemTitle</key>
|
||||
<string>Einlagern</string>
|
||||
<key>UIApplicationShortcutItemIconType</key>
|
||||
<string>UIApplicationShortcutIconTypeAdd</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>UIApplicationShortcutItemType</key>
|
||||
<string>com.scarriffle.projectgood.checkout</string>
|
||||
<key>UIApplicationShortcutItemTitle</key>
|
||||
<string>Auslagern</string>
|
||||
<key>UIApplicationShortcutItemIconType</key>
|
||||
<string>UIApplicationShortcutIconTypeRemove</string>
|
||||
</dict>
|
||||
</array>
|
||||
|
||||
<!-- projectgood://checkin bzw. projectgood://checkout -->
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.scarriffle.projectgood</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>projectgood</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
51
ios/Sources/LoginView.swift
Normal file
51
ios/Sources/LoginView.swift
Normal file
@@ -0,0 +1,51 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LoginView: View {
|
||||
@EnvironmentObject private var session: Session
|
||||
|
||||
@State private var server: String = Session.shared.baseURL?.absoluteString ?? ""
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
@State private var error: String?
|
||||
@State private var busy = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Server") {
|
||||
TextField("http://192.168.1.10:8080", text: $server)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.keyboardType(.URL)
|
||||
}
|
||||
Section("Anmeldung") {
|
||||
TextField("Benutzername", text: $username)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
SecureField("Passwort", text: $password)
|
||||
}
|
||||
if let error {
|
||||
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||||
}
|
||||
Section {
|
||||
Button(busy ? "Anmelden…" : "Anmelden") { Task { await login() } }
|
||||
.disabled(busy || server.isEmpty || username.isEmpty)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Project-Good")
|
||||
}
|
||||
}
|
||||
|
||||
private func login() async {
|
||||
error = nil
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
session.setServer(server)
|
||||
do {
|
||||
let token = try await APIClient.shared.login(username: username, password: password)
|
||||
session.store(token: token.accessToken, username: token.username, isAdmin: token.role == "admin")
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
204
ios/Sources/Models.swift
Normal file
204
ios/Sources/Models.swift
Normal file
@@ -0,0 +1,204 @@
|
||||
import Foundation
|
||||
|
||||
// Entspricht den Schemas des Backends (backend/app/schemas.py).
|
||||
|
||||
struct TokenResponse: Codable {
|
||||
let accessToken: String
|
||||
let role: String
|
||||
let username: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case accessToken = "access_token"
|
||||
case role, username
|
||||
}
|
||||
}
|
||||
|
||||
struct MeResponse: Codable {
|
||||
let id: Int
|
||||
let username: String
|
||||
let role: String
|
||||
|
||||
var isAdmin: Bool { role == "admin" }
|
||||
}
|
||||
|
||||
struct Unit: Codable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let kind: String
|
||||
let factor: Double
|
||||
}
|
||||
|
||||
struct BarcodeEntry: Codable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let code: String
|
||||
let note: String?
|
||||
}
|
||||
|
||||
struct Product: Codable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let barcode: String?
|
||||
let name: String
|
||||
let brand: String?
|
||||
let imageUrl: String?
|
||||
let baseUnit: String
|
||||
let packageSize: Double?
|
||||
let packageLabel: String?
|
||||
let groupId: Int?
|
||||
let minStock: Double?
|
||||
let stock: Double
|
||||
let expiredCount: Int
|
||||
let kind: String
|
||||
let unitName: String
|
||||
let unitFactor: Double
|
||||
let barcodes: [BarcodeEntry]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, barcode, name, brand, stock, kind, barcodes
|
||||
case imageUrl = "image_url"
|
||||
case baseUnit = "base_unit"
|
||||
case packageSize = "package_size"
|
||||
case packageLabel = "package_label"
|
||||
case groupId = "group_id"
|
||||
case minStock = "min_stock"
|
||||
case expiredCount = "expired_count"
|
||||
case unitName = "unit_name"
|
||||
case unitFactor = "unit_factor"
|
||||
}
|
||||
|
||||
/// Bezeichnung der Artikeleinheit (Gebinde, sonst Produkteinheit).
|
||||
var articleUnitLabel: String {
|
||||
if let size = packageSize, size > 0 { return packageLabel ?? "Packung" }
|
||||
return unitName
|
||||
}
|
||||
|
||||
/// Faktor, um Basiseinheiten in Artikeleinheiten umzurechnen.
|
||||
var articleUnitFactor: Double {
|
||||
if let size = packageSize, size > 0 { return size }
|
||||
return unitFactor == 0 ? 1 : unitFactor
|
||||
}
|
||||
|
||||
var stockInArticleUnits: Double { stock / articleUnitFactor }
|
||||
}
|
||||
|
||||
struct LookupResult: Codable {
|
||||
let found: Bool
|
||||
let existingProduct: Product?
|
||||
let suggestion: Suggestion?
|
||||
let groupId: Int?
|
||||
let groupName: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case found, suggestion
|
||||
case existingProduct = "existing_product"
|
||||
case groupId = "group_id"
|
||||
case groupName = "group_name"
|
||||
}
|
||||
|
||||
struct Suggestion: Codable {
|
||||
let barcode: String?
|
||||
let name: String
|
||||
let brand: String?
|
||||
let imageUrl: String?
|
||||
let baseUnit: String?
|
||||
let packageSize: Double?
|
||||
let quantityText: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case barcode, name, brand
|
||||
case imageUrl = "image_url"
|
||||
case baseUnit = "base_unit"
|
||||
case packageSize = "package_size"
|
||||
case quantityText = "quantity_text"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Lot: Codable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let productId: Int
|
||||
let quantity: Double
|
||||
let bestBefore: String?
|
||||
let locationId: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, quantity
|
||||
case productId = "product_id"
|
||||
case bestBefore = "best_before"
|
||||
case locationId = "location_id"
|
||||
}
|
||||
}
|
||||
|
||||
struct StorageLocation: Codable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let parentId: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name
|
||||
case parentId = "parent_id"
|
||||
}
|
||||
}
|
||||
|
||||
struct CheckInLine: Codable {
|
||||
let quantity: Double
|
||||
let bestBefore: String?
|
||||
let locationId: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case quantity
|
||||
case bestBefore = "best_before"
|
||||
case locationId = "location_id"
|
||||
}
|
||||
}
|
||||
|
||||
struct BatchCheckInRequest: Codable {
|
||||
let productId: Int
|
||||
let unit: String
|
||||
let lines: [CheckInLine]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case unit, lines
|
||||
case productId = "product_id"
|
||||
}
|
||||
}
|
||||
|
||||
struct CheckOutRequest: Codable {
|
||||
let productId: Int
|
||||
let quantity: Double
|
||||
let unit: String
|
||||
let lotId: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case quantity, unit
|
||||
case productId = "product_id"
|
||||
case lotId = "lot_id"
|
||||
}
|
||||
}
|
||||
|
||||
struct StockResponse: Codable {
|
||||
let productStock: Double
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case productStock = "product_stock"
|
||||
}
|
||||
}
|
||||
|
||||
struct NewProductRequest: Codable {
|
||||
let barcode: String?
|
||||
let name: String
|
||||
let brand: String?
|
||||
let imageUrl: String?
|
||||
let baseUnit: String
|
||||
let packageSize: Double?
|
||||
let packageLabel: String?
|
||||
let groupId: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case barcode, name, brand
|
||||
case imageUrl = "image_url"
|
||||
case baseUnit = "base_unit"
|
||||
case packageSize = "package_size"
|
||||
case packageLabel = "package_label"
|
||||
case groupId = "group_id"
|
||||
}
|
||||
}
|
||||
136
ios/Sources/ProductViews.swift
Normal file
136
ios/Sources/ProductViews.swift
Normal file
@@ -0,0 +1,136 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Artikel per Namenssuche auswählen (Alternative zum Scannen).
|
||||
struct ProductSearchView: View {
|
||||
var onPick: (Product) -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var query = ""
|
||||
@State private var results: [Product] = []
|
||||
@State private var busy = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(results) { product in
|
||||
Button {
|
||||
onPick(product)
|
||||
dismiss()
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(product.name)
|
||||
Text("\(formatted(product.stockInArticleUnits)) \(product.articleUnitLabel)")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
if results.isEmpty && !busy {
|
||||
Text("Keine Treffer").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.searchable(text: $query, prompt: "Artikel suchen")
|
||||
.onSubmit(of: .search) { Task { await search() } }
|
||||
.task { await search() }
|
||||
.onChange(of: query) { _, _ in Task { await search() } }
|
||||
.navigationTitle("Artikel suchen")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
|
||||
}
|
||||
}
|
||||
|
||||
private func formatted(_ value: Double) -> String {
|
||||
value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value)
|
||||
}
|
||||
|
||||
private func search() async {
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
results = (try? await APIClient.shared.searchProducts(query)) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// Neues Produkt anlegen – bei Bedarf mit den Daten von Open Food Facts vorbefüllt.
|
||||
struct ProductFormView: View {
|
||||
let prefillBarcode: String?
|
||||
let groupId: Int?
|
||||
var suggestion: LookupResult.Suggestion?
|
||||
var onCreated: (Product) -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var barcode = ""
|
||||
@State private var name = ""
|
||||
@State private var brand = ""
|
||||
@State private var baseUnit = "piece"
|
||||
@State private var packageSize = ""
|
||||
@State private var packageLabel = ""
|
||||
@State private var busy = false
|
||||
@State private var error: String?
|
||||
|
||||
private let baseUnits = [("piece", "Stück"), ("gram", "Gramm"), ("milliliter", "Milliliter")]
|
||||
private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"]
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Artikel") {
|
||||
TextField("Barcode", text: $barcode).keyboardType(.numberPad)
|
||||
TextField("Name", text: $name)
|
||||
TextField("Marke", text: $brand)
|
||||
}
|
||||
Section("Einheit") {
|
||||
Picker("Basiseinheit", selection: $baseUnit) {
|
||||
ForEach(baseUnits, id: \.0) { Text($0.1).tag($0.0) }
|
||||
}
|
||||
TextField("Packungsgröße (in Basiseinheit)", text: $packageSize)
|
||||
.keyboardType(.decimalPad)
|
||||
Picker("Bezeichnung", selection: $packageLabel) {
|
||||
ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) }
|
||||
}
|
||||
}
|
||||
if let error {
|
||||
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||||
}
|
||||
Section {
|
||||
Button(busy ? "Anlegen…" : "Anlegen & weiter") { Task { await create() } }
|
||||
.disabled(busy || name.isEmpty)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Neuer Artikel")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear(perform: prefill)
|
||||
}
|
||||
|
||||
private func prefill() {
|
||||
barcode = suggestion?.barcode ?? prefillBarcode ?? ""
|
||||
if let suggestion {
|
||||
name = suggestion.name
|
||||
brand = suggestion.brand ?? ""
|
||||
baseUnit = suggestion.baseUnit ?? "piece"
|
||||
if let size = suggestion.packageSize { packageSize = String(size) }
|
||||
}
|
||||
}
|
||||
|
||||
private func create() async {
|
||||
error = nil
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
do {
|
||||
let product = try await APIClient.shared.createProduct(
|
||||
NewProductRequest(
|
||||
barcode: barcode.isEmpty ? nil : barcode,
|
||||
name: name,
|
||||
brand: brand.isEmpty ? nil : brand,
|
||||
imageUrl: suggestion?.imageUrl,
|
||||
baseUnit: baseUnit,
|
||||
packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")),
|
||||
packageLabel: packageLabel.isEmpty ? nil : packageLabel,
|
||||
groupId: groupId
|
||||
)
|
||||
)
|
||||
onCreated(product)
|
||||
dismiss()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
69
ios/Sources/ProjectGoodApp.swift
Normal file
69
ios/Sources/ProjectGoodApp.swift
Normal file
@@ -0,0 +1,69 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// Zielbildschirm, der über Home-Screen-Shortcut oder URL angesprungen wird.
|
||||
enum Route: String {
|
||||
case checkin
|
||||
case checkout
|
||||
}
|
||||
|
||||
final class Router: ObservableObject {
|
||||
@Published var route: Route?
|
||||
|
||||
func handle(shortcut type: String) {
|
||||
if type.hasSuffix("checkin") { route = .checkin }
|
||||
if type.hasSuffix("checkout") { route = .checkout }
|
||||
}
|
||||
|
||||
/// projectgood://checkin bzw. projectgood://checkout
|
||||
func handle(url: URL) {
|
||||
guard url.scheme == "projectgood" else { return }
|
||||
let target = (url.host ?? url.path.replacingOccurrences(of: "/", with: "")).lowercased()
|
||||
if let route = Route(rawValue: target) { self.route = route }
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct ProjectGoodApp: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
@StateObject private var session = Session.shared
|
||||
@StateObject private var router = Router()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
.environmentObject(session)
|
||||
.environmentObject(router)
|
||||
.onOpenURL { router.handle(url: $0) }
|
||||
.onAppear {
|
||||
if let type = AppDelegate.pendingShortcut {
|
||||
router.handle(shortcut: type)
|
||||
AppDelegate.pendingShortcut = nil
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: AppDelegate.shortcutNotification)) { note in
|
||||
if let type = note.object as? String { router.handle(shortcut: type) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Nimmt Home-Screen-Quick-Actions entgegen (auch beim Kaltstart).
|
||||
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
static let shortcutNotification = Notification.Name("ProjectGoodShortcut")
|
||||
static var pendingShortcut: String?
|
||||
|
||||
func application(_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||
if let item = launchOptions?[.shortcutItem] as? UIApplicationShortcutItem {
|
||||
AppDelegate.pendingShortcut = item.type
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication,
|
||||
performActionFor shortcutItem: UIApplicationShortcutItem) async -> Bool {
|
||||
NotificationCenter.default.post(name: AppDelegate.shortcutNotification, object: shortcutItem.type)
|
||||
return true
|
||||
}
|
||||
}
|
||||
93
ios/Sources/RootView.swift
Normal file
93
ios/Sources/RootView.swift
Normal file
@@ -0,0 +1,93 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RootView: View {
|
||||
@EnvironmentObject private var session: Session
|
||||
@EnvironmentObject private var router: Router
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if session.isLoggedIn {
|
||||
HomeView()
|
||||
} else {
|
||||
LoginView()
|
||||
}
|
||||
}
|
||||
// Shortcut/URL öffnet den passenden Scan-Bildschirm direkt.
|
||||
.fullScreenCover(item: $router.route) { route in
|
||||
NavigationStack {
|
||||
switch route {
|
||||
case .checkin: CheckInView()
|
||||
case .checkout: CheckOutView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Route: Identifiable {
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
struct HomeView: View {
|
||||
@EnvironmentObject private var session: Session
|
||||
@EnvironmentObject private var router: Router
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 16) {
|
||||
Button {
|
||||
router.route = .checkin
|
||||
} label: {
|
||||
ActionTile(title: "Einlagern", subtitle: "Barcode scannen und Bestand erfassen",
|
||||
systemImage: "arrow.down.to.line")
|
||||
}
|
||||
Button {
|
||||
router.route = .checkout
|
||||
} label: {
|
||||
ActionTile(title: "Auslagern", subtitle: "Barcode scannen und Menge entnehmen",
|
||||
systemImage: "arrow.up.from.line")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("Project-Good")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Text("Angemeldet als \(session.username)")
|
||||
Button("Abmelden", role: .destructive) { session.logout() }
|
||||
} label: {
|
||||
Image(systemName: "person.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ActionTile: View {
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let systemImage: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.title2)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(Color.accentColor.opacity(0.15))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title).font(.headline)
|
||||
Text(subtitle).font(.caption).foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right").foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
107
ios/Sources/ScannerView.swift
Normal file
107
ios/Sources/ScannerView.swift
Normal file
@@ -0,0 +1,107 @@
|
||||
import AVFoundation
|
||||
import SwiftUI
|
||||
|
||||
/// Live-Kamerabild mit Barcode-Erkennung (EAN-8/13, UPC-E, Code128, QR).
|
||||
/// Meldet jeden erkannten Code einmal – erst nach `resume()` wieder.
|
||||
struct ScannerView: UIViewControllerRepresentable {
|
||||
var onCode: (String) -> Void
|
||||
@Binding var isPaused: Bool
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(onCode: onCode) }
|
||||
|
||||
func makeUIViewController(context: Context) -> ScannerViewController {
|
||||
let controller = ScannerViewController()
|
||||
controller.delegate = context.coordinator
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_ controller: ScannerViewController, context: Context) {
|
||||
context.coordinator.isPaused = isPaused
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, ScannerViewControllerDelegate {
|
||||
let onCode: (String) -> Void
|
||||
var isPaused: Bool = false
|
||||
private var lastCode: String?
|
||||
private var lastTime: Date = .distantPast
|
||||
|
||||
init(onCode: @escaping (String) -> Void) { self.onCode = onCode }
|
||||
|
||||
func scanner(_ controller: ScannerViewController, didFind code: String) {
|
||||
guard !isPaused else { return }
|
||||
// Entprellen: derselbe Code nicht mehrfach in kurzer Folge.
|
||||
if code == lastCode, Date().timeIntervalSince(lastTime) < 2 { return }
|
||||
lastCode = code
|
||||
lastTime = Date()
|
||||
AudioServicesPlaySystemSound(1057)
|
||||
onCode(code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protocol ScannerViewControllerDelegate: AnyObject {
|
||||
func scanner(_ controller: ScannerViewController, didFind code: String)
|
||||
}
|
||||
|
||||
final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
|
||||
weak var delegate: ScannerViewControllerDelegate?
|
||||
|
||||
private let session = AVCaptureSession()
|
||||
private var preview: AVCaptureVideoPreviewLayer?
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .black
|
||||
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
||||
guard granted else { return }
|
||||
DispatchQueue.main.async { self?.configure() }
|
||||
}
|
||||
}
|
||||
|
||||
private func configure() {
|
||||
guard let device = AVCaptureDevice.default(for: .video),
|
||||
let input = try? AVCaptureDeviceInput(device: device),
|
||||
session.canAddInput(input) else { return }
|
||||
session.addInput(input)
|
||||
|
||||
let output = AVCaptureMetadataOutput()
|
||||
guard session.canAddOutput(output) else { return }
|
||||
session.addOutput(output)
|
||||
output.setMetadataObjectsDelegate(self, queue: .main)
|
||||
output.metadataObjectTypes = [.ean13, .ean8, .upce, .code128, .code39, .qr]
|
||||
|
||||
let layer = AVCaptureVideoPreviewLayer(session: session)
|
||||
layer.videoGravity = .resizeAspectFill
|
||||
layer.frame = view.bounds
|
||||
view.layer.addSublayer(layer)
|
||||
preview = layer
|
||||
|
||||
start()
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard !session.isRunning else { return }
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in self?.session.startRunning() }
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard session.isRunning else { return }
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in self?.session.stopRunning() }
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); start() }
|
||||
override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated); stop() }
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
preview?.frame = view.bounds
|
||||
}
|
||||
|
||||
func metadataOutput(_ output: AVCaptureMetadataOutput,
|
||||
didOutput metadataObjects: [AVMetadataObject],
|
||||
from connection: AVCaptureConnection) {
|
||||
guard let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
|
||||
let value = object.stringValue else { return }
|
||||
delegate?.scanner(self, didFind: value)
|
||||
}
|
||||
}
|
||||
95
ios/Sources/Session.swift
Normal file
95
ios/Sources/Session.swift
Normal file
@@ -0,0 +1,95 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Hält Server-Adresse und Anmeldedaten. Das Token liegt im Keychain,
|
||||
/// die Server-URL in den UserDefaults (nicht geheim).
|
||||
final class Session: ObservableObject {
|
||||
static let shared = Session()
|
||||
|
||||
private let urlKey = "server_url"
|
||||
private let keychainAccount = "project-good-token"
|
||||
|
||||
@Published private(set) var baseURL: URL?
|
||||
@Published private(set) var token: String?
|
||||
@Published var username: String = ""
|
||||
@Published var isAdmin: Bool = false
|
||||
|
||||
var isLoggedIn: Bool { token != nil && baseURL != nil }
|
||||
|
||||
private init() {
|
||||
if let stored = UserDefaults.standard.string(forKey: urlKey) {
|
||||
baseURL = Session.normalize(stored)
|
||||
}
|
||||
token = Keychain.read(account: keychainAccount)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
func setServer(_ raw: String) {
|
||||
guard let url = Session.normalize(raw) else { return }
|
||||
baseURL = url
|
||||
UserDefaults.standard.set(url.absoluteString, forKey: urlKey)
|
||||
}
|
||||
|
||||
func store(token newToken: String, username name: String, isAdmin admin: Bool) {
|
||||
token = newToken
|
||||
username = name
|
||||
isAdmin = admin
|
||||
Keychain.write(newToken, account: keychainAccount)
|
||||
}
|
||||
|
||||
func logout() {
|
||||
token = nil
|
||||
username = ""
|
||||
isAdmin = false
|
||||
Keychain.delete(account: keychainAccount)
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimaler Keychain-Zugriff für ein einzelnes Token.
|
||||
enum Keychain {
|
||||
private static let service = "com.scarriffle.projectgood"
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user