Compare commits

11 Commits
Author SHA1 Message Date
GuidoandClaude Opus 5 09ec2dc5fd feat: Bildschirmsperre mit Face ID und PIN
Beim ersten Start fuehrt kein Weg an einer sechsstelligen PIN vorbei. Danach
sperrt die App beim Kaltstart und beim Wegschalten; kommt man binnen einer
Minute zurueck, geht sie ohne Nachfrage wieder auf. Face ID zuerst, die PIN
als Rueckfallweg und als einziger Weg ohne Biometrie. Nach fuenf falschen
PINs meldet die App ab.

Gesperrt wird schon bei inactive, nicht erst im Hintergrund - sonst stuenden
die Kontostaende im App-Umschalter.

Von der PIN liegt nur ein gesalzener SHA-256 im Schluesselbund. Die
Gerätemarke selbst bleibt wie bisher abgelegt: Die Sperre verdeckt die
Oberflaeche, sie verschluesselt nichts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 18:09:05 +02:00
GuidoandClaude Opus 5 4e7f572d93 chore: Appicon mit eigenem Dunkelmodus, Projekt auf iPhone-only
Aus Xcode uebernommen: eigener Verlauf fuer den Dunkelmodus im Icon,
Kategorie Finanzen, nur iPhone, Hochformat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 17:59:20 +02:00
GuidoandClaude Opus 5 2d1d571b17 feat: Gruen ist die Vorgabe fuer Kategorien und Schlagwoerter
Die Farbauswahl der App lag dreimal gleich im Code; sie steht jetzt einmal
in Farbwahl, mit dem Markengruen an erster Stelle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 17:51:49 +02:00
GuidoandClaude Opus 5 a079bf406c feat: Die iOS-App traegt die Markenfarbe statt des System-Blaus
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 17:45:54 +02:00
GuidoandClaude Opus 5 929a735699 feat: Appicon der iOS-App - Icon-Composer-Buendel eingehaengt
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 17:40:06 +02:00
GuidoandClaude Opus 5 9195906c2b chore: Marke als freigestelltes SVG statt Appicon-Vorlage
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 17:36:51 +02:00
GuidoandClaude Opus 5 f918297742 chore: Appicon-Vorlage als SVG neben der iOS-App
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 17:33:52 +02:00
GuidoandClaude Opus 5 37517f360b docs: Uebergabe, README und Fahrplan halten Stufe 5 fest
Neu drin: warum der zweite Faktor sich hier einrichten, aber nicht
abschalten laesst (das verlangt das Kontopasswort, und die App speichert
keines - ein Vollzugang auf dem Geraet soll den Schutz nicht aufheben
koennen), warum auf dem Telefon die otpauth-Adresse statt eines QR-Codes
genommen wird, und dass die Rettungscodes genau einmal erscheinen.

Dazu der Grund fuer Einstellungen ohne Sichern-Knopf und der Hinweis, dass
sie dem Konto gehoeren, nicht dem Geraet.

Die Liste dessen, was bewusst am Rechner bleibt, ist jetzt vollstaendig:
Vertragslogo, Kontoreihenfolge, Loesen einer Verknuepfung,
Transfer-Schlagwoerter und das Anwenden von Regeln auf Bestehendes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 22:04:35 +02:00
GuidoandClaude Opus 5 d290930759 feat: Fuenfte Stufe der iOS-App - Kategorien, Schlagwoerter, Bank, zweiter Faktor
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>
2026-10-21 22:03:28 +02:00
GuidoandClaude Opus 5 bab5cc5060 test: Die Pruefdatei deckt auch die Nebenraeume ab
Erkennungswoerter einer Kategorie, markierte Woerter, der Stand des zweiten
Faktors und die Bankverbindungen kommen jetzt mit in
ios/Tests/Fixtures/antworten.json. Das Skript gibt die IDs aus der Saat
zurueck, damit ein Pfad wie /categories/{id}/keywords ueberhaupt gebaut
werden kann.

Zwei davon bleiben leer, und das steht auch dort: Eine Markierung entsteht
erst nach fuenf Anwendungen mit Korrekturen, eine Bankanbindung ohne echten
Anbieter gar nicht. Die Felder von BankConnectionOut deckt der Test also
NICHT ab - wer sie aendert, prueft von Hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 22:03:28 +02:00
GuidoandClaude Opus 5 6c6257b8f9 docs: Uebergabe, README und Fahrplan halten Stufe 4 fest
Neu drin: wo der einzige 409-Fall sitzt und wie er behandelt wird (eine
Frage, kein Fehler), woran man einen Uebertrag im Eingang erkennt, warum die
Dublettenliste die Kennung der Bank zeigt, und der Backend-Fehler, den die
App ans Licht brachte.

Ausserdem der Hinweis, der den naechsten sonst eine Stunde kostet: Die
Pruefdatei-Tests zaehlen keine Mengen mehr. Wer die Saat erweitert, macht
sonst die aelteren Pruefungen rot, ohne dass etwas kaputt waere.

