Files
Vorrania/ios/Sources/ServerListView.swift
Scarriffle 96172c6fa0 iOS: Ablauf-Benachrichtigungen, je Server einstellbar
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>
2026-07-23 21:15:09 +02:00

212 lines
8.3 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}
}