Compare commits
17
Commits
bf437b3b69
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09ec2dc5fd | ||
|
|
4e7f572d93 | ||
|
|
2d1d571b17 | ||
|
|
a079bf406c | ||
|
|
929a735699 | ||
|
|
9195906c2b | ||
|
|
f918297742 | ||
|
|
37517f360b | ||
|
|
d290930759 | ||
|
|
bab5cc5060 | ||
|
|
6c6257b8f9 | ||
|
|
9edf610d59 | ||
|
|
adbc3e0aa4 | ||
|
|
d04b6b74ad | ||
|
|
82db09e6de | ||
|
|
4c61a02c53 | ||
|
|
37b29934ef |
@@ -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__ = (
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -217,13 +217,27 @@ def reset_month(db: Session, user: User, label: str) -> None:
|
||||
|
||||
|
||||
def copy_month_to_template(db: Session, user: User, label: str) -> None:
|
||||
"""Setzt die Standard-Vorlage auf die wirksamen Soll-Werte dieses Monats
|
||||
(„diesen Monat als Standard festlegen"). Einnahmen bleiben unberührt."""
|
||||
planned = effective_planned(db, user, label) # {category_id: cents}
|
||||
"""Setzt die Standard-Vorlage auf die Soll-Werte dieses Monats
|
||||
(„diesen Monat als Standard festlegen"). Einnahmen bleiben unberührt.
|
||||
|
||||
Übernommen wird die **Betriebsart**, nicht die gerade herauskommende Zahl:
|
||||
Eine berechnete Zeile soll auch in der Vorlage berechnet bleiben. Vorher
|
||||
fror dieser Weg den Monatswert als festen Betrag ein – der Bezug zu den
|
||||
Verträgen war danach still weg, und niemand sah, wo er geblieben war.
|
||||
"""
|
||||
zeilen = list(_zeilen(db, user, label))
|
||||
db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id).delete()
|
||||
for category_id, cents in planned.items():
|
||||
if cents > 0:
|
||||
db.add(BudgetTemplateLine(owner_id=user.id, category_id=category_id, planned_cents=cents))
|
||||
for zeile in zeilen:
|
||||
# Eine feste Null ist nichts Geplantes und gehört nicht in die Vorlage.
|
||||
# Eine berechnete Zeile dagegen schon: Ihre Zahl steht in den
|
||||
# Verträgen, nicht in planned_cents.
|
||||
if zeile.modus != "berechnet" and zeile.planned_cents <= 0:
|
||||
continue
|
||||
db.add(BudgetTemplateLine(
|
||||
owner_id=user.id, category_id=zeile.category_id,
|
||||
planned_cents=zeile.planned_cents, modus=zeile.modus,
|
||||
zuschlag_cents=zeile.zuschlag_cents,
|
||||
))
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -241,7 +255,13 @@ def copy_template_to_month(db: Session, user: User, label: str) -> BudgetPeriod:
|
||||
db.flush()
|
||||
period.customized = True
|
||||
for tl in db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id):
|
||||
period.lines.append(BudgetPeriodLine(category_id=tl.category_id, planned_cents=tl.planned_cents))
|
||||
period.lines.append(BudgetPeriodLine(
|
||||
category_id=tl.category_id, planned_cents=tl.planned_cents,
|
||||
# Wie in ``ensure_customized``: Ohne diese beiden fiele eine
|
||||
# berechnete Zeile auf „fest" zurueck, mit der Zahl, die zufaellig
|
||||
# zuletzt in planned_cents stand.
|
||||
modus=tl.modus, zuschlag_cents=tl.zuschlag_cents,
|
||||
))
|
||||
db.commit()
|
||||
return period
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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]}")
|
||||
|
||||
@@ -399,3 +399,66 @@ def test_vorlage_nennt_betriebsart_und_zuschlag(admin):
|
||||
assert zeile["planned_cents"] == 12000
|
||||
assert zeile["modus"] == "berechnet"
|
||||
assert zeile["zuschlag_cents"] == 2500
|
||||
|
||||
|
||||
def _frische_kategorie(admin, name):
|
||||
import secrets
|
||||
|
||||
gruppe = next(g for g in admin.get("/api/categories").json()
|
||||
if g["kind"] == "expense")
|
||||
return admin.post("/api/categories", json={
|
||||
"group_id": gruppe["id"], "name": f"{name} {secrets.token_hex(3)}"}).json()["id"]
|
||||
|
||||
|
||||
def test_berechnete_zeile_ueberlebt_den_weg_in_den_monat(admin):
|
||||
"""Vorlage -> Monat darf die Betriebsart nicht verlieren.
|
||||
|
||||
Fiel die Zeile auf "fest" zurueck, stand als Soll die Zahl, die zufaellig
|
||||
zuletzt in planned_cents lag - und der Bezug zu den Vertraegen war weg,
|
||||
ohne dass irgendwo stand, dass er weg ist.
|
||||
"""
|
||||
today = date.today()
|
||||
label = f"{today.year:04d}-{today.month:02d}"
|
||||
kat = _frische_kategorie(admin, "Berechnet-Monat")
|
||||
|
||||
admin.put(f"/api/budget/template/category/{kat}",
|
||||
params={"planned_cents": 0, "modus": "berechnet", "zuschlag_cents": 1500})
|
||||
|
||||
r = admin.post(f"/api/budget/{label}/from-template")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
zeile = next(c for g in r.json()["groups"] for c in g["categories"]
|
||||
if c["category_id"] == kat)
|
||||
assert zeile["modus"] == "berechnet"
|
||||
assert zeile["zuschlag_cents"] == 1500
|
||||
|
||||
|
||||
def test_berechnete_zeile_ueberlebt_den_weg_in_die_vorlage(admin):
|
||||
"""Monat -> Vorlage ebenso: "diesen Monat als Standard festlegen" soll die
|
||||
Betriebsart uebernehmen, nicht die gerade herauskommende Zahl einfrieren."""
|
||||
today = date.today()
|
||||
label = f"{today.year:04d}-{today.month:02d}"
|
||||
kat = _frische_kategorie(admin, "Berechnet-Vorlage")
|
||||
|
||||
admin.put(f"/api/budget/{label}/category/{kat}",
|
||||
params={"planned_cents": 0, "modus": "berechnet", "zuschlag_cents": 2500})
|
||||
|
||||
r = admin.post(f"/api/budget/template/from-month/{label}")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
zeile = next(l for l in r.json()["lines"] if l["category_id"] == kat)
|
||||
assert zeile["modus"] == "berechnet"
|
||||
assert zeile["zuschlag_cents"] == 2500
|
||||
|
||||
|
||||
def test_eine_feste_null_landet_nicht_in_der_vorlage(admin):
|
||||
"""Nichts Geplantes bleibt nichts Geplantes - sonst fuellte sich die
|
||||
Vorlage mit Nullzeilen fuer jede je beruehrte Kategorie."""
|
||||
today = date.today()
|
||||
label = f"{today.year:04d}-{today.month:02d}"
|
||||
kat = _frische_kategorie(admin, "Nullzeile")
|
||||
|
||||
admin.put(f"/api/budget/{label}/category/{kat}", params={"planned_cents": 0})
|
||||
r = admin.post(f"/api/budget/template/from-month/{label}")
|
||||
|
||||
assert all(l["category_id"] != kat for l in r.json()["lines"])
|
||||
|
||||
@@ -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"
|
||||
|
||||
+10
-4
@@ -51,9 +51,15 @@ Gerät, kein 30-Tage-Ablauf, einzeln widerrufbar). Deutsch und Englisch, iPad
|
||||
zweispaltig, offline der letzte Stand mit Datum.
|
||||
|
||||
Stufe 1 ✅: Gerüst, Netzwerkschicht, Anmeldung samt zweitem Faktor und
|
||||
Pflicht-Passwortwechsel, Kontenliste. Übersetzt fehlerfrei, 28 Tests grün,
|
||||
läuft im Simulator; das Xcode-Projekt entsteht aus `ios/project.yml`.
|
||||
Stufen 2–7 (Lesen, Schreiben, Arbeitsliste, Nebenräume, Erweiterungen, voller
|
||||
CSV-Import) stehen aus.
|
||||
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. 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`.**
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Einen Vertrag anlegen oder ändern.
|
||||
struct AboFormular: View {
|
||||
enum Zweck: Equatable, Identifiable {
|
||||
case neu
|
||||
case aendern(Abo)
|
||||
|
||||
var id: Int {
|
||||
switch self {
|
||||
case .neu: return 0
|
||||
case .aendern(let abo): return abo.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 betragCents = 0
|
||||
@State private var waehrung = "EUR"
|
||||
@State private var rhythmus: Zahlungsrhythmus = .monatlich
|
||||
@State private var kategorieId: Int?
|
||||
@State private var kontoId: Int?
|
||||
@State private var hatFaelligkeit = false
|
||||
@State private var faelligAm = Date()
|
||||
@State private var erkennungstext = ""
|
||||
@State private var notiz = ""
|
||||
@State private var istAktiv = true
|
||||
@State private var symbol = "refresh-ccw"
|
||||
@State private var farbe = "#6b7480"
|
||||
@State private var fragtNachLoeschen = false
|
||||
@State private var vorbelegt = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField(String(localized: "abo.name"), text: $name)
|
||||
Betragsfeld(titel: String(localized: "abo.betrag"),
|
||||
waehrung: waehrung, cents: $betragCents)
|
||||
Picker(String(localized: "abo.rhythmus"), selection: $rhythmus) {
|
||||
ForEach(Zahlungsrhythmus.allCases) { einer in
|
||||
Text(einer.name).tag(einer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
KategorieFeld(titel: String(localized: "buchung.kategorie"),
|
||||
nachschlag: zustand.nachschlag, art: .expense,
|
||||
auswahl: $kategorieId)
|
||||
KontoFeld(titel: String(localized: "abo.konto"),
|
||||
nachschlag: zustand.nachschlag, auswahl: $kontoId)
|
||||
} footer: {
|
||||
Text(String(localized: "abo.kategorieErklaerung"))
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle(String(localized: "abo.faelligkeitKennen"), isOn: $hatFaelligkeit)
|
||||
if hatFaelligkeit {
|
||||
DatePicker(String(localized: "abo.naechsteZahlung"),
|
||||
selection: $faelligAm, displayedComponents: .date)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField(String(localized: "abo.erkennungstext"), text: $erkennungstext)
|
||||
} footer: {
|
||||
Text(String(localized: "abo.erkennungErklaerung"))
|
||||
}
|
||||
|
||||
Section {
|
||||
SymbolUndFarbeFeld(symbol: $symbol, farbe: $farbe)
|
||||
TextField(String(localized: "buchung.notiz"), text: $notiz, axis: .vertical)
|
||||
.lineLimit(1...3)
|
||||
}
|
||||
|
||||
if case .aendern = zweck {
|
||||
Section {
|
||||
Toggle(String(localized: "abo.laeuftNoch"), isOn: $istAktiv)
|
||||
Button(String(localized: "abo.loeschen"), role: .destructive) {
|
||||
fragtNachLoeschen = true
|
||||
}
|
||||
} footer: {
|
||||
Text(String(localized: "abo.beendenStattLoeschen"))
|
||||
}
|
||||
}
|
||||
|
||||
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(!istVollstaendig || vorgang.laeuft)
|
||||
}
|
||||
}
|
||||
.interactiveDismissDisabled(vorgang.laeuft)
|
||||
.confirmationDialog(String(localized: "abo.loeschenFrage"),
|
||||
isPresented: $fragtNachLoeschen, titleVisibility: .visible) {
|
||||
Button(String(localized: "abo.loeschen"), role: .destructive) {
|
||||
Task { await loesche() }
|
||||
}
|
||||
Button(String(localized: "allgemein.abbrechen"), role: .cancel) {}
|
||||
} message: {
|
||||
Text(String(localized: "abo.loeschenErklaerung"))
|
||||
}
|
||||
.onAppear(perform: belegeVor)
|
||||
}
|
||||
}
|
||||
|
||||
private var titel: String {
|
||||
switch zweck {
|
||||
case .neu: return String(localized: "abo.neu")
|
||||
case .aendern: return String(localized: "abo.aendern")
|
||||
}
|
||||
}
|
||||
|
||||
private var istVollstaendig: Bool {
|
||||
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && betragCents > 0
|
||||
}
|
||||
|
||||
private func belegeVor() {
|
||||
guard !vorbelegt else { return }
|
||||
vorbelegt = true
|
||||
|
||||
guard case .aendern(let abo) = zweck else {
|
||||
waehrung = zustand.einstellungen.basiswaehrung
|
||||
return
|
||||
}
|
||||
name = abo.name
|
||||
betragCents = abo.amountCents
|
||||
waehrung = abo.currency
|
||||
rhythmus = Zahlungsrhythmus(rawValue: abo.intervalMonths) ?? .monatlich
|
||||
kategorieId = abo.categoryId
|
||||
kontoId = abo.accountId
|
||||
hatFaelligkeit = abo.nextDue != nil
|
||||
faelligAm = abo.nextDue?.datum ?? Date()
|
||||
erkennungstext = abo.payeePattern ?? ""
|
||||
notiz = abo.notes ?? ""
|
||||
istAktiv = abo.isActive
|
||||
symbol = abo.icon
|
||||
farbe = abo.color
|
||||
}
|
||||
|
||||
private func sichere() async {
|
||||
guard let client = zustand.client else { return }
|
||||
let faellig = hatFaelligkeit ? Tagesdatum(faelligAm) : nil
|
||||
let sauberName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
let geglueckt = await vorgang.fuehreAus {
|
||||
switch zweck {
|
||||
case .neu:
|
||||
_ = try await client.legeAboAn(AboAnlegen(
|
||||
name: sauberName, icon: symbol, color: farbe,
|
||||
amountCents: betragCents, currency: waehrung,
|
||||
intervalMonths: rhythmus.rawValue, categoryId: kategorieId,
|
||||
accountId: kontoId, nextDue: faellig,
|
||||
payeePattern: leerAlsNil(erkennungstext), notes: leerAlsNil(notiz)))
|
||||
case .aendern(let abo):
|
||||
_ = try await client.aendereAbo(abo.id, AboAendern(
|
||||
name: sauberName, icon: symbol, color: farbe,
|
||||
amountCents: betragCents, intervalMonths: rhythmus.rawValue,
|
||||
categoryId: kategorieId, accountId: kontoId, nextDue: faellig,
|
||||
payeePattern: leerAlsNil(erkennungstext), isActive: istAktiv,
|
||||
notes: leerAlsNil(notiz)))
|
||||
}
|
||||
}
|
||||
if geglueckt {
|
||||
fertig()
|
||||
schliesse()
|
||||
}
|
||||
}
|
||||
|
||||
private func loesche() async {
|
||||
guard let client = zustand.client, case .aendern(let abo) = zweck else { return }
|
||||
if await vorgang.fuehreAus({ try await client.loescheAbo(abo.id) }) {
|
||||
fertig()
|
||||
schliesse()
|
||||
}
|
||||
}
|
||||
|
||||
private func leerAlsNil(_ text: String) -> String? {
|
||||
let sauber = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return sauber.isEmpty ? nil : sauber
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ struct AboListe: View {
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@State private var lader: Lader<Aboliste>?
|
||||
@State private var zeigeBeendete = false
|
||||
@State private var formular: AboFormular.Zweck?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
@@ -25,6 +26,14 @@ struct AboListe: View {
|
||||
Image(systemName: "line.3.horizontal.decrease.circle")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button { formular = .neu } label: { Image(systemName: "plus") }
|
||||
.accessibilityLabel(String(localized: "abo.neu"))
|
||||
}
|
||||
}
|
||||
.sheet(item: $formular) { zweck in
|
||||
AboFormular(zweck: zweck) { Task { await lader?.aktualisiere() } }
|
||||
.environment(zustand)
|
||||
}
|
||||
.task {
|
||||
guard lader == nil, let client = zustand.client else { return }
|
||||
@@ -51,8 +60,13 @@ struct AboListe: View {
|
||||
}
|
||||
Section {
|
||||
ForEach(sichtbar) { abo in
|
||||
Button {
|
||||
formular = .aendern(abo)
|
||||
} label: {
|
||||
AboZeile(abo: abo, nachschlag: zustand.nachschlag)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 ?? ""
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import SwiftUI
|
||||
import Observation
|
||||
|
||||
/// Ein laufendes Speichern: sperrt den Knopf und hält den Fehler fest.
|
||||
///
|
||||
/// Vier Formulare brauchen genau dasselbe. Zwei davon hätten es noch je für
|
||||
/// sich machen können, bei vieren liefen die Kopien auseinander.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class Speichervorgang {
|
||||
private(set) var laeuft = false
|
||||
var fehler: String?
|
||||
|
||||
/// Führt die Arbeit aus und sagt, ob sie geklappt hat. Ein Fehler bleibt
|
||||
/// in `fehler` stehen, damit das Formular offen bleiben und ihn zeigen
|
||||
/// kann – zugeklappt wäre die Eingabe weg und die Meldung nutzlos.
|
||||
func fuehreAus(_ arbeit: () async throws -> Void) async -> Bool {
|
||||
guard !laeuft else { return false }
|
||||
laeuft = true
|
||||
fehler = nil
|
||||
defer { laeuft = false }
|
||||
do {
|
||||
try await arbeit()
|
||||
return true
|
||||
} catch {
|
||||
fehler = (error as? APIFehler)?.errorDescription ?? error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Fehlerzeile unter einem Formular.
|
||||
struct FehlerZeile: View {
|
||||
let text: String?
|
||||
|
||||
var body: some View {
|
||||
if let text {
|
||||
Label {
|
||||
Text(text)
|
||||
} icon: {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
}
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Betrag zum Eintippen.
|
||||
///
|
||||
/// Gebunden an **Cent**, nicht an Text: Die Umrechnung an genau einer Stelle
|
||||
/// zu haben ist der Grund, warum `Geldbetrag` existiert. Die Zehnertastatur
|
||||
/// liefert einen Punkt, die deutsche ein Komma – `ausEingabe` nimmt beides.
|
||||
struct Betragsfeld: View {
|
||||
let titel: String
|
||||
let waehrung: String
|
||||
@Binding var cents: Int
|
||||
|
||||
@State private var text = ""
|
||||
@FocusState private var hatFokus: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(titel)
|
||||
Spacer()
|
||||
TextField(Geldbetrag(cents: 0, waehrung: waehrung).alsEingabe, text: $text)
|
||||
.keyboardType(.decimalPad)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.monospacedDigit()
|
||||
.focused($hatFokus)
|
||||
.frame(maxWidth: 140)
|
||||
Text(waehrung)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.onAppear {
|
||||
// Eine glatte Null als "0,00" vorzublenden sähe aus wie eine
|
||||
// Eingabe. Leer lassen, der Platzhalter sagt genug.
|
||||
if cents != 0 { text = Geldbetrag(cents: cents, waehrung: waehrung).alsEingabe }
|
||||
}
|
||||
.onChange(of: text) { _, neu in
|
||||
cents = Geldbetrag.ausEingabe(neu, waehrung: waehrung)?.cents ?? 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kategorie wählen, nach Gruppen sortiert – wie im Web das Dropdown mit
|
||||
/// Gruppenüberschriften.
|
||||
struct KategorieFeld: View {
|
||||
let titel: String
|
||||
let nachschlag: Nachschlag
|
||||
/// Nur Kategorien dieser Art. `nil` zeigt alle.
|
||||
var art: Kategorieart?
|
||||
@Binding var auswahl: Int?
|
||||
|
||||
var body: some View {
|
||||
Picker(titel, selection: $auswahl) {
|
||||
Text(String(localized: "buchung.ohneKategorie")).tag(Int?.none)
|
||||
ForEach(gruppen) { gruppe in
|
||||
Section(gruppe.name) {
|
||||
ForEach(gruppe.categories.filter { !$0.isArchived }) { kategorie in
|
||||
Text(kategorie.name).tag(Int?.some(kategorie.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var gruppen: [Kategoriegruppe] {
|
||||
nachschlag.gruppen
|
||||
.filter { art == nil || $0.kind == art }
|
||||
.filter { $0.categories.contains { !$0.isArchived } }
|
||||
}
|
||||
}
|
||||
|
||||
/// Konto wählen.
|
||||
struct KontoFeld: View {
|
||||
let titel: String
|
||||
let nachschlag: Nachschlag
|
||||
/// Ein Konto, das nicht zur Auswahl stehen soll – das Gegenkonto eines
|
||||
/// Übertrags darf nicht dasselbe sein wie das Quellkonto.
|
||||
var ausser: Int?
|
||||
@Binding var auswahl: Int?
|
||||
|
||||
var body: some View {
|
||||
Picker(titel, selection: $auswahl) {
|
||||
Text(String(localized: "buchung.konto.keines")).tag(Int?.none)
|
||||
ForEach(auswaehlbar) { konto in
|
||||
Text(konto.name).tag(Int?.some(konto.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var auswaehlbar: [Konto] {
|
||||
nachschlag.konten.filter { !$0.isArchived && $0.id != ausser }
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Schlagwörter einer Buchung an- und abwählen.
|
||||
struct SchlagwortFeld: View {
|
||||
let nachschlag: Nachschlag
|
||||
@Binding var gewaehlt: Set<Int>
|
||||
|
||||
var body: some View {
|
||||
if nachschlag.schlagwoerter.isEmpty {
|
||||
Text(String(localized: "buchung.keineSchlagwoerter"))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(nachschlag.schlagwoerter) { etikett in
|
||||
Button {
|
||||
if gewaehlt.contains(etikett.id) { gewaehlt.remove(etikett.id) }
|
||||
else { gewaehlt.insert(etikett.id) }
|
||||
} label: {
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color(hex: etikett.color))
|
||||
.frame(width: 10, height: 10)
|
||||
Text(etikett.name)
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
if gewaehlt.contains(etikett.id) {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Symbol und Farbe wählen – für Konten und Verträge.
|
||||
struct SymbolUndFarbeFeld: View {
|
||||
@Binding var symbol: String
|
||||
@Binding var farbe: String
|
||||
|
||||
/// Eine kleine Auswahl statt aller 600 Lucide-Namen: Wer mehr braucht,
|
||||
/// stellt es am Rechner ein. Die Namen sind die des Backends, nicht die
|
||||
/// der SF Symbols – `Symbole.sf` übersetzt beim Anzeigen.
|
||||
private static let symbole = [
|
||||
"wallet", "landmark", "piggy-bank", "credit-card", "banknote", "coins",
|
||||
"vault", "trending-up", "home", "shopping-cart", "car", "heart",
|
||||
"film", "music", "wifi", "phone", "gift", "briefcase", "refresh-ccw",
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
auswahl(Self.symbole, aktuell: symbol) { symbol = $0 } inhalt: { name in
|
||||
Image(systemName: Symbole.sf(name))
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(Color(hex: farbe))
|
||||
}
|
||||
|
||||
auswahl(Farbwahl.palette, aktuell: farbe) { farbe = $0 } inhalt: { wert in
|
||||
Circle().fill(Color(hex: wert)).frame(width: 22, height: 22)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func auswahl<Inhalt: View>(_ werte: [String], aktuell: String,
|
||||
waehle: @escaping (String) -> Void,
|
||||
@ViewBuilder inhalt: @escaping (String) -> Inhalt) -> some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(werte, id: \.self) { wert in
|
||||
Button { waehle(wert) } label: {
|
||||
inhalt(wert)
|
||||
.frame(width: 38, height: 38)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(wert == aktuell ? Color.accentColor.opacity(0.18)
|
||||
: Color.clear)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(wert == aktuell ? Color.accentColor : .clear,
|
||||
lineWidth: 2)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Buchung anlegen oder ändern.
|
||||
///
|
||||
/// Eine Ansicht für beides: Die Felder sind dieselben, und zwei Formulare
|
||||
/// nebeneinander wären genau die Stelle, an der sie auseinanderlaufen. Was
|
||||
/// sich unterscheidet, steht in `BuchungFormular.Zweck`.
|
||||
struct BuchungFormular: View {
|
||||
enum Zweck: Equatable, Identifiable {
|
||||
case neu
|
||||
case aendern(Buchung)
|
||||
|
||||
/// Damit `.sheet(item:)` damit umgehen kann. Die ID der Buchung
|
||||
/// genügt: Zwei Formulare für dieselbe Buchung gibt es nicht.
|
||||
var id: Int {
|
||||
switch self {
|
||||
case .neu: return 0
|
||||
case .aendern(let buchung): return buchung.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let zweck: Zweck
|
||||
/// Wird nach erfolgreichem Speichern oder Löschen gerufen – die Liste
|
||||
/// dahinter muss sich neu laden.
|
||||
var fertig: () -> Void
|
||||
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@Environment(\.dismiss) private var schliesse
|
||||
@State private var vorgang = Speichervorgang()
|
||||
|
||||
@State private var art: Buchungsart = .expense
|
||||
@State private var datum = Date()
|
||||
@State private var kontoId: Int?
|
||||
@State private var zielkontoId: Int?
|
||||
@State private var betragCents = 0
|
||||
@State private var kategorieId: Int?
|
||||
@State private var empfaenger = ""
|
||||
@State private var zweckText = ""
|
||||
@State private var notiz = ""
|
||||
@State private var etiketten: Set<Int> = []
|
||||
@State private var fragtNachLoeschen = false
|
||||
@State private var vorbelegt = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
Picker(String(localized: "buchungen.art"), selection: $art) {
|
||||
ForEach(Buchungsart.allCases) { eine in
|
||||
Text(eine.name).tag(eine)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
DatePicker(String(localized: "buchung.datum"), selection: $datum,
|
||||
displayedComponents: .date)
|
||||
|
||||
Betragsfeld(titel: String(localized: "buchung.betrag"),
|
||||
waehrung: waehrung, cents: $betragCents)
|
||||
}
|
||||
|
||||
Section {
|
||||
KontoFeld(titel: String(localized: "buchung.konto"),
|
||||
nachschlag: zustand.nachschlag, auswahl: $kontoId)
|
||||
if art == .transfer {
|
||||
KontoFeld(titel: String(localized: "buchung.zielkonto"),
|
||||
nachschlag: zustand.nachschlag, ausser: kontoId,
|
||||
auswahl: $zielkontoId)
|
||||
} else {
|
||||
KategorieFeld(titel: String(localized: "buchung.kategorie"),
|
||||
nachschlag: zustand.nachschlag,
|
||||
art: art == .income ? .income : .expense,
|
||||
auswahl: $kategorieId)
|
||||
}
|
||||
} footer: {
|
||||
if art == .transfer {
|
||||
Text(String(localized: "buchung.uebertragErklaerung"))
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField(String(localized: "buchung.empfaenger"), text: $empfaenger)
|
||||
TextField(String(localized: "buchung.verwendungszweck"), text: $zweckText)
|
||||
TextField(String(localized: "buchung.notiz"), text: $notiz, axis: .vertical)
|
||||
.lineLimit(1...4)
|
||||
}
|
||||
|
||||
Section(String(localized: "buchung.schlagwoerter")) {
|
||||
SchlagwortFeld(nachschlag: zustand.nachschlag, gewaehlt: $etiketten)
|
||||
}
|
||||
|
||||
if let fehler = vorgang.fehler {
|
||||
Section { FehlerZeile(text: fehler) }
|
||||
}
|
||||
|
||||
if case .aendern = zweck {
|
||||
Section {
|
||||
Button(String(localized: "buchung.loeschen"), role: .destructive) {
|
||||
fragtNachLoeschen = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.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(!istVollstaendig || vorgang.laeuft)
|
||||
}
|
||||
}
|
||||
.interactiveDismissDisabled(vorgang.laeuft)
|
||||
.confirmationDialog(String(localized: "buchung.loeschenFrage"),
|
||||
isPresented: $fragtNachLoeschen, titleVisibility: .visible) {
|
||||
Button(String(localized: "buchung.loeschen"), role: .destructive) {
|
||||
Task { await loesche() }
|
||||
}
|
||||
Button(String(localized: "allgemein.abbrechen"), role: .cancel) {}
|
||||
}
|
||||
.onAppear(perform: belegeVor)
|
||||
}
|
||||
}
|
||||
|
||||
private var titel: String {
|
||||
switch zweck {
|
||||
case .neu: return String(localized: "buchung.neu")
|
||||
case .aendern: return String(localized: "buchung.aendern")
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Währung des gewählten Kontos – Beträge stehen immer in ihr.
|
||||
private var waehrung: String {
|
||||
zustand.nachschlag.konto(kontoId)?.currency ?? zustand.einstellungen.basiswaehrung
|
||||
}
|
||||
|
||||
/// Das Backend verlangt einen Betrag grösser null und bei einem Übertrag
|
||||
/// ein Zielkonto. Hier zu prüfen erspart dem Nutzer eine Fehlermeldung
|
||||
/// vom Server für etwas, das die App schon weiss.
|
||||
private var istVollstaendig: Bool {
|
||||
guard betragCents > 0, kontoId != nil else { return false }
|
||||
if art == .transfer { return zielkontoId != nil && zielkontoId != kontoId }
|
||||
return true
|
||||
}
|
||||
|
||||
private func belegeVor() {
|
||||
guard !vorbelegt else { return }
|
||||
vorbelegt = true
|
||||
|
||||
guard case .aendern(let buchung) = zweck else {
|
||||
kontoId = zustand.nachschlag.konten.first { !$0.isArchived }?.id
|
||||
return
|
||||
}
|
||||
art = buchung.type
|
||||
datum = buchung.date.datum
|
||||
kontoId = buchung.accountId
|
||||
zielkontoId = buchung.toAccountId
|
||||
betragCents = abs(buchung.amountCents)
|
||||
kategorieId = buchung.categoryId
|
||||
empfaenger = buchung.payee ?? ""
|
||||
zweckText = buchung.description ?? ""
|
||||
notiz = buchung.notes ?? ""
|
||||
etiketten = Set(buchung.tagIds)
|
||||
}
|
||||
|
||||
private func sichere() async {
|
||||
guard let client = zustand.client, let kontoId else { return }
|
||||
let tag = Tagesdatum(datum)
|
||||
// Bei einem Übertrag gibt es keine Kategorie – sie mitzuschicken
|
||||
// hiesse, Geld einzuordnen, das nur die Tasche wechselt.
|
||||
let kategorie = art == .transfer ? nil : kategorieId
|
||||
let ziel = art == .transfer ? zielkontoId : nil
|
||||
|
||||
let geglueckt = await vorgang.fuehreAus {
|
||||
switch zweck {
|
||||
case .neu:
|
||||
_ = try await client.legeBuchungAn(BuchungAnlegen(
|
||||
type: art, date: tag, accountId: kontoId, toAccountId: ziel,
|
||||
amountCents: betragCents, categoryId: kategorie,
|
||||
payee: leerAlsNil(empfaenger), description: leerAlsNil(zweckText),
|
||||
notes: leerAlsNil(notiz), tagIds: Array(etiketten)))
|
||||
case .aendern(let buchung):
|
||||
_ = try await client.aendereBuchung(buchung.id, BuchungAendern(
|
||||
type: art, date: tag, accountId: kontoId, toAccountId: ziel,
|
||||
amountCents: betragCents, categoryId: kategorie,
|
||||
payee: leerAlsNil(empfaenger), description: leerAlsNil(zweckText),
|
||||
notes: leerAlsNil(notiz), tagIds: Array(etiketten)))
|
||||
}
|
||||
}
|
||||
if geglueckt {
|
||||
fertig()
|
||||
schliesse()
|
||||
}
|
||||
}
|
||||
|
||||
private func loesche() async {
|
||||
guard let client = zustand.client, case .aendern(let buchung) = zweck else { return }
|
||||
let geglueckt = await vorgang.fuehreAus {
|
||||
try await client.loescheBuchung(buchung.id)
|
||||
}
|
||||
if geglueckt {
|
||||
fertig()
|
||||
schliesse()
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein leeres Feld ist keine Angabe, sondern das Fehlen einer. Leerzeichen
|
||||
/// als Empfänger zu speichern wäre eine Angabe, die niemand gemacht hat.
|
||||
private func leerAlsNil(_ text: String) -> String? {
|
||||
let sauber = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return sauber.isEmpty ? nil : sauber
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ struct BuchungsListe: View {
|
||||
/// Was zuletzt wirklich abgerufen wurde – damit das Tippen nicht je
|
||||
/// Buchstabe einen Abruf auslöst.
|
||||
@State private var abgerufen = ""
|
||||
@State private var formular: BuchungFormular.Zweck?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
@@ -47,6 +48,18 @@ struct BuchungsListe: View {
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) { filterMenue }
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
formular = .neu
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibilityLabel(String(localized: "buchung.neu"))
|
||||
}
|
||||
}
|
||||
.sheet(item: $formular) { zweck in
|
||||
BuchungFormular(zweck: zweck) { Task { await lade() } }
|
||||
.environment(zustand)
|
||||
}
|
||||
.task {
|
||||
guard lader == nil else { return }
|
||||
@@ -112,8 +125,13 @@ struct BuchungsListe: View {
|
||||
ForEach(nachTagen(zeilen), id: \.tag) { gruppe in
|
||||
Section(gruppe.tag.formatiert()) {
|
||||
ForEach(gruppe.zeilen) { zeile in
|
||||
Button {
|
||||
formular = .aendern(zeile.buchung)
|
||||
} label: {
|
||||
BuchungsZeile(zeile: zeile, nachschlag: zustand.nachschlag)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,17 @@ struct BudgetAnsicht: View {
|
||||
/// `nil` heisst „der laufende Monat". Beim Blättern steht hier ein Label.
|
||||
@State private var label: String?
|
||||
@State private var lader: Lader<Budgetmonat>?
|
||||
@State private var bearbeitet: ZeileZumBearbeiten?
|
||||
@State private var vorgang = Speichervorgang()
|
||||
@State private var fragtNachZuruecksetzen = false
|
||||
|
||||
/// Welche Zeile wohin gesetzt wird – beides zusammen, damit das Blatt
|
||||
/// weiss, ob es den Monat oder die Vorlage ändert.
|
||||
struct ZeileZumBearbeiten: Identifiable, Equatable {
|
||||
let kategorie: Budgetkategorie
|
||||
let ziel: BudgetZeileBearbeiten.Ziel
|
||||
var id: Int { kategorie.categoryId }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
@@ -54,8 +65,23 @@ struct BudgetAnsicht: View {
|
||||
ForEach(monat.groups) { gruppe in
|
||||
Section {
|
||||
ForEach(gruppe.categories) { kategorie in
|
||||
Button {
|
||||
bearbeitet = ZeileZumBearbeiten(kategorie: kategorie,
|
||||
ziel: .monat(monat.label))
|
||||
} label: {
|
||||
KategorieZeile(kategorie: kategorie, waehrung: monat.baseCurrency)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button {
|
||||
bearbeitet = ZeileZumBearbeiten(kategorie: kategorie,
|
||||
ziel: .vorlage)
|
||||
} label: {
|
||||
Label(String(localized: "budget.vorlage"), systemImage: "doc.text")
|
||||
}
|
||||
.tint(.marke)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
GruppenKopf(gruppe: gruppe, waehrung: monat.baseCurrency)
|
||||
}
|
||||
@@ -90,6 +116,63 @@ struct BudgetAnsicht: View {
|
||||
}
|
||||
.accessibilityLabel(String(localized: "budget.naechsterMonat"))
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Button {
|
||||
Task { await uebernimm { try await $0.holeVorlageInMonat(monat.label) } }
|
||||
} label: {
|
||||
Label(String(localized: "budget.vorlageHolen"),
|
||||
systemImage: "arrow.down.doc")
|
||||
}
|
||||
Button {
|
||||
Task { await uebernimm { try await $0.macheMonatZurVorlage(monat.label) } }
|
||||
} label: {
|
||||
Label(String(localized: "budget.alsVorlageSetzen"),
|
||||
systemImage: "arrow.up.doc")
|
||||
}
|
||||
if monat.customized {
|
||||
Button(role: .destructive) {
|
||||
fragtNachZuruecksetzen = true
|
||||
} label: {
|
||||
Label(String(localized: "budget.zuruecksetzen"),
|
||||
systemImage: "arrow.uturn.backward")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
.disabled(vorgang.laeuft)
|
||||
}
|
||||
}
|
||||
.sheet(item: $bearbeitet) { eintrag in
|
||||
BudgetZeileBearbeiten(ziel: eintrag.ziel, kategorie: eintrag.kategorie,
|
||||
waehrung: monat.baseCurrency) {
|
||||
Task { await lader?.aktualisiere() }
|
||||
}
|
||||
.environment(zustand)
|
||||
}
|
||||
.confirmationDialog(String(localized: "budget.zuruecksetzenFrage"),
|
||||
isPresented: $fragtNachZuruecksetzen, titleVisibility: .visible) {
|
||||
Button(String(localized: "budget.zuruecksetzen"), role: .destructive) {
|
||||
Task { await uebernimm { try await $0.setzeBudgetMonatZurueck(monat.label) } }
|
||||
}
|
||||
Button(String(localized: "allgemein.abbrechen"), role: .cancel) {}
|
||||
} message: {
|
||||
Text(String(localized: "budget.zuruecksetzenErklaerung"))
|
||||
}
|
||||
.alert(String(localized: "fehler.titel"),
|
||||
isPresented: .constant(vorgang.fehler != nil)) {
|
||||
Button(String(localized: "allgemein.ok")) { vorgang.fehler = nil }
|
||||
} message: {
|
||||
Text(vorgang.fehler ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
/// Die drei Knöpfe im Menü tun dasselbe: etwas schreiben, dann neu laden.
|
||||
private func uebernimm(_ 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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Eine Budget-Zeile setzen – für den Monat oder für die Vorlage.
|
||||
///
|
||||
/// Dieselbe Maske für beides, weil das Backend dieselben drei Angaben
|
||||
/// entgegennimmt (`planned_cents`, `modus`, `zuschlag_cents`) und sie über
|
||||
/// `uebernimm` durch dieselbe Regel schickt. Was sich unterscheidet, ist nur
|
||||
/// der Endpunkt und der Satz darunter.
|
||||
struct BudgetZeileBearbeiten: View {
|
||||
enum Ziel: Equatable {
|
||||
/// Wirkt nur auf diesen Monat; er gilt danach als angepasst.
|
||||
case monat(String)
|
||||
/// Wirkt auf alle Monate, die nicht eigens angepasst sind.
|
||||
case vorlage
|
||||
}
|
||||
|
||||
let ziel: Ziel
|
||||
let kategorie: Budgetkategorie
|
||||
let waehrung: String
|
||||
var fertig: () -> Void
|
||||
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@Environment(\.dismiss) private var schliesse
|
||||
@State private var vorgang = Speichervorgang()
|
||||
|
||||
@State private var istBerechnet = false
|
||||
@State private var geplantCents = 0
|
||||
@State private var zuschlagCents = 0
|
||||
@State private var vorbelegt = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
HStack(spacing: 10) {
|
||||
Symbolplakette(symbol: kategorie.icon, farbe: kategorie.color)
|
||||
Text(kategorie.name)
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle(String(localized: "budget.ausVertraegenRechnen"),
|
||||
isOn: $istBerechnet)
|
||||
} footer: {
|
||||
Text(istBerechnet ? String(localized: "budget.berechnetErklaerung")
|
||||
: String(localized: "budget.festErklaerung"))
|
||||
}
|
||||
|
||||
Section {
|
||||
if istBerechnet {
|
||||
// Bei „berechnet" ist die hingeschriebene Zahl nicht
|
||||
// der Soll-Betrag, sondern nur der Zuschlag obendrauf.
|
||||
LabeledContent(String(localized: "budget.ausVertraegen"),
|
||||
value: Geldbetrag(cents: kategorie.ausVertraegenCents,
|
||||
waehrung: waehrung).formatiert())
|
||||
Betragsfeld(titel: String(localized: "budget.zuschlag"),
|
||||
waehrung: waehrung, cents: $zuschlagCents)
|
||||
LabeledContent(String(localized: "budget.ergibt"),
|
||||
value: Geldbetrag(cents: kategorie.ausVertraegenCents + zuschlagCents,
|
||||
waehrung: waehrung).formatiert())
|
||||
.fontWeight(.medium)
|
||||
} else {
|
||||
Betragsfeld(titel: String(localized: "budget.geplant"),
|
||||
waehrung: waehrung, cents: $geplantCents)
|
||||
}
|
||||
}
|
||||
|
||||
if let fehler = vorgang.fehler {
|
||||
Section { FehlerZeile(text: fehler) }
|
||||
}
|
||||
}
|
||||
.navigationTitle(zielTitel)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(String(localized: "allgemein.sichern")) {
|
||||
Task { await sichere() }
|
||||
}
|
||||
.disabled(vorgang.laeuft)
|
||||
}
|
||||
}
|
||||
.interactiveDismissDisabled(vorgang.laeuft)
|
||||
.onAppear {
|
||||
guard !vorbelegt else { return }
|
||||
vorbelegt = true
|
||||
istBerechnet = kategorie.istBerechnet
|
||||
geplantCents = kategorie.plannedCents
|
||||
zuschlagCents = kategorie.zuschlagCents
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var zielTitel: String {
|
||||
switch ziel {
|
||||
case .monat: return String(localized: "budget.monatSetzen")
|
||||
case .vorlage: return String(localized: "budget.vorlageSetzen")
|
||||
}
|
||||
}
|
||||
|
||||
private func sichere() async {
|
||||
guard let client = zustand.client else { return }
|
||||
let modus = istBerechnet ? "berechnet" : "fest"
|
||||
// Bei „berechnet" bleibt planned_cents unberührt: Das Backend liest
|
||||
// es dann nicht, und es zu überschreiben verlöre den Wert, auf den
|
||||
// ein Zurückschalten auf „fest" zurückfallen soll.
|
||||
let geplant = istBerechnet ? nil : geplantCents
|
||||
let zuschlag = istBerechnet ? zuschlagCents : nil
|
||||
|
||||
let geglueckt = await vorgang.fuehreAus {
|
||||
switch ziel {
|
||||
case .monat(let label):
|
||||
try await client.setzeBudgetMonat(label, kategorie: kategorie.categoryId,
|
||||
geplantCents: geplant, modus: modus,
|
||||
zuschlagCents: zuschlag)
|
||||
case .vorlage:
|
||||
try await client.setzeBudgetVorlage(kategorie: kategorie.categoryId,
|
||||
geplantCents: geplant, modus: modus,
|
||||
zuschlagCents: zuschlag)
|
||||
}
|
||||
}
|
||||
if geglueckt {
|
||||
fertig()
|
||||
schliesse()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,17 +9,25 @@
|
||||
/* 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 */; };
|
||||
428AA2C2634A6F94D2B30158 /* BudgetZeileBearbeiten.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FDF13E61F770748435D0097 /* BudgetZeileBearbeiten.swift */; };
|
||||
431D27A182E4DEBD84F0DA05 /* Geldbetrag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 287D314176EBA5E888605F5F /* Geldbetrag.swift */; };
|
||||
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 */; };
|
||||
@@ -27,19 +35,28 @@
|
||||
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 */; };
|
||||
E5F283F445FA9E26CFC15FA5 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BDF076C7F062202810699EE9 /* Localizable.strings */; };
|
||||
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 */
|
||||
@@ -55,39 +72,56 @@
|
||||
/* 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>"; };
|
||||
A150CAB4967A479B506A997F /* BuchungsListe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuchungsListe.swift; sourceTree = "<group>"; };
|
||||
A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZweiFaktorAnsicht.swift; sourceTree = "<group>"; };
|
||||
A4FED25B5220CEAD1638B53F /* BuchungFormular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuchungFormular.swift; sourceTree = "<group>"; };
|
||||
A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerAnsicht.swift; sourceTree = "<group>"; };
|
||||
B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Kategorie.swift; sourceTree = "<group>"; };
|
||||
BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dashboard.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
E51DE5BDB909910D586F819F /* Budget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Budget.swift; sourceTree = "<group>"; };
|
||||
E8CBA42A030509615199DD45 /* Abo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Abo.swift; sourceTree = "<group>"; };
|
||||
EFABF1A481445B1CD960C993 /* AboFormular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboFormular.swift; sourceTree = "<group>"; };
|
||||
F8BE152C9F91584900796ADA /* Grundmodelle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Grundmodelle.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
@@ -104,6 +138,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
78AD0B83BDFDF777917C9382 /* KontenListe.swift */,
|
||||
4AFC43300F101BE4F4541BA2 /* KontoFormular.swift */,
|
||||
);
|
||||
path = Konten;
|
||||
sourceTree = "<group>";
|
||||
@@ -129,6 +164,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
451BA41D9B53FB6B511AF734 /* Darstellung.swift */,
|
||||
778040646D05B1DEEE3BF727 /* Formular.swift */,
|
||||
287D314176EBA5E888605F5F /* Geldbetrag.swift */,
|
||||
776F26865B849D2318B6819B /* Zustandsansichten.swift */,
|
||||
);
|
||||
@@ -147,12 +183,15 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E8CBA42A030509615199DD45 /* Abo.swift */,
|
||||
0A2E765F9D8DDC8D46A1BDD6 /* Arbeitsliste.swift */,
|
||||
327787E4869ED7D0C7E9EE63 /* Buchung.swift */,
|
||||
E51DE5BDB909910D586F819F /* Budget.swift */,
|
||||
BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */,
|
||||
647E9AA0BFEDCAFF98BADB9C /* Eingaben.swift */,
|
||||
F8BE152C9F91584900796ADA /* Grundmodelle.swift */,
|
||||
B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */,
|
||||
4119B85EFF3FB38E84FAA913 /* Konto.swift */,
|
||||
3114763A11EC90E1D38C9DEF /* Nebenraeume.swift */,
|
||||
75EA409F3F0CEF3DF04C32E7 /* Zeitangaben.swift */,
|
||||
);
|
||||
path = Modelle;
|
||||
@@ -172,6 +211,7 @@
|
||||
DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */,
|
||||
52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */,
|
||||
A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */,
|
||||
3C618BE3CBCEBBF65F79A63D /* SperrAnsicht.swift */,
|
||||
A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.swift */,
|
||||
);
|
||||
path = Anmeldung;
|
||||
@@ -180,9 +220,11 @@
|
||||
BC82A6A323BDD2AD8325BEA2 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D42C757A9D818A524C4CAC8A /* AppIcon.icon */,
|
||||
CB7098BB5F4AEFE48BA9BB36 /* Abos */,
|
||||
B4505C6E527A0020B80AFF04 /* Anmeldung */,
|
||||
BEE6BC97D865C2F0C4EB80C4 /* App */,
|
||||
FAFA069DDABF0E56CAF24C4F /* Arbeitsliste */,
|
||||
5E720D91F0CF9DBDA975A959 /* Bausteine */,
|
||||
D99101183ED9DA14C8D22722 /* Buchungen */,
|
||||
C4B3E718A814E0236A12EB86 /* Budget */,
|
||||
@@ -190,6 +232,7 @@
|
||||
56CB7B1C18136BFD83ACADBD /* Einstellungen */,
|
||||
298E43BBA9248E5A9779DA98 /* Konten */,
|
||||
80EFC9A43394C64E9AEEF5A1 /* Modelle */,
|
||||
F77DCF8FF4A1F732F2FA3EA8 /* Nebenraeume */,
|
||||
DB51F54CC077E85C286AD9DC /* Netzwerk */,
|
||||
524FD85EDE7C4DE5ABE9A8E5 /* Tests */,
|
||||
0EDDD922C06B1465AD13C782 /* Texte */,
|
||||
@@ -220,6 +263,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.swift */,
|
||||
0FDF13E61F770748435D0097 /* BudgetZeileBearbeiten.swift */,
|
||||
);
|
||||
path = Budget;
|
||||
sourceTree = "<group>";
|
||||
@@ -227,6 +271,7 @@
|
||||
CB7098BB5F4AEFE48BA9BB36 /* Abos */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
EFABF1A481445B1CD960C993 /* AboFormular.swift */,
|
||||
8C5FA8AE870EDCB51232CA9E /* AboListe.swift */,
|
||||
);
|
||||
path = Abos;
|
||||
@@ -235,6 +280,7 @@
|
||||
D99101183ED9DA14C8D22722 /* Buchungen */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A4FED25B5220CEAD1638B53F /* BuchungFormular.swift */,
|
||||
A150CAB4967A479B506A997F /* BuchungsListe.swift */,
|
||||
);
|
||||
path = Buchungen;
|
||||
@@ -253,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 */
|
||||
@@ -334,6 +401,7 @@
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B551D26D31D9C35898074FE5 /* AppIcon.icon in Resources */,
|
||||
E5F283F445FA9E26CFC15FA5 /* Localizable.strings in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -364,32 +432,48 @@
|
||||
C518581A7F62632420821479 /* APIFehler.swift in Sources */,
|
||||
F414F982412C79242CADB473 /* Ablage.swift in Sources */,
|
||||
A499FAEF73B54C5533817BAD /* Abo.swift in Sources */,
|
||||
F2C67D902D1AC96A60691687 /* AboFormular.swift in Sources */,
|
||||
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 */,
|
||||
841A4EE94250BDDF88FA2FD4 /* Budget.swift in Sources */,
|
||||
2685982A4603DE880837D10E /* BudgetAnsicht.swift in Sources */,
|
||||
428AA2C2634A6F94D2B30158 /* BudgetZeileBearbeiten.swift in Sources */,
|
||||
18B6A9629AA749B790F21DC9 /* CertainApp.swift in Sources */,
|
||||
EE4F2382CFF3F60FF5AC8241 /* CertainClient.swift in Sources */,
|
||||
918BA7A7CC3972ED4D8A93B1 /* Darstellung.swift in Sources */,
|
||||
1E53B174991E91B8D25CCE04 /* Dashboard.swift in Sources */,
|
||||
89C924D18B1875B037EF08BA /* DashboardAnsicht.swift in Sources */,
|
||||
DE172F2DBA80FB2E8CE93A06 /* Eingaben.swift in Sources */,
|
||||
66EE367CB37CDA59EEF0F023 /* EinstellungenAnsicht.swift in Sources */,
|
||||
BEC6BF023D4A649003F38A2D /* Endpunkte.swift in Sources */,
|
||||
C810555B892E1CAD08053FC3 /* Formular.swift in Sources */,
|
||||
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;
|
||||
@@ -427,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",
|
||||
@@ -437,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;
|
||||
};
|
||||
@@ -607,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",
|
||||
@@ -617,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;
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ struct KontenListe: View {
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@State private var lader: Lader<[Konto]>?
|
||||
@State private var zeigeArchivierte = false
|
||||
@State private var formular: KontoFormular.Zweck?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
@@ -27,6 +28,14 @@ struct KontenListe: View {
|
||||
Image(systemName: "line.3.horizontal.decrease.circle")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button { formular = .neu } label: { Image(systemName: "plus") }
|
||||
.accessibilityLabel(String(localized: "konto.neu"))
|
||||
}
|
||||
}
|
||||
.sheet(item: $formular) { zweck in
|
||||
KontoFormular(zweck: zweck) { Task { await lader?.aktualisiere() } }
|
||||
.environment(zustand)
|
||||
}
|
||||
.task {
|
||||
guard lader == nil, let client = zustand.client else { return }
|
||||
@@ -50,8 +59,13 @@ struct KontenListe: View {
|
||||
List {
|
||||
Section {
|
||||
ForEach(sichtbar) { konto in
|
||||
Button {
|
||||
formular = .aendern(konto)
|
||||
} label: {
|
||||
KontoZeile(konto: konto)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
} footer: {
|
||||
Gesamtsumme(konten: sichtbar,
|
||||
waehrung: zustand.einstellungen.basiswaehrung)
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Ein Konto anlegen oder ändern.
|
||||
struct KontoFormular: View {
|
||||
enum Zweck: Equatable, Identifiable {
|
||||
case neu
|
||||
case aendern(Konto)
|
||||
|
||||
var id: Int {
|
||||
switch self {
|
||||
case .neu: return 0
|
||||
case .aendern(let konto): return konto.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: Kontoart = .girokonto
|
||||
@State private var waehrung = "EUR"
|
||||
@State private var anfangssaldoCents = 0
|
||||
@State private var hatStichtag = false
|
||||
@State private var stichtag = Date()
|
||||
@State private var hatKreditrahmen = false
|
||||
@State private var kreditrahmenCents = 0
|
||||
@State private var istGebunden = false
|
||||
@State private var istArchiviert = false
|
||||
@State private var symbol = "wallet"
|
||||
@State private var farbe = "#3f51b5"
|
||||
@State private var fragtNachLoeschen = false
|
||||
@State private var vorbelegt = false
|
||||
|
||||
/// Was das Backend annimmt (`_check_currency`). Mehr braucht ein
|
||||
/// Haushaltsbuch hier nicht; die Hauptwährung steht in den Einstellungen.
|
||||
private static let waehrungen = ["EUR", "CHF", "USD", "GBP"]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField(String(localized: "konto.name"), text: $name)
|
||||
Picker(String(localized: "konto.art"), selection: $art) {
|
||||
ForEach(Kontoart.allCases, id: \.self) { eine in
|
||||
Text(eine.name).tag(eine)
|
||||
}
|
||||
}
|
||||
if case .neu = zweck {
|
||||
Picker(String(localized: "konto.waehrung"), selection: $waehrung) {
|
||||
ForEach(Self.waehrungen, id: \.self) { code in
|
||||
Text(code).tag(code)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Nachträglich zu wechseln liesse jeden gespeicherten
|
||||
// Betrag etwas anderes bedeuten. Deshalb nur zu sehen.
|
||||
LabeledContent(String(localized: "konto.waehrung"), value: waehrung)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Betragsfeld(titel: String(localized: "konto.anfangssaldo"),
|
||||
waehrung: waehrung, cents: $anfangssaldoCents)
|
||||
Toggle(String(localized: "konto.stichtagKennen"), isOn: $hatStichtag)
|
||||
if hatStichtag {
|
||||
DatePicker(String(localized: "konto.stichtag"), selection: $stichtag,
|
||||
displayedComponents: .date)
|
||||
}
|
||||
} footer: {
|
||||
Text(String(localized: "konto.anfangssaldoErklaerung"))
|
||||
}
|
||||
|
||||
if art == .kreditkarte {
|
||||
Section {
|
||||
Toggle(String(localized: "konto.kreditrahmenKennen"),
|
||||
isOn: $hatKreditrahmen)
|
||||
if hatKreditrahmen {
|
||||
Betragsfeld(titel: String(localized: "konto.kreditrahmen"),
|
||||
waehrung: waehrung, cents: $kreditrahmenCents)
|
||||
}
|
||||
} footer: {
|
||||
Text(String(localized: "konto.kreditrahmenErklaerung"))
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle(String(localized: "konto.gebunden"), isOn: $istGebunden)
|
||||
} footer: {
|
||||
Text(String(localized: "konto.gebundenErklaerung"))
|
||||
}
|
||||
|
||||
Section {
|
||||
SymbolUndFarbeFeld(symbol: $symbol, farbe: $farbe)
|
||||
}
|
||||
|
||||
if case .aendern = zweck {
|
||||
Section {
|
||||
Toggle(String(localized: "konto.archiviert"), isOn: $istArchiviert)
|
||||
Button(String(localized: "konto.loeschen"), role: .destructive) {
|
||||
fragtNachLoeschen = true
|
||||
}
|
||||
} footer: {
|
||||
Text(String(localized: "konto.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(!istVollstaendig || vorgang.laeuft)
|
||||
}
|
||||
}
|
||||
.interactiveDismissDisabled(vorgang.laeuft)
|
||||
.confirmationDialog(String(localized: "konto.loeschenFrage"),
|
||||
isPresented: $fragtNachLoeschen, titleVisibility: .visible) {
|
||||
Button(String(localized: "konto.loeschen"), role: .destructive) {
|
||||
Task { await loesche() }
|
||||
}
|
||||
Button(String(localized: "allgemein.abbrechen"), role: .cancel) {}
|
||||
} message: {
|
||||
Text(String(localized: "konto.loeschenErklaerung"))
|
||||
}
|
||||
.onAppear(perform: belegeVor)
|
||||
}
|
||||
}
|
||||
|
||||
private var titel: String {
|
||||
switch zweck {
|
||||
case .neu: return String(localized: "konto.neu")
|
||||
case .aendern: return String(localized: "konto.aendern")
|
||||
}
|
||||
}
|
||||
|
||||
private var istVollstaendig: Bool {
|
||||
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
private func belegeVor() {
|
||||
guard !vorbelegt else { return }
|
||||
vorbelegt = true
|
||||
|
||||
guard case .aendern(let konto) = zweck else {
|
||||
waehrung = zustand.einstellungen.basiswaehrung
|
||||
return
|
||||
}
|
||||
name = konto.name
|
||||
art = konto.type
|
||||
waehrung = konto.currency
|
||||
anfangssaldoCents = konto.openingBalanceCents
|
||||
hatStichtag = konto.openingDate != nil
|
||||
stichtag = konto.openingDate?.datum ?? Date()
|
||||
hatKreditrahmen = konto.creditLimitCents != nil
|
||||
kreditrahmenCents = konto.creditLimitCents ?? 0
|
||||
istGebunden = konto.isLocked
|
||||
istArchiviert = konto.isArchived
|
||||
symbol = konto.icon
|
||||
farbe = konto.color
|
||||
}
|
||||
|
||||
private func sichere() async {
|
||||
guard let client = zustand.client else { return }
|
||||
let sauberName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let datum = hatStichtag ? Tagesdatum(stichtag) : nil
|
||||
// Nur bei Kreditkarten überhaupt ein Rahmen – bei allem anderen wäre
|
||||
// die Zahl bedeutungslos und würde beim Umstellen der Art hängenbleiben.
|
||||
let rahmen = (art == .kreditkarte && hatKreditrahmen) ? kreditrahmenCents : nil
|
||||
|
||||
let geglueckt = await vorgang.fuehreAus {
|
||||
switch zweck {
|
||||
case .neu:
|
||||
_ = try await client.legeKontoAn(KontoAnlegen(
|
||||
name: sauberName, type: art, currency: waehrung, icon: symbol,
|
||||
color: farbe, openingBalanceCents: anfangssaldoCents,
|
||||
openingDate: datum, creditLimitCents: rahmen, isLocked: istGebunden))
|
||||
case .aendern(let konto):
|
||||
_ = try await client.aendereKonto(konto.id, KontoAendern(
|
||||
name: sauberName, type: art, icon: symbol, color: farbe,
|
||||
openingBalanceCents: anfangssaldoCents, openingDate: datum,
|
||||
creditLimitCents: rahmen, isLocked: istGebunden,
|
||||
isArchived: istArchiviert))
|
||||
}
|
||||
}
|
||||
if geglueckt {
|
||||
await zustand.ladeNachschlag()
|
||||
fertig()
|
||||
schliesse()
|
||||
}
|
||||
}
|
||||
|
||||
private func loesche() async {
|
||||
guard let client = zustand.client, case .aendern(let konto) = zweck else { return }
|
||||
if await vorgang.fuehreAus({ try await client.loescheKonto(konto.id) }) {
|
||||
await zustand.ladeNachschlag()
|
||||
fertig()
|
||||
schliesse()
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 11 KiB |
@@ -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?
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import Foundation
|
||||
|
||||
// Was die App zum Server schickt. Getrennt von den Lesemodellen, weil das
|
||||
// Backend es ebenso trennt: `TransactionCreate` kennt Felder, die
|
||||
// `TransactionOut` nicht hat, und umgekehrt (`subscription_id` etwa steht nur
|
||||
// in der Ausgabe – beim Anlegen gibt man es nicht mit).
|
||||
//
|
||||
// Alle Änderungstypen sind **teilweise**: Nur was gesetzt ist, geht mit. Ein
|
||||
// `nil` darf nicht als `null` hinausgehen, sonst löschte jedes Speichern die
|
||||
// Felder, die das Formular gar nicht anfasst.
|
||||
//
|
||||
// Das macht Swift von selbst – die erzeugte `encode(to:)` ruft für Optionals
|
||||
// `encodeIfPresent`. Hier stand das eine Weile von Hand ausgeschrieben, bis
|
||||
// ein Test zeigte, dass es dasselbe tut. Der Test bleibt: Die Eigenschaft ist
|
||||
// zu wichtig, um sie ungeprüft einer Spracheigenheit zu überlassen.
|
||||
|
||||
/// `TransactionCreate`
|
||||
struct BuchungAnlegen: Encodable, Sendable {
|
||||
var type: Buchungsart
|
||||
var date: Tagesdatum
|
||||
var accountId: Int
|
||||
var toAccountId: Int?
|
||||
/// Immer positiv – die Richtung steckt in `type`, nicht im Vorzeichen.
|
||||
var amountCents: Int
|
||||
var toAmountCents: Int?
|
||||
var categoryId: Int?
|
||||
var payee: String?
|
||||
var description: String?
|
||||
var notes: String?
|
||||
var tagIds: [Int] = []
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case type, date, payee, description, notes
|
||||
case accountId = "account_id"
|
||||
case toAccountId = "to_account_id"
|
||||
case amountCents = "amount_cents"
|
||||
case toAmountCents = "to_amount_cents"
|
||||
case categoryId = "category_id"
|
||||
case tagIds = "tag_ids"
|
||||
}
|
||||
}
|
||||
|
||||
/// `TransactionUpdate` – nur die gesetzten Felder gehen mit.
|
||||
struct BuchungAendern: Encodable, Sendable {
|
||||
var type: Buchungsart?
|
||||
var date: Tagesdatum?
|
||||
var accountId: Int?
|
||||
var toAccountId: Int?
|
||||
var amountCents: Int?
|
||||
var categoryId: Int?
|
||||
var payee: String?
|
||||
var description: String?
|
||||
var notes: String?
|
||||
/// Mitgeschickt heisst **ersetzen**; eine leere Liste nimmt alle Tags ab.
|
||||
/// Weggelassen lässt sie unberührt.
|
||||
var tagIds: [Int]?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case type, date, payee, description, notes
|
||||
case accountId = "account_id"
|
||||
case toAccountId = "to_account_id"
|
||||
case amountCents = "amount_cents"
|
||||
case categoryId = "category_id"
|
||||
case tagIds = "tag_ids"
|
||||
}
|
||||
}
|
||||
|
||||
/// `AccountCreate`
|
||||
struct KontoAnlegen: Encodable, Sendable {
|
||||
var name: String
|
||||
var type: Kontoart = .girokonto
|
||||
var currency: String = "EUR"
|
||||
var icon: String = "wallet"
|
||||
var color: String = "#3f51b5"
|
||||
var openingBalanceCents: Int = 0
|
||||
var openingDate: Tagesdatum?
|
||||
var creditLimitCents: Int?
|
||||
var isLocked: Bool = false
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name, type, currency, icon, color
|
||||
case openingBalanceCents = "opening_balance_cents"
|
||||
case openingDate = "opening_date"
|
||||
case creditLimitCents = "credit_limit_cents"
|
||||
case isLocked = "is_locked"
|
||||
}
|
||||
}
|
||||
|
||||
/// `AccountUpdate` – nur die gesetzten Felder gehen mit.
|
||||
///
|
||||
/// Die Währung fehlt mit Absicht: Sie nachträglich zu wechseln liesse jeden
|
||||
/// gespeicherten Betrag etwas anderes bedeuten. Wer das will, legt ein neues
|
||||
/// Konto an.
|
||||
struct KontoAendern: Encodable, Sendable {
|
||||
var name: String?
|
||||
var type: Kontoart?
|
||||
var icon: String?
|
||||
var color: String?
|
||||
var openingBalanceCents: Int?
|
||||
var openingDate: Tagesdatum?
|
||||
var creditLimitCents: Int?
|
||||
var isLocked: Bool?
|
||||
var isArchived: Bool?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name, type, icon, color
|
||||
case openingBalanceCents = "opening_balance_cents"
|
||||
case openingDate = "opening_date"
|
||||
case creditLimitCents = "credit_limit_cents"
|
||||
case isLocked = "is_locked"
|
||||
case isArchived = "is_archived"
|
||||
}
|
||||
}
|
||||
|
||||
/// `SubscriptionIn`
|
||||
struct AboAnlegen: Encodable, Sendable {
|
||||
var name: String
|
||||
var icon: String = "refresh-ccw"
|
||||
var color: String = "#6b7480"
|
||||
var amountCents: Int
|
||||
var currency: String = "EUR"
|
||||
/// Nur 1, 2, 3, 6 oder 12 – das Backend weist alles andere ab.
|
||||
var intervalMonths: Int = 1
|
||||
var categoryId: Int?
|
||||
var accountId: Int?
|
||||
var nextDue: Tagesdatum?
|
||||
var payeePattern: String?
|
||||
var notes: String?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name, icon, color, currency, notes
|
||||
case amountCents = "amount_cents"
|
||||
case intervalMonths = "interval_months"
|
||||
case categoryId = "category_id"
|
||||
case accountId = "account_id"
|
||||
case nextDue = "next_due"
|
||||
case payeePattern = "payee_pattern"
|
||||
}
|
||||
}
|
||||
|
||||
/// `SubscriptionUpdate` – nur die gesetzten Felder gehen mit.
|
||||
struct AboAendern: Encodable, Sendable {
|
||||
var name: String?
|
||||
var icon: String?
|
||||
var color: String?
|
||||
var amountCents: Int?
|
||||
var intervalMonths: Int?
|
||||
var categoryId: Int?
|
||||
var accountId: Int?
|
||||
var nextDue: Tagesdatum?
|
||||
var payeePattern: String?
|
||||
var isActive: Bool?
|
||||
var notes: String?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name, icon, color, notes
|
||||
case amountCents = "amount_cents"
|
||||
case intervalMonths = "interval_months"
|
||||
case categoryId = "category_id"
|
||||
case accountId = "account_id"
|
||||
case nextDue = "next_due"
|
||||
case payeePattern = "payee_pattern"
|
||||
case isActive = "is_active"
|
||||
}
|
||||
}
|
||||
|
||||
/// Die möglichen Zahlungsrhythmen. Mehr nimmt das Backend nicht an.
|
||||
enum Zahlungsrhythmus: Int, CaseIterable, Identifiable, Sendable {
|
||||
case monatlich = 1
|
||||
case zweimonatlich = 2
|
||||
case vierteljaehrlich = 3
|
||||
case halbjaehrlich = 6
|
||||
case jaehrlich = 12
|
||||
|
||||
var id: Int { rawValue }
|
||||
|
||||
var name: String {
|
||||
switch self {
|
||||
case .monatlich: return String(localized: "abo.monatlich")
|
||||
case .zweimonatlich: return String(format: String(localized: "abo.alleNMonate"), 2)
|
||||
case .vierteljaehrlich: return String(localized: "abo.vierteljaehrlich")
|
||||
case .halbjaehrlich: return String(localized: "abo.halbjaehrlich")
|
||||
case .jaehrlich: return String(localized: "abo.jaehrlich")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -190,4 +190,286 @@ extension CertainClient {
|
||||
try await hole("dashboard/recent",
|
||||
abfrage: [URLQueryItem(name: "limit", value: String(grenze))])
|
||||
}
|
||||
|
||||
// MARK: - Schreiben: Buchungen
|
||||
|
||||
func legeBuchungAn(_ eingabe: BuchungAnlegen) async throws -> Buchung {
|
||||
try await schicke(.post, "transactions", koerper: eingabe)
|
||||
}
|
||||
|
||||
func aendereBuchung(_ id: Int, _ aenderung: BuchungAendern) async throws -> Buchung {
|
||||
try await schicke(.patch, "transactions/\(id)", koerper: aenderung)
|
||||
}
|
||||
|
||||
func loescheBuchung(_ id: Int) async throws {
|
||||
try await schickeStumm(.delete, "transactions/\(id)")
|
||||
}
|
||||
|
||||
// MARK: - Schreiben: Budget
|
||||
|
||||
/// Setzt eine Zeile der Monatsansicht. Der Monat wird dadurch als
|
||||
/// „angepasst" geführt und folgt der Vorlage nicht mehr.
|
||||
///
|
||||
/// Query statt Körper – so will es das Backend (`routers/budgets.py`).
|
||||
@discardableResult
|
||||
func setzeBudgetMonat(_ label: String, kategorie: Int, geplantCents: Int?,
|
||||
modus: String? = nil,
|
||||
zuschlagCents: Int? = nil) async throws -> Budgetmonat {
|
||||
try await schicke(.put, "budget/\(label)/category/\(kategorie)",
|
||||
abfrage: budgetAbfrage(geplantCents, modus, zuschlagCents))
|
||||
}
|
||||
|
||||
/// Setzt eine Zeile der Standard-Vorlage. Wirkt auf alle Monate, die
|
||||
/// nicht eigens angepasst sind.
|
||||
@discardableResult
|
||||
func setzeBudgetVorlage(kategorie: Int, geplantCents: Int?, modus: String? = nil,
|
||||
zuschlagCents: Int? = nil) async throws -> Vorlagenzeile {
|
||||
try await schicke(.put, "budget/template/category/\(kategorie)",
|
||||
abfrage: budgetAbfrage(geplantCents, modus, zuschlagCents))
|
||||
}
|
||||
|
||||
private func budgetAbfrage(_ geplantCents: Int?, _ modus: String?,
|
||||
_ zuschlagCents: Int?) -> [URLQueryItem] {
|
||||
var abfrage: [URLQueryItem] = []
|
||||
if let geplantCents {
|
||||
abfrage.append(URLQueryItem(name: "planned_cents", value: String(geplantCents)))
|
||||
}
|
||||
if let modus {
|
||||
abfrage.append(URLQueryItem(name: "modus", value: modus))
|
||||
}
|
||||
if let zuschlagCents {
|
||||
abfrage.append(URLQueryItem(name: "zuschlag_cents", value: String(zuschlagCents)))
|
||||
}
|
||||
return abfrage
|
||||
}
|
||||
|
||||
/// Nimmt die Anpassungen dieses Monats zurück – er folgt wieder der Vorlage.
|
||||
@discardableResult
|
||||
func setzeBudgetMonatZurueck(_ label: String) async throws -> Budgetmonat {
|
||||
try await schicke(.delete, "budget/\(label)")
|
||||
}
|
||||
|
||||
/// Überschreibt den Monat mit der Vorlage.
|
||||
@discardableResult
|
||||
func holeVorlageInMonat(_ label: String) async throws -> Budgetmonat {
|
||||
try await schicke(.post, "budget/\(label)/from-template")
|
||||
}
|
||||
|
||||
/// Macht diesen Monat zur neuen Vorlage.
|
||||
@discardableResult
|
||||
func macheMonatZurVorlage(_ label: String) async throws -> Budgetvorlage {
|
||||
try await schicke(.post, "budget/template/from-month/\(label)")
|
||||
}
|
||||
|
||||
// MARK: - Schreiben: Verträge
|
||||
|
||||
func legeAboAn(_ eingabe: AboAnlegen) async throws -> Abo {
|
||||
try await schicke(.post, "subscriptions", koerper: eingabe)
|
||||
}
|
||||
|
||||
func aendereAbo(_ id: Int, _ aenderung: AboAendern) async throws -> Abo {
|
||||
try await schicke(.patch, "subscriptions/\(id)", koerper: aenderung)
|
||||
}
|
||||
|
||||
func loescheAbo(_ id: Int) async throws {
|
||||
try await schickeStumm(.delete, "subscriptions/\(id)")
|
||||
}
|
||||
|
||||
// MARK: - Schreiben: Konten
|
||||
|
||||
func legeKontoAn(_ eingabe: KontoAnlegen) async throws -> Konto {
|
||||
try await schicke(.post, "accounts", koerper: eingabe)
|
||||
}
|
||||
|
||||
func aendereKonto(_ id: Int, _ aenderung: KontoAendern) async throws -> Konto {
|
||||
try await schicke(.patch, "accounts/\(id)", koerper: aenderung)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
+10
-4
@@ -41,11 +41,17 @@ Vollzugang. Wer eine Ausnahme braucht, trägt sie bewusst in `project.yml` ein.
|
||||
| `App/` | Einstiegspunkt, Navigation, globaler Zustand |
|
||||
| `Netzwerk/` | Client, Endpunkte, Fehler, Offline-Ablage |
|
||||
| `Modelle/` | Codable-Typen, eins zu eins zu den Backend-Schemas |
|
||||
| `Bausteine/` | Wiederverwendetes: Geld, Farben, Symbole, Zustandsansichten |
|
||||
| `Bausteine/` | Wiederverwendetes: Geld, Farben, Symbole, Zustandsansichten, Formularfelder |
|
||||
| `Anmeldung/` | Server-Adresse, Anmeldung, zweiter Faktor, Passwortwechsel |
|
||||
| `Konten/` | Kontenliste (die erste Fachansicht) |
|
||||
| `Texte/` | Übersetzungen |
|
||||
| `Tests/` | Reine Logiktests, ohne Netz |
|
||||
| `Dashboard/` | Die Übersicht: vier feste Karten |
|
||||
| `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` |
|
||||
|
||||
## Wie die Anmeldung funktioniert
|
||||
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -578,3 +583,238 @@ struct ServerantwortenTests {
|
||||
#expect(!Einstellungen(roh).basiswaehrung.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Was zum Server hinausgeht
|
||||
|
||||
@Suite("Eingaben")
|
||||
struct EingabenTests {
|
||||
|
||||
/// Die Felder, die wirklich im JSON stehen.
|
||||
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 Teiländerung schickt nur, was gesetzt ist")
|
||||
func teilaenderungBleibtTeilweise() throws {
|
||||
// Der Fall, der stillschweigend Daten frisst: Ginge ein nicht
|
||||
// angefasstes Feld als null hinaus, loeschte jedes Speichern den
|
||||
// Empfänger, die Notiz und die Kategorie gleich mit.
|
||||
let nurNotiz = try felder(BuchungAendern(notes: "Kassenbon fehlt"))
|
||||
|
||||
#expect(nurNotiz.keys.sorted() == ["notes"])
|
||||
#expect(nurNotiz["notes"] as? String == "Kassenbon fehlt")
|
||||
}
|
||||
|
||||
@Test("Eine leere Tag-Liste geht mit, denn sie bedeutet etwas")
|
||||
func leereTagListeGehtMit() throws {
|
||||
// Mitgeschickt heisst ERSETZEN. Eine leere Liste nimmt alle Tags ab;
|
||||
// sie wegzulassen liesse sie stehen. Die zwei Faelle duerfen nicht
|
||||
// zusammenfallen.
|
||||
let ohneTags = try felder(BuchungAendern(tagIds: []))
|
||||
#expect(ohneTags["tag_ids"] as? [Int] == [])
|
||||
|
||||
let unberuehrt = try felder(BuchungAendern(payee: "Laden"))
|
||||
#expect(unberuehrt["tag_ids"] == nil)
|
||||
}
|
||||
|
||||
@Test("Die Feldnamen sind die des Backends")
|
||||
func feldnamenPassen() throws {
|
||||
let anlegen = try felder(BuchungAnlegen(
|
||||
type: .expense, date: #require(Tagesdatum("2026-10-15")), accountId: 3,
|
||||
amountCents: 4250, categoryId: 7, payee: "Supermarkt"))
|
||||
|
||||
#expect(anlegen["account_id"] as? Int == 3)
|
||||
#expect(anlegen["amount_cents"] as? Int == 4250)
|
||||
#expect(anlegen["category_id"] as? Int == 7)
|
||||
#expect(anlegen["date"] as? String == "2026-10-15")
|
||||
#expect(anlegen["type"] as? String == "expense")
|
||||
}
|
||||
|
||||
@Test("Konto und Vertrag ändern sich ebenso nur in dem, was gesetzt ist")
|
||||
func weitereTeilaenderungen() throws {
|
||||
#expect(try felder(KontoAendern(isArchived: true)).keys.sorted() == ["is_archived"])
|
||||
#expect(try felder(AboAendern(isActive: false)).keys.sorted() == ["is_active"])
|
||||
|
||||
let kreditrahmen = try felder(KontoAendern(creditLimitCents: 200000))
|
||||
#expect(kreditrahmen["credit_limit_cents"] as? Int == 200000)
|
||||
}
|
||||
|
||||
@Test("Die Rhythmen sind genau die, die das Backend annimmt")
|
||||
func rhythmenPassenZumBackend() {
|
||||
// INTERVALL_MONATE in backend/app/schemas.py. Weicht die App ab,
|
||||
// antwortet der Server mit 422 auf etwas, das die Auswahl anbot.
|
||||
#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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,18 @@
|
||||
"allgemein.abbrechen" = "Abbrechen";
|
||||
"allgemein.abmelden" = "Abmelden";
|
||||
"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";
|
||||
|
||||
@@ -163,3 +169,209 @@
|
||||
"abo.proMonat" = "%@ im Monat";
|
||||
"abo.naechste" = "nächste am %@";
|
||||
"abo.inTagen" = "fällig in %d Tagen";
|
||||
|
||||
/* --- Buchung bearbeiten --- */
|
||||
"buchung.neu" = "Neue Buchung";
|
||||
"buchung.aendern" = "Buchung ändern";
|
||||
"buchung.datum" = "Datum";
|
||||
"buchung.betrag" = "Betrag";
|
||||
"buchung.konto" = "Konto";
|
||||
"buchung.konto.keines" = "Kein Konto";
|
||||
"buchung.zielkonto" = "Auf Konto";
|
||||
"buchung.kategorie" = "Kategorie";
|
||||
"buchung.empfaenger" = "Empfänger";
|
||||
"buchung.verwendungszweck" = "Verwendungszweck";
|
||||
"buchung.notiz" = "Notiz";
|
||||
"buchung.schlagwoerter" = "Schlagwörter";
|
||||
"buchung.keineSchlagwoerter" = "Noch keine Schlagwörter angelegt. Das geht am Rechner.";
|
||||
"buchung.uebertragErklaerung" = "Ein Übertrag verschiebt Geld zwischen deinen Konten. Er zählt in keiner Auswertung mit und braucht deshalb keine Kategorie.";
|
||||
"buchung.loeschen" = "Buchung löschen";
|
||||
"buchung.loeschenFrage" = "Diese Buchung löschen?";
|
||||
|
||||
/* --- Budget bearbeiten --- */
|
||||
"budget.geplant" = "Geplant";
|
||||
"budget.zuschlag" = "Zuschlag";
|
||||
"budget.ausVertraegen" = "Aus Verträgen";
|
||||
"budget.ergibt" = "Ergibt";
|
||||
"budget.monatSetzen" = "Nur dieser Monat";
|
||||
"budget.vorlageSetzen" = "Standard-Budget";
|
||||
"budget.vorlage" = "Vorlage";
|
||||
"budget.ausVertraegenRechnen" = "Aus Verträgen rechnen";
|
||||
"budget.berechnetErklaerung" = "Der Soll-Betrag ergibt sich aus den Verträgen dieser Kategorie plus Zuschlag. Ändert sich ein Vertrag, ändert sich das Budget mit.";
|
||||
"budget.festErklaerung" = "Ein fester Betrag, den du selbst setzt.";
|
||||
"budget.vorlageHolen" = "Vorlage in diesen Monat holen";
|
||||
"budget.alsVorlageSetzen" = "Diesen Monat als Standard festlegen";
|
||||
"budget.zuruecksetzen" = "Monat zurücksetzen";
|
||||
"budget.zuruecksetzenFrage" = "Diesen Monat zurücksetzen?";
|
||||
"budget.zuruecksetzenErklaerung" = "Alle Anpassungen dieses Monats fallen weg. Danach gilt wieder das Standard-Budget.";
|
||||
|
||||
/* --- Vertrag bearbeiten --- */
|
||||
"abo.neu" = "Neuer Vertrag";
|
||||
"abo.aendern" = "Vertrag ändern";
|
||||
"abo.name" = "Name";
|
||||
"abo.betrag" = "Betrag je Zahlung";
|
||||
"abo.rhythmus" = "Rhythmus";
|
||||
"abo.konto" = "Wird abgebucht von";
|
||||
"abo.kategorieErklaerung" = "Die Kategorie entscheidet, in welcher Budget-Zeile dieser Vertrag mitzählt.";
|
||||
"abo.faelligkeitKennen" = "Nächste Zahlung bekannt";
|
||||
"abo.naechsteZahlung" = "Nächste Zahlung";
|
||||
"abo.erkennungstext" = "Erkennungstext";
|
||||
"abo.erkennungErklaerung" = "Woran die Erkennung diesen Vertrag in einer Buchung wiedererkennt - meist ein Teil des Empfängernamens.";
|
||||
"abo.laeuftNoch" = "Läuft noch";
|
||||
"abo.beendenStattLoeschen" = "Beenden statt löschen hält fest, was er gekostet hat. Löschen entfernt ihn ganz.";
|
||||
"abo.loeschen" = "Vertrag löschen";
|
||||
"abo.loeschenFrage" = "Diesen Vertrag löschen?";
|
||||
"abo.loeschenErklaerung" = "Die zugeordneten Buchungen bleiben, sie verlieren nur das Etikett.";
|
||||
|
||||
/* --- Konto bearbeiten --- */
|
||||
"konto.neu" = "Neues Konto";
|
||||
"konto.aendern" = "Konto ändern";
|
||||
"konto.name" = "Name";
|
||||
"konto.art" = "Art";
|
||||
"konto.waehrung" = "Währung";
|
||||
"konto.anfangssaldo" = "Anfangssaldo";
|
||||
"konto.stichtagKennen" = "Stichtag angeben";
|
||||
"konto.stichtag" = "Stichtag";
|
||||
"konto.anfangssaldoErklaerung" = "Der Stand zu Beginn. Ohne Stichtag gilt er von Anfang an; mit Stichtag zählen nur Buchungen ab diesem Tag darauf.";
|
||||
"konto.kreditrahmenKennen" = "Kreditrahmen angeben";
|
||||
"konto.kreditrahmen" = "Kreditrahmen";
|
||||
"konto.kreditrahmenErklaerung" = "Was die Karte ausgeben darf - kein Guthaben.";
|
||||
"konto.gebunden" = "Gebundenes Geld";
|
||||
"konto.gebundenErklaerung" = "Geld, an das du nicht herankommst, etwa Säule 3a. Es zählt nicht als verfügbar.";
|
||||
"konto.archiviert" = "Archiviert";
|
||||
"konto.archivierenStattLoeschen" = "Archivieren blendet das Konto aus und behält seine Buchungen. Löschen geht nur, solange keine daran hängen.";
|
||||
"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";
|
||||
|
||||
@@ -9,12 +9,18 @@
|
||||
"allgemein.abbrechen" = "Cancel";
|
||||
"allgemein.abmelden" = "Sign out";
|
||||
"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";
|
||||
|
||||
@@ -163,3 +169,209 @@
|
||||
"abo.proMonat" = "%@ per month";
|
||||
"abo.naechste" = "next on %@";
|
||||
"abo.inTagen" = "due in %d days";
|
||||
|
||||
/* --- Edit transaction --- */
|
||||
"buchung.neu" = "New transaction";
|
||||
"buchung.aendern" = "Edit transaction";
|
||||
"buchung.datum" = "Date";
|
||||
"buchung.betrag" = "Amount";
|
||||
"buchung.konto" = "Account";
|
||||
"buchung.konto.keines" = "No account";
|
||||
"buchung.zielkonto" = "To account";
|
||||
"buchung.kategorie" = "Category";
|
||||
"buchung.empfaenger" = "Payee";
|
||||
"buchung.verwendungszweck" = "Description";
|
||||
"buchung.notiz" = "Note";
|
||||
"buchung.schlagwoerter" = "Tags";
|
||||
"buchung.keineSchlagwoerter" = "No tags yet. You can add them on the desktop.";
|
||||
"buchung.uebertragErklaerung" = "A transfer moves money between your own accounts. It counts in no report and therefore needs no category.";
|
||||
"buchung.loeschen" = "Delete transaction";
|
||||
"buchung.loeschenFrage" = "Delete this transaction?";
|
||||
|
||||
/* --- Edit budget --- */
|
||||
"budget.geplant" = "Planned";
|
||||
"budget.zuschlag" = "Extra";
|
||||
"budget.ausVertraegen" = "From subscriptions";
|
||||
"budget.ergibt" = "Totals";
|
||||
"budget.monatSetzen" = "This month only";
|
||||
"budget.vorlageSetzen" = "Default budget";
|
||||
"budget.vorlage" = "Template";
|
||||
"budget.ausVertraegenRechnen" = "Calculate from subscriptions";
|
||||
"budget.berechnetErklaerung" = "The planned amount comes from this category's subscriptions plus the extra. Change a subscription and the budget follows.";
|
||||
"budget.festErklaerung" = "A fixed amount you set yourself.";
|
||||
"budget.vorlageHolen" = "Load template into this month";
|
||||
"budget.alsVorlageSetzen" = "Make this month the default";
|
||||
"budget.zuruecksetzen" = "Reset month";
|
||||
"budget.zuruecksetzenFrage" = "Reset this month?";
|
||||
"budget.zuruecksetzenErklaerung" = "All adjustments to this month are dropped. The default budget applies again.";
|
||||
|
||||
/* --- Edit subscription --- */
|
||||
"abo.neu" = "New subscription";
|
||||
"abo.aendern" = "Edit subscription";
|
||||
"abo.name" = "Name";
|
||||
"abo.betrag" = "Amount per payment";
|
||||
"abo.rhythmus" = "Billed";
|
||||
"abo.konto" = "Charged to";
|
||||
"abo.kategorieErklaerung" = "The category decides which budget line this subscription counts towards.";
|
||||
"abo.faelligkeitKennen" = "Next payment known";
|
||||
"abo.naechsteZahlung" = "Next payment";
|
||||
"abo.erkennungstext" = "Match text";
|
||||
"abo.erkennungErklaerung" = "How the matcher recognises this subscription in a transaction - usually part of the payee name.";
|
||||
"abo.laeuftNoch" = "Still running";
|
||||
"abo.beendenStattLoeschen" = "Cancelling keeps the record of what it cost. Deleting removes it entirely.";
|
||||
"abo.loeschen" = "Delete subscription";
|
||||
"abo.loeschenFrage" = "Delete this subscription?";
|
||||
"abo.loeschenErklaerung" = "The linked transactions stay; they only lose the label.";
|
||||
|
||||
/* --- Edit account --- */
|
||||
"konto.neu" = "New account";
|
||||
"konto.aendern" = "Edit account";
|
||||
"konto.name" = "Name";
|
||||
"konto.art" = "Type";
|
||||
"konto.waehrung" = "Currency";
|
||||
"konto.anfangssaldo" = "Opening balance";
|
||||
"konto.stichtagKennen" = "Set an opening date";
|
||||
"konto.stichtag" = "Opening date";
|
||||
"konto.anfangssaldoErklaerung" = "The balance to start from. Without a date it applies from the beginning; with one, only transactions from that day count on top.";
|
||||
"konto.kreditrahmenKennen" = "Set a credit limit";
|
||||
"konto.kreditrahmen" = "Credit limit";
|
||||
"konto.kreditrahmenErklaerung" = "What the card may spend - not a balance.";
|
||||
"konto.gebunden" = "Locked money";
|
||||
"konto.gebundenErklaerung" = "Money you cannot reach, such as a pension account. It does not count as available.";
|
||||
"konto.archiviert" = "Archived";
|
||||
"konto.archivierenStattLoeschen" = "Archiving hides the account and keeps its transactions. Deleting only works while none are attached.";
|
||||
"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";
|
||||
|
||||
+216
-25
@@ -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 `ffe1870`. Alles committet und gepusht.
|
||||
Stand: 21.10.2026, Commit `d290930`. Alles committet und gepusht.
|
||||
|
||||
---
|
||||
|
||||
@@ -173,8 +173,8 @@ umgestellt, weil sie so funktioniert. Die App soll die Filter benutzen.
|
||||
|
||||
## 4. Backend-Ergänzungen
|
||||
|
||||
Vier geplant. **A, B und C sind fertig**, D steht aus. Jede einzeln dem Nutzer
|
||||
vorlegen, bevor du sie schreibst.
|
||||
Vier geplant, **alle vier sind fertig**. Falls du eine fünfte brauchst: einzeln
|
||||
dem Nutzer vorlegen, bevor du sie schreibst.
|
||||
|
||||
### A — App-Token ✅ (Commit `8a7545b`)
|
||||
|
||||
@@ -225,17 +225,26 @@ zeigt eine berechnete Zeile als feste **und schreibt sie beim nächsten
|
||||
Speichern auch als feste zurück**. Die Weboberfläche traf es nicht, weil sie
|
||||
die Betriebsart aus der Monatsansicht nimmt.
|
||||
|
||||
### D — Zwei Kopier-Lücken im Budget (offen)
|
||||
### D — Zwei Kopier-Lücken im Budget ✅
|
||||
|
||||
`copy_template_to_month` und `template_from_month` in
|
||||
`backend/app/services/budget.py` übernehmen nur `planned_cents`. Ein
|
||||
„berechneter" Eintrag fällt beim Kopieren still auf „fest" zurück. Das ist ein
|
||||
**bestehender Fehler**, kein App-Thema — aber die App macht ihn sichtbar, weil
|
||||
sie beide Richtungen anbietet. Nötig für Stufe 3.
|
||||
`copy_template_to_month` und `copy_month_to_template` in
|
||||
`backend/app/services/budget.py` übernahmen nur `planned_cents`. Ein
|
||||
„berechneter" Eintrag fiel beim Kopieren still auf „fest" zurück — mit der
|
||||
Zahl, die zufällig zuletzt in `planned_cents` stand.
|
||||
|
||||
`ensure_customized` nebenan machte es längst richtig; die zwei Kopierer ziehen
|
||||
jetzt nach. Dabei kam eine Frage auf, die vorher niemand stellen musste: Eine
|
||||
**berechnete** Zeile gehört auch mit `planned_cents == 0` in die Vorlage, ihre
|
||||
Zahl steht ja in den Verträgen. Eine **feste** Null bleibt draussen, sonst
|
||||
füllte sich die Vorlage mit Nullzeilen für jede je berührte Kategorie.
|
||||
|
||||
Drei Tests in `test_budget.py` halten beide Richtungen und die Null fest.
|
||||
|
||||
---
|
||||
|
||||
## 5. Was fertig ist — Stufe 1 ✅ (Commit `6b86a6b`)
|
||||
## 5. Was fertig ist — Stufen 1 bis 5 ✅
|
||||
|
||||
### Stufe 1 (Commit `6b86a6b`)
|
||||
|
||||
21 Swift-Dateien, rund 2600 Zeilen, in `ios/`:
|
||||
|
||||
@@ -287,25 +296,202 @@ die App startet im Simulator bei der Serverabfrage.
|
||||
Faktor, Marke anlegen, Kontenliste — das lief bisher nur gegen die Modelle,
|
||||
nicht gegen einen laufenden Server. Siehe „Klartext-HTTP" unten.
|
||||
|
||||
### Stufe 2 — Lesen ✅ (Commit `bf437b3`)
|
||||
|
||||
Vier neue Reiter, dazu die Modelle und Endpunkte darunter:
|
||||
|
||||
| Ordner | Inhalt |
|
||||
|---|---|
|
||||
| `Dashboard/` | `DashboardAnsicht` — vier feste Karten, **ein** Abruf |
|
||||
| `Budget/` | `BudgetAnsicht` — Soll/Ist je Gruppe, blätterbar |
|
||||
| `Buchungen/` | `BuchungsListe` — Suche und Filter im Server |
|
||||
| `Abos/` | `AboListe` — Monats- und Jahressumme, Fälligkeit |
|
||||
| `Modelle/` | `Buchung`, `Budget`, `Abo`, `Kategorie`, `Dashboard` |
|
||||
|
||||
Drei Entscheidungen, die man kennen sollte, bevor man daran weiterbaut:
|
||||
|
||||
1. **Das Dashboard holt alles in einem Wert** (`Dashboarddaten`). Vier
|
||||
getrennte Zwischenspeicher könnten offline aus vier verschiedenen
|
||||
Momenten stammen und sich widersprechen.
|
||||
2. **Das Nachschlagewerk sitzt im `AppZustand`**, nicht in den Ansichten.
|
||||
`Nachschlag` hält Kategorien, Schlagwörter und Konten; jede Liste braucht
|
||||
zu einer ID den Namen, das Symbol und die Farbe.
|
||||
3. **Die Buchungsliste sucht erst beim Absenden.** Je Tastendruck zu fragen
|
||||
hiesse, bei „Supermarkt" zehn Abrufe zu schicken, von denen neun niemanden
|
||||
interessieren.
|
||||
|
||||
**Die Übertrags-Paarung steckt in `Buchungszeile.zeilen`** (`Modelle/Buchung.swift`).
|
||||
Zwei Dinge daran sind leicht falsch zu machen, beide durch Tests festgehalten:
|
||||
- Zusammengefasst wird **nur, wenn beide Beine in der Liste stehen**. Sonst
|
||||
liesse ein Kontofilter Zeilen verschwinden, die zu diesem Konto gehören.
|
||||
- Die Richtung kommt aus `richtung` der Verknüpfung, **nicht aus dem
|
||||
Vorzeichen** — manche Banken liefern beide Beine positiv.
|
||||
|
||||
### Die Modellprüfung ist jetzt ein Test
|
||||
|
||||
`backend/scripts/erzeuge_ios_fixture.py` legt eine Wegwerfdatenbank an,
|
||||
befüllt sie so, dass jedes Feld einmal belegt ist, und schreibt 15 echte
|
||||
Antworten nach `ios/Tests/Fixtures/antworten.json`. Der Swift-Test
|
||||
`Serverantworten` entschlüsselt daraus jedes Modell.
|
||||
|
||||
**Das ersetzt die Handarbeit, die hier früher beschrieben stand.** Nach jeder
|
||||
Änderung an einem Schema das Skript neu laufen lassen und die Datei mit
|
||||
committen:
|
||||
|
||||
```
|
||||
cd backend && .venv/bin/python -m scripts.erzeuge_ios_fixture
|
||||
```
|
||||
|
||||
Wer ein Modell ergänzt, nimmt seinen Endpunkt in `PFADE` auf und schreibt eine
|
||||
Zeile im Test dazu. Das ist die einzige Prüfung, die einen vertippten
|
||||
Feldnamen fängt — der übersetzt anstandslos.
|
||||
|
||||
### Stufe 3 — Schreiben ✅ (Commit `82db09e`)
|
||||
|
||||
Buchungen, Budget-Zeilen, Verträge und Konten lassen sich anlegen, ändern und
|
||||
löschen.
|
||||
|
||||
| Datei | Inhalt |
|
||||
|---|---|
|
||||
| `Modelle/Eingaben.swift` | Was zum Server hinausgeht |
|
||||
| `Bausteine/Formular.swift` | Speichervorgang, Betragsfeld, Auswahlfelder |
|
||||
| `Buchungen/BuchungFormular.swift` | Anlegen **und** Ändern in einer Maske |
|
||||
| `Budget/BudgetZeileBearbeiten.swift` | Monat oder Vorlage, dieselbe Maske |
|
||||
| `Abos/AboFormular.swift`, `Konten/KontoFormular.swift` | ebenso |
|
||||
|
||||
**Ein Formular je Sache, nicht zwei.** Anlegen und Ändern teilen sich die
|
||||
Maske; was sich unterscheidet, steht in einem `Zweck`-Typ. Zwei Masken
|
||||
nebeneinander wären genau die Stelle, an der sie auseinanderlaufen.
|
||||
|
||||
#### Vier Regeln, die man nicht sieht, aber merkt, wenn sie fehlen
|
||||
|
||||
1. **Eine Teiländerung schickt nur, was gesetzt ist.** Ginge ein nicht
|
||||
angefasstes Feld als `null` hinaus, löschte jedes Speichern den Empfänger,
|
||||
die Notiz und die Kategorie gleich mit. Swift macht das von selbst
|
||||
(`encodeIfPresent` für Optionals) — der Test dazu bleibt trotzdem stehen,
|
||||
die Eigenschaft ist zu wichtig für eine ungeprüfte Spracheigenheit.
|
||||
2. **Eine leere Tag-Liste geht sehr wohl mit.** Sie heisst „alle abnehmen" und
|
||||
darf nicht mit „nicht angefasst" zusammenfallen.
|
||||
3. **Ein Übertrag bekommt keine Kategorie.** Das Geld wechselt nur die Tasche.
|
||||
4. **Bei „berechnet" bleibt `planned_cents` unberührt.** Das Backend liest es
|
||||
dann nicht, und es zu überschreiben verlöre den Wert, auf den ein
|
||||
Zurückschalten auf „fest" zurückfallen soll.
|
||||
|
||||
Die **Währung eines bestehenden Kontos** lässt sich nicht ändern — jeder
|
||||
gespeicherte Betrag würde danach etwas anderes bedeuten. Das Backend erlaubt
|
||||
es; die App nicht.
|
||||
|
||||
#### Fehlende Texte findet dieser Befehl
|
||||
|
||||
`String(localized:)` mit unbekanntem Schlüssel übersetzt anstandslos und zeigt
|
||||
dem Nutzer den Schlüssel. Vor dem Committen:
|
||||
|
||||
```
|
||||
cd ios && comm -23 \
|
||||
<(grep -rhoE 'localized: "[a-zA-Z0-9._]+"' --include="*.swift" . | sed 's/localized: "//; s/"//' | sort -u) \
|
||||
<(grep -oE '^"[a-zA-Z0-9._]+"' Texte/de.lproj/Localizable.strings | tr -d '"' | sort -u)
|
||||
```
|
||||
|
||||
Gibt er nichts aus, ist nichts offen. (Ein Schlüssel, der nur in einem
|
||||
`? :` steckt, taucht fälschlich als „fehlend" nicht auf — das ist die eine
|
||||
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 2 — Lesen.** Dashboard (feste Ansicht: Kontostände, Budget-Fortschritt,
|
||||
letzte Buchungen, Einnahmen/Ausgaben), Budget-Monatsansicht, Buchungsliste,
|
||||
Abo-Liste. Die Backend-Ergänzungen **B** und **C**, die sie braucht, sind da.
|
||||
**Stufe 6 ist dran.** Alle vier Backend-Ergänzungen sind drin; von hier an
|
||||
reicht die API, wie sie ist.
|
||||
|
||||
**Stufe 3 — Schreiben.** Buchungen anlegen und ändern, Budget bearbeiten
|
||||
(Monat und Vorlage, beide Betriebsarten `fest`/`berechnet`), Abos verwalten
|
||||
samt Logo, Konten bearbeiten. Dazu Backend-Ergänzung **D**.
|
||||
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
|
||||
@@ -321,8 +507,13 @@ Vorlagen, Verlauf mit Rücknahme.
|
||||
### Zum Umfang, ehrlich
|
||||
|
||||
Die Weboberfläche hat rund 8900 Zeilen JSX. Nativ wird das nicht weniger.
|
||||
Stufen 2 bis 6 zusammen sind grob drei bis vier 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) 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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user