Die App zeigte "Bald ablaufend" nur beim Oeffnen. Jetzt erinnert sie aktiv vor dem Ablauf - mit frei zusammenstellbaren Regeln, pro Server getrennt. Eine Regel ist "X Tage/Wochen vor Ablauf", optional nur fuer eine Kategorie (inklusive deren Unterkategorien), einmalig oder taeglich wiederkehrend. Eine Kategorie-Regel ersetzt fuer ihre Produkte die allgemeine - so kann Molkerei frueher warnen als Chips. Dazu am Ablauftag eine letzte, deutliche Warnung fuer alle Produkte (eigener Schalter). Die Meldungen kommen als Tages-Sammelmeldung, je Server eine, zur pro Server eingestellten Uhrzeit. Weil Vorrania im Heimnetz laeuft und von unterwegs oft nicht erreichbar ist, kann die App nicht zuverlaessig im Hintergrund abfragen - und iOS verlangt ohnehin, dass lokale Benachrichtigungen im Voraus geplant werden. Also: aus den Ablaufdaten kuenftige Termine berechnen und einplanen; aufgefrischt beim Oeffnen, im Vordergrund und best-effort per Hintergrund-Aktualisierung. Geplant wird fuer alle eingerichteten Server, jeder mit seinem Token aus dem Keychain; unerreichbare werden uebersprungen. iOS erlaubt 64 ausstehende Meldungen je App, deshalb die naechstliegenden zuerst und bei 60 gekappt. Einstellungen sitzen hinter der Glocke je Server in der Server-Verwaltung (nicht admin-beschraenkt, es ist eine Geraete-Einstellung). Getippt wechselt die Meldung auf den betroffenen Server und oeffnet "Bald ablaufend". Keine Backend-Aenderung: die Kategorie kommt aus /products, der Ablauf aus /expiring. Die Rechenlogik (ExpiryPlanner) ist bewusst netz- und iOS-frei und mit einem eigenstaendigen Swift-Harnisch geprueft: Kategorie-Vorrang, Unterkategorien, Rueckfall auf die allgemeine Regel, einmalig vs. wiederkehrend und die letzte Warnung. Modelle gegen echte /expiring- und /products-Antworten abgeglichen, App startet ohne Absturz (auch die BGTask-Registrierung). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
212 lines
8.3 KiB
Swift
212 lines
8.3 KiB
Swift
import SwiftUI
|
||
|
||
/// Wechselt den Server aus der Werkzeugleiste heraus - der schnelle Weg.
|
||
/// Die vollstaendige Verwaltung liegt in `ServerListView`.
|
||
struct ServerMenu: View {
|
||
@EnvironmentObject private var session: Session
|
||
@Binding var manageShown: Bool
|
||
|
||
var body: some View {
|
||
Menu {
|
||
if session.profiles.count > 1 {
|
||
Section("Server") {
|
||
ForEach(session.profiles) { profile in
|
||
Button {
|
||
guard profile.id != session.activeProfileID else { return }
|
||
session.switchTo(profile.id)
|
||
} label: {
|
||
// Der Haken zeigt, wo man gerade ist. Ein leerer
|
||
// Symbolname wuerde eine kaputte Lucke hinterlassen,
|
||
// deshalb zwei getrennte Beschriftungen.
|
||
if profile.id == session.activeProfileID {
|
||
Label(profile.name, systemImage: "checkmark")
|
||
} else {
|
||
Text(profile.name)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Button("Server verwalten…", systemImage: "server.rack") { manageShown = true }
|
||
if session.isLoggedIn {
|
||
Divider()
|
||
Text("Angemeldet als \(session.username)")
|
||
Button("Abmelden", role: .destructive) { session.logout() }
|
||
}
|
||
} label: {
|
||
Image(systemName: "person.circle")
|
||
}
|
||
}
|
||
}
|
||
|
||
struct ServerListView: View {
|
||
@EnvironmentObject private var session: Session
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
@State private var editing: ServerProfile?
|
||
@State private var addShown = false
|
||
@State private var pendingDeletion: ServerProfile?
|
||
@State private var notifyFor: ServerProfile?
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
List {
|
||
Section {
|
||
ForEach(session.profiles) { profile in
|
||
HStack(spacing: 8) {
|
||
// Zeile wechselt den Server, die Glocke oeffnet die
|
||
// Benachrichtigungen - zwei getrennte Ziele in einer Zeile.
|
||
Button {
|
||
session.switchTo(profile.id)
|
||
dismiss()
|
||
} label: {
|
||
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) {
|
||
Button("Löschen", role: .destructive) { pendingDeletion = profile }
|
||
Button("Bearbeiten") { editing = profile }.tint(.gray)
|
||
}
|
||
}
|
||
} footer: {
|
||
Text("Jeder Server merkt sich seine eigene Anmeldung. Ein Wechsel meldet dich nicht ab. Die Glocke stellt Ablauf-Erinnerungen ein.")
|
||
}
|
||
|
||
if session.profiles.isEmpty {
|
||
Text("Noch kein Server hinterlegt.")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.navigationTitle("Server")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button("Hinzufügen", systemImage: "plus") { addShown = true }
|
||
}
|
||
ToolbarItem(placement: .topBarLeading) {
|
||
Button("Fertig") { dismiss() }
|
||
}
|
||
}
|
||
.sheet(isPresented: $addShown) {
|
||
ServerEditView(profile: nil)
|
||
}
|
||
.sheet(item: $editing) { profile in
|
||
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(
|
||
pendingDeletion.map { "„\($0.name)“ löschen?" } ?? "",
|
||
isPresented: Binding(get: { pendingDeletion != nil },
|
||
set: { if !$0 { pendingDeletion = nil } }),
|
||
titleVisibility: .visible
|
||
) {
|
||
Button("Löschen", role: .destructive) {
|
||
if let profile = pendingDeletion { session.removeProfile(id: profile.id) }
|
||
pendingDeletion = nil
|
||
}
|
||
Button("Abbrechen", role: .cancel) { pendingDeletion = nil }
|
||
} message: {
|
||
Text("Die gespeicherte Anmeldung für diesen Server wird mit entfernt. Auf dem Server selbst ändert sich nichts.")
|
||
}
|
||
}
|
||
}
|
||
|
||
private func row(for profile: ServerProfile) -> some View {
|
||
HStack {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(profile.name).font(.body)
|
||
Text(profile.urlString).font(.caption).foregroundStyle(.secondary)
|
||
.lineLimit(1).truncationMode(.middle)
|
||
}
|
||
Spacer()
|
||
if profile.id == session.activeProfileID {
|
||
Image(systemName: "checkmark").foregroundStyle(Color.accentColor)
|
||
}
|
||
}
|
||
.foregroundStyle(.primary)
|
||
}
|
||
}
|
||
|
||
/// Anlegen und Bearbeiten in einem - der Unterschied ist nur, ob schon ein
|
||
/// Profil hereingereicht wurde.
|
||
struct ServerEditView: View {
|
||
@EnvironmentObject private var session: Session
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
let profile: ServerProfile?
|
||
|
||
@State private var name: String
|
||
@State private var urlString: String
|
||
|
||
init(profile: ServerProfile?) {
|
||
self.profile = profile
|
||
_name = State(initialValue: profile?.name ?? "")
|
||
_urlString = State(initialValue: profile?.urlString ?? "")
|
||
}
|
||
|
||
private var canSave: Bool {
|
||
Session.normalize(urlString) != nil
|
||
}
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
Form {
|
||
// Der Name steht bewusst oben: Er ist das, was in der App zu
|
||
// sehen ist ("Zuhause"), die Adresse braucht man nur einmal.
|
||
Section {
|
||
TextField("z. B. Zuhause", text: $name)
|
||
} header: {
|
||
Text("Name")
|
||
} footer: {
|
||
Text("So heißt der Server in der App. Ohne Angabe wird der Rechnername verwendet – meist die IP-Adresse.")
|
||
}
|
||
Section("Adresse") {
|
||
TextField("http://192.168.1.10:8080", text: $urlString)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled()
|
||
.keyboardType(.URL)
|
||
}
|
||
}
|
||
.navigationTitle(profile == nil ? "Server hinzufügen" : "Server bearbeiten")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarLeading) {
|
||
Button("Abbrechen") { dismiss() }
|
||
}
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button("Sichern") { save() }.disabled(!canSave)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func save() {
|
||
if let profile {
|
||
session.updateProfile(id: profile.id, name: name, urlString: urlString)
|
||
} else {
|
||
// Nach dem Anlegen ist der neue Server der aktive; fehlt dort eine
|
||
// Anmeldung, erscheint sie von selbst.
|
||
session.addProfile(name: name, urlString: urlString)
|
||
}
|
||
dismiss()
|
||
}
|
||
}
|