Compare commits

13 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
GuidoandClaude Opus 5 9edf610d59 feat: Vierte Stufe der iOS-App - die Arbeitsliste
Ein neuer Reiter "Eingang" mit allem, worueber jemand entscheiden muss. Vier
Abschnitte, jeweils mit Anzahl im Kopf:

- Zu buchen: offene Eingangseintraege aus Kurzbefehl, Postfach oder Bank.
  Buchen, als Uebertrag buchen, verwerfen - oder alle auf einmal, bei denen
  das Konto feststeht. Unklare tragen einen Hinweis ueber den Knoepfen, nicht
  darunter: Man soll sie ansehen, nicht wegbuchen.
- Koennten Uebertraege sein: zwei getrennte Buchungen, die zusammengehoeren
  koennten. Verbinden oder ablehnen. Hat eine der beiden eine Kategorie,
  steht dabei, dass sie durchs Verbinden aus dem Budget faellt.
- Ohne Kategorie: gebuchtes Geld, das in keiner Budget-Zeile mitzaehlt. Ein
  Tipp, eine Kategorie - kein Formular mit zwoelf Feldern davor.
- Sieht doppelt aus: gleiches Konto, gleicher Tag, gleicher Betrag. Mit der
  Kennung der Bank je Zeile, denn verschiedene Kennungen heissen, dass die
  Bank sie als verschiedene Zahlungen gemeldet hat.

Alles in einem Abruf. Vier getrennte Zwischenspeicher koennten offline aus
vier verschiedenen Momenten stammen und sich widersprechen.

Hier sitzt der einzige 409-Fall der App: Beim Buchen als Uebertrag kann das
Backend Bedenken haben. Das ist eine Frage, kein Fehler - die App zeigt den
Hinweis und wiederholt den Aufruf auf Wunsch mit trotzdem=true. Ihn zu
verschlucken waere falsch, ihn als blossen Fehler zu zeigen auch.

Zwei Dinge, die die Tests festhalten: Ohne Konto laesst sich nichts buchen
(das Backend lehnt mit 400 ab, der Knopf bleibt also grau - und die Zeile sagt
auch warum). Und ein Uebertrag braucht keine Kategorie, sonst fragte die Liste
ewig nach einer, die es nie geben wird.

Die Pruefdatei deckt jetzt auch Eingang, Dubletten und Uebertragsvorschlaege
ab. Die aelteren Pruefungen zaehlten exakt und wurden dadurch rot - sie
pruefen jetzt Eigenschaften statt Mengen, damit sie das naechste Wachstum
ueberleben. 54 Tests gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 21:28:00 +02:00
GuidoandClaude Opus 5 adbc3e0aa4 fix: Ein Uebertragsvorschlag erscheint nur noch einmal
Jedes Paar steht in der Kandidatenliste zweimal, einmal je Richtung. Was
verknuepft wird, faellt ueber "verbraucht" heraus - ein blosser Vorschlag aber
nicht. Also bot /uebertraege/vorschlaege dieselbe Zahlung zweimal zum
Verbinden an, und wer das zweite anklickte, bekam "bereits verknuepft" fuer
etwas, das er gerade selbst verknuepft hatte.

Betrifft auch die Weboberflaeche: Uebertragsvorschlaege.jsx zeigt die Liste
unveraendert an und hatte denselben Doppeleintrag.