Festgehalten, was weiter bewusst fehlt: Vertragslogo, Kontoreihenfolge und
das Loesen einer Uebertrags-Verknuepfung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 21:29:10 +02:00
33 changed files with 2493 additions and 85 deletions
+3 -3
View File
@@ -409,7 +409,7 @@ class CategoryGroup(Base):
name: Mapped[str] = mapped_column(String(120), nullable=False)
kind: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense)
icon: Mapped[str] = mapped_column(String(40), nullable=False, default="list")
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480")
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3f9c72")
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
@@ -431,7 +431,7 @@ class Category(Base):
name: Mapped[str] = mapped_column(String(120), nullable=False)
type: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense)
icon: Mapped[str] = mapped_column(String(40), nullable=False, default="more-horizontal")
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480")
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3f9c72")
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
@@ -1312,7 +1312,7 @@ class Tag(Base):
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(60), nullable=False)
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480")
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3f9c72")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
__table_args__ = (
+3 -3
View File
@@ -361,7 +361,7 @@ class TagOut(BaseModel):
class TagCreate(BaseModel):
name: str = Field(min_length=1, max_length=60)
# Ohne Angabe dasselbe Grau wie bei einer neuen Kategorie.
color: str = "#6b7480"
color: str = "#3f9c72"
class TagUpdate(BaseModel):
@@ -406,7 +406,7 @@ class CategoryGroupCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
kind: CategoryKind = CategoryKind.expense
icon: str = "list"
color: str = "#6b7480"
color: str = "#3f9c72"
class CategoryGroupUpdate(BaseModel):
@@ -420,7 +420,7 @@ class CategoryCreate(BaseModel):
group_id: int
name: str = Field(min_length=1, max_length=120)
icon: str = "more-horizontal"
color: str = "#6b7480"
color: str = "#3f9c72"
excluded_from_stats: bool = False
spending_is_good: bool = False
+21 -4
View File
@@ -40,7 +40,7 @@ from app.main import app # noqa: E402
ZIEL = Path(__file__).resolve().parents[2] / "ios" / "Tests" / "Fixtures" / "antworten.json"
def _seed(c: TestClient) -> None:
def _seed(c: TestClient) -> dict:
"""Genug Daten, dass jedes Feld einmal belegt ist.
Leere Listen pruefen nichts: Ein falscher Feldname in einer Zeile faellt
@@ -122,8 +122,18 @@ def _seed(c: TestClient) -> None:
"type": "income", "date": heute, "account_id": zweitgiro["id"],
"amount_cents": 7500, "payee": "Dauerauftrag"})
# --- Nebenraeume ------------------------------------------------------
# Ein Schlagwort, damit die Liste nicht leer ist. Die Markierung
# ("wird oft korrigiert") entsteht erst nach fuenf Anwendungen und laesst
# sich hier nicht erzwingen - dort bleibt die Liste leer.
c.post(f"/api/categories/{ausgabe['id']}/keywords", json={"word": "supermarkt"})
PFADE = {
return {"kategorie": ausgabe["id"]}
def pfade(ids: dict) -> dict[str, str]:
"""Welche Antwort unter welchem Namen in die Pruefdatei kommt."""
return {
"version": "/api/version",
"ichSelbst": "/api/auth/me",
"einstellungen": "/api/settings",
@@ -142,6 +152,13 @@ PFADE = {
"eingang": "/api/inbox",
"dubletten": "/api/transactions/dubletten",
"uebertragsvorschlaege": "/api/uebertraege/vorschlaege",
"schlagwoerter_der_kategorie": f"/api/categories/{ids['kategorie']}/keywords",
"markierteSchlagwoerter": "/api/categories/keywords/flagged",
"zweiterFaktor": "/api/auth/2fa/status",
# Bleibt leer: Eine Bankanbindung laesst sich ohne echten Anbieter nicht
# herstellen. Der Test prueft hier nur, dass sich die leere Liste lesen
# laesst - die Felder von BankConnectionOut deckt er nicht ab.
"bankverbindungen": "/api/bank/connections",
}
@@ -152,10 +169,10 @@ def main() -> int:
if r.status_code != 200:
print("Anmeldung fehlgeschlagen:", r.text)
return 1
_seed(c)
ids = _seed(c)
heraus = {}
for name, pfad in PFADE.items():
for name, pfad in pfade(ids).items():
antwort = c.get(pfad)
if antwort.status_code != 200:
print(f"{pfad} antwortet {antwort.status_code}: {antwort.text[:200]}")
+8 -5
View File
@@ -53,10 +53,13 @@ zweispaltig, offline der letzte Stand mit Datum.
Stufe 1 ✅: Gerüst, Netzwerkschicht, Anmeldung samt zweitem Faktor und
Pflicht-Passwortwechsel, Kontenliste. Stufe 2 ✅: Übersicht, Budget-Monat,
Buchungsliste mit Suche, Vertragsliste. Stufe 3 ✅: Buchungen, Budget-Zeilen,
Verträge und Konten anlegen, ändern und löschen. Übersetzt fehlerfrei,
49 Tests grün, läuft im Simulator; das Xcode-Projekt entsteht aus
`ios/project.yml`, die Modelle werden gegen echte Serverantworten geprüft.
Stufen 4–7 (Arbeitsliste, Nebenräume, Erweiterungen, voller CSV-Import)
stehen aus.
Verträge und Konten anlegen, ändern und löschen. Stufe 4 ✅: Arbeitsliste mit
Eingang, Übertragsvorschlägen, Gebuchtem ohne Kategorie und Dubletten.
Stufe 5 ✅: Kategorien samt Erkennungswörtern, Schlagwörter, Einstellungen,
Bankabruf und das Einrichten des zweiten Faktors.
Übersetzt fehlerfrei, 59 Tests grün, läuft im Simulator; das Xcode-Projekt
entsteht aus `ios/project.yml`, die Modelle werden gegen echte
Serverantworten geprüft.
Stufen 6–7 (Teilen-Menü und Kurzbefehle, voller CSV-Import) stehen aus.
**Stand, Entscheidungen und offene Punkte: `ios/UEBERGABE.md`.**
+210
View File
@@ -0,0 +1,210 @@
import LocalAuthentication
import SwiftUI
/// Der Sperrbildschirm. Face ID zuerst, die PIN als Rückfallweg – und als
/// einziger Weg, wenn das Gerät keine Biometrie hat oder sie abgelehnt wurde.
///
/// Die Sperre verdeckt die Oberfläche; die Gerätemarke bleibt im
/// Schlüsselbund wie zuvor (siehe `Ablage.hatPIN`). Nach fünf falschen PINs
/// meldet die App ab, sonst liesse sich eine sechsstellige PIN in Ruhe
/// durchprobieren.
struct SperrAnsicht: View {
@Environment(AppZustand.self) private var zustand
@State private var pin = ""
@State private var meldung: String?
@State private var biometrieLaeuft = false
@FocusState private var imFeld: Bool
/// Face ID, Touch ID oder gar nichts – entscheidet Beschriftung und Symbol.
private var biometrie: LABiometryType {
let pruefer = LAContext()
guard pruefer.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
error: nil) else { return .none }
return pruefer.biometryType
}
var body: some View {
VStack(spacing: 28) {
Spacer()
Image(systemName: "lock.fill")
.font(.system(size: 44, weight: .medium))
.foregroundStyle(Color.marke)
Text(String(localized: "sperre.titel"))
.font(.title2.weight(.semibold))
VStack(spacing: 12) {
SecureField(String(localized: "sperre.pin"), text: $pin)
.textContentType(.password)
.keyboardType(.numberPad)
.multilineTextAlignment(.center)
.monospaced()
.font(.title3)
.focused($imFeld)
.padding(.vertical, 12)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 12,
style: .continuous))
.onChange(of: pin) { _, neu in
pin = String(neu.filter(\.isNumber).prefix(Ziffern.anzahl))
if pin.count == Ziffern.anzahl { pruefe() }
}
if let meldung {
Text(meldung)
.font(.footnote)
.foregroundStyle(.red)
.multilineTextAlignment(.center)
}
}
.frame(maxWidth: 240)
if biometrie != .none {
Button {
Task { await versucheBiometrie() }
} label: {
Label(biometrieName, systemImage: biometrieSymbol)
}
.disabled(biometrieLaeuft)
}
Spacer()
Button(String(localized: "sperre.abmelden"), role: .destructive) {
Task { await zustand.meldeAb() }
}
.font(.footnote)
.padding(.bottom, 24)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemBackground))
.task { await versucheBiometrie() }
}
private var biometrieName: String {
biometrie == .touchID
? String(localized: "sperre.touchid")
: String(localized: "sperre.faceid")
}
private var biometrieSymbol: String {
biometrie == .touchID ? "touchid" : "faceid"
}
/// Fragt Face ID. Jede Ablehnung – abgebrochen, nicht erkannt, nicht
/// eingerichtet – führt schlicht zum PIN-Feld; das ist der Rückfallweg.
private func versucheBiometrie() async {
guard biometrie != .none, !biometrieLaeuft else {
imFeld = true
return
}
biometrieLaeuft = true
defer { biometrieLaeuft = false }
let pruefer = LAContext()
// Der Systemdialog bietet sonst „Passcode eingeben" an – das wäre der
// Gerätecode, nicht unsere PIN, und damit verwirrend.
pruefer.localizedFallbackTitle = ""
let geglueckt = (try? await pruefer.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: String(localized: "sperre.grund"))) ?? false
if geglueckt {
zustand.entsperrt()
} else {
imFeld = true
}
}
private func pruefe() {
guard Ablage.stimmtPIN(pin) else {
pin = ""
Task {
let weiter = await zustand.falschePIN()
if weiter {
meldung = String(format: String(localized: "sperre.falsch"),
zustand.verbleibendeVersuche)
}
}
return
}
meldung = nil
zustand.entsperrt()
}
}
enum Ziffern {
/// Sechs Stellen, wie der Gerätecode und wie der zweite Faktor.
static let anzahl = 6
}
/// PIN festlegen oder ändern. Beim ersten Mal führt kein Weg daran vorbei –
/// darum gibt es dann kein „Abbrechen".
struct PINEinrichtenAnsicht: View {
@Environment(AppZustand.self) private var zustand
@Environment(\.dismiss) private var schliesse
/// Beim ersten Einrichten Pflicht, beim Ändern aus den Einstellungen nicht.
var pflicht = true
@State private var neue = ""
@State private var wiederholung = ""
@State private var meldung: String?
var body: some View {
NavigationStack {
Form {
Section {
SecureField(String(localized: "pin.neu"), text: $neue)
.keyboardType(.numberPad)
.monospaced()
.onChange(of: neue) { _, wert in
neue = String(wert.filter(\.isNumber).prefix(Ziffern.anzahl))
}
SecureField(String(localized: "pin.wiederholen"), text: $wiederholung)
.keyboardType(.numberPad)
.monospaced()
.onChange(of: wiederholung) { _, wert in
wiederholung = String(wert.filter(\.isNumber).prefix(Ziffern.anzahl))
}
} footer: {
// Keine Überschrift: Die steht schon in der Leiste oben.
Text(String(localized: "pin.erklaerung"))
}
if let meldung {
Section {
Label(meldung, systemImage: "exclamationmark.triangle")
.foregroundStyle(.red)
}
}
Section {
Button(String(localized: "pin.sichern"), action: lege)
.disabled(neue.count < Ziffern.anzahl
|| wiederholung.count < Ziffern.anzahl)
}
}
.navigationTitle(String(localized: "pin.einrichtenTitel"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if !pflicht {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
}
}
}
}
}
private func lege() {
guard neue.count == Ziffern.anzahl else {
meldung = String(localized: "pin.zuKurz"); return
}
guard neue == wiederholung else {
meldung = String(localized: "pin.ungleich")
wiederholung = ""
return
}
zustand.setzePIN(neue)
if !pflicht { schliesse() }
}
}
+64
View File
@@ -43,6 +43,22 @@ final class AppZustand {
/// Läuft gerade ein Anmeldeschritt? Sperrt die Knöpfe.
private(set) var beschaeftigt = false
// MARK: - Bildschirmsperre
/// Liegt der Sperrbildschirm über der App?
private(set) var gesperrt = false
/// Ob eine PIN eingerichtet ist. Als Eigenschaft und nicht direkt aus der
/// Ablage gelesen, damit die Ansicht nach dem Einrichten neu zeichnet.
private(set) var hatPIN = Ablage.hatPIN
private var seitWannWeg: Date?
private var fehlversuche = 0
/// So lange darf man weg sein, ohne erneut zu entsperren.
private static let gnadenfrist: TimeInterval = 60
/// Danach fliegt die Marke raus und es geht zur Anmeldung. Ohne Grenze
/// wäre eine sechsstellige PIN in Ruhe durchprobierbar.
private static let maxFehlversuche = 5
var serverAdresse: URL? { Ablage.serverAdresse }
// MARK: - Start
@@ -54,6 +70,9 @@ final class AppZustand {
}
let neuer = CertainClient(basis: adresse, marke: Ablage.marke)
client = neuer
// Kaltstart: wer eine PIN hat, sieht zuerst den Sperrbildschirm.
hatPIN = Ablage.hatPIN
gesperrt = hatPIN
guard Ablage.marke != nil else {
lage = .brauchtAnmeldung
return
@@ -237,6 +256,9 @@ final class AppZustand {
let adresse = adresseBehalten ? Ablage.serverAdresse : nil
Ablage.raeumeAuf()
Ablage.serverAdresse = adresse
hatPIN = false
gesperrt = false
fehlversuche = 0
if let adresse {
client = CertainClient(basis: adresse)
lage = .brauchtAnmeldung
@@ -246,6 +268,48 @@ final class AppZustand {
}
}
// MARK: - Sperren und entsperren
/// iOS fotografiert die App beim Wegschalten für den App-Umschalter.
/// Darum wird sofort gesperrt und nicht erst beim Zurückkommen – sonst
/// stünden die Kontostände im Umschalter.
func gehtWeg() {
guard hatPIN, !gesperrt else { return }
seitWannWeg = Date()
gesperrt = true
}
/// Kurz weggewischt – etwa für eine eintreffende Nachricht – geht ohne
/// Nachfrage wieder auf. Erst nach der Gnadenfrist wird gefragt.
func kommtZurueck() {
guard let weg = seitWannWeg else { return }
seitWannWeg = nil
if Date().timeIntervalSince(weg) <= Self.gnadenfrist { gesperrt = false }
}
func entsperrt() {
gesperrt = false
fehlversuche = 0
}
/// Meldet einen Fehlversuch. Gibt zurück, ob noch Versuche übrig sind;
/// beim letzten meldet die App ab.
func falschePIN() async -> Bool {
fehlversuche += 1
guard fehlversuche >= Self.maxFehlversuche else { return true }
await meldeAb()
return false
}
var verbleibendeVersuche: Int { max(0, Self.maxFehlversuche - fehlversuche) }
func setzePIN(_ pin: String) {
Ablage.setzePIN(pin)
hatPIN = true
gesperrt = false
fehlversuche = 0
}
func aktualisiereEinstellungen() async {
guard let client else { return }
if let frisch = try? await client.einstellungen() { einstellungen = frisch }
+14
View File
@@ -3,6 +3,7 @@ import SwiftUI
@main
struct CertainApp: App {
@State private var zustand = AppZustand()
@Environment(\.scenePhase) private var phase
var body: some Scene {
// Das Erscheinungsbild kommt aus den Server-Einstellungen, damit App
@@ -19,6 +20,19 @@ struct CertainApp: App {
.environment(zustand)
.task { await zustand.starte() }
.preferredColorScheme(schema)
// Einmal hier, damit jeder Knopf, Schalter und Link
// die Markenfarbe traegt statt des System-Blaus.
.tint(.marke)
.onChange(of: phase) { _, neue in
// `inactive` kommt, bevor iOS das Bild für den
// App-Umschalter macht. Dort soll der Sperrbildschirm
// stehen, nicht der Kontostand.
switch neue {
case .inactive, .background: zustand.gehtWeg()
case .active: zustand.kommtZurueck()
@unknown default: break
}
}
}
}
}
+8
View File
@@ -28,10 +28,18 @@ struct RootAnsicht: View {
ZweitenFaktorEinrichtenHinweis()
case .bereit:
// Reihenfolge mit Absicht: Ohne PIN kommt niemand an die Zahlen,
// und der Sperrbildschirm liegt vor allem anderen.
if !zustand.hatPIN {
PINEinrichtenAnsicht()
} else if zustand.gesperrt {
SperrAnsicht()
} else {
Hauptnavigation()
}
}
}
}
/// Die Reiter. Auf dem iPhone unten, auf dem iPad als Seitenleiste – das
/// erledigt `.sidebarAdaptable` von selbst.
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

+42
View File
@@ -0,0 +1,42 @@
{
"fill-specializations" : [
{
"value" : {
"automatic-gradient" : "display-p3:0.35897,0.51845,0.35368,1.00000"
}
},
{
"appearance" : "dark",
"value" : {
"automatic-gradient" : "display-p3:0.16927,0.24447,0.16678,1.00000"
}
}
],
"groups" : [
{
"layers" : [
{
"fill" : {
"solid" : "display-p3:0.82502,0.96783,0.89028,1.00000"
},
"image-name" : "Marke.svg",
"name" : "Marke"
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.3
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}
+21
View File
@@ -23,6 +23,27 @@ extension Color {
}
}
extension Color {
/// Die Markenfarbe: dasselbe Grün wie `--accent` in `web/src/styles.css`,
/// damit App, Weboberfläche und Appicon eine Familie sind. In Dunkel eine
/// Spur heller, sonst säuft das Grün auf dunklem Grund ab.
static let marke = Color(uiColor: UIColor { zug in
zug.userInterfaceStyle == .dark
? UIColor(red: 0.373, green: 0.741, blue: 0.569, alpha: 1) // #5fbd91
: UIColor(red: 0.247, green: 0.612, blue: 0.447, alpha: 1) // #3f9c72
})
}
/// Die Farbauswahl für Kategorien, Schlagwörter und Konten – eine Liste für
/// alle drei Formulare, damit sie nicht auseinanderlaufen. Das Markengrün
/// steht vorn und ist die Vorbelegung; der Rest bleibt bunt, sonst liessen
/// sich die Kategorien in Diagrammen nicht auseinanderhalten.
enum Farbwahl {
static let standard = "#3f9c72"
static let palette = [standard, "#3f51b5", "#b3541e", "#8e24aa",
"#c62828", "#0277bd", "#6b7480", "#5d4037"]
}
/// Übersetzt die Symbolnamen aus der Datenbank in SF Symbols.
///
/// Die Weboberfläche benutzt Lucide-Namen und hält dafür ein eigenes
+1 -6
View File
@@ -183,11 +183,6 @@ struct SymbolUndFarbeFeld: View {
"film", "music", "wifi", "phone", "gift", "briefcase", "refresh-ccw",
]
private static let farben = [
"#3f51b5", "#2e7d55", "#b3541e", "#8e24aa", "#c62828",
"#0277bd", "#6b7480", "#5d4037",
]
var body: some View {
VStack(alignment: .leading, spacing: 12) {
auswahl(Self.symbole, aktuell: symbol) { symbol = $0 } inhalt: { name in
@@ -196,7 +191,7 @@ struct SymbolUndFarbeFeld: View {
.foregroundStyle(Color(hex: farbe))
}
auswahl(Self.farben, aktuell: farbe) { farbe = $0 } inhalt: { wert in
auswahl(Farbwahl.palette, aktuell: farbe) { farbe = $0 } inhalt: { wert in
Circle().fill(Color(hex: wert)).frame(width: 22, height: 22)
}
}
+1 -1
View File
@@ -79,7 +79,7 @@ struct BudgetAnsicht: View {
} label: {
Label(String(localized: "budget.vorlage"), systemImage: "doc.text")
}
.tint(.indigo)
.tint(.marke)
}
}
} header: {
+58 -4
View File
@@ -9,6 +9,7 @@
/* Begin PBXBuildFile section */
04F945FE4BAC298FC242354B /* AboListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C5FA8AE870EDCB51232CA9E /* AboListe.swift */; };
0E66BEE1DEE6E4A5D88A6696 /* Konto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4119B85EFF3FB38E84FAA913 /* Konto.swift */; };
18384EACDD7DE161B874636C /* ZweiterFaktorAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = D32F7DD2D6EB6CD5D75DD95D /* ZweiterFaktorAnsicht.swift */; };
18B6A9629AA749B790F21DC9 /* CertainApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A1BD38230CAE03DFA02A3F0 /* CertainApp.swift */; };
1E0AE92B8994298A0468E4EC /* ZweiFaktorAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.swift */; };
1E53B174991E91B8D25CCE04 /* Dashboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */; };
@@ -17,6 +18,7 @@
2685982A4603DE880837D10E /* BudgetAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.swift */; };
2E0CAE876D1D74771FC1011B /* ArbeitslisteAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5EA4F794EFAD4CB4EE63B4 /* ArbeitslisteAnsicht.swift */; };
2F3B8F55EB066B0C90200534 /* Arbeitsliste.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A2E765F9D8DDC8D46A1BDD6 /* Arbeitsliste.swift */; };
3BE070886D8D49F0BF9B49BC /* Nebenraeume.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3114763A11EC90E1D38C9DEF /* Nebenraeume.swift */; };
3E574E1D2C0CDB24C652190C /* ServerAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */; };
3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78AD0B83BDFDF777917C9382 /* KontenListe.swift */; };
402F07ED1E39AB8C3BC6F62B /* AppZustand.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9FE5CE135C1B9607C47BCE9 /* AppZustand.swift */; };
@@ -25,6 +27,7 @@
49AA743A07B0A1E21D2D6AE8 /* KontoFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AFC43300F101BE4F4541BA2 /* KontoFormular.swift */; };
4C9AC9D9DCA7A94F831AB207 /* Zwischenspeicher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C24DAC366AE34403646DBE67 /* Zwischenspeicher.swift */; };
4EEA2CC5273F9C12D9E0C61D /* Buchung.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327787E4869ED7D0C7E9EE63 /* Buchung.swift */; };
5415C2CE4B44A7B3611E4D7D /* KategorienAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 777281FF90E90AC0B14D2807 /* KategorienAnsicht.swift */; };
66EE367CB37CDA59EEF0F023 /* EinstellungenAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09B6D688EF69B6EC58A8B4BE /* EinstellungenAnsicht.swift */; };
6FD2F62BEFE9BE9FE159416C /* antworten.json in Resources */ = {isa = PBXBuildFile; fileRef = 3137E525D1ADE3DB2F8E45A8 /* antworten.json */; };
7A5BD17CBEB8E7E61BEC6396 /* GrundlagenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23B6CF6893EB57064D36F609 /* GrundlagenTests.swift */; };
@@ -32,16 +35,20 @@
841A4EE94250BDDF88FA2FD4 /* Budget.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51DE5BDB909910D586F819F /* Budget.swift */; };
89C924D18B1875B037EF08BA /* DashboardAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09BBFDDFA7A1FFC8785925BE /* DashboardAnsicht.swift */; };
8EAA9F11CF1FDFE702557B55 /* Grundmodelle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8BE152C9F91584900796ADA /* Grundmodelle.swift */; };
90BC49DB37BABF00BE3CC2F7 /* BankAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F14B825905BA99472E4B289 /* BankAnsicht.swift */; };
917FFB036BF2D36FBD9F777E /* BuchungsListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = A150CAB4967A479B506A997F /* BuchungsListe.swift */; };
918BA7A7CC3972ED4D8A93B1 /* Darstellung.swift in Sources */ = {isa = PBXBuildFile; fileRef = 451BA41D9B53FB6B511AF734 /* Darstellung.swift */; };
A16063F94D4F5C1631130609 /* KategorieFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = C841BA87849B2A461449BAF5 /* KategorieFormular.swift */; };
A499FAEF73B54C5533817BAD /* Abo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8CBA42A030509615199DD45 /* Abo.swift */; };
AFC54370E3CC318193D28E35 /* AuswahlBlaetter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F7647AF036D2CD6339FA287 /* AuswahlBlaetter.swift */; };
B2470AAE64D657CF7CDEA099 /* PasswortWechselAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */; };
B551D26D31D9C35898074FE5 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = D42C757A9D818A524C4CAC8A /* AppIcon.icon */; };
B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB439591BFEABB503F2E071 /* RootAnsicht.swift */; };
BEC6BF023D4A649003F38A2D /* Endpunkte.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D6CEAB46FB8955115A3064B /* Endpunkte.swift */; };
C518581A7F62632420821479 /* APIFehler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5C59F93E8CD995DFDCABF90 /* APIFehler.swift */; };
C810555B892E1CAD08053FC3 /* Formular.swift in Sources */ = {isa = PBXBuildFile; fileRef = 778040646D05B1DEEE3BF727 /* Formular.swift */; };
D19F572D7B11F3144FDF1CD6 /* Kategorie.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */; };
D5800913A3A01494BEFE85C5 /* TagsAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89DDE9775816D9CB03489B56 /* TagsAnsicht.swift */; };
D5AFF6E8CE7AB6674BDCEEE1 /* Lader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDF1014AC23C916BE571DDC /* Lader.swift */; };
DE172F2DBA80FB2E8CE93A06 /* Eingaben.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647E9AA0BFEDCAFF98BADB9C /* Eingaben.swift */; };
E49213EDDBF2C0C7995AF558 /* AnmeldeAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */; };
@@ -49,6 +56,7 @@
EE4F2382CFF3F60FF5AC8241 /* CertainClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E5A58246E6A9D72E99C2F2 /* CertainClient.swift */; };
F2C67D902D1AC96A60691687 /* AboFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFABF1A481445B1CD960C993 /* AboFormular.swift */; };
F414F982412C79242CADB473 /* Ablage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C14F1D9CEF39FF3E095239 /* Ablage.swift */; };
F8BC5F736C54FFFAD25670D5 /* SperrAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C618BE3CBCEBBF65F79A63D /* SperrAnsicht.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -69,10 +77,13 @@
13C14F1D9CEF39FF3E095239 /* Ablage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ablage.swift; sourceTree = "<group>"; };
1AB439591BFEABB503F2E071 /* RootAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootAnsicht.swift; sourceTree = "<group>"; };
1E6FE0925FC71A03818FFF72 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
1F14B825905BA99472E4B289 /* BankAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BankAnsicht.swift; sourceTree = "<group>"; };
23B6CF6893EB57064D36F609 /* GrundlagenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrundlagenTests.swift; sourceTree = "<group>"; };
287D314176EBA5E888605F5F /* Geldbetrag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Geldbetrag.swift; sourceTree = "<group>"; };
3114763A11EC90E1D38C9DEF /* Nebenraeume.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nebenraeume.swift; sourceTree = "<group>"; };
3137E525D1ADE3DB2F8E45A8 /* antworten.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = antworten.json; sourceTree = "<group>"; };
327787E4869ED7D0C7E9EE63 /* Buchung.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Buchung.swift; sourceTree = "<group>"; };
3C618BE3CBCEBBF65F79A63D /* SperrAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SperrAnsicht.swift; sourceTree = "<group>"; };
3F7647AF036D2CD6339FA287 /* AuswahlBlaetter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuswahlBlaetter.swift; sourceTree = "<group>"; };
4119B85EFF3FB38E84FAA913 /* Konto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Konto.swift; sourceTree = "<group>"; };
416FD5B020E78140037306C8 /* Certain.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Certain.app; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -85,9 +96,11 @@
647E9AA0BFEDCAFF98BADB9C /* Eingaben.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Eingaben.swift; sourceTree = "<group>"; };
75EA409F3F0CEF3DF04C32E7 /* Zeitangaben.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zeitangaben.swift; sourceTree = "<group>"; };
776F26865B849D2318B6819B /* Zustandsansichten.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zustandsansichten.swift; sourceTree = "<group>"; };
777281FF90E90AC0B14D2807 /* KategorienAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KategorienAnsicht.swift; sourceTree = "<group>"; };
778040646D05B1DEEE3BF727 /* Formular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Formular.swift; sourceTree = "<group>"; };
77E5A58246E6A9D72E99C2F2 /* CertainClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertainClient.swift; sourceTree = "<group>"; };
78AD0B83BDFDF777917C9382 /* KontenListe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KontenListe.swift; sourceTree = "<group>"; };
89DDE9775816D9CB03489B56 /* TagsAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsAnsicht.swift; sourceTree = "<group>"; };
8A1BD38230CAE03DFA02A3F0 /* CertainApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertainApp.swift; sourceTree = "<group>"; };
8C5FA8AE870EDCB51232CA9E /* AboListe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboListe.swift; sourceTree = "<group>"; };
9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetAnsicht.swift; sourceTree = "<group>"; };
@@ -100,6 +113,9 @@
C24DAC366AE34403646DBE67 /* Zwischenspeicher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zwischenspeicher.swift; sourceTree = "<group>"; };
C5C59F93E8CD995DFDCABF90 /* APIFehler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIFehler.swift; sourceTree = "<group>"; };
C5F948B06FF2E65118C82636 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
C841BA87849B2A461449BAF5 /* KategorieFormular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KategorieFormular.swift; sourceTree = "<group>"; };
D32F7DD2D6EB6CD5D75DD95D /* ZweiterFaktorAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZweiterFaktorAnsicht.swift; sourceTree = "<group>"; };
D42C757A9D818A524C4CAC8A /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = "<group>"; };
D9FE5CE135C1B9607C47BCE9 /* AppZustand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppZustand.swift; sourceTree = "<group>"; };
DBDF1014AC23C916BE571DDC /* Lader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Lader.swift; sourceTree = "<group>"; };
DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnmeldeAnsicht.swift; sourceTree = "<group>"; };
@@ -175,6 +191,7 @@
F8BE152C9F91584900796ADA /* Grundmodelle.swift */,
B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */,
4119B85EFF3FB38E84FAA913 /* Konto.swift */,
3114763A11EC90E1D38C9DEF /* Nebenraeume.swift */,
75EA409F3F0CEF3DF04C32E7 /* Zeitangaben.swift */,
);
path = Modelle;
@@ -194,6 +211,7 @@
DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */,
52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */,
A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */,
3C618BE3CBCEBBF65F79A63D /* SperrAnsicht.swift */,
A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.swift */,
);
path = Anmeldung;
@@ -202,6 +220,7 @@
BC82A6A323BDD2AD8325BEA2 = {
isa = PBXGroup;
children = (
D42C757A9D818A524C4CAC8A /* AppIcon.icon */,
CB7098BB5F4AEFE48BA9BB36 /* Abos */,
B4505C6E527A0020B80AFF04 /* Anmeldung */,
BEE6BC97D865C2F0C4EB80C4 /* App */,
@@ -213,6 +232,7 @@
56CB7B1C18136BFD83ACADBD /* Einstellungen */,
298E43BBA9248E5A9779DA98 /* Konten */,
80EFC9A43394C64E9AEEF5A1 /* Modelle */,
F77DCF8FF4A1F732F2FA3EA8 /* Nebenraeume */,
DB51F54CC077E85C286AD9DC /* Netzwerk */,
524FD85EDE7C4DE5ABE9A8E5 /* Tests */,
0EDDD922C06B1465AD13C782 /* Texte */,
@@ -279,6 +299,18 @@
path = Netzwerk;
sourceTree = "<group>";
};
F77DCF8FF4A1F732F2FA3EA8 /* Nebenraeume */ = {
isa = PBXGroup;
children = (
1F14B825905BA99472E4B289 /* BankAnsicht.swift */,
C841BA87849B2A461449BAF5 /* KategorieFormular.swift */,
777281FF90E90AC0B14D2807 /* KategorienAnsicht.swift */,
89DDE9775816D9CB03489B56 /* TagsAnsicht.swift */,
D32F7DD2D6EB6CD5D75DD95D /* ZweiterFaktorAnsicht.swift */,
);
path = Nebenraeume;
sourceTree = "<group>";
};
FAFA069DDABF0E56CAF24C4F /* Arbeitsliste */ = {
isa = PBXGroup;
children = (
@@ -369,6 +401,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B551D26D31D9C35898074FE5 /* AppIcon.icon in Resources */,
E5F283F445FA9E26CFC15FA5 /* Localizable.strings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -406,6 +439,7 @@
2F3B8F55EB066B0C90200534 /* Arbeitsliste.swift in Sources */,
2E0CAE876D1D74771FC1011B /* ArbeitslisteAnsicht.swift in Sources */,
AFC54370E3CC318193D28E35 /* AuswahlBlaetter.swift in Sources */,
90BC49DB37BABF00BE3CC2F7 /* BankAnsicht.swift in Sources */,
4EEA2CC5273F9C12D9E0C61D /* Buchung.swift in Sources */,
2041749A0179B0AC29AFF2FF /* BuchungFormular.swift in Sources */,
917FFB036BF2D36FBD9F777E /* BuchungsListe.swift in Sources */,
@@ -424,16 +458,22 @@
431D27A182E4DEBD84F0DA05 /* Geldbetrag.swift in Sources */,
8EAA9F11CF1FDFE702557B55 /* Grundmodelle.swift in Sources */,
D19F572D7B11F3144FDF1CD6 /* Kategorie.swift in Sources */,
A16063F94D4F5C1631130609 /* KategorieFormular.swift in Sources */,
5415C2CE4B44A7B3611E4D7D /* KategorienAnsicht.swift in Sources */,
3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */,
0E66BEE1DEE6E4A5D88A6696 /* Konto.swift in Sources */,
49AA743A07B0A1E21D2D6AE8 /* KontoFormular.swift in Sources */,
D5AFF6E8CE7AB6674BDCEEE1 /* Lader.swift in Sources */,
3BE070886D8D49F0BF9B49BC /* Nebenraeume.swift in Sources */,
B2470AAE64D657CF7CDEA099 /* PasswortWechselAnsicht.swift in Sources */,
B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */,
3E574E1D2C0CDB24C652190C /* ServerAnsicht.swift in Sources */,
F8BC5F736C54FFFAD25670D5 /* SperrAnsicht.swift in Sources */,
D5800913A3A01494BEFE85C5 /* TagsAnsicht.swift in Sources */,
7AFCD4ADF85329F373C89E06 /* Zeitangaben.swift in Sources */,
25EC32ED2FF88F4286B752A7 /* Zustandsansichten.swift in Sources */,
1E0AE92B8994298A0468E4EC /* ZweiFaktorAnsicht.swift in Sources */,
18384EACDD7DE161B874636C /* ZweiterFaktorAnsicht.swift in Sources */,
4C9AC9D9DCA7A94F831AB207 /* Zwischenspeicher.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -471,9 +511,12 @@
DEVELOPMENT_TEAM = PP34X97WS3;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Certain;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
INFOPLIST_KEY_NSFaceIDUsageDescription = "Damit entsperrst du Certain, ohne deine PIN zu tippen.";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -481,8 +524,12 @@
MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.certain;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = 1;
};
name = Release;
};
@@ -651,9 +698,12 @@
DEVELOPMENT_TEAM = PP34X97WS3;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Certain;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
INFOPLIST_KEY_NSFaceIDUsageDescription = "Damit entsperrst du Certain, ohne deine PIN zu tippen.";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -661,8 +711,12 @@
MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.certain;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = 1;
};
name = Debug;
};
+167 -9
View File
@@ -1,11 +1,16 @@
import SwiftUI
/// Stufe 1 zeigt hier nur, woran man ist, und lässt abmelden. Die echten
/// Einstellungen (Hauptwährung, Budget-Starttag, Sprache, Gerätemarken)
/// kommen in Stufe 5.
/// Einstellungen und die Nebenräume: Kategorien, Schlagwörter, Bank,
/// zweiter Faktor.
///
/// Sie hängen hier und nicht als eigene Reiter: Man braucht sie selten, und
/// eine Leiste mit zehn Reitern findet niemand mehr.
struct EinstellungenAnsicht: View {
@Environment(AppZustand.self) private var zustand
@State private var vorgang = Speichervorgang()
@State private var zeigeAbmeldefrage = false
@State private var markierte: [MarkiertesWort] = []
@State private var aenderePIN = false
var body: some View {
Form {
@@ -18,17 +23,83 @@ struct EinstellungenAnsicht: View {
LabeledContent(String(localized: "einstellungen.server"),
value: adresse.host() ?? adresse.absoluteString)
}
NavigationLink {
ZweiterFaktorAnsicht()
} label: {
Label(String(localized: "zweifaktor.titel"), systemImage: "lock.shield")
}
Button {
aenderePIN = true
} label: {
Label(String(localized: "pin.aendern"), systemImage: "lock.rotation")
}
} header: {
Text(String(localized: "einstellungen.zugang"))
} footer: {
Text(String(localized: "einstellungen.markeErklaerung"))
}
Section(String(localized: "einstellungen.ausDemServer")) {
LabeledContent(String(localized: "einstellungen.hauptwaehrung"),
value: zustand.einstellungen.basiswaehrung)
LabeledContent(String(localized: "einstellungen.budgetStarttag"),
value: "\(zustand.einstellungen.budgetStarttag)")
Section(String(localized: "nebenraeume.titel")) {
NavigationLink {
KategorienAnsicht()
} label: {
Label(String(localized: "nebenraeume.kategorien"), systemImage: "square.grid.2x2")
}
NavigationLink {
TagsAnsicht()
} label: {
Label(String(localized: "nebenraeume.tags"), systemImage: "tag")
}
NavigationLink {
BankAnsicht()
} label: {
Label(String(localized: "nebenraeume.bank"), systemImage: "building.columns")
}
}
if !markierte.isEmpty {
Section {
ForEach(markierte) { wort in
MarkiertesWortZeile(wort: wort,
behalten: { Task { await behalte(wort) } },
entfernen: { Task { await entferne(wort) } })
}
} header: {
Text(String(localized: "kategorien.markiertTitel"))
} footer: {
Text(String(localized: "kategorien.markiertErklaerung"))
}
}
Section {
Picker(String(localized: "einstellungen.erscheinungsbild"),
selection: bindung(\.erscheinungsbild, "theme")) {
Text(String(localized: "einstellungen.system")).tag("system")
Text(String(localized: "einstellungen.hell")).tag("light")
Text(String(localized: "einstellungen.dunkel")).tag("dark")
}
Picker(String(localized: "einstellungen.hauptwaehrung"),
selection: bindung(\.basiswaehrung, "base_currency")) {
ForEach(zustand.einstellungen.aktiveWaehrungen, id: \.self) { code in
Text(code).tag(code)
}
}
Picker(String(localized: "einstellungen.budgetStarttag"),
selection: tagBindung) {
ForEach(1...28, id: \.self) { tag in
Text("\(tag).").tag(tag)
}
}
} header: {
Text(String(localized: "einstellungen.ausDemServer"))
} footer: {
// Die Hauptwährung rechnet alle Auswertungen um. Das gilt
// für jedes Gerät, nicht nur für dieses.
Text(String(localized: "einstellungen.serverweitErklaerung"))
}
if let fehler = vorgang.fehler {
Section { FehlerZeile(text: fehler) }
}
Section {
@@ -39,8 +110,12 @@ struct EinstellungenAnsicht: View {
Text(String(localized: "einstellungen.abmeldenErklaerung"))
}
}
.sheet(isPresented: $aenderePIN) {
PINEinrichtenAnsicht(pflicht: false)
}
.navigationTitle(String(localized: "reiter.einstellungen"))
.refreshable { await zustand.aktualisiereEinstellungen() }
.refreshable { await lade() }
.task { await lade() }
.confirmationDialog(String(localized: "einstellungen.abmeldenFrage"),
isPresented: $zeigeAbmeldefrage, titleVisibility: .visible) {
Button(String(localized: "allgemein.abmelden"), role: .destructive) {
@@ -49,4 +124,87 @@ struct EinstellungenAnsicht: View {
Button(String(localized: "allgemein.abbrechen"), role: .cancel) {}
}
}
// MARK: - Einstellungen schreiben
/// Eine Bindung, die beim Setzen gleich zum Server geht.
///
/// Ein „Sichern"-Knopf wäre hier falsch: Es ist je Zeile genau ein Wert,
/// und das Backend nimmt ihn einzeln entgegen (`PUT /settings/{key}`).
private func bindung(_ pfad: KeyPath<Einstellungen, String>,
_ schluessel: String) -> Binding<String> {
Binding(get: { zustand.einstellungen[keyPath: pfad] },
set: { neu in Task { await setze(schluessel, neu) } })
}
private var tagBindung: Binding<Int> {
Binding(get: { zustand.einstellungen.budgetStarttag },
set: { neu in Task { await setze("budget_start_day", String(neu)) } })
}
private func setze(_ schluessel: String, _ wert: String) async {
guard let client = zustand.client else { return }
let geglueckt = await vorgang.fuehreAus {
_ = try await client.setzeEinstellung(schluessel, wert)
}
if geglueckt { await zustand.aktualisiereEinstellungen() }
}
// MARK: - Markierte Erkennungswörter
private func lade() async {
await zustand.aktualisiereEinstellungen()
guard let client = zustand.client else { return }
markierte = (try? await client.markierteErkennungswoerter()) ?? []
}
private func behalte(_ wort: MarkiertesWort) async {
guard let client = zustand.client else { return }
if await vorgang.fuehreAus({ try await client.bestaetigeErkennungswort(wort.id) }) {
await lade()
}
}
private func entferne(_ wort: MarkiertesWort) async {
guard let client = zustand.client else { return }
if await vorgang.fuehreAus({ try await client.loescheErkennungswort(wort.id) }) {
await lade()
await zustand.ladeNachschlag()
}
}
}
/// Ein Erkennungswort, das oft wieder korrigiert wird.
private struct MarkiertesWortZeile: View {
let wort: MarkiertesWort
let behalten: () -> Void
let entfernen: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(wort.word)
.font(.callout)
Spacer()
Text(wort.categoryName)
.font(.caption2)
.foregroundStyle(.secondary)
}
Text(String(format: String(localized: "kategorien.markiertZaehler"),
wort.appliedCount, wort.overriddenCount))
.font(.caption2)
.foregroundStyle(.secondary)
HStack(spacing: 8) {
Button(String(localized: "kategorien.behalten"), action: behalten)
.buttonStyle(.bordered)
.controlSize(.small)
Button(String(localized: "kategorien.entfernen"), role: .destructive,
action: entfernen)
.buttonStyle(.bordered)
.controlSize(.small)
}
}
.padding(.vertical, 2)
}
}
+3
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

+218
View File
@@ -0,0 +1,218 @@
import Foundation
// MARK: - Schlagwörter
/// `KeywordOut` – ein Wort, an dem die Erkennung eine Kategorie festmacht.
///
/// Nicht zu verwechseln mit `Schlagwort` (einem Tag): Ein Tag klebt an einer
/// Buchung, ein Schlagwort hängt an einer **Kategorie** und wirkt nur auf
/// künftige Importe.
struct Erkennungswort: Codable, Sendable, Identifiable, Hashable {
let id: Int
let categoryId: Int
let word: String
/// Nur wenn der Betrag mitentscheiden soll – „Migros" über 200 ist
/// Haushalt, darunter Lebensmittel.
let amountCents: Int?
private enum CodingKeys: String, CodingKey {
case id, word
case categoryId = "category_id"
case amountCents = "amount_cents"
}
}
/// `FlaggedKeywordOut` – ein Wort, das oft wieder korrigiert wird.
///
/// Ab fünf Anwendungen und mindestens einem Drittel Korrekturen meldet das
/// Backend es zur Prüfung. Behalten oder entfernen entscheidet der Mensch.
struct MarkiertesWort: Codable, Sendable, Identifiable, Hashable {
let id: Int
let categoryId: Int?
let categoryName: String
let word: String
let appliedCount: Int
let overriddenCount: Int
private enum CodingKeys: String, CodingKey {
case id, word
case categoryId = "category_id"
case categoryName = "category_name"
case appliedCount = "applied_count"
case overriddenCount = "overridden_count"
}
}
/// `KeywordCreate`
struct ErkennungswortAnlegen: Encodable, Sendable {
let word: String
var amountCents: Int?
private enum CodingKeys: String, CodingKey {
case word
case amountCents = "amount_cents"
}
}
// MARK: - Kategorien schreiben
/// `CategoryCreate`
struct KategorieAnlegen: Encodable, Sendable {
let groupId: Int
let name: String
var icon = "more-horizontal"
var color = "#6b7480"
/// Zählt in keiner Auswertung mit – etwa durchlaufende Posten.
var excludedFromStats = false
/// Eine Ausgabe, die kein Verlust ist: Sparen.
var spendingIsGood = false
private enum CodingKeys: String, CodingKey {
case name, icon, color
case groupId = "group_id"
case excludedFromStats = "excluded_from_stats"
case spendingIsGood = "spending_is_good"
}
}
/// `CategoryUpdate` – nur die gesetzten Felder gehen mit.
struct KategorieAendern: Encodable, Sendable {
var name: String?
var groupId: Int?
var icon: String?
var color: String?
var isArchived: Bool?
var excludedFromStats: Bool?
var spendingIsGood: Bool?
private enum CodingKeys: String, CodingKey {
case name, icon, color
case groupId = "group_id"
case isArchived = "is_archived"
case excludedFromStats = "excluded_from_stats"
case spendingIsGood = "spending_is_good"
}
}
/// `CategoryGroupCreate`
struct GruppeAnlegen: Encodable, Sendable {
let name: String
var kind: Kategorieart = .expense
var icon = "list"
var color = "#6b7480"
}
/// `CategoryGroupUpdate`
struct GruppeAendern: Encodable, Sendable {
var name: String?
var icon: String?
var color: String?
}
// MARK: - Tags schreiben
/// `TagCreate`
struct TagAnlegen: Encodable, Sendable {
let name: String
var color = "#6b7480"
}
/// `TagUpdate`
struct TagAendern: Encodable, Sendable {
var name: String?
var color: String?
}
// MARK: - Zweiter Faktor
/// `TwoFactorStatusOut`
struct ZweiterFaktorStand: Codable, Sendable, Hashable {
let enabled: Bool
let recoveryCodesLeft: Int
private enum CodingKeys: String, CodingKey {
case enabled
case recoveryCodesLeft = "recovery_codes_left"
}
}
/// `TwoFactorSetupOut` – das noch **nicht** aktive Geheimnis.
///
/// Gültig wird es erst, wenn ein Code daraus geprüft wurde. Wer die
/// Einrichtung abbricht, lässt einfach ein unbenutztes Geheimnis stehen.
struct ZweiterFaktorEinrichtung: Codable, Sendable, Hashable {
let secret: String
let otpauthUri: String
/// Der QR-Code als `data:`-URI. Auf dem Telefon selten gebraucht – dort
/// führt der Weg über `otpauthUri` direkt in die Authenticator-App.
let qrPng: String
private enum CodingKeys: String, CodingKey {
case secret
case otpauthUri = "otpauth_uri"
case qrPng = "qr_png"
}
}
/// `RecoveryCodesOut` – einmal zu sehen, danach nie wieder.
struct Rettungscodes: Codable, Sendable, Hashable {
let codes: [String]
}
struct ZweiterFaktorCode: Encodable, Sendable {
let code: String
}
// MARK: - Bank
/// `BankConnectionOut` – eine bestehende Anbindung.
///
/// Hergestellt wird sie am Rechner: Die Bank leitet fest auf
/// `{base_url}/bank/rueckkehr` zurück. Abrufen geht von hier.
struct Bankverbindung: Codable, Sendable, Identifiable, Hashable {
let id: Int
let aspspName: String
let aspspCountry: String
let validUntil: Zeitpunkt?
let lastSyncAt: Zeitpunkt?
/// Takt des automatischen Abrufs in Minuten, 0 = aus.
let pollMinutes: Int
let lastError: String?
let expiresSoon: Bool
private enum CodingKeys: String, CodingKey {
case id
case aspspName = "aspsp_name"
case aspspCountry = "aspsp_country"
case validUntil = "valid_until"
case lastSyncAt = "last_sync_at"
case pollMinutes = "poll_minutes"
case lastError = "last_error"
case expiresSoon = "expires_soon"
}
}
/// Was ein Abruf zurückmeldet.
struct Abrufergebnis: Codable, Sendable, Hashable {
let geholt: Int
let neu: Int
let gebucht: Int?
let vorgemerkt: Int?
let doppelt: Int?
let uebertraege: Int?
/// Die Verbindung hat kein zugeordnetes Konto – dann wurde nichts geholt.
let ohneZuordnung: Bool?
private enum CodingKeys: String, CodingKey {
case geholt, neu, gebucht, vorgemerkt, doppelt, uebertraege
case ohneZuordnung = "ohne_zuordnung"
}
/// Ein Satz für die Rückmeldung.
var meldung: String {
if ohneZuordnung == true {
return String(localized: "bank.ohneZuordnung")
}
return String(format: String(localized: "bank.abrufErgebnis"), geholt, neu)
}
}
+146
View File
@@ -0,0 +1,146 @@
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)
}
}
+307
View File
@@ -0,0 +1,307 @@
import SwiftUI
/// Eine Kategorie anlegen oder ändern.
struct KategorieFormular: View {
enum Zweck: Equatable, Identifiable {
/// Neu in dieser Gruppe. Die Art erbt sie von der Gruppe – eine
/// Einnahme-Kategorie in einer Ausgabe-Gruppe gibt es nicht.
case neu(Int, Kategorieart)
case aendern(Kategorie)
var id: Int {
switch self {
case .neu(let gruppe, _): return -gruppe
case .aendern(let kategorie): return kategorie.id
}
}
}
let zweck: Zweck
var fertig: () -> Void
@Environment(AppZustand.self) private var zustand
@Environment(\.dismiss) private var schliesse
@State private var vorgang = Speichervorgang()
@State private var name = ""
@State private var symbol = "more-horizontal"
@State private var farbe = Farbwahl.standard
@State private var gruppeId = 0
@State private var istSparen = false
@State private var ohneAuswertung = false
@State private var istArchiviert = false
@State private var fragtNachLoeschen = false
@State private var vorbelegt = false
var body: some View {
NavigationStack {
Form {
Section {
TextField(String(localized: "konto.name"), text: $name)
Picker(String(localized: "kategorien.gruppe"), selection: $gruppeId) {
ForEach(passendeGruppen) { gruppe in
Text(gruppe.name).tag(gruppe.id)
}
}
}
Section {
SymbolUndFarbeFeld(symbol: $symbol, farbe: $farbe)
}
if art == .expense {
Section {
Toggle(String(localized: "kategorien.istSparen"), isOn: $istSparen)
} footer: {
Text(String(localized: "kategorien.istSparenErklaerung"))
}
}
Section {
Toggle(String(localized: "kategorien.ohneAuswertungSchalter"),
isOn: $ohneAuswertung)
} footer: {
Text(String(localized: "kategorien.ohneAuswertungErklaerung"))
}
if case .aendern = zweck {
Section {
Toggle(String(localized: "konto.archiviert"), isOn: $istArchiviert)
Button(String(localized: "kategorien.loeschen"), role: .destructive) {
fragtNachLoeschen = true
}
} footer: {
Text(String(localized: "kategorien.archivierenStattLoeschen"))
}
}
if let fehler = vorgang.fehler {
Section { FehlerZeile(text: fehler) }
}
}
.navigationTitle(titel)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
}
ToolbarItem(placement: .confirmationAction) {
Button(String(localized: "allgemein.sichern")) { Task { await sichere() } }
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty
|| vorgang.laeuft)
}
}
.interactiveDismissDisabled(vorgang.laeuft)
.confirmationDialog(String(localized: "kategorien.loeschenFrage"),
isPresented: $fragtNachLoeschen, titleVisibility: .visible) {
Button(String(localized: "kategorien.loeschen"), role: .destructive) {
Task { await loesche() }
}
Button(String(localized: "allgemein.abbrechen"), role: .cancel) {}
} message: {
Text(String(localized: "kategorien.loeschenErklaerung"))
}
.onAppear(perform: belegeVor)
}
}
private var titel: String {
switch zweck {
case .neu: return String(localized: "kategorien.neu")
case .aendern: return String(localized: "kategorien.aendern")
}
}
/// Welche Art diese Kategorie hat – sie steckt in der Gruppe, nicht in
/// der Kategorie selbst.
private var art: Kategorieart {
switch zweck {
case .neu(_, let kind): return kind
case .aendern(let kategorie): return kategorie.type
}
}
/// Umhängen nur innerhalb derselben Art: Eine Ausgabe-Kategorie in einer
/// Einnahme-Gruppe wäre ein Widerspruch.
private var passendeGruppen: [Kategoriegruppe] {
zustand.nachschlag.gruppen.filter { $0.kind == art }
}
private func belegeVor() {
guard !vorbelegt else { return }
vorbelegt = true
switch zweck {
case .neu(let gruppe, _):
gruppeId = gruppe
case .aendern(let kategorie):
name = kategorie.name
symbol = kategorie.icon
farbe = kategorie.color
gruppeId = kategorie.groupId
istSparen = kategorie.spendingIsGood
ohneAuswertung = kategorie.excludedFromStats
istArchiviert = kategorie.isArchived
}
}
private func sichere() async {
guard let client = zustand.client else { return }
let sauber = name.trimmingCharacters(in: .whitespacesAndNewlines)
// Sparen gibt es nur bei Ausgaben. Beim Wechsel der Art bliebe der
// Haken sonst gesetzt und wirkte still weiter.
let sparen = art == .expense && istSparen
let geglueckt = await vorgang.fuehreAus {
switch zweck {
case .neu:
_ = try await client.legeKategorieAn(KategorieAnlegen(
groupId: gruppeId, name: sauber, icon: symbol, color: farbe,
excludedFromStats: ohneAuswertung, spendingIsGood: sparen))
case .aendern(let kategorie):
_ = try await client.aendereKategorie(kategorie.id, KategorieAendern(
name: sauber, groupId: gruppeId, icon: symbol, color: farbe,
isArchived: istArchiviert, excludedFromStats: ohneAuswertung,
spendingIsGood: sparen))
}
}
if geglueckt {
fertig()
schliesse()
}
}
private func loesche() async {
guard let client = zustand.client, case .aendern(let kategorie) = zweck else { return }
if await vorgang.fuehreAus({ try await client.loescheKategorie(kategorie.id) }) {
fertig()
schliesse()
}
}
}
/// Eine Kategoriegruppe anlegen oder ändern.
struct GruppenFormular: View {
enum Zweck: Equatable, Identifiable {
case neu
case aendern(Kategoriegruppe)
var id: Int {
switch self {
case .neu: return 0
case .aendern(let gruppe): return gruppe.id
}
}
}
let zweck: Zweck
var fertig: () -> Void
@Environment(AppZustand.self) private var zustand
@Environment(\.dismiss) private var schliesse
@State private var vorgang = Speichervorgang()
@State private var name = ""
@State private var art: Kategorieart = .expense
@State private var symbol = "list"
@State private var farbe = Farbwahl.standard
@State private var fragtNachLoeschen = false
@State private var vorbelegt = false
var body: some View {
NavigationStack {
Form {
Section {
TextField(String(localized: "konto.name"), text: $name)
if case .neu = zweck {
Picker(String(localized: "kategorien.art"), selection: $art) {
Text(String(localized: "kategorien.ausgaben")).tag(Kategorieart.expense)
Text(String(localized: "kategorien.einnahmen")).tag(Kategorieart.income)
}
.pickerStyle(.segmented)
} else {
// Die Art einer Gruppe nachträglich zu wechseln würde
// alle ihre Kategorien die Seite wechseln lassen.
LabeledContent(String(localized: "kategorien.art"),
value: art == .income
? String(localized: "kategorien.einnahmen")
: String(localized: "kategorien.ausgaben"))
}
}
Section {
SymbolUndFarbeFeld(symbol: $symbol, farbe: $farbe)
}
if case .aendern = zweck {
Section {
Button(String(localized: "kategorien.gruppeLoeschen"), role: .destructive) {
fragtNachLoeschen = true
}
} footer: {
Text(String(localized: "kategorien.gruppeLoeschenErklaerung"))
}
}
if let fehler = vorgang.fehler {
Section { FehlerZeile(text: fehler) }
}
}
.navigationTitle(String(localized: "kategorien.gruppe"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
}
ToolbarItem(placement: .confirmationAction) {
Button(String(localized: "allgemein.sichern")) { Task { await sichere() } }
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty
|| vorgang.laeuft)
}
}
.confirmationDialog(String(localized: "kategorien.gruppeLoeschenFrage"),
isPresented: $fragtNachLoeschen, titleVisibility: .visible) {
Button(String(localized: "kategorien.gruppeLoeschen"), role: .destructive) {
Task { await loesche() }
}
Button(String(localized: "allgemein.abbrechen"), role: .cancel) {}
}
.onAppear {
guard !vorbelegt else { return }
vorbelegt = true
if case .aendern(let gruppe) = zweck {
name = gruppe.name
art = gruppe.kind
symbol = gruppe.icon
farbe = gruppe.color
}
}
}
}
private func sichere() async {
guard let client = zustand.client else { return }
let sauber = name.trimmingCharacters(in: .whitespacesAndNewlines)
let geglueckt = await vorgang.fuehreAus {
switch zweck {
case .neu:
_ = try await client.legeGruppeAn(GruppeAnlegen(
name: sauber, kind: art, icon: symbol, color: farbe))
case .aendern(let gruppe):
_ = try await client.aendereGruppe(gruppe.id, GruppeAendern(
name: sauber, icon: symbol, color: farbe))
}
}
if geglueckt {
fertig()
schliesse()
}
}
private func loesche() async {
guard let client = zustand.client, case .aendern(let gruppe) = zweck else { return }
if await vorgang.fuehreAus({ try await client.loescheGruppe(gruppe.id) }) {
fertig()
schliesse()
}
}
}
+250
View File
@@ -0,0 +1,250 @@
import SwiftUI
/// Kategorien, nach Gruppen. Antippen öffnet die Kategorie samt ihren
/// Erkennungswörtern.
struct KategorienAnsicht: View {
@Environment(AppZustand.self) private var zustand
@State private var vorgang = Speichervorgang()
@State private var bearbeitet: KategorieFormular.Zweck?
@State private var gruppeBearbeiten: GruppenFormular.Zweck?
@State private var zeigeArchivierte = false
var body: some View {
List {
if zustand.nachschlag.gruppen.isEmpty {
Section {
Text(String(localized: "kategorien.leer"))
.foregroundStyle(.secondary)
}
}
ForEach(zustand.nachschlag.gruppen) { gruppe in
Section {
ForEach(sichtbar(gruppe)) { kategorie in
NavigationLink {
KategorieAnsicht(kategorie: kategorie)
} label: {
KategorieZeile(kategorie: kategorie)
}
.swipeActions(edge: .trailing) {
Button {
bearbeitet = .aendern(kategorie)
} label: {
Label(String(localized: "allgemein.bearbeiten"),
systemImage: "pencil")
}
.tint(.marke)
}
}
Button {
bearbeitet = .neu(gruppe.id, gruppe.kind)
} label: {
Label(String(localized: "kategorien.neu"), systemImage: "plus")
.font(.callout)
}
} header: {
HStack(spacing: 8) {
Image(systemName: Symbole.sf(gruppe.icon))
.foregroundStyle(Color(hex: gruppe.color))
Text(gruppe.name)
Text(gruppe.kind == .income
? String(localized: "kategorien.einnahmen")
: String(localized: "kategorien.ausgaben"))
.foregroundStyle(.tertiary)
Spacer()
Button {
gruppeBearbeiten = .aendern(gruppe)
} label: {
Image(systemName: "pencil")
}
}
.textCase(nil)
.font(.footnote)
}
}
}
.listStyle(.insetGrouped)
.navigationTitle(String(localized: "nebenraeume.kategorien"))
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Toggle(String(localized: "kategorien.archivierteZeigen"),
isOn: $zeigeArchivierte)
Button {
gruppeBearbeiten = .neu
} label: {
Label(String(localized: "kategorien.neueGruppe"),
systemImage: "folder.badge.plus")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
.refreshable { await zustand.ladeNachschlag() }
.sheet(item: $bearbeitet) { zweck in
KategorieFormular(zweck: zweck) { Task { await zustand.ladeNachschlag() } }
.environment(zustand)
}
.sheet(item: $gruppeBearbeiten) { zweck in
GruppenFormular(zweck: zweck) { Task { await zustand.ladeNachschlag() } }
.environment(zustand)
}
}
private func sichtbar(_ gruppe: Kategoriegruppe) -> [Kategorie] {
gruppe.categories.filter { zeigeArchivierte || !$0.isArchived }
}
}
private struct KategorieZeile: View {
let kategorie: Kategorie
var body: some View {
HStack(spacing: 10) {
Symbolplakette(symbol: kategorie.icon, farbe: kategorie.color, groesse: 30)
Text(kategorie.name)
.lineLimit(1)
Spacer(minLength: 6)
ForEach(merkmale, id: \.self) { merkmal in
Text(merkmal)
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 1)
.background(.quaternary, in: Capsule())
}
if kategorie.keywordCount > 0 {
Label("\(kategorie.keywordCount)", systemImage: "text.magnifyingglass")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
/// Nur was vom Normalfall abweicht. Eine Plakette an jeder Zeile sagt
/// nichts mehr.
private var merkmale: [String] {
var liste: [String] = []
if kategorie.isArchived { liste.append(String(localized: "konten.archiviert")) }
if kategorie.spendingIsGood { liste.append(String(localized: "kategorien.sparen")) }
if kategorie.excludedFromStats {
liste.append(String(localized: "kategorien.ohneAuswertung"))
}
return liste
}
}
// MARK: - Eine Kategorie mit ihren Erkennungswörtern
/// Was die Erkennung an dieser Kategorie festmacht.
///
/// Erkennungswörter wirken **nur auf künftige Importe** – an bestehenden
/// Buchungen ändert sich nichts. Das steht auch so unter der Liste, sonst
/// wartet jemand vergeblich darauf, dass sich etwas rührt.
struct KategorieAnsicht: View {
let kategorie: Kategorie
@Environment(AppZustand.self) private var zustand
@State private var lader: Lader<[Erkennungswort]>?
@State private var vorgang = Speichervorgang()
@State private var neuesWort = ""
var body: some View {
Group {
if let lader {
Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { woerter in
liste(woerter)
}
} else {
ProgressView()
}
}
.navigationTitle(kategorie.name)
.navigationBarTitleDisplayMode(.inline)
.task {
guard lader == nil, let client = zustand.client else { return }
let neuer = Lader<[Erkennungswort]>(schluessel: "keywords/\(kategorie.id)") {
try await client.erkennungswoerter(kategorie: kategorie.id)
}
lader = neuer
await neuer.lade()
}
}
@ViewBuilder
private func liste(_ woerter: [Erkennungswort]) -> some View {
List {
Section {
ForEach(woerter) { wort in
HStack {
Text(wort.word)
Spacer()
if let cents = wort.amountCents {
Text(Geldbetrag(cents: cents,
waehrung: zustand.einstellungen.basiswaehrung)
.formatiert())
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
.swipeActions {
Button(role: .destructive) {
Task { await loesche(wort) }
} label: {
Label(String(localized: "allgemein.loeschen"), systemImage: "trash")
}
}
}
HStack {
TextField(String(localized: "kategorien.wortHinzufuegen"), text: $neuesWort)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.onSubmit { Task { await fuegeHinzu() } }
Button {
Task { await fuegeHinzu() }
} label: {
Image(systemName: "plus.circle.fill")
}
.disabled(neuesWort.trimmingCharacters(in: .whitespaces).isEmpty
|| vorgang.laeuft)
}
} header: {
Text(String(localized: "kategorien.erkennungswoerter"))
} footer: {
Text(String(localized: "kategorien.erkennungErklaerung"))
}
if let fehler = vorgang.fehler {
Section { FehlerZeile(text: fehler) }
}
}
.listStyle(.insetGrouped)
.refreshable { await lader?.aktualisiere() }
}
private func fuegeHinzu() async {
guard let client = zustand.client else { return }
let wort = neuesWort.trimmingCharacters(in: .whitespacesAndNewlines)
guard !wort.isEmpty else { return }
let geglueckt = await vorgang.fuehreAus {
_ = try await client.legeErkennungswortAn(
kategorie: kategorie.id, ErkennungswortAnlegen(word: wort))
}
if geglueckt {
neuesWort = ""
await lader?.aktualisiere()
// Die Kategorie führt die Anzahl mit; ohne das bleibt die
// Plakette in der Übersicht auf dem alten Stand stehen.
await zustand.ladeNachschlag()
}
}
private func loesche(_ wort: Erkennungswort) async {
guard let client = zustand.client else { return }
if await vorgang.fuehreAus({ try await client.loescheErkennungswort(wort.id) }) {
await lader?.aktualisiere()
await zustand.ladeNachschlag()
}
}
}
+168
View File
@@ -0,0 +1,168 @@
import SwiftUI
/// Schlagwörter: frei vergebene Etiketten, quer zur Kategorie.
///
/// Die Kategorie beantwortet „wofür war das Geld". Alles, was quer dazu
/// liegt – „Urlaub Island", „steuerlich absetzbar" –, passt in keine, weil
/// eine Buchung davon mehrere auf einmal sein kann.
struct TagsAnsicht: View {
@Environment(AppZustand.self) private var zustand
@State private var vorgang = Speichervorgang()
@State private var bearbeitet: Schlagwort?
@State private var neuerName = ""
var body: some View {
List {
Section {
ForEach(zustand.nachschlag.schlagwoerter) { etikett in
Button {
bearbeitet = etikett
} label: {
HStack(spacing: 10) {
Circle()
.fill(Color(hex: etikett.color))
.frame(width: 14, height: 14)
Text(etikett.name)
.foregroundStyle(.primary)
Spacer()
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.swipeActions {
Button(role: .destructive) {
Task { await loesche(etikett) }
} label: {
Label(String(localized: "allgemein.loeschen"), systemImage: "trash")
}
}
}
HStack {
TextField(String(localized: "tags.neu"), text: $neuerName)
.onSubmit { Task { await legeAn() } }
Button {
Task { await legeAn() }
} label: {
Image(systemName: "plus.circle.fill")
}
.disabled(neuerName.trimmingCharacters(in: .whitespaces).isEmpty
|| vorgang.laeuft)
}
} footer: {
Text(String(localized: "tags.erklaerung"))
}
if let fehler = vorgang.fehler {
Section { FehlerZeile(text: fehler) }
}
}
.listStyle(.insetGrouped)
.navigationTitle(String(localized: "nebenraeume.tags"))
.refreshable { await zustand.ladeNachschlag() }
.sheet(item: $bearbeitet) { etikett in
TagFormular(etikett: etikett) { Task { await zustand.ladeNachschlag() } }
.environment(zustand)
}
}
private func legeAn() async {
guard let client = zustand.client else { return }
let name = neuerName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else { return }
// Reihum eine Farbe, damit nicht alle grau sind. Ändern geht danach.
let farbe = Farbwahl.palette[zustand.nachschlag.schlagwoerter.count % Farbwahl.palette.count]
let geglueckt = await vorgang.fuehreAus {
_ = try await client.legeTagAn(TagAnlegen(name: name, color: farbe))
}
if geglueckt {
neuerName = ""
await zustand.ladeNachschlag()
}
}
private func loesche(_ etikett: Schlagwort) async {
guard let client = zustand.client else { return }
if await vorgang.fuehreAus({ try await client.loescheTag(etikett.id) }) {
await zustand.ladeNachschlag()
}
}
}
/// Ein Schlagwort umbenennen oder umfärben.
struct TagFormular: View {
let etikett: Schlagwort
var fertig: () -> Void
@Environment(AppZustand.self) private var zustand
@Environment(\.dismiss) private var schliesse
@State private var vorgang = Speichervorgang()
@State private var name = ""
@State private var farbe = Farbwahl.standard
@State private var vorbelegt = false
var body: some View {
NavigationStack {
Form {
Section {
TextField(String(localized: "konto.name"), text: $name)
}
Section {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(Farbwahl.palette, id: \.self) { wert in
Button { farbe = wert } label: {
Circle()
.fill(Color(hex: wert))
.frame(width: 28, height: 28)
.overlay {
if wert == farbe {
Circle().strokeBorder(.primary, lineWidth: 2)
}
}
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 4)
}
}
if let fehler = vorgang.fehler {
Section { FehlerZeile(text: fehler) }
}
}
.navigationTitle(String(localized: "nebenraeume.tags"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
}
ToolbarItem(placement: .confirmationAction) {
Button(String(localized: "allgemein.sichern")) { Task { await sichere() } }
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty
|| vorgang.laeuft)
}
}
.onAppear {
guard !vorbelegt else { return }
vorbelegt = true
name = etikett.name
farbe = etikett.color
}
}
}
private func sichere() async {
guard let client = zustand.client else { return }
let sauber = name.trimmingCharacters(in: .whitespacesAndNewlines)
let geglueckt = await vorgang.fuehreAus {
_ = try await client.aendereTag(etikett.id,
TagAendern(name: sauber, color: farbe))
}
if geglueckt {
fertig()
schliesse()
}
}
}
+190
View File
@@ -0,0 +1,190 @@
import SwiftUI
/// Den zweiten Faktor einrichten.
///
/// Bis Stufe 5 verwies die App dafür in den Browser. Drei Schritte:
/// Geheimnis anlegen, in die Authenticator-App übernehmen, mit einem Code
/// scharfschalten.
///
/// **Abschalten geht hier nicht**, und das ist Absicht: Es verlangt das
/// Kontopasswort, und die App speichert keines. Wer es abschalten will,
/// macht das am Rechner – ein Vollzugang auf diesem Gerät soll den Schutz
/// des Kontos nicht aufheben können.
struct ZweiterFaktorAnsicht: View {
@Environment(AppZustand.self) private var zustand
@State private var lader: Lader<ZweiterFaktorStand>?
@State private var vorgang = Speichervorgang()
@State private var einrichtung: ZweiterFaktorEinrichtung?
@State private var code = ""
@State private var rettungscodes: [String]?
var body: some View {
Group {
if let lader {
Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { stand in
inhalt(stand)
}
} else {
ProgressView()
}
}
.navigationTitle(String(localized: "zweifaktor.titel"))
.navigationBarTitleDisplayMode(.inline)
.task {
guard lader == nil, let client = zustand.client else { return }
let neuer = Lader<ZweiterFaktorStand>(schluessel: "auth/2fa/status") {
try await client.zweiterFaktorStand()
}
lader = neuer
await neuer.lade()
}
.sheet(item: Binding(get: { rettungscodes.map(Codeliste.init) },
set: { if $0 == nil { rettungscodes = nil } })) { liste in
RettungscodeBlatt(codes: liste.codes) {
rettungscodes = nil
Task { await lader?.aktualisiere() }
}
}
}
/// Nur da, um die Codes an `.sheet(item:)` zu geben.
private struct Codeliste: Identifiable {
let codes: [String]
var id: String { codes.joined() }
}
@ViewBuilder
private func inhalt(_ stand: ZweiterFaktorStand) -> some View {
Form {
Section {
LabeledContent(String(localized: "zweifaktor.stand")) {
Text(stand.enabled ? String(localized: "zweifaktor.aktiv")
: String(localized: "zweifaktor.inaktiv"))
.foregroundStyle(stand.enabled ? .green : .secondary)
}
if stand.enabled {
LabeledContent(String(localized: "zweifaktor.rettungscodes"),
value: "\(stand.recoveryCodesLeft)")
}
} footer: {
Text(stand.enabled ? String(localized: "zweifaktor.aktivErklaerung")
: String(localized: "zweifaktor.inaktivErklaerung"))
}
if !stand.enabled {
if let einrichtung {
Section {
// Auf dem Telefon selbst ist ein QR-Code sinnlos – die
// Authenticator-App liegt daneben. Der Link führt
// direkt hinein, das Geheimnis steht zum Abtippen da.
Link(destination: URL(string: einrichtung.otpauthUri)
?? URL(string: "https://example.invalid")!) {
Label(String(localized: "zweifaktor.inAppOeffnen"),
systemImage: "arrow.up.forward.app")
}
LabeledContent(String(localized: "zweifaktor.geheimnis")) {
Text(einrichtung.secret)
.font(.footnote.monospaced())
.textSelection(.enabled)
}
} header: {
Text(String(localized: "zweifaktor.schrittEins"))
} footer: {
Text(String(localized: "zweifaktor.geheimnisErklaerung"))
}
Section {
TextField(String(localized: "zweifaktor.code"), text: $code)
.keyboardType(.numberPad)
.textContentType(.oneTimeCode)
.monospaced()
Button(String(localized: "zweifaktor.aktivieren")) {
Task { await aktiviere() }
}
.disabled(code.count < 6 || vorgang.laeuft)
} header: {
Text(String(localized: "zweifaktor.schrittZwei"))
}
} else {
Section {
Button(String(localized: "zweifaktor.einrichten")) {
Task { await starte() }
}
.disabled(vorgang.laeuft)
}
}
}
if let fehler = vorgang.fehler {
Section { FehlerZeile(text: fehler) }
}
}
}
private func starte() async {
guard let client = zustand.client else { return }
var neu: ZweiterFaktorEinrichtung?
if await vorgang.fuehreAus({ neu = try await client.richteZweitenFaktorEin() }) {
einrichtung = neu
}
}
private func aktiviere() async {
guard let client = zustand.client else { return }
var codes: Rettungscodes?
let geglueckt = await vorgang.fuehreAus {
codes = try await client.aktiviereZweitenFaktor(code: code)
}
if geglueckt {
code = ""
einrichtung = nil
rettungscodes = codes?.codes
}
}
}
/// Die Rettungscodes – einmal zu sehen, danach nie wieder.
///
/// Deshalb lässt sich dieses Blatt nicht wegwischen: Wer es versehentlich
/// schliesst, bekommt neue nur mit dem Kontopasswort am Rechner.
private struct RettungscodeBlatt: View {
let codes: [String]
let fertig: () -> Void
@State private var abgehakt = false
var body: some View {
NavigationStack {
List {
Section {
ForEach(codes, id: \.self) { code in
Text(code)
.font(.body.monospaced())
.textSelection(.enabled)
}
} header: {
Text(String(localized: "zweifaktor.rettungscodes"))
} footer: {
Text(String(localized: "zweifaktor.rettungscodesErklaerung"))
}
Section {
ShareLink(item: codes.joined(separator: "\n")) {
Label(String(localized: "zweifaktor.codesSichern"),
systemImage: "square.and.arrow.up")
}
Toggle(String(localized: "zweifaktor.codesNotiert"), isOn: $abgehakt)
}
}
.navigationTitle(String(localized: "zweifaktor.aktiv"))
.navigationBarTitleDisplayMode(.inline)
.interactiveDismissDisabled()
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(String(localized: "allgemein.fertig"), action: fertig)
.disabled(!abgehakt)
}
}
}
}
}
+47
View File
@@ -1,3 +1,4 @@
import CryptoKit
import Foundation
import Security
@@ -26,6 +27,7 @@ enum Ablage {
static let serverAdresse = "certain.serverAdresse"
static let marke = "certain.marke"
static let benutzername = "certain.benutzername"
static let pin = "certain.pin"
}
// MARK: - Server-Adresse
@@ -71,9 +73,54 @@ enum Ablage {
}
}
// MARK: - Bildschirmsperre
/// Die PIN selbst wird nie abgelegt, nur ein gesalzener SHA-256 davon.
/// Das Salz liegt daneben, beides im Schlüsselbund als `salz:hash` in Hex.
///
/// Die PIN verdeckt die Oberfläche, sie verschlüsselt nicht die
/// Gerätemarke – wer das Telefon entsperrt hat und es auslesen kann,
/// käme an die Marke. Gegen Mitlesen über die Schulter und gegen ein
/// kurz liegengelassenes Telefon hilft sie, gegen Forensik nicht.
static var hatPIN: Bool { schluesselbundLesen(Schluessel.pin) != nil }
static func setzePIN(_ pin: String) {
var salz = Data(count: 16)
_ = salz.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
schluesselbundSchreiben(Schluessel.pin, "\(hex(salz)):\(hex(verhashe(pin, salz: salz)))")
}
static func stimmtPIN(_ pin: String) -> Bool {
guard let abgelegt = schluesselbundLesen(Schluessel.pin) else { return false }
let teile = abgelegt.split(separator: ":", maxSplits: 1)
guard teile.count == 2, let salz = daten(hex: String(teile[0])) else { return false }
return hex(verhashe(pin, salz: salz)) == String(teile[1])
}
static func loeschePIN() { schluesselbundLoeschen(Schluessel.pin) }
private static func verhashe(_ pin: String, salz: Data) -> Data {
Data(SHA256.hash(data: salz + Data(pin.utf8)))
}
private static func hex(_ daten: Data) -> String {
daten.map { String(format: "%02x", $0) }.joined()
}
private static func daten(hex text: String) -> Data? {
guard text.count % 2 == 0 else { return nil }
var roh = Data(); var rest = Substring(text)
while !rest.isEmpty {
guard let wert = UInt8(rest.prefix(2), radix: 16) else { return nil }
roh.append(wert); rest = rest.dropFirst(2)
}
return roh
}
/// Alles vergessen – beim Abmelden und wenn der Server die Marke ablehnt.
static func raeumeAuf() {
marke = nil
loeschePIN()
benutzername = nil
serverAdresse = nil
Zwischenspeicher.leere()
+102
View File
@@ -370,4 +370,106 @@ extension CertainClient {
try await schickeStumm(.post, "transactions/dubletten/behalten",
koerper: ["ids": ids])
}
// MARK: - Kategorien schreiben
func legeKategorieAn(_ eingabe: KategorieAnlegen) async throws -> Kategorie {
try await schicke(.post, "categories", koerper: eingabe)
}
func aendereKategorie(_ id: Int, _ aenderung: KategorieAendern) async throws -> Kategorie {
try await schicke(.patch, "categories/\(id)", koerper: aenderung)
}
/// Geht nur, solange keine Buchung daran hängt – sonst 400. Archivieren
/// ist der Weg für alles, was schon benutzt wurde.
func loescheKategorie(_ id: Int) async throws {
try await schickeStumm(.delete, "categories/\(id)")
}
func legeGruppeAn(_ eingabe: GruppeAnlegen) async throws -> Kategoriegruppe {
try await schicke(.post, "categories/groups", koerper: eingabe)
}
func aendereGruppe(_ id: Int, _ aenderung: GruppeAendern) async throws -> Kategoriegruppe {
try await schicke(.patch, "categories/groups/\(id)", koerper: aenderung)
}
func loescheGruppe(_ id: Int) async throws {
try await schickeStumm(.delete, "categories/groups/\(id)")
}
// MARK: - Schlagwörter (Tags) schreiben
func legeTagAn(_ eingabe: TagAnlegen) async throws -> Schlagwort {
try await schicke(.post, "tags", koerper: eingabe)
}
func aendereTag(_ id: Int, _ aenderung: TagAendern) async throws -> Schlagwort {
try await schicke(.patch, "tags/\(id)", koerper: aenderung)
}
func loescheTag(_ id: Int) async throws {
try await schickeStumm(.delete, "tags/\(id)")
}
// MARK: - Erkennungswörter
func erkennungswoerter(kategorie: Int) async throws -> [Erkennungswort] {
try await hole("categories/\(kategorie)/keywords")
}
func legeErkennungswortAn(kategorie: Int,
_ eingabe: ErkennungswortAnlegen) async throws -> Erkennungswort {
try await schicke(.post, "categories/\(kategorie)/keywords", koerper: eingabe)
}
func loescheErkennungswort(_ id: Int) async throws {
try await schickeStumm(.delete, "categories/keywords/\(id)")
}
/// Wörter, die oft wieder korrigiert werden – zur Prüfung gemeldet.
func markierteErkennungswoerter() async throws -> [MarkiertesWort] {
try await hole("categories/keywords/flagged")
}
/// „Behalten": Das Wort stimmt, die Korrekturen waren Einzelfälle.
func bestaetigeErkennungswort(_ id: Int) async throws {
try await schickeStumm(.post, "categories/keywords/\(id)/confirm")
}
// MARK: - Zweiter Faktor
func zweiterFaktorStand() async throws -> ZweiterFaktorStand {
try await hole("auth/2fa/status")
}
/// Legt ein noch **nicht** aktives Geheimnis an. Gültig wird es erst mit
/// `aktiviereZweitenFaktor`.
func richteZweitenFaktorEin() async throws -> ZweiterFaktorEinrichtung {
try await schicke(.post, "auth/2fa/setup")
}
/// Prüft einen Code aus der Authenticator-App und schaltet scharf.
/// Die Rettungscodes kommen dabei **einmal** – danach nie wieder.
func aktiviereZweitenFaktor(code: String) async throws -> Rettungscodes {
try await schicke(.post, "auth/2fa/activate",
koerper: ZweiterFaktorCode(code: code))
}
// MARK: - Bank
func bankverbindungen() async throws -> [Bankverbindung] {
try await hole("bank/connections")
}
/// Holt die Umsätze dieser Verbindung in den Eingang.
func rufeBankAb(_ verbindung: Int) async throws -> Abrufergebnis {
try await schicke(.post, "bank/connections/\(verbindung)/sync")
}
/// Nur die Salden auffrischen, ohne Umsätze zu holen.
func frischeSaldenAuf(_ verbindung: Int) async throws {
try await schickeStumm(.post, "bank/connections/\(verbindung)/salden")
}
}
+2
View File
@@ -47,6 +47,8 @@ Vollzugang. Wer eine Ausnahme braucht, trägt sie bewusst in `project.yml` ein.
| `Budget/` | Soll gegen Ist, Monat für Monat |
| `Buchungen/` | Buchungsliste mit Suche, Formular zum Anlegen und Ändern |
| `Abos/` | Verträge und Abos |
| `Arbeitsliste/` | Eingang, Übertragsvorschläge, Dubletten |
| `Nebenraeume/` | Kategorien, Schlagwörter, Bank, zweiter Faktor |
| `Konten/` | Kontenliste und Konto-Formular |
| `Texte/` | Übersetzungen, Deutsch und Englisch zeilengleich |
| `Tests/` | Logiktests ohne Netz, plus die Modellprüfung gegen `Fixtures/antworten.json` |
+17 -3
View File
@@ -13,7 +13,7 @@
"must_change_password": false,
"must_change_password_reason": null,
"totp_enabled": false,
"created_at": "2026-10-21T19:21:28.289884",
"created_at": "2026-10-21T19:57:08.443998",
"mfa_setup_required": false
},
"einstellungen": [
@@ -129,7 +129,7 @@
"is_archived": false,
"excluded_from_stats": false,
"spending_is_good": false,
"keyword_count": 0
"keyword_count": 1
},
{
"id": 2,
@@ -1339,7 +1339,7 @@
"fx_rate": null,
"counter_amount_cents": null,
"counter_currency": null,
"created_at": "2026-10-21T19:21:28.451708"
"created_at": "2026-10-21T19:57:08.586391"
}
],
"dubletten": {
@@ -1400,5 +1400,19 @@
}
}
]
},
"schlagwoerter_der_kategorie": [
{
"id": 1,
"category_id": 1,
"word": "supermarkt",
"amount_cents": null
}
],
"markierteSchlagwoerter": [],
"zweiterFaktor": {
"enabled": false,
"recovery_codes_left": 0
},
"bankverbindungen": []
}
+74
View File
@@ -744,3 +744,77 @@ extension ServerantwortenTests {
#expect(ids.count == vorschlaege.vorschlaege.count)
}
}
// MARK: - Nebenraeume
@Suite("Nebenraeume")
struct NebenraeumeTests {
private func felder<A: Encodable>(_ wert: A) throws -> [String: Any] {
let roh = try JSONEncoder().encode(wert)
return try #require(JSONSerialization.jsonObject(with: roh) as? [String: Any])
}
@Test("Eine Teilaenderung an Kategorie und Tag bleibt teilweise")
func teilaenderungen() throws {
// Dieselbe Falle wie bei Buchung und Konto: Ein nicht angefasstes
// Feld als null zu schicken loeschte Symbol, Farbe und Gruppe mit.
#expect(try felder(KategorieAendern(isArchived: true)).keys.sorted()
== ["is_archived"])
#expect(try felder(TagAendern(color: "#2a78d6")).keys.sorted() == ["color"])
}
@Test("Die Feldnamen der Kategorie sind die des Backends")
func kategorieFeldnamen() throws {
let neu = try felder(KategorieAnlegen(groupId: 2, name: "Spartopf",
spendingIsGood: true))
#expect(neu["group_id"] as? Int == 2)
#expect(neu["spending_is_good"] as? Bool == true)
#expect(neu["excluded_from_stats"] as? Bool == false)
}
@Test("Ein Abruf ohne zugeordnetes Konto sagt das auch")
func abrufOhneZuordnung() throws {
// Sonst meldet die App "0 Umsaetze geholt" und der Nutzer sucht den
// Fehler bei der Bank statt bei der fehlenden Zuordnung.
let roh = """
{"geholt": 0, "neu": 0, "saldi": {}, "ohne_zuordnung": true}
"""
let ergebnis = try JSONDecoder().decode(Abrufergebnis.self, from: Data(roh.utf8))
#expect(ergebnis.ohneZuordnung == true)
#expect(ergebnis.meldung != String(format: String(localized: "bank.abrufErgebnis"), 0, 0))
}
@Test("Ein voller Abruf liest sich trotz der vielen Felder")
func vollerAbruf() throws {
let roh = """
{"uebertraege": 1, "geholt": 42, "neu": 7, "gebucht": 5, "vorgemerkt": 2,
"doppelt": 0, "saldi": {}, "ab": "2026-10-01", "ohne_zuordnung": false}
"""
let ergebnis = try JSONDecoder().decode(Abrufergebnis.self, from: Data(roh.utf8))
#expect(ergebnis.geholt == 42 && ergebnis.neu == 7)
#expect(ergebnis.uebertraege == 1)
}
}
extension ServerantwortenTests {
@Test("Erkennungswoerter, zweiter Faktor und Bankverbindungen")
func nebenraeume() throws {
let woerter = try lies([Erkennungswort].self, "schlagwoerter_der_kategorie")
#expect(!woerter.isEmpty)
#expect(woerter.allSatisfy { !$0.word.isEmpty })
// Leer, solange niemand oft genug korrigiert hat - der Typ muss sich
// trotzdem lesen lassen.
_ = try lies([MarkiertesWort].self, "markierteSchlagwoerter")
let faktor = try lies(ZweiterFaktorStand.self, "zweiterFaktor")
#expect(faktor.enabled == false)
// Bleibt leer: Eine Anbindung laesst sich ohne echten Anbieter nicht
// herstellen. Die Felder von Bankverbindung deckt dieser Test NICHT
// ab - wer sie aendert, prueft von Hand.
#expect(try lies([Bankverbindung].self, "bankverbindungen").isEmpty)
}
}
+104
View File
@@ -11,6 +11,9 @@
"allgemein.unbekannt" = "Unbekannt";
"allgemein.sichern" = "Sichern";
"allgemein.ok" = "OK";
"allgemein.fertig" = "Fertig";
"allgemein.bearbeiten" = "Bearbeiten";
"allgemein.loeschen" = "Löschen";
/* --- Reiter --- */
"reiter.dashboard" = "Übersicht";
@@ -271,3 +274,104 @@
"eingang.stand.offen" = "offen";
"eingang.stand.unklar" = "unklar";
"eingang.stand.verworfen" = "verworfen";
/* --- Nebenräume --- */
"nebenraeume.titel" = "Verwalten";
"nebenraeume.kategorien" = "Kategorien";
"nebenraeume.tags" = "Schlagwörter";
"nebenraeume.bank" = "Bankanbindung";
/* --- Kategorien --- */
"kategorien.leer" = "Noch keine Kategorien.";
"kategorien.neu" = "Neue Kategorie";
"kategorien.aendern" = "Kategorie ändern";
"kategorien.neueGruppe" = "Neue Gruppe";
"kategorien.gruppe" = "Gruppe";
"kategorien.art" = "Art";
"kategorien.ausgaben" = "Ausgaben";
"kategorien.einnahmen" = "Einnahmen";
"kategorien.archivierteZeigen" = "Archivierte zeigen";
"kategorien.sparen" = "Sparen";
"kategorien.ohneAuswertung" = "ohne Auswertung";
"kategorien.istSparen" = "Ausgabe, die kein Verlust ist";
"kategorien.istSparenErklaerung" = "Für Sparen und Rücklagen. Mehr auszugeben als geplant ist hier keine Warnung, sondern gut.";
"kategorien.ohneAuswertungSchalter" = "Aus Auswertungen heraushalten";
"kategorien.ohneAuswertungErklaerung" = "Für durchlaufende Posten. Die Buchungen bleiben, zählen aber in keinem Diagramm und keiner Summe mit.";
"kategorien.loeschen" = "Kategorie löschen";
"kategorien.loeschenFrage" = "Diese Kategorie löschen?";
"kategorien.loeschenErklaerung" = "Das geht nur, solange keine Buchung daran hängt.";
"kategorien.archivierenStattLoeschen" = "Archivieren blendet die Kategorie aus und behält ihre Buchungen. Löschen geht nur, solange keine daran hängt.";
"kategorien.gruppeLoeschen" = "Gruppe löschen";
"kategorien.gruppeLoeschenFrage" = "Diese Gruppe löschen?";
"kategorien.gruppeLoeschenErklaerung" = "Das geht nur, solange keine Kategorie mehr darin steht.";
"kategorien.erkennungswoerter" = "Erkennungswörter";
"kategorien.wortHinzufuegen" = "Wort hinzufügen";
"kategorien.erkennungErklaerung" = "Trifft eines dieser Wörter auf Empfänger oder Verwendungszweck zu, ordnet der Import diese Kategorie zu. Das längste Wort gewinnt. Wirkt nur auf künftige Importe - an bestehenden Buchungen ändert sich nichts.";
"kategorien.markiertTitel" = "Wörter, die oft korrigiert werden";
"kategorien.markiertErklaerung" = "Diese Wörter ordnen häufig falsch zu. Behalten, wenn es Einzelfälle waren - entfernen, wenn das Wort nicht taugt.";
"kategorien.markiertZaehler" = "%1$d-mal angewendet, %2$d-mal korrigiert";
"kategorien.behalten" = "Behalten";
"kategorien.entfernen" = "Entfernen";
/* --- Schlagwörter (Tags) --- */
"tags.neu" = "Neues Schlagwort";
"tags.erklaerung" = "Schlagwörter liegen quer zur Kategorie: Eine Buchung kann mehrere tragen. Gut für alles, was sich nicht einordnen lässt - Urlaub Island, steuerlich absetzbar, zurückfordern.";
/* --- Einstellungen --- */
"einstellungen.erscheinungsbild" = "Erscheinungsbild";
"einstellungen.system" = "System";
"einstellungen.hell" = "Hell";
"einstellungen.dunkel" = "Dunkel";
"einstellungen.serverweitErklaerung" = "Diese Einstellungen gelten für dein Konto, nicht nur für dieses Gerät - auch im Browser.";
/* --- Zweiter Faktor einrichten --- */
"zweifaktor.stand" = "Zweiter Faktor";
"zweifaktor.aktiv" = "Aktiv";
"zweifaktor.inaktiv" = "Nicht eingerichtet";
"zweifaktor.aktivErklaerung" = "Beim Anmelden im Browser wird zusätzlich ein Code verlangt. Abschalten geht nur am Rechner - es verlangt dein Passwort, und das speichert diese App bewusst nicht.";
"zweifaktor.inaktivErklaerung" = "Ein zweiter Faktor schützt dein Konto, falls jemand dein Passwort kennt.";
"zweifaktor.einrichten" = "Einrichten";
"zweifaktor.schrittEins" = "Schritt 1: In die Authenticator-App übernehmen";
"zweifaktor.schrittZwei" = "Schritt 2: Mit einem Code bestätigen";
"zweifaktor.inAppOeffnen" = "In der Authenticator-App öffnen";
"zweifaktor.geheimnis" = "Geheimnis";
"zweifaktor.geheimnisErklaerung" = "Der Knopf öffnet die App direkt. Geht das nicht, trägst du das Geheimnis von Hand ein.";
"zweifaktor.code" = "Sechsstelliger Code";
"zweifaktor.aktivieren" = "Scharfschalten";
"zweifaktor.rettungscodes" = "Rettungscodes";
"zweifaktor.rettungscodesErklaerung" = "Jeder Code lässt sich einmal statt eines App-Codes verwenden. Sie stehen genau hier und nie wieder - sichere sie jetzt.";
"zweifaktor.codesSichern" = "Codes sichern";
"zweifaktor.codesNotiert" = "Ich habe sie gesichert";
/* --- Bank --- */
"bank.leerTitel" = "Keine Bankanbindung";
"bank.leerErklaerung" = "Eine Bank verbinden geht nur am Rechner - die Bank leitet dorthin zurück. Abrufen kannst du sie danach von hier.";
"bank.land" = "Land";
"bank.zuletzt" = "Zuletzt abgerufen";
"bank.nie" = "noch nie";
"bank.gueltigBis" = "Gültig bis";
"bank.takt" = "Automatisch";
"bank.alleNMinuten" = "alle %d Minuten";
"bank.abrufen" = "Jetzt abrufen";
"bank.saldenAuffrischen" = "Nur Salden auffrischen";
"bank.saldenAufgefrischt" = "Die Salden sind aufgefrischt.";
"bank.abrufFertig" = "Abruf fertig";
"bank.abrufErgebnis" = "%1$d Umsätze geholt, davon %2$d neu.";
"bank.ohneZuordnung" = "Dieser Anbindung ist noch kein Konto zugeordnet. Das geht am Rechner.";
/* Bildschirmsperre */
"sperre.titel" = "Certain ist gesperrt";
"sperre.pin" = "PIN";
"sperre.faceid" = "Mit Face ID entsperren";
"sperre.touchid" = "Mit Touch ID entsperren";
"sperre.grund" = "Entsperrt Certain, ohne die PIN zu tippen.";
"sperre.falsch" = "Falsche PIN. Noch %d Versuche, dann meldet sich die App ab.";
"sperre.abmelden" = "PIN vergessen? Abmelden";
"pin.einrichtenTitel" = "PIN festlegen";
"pin.erklaerung" = "Sechs Ziffern. Du brauchst sie, wenn Face ID nicht greift – und bei jedem Start.";
"pin.neu" = "Neue PIN";
"pin.wiederholen" = "Noch einmal";
"pin.zuKurz" = "Die PIN muss sechs Ziffern haben.";
"pin.ungleich" = "Die beiden Eingaben sind verschieden.";
"pin.sichern" = "Festlegen";
"pin.aendern" = "PIN ändern";
+104
View File
@@ -11,6 +11,9 @@
"allgemein.unbekannt" = "Unknown";
"allgemein.sichern" = "Save";
"allgemein.ok" = "OK";
"allgemein.fertig" = "Done";
"allgemein.bearbeiten" = "Edit";
"allgemein.loeschen" = "Delete";
/* --- Tabs --- */
"reiter.dashboard" = "Overview";
@@ -271,3 +274,104 @@
"eingang.stand.offen" = "pending";
"eingang.stand.unklar" = "unclear";
"eingang.stand.verworfen" = "discarded";
/* --- Manage --- */
"nebenraeume.titel" = "Manage";
"nebenraeume.kategorien" = "Categories";
"nebenraeume.tags" = "Tags";
"nebenraeume.bank" = "Bank connection";
/* --- Categories --- */
"kategorien.leer" = "No categories yet.";
"kategorien.neu" = "New category";
"kategorien.aendern" = "Edit category";
"kategorien.neueGruppe" = "New group";
"kategorien.gruppe" = "Group";
"kategorien.art" = "Kind";
"kategorien.ausgaben" = "Expenses";
"kategorien.einnahmen" = "Income";
"kategorien.archivierteZeigen" = "Show archived";
"kategorien.sparen" = "saving";
"kategorien.ohneAuswertung" = "not counted";
"kategorien.istSparen" = "Spending that is not a loss";
"kategorien.istSparenErklaerung" = "For savings and reserves. Spending more than planned is no warning here - it is good news.";
"kategorien.ohneAuswertungSchalter" = "Keep out of reports";
"kategorien.ohneAuswertungErklaerung" = "For pass-through items. The transactions stay but count in no chart and no total.";
"kategorien.loeschen" = "Delete category";
"kategorien.loeschenFrage" = "Delete this category?";
"kategorien.loeschenErklaerung" = "Only possible while no transaction is attached.";
"kategorien.archivierenStattLoeschen" = "Archiving hides the category and keeps its transactions. Deleting only works while none are attached.";
"kategorien.gruppeLoeschen" = "Delete group";
"kategorien.gruppeLoeschenFrage" = "Delete this group?";
"kategorien.gruppeLoeschenErklaerung" = "Only possible while no category is left in it.";
"kategorien.erkennungswoerter" = "Match words";
"kategorien.wortHinzufuegen" = "Add a word";
"kategorien.erkennungErklaerung" = "If one of these words appears in the payee or description, the importer assigns this category. The longest word wins. Applies to future imports only - existing transactions stay as they are.";
"kategorien.markiertTitel" = "Words that get corrected a lot";
"kategorien.markiertErklaerung" = "These words often assign the wrong category. Keep them if those were one-offs - remove them if the word does not work.";
"kategorien.markiertZaehler" = "applied %1$d times, corrected %2$d times";
"kategorien.behalten" = "Keep";
"kategorien.entfernen" = "Remove";
/* --- Tags --- */
"tags.neu" = "New tag";
"tags.erklaerung" = "Tags sit across categories: one transaction can carry several. Good for everything that does not fit a category - Iceland trip, tax deductible, to be reimbursed.";
/* --- Settings --- */
"einstellungen.erscheinungsbild" = "Appearance";
"einstellungen.system" = "System";
"einstellungen.hell" = "Light";
"einstellungen.dunkel" = "Dark";
"einstellungen.serverweitErklaerung" = "These settings belong to your account, not just this device - the browser follows them too.";
/* --- Two-factor setup --- */
"zweifaktor.stand" = "Two-factor";
"zweifaktor.aktiv" = "Active";
"zweifaktor.inaktiv" = "Not set up";
"zweifaktor.aktivErklaerung" = "Signing in through the browser asks for a code as well. Turning it off only works on the desktop - it needs your password, and this app deliberately stores none.";
"zweifaktor.inaktivErklaerung" = "A second factor protects your account even if someone knows your password.";
"zweifaktor.einrichten" = "Set up";
"zweifaktor.schrittEins" = "Step 1: add it to your authenticator app";
"zweifaktor.schrittZwei" = "Step 2: confirm with a code";
"zweifaktor.inAppOeffnen" = "Open in authenticator app";
"zweifaktor.geheimnis" = "Secret";
"zweifaktor.geheimnisErklaerung" = "The button opens the app directly. If that does not work, type the secret in by hand.";
"zweifaktor.code" = "Six-digit code";
"zweifaktor.aktivieren" = "Turn on";
"zweifaktor.rettungscodes" = "Recovery codes";
"zweifaktor.rettungscodesErklaerung" = "Each code works once instead of an app code. They appear here and nowhere else - save them now.";
"zweifaktor.codesSichern" = "Save codes";
"zweifaktor.codesNotiert" = "I have saved them";
/* --- Bank --- */
"bank.leerTitel" = "No bank connection";
"bank.leerErklaerung" = "Connecting a bank only works on the desktop - the bank redirects there. You can fetch from here afterwards.";
"bank.land" = "Country";
"bank.zuletzt" = "Last fetched";
"bank.nie" = "never";
"bank.gueltigBis" = "Valid until";
"bank.takt" = "Automatically";
"bank.alleNMinuten" = "every %d minutes";
"bank.abrufen" = "Fetch now";
"bank.saldenAuffrischen" = "Refresh balances only";
"bank.saldenAufgefrischt" = "Balances refreshed.";
"bank.abrufFertig" = "Fetch complete";
"bank.abrufErgebnis" = "Fetched %1$d transactions, %2$d of them new.";
"bank.ohneZuordnung" = "This connection has no account assigned yet. That is done on the desktop.";
/* Screen lock */
"sperre.titel" = "Certain is locked";
"sperre.pin" = "PIN";
"sperre.faceid" = "Unlock with Face ID";
"sperre.touchid" = "Unlock with Touch ID";
"sperre.grund" = "Unlocks Certain without typing your PIN.";
"sperre.falsch" = "Wrong PIN. %d attempts left, then the app signs out.";
"sperre.abmelden" = "Forgot your PIN? Sign out";
"pin.einrichtenTitel" = "Set a PIN";
"pin.erklaerung" = "Six digits. You need them when Face ID does not work – and on every start.";
"pin.neu" = "New PIN";
"pin.wiederholen" = "Again";
"pin.zuKurz" = "The PIN must be six digits.";
"pin.ungleich" = "The two entries differ.";
"pin.sichern" = "Set PIN";
"pin.aendern" = "Change PIN";
+96 -21
View File
@@ -6,7 +6,7 @@ haben: was entschieden ist, was fertig ist, was als Nächstes dran ist, und was
ich über das Backend herausgefunden habe. Lies sie ganz, bevor du anfängst —
die Abschnitte „Was das Backend vorgibt" und „Fallen" ersparen dir Tage.
Stand: 21.10.2026, Commit `82db09e`. Alles committet und gepusht.
Stand: 21.10.2026, Commit `d290930`. Alles committet und gepusht.
---
@@ -242,7 +242,7 @@ Drei Tests in `test_budget.py` halten beide Richtungen und die Null fest.
---
## 5. Was fertig ist — Stufen 1 bis 3 ✅
## 5. Was fertig ist — Stufen 1 bis 5 ✅
### Stufe 1 (Commit `6b86a6b`)
@@ -397,25 +397,101 @@ Gibt er nichts aus, ist nichts offen. (Ein Schlüssel, der nur in einem
Lücke des Befehls.) Dass beide Sprachfassungen dieselben Schlüssel tragen,
prüft ein `diff` der beiden `grep`-Ausgaben.
### Stufe 4 — Arbeitsliste ✅ (Commit `9edf610`)
Ein Reiter „Eingang" mit vier Abschnitten, jeder mit Anzahl im Kopf:
offene Eingangseinträge, Übertragsvorschläge, Gebuchtes ohne Kategorie,
Dubletten. `Arbeitsliste/ArbeitslisteAnsicht.swift`, dazu zwei schmale
Auswahlblätter in `AuswahlBlaetter.swift`.
**Alles in einem Abruf** (`Arbeitsstand`), aus demselben Grund wie beim
Dashboard: Vier getrennte Zwischenspeicher könnten offline aus vier
verschiedenen Momenten stammen.
#### Der 409-Fall sitzt hier, und nur hier
`POST /inbox/{id}/transfer` kann mit 409 antworten. Das ist eine **Frage**,
kein Fehler: Die App zeigt den Hinweis und wiederholt den Aufruf auf Wunsch
mit `trotzdem: true`. `bucheAlsUebertrag` fängt `APIFehler.konflikt` eigens
ab, statt ihn in den allgemeinen Fehlerweg laufen zu lassen. Wer später einen
weiteren 409-Endpunkt anbindet, macht es genauso.
#### Kleinigkeiten, die man sonst zweimal findet
- **Ohne Konto lässt sich nichts buchen** (400 vom Backend). Der Knopf bleibt
grau — und die Zeile sagt „ohne Konto", damit das nicht unerklärt wirkt.
- **Ein Übertrag braucht keine Kategorie.** Daran erkennt man ihn:
`transfer_account_id` ist gesetzt. Ohne diese Prüfung fragte die Liste ewig
nach einer Kategorie, die es nie geben wird.
- **Die Dublettenliste zeigt die Kennung der Bank.** Verschiedene Kennungen
heissen: Die Bank hat sie ausdrücklich als verschiedene Zahlungen gemeldet.
Ohne diese Spalte lässt sich die Frage nicht entscheiden.
#### Ein Backend-Fehler, den die App ans Licht brachte
`/uebertraege/vorschlaege` lieferte jedes Paar **zweimal**, einmal je
Richtung: Was verknüpft wird, fällt über `verbraucht` heraus — ein blosser
Vorschlag nicht. Die Weboberfläche zeigte den Doppeleintrag genauso.
Behoben in `services/uebertraege.py` (`schlage_vor`), mit Test.
### Stufe 5 — Nebenräume ✅ (Commit `d290930`)
Kategorien, Schlagwörter, Bankabruf und der zweite Faktor — alle unter
**Einstellungen**, nicht als eigene Reiter. Man braucht sie selten, und eine
Leiste mit zehn Reitern findet niemand mehr. `Nebenraeume/`.
#### Was hier bewusst nicht geht
- **Den zweiten Faktor abschalten.** Es verlangt das Kontopasswort, und die
App speichert keines. Ein Vollzugang auf diesem Gerät soll den Schutz des
Kontos nicht aufheben können. Dasselbe gilt für neue Rettungscodes.
- **Eine Bank verbinden.** Die Bank leitet fest auf `{base_url}/bank/rueckkehr`
zurück; der Weg führt zwangsläufig über den Browser. Abrufen und Salden
auffrischen gehen von hier.
#### Zwei Eigenheiten, die beim Nachbauen zählen
1. **Der zweite Faktor braucht auf dem Telefon keinen QR-Code.** Die
Authenticator-App liegt daneben — `otpauth_uri` führt per Link direkt
hinein. `qr_png` bleibt ungenutzt; auf dem iPad könnte man es zeigen.
2. **Die Rettungscodes erscheinen genau einmal.** Das Blatt lässt sich nicht
wegwischen (`interactiveDismissDisabled`) und erst schliessen, wenn der
Nutzer bestätigt hat, sie gesichert zu haben. Wer es verliert, braucht
das Kontopasswort am Rechner.
#### Einstellungen schreiben ohne Sichern-Knopf
`PUT /settings/{key}` nimmt je Aufruf **einen** Wert, als Query. Die Bindung
schreibt beim Setzen direkt; ein Formular mit Sichern-Knopf wäre eine
Zwischenstufe, die nichts bringt. Die Werte gehören dem **Konto**, nicht dem
Gerät — das steht auch unter dem Abschnitt, sonst wundert sich jemand, warum
der Browser sich mitändert.
#### Prüfdatei-Tests zählen keine Mengen mehr
Die Saat in `erzeuge_ios_fixture.py` wuchs um Eingangseintrag, Dubletten und
ein Übertragspaar — und die älteren Tests wurden rot, weil sie exakte Anzahlen
prüften. Sie prüfen jetzt Eigenschaften (`>= 2`, „enthält einen Übertrag"),
damit sie das nächste Wachstum überleben. **Daran denken, wenn du die Saat
erweiterst.**
---
## 6. Was als Nächstes dran ist
**Stufe 4 ist dran.** Alle vier Backend-Ergänzungen sind drin; von hier an
**Stufe 6 ist dran.** Alle vier Backend-Ergänzungen sind drin; von hier an
reicht die API, wie sie ist.
Was aus Stufe 3 bewusst nicht mitkam: das **Logo** eines Vertrags (Upload und
Zuschnitt, `POST /subscriptions/{id}/logo`) und die **Reihenfolge** der Konten
(`PUT /accounts/order`). Beides ist am Rechner bequemer und hielt niemanden
auf.
Was bewusst nicht mitkam: das **Logo** eines Vertrags (Upload und Zuschnitt,
`POST /subscriptions/{id}/logo`), die **Reihenfolge** der Konten
(`PUT /accounts/order`), das **Lösen** einer Übertrags-Verknüpfung
(`DELETE /uebertraege/{link_id}`), die **Transfer-Schlagwörter**
(`/categories/transfer-keywords`) und das **Anwenden** von Regeln auf
Bestehendes (`POST /categories/keywords/anwenden`). Alles am Rechner
bequemer, nichts davon hielt jemanden auf.
**Stufe 4 — Arbeitsliste.** Eingang mit Buchen, Verwerfen, Kategorie zuordnen,
Übertragsvorschlägen und Dubletten. Fachlich der Teil, der am besten aufs
Telefon gehört: eine Zeile, eine Entscheidung.
**Stufe 5 — Nebenräume.** Kategorien, Tags, Schlagwörter, Einstellungen,
Bankabruf anstossen. Hier gehört auch das **Einrichten** des zweiten Faktors
hin — bisher verweist die App dafür in den Browser.
Weiter aussen vor, wie von Anfang an entschieden: Bank **verbinden**,
Postfach einrichten, Sicherung und Wiederherstellung, Benutzerverwaltung.
**Stufe 6 — Erweiterungen.** Teilen-Menü und Kurzbefehle. Brauchen eigene Ziele
im Xcode-Projekt, eine **App-Gruppe** und eine **geteilte
@@ -431,14 +507,13 @@ Vorlagen, Verlauf mit Rücknahme.
### Zum Umfang, ehrlich
Die Weboberfläche hat rund 8900 Zeilen JSX. Nativ wird das nicht weniger.
Stufe 2 hat rund 1800 Zeilen gebraucht, Stufe 3 rund 1200; Stufen 4 bis 6
zusammen sind grob ein bis zwei Wochen, Stufe 7 nochmal zwei bis drei.
Stufe 2 hat rund 1800 Zeilen gebraucht, Stufe 3 rund 1200, Stufe 4 rund 900,
Stufe 5 rund 1500; Stufe 6 ist überschaubar, Stufe 7 nochmal zwei bis drei
Wochen.
Der **409-Fall** („nachfragen", mit `trotzdem=true` wiederholbar) kam in
Stufe 3 nicht vor — er steckt nur an
`POST /transactions/{id}/transfer`, also in Stufe 4. Dort wird er gebraucht:
Die App muss den Hinweis zeigen und den Aufruf auf Wunsch wiederholen,
statt den Fehler bloss zu melden.
Der **409-Fall** („nachfragen", mit `trotzdem=true` wiederholbar) ist in
Stufe 4 angekommen und erledigt — siehe dort. In den Stufen 5 bis 7 kommt er
nicht mehr vor.
Stufe 7 steht bewusst am Ende. Alles davor ist für sich nutzbar. Der Ablauf
hat **keinen Server-Zustand** zwischen den Schritten: Die Datei wird bei jedem
+18 -3
View File
@@ -28,20 +28,32 @@ targets:
- path: Einstellungen
- path: Konten
- path: Modelle
- path: Nebenraeume
- path: Netzwerk
# Deutsch und Englisch, zeilengleich gehalten.
- path: Texte
# Icon-Composer-Buendel (Ordner). Als Datei einhaengen, sonst zerlegt
# XcodeGen es in eine Gruppe und actool findet das Icon nicht.
- path: AppIcon.icon
type: file
buildPhase: resources
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.scarriffle.certain
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
# Signing automatisch; wer mit einem anderen Apple-Konto baut, traegt
# hier seine Team-ID ein (Xcode: Signing & Capabilities -> Team).
CODE_SIGN_STYLE: Automatic
DEVELOPMENT_TEAM: PP34X97WS3
MARKETING_VERSION: "0.1"
CURRENT_PROJECT_VERSION: "1"
# iPhone und iPad, zweispaltig gedacht.
TARGETED_DEVICE_FAMILY: "1,2"
# Nur iPhone, nur Hochformat - am Rechner gibt es die Weboberflaeche.
TARGETED_DEVICE_FAMILY: "1"
SUPPORTED_PLATFORMS: "iphoneos iphonesimulator"
SUPPORTS_MACCATALYST: NO
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD: NO
INFOPLIST_KEY_LSApplicationCategoryType: "public.app-category.finance"
# Sprachfassung 5: der Code ist nicht auf die strikte
# Nebenlaeufigkeitspruefung von Swift 6 hin geschrieben. Umstellen,
# wenn die Uebergaenge AppZustand <-> CertainClient geprueft sind.
@@ -51,7 +63,10 @@ targets:
INFOPLIST_KEY_CFBundleDisplayName: Certain
INFOPLIST_KEY_UILaunchScreen_Generation: YES
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
INFOPLIST_KEY_UISupportedInterfaceOrientations: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"
INFOPLIST_KEY_UISupportedInterfaceOrientations: "UIInterfaceOrientationPortrait"
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"
# Ohne diesen Text stuerzt die App beim ersten Face-ID-Versuch ab.
INFOPLIST_KEY_NSFaceIDUsageDescription: "Damit entsperrst du Certain, ohne deine PIN zu tippen."
CertainTests:
type: bundle.unit-test
+1 -1
View File
@@ -187,7 +187,7 @@ export default function Categories() {
editing,
form: editing
? { name: editing.name, kind: editing.kind, icon: editing.icon, color: editing.color }
: { name: "", kind: "expense", icon: "list", color: "#3f51b5" },
: { name: "", kind: "expense", icon: "list", color: "#3f9c72" },
});
}
function openCategory(groupId, editing = null) {
+1 -1
View File
@@ -17,7 +17,7 @@ import { useToast } from "../toast";
//
// Gesetzt werden Tags nicht hier, sondern an der Buchung – in der Liste und im
// Bearbeiten-Dialog. Hier geht es nur um den Vorrat.
const STANDARDFARBE = "#3f51b5";
const STANDARDFARBE = "#3f9c72";
export default function Tags() {
const { t } = useTranslation();