Compare commits
2 Commits
2ad197df42
...
96172c6fa0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96172c6fa0 | ||
|
|
85ac3dfa50 |
@@ -108,17 +108,18 @@ struct QuickBookingCard: View {
|
|||||||
fehler = nil
|
fehler = nil
|
||||||
meldung = nil
|
meldung = nil
|
||||||
do {
|
do {
|
||||||
// "article" ist die Einheit, in der der Artikel gefuehrt wird -
|
// Menge in Artikeleinheiten: Gebinde -> Packung, sonst Basiseinheit.
|
||||||
// genau die, die in der Karte steht.
|
// Der Server kennt kein "article".
|
||||||
|
let unit = (artikel.packageSize ?? 0) > 0 ? "package" : artikel.baseUnit
|
||||||
if einlagern {
|
if einlagern {
|
||||||
let zeile = CheckInLine(quantity: wert, bestBefore: nil,
|
let zeile = CheckInLine(quantity: wert, bestBefore: nil,
|
||||||
bestBeforePrecision: "day", locationId: nil)
|
bestBeforePrecision: "day", locationId: nil)
|
||||||
_ = try await APIClient.shared.checkInBatch(
|
_ = try await APIClient.shared.checkInBatch(
|
||||||
BatchCheckInRequest(productId: artikel.id, unit: "article", lines: [zeile]))
|
BatchCheckInRequest(productId: artikel.id, unit: unit, lines: [zeile]))
|
||||||
} else {
|
} else {
|
||||||
_ = try await APIClient.shared.checkOut(
|
_ = try await APIClient.shared.checkOut(
|
||||||
CheckOutRequest(productId: artikel.id, quantity: wert,
|
CheckOutRequest(productId: artikel.id, quantity: wert,
|
||||||
unit: "article", lotId: nil))
|
unit: unit, lotId: nil))
|
||||||
}
|
}
|
||||||
meldung = "\(formatAmount(wert)) \(einlagern ? "eingelagert" : "ausgelagert")."
|
meldung = "\(formatAmount(wert)) \(einlagern ? "eingelagert" : "ausgelagert")."
|
||||||
menge = "1"
|
menge = "1"
|
||||||
|
|||||||
@@ -58,6 +58,17 @@
|
|||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|
||||||
|
<!-- Hintergrund-Aktualisierung: die App frischt gelegentlich den Plan der
|
||||||
|
Ablauf-Benachrichtigungen auf. Zeitpunkt bestimmt iOS. -->
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>fetch</string>
|
||||||
|
</array>
|
||||||
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>com.scarriffle.vorrania.refresh</string>
|
||||||
|
</array>
|
||||||
|
|
||||||
<!-- Schnellaktionen beim langen Druck auf das App-Symbol -->
|
<!-- Schnellaktionen beim langen Druck auf das App-Symbol -->
|
||||||
<key>UIApplicationShortcutItems</key>
|
<key>UIApplicationShortcutItems</key>
|
||||||
<array>
|
<array>
|
||||||
|
|||||||
263
ios/Sources/NotificationScheduler.swift
Normal file
263
ios/Sources/NotificationScheduler.swift
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import Foundation
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
// MARK: - Reine Planung (netzfrei, testbar)
|
||||||
|
|
||||||
|
/// Eine fertig berechnete Meldung: an welchem Tag, welcher Art, mit welchen
|
||||||
|
/// Produkten. Bewusst ohne iOS-Typen, damit sich die Logik ohne Geraet pruefen
|
||||||
|
/// laesst.
|
||||||
|
struct PlannedNotification: Equatable {
|
||||||
|
enum Kind { case upcoming, expired }
|
||||||
|
|
||||||
|
let profileID: UUID
|
||||||
|
let serverName: String
|
||||||
|
/// Tagesbeginn des Meldetags.
|
||||||
|
let day: Date
|
||||||
|
let kind: Kind
|
||||||
|
/// Produktnamen fuer diesen Tag, ohne Dopplung, in stabiler Reihenfolge.
|
||||||
|
let products: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rechnet aus den Ablaufdaten eines Servers die Meldungen aus. Alle
|
||||||
|
/// Entscheidungen des Nutzers stecken hier - deshalb strikt getrennt vom
|
||||||
|
/// Netz- und iOS-Teil.
|
||||||
|
enum ExpiryPlanner {
|
||||||
|
static func plan(
|
||||||
|
profileID: UUID,
|
||||||
|
serverName: String,
|
||||||
|
config: ServerNotificationConfig,
|
||||||
|
snapshot: ServerFetch.Snapshot,
|
||||||
|
today: Date,
|
||||||
|
horizonDays: Int,
|
||||||
|
calendar: Calendar = .current
|
||||||
|
) -> [PlannedNotification] {
|
||||||
|
guard config.hasSomethingToSchedule else { return [] }
|
||||||
|
|
||||||
|
let heute = calendar.startOfDay(for: today)
|
||||||
|
guard let horizont = calendar.date(byAdding: .day, value: horizonDays, to: heute) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Produkt -> Kategorie, und der Kategorie-Baum fuer die Nachfahren.
|
||||||
|
let kategorieVon = Dictionary(
|
||||||
|
snapshot.products.map { ($0.id, $0.categoryId) },
|
||||||
|
uniquingKeysWith: { first, _ in first }
|
||||||
|
)
|
||||||
|
let nachfahren = descendantMap(snapshot.categories)
|
||||||
|
|
||||||
|
let allgemeine = config.rules.filter { $0.categoryId == nil }
|
||||||
|
let kategorieRegeln = config.rules.filter { $0.categoryId != nil }
|
||||||
|
|
||||||
|
// Tag -> Produktnamen, getrennt nach bald-ablaufend und abgelaufen.
|
||||||
|
var bald: [Date: [String]] = [:]
|
||||||
|
var abgelaufen: [Date: [String]] = [:]
|
||||||
|
|
||||||
|
func ergaenze(_ eimer: inout [Date: [String]], _ tag: Date, _ name: String) {
|
||||||
|
var liste = eimer[tag] ?? []
|
||||||
|
if !liste.contains(name) { liste.append(name) }
|
||||||
|
eimer[tag] = liste
|
||||||
|
}
|
||||||
|
|
||||||
|
for item in snapshot.expiring {
|
||||||
|
guard let mhd = parseTag(item.bestBefore, calendar: calendar) else { continue }
|
||||||
|
let mhdTag = calendar.startOfDay(for: mhd)
|
||||||
|
|
||||||
|
// Welche Regeln gelten fuer dieses Produkt? Kategorie ersetzt Allgemeine:
|
||||||
|
// greift eine Kategorie-Regel (Produktkategorie im Teilbaum), zaehlen
|
||||||
|
// nur diese, sonst die allgemeinen.
|
||||||
|
let produktKategorie = kategorieVon[item.productId] ?? nil
|
||||||
|
let passendeKategorie = kategorieRegeln.filter { regel in
|
||||||
|
guard let ziel = regel.categoryId, let pk = produktKategorie else { return false }
|
||||||
|
return pk == ziel || (nachfahren[ziel]?.contains(pk) ?? false)
|
||||||
|
}
|
||||||
|
let regeln = passendeKategorie.isEmpty ? allgemeine : passendeKategorie
|
||||||
|
|
||||||
|
for regel in regeln {
|
||||||
|
guard let fensterStart = calendar.date(
|
||||||
|
byAdding: .day, value: -regel.leadDays, to: mhdTag) else { continue }
|
||||||
|
|
||||||
|
// Erinnerungen liegen vor dem Ablauftag (der Ablauftag gehoert der
|
||||||
|
// letzten Warnung). Einmalig: nur der Starttag; wiederkehrend: jeder
|
||||||
|
// Tag im Fenster bis zum Vortag des Ablaufs.
|
||||||
|
let letzterTag = regel.recurring
|
||||||
|
? (calendar.date(byAdding: .day, value: -1, to: mhdTag) ?? fensterStart)
|
||||||
|
: fensterStart
|
||||||
|
|
||||||
|
var tag = fensterStart
|
||||||
|
while tag <= letzterTag {
|
||||||
|
if tag >= heute && tag <= horizont && tag < mhdTag {
|
||||||
|
ergaenze(&bald, tag, item.productName)
|
||||||
|
}
|
||||||
|
guard let naechster = calendar.date(byAdding: .day, value: 1, to: tag) else { break }
|
||||||
|
tag = naechster
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Letzte Warnung: am Ablauftag selbst, fuer alle Produkte, danach nie
|
||||||
|
// wieder (bereits abgelaufene Chargen lassen wir bewusst ruhen).
|
||||||
|
if config.lastWarningOnExpiry && mhdTag >= heute && mhdTag <= horizont {
|
||||||
|
ergaenze(&abgelaufen, mhdTag, item.productName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result: [PlannedNotification] = []
|
||||||
|
for (tag, namen) in bald {
|
||||||
|
result.append(PlannedNotification(
|
||||||
|
profileID: profileID, serverName: serverName, day: tag,
|
||||||
|
kind: .upcoming, products: namen))
|
||||||
|
}
|
||||||
|
for (tag, namen) in abgelaufen {
|
||||||
|
result.append(PlannedNotification(
|
||||||
|
profileID: profileID, serverName: serverName, day: tag,
|
||||||
|
kind: .expired, products: namen))
|
||||||
|
}
|
||||||
|
// Nach Datum, dann abgelaufen vor bald-ablaufend (dringender zuerst).
|
||||||
|
return result.sorted {
|
||||||
|
$0.day != $1.day ? $0.day < $1.day
|
||||||
|
: ($0.kind == .expired && $1.kind == .upcoming)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kategorie-Id -> alle darunterliegenden Kategorie-Ids (ohne die eigene).
|
||||||
|
static func descendantMap(_ categories: [CategoryItem]) -> [Int: Set<Int>] {
|
||||||
|
var kinder: [Int: [Int]] = [:]
|
||||||
|
for c in categories {
|
||||||
|
if let p = c.parentId { kinder[p, default: []].append(c.id) }
|
||||||
|
}
|
||||||
|
var ergebnis: [Int: Set<Int>] = [:]
|
||||||
|
for c in categories {
|
||||||
|
var alle: Set<Int> = []
|
||||||
|
var stapel = kinder[c.id] ?? []
|
||||||
|
while let n = stapel.popLast() {
|
||||||
|
if alle.insert(n).inserted { stapel.append(contentsOf: kinder[n] ?? []) }
|
||||||
|
}
|
||||||
|
ergebnis[c.id] = alle
|
||||||
|
}
|
||||||
|
return ergebnis
|
||||||
|
}
|
||||||
|
|
||||||
|
/// „yyyy-MM-dd" -> Date (Tagesbeginn). Monatsangaben liefert der Server bereits
|
||||||
|
/// als Monatsletzten, daher reicht das Tagesdatum.
|
||||||
|
static func parseTag(_ raw: String, calendar: Calendar) -> Date? {
|
||||||
|
let teile = raw.prefix(10).split(separator: "-")
|
||||||
|
guard teile.count == 3,
|
||||||
|
let y = Int(teile[0]), let m = Int(teile[1]), let d = Int(teile[2]) else { return nil }
|
||||||
|
return calendar.date(from: DateComponents(year: y, month: m, day: d))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - iOS-Planung
|
||||||
|
|
||||||
|
/// Plant die berechneten Meldungen als lokale Benachrichtigungen ein.
|
||||||
|
@MainActor
|
||||||
|
final class NotificationScheduler {
|
||||||
|
static let shared = NotificationScheduler()
|
||||||
|
|
||||||
|
/// Wie weit im Voraus geplant wird und wie viele Meldungen hoechstens - iOS
|
||||||
|
/// erlaubt 64 ausstehende je App, ueber alle Server zusammen.
|
||||||
|
static let horizonDays = 60
|
||||||
|
static let maxScheduled = 60
|
||||||
|
|
||||||
|
private var laeuftGerade = false
|
||||||
|
|
||||||
|
/// Fragt die Erlaubnis, falls noch nicht geschehen. Gibt zurueck, ob erlaubt.
|
||||||
|
@discardableResult
|
||||||
|
func requestAuthorization() async -> Bool {
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
let status = await center.notificationSettings().authorizationStatus
|
||||||
|
switch status {
|
||||||
|
case .authorized, .provisional, .ephemeral:
|
||||||
|
return true
|
||||||
|
case .notDetermined:
|
||||||
|
return (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rechnet alle Server neu durch und ersetzt den geplanten Bestand.
|
||||||
|
func rescheduleAll() async {
|
||||||
|
guard !laeuftGerade else { return }
|
||||||
|
laeuftGerade = true
|
||||||
|
defer { laeuftGerade = false }
|
||||||
|
|
||||||
|
let profile = Session.shared.profiles
|
||||||
|
let relevant = profile.filter {
|
||||||
|
NotificationStore.config(for: $0.id).hasSomethingToSchedule
|
||||||
|
}
|
||||||
|
guard !relevant.isEmpty else {
|
||||||
|
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard await requestAuthorization() else { return }
|
||||||
|
|
||||||
|
var geplant: [PlannedNotification] = []
|
||||||
|
let heute = Date()
|
||||||
|
|
||||||
|
for prof in relevant {
|
||||||
|
guard let url = prof.url,
|
||||||
|
let token = Keychain.read(account: Session.keychainAccount(for: prof.id))
|
||||||
|
else { continue }
|
||||||
|
let config = NotificationStore.config(for: prof.id)
|
||||||
|
do {
|
||||||
|
let snapshot = try await ServerFetch.snapshot(
|
||||||
|
baseURL: url, token: token, horizonDays: Self.horizonDays)
|
||||||
|
geplant += ExpiryPlanner.plan(
|
||||||
|
profileID: prof.id, serverName: prof.name, config: config,
|
||||||
|
snapshot: snapshot, today: heute, horizonDays: Self.horizonDays)
|
||||||
|
} catch {
|
||||||
|
// Unerreichbar (Heimnetz von unterwegs) oder Token abgelaufen:
|
||||||
|
// diesen Server ueberspringen, die anderen trotzdem planen.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nach Datum sortieren und auf das iOS-Limit kappen - die naechsten zuerst.
|
||||||
|
geplant.sort { $0.day != $1.day ? $0.day < $1.day
|
||||||
|
: ($0.kind == .expired && $1.kind == .upcoming) }
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
center.removeAllPendingNotificationRequests()
|
||||||
|
for meldung in geplant.prefix(Self.maxScheduled) {
|
||||||
|
center.add(request(for: meldung), withCompletionHandler: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func request(for meldung: PlannedNotification) -> UNNotificationRequest {
|
||||||
|
let inhalt = UNMutableNotificationContent()
|
||||||
|
let namen = meldung.products
|
||||||
|
let anzahl = namen.count
|
||||||
|
let liste = namen.prefix(4).joined(separator: ", ")
|
||||||
|
let mehr = anzahl > 4 ? " u. a." : ""
|
||||||
|
|
||||||
|
switch meldung.kind {
|
||||||
|
case .upcoming:
|
||||||
|
inhalt.title = meldung.serverName
|
||||||
|
inhalt.body = anzahl == 1
|
||||||
|
? "\(liste) läuft bald ab."
|
||||||
|
: "\(anzahl) Produkte laufen bald ab: \(liste)\(mehr)"
|
||||||
|
case .expired:
|
||||||
|
inhalt.title = "ACHTUNG – \(meldung.serverName)"
|
||||||
|
inhalt.body = anzahl == 1
|
||||||
|
? "\(liste) ist heute abgelaufen."
|
||||||
|
: "\(anzahl) Produkte sind abgelaufen: \(liste)\(mehr)"
|
||||||
|
}
|
||||||
|
inhalt.sound = .default
|
||||||
|
inhalt.userInfo = ["profileID": meldung.profileID.uuidString]
|
||||||
|
|
||||||
|
let config = NotificationStore.config(for: meldung.profileID)
|
||||||
|
var wann = Calendar.current.dateComponents([.year, .month, .day], from: meldung.day)
|
||||||
|
wann.hour = config.hour
|
||||||
|
wann.minute = config.minute
|
||||||
|
|
||||||
|
// Zwei Meldungen am selben Tag (bald + abgelaufen) brauchen verschiedene
|
||||||
|
// Kennungen, sonst ueberschreibt die zweite die erste.
|
||||||
|
let art = meldung.kind == .expired ? "expired" : "upcoming"
|
||||||
|
let tag = "\(wann.year ?? 0)-\(wann.month ?? 0)-\(wann.day ?? 0)"
|
||||||
|
let id = "vorrania.\(meldung.profileID.uuidString).\(tag).\(art)"
|
||||||
|
|
||||||
|
return UNNotificationRequest(
|
||||||
|
identifier: id,
|
||||||
|
content: inhalt,
|
||||||
|
trigger: UNCalendarNotificationTrigger(dateMatching: wann, repeats: false))
|
||||||
|
}
|
||||||
|
}
|
||||||
86
ios/Sources/NotificationSettings.swift
Normal file
86
ios/Sources/NotificationSettings.swift
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Einstellungen fuer Ablauf-Benachrichtigungen - je Server eigene.
|
||||||
|
///
|
||||||
|
/// Rein auf dem Geraet gespeichert (UserDefaults), nicht am Server: Es ist eine
|
||||||
|
/// Einstellung dieser App auf diesem iPhone, kein geteilter Serverzustand.
|
||||||
|
|
||||||
|
/// Die Einheit, in der „X vor Ablauf" gemeint ist.
|
||||||
|
enum LeadUnit: String, Codable, CaseIterable {
|
||||||
|
case days, weeks
|
||||||
|
|
||||||
|
var label: String { self == .weeks ? "Wochen" : "Tage" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Erinnerungsregel: „X Tage/Wochen vor Ablauf", optional nur fuer eine
|
||||||
|
/// Kategorie, einmalig oder taeglich wiederkehrend.
|
||||||
|
struct NotificationRule: Codable, Identifiable, Equatable {
|
||||||
|
let id: UUID
|
||||||
|
var amount: Int
|
||||||
|
var unit: LeadUnit
|
||||||
|
/// nil = alle Kategorien (die allgemeine Regel).
|
||||||
|
var categoryId: Int?
|
||||||
|
/// true = das Produkt taucht jeden Tag im Fenster (ab „X vorher" bis Ablauf)
|
||||||
|
/// in der Tagesmeldung auf; false = nur am Tag „X vorher".
|
||||||
|
var recurring: Bool
|
||||||
|
|
||||||
|
init(id: UUID = UUID(), amount: Int = 3, unit: LeadUnit = .days,
|
||||||
|
categoryId: Int? = nil, recurring: Bool = false) {
|
||||||
|
self.id = id
|
||||||
|
self.amount = amount
|
||||||
|
self.unit = unit
|
||||||
|
self.categoryId = categoryId
|
||||||
|
self.recurring = recurring
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vorlaufzeit in Tagen - Wochen werden umgerechnet.
|
||||||
|
var leadDays: Int { unit == .weeks ? amount * 7 : amount }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alle Benachrichtigungseinstellungen eines Servers.
|
||||||
|
struct ServerNotificationConfig: Codable, Equatable {
|
||||||
|
var enabled: Bool = false
|
||||||
|
/// Uhrzeit der Tagesmeldung.
|
||||||
|
var hour: Int = 9
|
||||||
|
var minute: Int = 0
|
||||||
|
/// Letzte Warnung am Ablauftag („ACHTUNG: … abgelaufen"), fuer alle Produkte.
|
||||||
|
var lastWarningOnExpiry: Bool = true
|
||||||
|
var rules: [NotificationRule] = []
|
||||||
|
|
||||||
|
/// Ohne aktive Regel und ohne letzte Warnung gibt es nichts zu planen.
|
||||||
|
var hasSomethingToSchedule: Bool {
|
||||||
|
enabled && (!rules.isEmpty || lastWarningOnExpiry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liest und schreibt die Konfigurationen. Ein Woerterbuch je Profil-UUID,
|
||||||
|
/// analog zu `ProfileStore`.
|
||||||
|
enum NotificationStore {
|
||||||
|
static let key = "notification_configs"
|
||||||
|
|
||||||
|
static func loadAll() -> [String: ServerNotificationConfig] {
|
||||||
|
guard let data = UserDefaults.standard.data(forKey: key),
|
||||||
|
let dict = try? JSONDecoder().decode([String: ServerNotificationConfig].self, from: data)
|
||||||
|
else { return [:] }
|
||||||
|
return dict
|
||||||
|
}
|
||||||
|
|
||||||
|
static func config(for profileID: UUID) -> ServerNotificationConfig {
|
||||||
|
loadAll()[profileID.uuidString] ?? ServerNotificationConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func save(_ config: ServerNotificationConfig, for profileID: UUID) {
|
||||||
|
var alle = loadAll()
|
||||||
|
alle[profileID.uuidString] = config
|
||||||
|
guard let data = try? JSONEncoder().encode(alle) else { return }
|
||||||
|
UserDefaults.standard.set(data, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Beim Loeschen eines Profils die zugehoerige Konfiguration mit entfernen.
|
||||||
|
static func remove(for profileID: UUID) {
|
||||||
|
var alle = loadAll()
|
||||||
|
alle.removeValue(forKey: profileID.uuidString)
|
||||||
|
guard let data = try? JSONEncoder().encode(alle) else { return }
|
||||||
|
UserDefaults.standard.set(data, forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
232
ios/Sources/NotificationSettingsView.swift
Normal file
232
ios/Sources/NotificationSettingsView.swift
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
/// Benachrichtigungs-Einstellungen eines Servers: Ein/Aus, Uhrzeit, letzte
|
||||||
|
/// Warnung und die Erinnerungsregeln.
|
||||||
|
struct NotificationSettingsView: View {
|
||||||
|
let profileID: UUID
|
||||||
|
let serverName: String
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var config = ServerNotificationConfig()
|
||||||
|
@State private var categories: [CategoryItem] = []
|
||||||
|
@State private var editingRule: NotificationRule?
|
||||||
|
@State private var addShown = false
|
||||||
|
@State private var berechtigungFehlt = false
|
||||||
|
|
||||||
|
private var uhrzeit: Binding<Date> {
|
||||||
|
Binding(
|
||||||
|
get: {
|
||||||
|
Calendar.current.date(from: DateComponents(
|
||||||
|
hour: config.hour, minute: config.minute)) ?? Date()
|
||||||
|
},
|
||||||
|
set: { neu in
|
||||||
|
let teile = Calendar.current.dateComponents([.hour, .minute], from: neu)
|
||||||
|
config.hour = teile.hour ?? 9
|
||||||
|
config.minute = teile.minute ?? 0
|
||||||
|
speichern()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
Toggle("Benachrichtigungen aktiv", isOn: Binding(
|
||||||
|
get: { config.enabled },
|
||||||
|
set: { an in Task { await setzeAktiv(an) } }
|
||||||
|
))
|
||||||
|
} footer: {
|
||||||
|
Text("Erinnert vor dem Ablauf von Produkten auf „\(serverName)“. Nur auf diesem iPhone, unabhängig von den anderen Servern.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if berechtigungFehlt {
|
||||||
|
Section {
|
||||||
|
Label("In den iOS-Einstellungen sind Mitteilungen für Vorrania aus. Ohne sie kommen keine Erinnerungen.",
|
||||||
|
systemImage: "exclamationmark.triangle")
|
||||||
|
.font(.callout).foregroundStyle(.orange)
|
||||||
|
Button("iOS-Einstellungen öffnen") {
|
||||||
|
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.enabled {
|
||||||
|
Section("Uhrzeit der Meldung") {
|
||||||
|
DatePicker("Täglich um", selection: uhrzeit, displayedComponents: .hourAndMinute)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Toggle("Letzte Warnung am Ablauftag", isOn: Binding(
|
||||||
|
get: { config.lastWarningOnExpiry },
|
||||||
|
set: { config.lastWarningOnExpiry = $0; speichern() }
|
||||||
|
))
|
||||||
|
} footer: {
|
||||||
|
Text("Am Tag des Ablaufs eine deutliche Warnung – für alle Produkte, unabhängig von den Regeln unten.")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
ForEach(config.rules) { regel in
|
||||||
|
Button {
|
||||||
|
editingRule = regel
|
||||||
|
} label: {
|
||||||
|
regelZeile(regel)
|
||||||
|
}
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
}
|
||||||
|
.onDelete { indexSet in
|
||||||
|
config.rules.remove(atOffsets: indexSet)
|
||||||
|
speichern()
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
addShown = true
|
||||||
|
} label: {
|
||||||
|
Label("Regel hinzufügen", systemImage: "plus")
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Regeln")
|
||||||
|
} footer: {
|
||||||
|
Text("Jede Regel erinnert „X vor Ablauf“. Eine Regel für eine Kategorie gilt auch für ihre Unterkategorien und ersetzt für diese Produkte die allgemeine Regel.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Benachrichtigungen")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.task {
|
||||||
|
config = NotificationStore.config(for: profileID)
|
||||||
|
await ladeKategorien()
|
||||||
|
await pruefeBerechtigung()
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $addShown) {
|
||||||
|
RuleEditView(rule: nil, categories: categories) { neu in
|
||||||
|
config.rules.append(neu)
|
||||||
|
speichern()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $editingRule) { regel in
|
||||||
|
RuleEditView(rule: regel, categories: categories) { geaendert in
|
||||||
|
if let index = config.rules.firstIndex(where: { $0.id == geaendert.id }) {
|
||||||
|
config.rules[index] = geaendert
|
||||||
|
speichern()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func regelZeile(_ regel: NotificationRule) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("\(regel.amount) \(regel.unit.label) vorher")
|
||||||
|
Text("\(kategorieName(regel.categoryId)) · \(regel.recurring ? "täglich" : "einmalig")")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func kategorieName(_ id: Int?) -> String {
|
||||||
|
guard let id else { return "Alle Kategorien" }
|
||||||
|
return categories.first { $0.id == id }?.name ?? "Kategorie \(id)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Aktionen
|
||||||
|
|
||||||
|
private func setzeAktiv(_ an: Bool) async {
|
||||||
|
if an {
|
||||||
|
let erlaubt = await NotificationScheduler.shared.requestAuthorization()
|
||||||
|
berechtigungFehlt = !erlaubt
|
||||||
|
guard erlaubt else { return }
|
||||||
|
}
|
||||||
|
config.enabled = an
|
||||||
|
speichern()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func speichern() {
|
||||||
|
NotificationStore.save(config, for: profileID)
|
||||||
|
Task { await NotificationScheduler.shared.rescheduleAll() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ladeKategorien() async {
|
||||||
|
// Kategorien vom Server dieses Profils - auch wenn es nicht der aktive ist.
|
||||||
|
guard let profile = Session.shared.profiles.first(where: { $0.id == profileID }),
|
||||||
|
let url = profile.url,
|
||||||
|
let token = Keychain.read(account: Session.keychainAccount(for: profileID))
|
||||||
|
else { return }
|
||||||
|
categories = (try? await ServerFetch.snapshot(
|
||||||
|
baseURL: url, token: token, horizonDays: 1).categories) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pruefeBerechtigung() async {
|
||||||
|
guard config.enabled else { berechtigungFehlt = false; return }
|
||||||
|
let status = await UNUserNotificationCenter.current().notificationSettings().authorizationStatus
|
||||||
|
berechtigungFehlt = !(status == .authorized || status == .provisional || status == .ephemeral)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anlegen und Bearbeiten einer Regel.
|
||||||
|
struct RuleEditView: View {
|
||||||
|
let rule: NotificationRule?
|
||||||
|
let categories: [CategoryItem]
|
||||||
|
let onSave: (NotificationRule) -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var amount: Int
|
||||||
|
@State private var unit: LeadUnit
|
||||||
|
@State private var categoryId: Int?
|
||||||
|
@State private var recurring: Bool
|
||||||
|
|
||||||
|
init(rule: NotificationRule?, categories: [CategoryItem],
|
||||||
|
onSave: @escaping (NotificationRule) -> Void) {
|
||||||
|
self.rule = rule
|
||||||
|
self.categories = categories
|
||||||
|
self.onSave = onSave
|
||||||
|
_amount = State(initialValue: rule?.amount ?? 3)
|
||||||
|
_unit = State(initialValue: rule?.unit ?? .days)
|
||||||
|
_categoryId = State(initialValue: rule?.categoryId)
|
||||||
|
_recurring = State(initialValue: rule?.recurring ?? false)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("Vorlauf") {
|
||||||
|
Stepper("\(amount) \(unit.label)", value: $amount, in: 1...99)
|
||||||
|
Picker("Einheit", selection: $unit) {
|
||||||
|
ForEach(LeadUnit.allCases, id: \.self) { Text($0.label).tag($0) }
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
CategoryPicker(categories: categories, selection: $categoryId)
|
||||||
|
} header: {
|
||||||
|
Text("Kategorie")
|
||||||
|
} footer: {
|
||||||
|
Text("„Alle Kategorien“ ist die allgemeine Regel. Eine bestimmte Kategorie schließt ihre Unterkategorien ein.")
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
Toggle("Täglich wiederholen", isOn: $recurring)
|
||||||
|
} footer: {
|
||||||
|
Text(recurring
|
||||||
|
? "Erinnert jeden Tag ab „\(amount) \(unit.label) vorher“ bis zum Ablauf."
|
||||||
|
: "Erinnert einmal, genau \(amount) \(unit.label) vor dem Ablauf.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(rule == nil ? "Regel hinzufügen" : "Regel bearbeiten")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button("Abbrechen") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("Sichern") {
|
||||||
|
onSave(NotificationRule(
|
||||||
|
id: rule?.id ?? UUID(), amount: amount, unit: unit,
|
||||||
|
categoryId: categoryId, recurring: recurring))
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,12 +22,20 @@ struct RootView: View {
|
|||||||
await session.refreshMe()
|
await session.refreshMe()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Shortcut/URL öffnet den passenden Scan-Bildschirm direkt.
|
// Shortcut/URL öffnet den passenden Scan-Bildschirm direkt; eine
|
||||||
|
// getippte Benachrichtigung öffnet „Bald ablaufend".
|
||||||
.fullScreenCover(item: $router.route) { route in
|
.fullScreenCover(item: $router.route) { route in
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
switch route {
|
switch route {
|
||||||
case .checkin: CheckInView()
|
case .checkin: CheckInView()
|
||||||
case .checkout: CheckOutView()
|
case .checkout: CheckOutView()
|
||||||
|
case .expiring:
|
||||||
|
ExpiringView()
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button("Fertig") { router.route = nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
46
ios/Sources/ServerFetch.swift
Normal file
46
ios/Sources/ServerFetch.swift
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Liest Daten von einem **bestimmten** Server, ohne den aktiven zu wechseln.
|
||||||
|
///
|
||||||
|
/// `APIClient` ist fest auf das aktive Profil verdrahtet (Session.shared).
|
||||||
|
/// Fuer die Benachrichtigungen muss die App aber jeden eingerichteten Server
|
||||||
|
/// abfragen - deshalb dieser parallele Pfad, der baseURL und Token ausdruecklich
|
||||||
|
/// bekommt. Modelle und Fehlerbedeutung sind dieselben wie im `APIClient`.
|
||||||
|
enum ServerFetch {
|
||||||
|
/// Alles, was der Planer je Server braucht: ablaufende Chargen im Horizont,
|
||||||
|
/// die Produkte (fuer die Kategoriezuordnung) und der Kategorie-Baum.
|
||||||
|
struct Snapshot {
|
||||||
|
let expiring: [ExpiringItem]
|
||||||
|
let products: [Product]
|
||||||
|
let categories: [CategoryItem]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func snapshot(baseURL: URL, token: String, horizonDays: Int) async throws -> Snapshot {
|
||||||
|
async let expiring: [ExpiringItem] = get(
|
||||||
|
"/expiring?days=\(horizonDays)", baseURL: baseURL, token: token)
|
||||||
|
async let products: [Product] = get("/products", baseURL: baseURL, token: token)
|
||||||
|
async let categories: [CategoryItem] = get("/categories", baseURL: baseURL, token: token)
|
||||||
|
return try await Snapshot(expiring: expiring, products: products, categories: categories)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func get<T: Decodable>(
|
||||||
|
_ path: String, baseURL: URL, token: String
|
||||||
|
) async throws -> T {
|
||||||
|
guard let url = URL(string: "api" + path, relativeTo: baseURL) else {
|
||||||
|
throw APIError.notConfigured
|
||||||
|
}
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "GET"
|
||||||
|
// Etwas grosszuegiger als der interaktive Client: Der Abruf laeuft im
|
||||||
|
// Hintergrund, ein langsamer Heimserver soll nicht sofort scheitern.
|
||||||
|
request.timeoutInterval = 20
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
|
||||||
|
if http.statusCode == 401 { throw APIError.unauthorized }
|
||||||
|
throw APIError.server("Serverfehler (\(http.statusCode))")
|
||||||
|
}
|
||||||
|
return try JSONDecoder().decode(T.self, from: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,25 +46,39 @@ struct ServerListView: View {
|
|||||||
@State private var editing: ServerProfile?
|
@State private var editing: ServerProfile?
|
||||||
@State private var addShown = false
|
@State private var addShown = false
|
||||||
@State private var pendingDeletion: ServerProfile?
|
@State private var pendingDeletion: ServerProfile?
|
||||||
|
@State private var notifyFor: ServerProfile?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
List {
|
||||||
Section {
|
Section {
|
||||||
ForEach(session.profiles) { profile in
|
ForEach(session.profiles) { profile in
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
// Zeile wechselt den Server, die Glocke oeffnet die
|
||||||
|
// Benachrichtigungen - zwei getrennte Ziele in einer Zeile.
|
||||||
Button {
|
Button {
|
||||||
session.switchTo(profile.id)
|
session.switchTo(profile.id)
|
||||||
dismiss()
|
dismiss()
|
||||||
} label: {
|
} label: {
|
||||||
row(for: profile)
|
row(for: profile)
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
Button {
|
||||||
|
notifyFor = profile
|
||||||
|
} label: {
|
||||||
|
Image(systemName: NotificationStore.config(for: profile.id).enabled
|
||||||
|
? "bell.fill" : "bell")
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
.swipeActions(edge: .trailing) {
|
.swipeActions(edge: .trailing) {
|
||||||
Button("Löschen", role: .destructive) { pendingDeletion = profile }
|
Button("Löschen", role: .destructive) { pendingDeletion = profile }
|
||||||
Button("Bearbeiten") { editing = profile }.tint(.gray)
|
Button("Bearbeiten") { editing = profile }.tint(.gray)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Jeder Server merkt sich seine eigene Anmeldung. Ein Wechsel meldet dich nicht ab.")
|
Text("Jeder Server merkt sich seine eigene Anmeldung. Ein Wechsel meldet dich nicht ab. Die Glocke stellt Ablauf-Erinnerungen ein.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if session.profiles.isEmpty {
|
if session.profiles.isEmpty {
|
||||||
@@ -88,6 +102,16 @@ struct ServerListView: View {
|
|||||||
.sheet(item: $editing) { profile in
|
.sheet(item: $editing) { profile in
|
||||||
ServerEditView(profile: profile)
|
ServerEditView(profile: profile)
|
||||||
}
|
}
|
||||||
|
.sheet(item: $notifyFor) { profile in
|
||||||
|
NavigationStack {
|
||||||
|
NotificationSettingsView(profileID: profile.id, serverName: profile.name)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button("Fertig") { notifyFor = nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
pendingDeletion.map { "„\($0.name)“ löschen?" } ?? "",
|
pendingDeletion.map { "„\($0.name)“ löschen?" } ?? "",
|
||||||
isPresented: Binding(get: { pendingDeletion != nil },
|
isPresented: Binding(get: { pendingDeletion != nil },
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ final class Session: ObservableObject {
|
|||||||
/// wenn der Server aus der Liste verschwindet.
|
/// wenn der Server aus der Liste verschwindet.
|
||||||
func removeProfile(id: UUID) {
|
func removeProfile(id: UUID) {
|
||||||
Keychain.delete(account: Session.keychainAccount(for: id))
|
Keychain.delete(account: Session.keychainAccount(for: id))
|
||||||
|
NotificationStore.remove(for: id)
|
||||||
profiles.removeAll { $0.id == id }
|
profiles.removeAll { $0.id == id }
|
||||||
ProfileStore.save(profiles)
|
ProfileStore.save(profiles)
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import BackgroundTasks
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
/// Zielbildschirm, der über Home-Screen-Shortcut oder URL angesprungen wird.
|
/// Zielbildschirm, der über Home-Screen-Shortcut, URL oder eine getippte
|
||||||
|
/// Benachrichtigung angesprungen wird.
|
||||||
enum Route: String {
|
enum Route: String {
|
||||||
case checkin
|
case checkin
|
||||||
case checkout
|
case checkout
|
||||||
|
case expiring
|
||||||
}
|
}
|
||||||
|
|
||||||
final class Router: ObservableObject {
|
final class Router: ObservableObject {
|
||||||
@@ -15,6 +19,13 @@ final class Router: ObservableObject {
|
|||||||
if type.hasSuffix("checkout") { route = .checkout }
|
if type.hasSuffix("checkout") { route = .checkout }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Getippte Ablauf-Benachrichtigung: auf den betroffenen Server wechseln und
|
||||||
|
/// „Bald ablaufend" oeffnen.
|
||||||
|
func handle(expiryProfile raw: String) {
|
||||||
|
if let id = UUID(uuidString: raw) { Session.shared.switchTo(id) }
|
||||||
|
route = .expiring
|
||||||
|
}
|
||||||
|
|
||||||
/// vorrania://checkin bzw. vorrania://checkout
|
/// vorrania://checkin bzw. vorrania://checkout
|
||||||
func handle(url: URL) {
|
func handle(url: URL) {
|
||||||
guard url.scheme == "vorrania" else { return }
|
guard url.scheme == "vorrania" else { return }
|
||||||
@@ -26,6 +37,7 @@ final class Router: ObservableObject {
|
|||||||
@main
|
@main
|
||||||
struct VorraniaApp: App {
|
struct VorraniaApp: App {
|
||||||
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@StateObject private var session = Session.shared
|
@StateObject private var session = Session.shared
|
||||||
@StateObject private var router = Router()
|
@StateObject private var router = Router()
|
||||||
@StateObject private var display = DisplaySettings.shared
|
@StateObject private var display = DisplaySettings.shared
|
||||||
@@ -42,24 +54,57 @@ struct VorraniaApp: App {
|
|||||||
router.handle(shortcut: type)
|
router.handle(shortcut: type)
|
||||||
AppDelegate.pendingShortcut = nil
|
AppDelegate.pendingShortcut = nil
|
||||||
}
|
}
|
||||||
|
// Kaltstart aus einer getippten Benachrichtigung.
|
||||||
|
if let profile = AppDelegate.pendingExpiryProfile {
|
||||||
|
router.handle(expiryProfile: profile)
|
||||||
|
AppDelegate.pendingExpiryProfile = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: AppDelegate.shortcutNotification)) { note in
|
.onReceive(NotificationCenter.default.publisher(for: AppDelegate.shortcutNotification)) { note in
|
||||||
if let type = note.object as? String { router.handle(shortcut: type) }
|
if let type = note.object as? String { router.handle(shortcut: type) }
|
||||||
}
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: AppDelegate.expiryTapNotification)) { note in
|
||||||
|
if let profile = note.object as? String { router.handle(expiryProfile: profile) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Im Vordergrund den Meldungsplan auffrischen und den naechsten
|
||||||
|
// Hintergrundlauf einplanen.
|
||||||
|
.onChange(of: scenePhase) { phase in
|
||||||
|
switch phase {
|
||||||
|
case .active:
|
||||||
|
Task { await NotificationScheduler.shared.rescheduleAll() }
|
||||||
|
case .background:
|
||||||
|
AppDelegate.scheduleBackgroundRefresh()
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Nimmt Home-Screen-Quick-Actions entgegen (auch beim Kaltstart).
|
/// Nimmt Home-Screen-Quick-Actions und getippte Benachrichtigungen entgegen
|
||||||
final class AppDelegate: NSObject, UIApplicationDelegate {
|
/// (auch beim Kaltstart) und treibt die Hintergrund-Aktualisierung.
|
||||||
|
final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
|
||||||
static let shortcutNotification = Notification.Name("VorraniaShortcut")
|
static let shortcutNotification = Notification.Name("VorraniaShortcut")
|
||||||
|
static let expiryTapNotification = Notification.Name("VorraniaExpiryTap")
|
||||||
static var pendingShortcut: String?
|
static var pendingShortcut: String?
|
||||||
|
static var pendingExpiryProfile: String?
|
||||||
|
|
||||||
|
static let refreshTaskID = "com.scarriffle.vorrania.refresh"
|
||||||
|
|
||||||
func application(_ application: UIApplication,
|
func application(_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||||
if let item = launchOptions?[.shortcutItem] as? UIApplicationShortcutItem {
|
if let item = launchOptions?[.shortcutItem] as? UIApplicationShortcutItem {
|
||||||
AppDelegate.pendingShortcut = item.type
|
AppDelegate.pendingShortcut = item.type
|
||||||
}
|
}
|
||||||
|
UNUserNotificationCenter.current().delegate = self
|
||||||
|
|
||||||
|
// Der Handler muss registriert sein, bevor das Starten abgeschlossen ist.
|
||||||
|
BGTaskScheduler.shared.register(
|
||||||
|
forTaskWithIdentifier: AppDelegate.refreshTaskID, using: nil
|
||||||
|
) { task in
|
||||||
|
AppDelegate.handleBackgroundRefresh(task as? BGAppRefreshTask)
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,4 +117,44 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
NotificationCenter.default.post(name: AppDelegate.shortcutNotification, object: shortcutItem.type)
|
NotificationCenter.default.post(name: AppDelegate.shortcutNotification, object: shortcutItem.type)
|
||||||
completionHandler(true)
|
completionHandler(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Benachrichtigungen
|
||||||
|
|
||||||
|
/// Auch im Vordergrund als Banner zeigen - sonst bliebe eine faellige
|
||||||
|
/// Erinnerung unbemerkt, solange die App offen ist.
|
||||||
|
func userNotificationCenter(_ center: UNUserNotificationCenter,
|
||||||
|
willPresent notification: UNNotification) async
|
||||||
|
-> UNNotificationPresentationOptions {
|
||||||
|
[.banner, .sound]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Antippen: auf den betroffenen Server wechseln und „Bald ablaufend" oeffnen.
|
||||||
|
func userNotificationCenter(_ center: UNUserNotificationCenter,
|
||||||
|
didReceive response: UNNotificationResponse) async {
|
||||||
|
guard let profile = response.notification.request.content.userInfo["profileID"] as? String
|
||||||
|
else { return }
|
||||||
|
AppDelegate.pendingExpiryProfile = profile
|
||||||
|
NotificationCenter.default.post(name: AppDelegate.expiryTapNotification, object: profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Hintergrund-Aktualisierung
|
||||||
|
|
||||||
|
static func scheduleBackgroundRefresh() {
|
||||||
|
let request = BGAppRefreshTaskRequest(identifier: refreshTaskID)
|
||||||
|
// Fruehestens in ein paar Stunden - iOS entscheidet den genauen Zeitpunkt.
|
||||||
|
request.earliestBeginDate = Date(timeIntervalSinceNow: 4 * 3600)
|
||||||
|
try? BGTaskScheduler.shared.submit(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func handleBackgroundRefresh(_ task: BGAppRefreshTask?) {
|
||||||
|
guard let task else { return }
|
||||||
|
// Gleich den naechsten Lauf einplanen, sonst gibt es nur einen.
|
||||||
|
scheduleBackgroundRefresh()
|
||||||
|
|
||||||
|
let arbeit = Task { @MainActor in
|
||||||
|
await NotificationScheduler.shared.rescheduleAll()
|
||||||
|
task.setTaskCompleted(success: true)
|
||||||
|
}
|
||||||
|
task.expirationHandler = { arbeit.cancel() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,16 @@
|
|||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 369B9841E43E727ACA2E2A2A /* ProductViews.swift */; };
|
0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 369B9841E43E727ACA2E2A2A /* ProductViews.swift */; };
|
||||||
0E0AE6CC6FBA82B190BBC36F /* CheckInFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */; };
|
0E0AE6CC6FBA82B190BBC36F /* CheckInFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */; };
|
||||||
|
17032C3F3B89BAD6CB249443 /* NotificationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */; };
|
||||||
25FC3913EFFA37D013328FDE /* CategoryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */; };
|
25FC3913EFFA37D013328FDE /* CategoryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */; };
|
||||||
|
38133EE3EC920B462BDAA29F /* NotificationScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 163F5DCB00387215F0EA1F21 /* NotificationScheduler.swift */; };
|
||||||
39F1F17DCB4E491652D08DEE /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378F1B2DE567B62169E84426 /* APIClient.swift */; };
|
39F1F17DCB4E491652D08DEE /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378F1B2DE567B62169E84426 /* APIClient.swift */; };
|
||||||
44F08407AF69F5B7FD15F932 /* ScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF19A735AC17451B57BF5BB /* ScannerView.swift */; };
|
44F08407AF69F5B7FD15F932 /* ScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF19A735AC17451B57BF5BB /* ScannerView.swift */; };
|
||||||
|
4B4A74E8A2964FF8F3D2CE02 /* NotificationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3731608B8D48960DC98912BC /* NotificationSettings.swift */; };
|
||||||
4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */; };
|
4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */; };
|
||||||
4F86620DF8303DB02314DB5A /* CheckInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0365A16FEAE3F2BEC321E68A /* CheckInView.swift */; };
|
4F86620DF8303DB02314DB5A /* CheckInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0365A16FEAE3F2BEC321E68A /* CheckInView.swift */; };
|
||||||
6816C33381DF52A96E5BB303 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B1BB7220691A7423E8929 /* Models.swift */; };
|
6816C33381DF52A96E5BB303 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B1BB7220691A7423E8929 /* Models.swift */; };
|
||||||
|
728CF5124D2D5BBF826BCD2C /* ServerFetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1847E65D41ACEDD01CD108BC /* ServerFetch.swift */; };
|
||||||
75F805F8AE93479933BD811D /* VorraniaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9948219CDDC4188EA4298F22 /* VorraniaApp.swift */; };
|
75F805F8AE93479933BD811D /* VorraniaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9948219CDDC4188EA4298F22 /* VorraniaApp.swift */; };
|
||||||
7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6785CD9174067EFBB2B9043 /* ListViews.swift */; };
|
7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6785CD9174067EFBB2B9043 /* ListViews.swift */; };
|
||||||
7E2FA69E0C3BED650E91A7E9 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 099CCD94907BE0179B7D5742 /* AppIcon.icon */; };
|
7E2FA69E0C3BED650E91A7E9 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 099CCD94907BE0179B7D5742 /* AppIcon.icon */; };
|
||||||
@@ -38,9 +42,12 @@
|
|||||||
090485CC54558EB4433376A9 /* DashboardCards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardCards.swift; sourceTree = "<group>"; };
|
090485CC54558EB4433376A9 /* DashboardCards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardCards.swift; sourceTree = "<group>"; };
|
||||||
099CCD94907BE0179B7D5742 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = "<group>"; };
|
099CCD94907BE0179B7D5742 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = "<group>"; };
|
||||||
121D44B9990DA931A9CD5847 /* ServerProfiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerProfiles.swift; sourceTree = "<group>"; };
|
121D44B9990DA931A9CD5847 /* ServerProfiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerProfiles.swift; sourceTree = "<group>"; };
|
||||||
|
163F5DCB00387215F0EA1F21 /* NotificationScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationScheduler.swift; sourceTree = "<group>"; };
|
||||||
|
1847E65D41ACEDD01CD108BC /* ServerFetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerFetch.swift; sourceTree = "<group>"; };
|
||||||
1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterDataViews.swift; sourceTree = "<group>"; };
|
1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterDataViews.swift; sourceTree = "<group>"; };
|
||||||
314B1BB7220691A7423E8929 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
|
314B1BB7220691A7423E8929 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
|
||||||
369B9841E43E727ACA2E2A2A /* ProductViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductViews.swift; sourceTree = "<group>"; };
|
369B9841E43E727ACA2E2A2A /* ProductViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductViews.swift; sourceTree = "<group>"; };
|
||||||
|
3731608B8D48960DC98912BC /* NotificationSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettings.swift; sourceTree = "<group>"; };
|
||||||
378F1B2DE567B62169E84426 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
|
378F1B2DE567B62169E84426 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
|
||||||
4741D0E95875919C921945CF /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
|
4741D0E95875919C921945CF /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
|
||||||
4BF4E4F5524B15728CEEE116 /* Vorrania.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Vorrania.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
4BF4E4F5524B15728CEEE116 /* Vorrania.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Vorrania.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
@@ -48,6 +55,7 @@
|
|||||||
717C8EB336170526F5F3E695 /* DateScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateScanView.swift; sourceTree = "<group>"; };
|
717C8EB336170526F5F3E695 /* DateScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateScanView.swift; sourceTree = "<group>"; };
|
||||||
7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
|
7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
|
||||||
8AF19A735AC17451B57BF5BB /* ScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScannerView.swift; sourceTree = "<group>"; };
|
8AF19A735AC17451B57BF5BB /* ScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScannerView.swift; sourceTree = "<group>"; };
|
||||||
|
939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsView.swift; sourceTree = "<group>"; };
|
||||||
9948219CDDC4188EA4298F22 /* VorraniaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VorraniaApp.swift; sourceTree = "<group>"; };
|
9948219CDDC4188EA4298F22 /* VorraniaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VorraniaApp.swift; sourceTree = "<group>"; };
|
||||||
99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckInFormView.swift; sourceTree = "<group>"; };
|
99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckInFormView.swift; sourceTree = "<group>"; };
|
||||||
A6785CD9174067EFBB2B9043 /* ListViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListViews.swift; sourceTree = "<group>"; };
|
A6785CD9174067EFBB2B9043 /* ListViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListViews.swift; sourceTree = "<group>"; };
|
||||||
@@ -80,10 +88,14 @@
|
|||||||
A75926511854BB8EE316ED3A /* LoginView.swift */,
|
A75926511854BB8EE316ED3A /* LoginView.swift */,
|
||||||
1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */,
|
1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */,
|
||||||
314B1BB7220691A7423E8929 /* Models.swift */,
|
314B1BB7220691A7423E8929 /* Models.swift */,
|
||||||
|
163F5DCB00387215F0EA1F21 /* NotificationScheduler.swift */,
|
||||||
|
3731608B8D48960DC98912BC /* NotificationSettings.swift */,
|
||||||
|
939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */,
|
||||||
6F574168AA0F849D46C384EE /* ProductDetailView.swift */,
|
6F574168AA0F849D46C384EE /* ProductDetailView.swift */,
|
||||||
369B9841E43E727ACA2E2A2A /* ProductViews.swift */,
|
369B9841E43E727ACA2E2A2A /* ProductViews.swift */,
|
||||||
4741D0E95875919C921945CF /* RootView.swift */,
|
4741D0E95875919C921945CF /* RootView.swift */,
|
||||||
8AF19A735AC17451B57BF5BB /* ScannerView.swift */,
|
8AF19A735AC17451B57BF5BB /* ScannerView.swift */,
|
||||||
|
1847E65D41ACEDD01CD108BC /* ServerFetch.swift */,
|
||||||
EA706060734632DE78FA1073 /* ServerListView.swift */,
|
EA706060734632DE78FA1073 /* ServerListView.swift */,
|
||||||
121D44B9990DA931A9CD5847 /* ServerProfiles.swift */,
|
121D44B9990DA931A9CD5847 /* ServerProfiles.swift */,
|
||||||
AD2F9406BD0D4F0D30FED345 /* Session.swift */,
|
AD2F9406BD0D4F0D30FED345 /* Session.swift */,
|
||||||
@@ -195,10 +207,14 @@
|
|||||||
E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */,
|
E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */,
|
||||||
96E5A2455B2EC26050E69232 /* MasterDataViews.swift in Sources */,
|
96E5A2455B2EC26050E69232 /* MasterDataViews.swift in Sources */,
|
||||||
6816C33381DF52A96E5BB303 /* Models.swift in Sources */,
|
6816C33381DF52A96E5BB303 /* Models.swift in Sources */,
|
||||||
|
38133EE3EC920B462BDAA29F /* NotificationScheduler.swift in Sources */,
|
||||||
|
4B4A74E8A2964FF8F3D2CE02 /* NotificationSettings.swift in Sources */,
|
||||||
|
17032C3F3B89BAD6CB249443 /* NotificationSettingsView.swift in Sources */,
|
||||||
AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */,
|
AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */,
|
||||||
0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */,
|
0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */,
|
||||||
9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */,
|
9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */,
|
||||||
44F08407AF69F5B7FD15F932 /* ScannerView.swift in Sources */,
|
44F08407AF69F5B7FD15F932 /* ScannerView.swift in Sources */,
|
||||||
|
728CF5124D2D5BBF826BCD2C /* ServerFetch.swift in Sources */,
|
||||||
87A34A269AAD85A19C3F4359 /* ServerListView.swift in Sources */,
|
87A34A269AAD85A19C3F4359 /* ServerListView.swift in Sources */,
|
||||||
80B15C315FB4FEBD385A5EA1 /* ServerProfiles.swift in Sources */,
|
80B15C315FB4FEBD385A5EA1 /* ServerProfiles.swift in Sources */,
|
||||||
DFA55EF4ACA34537F445E998 /* Session.swift in Sources */,
|
DFA55EF4ACA34537F445E998 /* Session.swift in Sources */,
|
||||||
|
|||||||
@@ -127,14 +127,15 @@ function KarteSchnellbuchung({ richtung }) {
|
|||||||
setFehler(null);
|
setFehler(null);
|
||||||
setMeldung(null);
|
setMeldung(null);
|
||||||
try {
|
try {
|
||||||
// "article" ist die Einheit, in der der Artikel gefuehrt wird (Gebinde
|
// Die Menge zaehlt in Artikeleinheiten: Gibt es ein Gebinde, ist das eine
|
||||||
// oder Basiseinheit) - genau das, was in der Karte steht.
|
// Packung, sonst die Basiseinheit. Der Server kennt kein "article".
|
||||||
|
const unit = artikel.package_size > 0 ? "package" : artikel.base_unit;
|
||||||
if (einlagern) {
|
if (einlagern) {
|
||||||
await api.checkInBatch({
|
await api.checkInBatch({
|
||||||
product_id: artikel.id, unit: "article", lines: [{ quantity: zahl }],
|
product_id: artikel.id, unit, lines: [{ quantity: zahl }],
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await api.checkOut({ product_id: artikel.id, quantity: zahl, unit: "article" });
|
await api.checkOut({ product_id: artikel.id, quantity: zahl, unit });
|
||||||
}
|
}
|
||||||
setMeldung(`${fmt(zahl)} ${einlagern ? "eingelagert" : "ausgelagert"}.`);
|
setMeldung(`${fmt(zahl)} ${einlagern ? "eingelagert" : "ausgelagert"}.`);
|
||||||
setMenge("1");
|
setMenge("1");
|
||||||
|
|||||||
Reference in New Issue
Block a user