1) In den Benachrichtigungs-Regeln liess sich keine Kategorie waehlen: der Abruf
holte den ganzen Snapshot (Produkte + Ablauf + Kategorien) - schlug ein Teil
fehl, kamen auch die Kategorien leer. snapshot ist jetzt widerstandsfaehig
(Teilausfaelle egal), und die Regel-Bearbeitung holt nur noch die Kategorien.
2) Die gruene Erfolgsmeldung ist jetzt eine saubere, eingerueckte Pille mit Titel
("Eingelagert"/"Ausgelagert") und Detailzeile ("Neuer Bestand: 2 Packungen").
Die Einheit steht in der Mehrzahl (Gebinde-Plural vom Server; Basiseinheiten
wie Gramm bleiben unveraendert).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
232 lines
8.9 KiB
Swift
232 lines
8.9 KiB
Swift
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.categories(baseURL: url, token: token)) ?? []
|
||
}
|
||
|
||
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()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|