Gefunden beim Bauen der Arbeitsliste fuer die iOS-App - die Pruefdatei brachte
den Vorschlag doppelt zurueck.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 21:28:00 +02:00
38 changed files with 3965 additions and 128 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
+15 -4
View File
@@ -310,6 +310,17 @@ def finde(db: Session, user: User, nur_vorschlaege: bool = False) -> dict:
verbraucht: set[int] = set()
verknuepft = 0
vorschlaege: list[tuple[Transaction, Transaction]] = []
# Jedes Paar steht in ``kandidaten`` zweimal, einmal je Richtung. Was
# verknüpft wird, fällt über ``verbraucht`` heraus – ein Vorschlag aber
# nicht, und so kam dieselbe Zahlung zweimal zum Verbinden angeboten.
vorgeschlagen: set[tuple[int, int]] = set()
def schlage_vor(a: Transaction, b: Transaction) -> None:
paar = (min(a.id, b.id), max(a.id, b.id))
if paar in vorgeschlagen:
return
vorgeschlagen.add(paar)
vorschlaege.append((a, b))
for a_id in sorted(kandidaten):
if a_id in verbraucht:
@@ -321,17 +332,17 @@ def finde(db: Session, user: User, nur_vorschlaege: bool = False) -> dict:
gegen = _eindeutig(a, offen, kandidaten, verbraucht)
if gegen is None:
if offen:
vorschlaege.append((a, offen[0]))
schlage_vor(a, offen[0])
continue
# Automatisch nur ohne Kategorie und mit Hinweis – siehe _hat_hinweis.
if a.category_id is not None or gegen.category_id is not None:
vorschlaege.append((a, gegen))
schlage_vor(a, gegen)
continue
if not _hat_hinweis(a, gegen, bezeichner, typen):
vorschlaege.append((a, gegen))
schlage_vor(a, gegen)
continue
if nur_vorschlaege:
vorschlaege.append((a, gegen))
schlage_vor(a, gegen)
continue
verbinde(db, user, a, gegen, automatisch=True)
verbraucht.update((a.id, gegen.id))
+55 -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
@@ -91,8 +91,49 @@ def _seed(c: TestClient) -> None:
params={"planned_cents": 50000})
c.post(f"/api/subscriptions/{abo['id']}/buchungen/{kauf['id']}")
# --- Arbeitsliste -----------------------------------------------------
# Ein Eingangseintrag entsteht nur ueber die Schnellerfassung; von Hand
# legt man keinen an. Also eine Marke ausstellen und damit buchen.
marke = c.post("/api/inbox/tokens",
json={"name": "Fixture", "default_account_id": giro["id"]}).json()
kopf = {"X-Certain-Token": marke["token"]}
c.post("/api/quick/booking", headers=kopf,
json={"amount": "12.90", "payee": "Baeckerei", "date": heute,
"external_id": "fixture-1"})
PFADE = {
# Zwei gleich aussehende Buchungen: gleiches Konto, gleicher Tag,
# gleicher krummer Betrag. Genau das meldet /dubletten.
for _ in range(2):
c.post("/api/transactions", json={
"type": "expense", "date": heute, "account_id": giro["id"],
"amount_cents": 1043, "payee": "Kiosk", "category_id": ausgabe["id"]})
# Ein Abgang und ein Zugang ueber verschiedene Konten, gleicher Betrag,
# unverknuepft - daraus macht /uebertraege/vorschlaege einen Vorschlag.
# Beide Konten muessen dieselbe Waehrung haben: 75 CHF und 75 EUR sind
# nicht derselbe Betrag, nur dieselbe Zahl (siehe _kandidaten).
zweitgiro = c.post("/api/accounts", json={
"name": "Zweitkonto", "type": "girokonto", "currency": "EUR",
"opening_balance_cents": 0}).json()
c.post("/api/transactions", json={
"type": "expense", "date": heute, "account_id": giro["id"],
"amount_cents": 7500, "payee": "Dauerauftrag"})
c.post("/api/transactions", json={
"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"})
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",
@@ -108,6 +149,16 @@ PFADE = {
"budgetfortschritt": "/api/dashboard/budget-progress",
"einnahmenAusgaben": "/api/dashboard/income-expense",
"letzteBuchungen": "/api/dashboard/recent",
"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",
}
@@ -118,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]}")
+27
View File
@@ -354,3 +354,30 @@ def test_vorschlag_auch_wenn_die_gegenseite_tage_spaeter_steht(admin):
# Bestaetigen kann der Nutzer es trotzdem.
assert admin.post("/api/uebertraege/verbinden",
json={"a_id": raus["id"], "b_id": rein["id"]}).status_code == 200
def test_ein_paar_wird_nur_einmal_vorgeschlagen(admin):
"""Jedes Paar steht intern zweimal, einmal je Richtung.
Ohne Entdopplung saehe der Nutzer dieselbe Zahlung zweimal zum Verbinden
angeboten - und wer das zweite anklickt, bekaeme den Fehler "bereits
verknuepft" fuer etwas, das er gerade selbst verknuepft hat.
"""
import secrets
betrag = 100000 + secrets.randbelow(800000)
a = admin.post("/api/accounts", json={
"name": f"A {secrets.token_hex(3)}", "currency": "EUR"}).json()
b = admin.post("/api/accounts", json={
"name": f"B {secrets.token_hex(3)}", "currency": "EUR"}).json()
raus = admin.post("/api/transactions", json={
"type": "expense", "date": "2026-07-14", "account_id": a["id"],
"amount_cents": betrag, "payee": "Umbuchung"}).json()
rein = admin.post("/api/transactions", json={
"type": "income", "date": "2026-07-14", "account_id": b["id"],
"amount_cents": betrag, "payee": "Umbuchung"}).json()
paare = admin.get("/api/uebertraege/vorschlaege").json()["vorschlaege"]
meins = [p for p in paare
if {p["a"]["id"], p["b"]["id"]} == {raus["id"], rein["id"]}]
assert len(meins) == 1, f"einmal vorschlagen, nicht {len(meins)}-mal"
+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
}
}
}
}
}
+11
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.
@@ -51,6 +59,9 @@ struct Hauptnavigation: View {
Tab(String(localized: "reiter.buchungen"), systemImage: "list.bullet.rectangle") {
NavigationStack { BuchungsListe() }
}
Tab(String(localized: "reiter.arbeitsliste"), systemImage: "tray") {
NavigationStack { ArbeitslisteAnsicht() }
}
Tab(String(localized: "reiter.abos"), systemImage: "arrow.triangle.2.circlepath") {
NavigationStack { AboListe() }
}
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"
}
}
+478
View File
@@ -0,0 +1,478 @@
import SwiftUI
/// Alles, worüber jemand entscheiden muss, in einem Abruf.
///
/// Vier Quellen, die nichts voneinander wissen – aber einen gemeinsamen
/// Stand ergeben sollen. Vier getrennte Zwischenspeicher könnten offline aus
/// vier verschiedenen Momenten stammen.
struct Arbeitsstand: Codable, Sendable, Hashable {
let eingang: [Eingangseintrag]
let vorschlaege: Uebertragsvorschlaege
let dubletten: Dublettengruppen
/// Gebuchtes ohne Kategorie – der stillste der vier Posten. Ohne
/// Kategorie zählt eine Ausgabe in keiner Budget-Zeile mit.
let ohneKategorie: [Buchung]
var istLeer: Bool {
offenerEingang.isEmpty && vorschlaege.vorschlaege.isEmpty
&& dubletten.gruppen.isEmpty && ohneKategorie.isEmpty
}
/// Was im Eingang noch eine Entscheidung braucht. Gebuchtes ist erledigt
/// und steht nur im Weg.
var offenerEingang: [Eingangseintrag] {
eingang.filter { $0.status == .pending || $0.status == .unclear }
}
var anzahlOffen: Int {
offenerEingang.count + vorschlaege.vorschlaege.count
+ dubletten.gruppen.count + ohneKategorie.count
}
}
extension CertainClient {
func arbeitsstand() async throws -> Arbeitsstand {
async let eingang = self.eingang()
async let vorschlaege = uebertragsvorschlaege()
async let dubletten = self.dubletten()
// Das Backend lässt Überträge hier schon aussen vor und überspringt
// verknüpfte Buchungen – siehe den uncategorized-Zweig im Router.
async let ohneKategorie = buchungen(ohneKategorie: true, grenze: 100)
return try await Arbeitsstand(eingang: eingang, vorschlaege: vorschlaege,
dubletten: dubletten, ohneKategorie: ohneKategorie)
}
}
/// Die Arbeitsliste: eine Zeile, eine Entscheidung.
///
/// Fachlich der Teil, der am besten aufs Telefon gehört – man arbeitet ihn
/// zwischendurch ab, nicht am Schreibtisch.
struct ArbeitslisteAnsicht: View {
@Environment(AppZustand.self) private var zustand
@State private var lader: Lader<Arbeitsstand>?
@State private var vorgang = Speichervorgang()
@State private var kategorieFuer: Eingangseintrag?
@State private var kategorieFuerBuchung: Buchung?
@State private var uebertragFuer: Eingangseintrag?
@State private var rueckfrage: Rueckfrage?
/// Was das Backend mit 409 beantwortet hat, samt dem Aufruf, der sich
/// mit `trotzdem` wiederholen lässt.
struct Rueckfrage: Identifiable {
let id = UUID()
let text: String
let nochmal: () async -> Void
}
var body: some View {
Group {
if let lader {
Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { stand in
liste(stand)
}
} else {
ProgressView()
}
}
.navigationTitle(String(localized: "reiter.arbeitsliste"))
.task {
guard lader == nil, let client = zustand.client else { return }
let neuer = Lader<Arbeitsstand>(schluessel: "arbeitsliste") {
try await client.arbeitsstand()
}
lader = neuer
await neuer.lade()
}
.sheet(item: $kategorieFuer) { eintrag in
KategorieWaehlen(titel: eintrag.payee ?? eintrag.quelle,
nachschlag: zustand.nachschlag) { kategorie in
await tu { try await $0.setzeEingangsKategorie(eintrag.id, kategorie: kategorie) }
}
}
.sheet(item: $kategorieFuerBuchung) { buchung in
KategorieWaehlen(titel: buchung.ueberschrift,
nachschlag: zustand.nachschlag,
art: buchung.type == .income ? .income : .expense) { kategorie in
await tu { try await $0.aendereBuchung(buchung.id,
BuchungAendern(categoryId: kategorie)) }
}
}
.sheet(item: $uebertragFuer) { eintrag in
ZielkontoWaehlen(nachschlag: zustand.nachschlag, ausser: eintrag.accountId) { ziel in
await bucheAlsUebertrag(eintrag, ziel: ziel, trotzdem: false)
}
}
.alert(item: $rueckfrage) { frage in
Alert(title: Text(String(localized: "arbeit.rueckfrage")),
message: Text(frage.text),
primaryButton: .default(Text(String(localized: "arbeit.trotzdem"))) {
Task { await frage.nochmal() }
},
secondaryButton: .cancel(Text(String(localized: "allgemein.abbrechen"))))
}
.alert(String(localized: "fehler.titel"),
isPresented: .constant(vorgang.fehler != nil)) {
Button(String(localized: "allgemein.ok")) { vorgang.fehler = nil }
} message: {
Text(vorgang.fehler ?? "")
}
}
// MARK: - Liste
@ViewBuilder
private func liste(_ stand: Arbeitsstand) -> some View {
if stand.istLeer {
LeerAnsicht(titel: String(localized: "arbeit.leerTitel"),
erklaerung: String(localized: "arbeit.leerErklaerung"),
symbol: "checkmark.circle")
} else {
List {
if !stand.offenerEingang.isEmpty {
Section {
ForEach(stand.offenerEingang) { eintrag in
EingangsZeile(eintrag: eintrag, nachschlag: zustand.nachschlag,
buchen: { Task { await buche(eintrag) } },
uebertrag: { uebertragFuer = eintrag },
verwerfen: { Task { await verwirf(eintrag) } })
}
} header: {
Kopf(titel: String(localized: "arbeit.eingang"),
anzahl: stand.offenerEingang.count)
} footer: {
if stand.offenerEingang.contains(where: \.laesstSichBuchen) {
Button(String(localized: "arbeit.alleBuchen")) {
Task { await tu { _ = try await $0.bucheGanzenEingang() } }
}
.disabled(vorgang.laeuft)
}
}
}
if !stand.vorschlaege.vorschlaege.isEmpty {
Section {
ForEach(stand.vorschlaege.vorschlaege) { paar in
VorschlagsZeile(paar: paar,
verbinden: { Task { await verbinde(paar) } },
ablehnen: { Task { await lehneAb(paar) } })
}
} header: {
Kopf(titel: String(localized: "arbeit.uebertraege"),
anzahl: stand.vorschlaege.vorschlaege.count)
}
}
if !stand.ohneKategorie.isEmpty {
Section {
ForEach(stand.ohneKategorie) { buchung in
Button {
kategorieFuerBuchung = buchung
} label: {
OhneKategorieZeile(buchung: buchung,
nachschlag: zustand.nachschlag)
}
.buttonStyle(.plain)
}
} header: {
Kopf(titel: String(localized: "arbeit.ohneKategorie"),
anzahl: stand.ohneKategorie.count)
}
}
if !stand.dubletten.gruppen.isEmpty {
Section {
ForEach(stand.dubletten.gruppen) { gruppe in
DublettenZeile(gruppe: gruppe, nachschlag: zustand.nachschlag) {
Task { await behalte(gruppe) }
}
}
} header: {
Kopf(titel: String(localized: "arbeit.dubletten"),
anzahl: stand.dubletten.gruppen.count)
} footer: {
Text(String(localized: "arbeit.dublettenErklaerung"))
}
}
}
.listStyle(.insetGrouped)
.refreshable { await lader?.aktualisiere() }
.disabled(vorgang.laeuft)
}
}
// MARK: - Entscheidungen
/// Jede Entscheidung läuft gleich: schreiben, dann neu laden. Die Liste
/// muss danach stimmen – ein erledigter Posten, der stehen bleibt, führt
/// zum zweiten Klick auf dieselbe Sache.
private func tu(_ arbeit: @escaping (CertainClient) async throws -> Void) async {
guard let client = zustand.client else { return }
if await vorgang.fuehreAus({ try await arbeit(client) }) {
await lader?.aktualisiere()
}
}
private func buche(_ eintrag: Eingangseintrag) async {
await tu { _ = try await $0.bucheEingang(eintrag.id) }
}
private func verwirf(_ eintrag: Eingangseintrag) async {
await tu { try await $0.verwerfeEingang(eintrag.id) }
}
/// Der einzige 409-Fall der App: Das Backend hat Bedenken und lässt sich
/// mit `trotzdem` überstimmen. Den Hinweis zu verschlucken wäre falsch,
/// aber ihn als blossen Fehler zu zeigen auch – er ist eine Frage.
private func bucheAlsUebertrag(_ eintrag: Eingangseintrag, ziel: Int,
trotzdem: Bool) async {
guard let client = zustand.client else { return }
do {
_ = try await client.bucheEingangAlsUebertrag(eintrag.id, aufKonto: ziel,
trotzdem: trotzdem)
await lader?.aktualisiere()
} catch APIFehler.konflikt(let text) {
rueckfrage = Rueckfrage(text: text) {
await bucheAlsUebertrag(eintrag, ziel: ziel, trotzdem: true)
}
} catch {
vorgang.fehler = (error as? APIFehler)?.errorDescription
?? error.localizedDescription
}
}
private func verbinde(_ paar: Uebertragsvorschlaege.Vorschlag) async {
await tu { try await $0.verbindeUebertrag(paar.a.id, paar.b.id) }
}
private func lehneAb(_ paar: Uebertragsvorschlaege.Vorschlag) async {
await tu { try await $0.lehneUebertragAb(paar.a.id, paar.b.id) }
}
private func behalte(_ gruppe: Dublettengruppen.Gruppe) async {
await tu { try await $0.behalteDubletten(gruppe.zeilen.map(\.id)) }
}
}
// MARK: - Bausteine
private struct Kopf: View {
let titel: String
let anzahl: Int
var body: some View {
HStack {
Text(titel)
Spacer()
Text("\(anzahl)")
.monospacedDigit()
.padding(.horizontal, 7)
.padding(.vertical, 1)
.background(.quaternary, in: Capsule())
}
.textCase(nil)
.font(.footnote)
}
}
private struct EingangsZeile: View {
let eintrag: Eingangseintrag
let nachschlag: Nachschlag
let buchen: () -> Void
let uebertrag: () -> Void
let verwerfen: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
Text(eintrag.payee ?? String(localized: "buchung.ohneBezeichnung"))
.font(.callout)
.lineLimit(1)
Text(unterzeile)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
Text(eintrag.betrag.formatiert())
.font(.callout.monospacedDigit())
.foregroundStyle(eintrag.amountCents < 0 ? Color.primary : Color.green)
}
if eintrag.status == .unclear {
// Die Erkennung war unsicher. Das gehört vor die Knöpfe, nicht
// dahinter – sonst bucht man es weg, ohne es gelesen zu haben.
Label(String(localized: "arbeit.unklarHinweis"),
systemImage: "questionmark.circle")
.font(.caption2)
.foregroundStyle(.orange)
}
HStack(spacing: 8) {
Button(String(localized: "arbeit.buchen"), action: buchen)
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(!eintrag.laesstSichBuchen)
Button(String(localized: "arbeit.alsUebertrag"), action: uebertrag)
.buttonStyle(.bordered)
.controlSize(.small)
Spacer()
Button(String(localized: "arbeit.verwerfen"), role: .destructive,
action: verwerfen)
.buttonStyle(.bordered)
.controlSize(.small)
}
.buttonStyle(.bordered)
}
.padding(.vertical, 4)
}
private var unterzeile: String {
var teile = [eintrag.quelle, eintrag.date.formatiert()]
if let konto = nachschlag.konto(eintrag.accountId) {
teile.append(konto.name)
} else {
// Ohne Konto lässt es sich nicht buchen. Das soll dastehen,
// statt dass der Knopf unerklärt grau bleibt.
teile.append(String(localized: "arbeit.ohneKonto"))
}
return teile.joined(separator: " · ")
}
}
private struct VorschlagsZeile: View {
let paar: Uebertragsvorschlaege.Vorschlag
let verbinden: () -> Void
let ablehnen: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8) {
seite(paar.a)
HStack(spacing: 6) {
Image(systemName: "arrow.down")
.font(.caption2)
.foregroundStyle(.secondary)
Text(paar.a.date.formatiert())
.font(.caption2)
.foregroundStyle(.secondary)
}
seite(paar.b)
if paar.hatKategorie {
// Eine zugeordnete Buchung fällt durchs Verbinden aus dem
// Budget. Das soll niemanden überraschen.
Label(String(localized: "arbeit.hatKategorie"),
systemImage: "exclamationmark.circle")
.font(.caption2)
.foregroundStyle(.orange)
}
HStack(spacing: 8) {
Button(String(localized: "arbeit.verbinden"), action: verbinden)
.buttonStyle(.borderedProminent)
.controlSize(.small)
Button(String(localized: "arbeit.keinUebertrag"), action: ablehnen)
.buttonStyle(.bordered)
.controlSize(.small)
}
}
.padding(.vertical, 4)
}
private func seite(_ seite: Uebertragsvorschlaege.Seite) -> some View {
HStack(spacing: 8) {
Image(systemName: seite.type.symbol)
.font(.caption)
.foregroundStyle(seite.type == .income ? .green : .secondary)
VStack(alignment: .leading, spacing: 1) {
Text(seite.accountName)
.font(.callout)
.lineLimit(1)
Text(seite.bezeichnung)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
Text(seite.betrag.formatiert())
.font(.callout.monospacedDigit())
}
}
}
private struct OhneKategorieZeile: View {
let buchung: Buchung
let nachschlag: Nachschlag
var body: some View {
HStack(spacing: 10) {
Symbolplakette(symbol: "more-horizontal", farbe: "#6b7480", groesse: 32)
VStack(alignment: .leading, spacing: 1) {
Text(buchung.ueberschrift)
.font(.callout)
.lineLimit(1)
Text("\(buchung.date.formatiert()) · \(nachschlag.kontoname(buchung.accountId))")
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
Text(buchung.betrag.formatiert(vorzeichen: .ohne))
.font(.callout.monospacedDigit())
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundStyle(.tertiary)
}
.padding(.vertical, 2)
}
}
private struct DublettenZeile: View {
let gruppe: Dublettengruppen.Gruppe
let nachschlag: Nachschlag
let behalten: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(Geldbetrag(cents: gruppe.amountCents,
waehrung: waehrung).formatiert())
.font(.callout.monospacedDigit())
Text("· \(gruppe.zeilen.count)×")
.font(.callout)
.foregroundStyle(.secondary)
Spacer()
Text("\(gruppe.datum.formatiert()) · \(nachschlag.kontoname(gruppe.accountId))")
.font(.caption2)
.foregroundStyle(.secondary)
}
ForEach(gruppe.zeilen) { zeile in
HStack(spacing: 6) {
Text("•").foregroundStyle(.tertiary)
Text(zeile.bezeichnung)
.lineLimit(1)
if let kennung = zeile.kennung {
// Verschiedene Kennungen heissen: Die Bank hat sie
// ausdrücklich als verschiedene Zahlungen gemeldet.
Text(kennung)
.foregroundStyle(.tertiary)
.lineLimit(1)
}
Spacer()
Text(zeile.herkunft)
.foregroundStyle(.tertiary)
}
.font(.caption2)
}
Button(String(localized: "arbeit.istRichtigSo"), action: behalten)
.buttonStyle(.bordered)
.controlSize(.small)
}
.padding(.vertical, 4)
}
private var waehrung: String {
nachschlag.konto(gruppe.accountId)?.currency ?? ""
}
}
+115
View File
@@ -0,0 +1,115 @@
import SwiftUI
/// Eine Kategorie zuordnen – mehr nicht.
///
/// Ein eigenes Blatt statt des ganzen Buchungsformulars: In der Arbeitsliste
/// fehlt genau diese eine Angabe, und ein Formular mit zwölf Feldern davor
/// macht aus einer Entscheidung eine Sitzung.
struct KategorieWaehlen: View {
let titel: String
let nachschlag: Nachschlag
var art: Kategorieart = .expense
/// Wird mit der gewählten ID gerufen; schliesst danach von selbst.
let waehle: (Int) async -> Void
@Environment(\.dismiss) private var schliesse
@State private var laeuft = false
var body: some View {
NavigationStack {
List {
ForEach(gruppen) { gruppe in
Section(gruppe.name) {
ForEach(gruppe.categories.filter { !$0.isArchived }) { kategorie in
Button {
Task {
laeuft = true
await waehle(kategorie.id)
schliesse()
}
} label: {
HStack(spacing: 10) {
Symbolplakette(symbol: kategorie.icon,
farbe: kategorie.color, groesse: 30)
Text(kategorie.name)
.foregroundStyle(.primary)
Spacer()
}
}
.disabled(laeuft)
}
}
}
}
.navigationTitle(titel)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
}
}
}
}
private var gruppen: [Kategoriegruppe] {
nachschlag.gruppen
.filter { $0.kind == art }
.filter { $0.categories.contains { !$0.isArchived } }
}
}
/// Auf welches Konto der Übertrag gehen soll.
struct ZielkontoWaehlen: View {
let nachschlag: Nachschlag
/// Das Konto, von dem er kommt – es kann nicht zugleich das Ziel sein.
var ausser: Int?
let waehle: (Int) async -> Void
@Environment(\.dismiss) private var schliesse
@State private var laeuft = false
var body: some View {
NavigationStack {
List {
Section {
ForEach(auswaehlbar) { konto in
Button {
Task {
laeuft = true
await waehle(konto.id)
schliesse()
}
} label: {
HStack(spacing: 10) {
Symbolplakette(symbol: konto.icon, farbe: konto.color,
groesse: 30)
VStack(alignment: .leading, spacing: 1) {
Text(konto.name)
.foregroundStyle(.primary)
Text(konto.type.name)
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
}
}
.disabled(laeuft)
}
} footer: {
Text(String(localized: "arbeit.zielkontoErklaerung"))
}
}
.navigationTitle(String(localized: "buchung.zielkonto"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
}
}
}
}
private var auswaehlbar: [Konto] {
nachschlag.konten.filter { !$0.isArchived && $0.id != ausser }
}
}
+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: {
+78 -4
View File
@@ -9,12 +9,16 @@
/* 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 */; };
2041749A0179B0AC29AFF2FF /* BuchungFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4FED25B5220CEAD1638B53F /* BuchungFormular.swift */; };
25EC32ED2FF88F4286B752A7 /* Zustandsansichten.swift in Sources */ = {isa = PBXBuildFile; fileRef = 776F26865B849D2318B6819B /* Zustandsansichten.swift */; };
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 */; };
@@ -23,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 */; };
@@ -30,15 +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 */; };
@@ -46,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 */
@@ -61,27 +72,35 @@
/* Begin PBXFileReference section */
09B6D688EF69B6EC58A8B4BE /* EinstellungenAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EinstellungenAnsicht.swift; sourceTree = "<group>"; };
09BBFDDFA7A1FFC8785925BE /* DashboardAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardAnsicht.swift; sourceTree = "<group>"; };
0A2E765F9D8DDC8D46A1BDD6 /* Arbeitsliste.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Arbeitsliste.swift; sourceTree = "<group>"; };
0FDF13E61F770748435D0097 /* BudgetZeileBearbeiten.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetZeileBearbeiten.swift; sourceTree = "<group>"; };
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; };
451BA41D9B53FB6B511AF734 /* Darstellung.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Darstellung.swift; sourceTree = "<group>"; };
4AFC43300F101BE4F4541BA2 /* KontoFormular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KontoFormular.swift; sourceTree = "<group>"; };
4E5EA4F794EFAD4CB4EE63B4 /* ArbeitslisteAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArbeitslisteAnsicht.swift; sourceTree = "<group>"; };
52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswortWechselAnsicht.swift; sourceTree = "<group>"; };
5D3341CDA026CDF55D621A8A /* CertainTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = CertainTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
5D6CEAB46FB8955115A3064B /* Endpunkte.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Endpunkte.swift; sourceTree = "<group>"; };
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>"; };
@@ -94,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>"; };
@@ -161,6 +183,7 @@
isa = PBXGroup;
children = (
E8CBA42A030509615199DD45 /* Abo.swift */,
0A2E765F9D8DDC8D46A1BDD6 /* Arbeitsliste.swift */,
327787E4869ED7D0C7E9EE63 /* Buchung.swift */,
E51DE5BDB909910D586F819F /* Budget.swift */,
BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */,
@@ -168,6 +191,7 @@
F8BE152C9F91584900796ADA /* Grundmodelle.swift */,
B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */,
4119B85EFF3FB38E84FAA913 /* Konto.swift */,
3114763A11EC90E1D38C9DEF /* Nebenraeume.swift */,
75EA409F3F0CEF3DF04C32E7 /* Zeitangaben.swift */,
);
path = Modelle;
@@ -187,6 +211,7 @@
DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */,
52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */,
A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */,
3C618BE3CBCEBBF65F79A63D /* SperrAnsicht.swift */,
A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.swift */,
);
path = Anmeldung;
@@ -195,9 +220,11 @@
BC82A6A323BDD2AD8325BEA2 = {
isa = PBXGroup;
children = (
D42C757A9D818A524C4CAC8A /* AppIcon.icon */,
CB7098BB5F4AEFE48BA9BB36 /* Abos */,
B4505C6E527A0020B80AFF04 /* Anmeldung */,
BEE6BC97D865C2F0C4EB80C4 /* App */,
FAFA069DDABF0E56CAF24C4F /* Arbeitsliste */,
5E720D91F0CF9DBDA975A959 /* Bausteine */,
D99101183ED9DA14C8D22722 /* Buchungen */,
C4B3E718A814E0236A12EB86 /* Budget */,
@@ -205,6 +232,7 @@
56CB7B1C18136BFD83ACADBD /* Einstellungen */,
298E43BBA9248E5A9779DA98 /* Konten */,
80EFC9A43394C64E9AEEF5A1 /* Modelle */,
F77DCF8FF4A1F732F2FA3EA8 /* Nebenraeume */,
DB51F54CC077E85C286AD9DC /* Netzwerk */,
524FD85EDE7C4DE5ABE9A8E5 /* Tests */,
0EDDD922C06B1465AD13C782 /* Texte */,
@@ -271,6 +299,27 @@
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 = (
4E5EA4F794EFAD4CB4EE63B4 /* ArbeitslisteAnsicht.swift */,
3F7647AF036D2CD6339FA287 /* AuswahlBlaetter.swift */,
);
path = Arbeitsliste;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -352,6 +401,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B551D26D31D9C35898074FE5 /* AppIcon.icon in Resources */,
E5F283F445FA9E26CFC15FA5 /* Localizable.strings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -386,6 +436,10 @@
04F945FE4BAC298FC242354B /* AboListe.swift in Sources */,
E49213EDDBF2C0C7995AF558 /* AnmeldeAnsicht.swift in Sources */,
402F07ED1E39AB8C3BC6F62B /* AppZustand.swift in Sources */,
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 */,
@@ -404,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;
@@ -451,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",
@@ -461,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;
};
@@ -631,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",
@@ -641,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

+215
View File
@@ -0,0 +1,215 @@
import Foundation
/// `InboxItemOut` – eine von aussen erfasste Zahlung, bevor der Kontoauszug
/// sie bestätigt.
///
/// Quellen: `shortcut` (Kurzbefehl vom Telefon), `mail` (Postfach-Abruf),
/// `bank`. Der Eintrag bleibt auch nach dem Buchen stehen, damit der Abgleich
/// dieselbe Zahlung später wiedererkennt.
struct Eingangseintrag: Codable, Sendable, Identifiable, Hashable {
let id: Int
let source: String
let status: Eingangsstand
let accountId: Int?
let date: Tagesdatum
/// Negativ bei einer Ausgabe – anders als an einer Buchung, wo die
/// Richtung in `type` steckt.
let amountCents: Int
let currency: String
let payee: String?
let note: String?
let categoryId: Int?
let transactionId: Int?
/// Das jeweils **andere** Konto, wenn die Buchung ein Übertrag ist. Ohne
/// das liesse sich ein Übertrag nicht von einer Ausgabe unterscheiden,
/// und die App fragte weiter nach einer Kategorie.
let transferAccountId: Int?
let fxRate: Double?
let counterAmountCents: Int?
let counterCurrency: String?
let createdAt: Zeitpunkt?
private enum CodingKeys: String, CodingKey {
case id, source, status, date, currency, payee, note
case accountId = "account_id"
case amountCents = "amount_cents"
case categoryId = "category_id"
case transactionId = "transaction_id"
case transferAccountId = "transfer_account_id"
case fxRate = "fx_rate"
case counterAmountCents = "counter_amount_cents"
case counterCurrency = "counter_currency"
case createdAt = "created_at"
}
var betrag: Geldbetrag { Geldbetrag(cents: amountCents, waehrung: currency) }
var istUebertrag: Bool { transferAccountId != nil }
/// Buchen geht nur mit Konto – das Backend lehnt es sonst mit 400 ab.
var laesstSichBuchen: Bool {
accountId != nil && (status == .pending || status == .unclear)
}
/// Fehlt nach dem Buchen noch die Kategorie? Überträge brauchen keine.
var brauchtKategorie: Bool {
status == .booked && categoryId == nil && !istUebertrag
}
var quelle: String {
switch source {
case "shortcut": return String(localized: "eingang.quelle.kurzbefehl")
case "mail": return String(localized: "eingang.quelle.postfach")
case "bank": return String(localized: "eingang.quelle.bank")
default: return source
}
}
}
/// `InboxStatus`
enum Eingangsstand: String, Codable, Sendable {
/// Als Buchung übernommen.
case booked
/// Wartet auf Bestätigung.
case pending
/// Die Erkennung war unsicher – erst ansehen.
case unclear
/// Verworfen. Taucht in der Liste nicht mehr auf.
case discarded
var name: String {
switch self {
case .booked: return String(localized: "eingang.stand.gebucht")
case .pending: return String(localized: "eingang.stand.offen")
case .unclear: return String(localized: "eingang.stand.unklar")
case .discarded: return String(localized: "eingang.stand.verworfen")
}
}
}
/// `GET /api/uebertraege/vorschlaege`
///
/// Zwei getrennte Buchungen, die zusammen ein Übertrag sein könnten. Das
/// Backend entscheidet hier **nichts** – beim Hinsehen darf sich nichts ändern.
struct Uebertragsvorschlaege: Codable, Sendable, Hashable {
/// Wie viele beim Suchen von selbst verknüpft wurden. Beim blossen
/// Fragen immer null.
let verknuepft: Int
let vorschlaege: [Vorschlag]
struct Vorschlag: Codable, Sendable, Identifiable, Hashable {
let a: Seite
let b: Seite
/// Die Reihenfolge ist nicht bedeutsam – die ID ist es deshalb auch
/// nur als Paar.
var id: String { "\(min(a.id, b.id))-\(max(a.id, b.id))" }
/// Eine zugeordnete Buchung fällt durchs Verbinden aus dem Budget.
/// Das soll niemanden überraschen.
var hatKategorie: Bool { a.categoryId != nil || b.categoryId != nil }
}
struct Seite: Codable, Sendable, Identifiable, Hashable {
let id: Int
let date: Tagesdatum
let type: Buchungsart
let amountCents: Int
let currency: String
let accountId: Int
let accountName: String
let payee: String?
let description: String?
let categoryId: Int?
private enum CodingKeys: String, CodingKey {
case id, date, type, currency, payee, description
case amountCents = "amount_cents"
case accountId = "account_id"
case accountName = "account_name"
case categoryId = "category_id"
}
var betrag: Geldbetrag { Geldbetrag(cents: amountCents, waehrung: currency) }
var bezeichnung: String {
for text in [payee, description] where !(text ?? "").isEmpty { return text! }
return String(localized: "buchung.ohneBezeichnung")
}
}
}
/// `GET /api/transactions/dubletten`
///
/// Buchungen, die gleich aussehen. Entschieden wird hier nichts: Drei
/// Aufrundungen über 1.00 am selben Tag sind echt, drei gleiche Einkäufe
/// können es auch sein – das weiss nur der Mensch.
struct Dublettengruppen: Codable, Sendable, Hashable {
let gruppen: [Gruppe]
struct Gruppe: Codable, Sendable, Identifiable, Hashable {
let accountId: Int
let datum: Tagesdatum
let amountCents: Int
let typ: Buchungsart
let zeilen: [Zeile]
var id: String { "\(accountId)-\(datum.text)-\(amountCents)-\(typ.rawValue)" }
private enum CodingKeys: String, CodingKey {
case datum, typ, zeilen
case accountId = "account_id"
case amountCents = "amount_cents"
}
struct Zeile: Codable, Sendable, Identifiable, Hashable {
let id: Int
let payee: String?
let description: String?
let categoryId: Int?
let herkunft: String
/// Die Kennung der Bank. Tragen zwei Zeilen verschiedene, hat die
/// Bank sie ausdrücklich als verschiedene Zahlungen gemeldet.
let kennung: String?
private enum CodingKeys: String, CodingKey {
case id, payee, description, herkunft, kennung
case categoryId = "category_id"
}
var bezeichnung: String {
for text in [payee, description] where !(text ?? "").isEmpty { return text! }
return String(localized: "buchung.ohneBezeichnung")
}
}
}
}
/// Zwei Buchungen, die zusammengehören – für Verbinden und Ablehnen.
struct Buchungspaar: Encodable, Sendable {
let aId: Int
let bId: Int
private enum CodingKeys: String, CodingKey {
case aId = "a_id"
case bId = "b_id"
}
}
/// `POST /api/inbox/{id}/transfer`
struct AlsUebertragBuchen: Encodable, Sendable {
let toAccountId: Int
/// Nur setzen, wenn der Nutzer es nach einer Rückfrage ausdrücklich will.
/// Das Backend antwortet mit 409, wenn es Bedenken hat.
var trotzdem = false
private enum CodingKeys: String, CodingKey {
case trotzdem
case toAccountId = "to_account_id"
}
}
/// Was `POST /api/inbox/book-all` zurückmeldet.
struct SammelErgebnis: Codable, Sendable, Hashable {
let gebucht: Int
let uebertraege: Int?
}
+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()
+184
View File
@@ -288,4 +288,188 @@ extension CertainClient {
func loescheKonto(_ id: Int) async throws {
try await schickeStumm(.delete, "accounts/\(id)")
}
// MARK: - Arbeitsliste: Eingang
func eingang(grenze: Int = 200) async throws -> [Eingangseintrag] {
try await hole("inbox", abfrage: [URLQueryItem(name: "limit", value: String(grenze))])
}
/// Macht aus dem Eintrag eine Buchung. Braucht ein Konto – sonst 400.
@discardableResult
func bucheEingang(_ id: Int) async throws -> Eingangseintrag {
try await schicke(.post, "inbox/\(id)/book")
}
/// Bucht den Eintrag als Kontoübertrag statt als Ausgabe oder Einnahme.
///
/// Hat das Backend Bedenken, antwortet es mit 409 (`APIFehler.konflikt`).
/// Dann den Nutzer fragen und mit `trotzdem: true` wiederholen – nicht
/// stillschweigend übergehen und nicht einfach aufgeben.
@discardableResult
func bucheEingangAlsUebertrag(_ id: Int, aufKonto: Int,
trotzdem: Bool = false) async throws -> Eingangseintrag {
try await schicke(.post, "inbox/\(id)/transfer",
koerper: AlsUebertragBuchen(toAccountId: aufKonto,
trotzdem: trotzdem))
}
/// Alle offenen Einträge buchen, die ein Konto haben. Unklare bleiben
/// liegen – die soll man ansehen, nicht im Stapel durchwinken.
@discardableResult
func bucheGanzenEingang() async throws -> SammelErgebnis {
try await schicke(.post, "inbox/book-all")
}
/// Verwirft den Eintrag samt der Buchung, die daraus entstanden ist.
func verwerfeEingang(_ id: Int) async throws {
try await schickeStumm(.delete, "inbox/\(id)")
}
/// Setzt die Kategorie auf dem Eintrag **und** auf der erzeugten Buchung.
@discardableResult
func setzeEingangsKategorie(_ id: Int, kategorie: Int) async throws -> Eingangseintrag {
try await schicke(.post, "inbox/\(id)/category/\(kategorie)")
}
// MARK: - Arbeitsliste: Überträge
/// Fragt nur – beim Hinsehen wird nichts verknüpft.
func uebertragsvorschlaege() async throws -> Uebertragsvorschlaege {
try await hole("uebertraege/vorschlaege")
}
/// Sucht aktiv: Eindeutige Paare mit Übertrags-Hinweis werden dabei
/// verknüpft, der Rest kommt als Vorschlag zurück.
@discardableResult
func sucheUebertraege() async throws -> Uebertragsvorschlaege {
try await schicke(.post, "uebertraege/suchen")
}
func verbindeUebertrag(_ a: Int, _ b: Int) async throws {
try await schickeStumm(.post, "uebertraege/verbinden",
koerper: Buchungspaar(aId: a, bId: b))
}
/// Lehnt einen Vorschlag ab, damit er bei jedem weiteren Suchlauf
/// wegbleibt.
func lehneUebertragAb(_ a: Int, _ b: Int) async throws {
try await schickeStumm(.post, "uebertraege/ignorieren",
koerper: Buchungspaar(aId: a, bId: b))
}
// MARK: - Arbeitsliste: Dubletten
func dubletten() async throws -> Dublettengruppen {
try await hole("transactions/dubletten")
}
/// „Sieht gleich aus, ist aber richtig so." Die Gruppe verschwindet erst,
/// wenn **alle** ihre Zeilen den Haken tragen.
func behalteDubletten(_ ids: [Int]) async throws {
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` |
+330 -39
View File
@@ -13,7 +13,7 @@
"must_change_password": false,
"must_change_password_reason": null,
"totp_enabled": false,
"created_at": "2026-10-21T18:22:28.427216",
"created_at": "2026-10-21T19:57:08.443998",
"mfa_setup_required": false
},
"einstellungen": [
@@ -58,8 +58,8 @@
"position": 0,
"savings_category_id": null,
"identifiers": [],
"balance_cents": 430750,
"balance_base_cents": 430750,
"balance_cents": 419874,
"balance_base_cents": 419874,
"bank_balance_cents": null,
"bank_balance_at": null,
"bank_balance_vergleichbar": false
@@ -84,6 +84,27 @@
"bank_balance_cents": null,
"bank_balance_at": null,
"bank_balance_vergleichbar": false
},
{
"id": 3,
"name": "Zweitkonto",
"type": "girokonto",
"currency": "EUR",
"icon": "wallet",
"color": "#3f51b5",
"opening_balance_cents": 0,
"opening_date": null,
"credit_limit_cents": null,
"is_locked": false,
"is_archived": false,
"position": 2,
"savings_category_id": null,
"identifiers": [],
"balance_cents": 7500,
"balance_base_cents": 7500,
"bank_balance_cents": null,
"bank_balance_at": null,
"bank_balance_vergleichbar": false
}
],
"kategoriegruppen": [
@@ -108,7 +129,7 @@
"is_archived": false,
"excluded_from_stats": false,
"spending_is_good": false,
"keyword_count": 0
"keyword_count": 1
},
{
"id": 2,
@@ -456,6 +477,136 @@
}
],
"buchungen": [
{
"id": 10,
"type": "income",
"date": "2026-10-21",
"time": null,
"account_id": 3,
"to_account_id": null,
"account_currency": "EUR",
"amount_cents": 7500,
"to_amount_cents": null,
"fx_rate": null,
"amount_eur_cents": 7500,
"amount_base_cents": 7500,
"category_id": null,
"budget_treatment": null,
"budget_category_id": null,
"subscription_id": null,
"budget_period": "2026-10",
"budget_period_manuell": false,
"import_batch_id": null,
"herkunft": "manuell",
"payee": "Dauerauftrag",
"description": null,
"notes": null,
"tag_ids": []
},
{
"id": 9,
"type": "expense",
"date": "2026-10-21",
"time": null,
"account_id": 1,
"to_account_id": null,
"account_currency": "EUR",
"amount_cents": 7500,
"to_amount_cents": null,
"fx_rate": null,
"amount_eur_cents": 7500,
"amount_base_cents": 7500,
"category_id": null,
"budget_treatment": null,
"budget_category_id": null,
"subscription_id": null,
"budget_period": "2026-10",
"budget_period_manuell": false,
"import_batch_id": null,
"herkunft": "manuell",
"payee": "Dauerauftrag",
"description": null,
"notes": null,
"tag_ids": []
},
{
"id": 8,
"type": "expense",
"date": "2026-10-21",
"time": null,
"account_id": 1,
"to_account_id": null,
"account_currency": "EUR",
"amount_cents": 1043,
"to_amount_cents": null,
"fx_rate": null,
"amount_eur_cents": 1043,
"amount_base_cents": 1043,
"category_id": 1,
"budget_treatment": null,
"budget_category_id": null,
"subscription_id": null,
"budget_period": "2026-10",
"budget_period_manuell": false,
"import_batch_id": null,
"herkunft": "manuell",
"payee": "Kiosk",
"description": null,
"notes": null,
"tag_ids": []
},
{
"id": 7,
"type": "expense",
"date": "2026-10-21",
"time": null,
"account_id": 1,
"to_account_id": null,
"account_currency": "EUR",
"amount_cents": 1043,
"to_amount_cents": null,
"fx_rate": null,
"amount_eur_cents": 1043,
"amount_base_cents": 1043,
"category_id": 1,
"budget_treatment": null,
"budget_category_id": null,
"subscription_id": null,
"budget_period": "2026-10",
"budget_period_manuell": false,
"import_batch_id": null,
"herkunft": "manuell",
"payee": "Kiosk",
"description": null,
"notes": null,
"tag_ids": []
},
{
"id": 6,
"type": "expense",
"date": "2026-10-21",
"time": null,
"account_id": 1,
"to_account_id": null,
"account_currency": "EUR",
"amount_cents": 1290,
"to_amount_cents": null,
"fx_rate": null,
"amount_eur_cents": 1290,
"amount_base_cents": 1290,
"category_id": null,
"budget_treatment": null,
"budget_category_id": null,
"subscription_id": null,
"budget_period": "2026-10",
"budget_period_manuell": false,
"import_batch_id": null,
"herkunft": "shortcut",
"payee": "Baeckerei",
"description": null,
"notes": null,
"tag_ids": []
},
{
"id": 5,
"type": "income",
@@ -620,7 +771,7 @@
"icon": "landmark",
"color": "#3f51b5",
"planned_cents": 50000,
"actual_cents": 4250,
"actual_cents": 6336,
"over": false,
"good": false,
"aus_vertraegen_cents": 108,
@@ -631,8 +782,8 @@
"icon": "home",
"color": "#3f51b5",
"planned_cents": 50000,
"actual_cents": 4250,
"delta_cents": 45750,
"actual_cents": 6336,
"delta_cents": 43664,
"over": false,
"modus": "fest",
"zuschlag_cents": 0,
@@ -958,7 +1109,7 @@
},
"totals": {
"planned_expense": 50000,
"actual_expense": 4250,
"actual_expense": 6336,
"planned_income": 0,
"received_income": 0
}
@@ -1016,8 +1167,8 @@
"icon": "wallet",
"color": "#3f51b5",
"currency": "EUR",
"balance_cents": 430750,
"balance_base_cents": 430750,
"balance_cents": 419874,
"balance_base_cents": 419874,
"archived": false,
"locked": false
},
@@ -1031,9 +1182,20 @@
"balance_base_cents": 535000,
"archived": false,
"locked": true
},
{
"id": 3,
"name": "Zweitkonto",
"icon": "wallet",
"color": "#3f51b5",
"currency": "EUR",
"balance_cents": 7500,
"balance_base_cents": 7500,
"archived": false,
"locked": false
}
],
"total": 430750,
"total": 427374,
"gebunden": 535000,
"currency": "EUR"
},
@@ -1043,7 +1205,7 @@
"name": "Fixkosten",
"color": "#3f51b5",
"planned": 50000,
"actual": 4250,
"actual": 6336,
"over": false,
"good": false
}
@@ -1053,14 +1215,74 @@
"group_by": "group"
},
"einnahmenAusgaben": {
"income": 350000,
"expense": 4250,
"net": 345750,
"income": 357500,
"expense": 6336,
"net": 351164,
"currency": "EUR",
"label": "2026-10"
},
"letzteBuchungen": {
"transactions": [
{
"id": 10,
"date": "2026-10-21",
"type": "income",
"title": "Dauerauftrag",
"icon": "more-horizontal",
"color": "#6b7480",
"sign": "pos",
"amount_cents": 7500,
"currency": "EUR",
"amount_base_cents": 7500
},
{
"id": 9,
"date": "2026-10-21",
"type": "expense",
"title": "Dauerauftrag",
"icon": "more-horizontal",
"color": "#6b7480",
"sign": "neg",
"amount_cents": 7500,
"currency": "EUR",
"amount_base_cents": 7500
},
{
"id": 8,
"date": "2026-10-21",
"type": "expense",
"title": "Wohnen",
"icon": "home",
"color": "#3f51b5",
"sign": "neg",
"amount_cents": 1043,
"currency": "EUR",
"amount_base_cents": 1043
},
{
"id": 7,
"date": "2026-10-21",
"type": "expense",
"title": "Wohnen",
"icon": "home",
"color": "#3f51b5",
"sign": "neg",
"amount_cents": 1043,
"currency": "EUR",
"amount_base_cents": 1043
},
{
"id": 6,
"date": "2026-10-21",
"type": "expense",
"title": "Baeckerei",
"icon": "more-horizontal",
"color": "#6b7480",
"sign": "neg",
"amount_cents": 1290,
"currency": "EUR",
"amount_base_cents": 1290
},
{
"id": 5,
"date": "2026-10-21",
@@ -1096,32 +1318,101 @@
"amount_cents": 20000,
"currency": "EUR",
"amount_base_cents": 20000
},
{
"id": 2,
"date": "2026-10-21",
"type": "income",
"title": "Gehalt",
"icon": "banknote",
"color": "#2e7d55",
"sign": "pos",
"amount_cents": 350000,
"currency": "EUR",
"amount_base_cents": 350000
},
{
"id": 1,
"date": "2026-10-21",
"type": "expense",
"title": "Wohnen",
"icon": "home",
"color": "#3f51b5",
"sign": "neg",
"amount_cents": 4250,
"currency": "EUR",
"amount_base_cents": 4250
}
],
"currency": "EUR"
},
"eingang": [
{
"id": 1,
"source": "shortcut",
"status": "booked",
"account_id": 1,
"date": "2026-10-21",
"amount_cents": -1290,
"currency": "EUR",
"payee": "Baeckerei",
"note": null,
"category_id": null,
"transaction_id": 6,
"transfer_account_id": null,
"fx_rate": null,
"counter_amount_cents": null,
"counter_currency": null,
"created_at": "2026-10-21T19:57:08.586391"
}
],
"dubletten": {
"gruppen": [
{
"account_id": 1,
"datum": "2026-10-21",
"amount_cents": 1043,
"typ": "expense",
"zeilen": [
{
"id": 7,
"payee": "Kiosk",
"description": null,
"category_id": 1,
"herkunft": "manuell",
"kennung": null
},
{
"id": 8,
"payee": "Kiosk",
"description": null,
"category_id": 1,
"herkunft": "manuell",
"kennung": null
}
]
}
]
},
"uebertragsvorschlaege": {
"verknuepft": 0,
"vorschlaege": [
{
"a": {
"id": 9,
"date": "2026-10-21",
"type": "expense",
"amount_cents": 7500,
"currency": "EUR",
"account_id": 1,
"account_name": "Girokonto",
"payee": "Dauerauftrag",
"description": null,
"category_id": null
},
"b": {
"id": 10,
"date": "2026-10-21",
"type": "income",
"amount_cents": 7500,
"currency": "EUR",
"account_id": 3,
"account_name": "Zweitkonto",
"payee": "Dauerauftrag",
"description": null,
"category_id": null
}
}
]
},
"schlagwoerter_der_kategorie": [
{
"id": 1,
"category_id": 1,
"word": "supermarkt",
"amount_cents": null
}
],
"markierteSchlagwoerter": [],
"zweiterFaktor": {
"enabled": false,
"recovery_codes_left": 0
},
"bankverbindungen": []
}
+182 -6
View File
@@ -503,7 +503,9 @@ struct ServerantwortenTests {
@Test("Konten, Kategorien und Schlagwörter")
func stammdaten() throws {
let konten = try lies([Konto].self, "konten")
#expect(konten.count == 2)
// Nicht auf eine genaue Zahl pruefen: Die Pruefdatei waechst mit
// jeder Stufe, und ein Test, der dann rot wird, sagt nichts aus.
#expect(konten.count >= 2)
#expect(konten.contains { $0.isLocked })
let gruppen = try lies([Kategoriegruppe].self, "kategoriegruppen")
@@ -515,7 +517,7 @@ struct ServerantwortenTests {
@Test("Buchungen samt Tags, Übertrag und Vertragszuordnung")
func buchungen() throws {
let alle = try lies([Buchung].self, "buchungen")
#expect(alle.count == 5)
#expect(alle.count >= 5)
// Jede Spielart einmal – sonst prüft die Datei die Felder nicht, die
// nur in einem Fall belegt sind.
#expect(alle.contains { !$0.tagIds.isEmpty })
@@ -531,9 +533,12 @@ struct ServerantwortenTests {
let alle = try lies([Buchung].self, "buchungen")
let zeilen = Buchungszeile.zeilen(aus: alle, paare: paare)
// Fünf Buchungen, zwei davon ein Paar: vier Zeilen.
#expect(zeilen.count == 4)
#expect(zeilen.filter(\.istUebertrag).count == 2)
// Die Paarliste fuehrt jede Verknuepfung zweimal auf, einmal je
// Richtung - also halb so viele Paare wie Eintraege. Genau so viele
// Zeilen fallen beim Zusammenfassen weg.
#expect(zeilen.count == alle.count - paare.count / 2)
let uebertraege = zeilen.filter(\.istUebertrag)
#expect(!uebertraege.isEmpty)
}
@Test("Budget-Monat, Vorlage und Verträge")
@@ -557,7 +562,7 @@ struct ServerantwortenTests {
@Test("Alle vier Dashboard-Karten")
func dashboard() throws {
let staende = try lies(Kontostaende.self, "kontostaende")
#expect(staende.accounts.count == 2)
#expect(staende.accounts.count >= 2)
#expect(staende.gebunden != 0)
#expect(try !lies(Budgetfortschritt.self, "budgetfortschritt").rows.isEmpty)
@@ -642,3 +647,174 @@ struct EingabenTests {
#expect(Zahlungsrhythmus.allCases.map(\.rawValue) == [1, 2, 3, 6, 12])
}
}
// MARK: - Arbeitsliste
@Suite("Arbeitsliste")
struct ArbeitslisteTests {
private func eintrag(stand: String, konto: Int?, kategorie: Int?,
gegenkonto: Int? = nil) throws -> Eingangseintrag {
let roh = """
{"id": 1, "source": "shortcut", "status": "\(stand)",
"account_id": \(konto.map(String.init) ?? "null"),
"date": "2026-10-15", "amount_cents": -1290, "currency": "EUR",
"payee": "Baeckerei", "note": null,
"category_id": \(kategorie.map(String.init) ?? "null"),
"transaction_id": null,
"transfer_account_id": \(gegenkonto.map(String.init) ?? "null"),
"fx_rate": null, "counter_amount_cents": null, "counter_currency": null,
"created_at": "2026-10-15T14:18:15.780705"}
"""
return try JSONDecoder().decode(Eingangseintrag.self, from: Data(roh.utf8))
}
@Test("Ohne Konto laesst sich nichts buchen")
func ohneKontoKeinBuchen() throws {
// Das Backend lehnt es mit 400 ab. Den Knopf trotzdem anzubieten
// hiesse, dem Nutzer eine Fehlermeldung als Antwort zu geben.
#expect(try eintrag(stand: "pending", konto: nil, kategorie: nil)
.laesstSichBuchen == false)
#expect(try eintrag(stand: "pending", konto: 3, kategorie: nil)
.laesstSichBuchen)
}
@Test("Was schon gebucht ist, steht nicht mehr zum Buchen an")
func gebuchtesIstErledigt() throws {
#expect(try eintrag(stand: "booked", konto: 3, kategorie: 7)
.laesstSichBuchen == false)
// Unklares schon: Es wartet auf einen Blick, nicht auf ein Konto.
#expect(try eintrag(stand: "unclear", konto: 3, kategorie: nil)
.laesstSichBuchen)
}
@Test("Ein Uebertrag braucht keine Kategorie")
func uebertragBrauchtKeineKategorie() throws {
// Ohne transfer_account_id liesse sich ein Uebertrag nicht von einer
// Ausgabe unterscheiden - und die App fragte ewig nach einer
// Kategorie, die es nie geben wird.
#expect(try eintrag(stand: "booked", konto: 3, kategorie: nil)
.brauchtKategorie)
#expect(try eintrag(stand: "booked", konto: 3, kategorie: nil, gegenkonto: 4)
.brauchtKategorie == false)
}
@Test("Ein Vorschlag ist dasselbe Paar, egal herum")
func paarIstUngeordnet() throws {
let roh = """
{"verknuepft": 0, "vorschlaege": [
{"a": {"id": 9, "date": "2026-10-15", "type": "expense",
"amount_cents": 7500, "currency": "EUR", "account_id": 1,
"account_name": "Giro", "payee": "Dauerauftrag",
"description": null, "category_id": null},
"b": {"id": 10, "date": "2026-10-15", "type": "income",
"amount_cents": 7500, "currency": "EUR", "account_id": 3,
"account_name": "Zweitkonto", "payee": "Dauerauftrag",
"description": null, "category_id": 4}}]}
"""
let stand = try JSONDecoder().decode(Uebertragsvorschlaege.self,
from: Data(roh.utf8))
let paar = try #require(stand.vorschlaege.first)
// Die ID muss unabhaengig von der Reihenfolge sein, sonst zeigt die
// Liste dasselbe Paar als zwei Eintraege.
#expect(paar.id == "9-10")
// Eine zugeordnete Buchung faellt durchs Verbinden aus dem Budget.
#expect(paar.hatKategorie)
}
}
extension ServerantwortenTests {
@Test("Eingang, Dubletten und Uebertragsvorschlaege")
func arbeitsliste() throws {
let eingang = try lies([Eingangseintrag].self, "eingang")
#expect(!eingang.isEmpty)
#expect(eingang.allSatisfy { $0.currency.isEmpty == false })
let dubletten = try lies(Dublettengruppen.self, "dubletten")
let gruppe = try #require(dubletten.gruppen.first)
#expect(gruppe.zeilen.count >= 2)
let vorschlaege = try lies(Uebertragsvorschlaege.self, "uebertragsvorschlaege")
#expect(vorschlaege.verknuepft == 0, "beim Fragen darf nichts verknuepft werden")
#expect(vorschlaege.vorschlaege.count == 1)
// Das Paar stand frueher zweimal drin, einmal je Richtung.
let ids = Set(vorschlaege.vorschlaege.map(\.id))
#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)
}
}
+136
View File
@@ -11,12 +11,16 @@
"allgemein.unbekannt" = "Unbekannt";
"allgemein.sichern" = "Sichern";
"allgemein.ok" = "OK";
"allgemein.fertig" = "Fertig";
"allgemein.bearbeiten" = "Bearbeiten";
"allgemein.loeschen" = "Löschen";
/* --- Reiter --- */
"reiter.dashboard" = "Übersicht";
"reiter.budget" = "Budget";
"reiter.buchungen" = "Buchungen";
"reiter.abos" = "Verträge";
"reiter.arbeitsliste" = "Eingang";
"reiter.konten" = "Konten";
"reiter.einstellungen" = "Einstellungen";
@@ -239,3 +243,135 @@
"konto.loeschen" = "Konto löschen";
"konto.loeschenFrage" = "Dieses Konto löschen?";
"konto.loeschenErklaerung" = "Das geht nur, wenn keine Buchung mehr daran hängt. Sonst archivieren.";
/* --- Arbeitsliste --- */
"arbeit.leerTitel" = "Nichts zu tun";
"arbeit.leerErklaerung" = "Keine offenen Einträge, keine Übertragsvorschläge, keine Dubletten. So soll es sein.";
"arbeit.eingang" = "Zu buchen";
"arbeit.uebertraege" = "Könnten Überträge sein";
"arbeit.ohneKategorie" = "Ohne Kategorie";
"arbeit.dubletten" = "Sieht doppelt aus";
"arbeit.dublettenErklaerung" = "Gleiches Konto, gleicher Tag, gleicher Betrag. Ob es wirklich dieselbe Zahlung ist, weisst nur du - verschiedene Kennungen bedeuten, dass die Bank sie als verschieden gemeldet hat.";
"arbeit.buchen" = "Buchen";
"arbeit.alleBuchen" = "Alle buchen, bei denen das Konto feststeht";
"arbeit.alsUebertrag" = "Übertrag";
"arbeit.verwerfen" = "Verwerfen";
"arbeit.verbinden" = "Verbinden";
"arbeit.keinUebertrag" = "Kein Übertrag";
"arbeit.istRichtigSo" = "Ist richtig so";
"arbeit.unklarHinweis" = "Die Erkennung war unsicher - erst ansehen.";
"arbeit.hatKategorie" = "Eine der beiden hat eine Kategorie. Durchs Verbinden fällt sie aus dem Budget.";
"arbeit.ohneKonto" = "ohne Konto";
"arbeit.rueckfrage" = "Sicher?";
"arbeit.trotzdem" = "Trotzdem";
"arbeit.zielkontoErklaerung" = "Es entsteht eine Buchung, die das Geld von einem Konto aufs andere bewegt. Sie zählt auf beiden Seiten im Saldo und in keiner Auswertung.";
/* --- Eingang: Quelle und Stand --- */
"eingang.quelle.kurzbefehl" = "Kurzbefehl";
"eingang.quelle.postfach" = "Postfach";
"eingang.quelle.bank" = "Bank";
"eingang.stand.gebucht" = "gebucht";
"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";
+136
View File
@@ -11,12 +11,16 @@
"allgemein.unbekannt" = "Unknown";
"allgemein.sichern" = "Save";
"allgemein.ok" = "OK";
"allgemein.fertig" = "Done";
"allgemein.bearbeiten" = "Edit";
"allgemein.loeschen" = "Delete";
/* --- Tabs --- */
"reiter.dashboard" = "Overview";
"reiter.budget" = "Budget";
"reiter.buchungen" = "Transactions";
"reiter.abos" = "Subscriptions";
"reiter.arbeitsliste" = "Inbox";
"reiter.konten" = "Accounts";
"reiter.einstellungen" = "Settings";
@@ -239,3 +243,135 @@
"konto.loeschen" = "Delete account";
"konto.loeschenFrage" = "Delete this account?";
"konto.loeschenErklaerung" = "Only possible while no transaction is attached. Archive it otherwise.";
/* --- Inbox / work list --- */
"arbeit.leerTitel" = "Nothing to do";
"arbeit.leerErklaerung" = "No pending entries, no transfer suggestions, no duplicates. Just as it should be.";
"arbeit.eingang" = "To book";
"arbeit.uebertraege" = "Might be transfers";
"arbeit.ohneKategorie" = "Without a category";
"arbeit.dubletten" = "Looks duplicated";
"arbeit.dublettenErklaerung" = "Same account, same day, same amount. Only you know whether it really is the same payment - different reference numbers mean the bank reported them as separate.";
"arbeit.buchen" = "Book";
"arbeit.alleBuchen" = "Book all that have an account";
"arbeit.alsUebertrag" = "Transfer";
"arbeit.verwerfen" = "Discard";
"arbeit.verbinden" = "Link";
"arbeit.keinUebertrag" = "Not a transfer";
"arbeit.istRichtigSo" = "That is correct";
"arbeit.unklarHinweis" = "The matcher was unsure - have a look first.";
"arbeit.hatKategorie" = "One of the two has a category. Linking takes it out of the budget.";
"arbeit.ohneKonto" = "no account";
"arbeit.rueckfrage" = "Are you sure?";
"arbeit.trotzdem" = "Do it anyway";
"arbeit.zielkontoErklaerung" = "This creates one transaction moving the money from one account to the other. It counts in both balances and in no report.";
/* --- Inbox: source and status --- */
"eingang.quelle.kurzbefehl" = "Shortcut";
"eingang.quelle.postfach" = "Mailbox";
"eingang.quelle.bank" = "Bank";
"eingang.stand.gebucht" = "booked";
"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
+19 -3
View File
@@ -20,6 +20,7 @@ targets:
- path: Anmeldung
- path: App
- path: Abos
- path: Arbeitsliste
- path: Bausteine
- path: Buchungen
- path: Budget
@@ -27,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.
@@ -50,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();