Die Nebenraeume haengen unter Einstellungen, nicht als eigene Reiter: Man braucht sie selten, und eine Leiste mit zehn Reitern findet niemand mehr. - Kategorien: anlegen, aendern, archivieren, loeschen - samt Gruppen. Eine Kategorie oeffnet ihre Erkennungswoerter, die sich dort hinzufuegen und entfernen lassen. Darunter steht, was sonst niemand merkt: Sie wirken nur auf kuenftige Importe. - Woerter, die oft korrigiert werden, stehen als Pruefmeldung in den Einstellungen - behalten oder entfernen. - Schlagwoerter: anlegen, umbenennen, umfaerben, loeschen. Neue bekommen reihum eine Farbe, damit nicht alle grau sind. - Einstellungen: Erscheinungsbild, Hauptwaehrung und Budget-Starttag lassen sich jetzt aendern. Ohne Sichern-Knopf - es ist je Zeile genau ein Wert, und das Backend nimmt ihn einzeln entgegen. Dazu der Hinweis, dass sie dem Konto gehoeren und nicht dem Geraet. - Bank: abrufen und Salden auffrischen. Verbinden bleibt am Rechner, die Bank leitet fest dorthin zurueck. Der letzte Fehler der Verbindung steht dabei, sonst ruft man vergeblich ab. - Zweiter Faktor: einrichten in drei Schritten. Auf dem Telefon fuehrt der Weg ueber die otpauth-Adresse direkt in die Authenticator-App statt ueber einen QR-Code, den man sich selbst zeigen muesste. Die Rettungscodes erscheinen einmal; das Blatt laesst sich nicht wegwischen und erst schliessen, wenn man bestaetigt hat, sie gesichert zu haben. Abschalten des zweiten Faktors bleibt bewusst draussen: Es verlangt das Kontopasswort, und die App speichert keines. Ein Vollzugang auf diesem Geraet soll den Schutz des Kontos nicht aufheben koennen. 59 Tests gruen. Texte in Deutsch und Englisch, zeilengleich. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
147 lines
5.6 KiB
Swift
147 lines
5.6 KiB
Swift
import SwiftUI
|
||
|
||
/// Die Bankanbindungen – abrufen, Salden auffrischen.
|
||
///
|
||
/// **Herstellen geht hier nicht.** Die Bank leitet fest auf
|
||
/// `{base_url}/bank/rueckkehr` zurück, registriert bei Enable Banking; dieser
|
||
/// Weg führt zwangsläufig über den Browser am Rechner. Abrufen dagegen ist
|
||
/// genau das, was man unterwegs will.
|
||
struct BankAnsicht: View {
|
||
@Environment(AppZustand.self) private var zustand
|
||
@State private var lader: Lader<[Bankverbindung]>?
|
||
@State private var vorgang = Speichervorgang()
|
||
@State private var meldung: String?
|
||
|
||
var body: some View {
|
||
Group {
|
||
if let lader {
|
||
Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { liste in
|
||
inhalt(liste)
|
||
}
|
||
} else {
|
||
ProgressView()
|
||
}
|
||
}
|
||
.navigationTitle(String(localized: "nebenraeume.bank"))
|
||
.task {
|
||
guard lader == nil, let client = zustand.client else { return }
|
||
let neuer = Lader<[Bankverbindung]>(schluessel: "bank/connections") {
|
||
try await client.bankverbindungen()
|
||
}
|
||
lader = neuer
|
||
await neuer.lade()
|
||
}
|
||
.alert(String(localized: "bank.abrufFertig"),
|
||
isPresented: .constant(meldung != nil)) {
|
||
Button(String(localized: "allgemein.ok")) { meldung = nil }
|
||
} message: {
|
||
Text(meldung ?? "")
|
||
}
|
||
.alert(String(localized: "fehler.titel"),
|
||
isPresented: .constant(vorgang.fehler != nil)) {
|
||
Button(String(localized: "allgemein.ok")) { vorgang.fehler = nil }
|
||
} message: {
|
||
Text(vorgang.fehler ?? "")
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func inhalt(_ liste: [Bankverbindung]) -> some View {
|
||
if liste.isEmpty {
|
||
LeerAnsicht(titel: String(localized: "bank.leerTitel"),
|
||
erklaerung: String(localized: "bank.leerErklaerung"),
|
||
symbol: "building.columns")
|
||
} else {
|
||
List {
|
||
ForEach(liste) { verbindung in
|
||
Section {
|
||
VerbindungsZeile(verbindung: verbindung)
|
||
|
||
Button {
|
||
Task { await rufeAb(verbindung) }
|
||
} label: {
|
||
Label(String(localized: "bank.abrufen"),
|
||
systemImage: "arrow.down.circle")
|
||
}
|
||
Button {
|
||
Task { await frischeSalden(verbindung) }
|
||
} label: {
|
||
Label(String(localized: "bank.saldenAuffrischen"),
|
||
systemImage: "arrow.clockwise")
|
||
}
|
||
} header: {
|
||
Text(verbindung.aspspName)
|
||
} footer: {
|
||
if let fehler = verbindung.lastError {
|
||
// Der letzte Fehler steht am Server. Ihn zu
|
||
// verschweigen hiesse, den Nutzer vergeblich
|
||
// abrufen zu lassen.
|
||
Label(fehler, systemImage: "exclamationmark.triangle")
|
||
.foregroundStyle(.orange)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.listStyle(.insetGrouped)
|
||
.refreshable { await lader?.aktualisiere() }
|
||
.disabled(vorgang.laeuft)
|
||
}
|
||
}
|
||
|
||
private func rufeAb(_ verbindung: Bankverbindung) async {
|
||
guard let client = zustand.client else { return }
|
||
var ergebnis: Abrufergebnis?
|
||
let geglueckt = await vorgang.fuehreAus {
|
||
ergebnis = try await client.rufeBankAb(verbindung.id)
|
||
}
|
||
if geglueckt {
|
||
meldung = ergebnis?.meldung
|
||
await lader?.aktualisiere()
|
||
}
|
||
}
|
||
|
||
private func frischeSalden(_ verbindung: Bankverbindung) async {
|
||
guard let client = zustand.client else { return }
|
||
if await vorgang.fuehreAus({ try await client.frischeSaldenAuf(verbindung.id) }) {
|
||
meldung = String(localized: "bank.saldenAufgefrischt")
|
||
await lader?.aktualisiere()
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct VerbindungsZeile: View {
|
||
let verbindung: Bankverbindung
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
LabeledContent(String(localized: "bank.land"), value: verbindung.aspspCountry)
|
||
|
||
if let zuletzt = verbindung.lastSyncAt {
|
||
LabeledContent(String(localized: "bank.zuletzt")) {
|
||
Text(zuletzt.datum.formatted(.relative(presentation: .named)))
|
||
}
|
||
} else {
|
||
LabeledContent(String(localized: "bank.zuletzt"),
|
||
value: String(localized: "bank.nie"))
|
||
}
|
||
|
||
if let gueltig = verbindung.validUntil {
|
||
LabeledContent(String(localized: "bank.gueltigBis")) {
|
||
Text(gueltig.datum.formatted(.dateTime.day().month().year()))
|
||
// Eine auslaufende Anbindung bricht sonst ohne
|
||
// Vorwarnung ab, und niemand weiss warum.
|
||
.foregroundStyle(verbindung.expiresSoon ? .orange : .secondary)
|
||
}
|
||
}
|
||
|
||
if verbindung.pollMinutes > 0 {
|
||
LabeledContent(String(localized: "bank.takt")) {
|
||
Text(String(format: String(localized: "bank.alleNMinuten"),
|
||
verbindung.pollMinutes))
|
||
}
|
||
}
|
||
}
|
||
.font(.callout)
|
||
}
|
||
}
|