Compare commits
20
Commits
3dff435156
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09ec2dc5fd | ||
|
|
4e7f572d93 | ||
|
|
2d1d571b17 | ||
|
|
a079bf406c | ||
|
|
929a735699 | ||
|
|
9195906c2b | ||
|
|
f918297742 | ||
|
|
37517f360b | ||
|
|
d290930759 | ||
|
|
bab5cc5060 | ||
|
|
6c6257b8f9 | ||
|
|
9edf610d59 | ||
|
|
adbc3e0aa4 | ||
|
|
d04b6b74ad | ||
|
|
82db09e6de | ||
|
|
4c61a02c53 | ||
|
|
37b29934ef | ||
|
|
bf437b3b69 | ||
|
|
58bd504333 | ||
|
|
a071422e23 |
@@ -409,7 +409,7 @@ class CategoryGroup(Base):
|
|||||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
kind: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense)
|
kind: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense)
|
||||||
icon: Mapped[str] = mapped_column(String(40), nullable=False, default="list")
|
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)
|
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
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)
|
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
type: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense)
|
type: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense)
|
||||||
icon: Mapped[str] = mapped_column(String(40), nullable=False, default="more-horizontal")
|
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)
|
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
is_archived: 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
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
)
|
)
|
||||||
name: Mapped[str] = mapped_column(String(60), nullable=False)
|
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)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
|
|||||||
@@ -361,7 +361,7 @@ class TagOut(BaseModel):
|
|||||||
class TagCreate(BaseModel):
|
class TagCreate(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=60)
|
name: str = Field(min_length=1, max_length=60)
|
||||||
# Ohne Angabe dasselbe Grau wie bei einer neuen Kategorie.
|
# Ohne Angabe dasselbe Grau wie bei einer neuen Kategorie.
|
||||||
color: str = "#6b7480"
|
color: str = "#3f9c72"
|
||||||
|
|
||||||
|
|
||||||
class TagUpdate(BaseModel):
|
class TagUpdate(BaseModel):
|
||||||
@@ -406,7 +406,7 @@ class CategoryGroupCreate(BaseModel):
|
|||||||
name: str = Field(min_length=1, max_length=120)
|
name: str = Field(min_length=1, max_length=120)
|
||||||
kind: CategoryKind = CategoryKind.expense
|
kind: CategoryKind = CategoryKind.expense
|
||||||
icon: str = "list"
|
icon: str = "list"
|
||||||
color: str = "#6b7480"
|
color: str = "#3f9c72"
|
||||||
|
|
||||||
|
|
||||||
class CategoryGroupUpdate(BaseModel):
|
class CategoryGroupUpdate(BaseModel):
|
||||||
@@ -420,7 +420,7 @@ class CategoryCreate(BaseModel):
|
|||||||
group_id: int
|
group_id: int
|
||||||
name: str = Field(min_length=1, max_length=120)
|
name: str = Field(min_length=1, max_length=120)
|
||||||
icon: str = "more-horizontal"
|
icon: str = "more-horizontal"
|
||||||
color: str = "#6b7480"
|
color: str = "#3f9c72"
|
||||||
excluded_from_stats: bool = False
|
excluded_from_stats: bool = False
|
||||||
spending_is_good: 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:
|
def copy_month_to_template(db: Session, user: User, label: str) -> None:
|
||||||
"""Setzt die Standard-Vorlage auf die wirksamen Soll-Werte dieses Monats
|
"""Setzt die Standard-Vorlage auf die Soll-Werte dieses Monats
|
||||||
(„diesen Monat als Standard festlegen"). Einnahmen bleiben unberührt."""
|
(„diesen Monat als Standard festlegen"). Einnahmen bleiben unberührt.
|
||||||
planned = effective_planned(db, user, label) # {category_id: cents}
|
|
||||||
|
Ü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()
|
db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id).delete()
|
||||||
for category_id, cents in planned.items():
|
for zeile in zeilen:
|
||||||
if cents > 0:
|
# Eine feste Null ist nichts Geplantes und gehört nicht in die Vorlage.
|
||||||
db.add(BudgetTemplateLine(owner_id=user.id, category_id=category_id, planned_cents=cents))
|
# 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()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -241,7 +255,13 @@ def copy_template_to_month(db: Session, user: User, label: str) -> BudgetPeriod:
|
|||||||
db.flush()
|
db.flush()
|
||||||
period.customized = True
|
period.customized = True
|
||||||
for tl in db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id):
|
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()
|
db.commit()
|
||||||
return period
|
return period
|
||||||
|
|
||||||
|
|||||||
@@ -310,6 +310,17 @@ def finde(db: Session, user: User, nur_vorschlaege: bool = False) -> dict:
|
|||||||
verbraucht: set[int] = set()
|
verbraucht: set[int] = set()
|
||||||
verknuepft = 0
|
verknuepft = 0
|
||||||
vorschlaege: list[tuple[Transaction, Transaction]] = []
|
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):
|
for a_id in sorted(kandidaten):
|
||||||
if a_id in verbraucht:
|
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)
|
gegen = _eindeutig(a, offen, kandidaten, verbraucht)
|
||||||
if gegen is None:
|
if gegen is None:
|
||||||
if offen:
|
if offen:
|
||||||
vorschlaege.append((a, offen[0]))
|
schlage_vor(a, offen[0])
|
||||||
continue
|
continue
|
||||||
# Automatisch nur ohne Kategorie und mit Hinweis – siehe _hat_hinweis.
|
# Automatisch nur ohne Kategorie und mit Hinweis – siehe _hat_hinweis.
|
||||||
if a.category_id is not None or gegen.category_id is not None:
|
if a.category_id is not None or gegen.category_id is not None:
|
||||||
vorschlaege.append((a, gegen))
|
schlage_vor(a, gegen)
|
||||||
continue
|
continue
|
||||||
if not _hat_hinweis(a, gegen, bezeichner, typen):
|
if not _hat_hinweis(a, gegen, bezeichner, typen):
|
||||||
vorschlaege.append((a, gegen))
|
schlage_vor(a, gegen)
|
||||||
continue
|
continue
|
||||||
if nur_vorschlaege:
|
if nur_vorschlaege:
|
||||||
vorschlaege.append((a, gegen))
|
schlage_vor(a, gegen)
|
||||||
continue
|
continue
|
||||||
verbinde(db, user, a, gegen, automatisch=True)
|
verbinde(db, user, a, gegen, automatisch=True)
|
||||||
verbraucht.update((a.id, gegen.id))
|
verbraucht.update((a.id, gegen.id))
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Echte Antworten des Backends als Prüfdatei für die iOS-Modelle.
|
||||||
|
|
||||||
|
Aufruf aus ``backend/``::
|
||||||
|
|
||||||
|
.venv/bin/python -m scripts.erzeuge_ios_fixture
|
||||||
|
|
||||||
|
Schreibt ``ios/Tests/Fixtures/antworten.json``. Der Swift-Test
|
||||||
|
``Serverantworten`` entschlüsselt daraus jedes Modell und schlägt fehl, sobald
|
||||||
|
ein Feldname nicht mehr passt.
|
||||||
|
|
||||||
|
**Warum es das gibt.** Die iOS-App wird gegen Modelle geschrieben, die jemand
|
||||||
|
aus den Pydantic-Schemas abgeschrieben hat. Ein vertippter Feldname fällt beim
|
||||||
|
Übersetzen nicht auf -- er fällt erst dem Nutzer auf, wenn die Liste leer
|
||||||
|
bleibt. Diese Datei ist die einzige Prüfung, die das fängt.
|
||||||
|
|
||||||
|
Nach jeder Änderung an einem Schema neu laufen lassen und das Ergebnis
|
||||||
|
mit committen. Die Datenbank ist eine Wegwerfdatenbank; es landen keine
|
||||||
|
echten Daten in der Datei.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Muss vor dem Import von app stehen -- die Konfiguration wird beim Import gelesen.
|
||||||
|
os.environ["APP_DATA_DIR"] = tempfile.mkdtemp(prefix="cf-fixture-")
|
||||||
|
os.environ.setdefault("APP_ECB_FETCH_ON_START", "false")
|
||||||
|
os.environ.setdefault("APP_ADMIN_USERNAME", "admin")
|
||||||
|
os.environ.setdefault("APP_ADMIN_PASSWORD", "AdminPass1")
|
||||||
|
os.environ.setdefault("APP_SESSION_SECRET", "fixture-secret")
|
||||||
|
|
||||||
|
from datetime import date # noqa: E402
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient # noqa: E402
|
||||||
|
|
||||||
|
from app.main import app # noqa: E402
|
||||||
|
|
||||||
|
ZIEL = Path(__file__).resolve().parents[2] / "ios" / "Tests" / "Fixtures" / "antworten.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(c: TestClient) -> dict:
|
||||||
|
"""Genug Daten, dass jedes Feld einmal belegt ist.
|
||||||
|
|
||||||
|
Leere Listen pruefen nichts: Ein falscher Feldname in einer Zeile faellt
|
||||||
|
nur auf, wenn es eine Zeile gibt.
|
||||||
|
"""
|
||||||
|
c.put("/api/settings/budget_start_day", params={"value": "1"})
|
||||||
|
giro = c.post("/api/accounts", json={
|
||||||
|
"name": "Girokonto", "type": "girokonto", "currency": "EUR",
|
||||||
|
"opening_balance_cents": 120000}).json()
|
||||||
|
sparen = c.post("/api/accounts", json={
|
||||||
|
"name": "Sparen", "type": "sparkonto", "currency": "CHF",
|
||||||
|
"opening_balance_cents": 500000, "is_locked": True}).json()
|
||||||
|
|
||||||
|
gruppen = c.get("/api/categories").json()
|
||||||
|
ausgabe = next(g for g in gruppen if g["kind"] == "expense")["categories"][0]
|
||||||
|
einnahme = next(g for g in gruppen if g["kind"] == "income")["categories"][0]
|
||||||
|
|
||||||
|
tag = c.post("/api/tags", json={"name": "Urlaub", "color": "#2a78d6"}).json()
|
||||||
|
abo = c.post("/api/subscriptions", json={
|
||||||
|
"name": "Streaming", "amount_cents": 1299, "currency": "EUR",
|
||||||
|
"interval_months": 12, "category_id": ausgabe["id"]}).json()
|
||||||
|
|
||||||
|
heute = str(date.today())
|
||||||
|
kauf = c.post("/api/transactions", json={
|
||||||
|
"type": "expense", "date": heute, "account_id": giro["id"],
|
||||||
|
"amount_cents": 4250, "category_id": ausgabe["id"], "payee": "Supermarkt",
|
||||||
|
"description": "Wocheneinkauf", "notes": "mit Pfand"}).json()
|
||||||
|
c.patch(f"/api/transactions/{kauf['id']}", json={"tag_ids": [tag["id"]]})
|
||||||
|
c.post("/api/transactions", json={
|
||||||
|
"type": "income", "date": heute, "account_id": giro["id"],
|
||||||
|
"amount_cents": 350000, "category_id": einnahme["id"], "payee": "Arbeitgeber"})
|
||||||
|
c.post("/api/transactions", json={
|
||||||
|
"type": "transfer", "date": heute, "account_id": giro["id"],
|
||||||
|
"to_account_id": sparen["id"], "amount_cents": 20000})
|
||||||
|
|
||||||
|
# Zwei getrennte Buchungen, von Hand zu einem Uebertrag verknuepft -- der
|
||||||
|
# zweite Mechanismus, den die App nachbauen muss.
|
||||||
|
raus = c.post("/api/transactions", json={
|
||||||
|
"type": "expense", "date": heute, "account_id": giro["id"],
|
||||||
|
"amount_cents": 15000, "payee": "Ueberweisung"}).json()
|
||||||
|
rein = c.post("/api/transactions", json={
|
||||||
|
"type": "income", "date": heute, "account_id": sparen["id"],
|
||||||
|
"amount_cents": 15000, "payee": "Gutschrift"}).json()
|
||||||
|
c.post("/api/uebertraege/verbinden",
|
||||||
|
json={"a_id": raus["id"], "b_id": rein["id"]})
|
||||||
|
|
||||||
|
c.put(f"/api/budget/template/category/{ausgabe['id']}",
|
||||||
|
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"})
|
||||||
|
|
||||||
|
# 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",
|
||||||
|
"konten": "/api/accounts",
|
||||||
|
"kategoriegruppen": "/api/categories",
|
||||||
|
"schlagwoerter": "/api/tags",
|
||||||
|
"buchungen": "/api/transactions",
|
||||||
|
"uebertragsverknuepfungen": "/api/transactions/links",
|
||||||
|
"budget": "/api/budget/current",
|
||||||
|
"budgetvorlage": "/api/budget/template",
|
||||||
|
"abos": "/api/subscriptions",
|
||||||
|
"kontostaende": "/api/dashboard/balances",
|
||||||
|
"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",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
with TestClient(app) as c:
|
||||||
|
r = c.post("/api/auth/login",
|
||||||
|
data={"username": "admin", "password": "AdminPass1"})
|
||||||
|
if r.status_code != 200:
|
||||||
|
print("Anmeldung fehlgeschlagen:", r.text)
|
||||||
|
return 1
|
||||||
|
ids = _seed(c)
|
||||||
|
|
||||||
|
heraus = {}
|
||||||
|
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]}")
|
||||||
|
return 1
|
||||||
|
heraus[name] = antwort.json()
|
||||||
|
|
||||||
|
ZIEL.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
ZIEL.write_text(json.dumps(heraus, indent=1, ensure_ascii=False) + "\n",
|
||||||
|
encoding="utf-8")
|
||||||
|
print(f"{ZIEL} geschrieben, {len(heraus)} Antworten.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -399,3 +399,66 @@ def test_vorlage_nennt_betriebsart_und_zuschlag(admin):
|
|||||||
assert zeile["planned_cents"] == 12000
|
assert zeile["planned_cents"] == 12000
|
||||||
assert zeile["modus"] == "berechnet"
|
assert zeile["modus"] == "berechnet"
|
||||||
assert zeile["zuschlag_cents"] == 2500
|
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.
|
# Bestaetigen kann der Nutzer es trotzdem.
|
||||||
assert admin.post("/api/uebertraege/verbinden",
|
assert admin.post("/api/uebertraege/verbinden",
|
||||||
json={"a_id": raus["id"], "b_id": rein["id"]}).status_code == 200
|
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.
|
zweispaltig, offline der letzte Stand mit Datum.
|
||||||
|
|
||||||
Stufe 1 ✅: Gerüst, Netzwerkschicht, Anmeldung samt zweitem Faktor und
|
Stufe 1 ✅: Gerüst, Netzwerkschicht, Anmeldung samt zweitem Faktor und
|
||||||
Pflicht-Passwortwechsel, Kontenliste. Übersetzt fehlerfrei, 28 Tests grün,
|
Pflicht-Passwortwechsel, Kontenliste. Stufe 2 ✅: Übersicht, Budget-Monat,
|
||||||
läuft im Simulator; das Xcode-Projekt entsteht aus `ios/project.yml`.
|
Buchungsliste mit Suche, Vertragsliste. Stufe 3 ✅: Buchungen, Budget-Zeilen,
|
||||||
Stufen 2–7 (Lesen, Schreiben, Arbeitsliste, Nebenräume, Erweiterungen, voller
|
Verträge und Konten anlegen, ändern und löschen. Stufe 4 ✅: Arbeitsliste mit
|
||||||
CSV-Import) stehen aus.
|
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`.**
|
**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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Verträge und Abos: was jeden Monat von selbst abgeht.
|
||||||
|
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 {
|
||||||
|
if let lader {
|
||||||
|
Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { liste in
|
||||||
|
inhalt(liste)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(String(localized: "reiter.abos"))
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Menu {
|
||||||
|
Toggle(String(localized: "abos.beendeteZeigen"), isOn: $zeigeBeendete)
|
||||||
|
} label: {
|
||||||
|
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 }
|
||||||
|
let neuer = Lader<Aboliste>(schluessel: "subscriptions") {
|
||||||
|
try await client.abos()
|
||||||
|
}
|
||||||
|
lader = neuer
|
||||||
|
await neuer.lade()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func inhalt(_ liste: Aboliste) -> some View {
|
||||||
|
let sichtbar = liste.subscriptions.filter { zeigeBeendete || $0.isActive }
|
||||||
|
|
||||||
|
if sichtbar.isEmpty {
|
||||||
|
LeerAnsicht(titel: String(localized: "abos.leerTitel"),
|
||||||
|
erklaerung: String(localized: "abos.leerErklaerung"),
|
||||||
|
symbol: "arrow.triangle.2.circlepath")
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
SummenKopf(summen: liste.summary)
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
ForEach(sichtbar) { abo in
|
||||||
|
Button {
|
||||||
|
formular = .aendern(abo)
|
||||||
|
} label: {
|
||||||
|
AboZeile(abo: abo, nachschlag: zustand.nachschlag)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
.refreshable { await lader?.aktualisiere() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SummenKopf: View {
|
||||||
|
let summen: Abosummen
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||||
|
Text(summen.monatlich.formatiert())
|
||||||
|
.font(.title.weight(.semibold).monospacedDigit())
|
||||||
|
Text(String(localized: "abo.monatlich"))
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(String(format: String(localized: "abos.jaehrlichUndAnzahl"),
|
||||||
|
summen.jaehrlich.formatiert(), summen.anzahlAktiv))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
if !summen.ohneKurs.isEmpty {
|
||||||
|
// Ohne diesen Hinweis ist die Summe still zu klein, und
|
||||||
|
// niemand sieht, dass etwas fehlt.
|
||||||
|
Label {
|
||||||
|
Text(String(format: String(localized: "abos.ohneKurs"),
|
||||||
|
summen.ohneKurs.joined(separator: ", ")))
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: "exclamationmark.triangle")
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AboZeile: View {
|
||||||
|
let abo: Abo
|
||||||
|
let nachschlag: Nachschlag
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Symbolplakette(symbol: abo.icon, farbe: abo.color, groesse: 36)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(abo.name)
|
||||||
|
.font(.body)
|
||||||
|
.lineLimit(1)
|
||||||
|
if !abo.isActive {
|
||||||
|
Text(String(localized: "abos.beendet"))
|
||||||
|
.font(.caption2)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 1)
|
||||||
|
.background(.quaternary, in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(unterzeile)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
Text(abo.betrag.formatiert())
|
||||||
|
.font(.body.monospacedDigit())
|
||||||
|
// Nur dort nennen, wo es etwas sagt: Bei einem monatlichen
|
||||||
|
// Vertrag stuende zweimal derselbe Betrag.
|
||||||
|
if abo.intervalMonths != 1 {
|
||||||
|
Text(String(format: String(localized: "abo.proMonat"),
|
||||||
|
abo.monatlich.formatiert()))
|
||||||
|
.font(.caption2.monospacedDigit())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var unterzeile: String {
|
||||||
|
var teile = [abo.rhythmus]
|
||||||
|
if let kategorie = nachschlag.kategorie(abo.categoryId) {
|
||||||
|
teile.append(kategorie.name)
|
||||||
|
}
|
||||||
|
if abo.isActive, let faellig = abo.nextDue {
|
||||||
|
if let tage = abo.tageBisFaellig, tage <= 7 {
|
||||||
|
teile.append(String(format: String(localized: "abo.inTagen"), tage))
|
||||||
|
} else {
|
||||||
|
teile.append(String(format: String(localized: "abo.naechste"), faellig.formatiert()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return teile.joined(separator: " · ")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,12 +31,34 @@ final class AppZustand {
|
|||||||
private(set) var einstellungen = Einstellungen()
|
private(set) var einstellungen = Einstellungen()
|
||||||
private(set) var client: CertainClient?
|
private(set) var client: CertainClient?
|
||||||
|
|
||||||
|
/// Kategorien, Schlagwörter und Konten – einmal geladen, von allen
|
||||||
|
/// Listen benutzt. Jede Zeile will zu einer ID den Namen, das Symbol und
|
||||||
|
/// die Farbe; das je Ansicht neu zu holen wäre genau das N+1, das das
|
||||||
|
/// Backend sich selbst verbietet.
|
||||||
|
private(set) var nachschlag = Nachschlag.leer
|
||||||
|
|
||||||
/// Die letzte Meldung für den Anmeldebildschirm. Keine Fehleransicht,
|
/// Die letzte Meldung für den Anmeldebildschirm. Keine Fehleransicht,
|
||||||
/// sondern eine Zeile unter dem Formular.
|
/// sondern eine Zeile unter dem Formular.
|
||||||
var meldung: String?
|
var meldung: String?
|
||||||
/// Läuft gerade ein Anmeldeschritt? Sperrt die Knöpfe.
|
/// Läuft gerade ein Anmeldeschritt? Sperrt die Knöpfe.
|
||||||
private(set) var beschaeftigt = false
|
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 }
|
var serverAdresse: URL? { Ablage.serverAdresse }
|
||||||
|
|
||||||
// MARK: - Start
|
// MARK: - Start
|
||||||
@@ -48,6 +70,9 @@ final class AppZustand {
|
|||||||
}
|
}
|
||||||
let neuer = CertainClient(basis: adresse, marke: Ablage.marke)
|
let neuer = CertainClient(basis: adresse, marke: Ablage.marke)
|
||||||
client = neuer
|
client = neuer
|
||||||
|
// Kaltstart: wer eine PIN hat, sieht zuerst den Sperrbildschirm.
|
||||||
|
hatPIN = Ablage.hatPIN
|
||||||
|
gesperrt = hatPIN
|
||||||
guard Ablage.marke != nil else {
|
guard Ablage.marke != nil else {
|
||||||
lage = .brauchtAnmeldung
|
lage = .brauchtAnmeldung
|
||||||
return
|
return
|
||||||
@@ -61,8 +86,12 @@ final class AppZustand {
|
|||||||
guard let client else { return }
|
guard let client else { return }
|
||||||
do {
|
do {
|
||||||
let ich = try await client.ichSelbst()
|
let ich = try await client.ichSelbst()
|
||||||
|
// Muss gemerkt werden, sonst greift der Offline-Zweig unten nie:
|
||||||
|
// Niemand sonst schreibt unter diesem Schlüssel.
|
||||||
|
Zwischenspeicher.lege(ich, unter: "auth/me")
|
||||||
einstellungen = (try? await client.einstellungen()) ?? Einstellungen()
|
einstellungen = (try? await client.einstellungen()) ?? Einstellungen()
|
||||||
lage = .bereit(ich)
|
lage = .bereit(ich)
|
||||||
|
await ladeNachschlag()
|
||||||
} catch APIFehler.nichtAngemeldet {
|
} catch APIFehler.nichtAngemeldet {
|
||||||
// Marke widerrufen oder gelöscht – neu anmelden, Adresse behalten.
|
// Marke widerrufen oder gelöscht – neu anmelden, Adresse behalten.
|
||||||
Ablage.marke = nil
|
Ablage.marke = nil
|
||||||
@@ -77,6 +106,7 @@ final class AppZustand {
|
|||||||
// Ansichten den letzten Stand. Die Marke ist ja da.
|
// Ansichten den letzten Stand. Die Marke ist ja da.
|
||||||
if let gemerkt = Zwischenspeicher.hole(Benutzer.self, unter: "auth/me") {
|
if let gemerkt = Zwischenspeicher.hole(Benutzer.self, unter: "auth/me") {
|
||||||
lage = .bereit(gemerkt.wert)
|
lage = .bereit(gemerkt.wert)
|
||||||
|
await ladeNachschlag()
|
||||||
} else {
|
} else {
|
||||||
meldung = error.localizedDescription
|
meldung = error.localizedDescription
|
||||||
lage = .brauchtAnmeldung
|
lage = .brauchtAnmeldung
|
||||||
@@ -226,6 +256,9 @@ final class AppZustand {
|
|||||||
let adresse = adresseBehalten ? Ablage.serverAdresse : nil
|
let adresse = adresseBehalten ? Ablage.serverAdresse : nil
|
||||||
Ablage.raeumeAuf()
|
Ablage.raeumeAuf()
|
||||||
Ablage.serverAdresse = adresse
|
Ablage.serverAdresse = adresse
|
||||||
|
hatPIN = false
|
||||||
|
gesperrt = false
|
||||||
|
fehlversuche = 0
|
||||||
if let adresse {
|
if let adresse {
|
||||||
client = CertainClient(basis: adresse)
|
client = CertainClient(basis: adresse)
|
||||||
lage = .brauchtAnmeldung
|
lage = .brauchtAnmeldung
|
||||||
@@ -235,11 +268,67 @@ 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 {
|
func aktualisiereEinstellungen() async {
|
||||||
guard let client else { return }
|
guard let client else { return }
|
||||||
if let frisch = try? await client.einstellungen() { einstellungen = frisch }
|
if let frisch = try? await client.einstellungen() { einstellungen = frisch }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Holt das Nachschlagewerk. Geht das schief, bleibt der letzte Stand –
|
||||||
|
/// eine Buchungsliste ohne Kategorienamen wäre schlechter als eine mit
|
||||||
|
/// alten.
|
||||||
|
func ladeNachschlag() async {
|
||||||
|
guard let client else { return }
|
||||||
|
if let frisch = try? await client.nachschlag() {
|
||||||
|
Zwischenspeicher.lege(frisch, unter: "nachschlag")
|
||||||
|
nachschlag = frisch
|
||||||
|
} else if nachschlag.gruppen.isEmpty,
|
||||||
|
let gemerkt = Zwischenspeicher.hole(Nachschlag.self, unter: "nachschlag") {
|
||||||
|
nachschlag = gemerkt.wert
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Kleinkram
|
// MARK: - Kleinkram
|
||||||
|
|
||||||
/// Der Name, unter dem die Marke am Rechner auftaucht. Soll erkennbar
|
/// Der Name, unter dem die Marke am Rechner auftaucht. Soll erkennbar
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import SwiftUI
|
|||||||
@main
|
@main
|
||||||
struct CertainApp: App {
|
struct CertainApp: App {
|
||||||
@State private var zustand = AppZustand()
|
@State private var zustand = AppZustand()
|
||||||
|
@Environment(\.scenePhase) private var phase
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
// Das Erscheinungsbild kommt aus den Server-Einstellungen, damit App
|
// Das Erscheinungsbild kommt aus den Server-Einstellungen, damit App
|
||||||
@@ -19,6 +20,19 @@ struct CertainApp: App {
|
|||||||
.environment(zustand)
|
.environment(zustand)
|
||||||
.task { await zustand.starte() }
|
.task { await zustand.starte() }
|
||||||
.preferredColorScheme(schema)
|
.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,19 +28,43 @@ struct RootAnsicht: View {
|
|||||||
ZweitenFaktorEinrichtenHinweis()
|
ZweitenFaktorEinrichtenHinweis()
|
||||||
|
|
||||||
case .bereit:
|
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()
|
Hauptnavigation()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Die Reiter. Auf dem iPhone unten, auf dem iPad als Seitenleiste – das
|
/// Die Reiter. Auf dem iPhone unten, auf dem iPad als Seitenleiste – das
|
||||||
/// erledigt `.sidebarAdaptable` von selbst.
|
/// erledigt `.sidebarAdaptable` von selbst.
|
||||||
///
|
///
|
||||||
/// Stufe 1 hat erst zwei Einträge. Die weiteren kommen hier dazu, die
|
/// Reihenfolge nach Häufigkeit, nicht nach Umfang: Das Dashboard beantwortet
|
||||||
/// Anordnung bleibt.
|
/// die Frage, wegen der man die App öffnet. Konten und Einstellungen stehen
|
||||||
|
/// hinten, weil man sie selten braucht.
|
||||||
struct Hauptnavigation: View {
|
struct Hauptnavigation: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView {
|
TabView {
|
||||||
|
Tab(String(localized: "reiter.dashboard"), systemImage: "square.grid.2x2") {
|
||||||
|
NavigationStack { DashboardAnsicht() }
|
||||||
|
}
|
||||||
|
Tab(String(localized: "reiter.budget"), systemImage: "chart.pie") {
|
||||||
|
NavigationStack { BudgetAnsicht() }
|
||||||
|
}
|
||||||
|
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() }
|
||||||
|
}
|
||||||
Tab(String(localized: "reiter.konten"), systemImage: "wallet.bifold") {
|
Tab(String(localized: "reiter.konten"), systemImage: "wallet.bifold") {
|
||||||
NavigationStack { KontenListe() }
|
NavigationStack { KontenListe() }
|
||||||
}
|
}
|
||||||
|
|||||||
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.
|
/// Übersetzt die Symbolnamen aus der Datenbank in SF Symbols.
|
||||||
///
|
///
|
||||||
/// Die Weboberfläche benutzt Lucide-Namen und hält dafür ein eigenes
|
/// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Buchungen und die Paarliste in einem Stand.
|
||||||
|
///
|
||||||
|
/// Beides gehört zusammen: Ohne die Paare zeigt die Liste beide Beine eines
|
||||||
|
/// Übertrags als zwei Zeilen und damit dasselbe Geld zweimal.
|
||||||
|
struct Buchungsstand: Codable, Sendable, Hashable {
|
||||||
|
let buchungen: [Buchung]
|
||||||
|
let paare: [String: Uebertragsverknuepfung]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Buchungsliste.
|
||||||
|
///
|
||||||
|
/// Gesucht und gefiltert wird im **Server** – seit der Backend-Ergänzung
|
||||||
|
/// kennt `GET /transactions` `q`, `tag_id` und `subscription_id`. Die
|
||||||
|
/// Weboberfläche lädt bis zu 5000 Zeilen und siebt im Browser; auf dem
|
||||||
|
/// Telefon wäre das der Unterschied zwischen „lädt" und „benutzbar".
|
||||||
|
struct BuchungsListe: View {
|
||||||
|
@Environment(AppZustand.self) private var zustand
|
||||||
|
|
||||||
|
@State private var lader: Lader<Buchungsstand>?
|
||||||
|
@State private var suche = ""
|
||||||
|
@State private var konto: Int?
|
||||||
|
@State private var art: Buchungsart?
|
||||||
|
/// 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 {
|
||||||
|
if let lader {
|
||||||
|
Inhalt(zustand: lader.zustand, nochmal: { Task { await lade() } }) { stand in
|
||||||
|
liste(stand)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(String(localized: "reiter.buchungen"))
|
||||||
|
.searchable(text: $suche, prompt: String(localized: "buchungen.suchen"))
|
||||||
|
// Erst beim Absenden abrufen. Je Tastendruck zu fragen hiesse, dem
|
||||||
|
// Server bei „Supermarkt" zehn Abrufe zu schicken, von denen neun
|
||||||
|
// niemanden interessieren.
|
||||||
|
.onSubmit(of: .search) { Task { await lade() } }
|
||||||
|
.onChange(of: suche) { _, neu in
|
||||||
|
if neu.isEmpty, !abgerufen.isEmpty { Task { await lade() } }
|
||||||
|
}
|
||||||
|
.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 }
|
||||||
|
await lade()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var filterMenue: some View {
|
||||||
|
Menu {
|
||||||
|
Picker(String(localized: "buchungen.konto"), selection: $konto) {
|
||||||
|
Text(String(localized: "buchungen.alleKonten")).tag(Int?.none)
|
||||||
|
ForEach(zustand.nachschlag.konten.filter { !$0.isArchived }) { eines in
|
||||||
|
Text(eines.name).tag(Int?.some(eines.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Picker(String(localized: "buchungen.art"), selection: $art) {
|
||||||
|
Text(String(localized: "buchungen.alleArten")).tag(Buchungsart?.none)
|
||||||
|
ForEach(Buchungsart.allCases) { eine in
|
||||||
|
Text(eine.name).tag(Buchungsart?.some(eine))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: gefiltert ? "line.3.horizontal.decrease.circle.fill"
|
||||||
|
: "line.3.horizontal.decrease.circle")
|
||||||
|
}
|
||||||
|
.onChange(of: konto) { _, _ in Task { await lade() } }
|
||||||
|
.onChange(of: art) { _, _ in Task { await lade() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var gefiltert: Bool { konto != nil || art != nil || !abgerufen.isEmpty }
|
||||||
|
|
||||||
|
private func lade() async {
|
||||||
|
guard let client = zustand.client else { return }
|
||||||
|
let text = suche.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let gewaehltesKonto = konto
|
||||||
|
let gewaehlteArt = art
|
||||||
|
abgerufen = text
|
||||||
|
|
||||||
|
// Der Schlüssel trägt die Filter: Sonst überschriebe eine gefilterte
|
||||||
|
// Liste den Offline-Stand der vollständigen.
|
||||||
|
let schluessel = "transactions_\(gewaehltesKonto ?? 0)_\(gewaehlteArt?.rawValue ?? "alle")_\(text)"
|
||||||
|
let neuer = Lader<Buchungsstand>(schluessel: schluessel) {
|
||||||
|
async let buchungen = client.buchungen(konto: gewaehltesKonto, art: gewaehlteArt,
|
||||||
|
suche: text.isEmpty ? nil : text)
|
||||||
|
async let paare = client.uebertragsverknuepfungen()
|
||||||
|
return try await Buchungsstand(buchungen: buchungen, paare: paare)
|
||||||
|
}
|
||||||
|
lader = neuer
|
||||||
|
await neuer.lade()
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func liste(_ stand: Buchungsstand) -> some View {
|
||||||
|
let zeilen = Buchungszeile.zeilen(aus: stand.buchungen, paare: stand.paare)
|
||||||
|
|
||||||
|
if zeilen.isEmpty {
|
||||||
|
LeerAnsicht(titel: String(localized: "buchungen.leerTitel"),
|
||||||
|
erklaerung: String(localized: gefiltert ? "buchungen.leerGefiltert"
|
||||||
|
: "buchungen.leerErklaerung"),
|
||||||
|
symbol: "list.bullet.rectangle")
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
.refreshable { await lader?.aktualisiere() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nach Tagen gruppiert, Reihenfolge wie geliefert (Datum absteigend).
|
||||||
|
private func nachTagen(_ zeilen: [Buchungszeile]) -> [(tag: Tagesdatum, zeilen: [Buchungszeile])] {
|
||||||
|
var reihenfolge: [Tagesdatum] = []
|
||||||
|
var nachTag: [Tagesdatum: [Buchungszeile]] = [:]
|
||||||
|
for zeile in zeilen {
|
||||||
|
let tag = zeile.buchung.date
|
||||||
|
if nachTag[tag] == nil { reihenfolge.append(tag) }
|
||||||
|
nachTag[tag, default: []].append(zeile)
|
||||||
|
}
|
||||||
|
return reihenfolge.map { (tag: $0, zeilen: nachTag[$0] ?? []) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Eine Zeile
|
||||||
|
|
||||||
|
private struct BuchungsZeile: View {
|
||||||
|
let zeile: Buchungszeile
|
||||||
|
let nachschlag: Nachschlag
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Symbolplakette(symbol: symbol, farbe: farbe, groesse: 34)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(ueberschrift)
|
||||||
|
.font(.callout)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(unterzeile)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
ForEach(zeile.buchung.tagIds, id: \.self) { tagId in
|
||||||
|
if let etikett = nachschlag.schlagwort(tagId) {
|
||||||
|
Text(etikett.name)
|
||||||
|
.font(.caption2)
|
||||||
|
.padding(.horizontal, 5)
|
||||||
|
.padding(.vertical, 1)
|
||||||
|
.background(Color(hex: etikett.color).opacity(0.18), in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
|
Text(zeile.buchung.betrag.formatiert(vorzeichen: .ohne))
|
||||||
|
.font(.callout.monospacedDigit())
|
||||||
|
.foregroundStyle(betragsfarbe)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var kategorie: Kategorie? { nachschlag.kategorie(zeile.buchung.categoryId) }
|
||||||
|
|
||||||
|
private var symbol: String {
|
||||||
|
if zeile.istUebertrag { return "arrow-left-right" }
|
||||||
|
return kategorie?.icon ?? "more-horizontal"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var farbe: String {
|
||||||
|
if zeile.istUebertrag { return "#6b7480" }
|
||||||
|
return kategorie?.color ?? "#6b7480"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bei einem Übertrag sagt die Zeile, wohin – „Übertrag" allein sagt
|
||||||
|
/// nichts. Sonst steht da der Empfänger.
|
||||||
|
private var ueberschrift: String {
|
||||||
|
guard zeile.istUebertrag else { return zeile.buchung.ueberschrift }
|
||||||
|
let von = nachschlag.kontoname(zeile.vonKonto)
|
||||||
|
guard let nach = zeile.nachKonto else {
|
||||||
|
return String(localized: "buchung.uebertrag")
|
||||||
|
}
|
||||||
|
return "\(von) → \(nachschlag.kontoname(nach))"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var unterzeile: String {
|
||||||
|
if zeile.istUebertrag {
|
||||||
|
return zeile.buchung.unterschrift ?? String(localized: "buchung.uebertrag")
|
||||||
|
}
|
||||||
|
var teile: [String] = []
|
||||||
|
if let kategorie { teile.append(kategorie.name) }
|
||||||
|
else { teile.append(String(localized: "buchung.ohneKategorie")) }
|
||||||
|
teile.append(nachschlag.kontoname(zeile.buchung.accountId))
|
||||||
|
return teile.joined(separator: " · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grün bei Einnahme, neutral beim Übertrag: Da verlässt kein Geld den
|
||||||
|
/// Haushalt, es wechselt nur die Tasche.
|
||||||
|
private var betragsfarbe: Color {
|
||||||
|
if zeile.istUebertrag { return .secondary }
|
||||||
|
return zeile.buchung.type == .income ? .green : .primary
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Soll gegen Ist, Monat für Monat.
|
||||||
|
///
|
||||||
|
/// Geblättert wird über `prev` und `next` aus der Antwort – welcher Monat auf
|
||||||
|
/// welchen folgt, hängt am eingestellten Budget-Starttag und ist nichts, was
|
||||||
|
/// der Client ausrechnen sollte.
|
||||||
|
struct BudgetAnsicht: View {
|
||||||
|
@Environment(AppZustand.self) private var zustand
|
||||||
|
/// `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 {
|
||||||
|
if let lader {
|
||||||
|
Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { monat in
|
||||||
|
liste(monat)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(String(localized: "reiter.budget"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.task(id: label) { await ladeMonat() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ladeMonat() async {
|
||||||
|
guard let client = zustand.client else { return }
|
||||||
|
let gewaehlt = label
|
||||||
|
let neuer = Lader<Budgetmonat>(schluessel: "budget/\(gewaehlt ?? "current")") {
|
||||||
|
try await client.budget(gewaehlt)
|
||||||
|
}
|
||||||
|
lader = neuer
|
||||||
|
await neuer.lade()
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func liste(_ monat: Budgetmonat) -> some View {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
MonatsKopf(monat: monat)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !monat.income.isEmpty {
|
||||||
|
Section(String(localized: "budget.einnahmen")) {
|
||||||
|
ForEach(monat.income) { posten in
|
||||||
|
EinnahmeZeile(posten: posten, waehrung: monat.baseCurrency)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !monat.vertraege.istLeer {
|
||||||
|
Section {
|
||||||
|
VertragsHinweis(reste: monat.vertraege, waehrung: monat.baseCurrency)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
.refreshable { await lader?.aktualisiere() }
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button {
|
||||||
|
label = monat.prev
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
}
|
||||||
|
.accessibilityLabel(String(localized: "budget.vorigerMonat"))
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .principal) {
|
||||||
|
Text(monatsname(monat))
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
label = monat.next
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
}
|
||||||
|
.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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// „Oktober 2026" statt „2026-10".
|
||||||
|
private func monatsname(_ monat: Budgetmonat) -> String {
|
||||||
|
monat.start.datum.formatted(.dateTime.month(.wide).year())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Bausteine
|
||||||
|
|
||||||
|
private struct MonatsKopf: View {
|
||||||
|
let monat: Budgetmonat
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
Text(Geldbetrag(cents: monat.restCents, waehrung: monat.baseCurrency).formatiert())
|
||||||
|
.font(.title.weight(.semibold).monospacedDigit())
|
||||||
|
.foregroundStyle(monat.restCents < 0 ? .red : .primary)
|
||||||
|
Text(String(localized: "budget.uebrig"))
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
ProgressView(value: monat.anteilVerbraucht)
|
||||||
|
.tint(monat.restCents < 0 ? .red : .accentColor)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text(String(format: String(localized: "budget.vonGeplant"),
|
||||||
|
Geldbetrag(cents: monat.totals.actualExpense,
|
||||||
|
waehrung: monat.baseCurrency).formatiert(),
|
||||||
|
Geldbetrag(cents: monat.totals.plannedExpense,
|
||||||
|
waehrung: monat.baseCurrency).formatiert()))
|
||||||
|
Spacer()
|
||||||
|
if monat.customized {
|
||||||
|
// Wer das nicht sieht, wundert sich, warum die Vorlage
|
||||||
|
// andere Zahlen zeigt als dieser Monat.
|
||||||
|
Text(String(localized: "budget.angepasst"))
|
||||||
|
.font(.caption2)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(.quaternary, in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct GruppenKopf: View {
|
||||||
|
let gruppe: Budgetgruppe
|
||||||
|
let waehrung: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: Symbole.sf(gruppe.icon))
|
||||||
|
.foregroundStyle(Color(hex: gruppe.color))
|
||||||
|
Text(gruppe.name)
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
Text(Geldbetrag(cents: gruppe.actualCents, waehrung: waehrung).formatiert())
|
||||||
|
.monospacedDigit()
|
||||||
|
.foregroundStyle(gruppe.over && !gruppe.good ? .red : .secondary)
|
||||||
|
Text("/")
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
Text(Geldbetrag(cents: gruppe.plannedCents, waehrung: waehrung).formatiert())
|
||||||
|
.monospacedDigit()
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.textCase(nil)
|
||||||
|
.font(.footnote)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct KategorieZeile: View {
|
||||||
|
let kategorie: Budgetkategorie
|
||||||
|
let waehrung: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Symbolplakette(symbol: kategorie.icon, farbe: kategorie.color, groesse: 30)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(kategorie.name)
|
||||||
|
.font(.callout)
|
||||||
|
.lineLimit(1)
|
||||||
|
if let unterzeile {
|
||||||
|
Text(unterzeile)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
|
VStack(alignment: .trailing, spacing: 1) {
|
||||||
|
Text(Geldbetrag(cents: kategorie.actualCents, waehrung: waehrung).formatiert())
|
||||||
|
.font(.callout.monospacedDigit())
|
||||||
|
.foregroundStyle(warnt ? .red : .primary)
|
||||||
|
Text(String(format: String(localized: "budget.vonBetrag"),
|
||||||
|
Geldbetrag(cents: kategorie.plannedCents,
|
||||||
|
waehrung: waehrung).formatiert()))
|
||||||
|
.font(.caption2.monospacedDigit())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if kategorie.plannedCents > 0 || kategorie.actualCents > 0 {
|
||||||
|
ProgressView(value: kategorie.anteilVerbraucht)
|
||||||
|
.tint(warnt ? .red : (kategorie.good ? .green : Color(hex: kategorie.color)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rot nur bei echter Überschreitung: Mehr sparen als geplant ist keine.
|
||||||
|
private var warnt: Bool { kategorie.over && !kategorie.good }
|
||||||
|
|
||||||
|
/// Woher der Soll-Wert kommt, wenn er sich nicht einfach ablesen lässt.
|
||||||
|
private var unterzeile: String? {
|
||||||
|
guard kategorie.vertraegeAnzahl > 0 else { return nil }
|
||||||
|
let ausVertraegen = Geldbetrag(cents: kategorie.ausVertraegenCents,
|
||||||
|
waehrung: waehrung).formatiert()
|
||||||
|
if kategorie.istBerechnet {
|
||||||
|
return String(format: String(localized: "budget.ausVertraegenBerechnet"),
|
||||||
|
kategorie.vertraegeAnzahl, ausVertraegen)
|
||||||
|
}
|
||||||
|
return String(format: String(localized: "budget.ausVertraegenHinweis"),
|
||||||
|
kategorie.vertraegeAnzahl, ausVertraegen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct EinnahmeZeile: View {
|
||||||
|
let posten: GeplanteEinnahme
|
||||||
|
let waehrung: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Text(posten.label)
|
||||||
|
.font(.callout)
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
Text(Geldbetrag(cents: posten.receivedCents, waehrung: waehrung).formatiert())
|
||||||
|
.font(.callout.monospacedDigit())
|
||||||
|
.foregroundStyle(posten.receivedCents >= posten.plannedCents ? .green : .primary)
|
||||||
|
Text("/")
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
Text(Geldbetrag(cents: posten.plannedCents, waehrung: waehrung).formatiert())
|
||||||
|
.font(.caption.monospacedDigit())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verträge, die im Budget fehlen – und Währungen, für die kein Kurs vorlag.
|
||||||
|
///
|
||||||
|
/// Ohne diesen Hinweis stimmen die Summen still nicht. Genau davor warnt das
|
||||||
|
/// Backend mit den `ohne_kurs`-Listen.
|
||||||
|
private struct VertragsHinweis: View {
|
||||||
|
let reste: Vertragsreste
|
||||||
|
let waehrung: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
if reste.ohneKategorieAnzahl > 0 {
|
||||||
|
Label {
|
||||||
|
Text(String(format: String(localized: "budget.vertraegeOhneKategorie"),
|
||||||
|
reste.ohneKategorieAnzahl,
|
||||||
|
Geldbetrag(cents: reste.ohneKategorieCents,
|
||||||
|
waehrung: waehrung).formatiert()))
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: "questionmark.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !reste.ohneKurs.isEmpty {
|
||||||
|
Label {
|
||||||
|
Text(String(format: String(localized: "budget.ohneKurs"),
|
||||||
|
reste.ohneKurs.joined(separator: ", ")))
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: "exclamationmark.triangle")
|
||||||
|
}
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.footnote)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,29 +7,56 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* 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 */; };
|
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 */; };
|
18B6A9629AA749B790F21DC9 /* CertainApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A1BD38230CAE03DFA02A3F0 /* CertainApp.swift */; };
|
||||||
1E0AE92B8994298A0468E4EC /* ZweiFaktorAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.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 */; };
|
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 */; };
|
3E574E1D2C0CDB24C652190C /* ServerAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */; };
|
||||||
3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78AD0B83BDFDF777917C9382 /* KontenListe.swift */; };
|
3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78AD0B83BDFDF777917C9382 /* KontenListe.swift */; };
|
||||||
402F07ED1E39AB8C3BC6F62B /* AppZustand.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9FE5CE135C1B9607C47BCE9 /* AppZustand.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 */; };
|
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 */; };
|
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 */; };
|
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 */; };
|
7A5BD17CBEB8E7E61BEC6396 /* GrundlagenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23B6CF6893EB57064D36F609 /* GrundlagenTests.swift */; };
|
||||||
7AFCD4ADF85329F373C89E06 /* Zeitangaben.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75EA409F3F0CEF3DF04C32E7 /* Zeitangaben.swift */; };
|
7AFCD4ADF85329F373C89E06 /* Zeitangaben.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75EA409F3F0CEF3DF04C32E7 /* Zeitangaben.swift */; };
|
||||||
|
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 */; };
|
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 */; };
|
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 */; };
|
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 */; };
|
B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB439591BFEABB503F2E071 /* RootAnsicht.swift */; };
|
||||||
BEC6BF023D4A649003F38A2D /* Endpunkte.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D6CEAB46FB8955115A3064B /* Endpunkte.swift */; };
|
BEC6BF023D4A649003F38A2D /* Endpunkte.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D6CEAB46FB8955115A3064B /* Endpunkte.swift */; };
|
||||||
C518581A7F62632420821479 /* APIFehler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5C59F93E8CD995DFDCABF90 /* APIFehler.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 */; };
|
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 */; };
|
E49213EDDBF2C0C7995AF558 /* AnmeldeAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */; };
|
||||||
E5F283F445FA9E26CFC15FA5 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BDF076C7F062202810699EE9 /* Localizable.strings */; };
|
E5F283F445FA9E26CFC15FA5 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BDF076C7F062202810699EE9 /* Localizable.strings */; };
|
||||||
EE4F2382CFF3F60FF5AC8241 /* CertainClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E5A58246E6A9D72E99C2F2 /* CertainClient.swift */; };
|
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 */; };
|
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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -44,30 +71,57 @@
|
|||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
09B6D688EF69B6EC58A8B4BE /* EinstellungenAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EinstellungenAnsicht.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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; };
|
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>"; };
|
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>"; };
|
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; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
F8BE152C9F91584900796ADA /* Grundmodelle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Grundmodelle.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
@@ -84,6 +138,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
78AD0B83BDFDF777917C9382 /* KontenListe.swift */,
|
78AD0B83BDFDF777917C9382 /* KontenListe.swift */,
|
||||||
|
4AFC43300F101BE4F4541BA2 /* KontoFormular.swift */,
|
||||||
);
|
);
|
||||||
path = Konten;
|
path = Konten;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -92,6 +147,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
23B6CF6893EB57064D36F609 /* GrundlagenTests.swift */,
|
23B6CF6893EB57064D36F609 /* GrundlagenTests.swift */,
|
||||||
|
7DD1C597D594D0F965FF1547 /* Fixtures */,
|
||||||
);
|
);
|
||||||
path = Tests;
|
path = Tests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -108,28 +164,54 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
451BA41D9B53FB6B511AF734 /* Darstellung.swift */,
|
451BA41D9B53FB6B511AF734 /* Darstellung.swift */,
|
||||||
|
778040646D05B1DEEE3BF727 /* Formular.swift */,
|
||||||
287D314176EBA5E888605F5F /* Geldbetrag.swift */,
|
287D314176EBA5E888605F5F /* Geldbetrag.swift */,
|
||||||
776F26865B849D2318B6819B /* Zustandsansichten.swift */,
|
776F26865B849D2318B6819B /* Zustandsansichten.swift */,
|
||||||
);
|
);
|
||||||
path = Bausteine;
|
path = Bausteine;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
7DD1C597D594D0F965FF1547 /* Fixtures */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
3137E525D1ADE3DB2F8E45A8 /* antworten.json */,
|
||||||
|
);
|
||||||
|
path = Fixtures;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
80EFC9A43394C64E9AEEF5A1 /* Modelle */ = {
|
80EFC9A43394C64E9AEEF5A1 /* Modelle */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
E8CBA42A030509615199DD45 /* Abo.swift */,
|
||||||
|
0A2E765F9D8DDC8D46A1BDD6 /* Arbeitsliste.swift */,
|
||||||
|
327787E4869ED7D0C7E9EE63 /* Buchung.swift */,
|
||||||
|
E51DE5BDB909910D586F819F /* Budget.swift */,
|
||||||
|
BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */,
|
||||||
|
647E9AA0BFEDCAFF98BADB9C /* Eingaben.swift */,
|
||||||
F8BE152C9F91584900796ADA /* Grundmodelle.swift */,
|
F8BE152C9F91584900796ADA /* Grundmodelle.swift */,
|
||||||
|
B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */,
|
||||||
4119B85EFF3FB38E84FAA913 /* Konto.swift */,
|
4119B85EFF3FB38E84FAA913 /* Konto.swift */,
|
||||||
|
3114763A11EC90E1D38C9DEF /* Nebenraeume.swift */,
|
||||||
75EA409F3F0CEF3DF04C32E7 /* Zeitangaben.swift */,
|
75EA409F3F0CEF3DF04C32E7 /* Zeitangaben.swift */,
|
||||||
);
|
);
|
||||||
path = Modelle;
|
path = Modelle;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
AE11D36E2DFAC34130E2868F /* Dashboard */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
09BBFDDFA7A1FFC8785925BE /* DashboardAnsicht.swift */,
|
||||||
|
);
|
||||||
|
path = Dashboard;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
B4505C6E527A0020B80AFF04 /* Anmeldung */ = {
|
B4505C6E527A0020B80AFF04 /* Anmeldung */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */,
|
DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */,
|
||||||
52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */,
|
52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */,
|
||||||
A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */,
|
A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */,
|
||||||
|
3C618BE3CBCEBBF65F79A63D /* SperrAnsicht.swift */,
|
||||||
A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.swift */,
|
A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.swift */,
|
||||||
);
|
);
|
||||||
path = Anmeldung;
|
path = Anmeldung;
|
||||||
@@ -138,12 +220,19 @@
|
|||||||
BC82A6A323BDD2AD8325BEA2 = {
|
BC82A6A323BDD2AD8325BEA2 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
D42C757A9D818A524C4CAC8A /* AppIcon.icon */,
|
||||||
|
CB7098BB5F4AEFE48BA9BB36 /* Abos */,
|
||||||
B4505C6E527A0020B80AFF04 /* Anmeldung */,
|
B4505C6E527A0020B80AFF04 /* Anmeldung */,
|
||||||
BEE6BC97D865C2F0C4EB80C4 /* App */,
|
BEE6BC97D865C2F0C4EB80C4 /* App */,
|
||||||
|
FAFA069DDABF0E56CAF24C4F /* Arbeitsliste */,
|
||||||
5E720D91F0CF9DBDA975A959 /* Bausteine */,
|
5E720D91F0CF9DBDA975A959 /* Bausteine */,
|
||||||
|
D99101183ED9DA14C8D22722 /* Buchungen */,
|
||||||
|
C4B3E718A814E0236A12EB86 /* Budget */,
|
||||||
|
AE11D36E2DFAC34130E2868F /* Dashboard */,
|
||||||
56CB7B1C18136BFD83ACADBD /* Einstellungen */,
|
56CB7B1C18136BFD83ACADBD /* Einstellungen */,
|
||||||
298E43BBA9248E5A9779DA98 /* Konten */,
|
298E43BBA9248E5A9779DA98 /* Konten */,
|
||||||
80EFC9A43394C64E9AEEF5A1 /* Modelle */,
|
80EFC9A43394C64E9AEEF5A1 /* Modelle */,
|
||||||
|
F77DCF8FF4A1F732F2FA3EA8 /* Nebenraeume */,
|
||||||
DB51F54CC077E85C286AD9DC /* Netzwerk */,
|
DB51F54CC077E85C286AD9DC /* Netzwerk */,
|
||||||
524FD85EDE7C4DE5ABE9A8E5 /* Tests */,
|
524FD85EDE7C4DE5ABE9A8E5 /* Tests */,
|
||||||
0EDDD922C06B1465AD13C782 /* Texte */,
|
0EDDD922C06B1465AD13C782 /* Texte */,
|
||||||
@@ -170,6 +259,33 @@
|
|||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
C4B3E718A814E0236A12EB86 /* Budget */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.swift */,
|
||||||
|
0FDF13E61F770748435D0097 /* BudgetZeileBearbeiten.swift */,
|
||||||
|
);
|
||||||
|
path = Budget;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
CB7098BB5F4AEFE48BA9BB36 /* Abos */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
EFABF1A481445B1CD960C993 /* AboFormular.swift */,
|
||||||
|
8C5FA8AE870EDCB51232CA9E /* AboListe.swift */,
|
||||||
|
);
|
||||||
|
path = Abos;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
D99101183ED9DA14C8D22722 /* Buchungen */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A4FED25B5220CEAD1638B53F /* BuchungFormular.swift */,
|
||||||
|
A150CAB4967A479B506A997F /* BuchungsListe.swift */,
|
||||||
|
);
|
||||||
|
path = Buchungen;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
DB51F54CC077E85C286AD9DC /* Netzwerk */ = {
|
DB51F54CC077E85C286AD9DC /* Netzwerk */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -183,6 +299,27 @@
|
|||||||
path = Netzwerk;
|
path = Netzwerk;
|
||||||
sourceTree = "<group>";
|
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 */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -209,6 +346,7 @@
|
|||||||
buildConfigurationList = B7C4A363393DD132E27BF360 /* Build configuration list for PBXNativeTarget "CertainTests" */;
|
buildConfigurationList = B7C4A363393DD132E27BF360 /* Build configuration list for PBXNativeTarget "CertainTests" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
104B97AFE88042A9CF83B9C7 /* Sources */,
|
104B97AFE88042A9CF83B9C7 /* Sources */,
|
||||||
|
5E084A3C9ED86572E29E01B7 /* Resources */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -263,10 +401,19 @@
|
|||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
B551D26D31D9C35898074FE5 /* AppIcon.icon in Resources */,
|
||||||
E5F283F445FA9E26CFC15FA5 /* Localizable.strings in Resources */,
|
E5F283F445FA9E26CFC15FA5 /* Localizable.strings in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
5E084A3C9ED86572E29E01B7 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
6FD2F62BEFE9BE9FE159416C /* antworten.json in Resources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@@ -284,24 +431,49 @@
|
|||||||
files = (
|
files = (
|
||||||
C518581A7F62632420821479 /* APIFehler.swift in Sources */,
|
C518581A7F62632420821479 /* APIFehler.swift in Sources */,
|
||||||
F414F982412C79242CADB473 /* Ablage.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 */,
|
E49213EDDBF2C0C7995AF558 /* AnmeldeAnsicht.swift in Sources */,
|
||||||
402F07ED1E39AB8C3BC6F62B /* AppZustand.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 */,
|
18B6A9629AA749B790F21DC9 /* CertainApp.swift in Sources */,
|
||||||
EE4F2382CFF3F60FF5AC8241 /* CertainClient.swift in Sources */,
|
EE4F2382CFF3F60FF5AC8241 /* CertainClient.swift in Sources */,
|
||||||
918BA7A7CC3972ED4D8A93B1 /* Darstellung.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 */,
|
66EE367CB37CDA59EEF0F023 /* EinstellungenAnsicht.swift in Sources */,
|
||||||
BEC6BF023D4A649003F38A2D /* Endpunkte.swift in Sources */,
|
BEC6BF023D4A649003F38A2D /* Endpunkte.swift in Sources */,
|
||||||
|
C810555B892E1CAD08053FC3 /* Formular.swift in Sources */,
|
||||||
431D27A182E4DEBD84F0DA05 /* Geldbetrag.swift in Sources */,
|
431D27A182E4DEBD84F0DA05 /* Geldbetrag.swift in Sources */,
|
||||||
8EAA9F11CF1FDFE702557B55 /* Grundmodelle.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 */,
|
3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */,
|
||||||
0E66BEE1DEE6E4A5D88A6696 /* Konto.swift in Sources */,
|
0E66BEE1DEE6E4A5D88A6696 /* Konto.swift in Sources */,
|
||||||
|
49AA743A07B0A1E21D2D6AE8 /* KontoFormular.swift in Sources */,
|
||||||
D5AFF6E8CE7AB6674BDCEEE1 /* Lader.swift in Sources */,
|
D5AFF6E8CE7AB6674BDCEEE1 /* Lader.swift in Sources */,
|
||||||
|
3BE070886D8D49F0BF9B49BC /* Nebenraeume.swift in Sources */,
|
||||||
B2470AAE64D657CF7CDEA099 /* PasswortWechselAnsicht.swift in Sources */,
|
B2470AAE64D657CF7CDEA099 /* PasswortWechselAnsicht.swift in Sources */,
|
||||||
B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */,
|
B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */,
|
||||||
3E574E1D2C0CDB24C652190C /* ServerAnsicht.swift in Sources */,
|
3E574E1D2C0CDB24C652190C /* ServerAnsicht.swift in Sources */,
|
||||||
|
F8BC5F736C54FFFAD25670D5 /* SperrAnsicht.swift in Sources */,
|
||||||
|
D5800913A3A01494BEFE85C5 /* TagsAnsicht.swift in Sources */,
|
||||||
7AFCD4ADF85329F373C89E06 /* Zeitangaben.swift in Sources */,
|
7AFCD4ADF85329F373C89E06 /* Zeitangaben.swift in Sources */,
|
||||||
25EC32ED2FF88F4286B752A7 /* Zustandsansichten.swift in Sources */,
|
25EC32ED2FF88F4286B752A7 /* Zustandsansichten.swift in Sources */,
|
||||||
1E0AE92B8994298A0468E4EC /* ZweiFaktorAnsicht.swift in Sources */,
|
1E0AE92B8994298A0468E4EC /* ZweiFaktorAnsicht.swift in Sources */,
|
||||||
|
18384EACDD7DE161B874636C /* ZweiterFaktorAnsicht.swift in Sources */,
|
||||||
4C9AC9D9DCA7A94F831AB207 /* Zwischenspeicher.swift in Sources */,
|
4C9AC9D9DCA7A94F831AB207 /* Zwischenspeicher.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -339,9 +511,12 @@
|
|||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Certain;
|
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_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = 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 = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -349,8 +524,12 @@
|
|||||||
MARKETING_VERSION = 0.1;
|
MARKETING_VERSION = 0.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.certain;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.certain;
|
||||||
SDKROOT = iphoneos;
|
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;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
@@ -519,9 +698,12 @@
|
|||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Certain;
|
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_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = 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 = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -529,8 +711,12 @@
|
|||||||
MARKETING_VERSION = 0.1;
|
MARKETING_VERSION = 0.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.certain;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.certain;
|
||||||
SDKROOT = iphoneos;
|
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;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Alles, was das Dashboard braucht, in einem Abruf.
|
||||||
|
///
|
||||||
|
/// Vier Endpunkte nebeneinander statt nacheinander – sie wissen nichts
|
||||||
|
/// voneinander. Zusammen in einem Wert, damit `Lader` sie als einen Stand
|
||||||
|
/// merkt: Vier getrennte Zwischenspeicher könnten offline aus vier
|
||||||
|
/// verschiedenen Momenten stammen und sich widersprechen.
|
||||||
|
struct Dashboarddaten: Codable, Sendable, Hashable {
|
||||||
|
let konten: Kontostaende
|
||||||
|
let fortschritt: Budgetfortschritt
|
||||||
|
let bilanz: EinnahmenAusgaben
|
||||||
|
let letzte: LetzteBuchungen
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CertainClient {
|
||||||
|
func dashboard() async throws -> Dashboarddaten {
|
||||||
|
async let konten = kontostaende()
|
||||||
|
async let fortschritt = budgetfortschritt()
|
||||||
|
async let bilanz = einnahmenAusgaben()
|
||||||
|
async let letzte = letzteBuchungen()
|
||||||
|
return try await Dashboarddaten(konten: konten, fortschritt: fortschritt,
|
||||||
|
bilanz: bilanz, letzte: letzte)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Startseite. Anders als im Web ist die Auswahl der Karten fest – ein
|
||||||
|
/// verschiebbares Raster auf Telefonbreite wäre Arbeit ohne Gewinn.
|
||||||
|
struct DashboardAnsicht: View {
|
||||||
|
@Environment(AppZustand.self) private var zustand
|
||||||
|
@State private var lader: Lader<Dashboarddaten>?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let lader {
|
||||||
|
Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { daten in
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
BilanzKarte(bilanz: daten.bilanz)
|
||||||
|
FortschrittKarte(fortschritt: daten.fortschritt)
|
||||||
|
KontenKarte(staende: daten.konten)
|
||||||
|
LetzteKarte(letzte: daten.letzte)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.refreshable { await lader.aktualisiere() }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(String(localized: "reiter.dashboard"))
|
||||||
|
.task {
|
||||||
|
guard lader == nil, let client = zustand.client else { return }
|
||||||
|
let neuer = Lader<Dashboarddaten>(schluessel: "dashboard") {
|
||||||
|
try await client.dashboard()
|
||||||
|
}
|
||||||
|
lader = neuer
|
||||||
|
await neuer.lade()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Karten
|
||||||
|
|
||||||
|
/// Der Rahmen, den alle Karten teilen.
|
||||||
|
private struct Karte<Inhalt: View>: View {
|
||||||
|
let titel: String
|
||||||
|
var fussnote: String?
|
||||||
|
@ViewBuilder let inhalt: () -> Inhalt
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text(titel)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
inhalt()
|
||||||
|
if let fussnote {
|
||||||
|
Text(fussnote)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(16)
|
||||||
|
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Einnahmen, Ausgaben und was übrig bleibt – die Zahl, wegen der man die
|
||||||
|
/// App öffnet.
|
||||||
|
private struct BilanzKarte: View {
|
||||||
|
let bilanz: EinnahmenAusgaben
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Karte(titel: String(localized: "dashboard.bilanz")) {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text(bilanz.uebrig.formatiert())
|
||||||
|
.font(.largeTitle.weight(.semibold).monospacedDigit())
|
||||||
|
.foregroundStyle(bilanz.net < 0 ? .red : .primary)
|
||||||
|
.contentTransition(.numericText())
|
||||||
|
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
Beschriftet(titel: String(localized: "dashboard.einnahmen"),
|
||||||
|
wert: bilanz.einnahmen.formatiert(), farbe: .green)
|
||||||
|
Beschriftet(titel: String(localized: "dashboard.ausgaben"),
|
||||||
|
wert: bilanz.ausgaben.formatiert(), farbe: .red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Beschriftet: View {
|
||||||
|
let titel: String
|
||||||
|
let wert: String
|
||||||
|
let farbe: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(titel)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(wert)
|
||||||
|
.font(.callout.monospacedDigit())
|
||||||
|
.foregroundStyle(farbe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Je Gruppe ein Balken: wie viel vom Budget schon weg ist.
|
||||||
|
private struct FortschrittKarte: View {
|
||||||
|
let fortschritt: Budgetfortschritt
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Karte(titel: String(localized: "dashboard.budgetfortschritt")) {
|
||||||
|
if fortschritt.rows.isEmpty {
|
||||||
|
Text(String(localized: "dashboard.keinBudget"))
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ForEach(fortschritt.rows) { zeile in
|
||||||
|
Balken(zeile: zeile, waehrung: fortschritt.currency)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Balken: View {
|
||||||
|
let zeile: Budgetfortschritt.Zeile
|
||||||
|
let waehrung: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Text(zeile.name)
|
||||||
|
.font(.callout)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
Text(Geldbetrag(cents: zeile.actual, waehrung: waehrung).formatiert())
|
||||||
|
.font(.caption.monospacedDigit())
|
||||||
|
Text("/")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
Text(Geldbetrag(cents: zeile.planned, waehrung: waehrung).formatiert())
|
||||||
|
.font(.caption.monospacedDigit())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
ProgressView(value: zeile.anteil)
|
||||||
|
.tint(farbe)
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rot nur bei echter Überschreitung. Wer mehr zurücklegt als
|
||||||
|
/// geplant, hat nichts überzogen – deshalb fragt `good` dazwischen.
|
||||||
|
private var farbe: Color {
|
||||||
|
if zeile.good { return .green }
|
||||||
|
return zeile.over ? .red : Color(hex: zeile.color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Kontostände, und was davon wirklich verfügbar ist.
|
||||||
|
private struct KontenKarte: View {
|
||||||
|
let staende: Kontostaende
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Karte(titel: String(localized: "dashboard.kontostaende")) {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
ForEach(staende.accounts.filter { !$0.archived }) { konto in
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Symbolplakette(symbol: konto.icon, farbe: konto.color, groesse: 28)
|
||||||
|
Text(konto.name)
|
||||||
|
.font(.callout)
|
||||||
|
.lineLimit(1)
|
||||||
|
if konto.locked {
|
||||||
|
Image(systemName: "lock.fill")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityLabel(String(localized: "konten.gebunden"))
|
||||||
|
}
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
Text(konto.saldo.formatiert())
|
||||||
|
.font(.callout.monospacedDigit())
|
||||||
|
.foregroundStyle(konto.balanceCents < 0 ? .red : .primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text(String(localized: "konten.verfuegbar"))
|
||||||
|
.font(.callout.weight(.medium))
|
||||||
|
Spacer()
|
||||||
|
Text(staende.summe.formatiert())
|
||||||
|
.font(.callout.weight(.medium).monospacedDigit())
|
||||||
|
}
|
||||||
|
if staende.gebunden != 0 {
|
||||||
|
HStack {
|
||||||
|
Text(String(localized: "konten.gebundenSumme"))
|
||||||
|
Spacer()
|
||||||
|
Text(staende.gebundenBetrag.formatiert())
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was zuletzt passiert ist. Die Überschriften kommen fertig formuliert vom
|
||||||
|
/// Server – bei einem Übertrag steht dort „Konto A → Konto B".
|
||||||
|
private struct LetzteKarte: View {
|
||||||
|
let letzte: LetzteBuchungen
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Karte(titel: String(localized: "dashboard.letzteBuchungen")) {
|
||||||
|
if letzte.transactions.isEmpty {
|
||||||
|
Text(String(localized: "buchungen.leerErklaerung"))
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ForEach(letzte.transactions) { zeile in
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Symbolplakette(symbol: zeile.icon, farbe: zeile.color, groesse: 30)
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(zeile.title)
|
||||||
|
.font(.callout)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(zeile.date.formatiert())
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
Text(zeile.betrag.formatiert(vorzeichen: .ohne))
|
||||||
|
.font(.callout.monospacedDigit())
|
||||||
|
.foregroundStyle(farbe(zeile.vorzeichen))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func farbe(_ vorzeichen: LetzteBuchungen.Zeile.Vorzeichen) -> Color {
|
||||||
|
switch vorzeichen {
|
||||||
|
case .plus: return .green
|
||||||
|
case .minus: return .primary
|
||||||
|
case .keines: return .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Stufe 1 zeigt hier nur, woran man ist, und lässt abmelden. Die echten
|
/// Einstellungen und die Nebenräume: Kategorien, Schlagwörter, Bank,
|
||||||
/// Einstellungen (Hauptwährung, Budget-Starttag, Sprache, Gerätemarken)
|
/// zweiter Faktor.
|
||||||
/// kommen in Stufe 5.
|
///
|
||||||
|
/// 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 {
|
struct EinstellungenAnsicht: View {
|
||||||
@Environment(AppZustand.self) private var zustand
|
@Environment(AppZustand.self) private var zustand
|
||||||
|
@State private var vorgang = Speichervorgang()
|
||||||
@State private var zeigeAbmeldefrage = false
|
@State private var zeigeAbmeldefrage = false
|
||||||
|
@State private var markierte: [MarkiertesWort] = []
|
||||||
|
@State private var aenderePIN = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
@@ -18,17 +23,83 @@ struct EinstellungenAnsicht: View {
|
|||||||
LabeledContent(String(localized: "einstellungen.server"),
|
LabeledContent(String(localized: "einstellungen.server"),
|
||||||
value: adresse.host() ?? adresse.absoluteString)
|
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: {
|
} header: {
|
||||||
Text(String(localized: "einstellungen.zugang"))
|
Text(String(localized: "einstellungen.zugang"))
|
||||||
} footer: {
|
} footer: {
|
||||||
Text(String(localized: "einstellungen.markeErklaerung"))
|
Text(String(localized: "einstellungen.markeErklaerung"))
|
||||||
}
|
}
|
||||||
|
|
||||||
Section(String(localized: "einstellungen.ausDemServer")) {
|
Section(String(localized: "nebenraeume.titel")) {
|
||||||
LabeledContent(String(localized: "einstellungen.hauptwaehrung"),
|
NavigationLink {
|
||||||
value: zustand.einstellungen.basiswaehrung)
|
KategorienAnsicht()
|
||||||
LabeledContent(String(localized: "einstellungen.budgetStarttag"),
|
} label: {
|
||||||
value: "\(zustand.einstellungen.budgetStarttag)")
|
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 {
|
Section {
|
||||||
@@ -39,8 +110,12 @@ struct EinstellungenAnsicht: View {
|
|||||||
Text(String(localized: "einstellungen.abmeldenErklaerung"))
|
Text(String(localized: "einstellungen.abmeldenErklaerung"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $aenderePIN) {
|
||||||
|
PINEinrichtenAnsicht(pflicht: false)
|
||||||
|
}
|
||||||
.navigationTitle(String(localized: "reiter.einstellungen"))
|
.navigationTitle(String(localized: "reiter.einstellungen"))
|
||||||
.refreshable { await zustand.aktualisiereEinstellungen() }
|
.refreshable { await lade() }
|
||||||
|
.task { await lade() }
|
||||||
.confirmationDialog(String(localized: "einstellungen.abmeldenFrage"),
|
.confirmationDialog(String(localized: "einstellungen.abmeldenFrage"),
|
||||||
isPresented: $zeigeAbmeldefrage, titleVisibility: .visible) {
|
isPresented: $zeigeAbmeldefrage, titleVisibility: .visible) {
|
||||||
Button(String(localized: "allgemein.abmelden"), role: .destructive) {
|
Button(String(localized: "allgemein.abmelden"), role: .destructive) {
|
||||||
@@ -49,4 +124,87 @@ struct EinstellungenAnsicht: View {
|
|||||||
Button(String(localized: "allgemein.abbrechen"), role: .cancel) {}
|
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
|
@Environment(AppZustand.self) private var zustand
|
||||||
@State private var lader: Lader<[Konto]>?
|
@State private var lader: Lader<[Konto]>?
|
||||||
@State private var zeigeArchivierte = false
|
@State private var zeigeArchivierte = false
|
||||||
|
@State private var formular: KontoFormular.Zweck?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
@@ -27,6 +28,14 @@ struct KontenListe: View {
|
|||||||
Image(systemName: "line.3.horizontal.decrease.circle")
|
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 {
|
.task {
|
||||||
guard lader == nil, let client = zustand.client else { return }
|
guard lader == nil, let client = zustand.client else { return }
|
||||||
@@ -50,8 +59,13 @@ struct KontenListe: View {
|
|||||||
List {
|
List {
|
||||||
Section {
|
Section {
|
||||||
ForEach(sichtbar) { konto in
|
ForEach(sichtbar) { konto in
|
||||||
|
Button {
|
||||||
|
formular = .aendern(konto)
|
||||||
|
} label: {
|
||||||
KontoZeile(konto: konto)
|
KontoZeile(konto: konto)
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
} footer: {
|
} footer: {
|
||||||
Gesamtsumme(konten: sichtbar,
|
Gesamtsumme(konten: sichtbar,
|
||||||
waehrung: zustand.einstellungen.basiswaehrung)
|
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,99 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// `SubscriptionListOut` – die Vertragsliste samt Kopfzahlen.
|
||||||
|
struct Aboliste: Codable, Sendable, Hashable {
|
||||||
|
let subscriptions: [Abo]
|
||||||
|
let summary: Abosummen
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `SubscriptionOut` aus `backend/app/schemas.py`.
|
||||||
|
struct Abo: Codable, Sendable, Identifiable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let name: String
|
||||||
|
let icon: String
|
||||||
|
let color: String
|
||||||
|
/// Der Betrag je Zahlung, in der Währung des Vertrags.
|
||||||
|
let amountCents: Int
|
||||||
|
let currency: String
|
||||||
|
/// 1 = monatlich, 12 = jährlich, 3 = vierteljährlich …
|
||||||
|
let intervalMonths: Int
|
||||||
|
let accountId: Int?
|
||||||
|
let categoryId: Int?
|
||||||
|
let nextDue: Tagesdatum?
|
||||||
|
/// Woran die Erkennung den Vertrag in einer Buchung wiedererkennt.
|
||||||
|
let payeePattern: String?
|
||||||
|
let savingsAccountId: Int?
|
||||||
|
let isActive: Bool
|
||||||
|
let cancelledAt: Tagesdatum?
|
||||||
|
let notes: String?
|
||||||
|
let position: Int
|
||||||
|
/// Auf einen Monat umgelegt, in der Vertragswährung – vom Server gerechnet.
|
||||||
|
let monatlichCents: Int
|
||||||
|
/// Dasselbe in der Hauptwährung. Fehlt der Kurs, fällt es still auf den
|
||||||
|
/// nativen Wert zurück; welche Währungen betroffen sind, sagt
|
||||||
|
/// `Abosummen.ohneKurs`.
|
||||||
|
let monatlichBaseCents: Int
|
||||||
|
let tageBisFaellig: Int?
|
||||||
|
let hasLogo: Bool
|
||||||
|
let logoVersion: Int?
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, icon, color, currency, notes, position
|
||||||
|
case amountCents = "amount_cents"
|
||||||
|
case intervalMonths = "interval_months"
|
||||||
|
case accountId = "account_id"
|
||||||
|
case categoryId = "category_id"
|
||||||
|
case nextDue = "next_due"
|
||||||
|
case payeePattern = "payee_pattern"
|
||||||
|
case savingsAccountId = "savings_account_id"
|
||||||
|
case isActive = "is_active"
|
||||||
|
case cancelledAt = "cancelled_at"
|
||||||
|
case monatlichCents = "monatlich_cents"
|
||||||
|
case monatlichBaseCents = "monatlich_base_cents"
|
||||||
|
case tageBisFaellig = "tage_bis_faellig"
|
||||||
|
case hasLogo = "has_logo"
|
||||||
|
case logoVersion = "logo_version"
|
||||||
|
}
|
||||||
|
|
||||||
|
var betrag: Geldbetrag { Geldbetrag(cents: amountCents, waehrung: currency) }
|
||||||
|
var monatlich: Geldbetrag { Geldbetrag(cents: monatlichCents, waehrung: currency) }
|
||||||
|
|
||||||
|
/// „monatlich", „jährlich", „alle 3 Monate".
|
||||||
|
var rhythmus: String {
|
||||||
|
switch intervalMonths {
|
||||||
|
case 1: return String(localized: "abo.monatlich")
|
||||||
|
case 3: return String(localized: "abo.vierteljaehrlich")
|
||||||
|
case 6: return String(localized: "abo.halbjaehrlich")
|
||||||
|
case 12: return String(localized: "abo.jaehrlich")
|
||||||
|
default: return String(format: String(localized: "abo.alleNMonate"), intervalMonths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Steht die nächste Zahlung in der nächsten Woche an?
|
||||||
|
var istBaldFaellig: Bool {
|
||||||
|
guard isActive, let tage = tageBisFaellig else { return false }
|
||||||
|
return tage <= 7
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was am Kopf der Liste steht – die Zahl, wegen der man sie öffnet.
|
||||||
|
struct Abosummen: Codable, Sendable, Hashable {
|
||||||
|
let currency: String
|
||||||
|
let monatlichCents: Int
|
||||||
|
let jaehrlichCents: Int
|
||||||
|
let anzahlAktiv: Int
|
||||||
|
/// Währungen, die sich mangels Kurs nicht umrechnen liessen. Lieber
|
||||||
|
/// nennen als stillschweigend zu klein summieren.
|
||||||
|
let ohneKurs: [String]
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case currency
|
||||||
|
case monatlichCents = "monatlich_cents"
|
||||||
|
case jaehrlichCents = "jaehrlich_cents"
|
||||||
|
case anzahlAktiv = "anzahl_aktiv"
|
||||||
|
case ohneKurs = "ohne_kurs"
|
||||||
|
}
|
||||||
|
|
||||||
|
var monatlich: Geldbetrag { Geldbetrag(cents: monatlichCents, waehrung: currency) }
|
||||||
|
var jaehrlich: Geldbetrag { Geldbetrag(cents: jaehrlichCents, waehrung: currency) }
|
||||||
|
}
|
||||||
@@ -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,192 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// `TransactionOut` aus `backend/app/schemas.py`.
|
||||||
|
///
|
||||||
|
/// Drei Beträge an jeder Buchung, und keiner davon wird hier gerechnet:
|
||||||
|
/// `amountCents` steht in der Kontowährung, `amountEurCents` ist der beim
|
||||||
|
/// Speichern eingefrorene EUR-Wert, `amountBaseCents` rechnet der Server bei
|
||||||
|
/// jedem Lesen in die Hauptwährung um. Die App formatiert nur.
|
||||||
|
struct Buchung: Codable, Sendable, Identifiable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let type: Buchungsart
|
||||||
|
let date: Tagesdatum
|
||||||
|
/// `"14:18:15"` oder nichts. Nur zur Anzeige – gerechnet wird mit dem Tag.
|
||||||
|
let time: String?
|
||||||
|
let accountId: Int
|
||||||
|
let toAccountId: Int?
|
||||||
|
let accountCurrency: String
|
||||||
|
let amountCents: Int
|
||||||
|
let toAmountCents: Int?
|
||||||
|
let fxRate: Double?
|
||||||
|
let amountEurCents: Int
|
||||||
|
let amountBaseCents: Int
|
||||||
|
let categoryId: Int?
|
||||||
|
let budgetTreatment: Budgetwirkung?
|
||||||
|
let budgetCategoryId: Int?
|
||||||
|
/// Der Vertrag, zu dem die Buchung gehört – von der Erkennung gesetzt.
|
||||||
|
let subscriptionId: Int?
|
||||||
|
let budgetPeriod: String
|
||||||
|
let budgetPeriodManuell: Bool
|
||||||
|
let importBatchId: Int?
|
||||||
|
/// bank, mail, shortcut, transfer, csv, manuell.
|
||||||
|
let herkunft: String
|
||||||
|
let payee: String?
|
||||||
|
let description: String?
|
||||||
|
let notes: String?
|
||||||
|
let tagIds: [Int]
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id, type, date, time, herkunft, payee, description, notes
|
||||||
|
case accountId = "account_id"
|
||||||
|
case toAccountId = "to_account_id"
|
||||||
|
case accountCurrency = "account_currency"
|
||||||
|
case amountCents = "amount_cents"
|
||||||
|
case toAmountCents = "to_amount_cents"
|
||||||
|
case fxRate = "fx_rate"
|
||||||
|
case amountEurCents = "amount_eur_cents"
|
||||||
|
case amountBaseCents = "amount_base_cents"
|
||||||
|
case categoryId = "category_id"
|
||||||
|
case budgetTreatment = "budget_treatment"
|
||||||
|
case budgetCategoryId = "budget_category_id"
|
||||||
|
case subscriptionId = "subscription_id"
|
||||||
|
case budgetPeriod = "budget_period"
|
||||||
|
case budgetPeriodManuell = "budget_period_manuell"
|
||||||
|
case importBatchId = "import_batch_id"
|
||||||
|
case tagIds = "tag_ids"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In der Kontowährung.
|
||||||
|
var betrag: Geldbetrag { Geldbetrag(cents: amountCents, waehrung: accountCurrency) }
|
||||||
|
|
||||||
|
/// Was in der Liste als Überschrift steht. Ohne Empfänger nimmt der
|
||||||
|
/// Verwendungszweck seinen Platz ein – eine leere Zeile hilft niemandem.
|
||||||
|
var ueberschrift: String {
|
||||||
|
for text in [payee, description] where !(text ?? "").isEmpty {
|
||||||
|
return text!
|
||||||
|
}
|
||||||
|
return String(localized: "buchung.ohneBezeichnung")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Zeile darunter: Verwendungszweck, falls er nicht schon oben steht.
|
||||||
|
var unterschrift: String? {
|
||||||
|
guard let beschreibung = description, !beschreibung.isEmpty,
|
||||||
|
beschreibung != ueberschrift else { return nil }
|
||||||
|
return beschreibung
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ob diese Buchung Geld aus dem Haushalt herausbewegt. Überträge nicht –
|
||||||
|
/// sie verschieben nur.
|
||||||
|
var istAusgabe: Bool { type == .expense }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `TxnType`
|
||||||
|
enum Buchungsart: String, Codable, Sendable, CaseIterable, Identifiable {
|
||||||
|
case expense, income, transfer
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var name: String {
|
||||||
|
switch self {
|
||||||
|
case .expense: return String(localized: "buchungsart.ausgabe")
|
||||||
|
case .income: return String(localized: "buchungsart.einnahme")
|
||||||
|
case .transfer: return String(localized: "buchungsart.uebertrag")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var symbol: String {
|
||||||
|
switch self {
|
||||||
|
case .expense: return "arrow.up.right"
|
||||||
|
case .income: return "arrow.down.left"
|
||||||
|
case .transfer: return "arrow.left.arrow.right"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `BudgetTreatment` – ob eine Buchung im Budget zählt oder bloss durchläuft.
|
||||||
|
enum Budgetwirkung: String, Codable, Sendable {
|
||||||
|
case budgeted, neutral
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Eintrag aus `GET /api/transactions/links`: die Gegenseite einer
|
||||||
|
/// Buchung, die mit einer zweiten zu einem Übertrag verknüpft ist.
|
||||||
|
///
|
||||||
|
/// Das Gegenkonto steht mit drin, weil die andere Buchung oft gar nicht in
|
||||||
|
/// der gerade gefilterten Liste steht – und „Übertrag" ohne Angabe wohin
|
||||||
|
/// sagt nichts.
|
||||||
|
struct Uebertragsverknuepfung: Codable, Sendable, Hashable {
|
||||||
|
let linkId: Int
|
||||||
|
let otherId: Int
|
||||||
|
/// `"raus"`: Von dieser Buchung geht das Geld weg. `"rein"`: hier kommt es an.
|
||||||
|
let richtung: String
|
||||||
|
let otherAccountId: Int?
|
||||||
|
let automatisch: Bool
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case richtung, automatisch
|
||||||
|
case linkId = "link_id"
|
||||||
|
case otherId = "other_id"
|
||||||
|
case otherAccountId = "other_account_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
var gehtRaus: Bool { richtung == "raus" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Zeile der Buchungsliste: entweder eine einzelne Buchung oder zwei,
|
||||||
|
/// die zusammen einen Übertrag ergeben.
|
||||||
|
///
|
||||||
|
/// Das Backend kennt zwei Mechanismen nebeneinander: einen Übertrag am Stück
|
||||||
|
/// (eine Buchung mit `toAccountId`) und die Verknüpfung zweier getrennter
|
||||||
|
/// Buchungen, wie die Bank sie liefert. Beide Beine als zwei Zeilen zu zeigen,
|
||||||
|
/// hiesse dasselbe Geld zweimal zeigen. Das Zusammenfassen macht der Client –
|
||||||
|
/// das Backend liefert nur die Paarliste.
|
||||||
|
struct Buchungszeile: Identifiable, Hashable, Sendable {
|
||||||
|
let buchung: Buchung
|
||||||
|
/// Das zweite Bein, wenn es in derselben Liste steht.
|
||||||
|
let gegenstueck: Buchung?
|
||||||
|
/// Die Verknüpfung selbst – auch dann da, wenn das zweite Bein fehlt.
|
||||||
|
let verknuepfung: Uebertragsverknuepfung?
|
||||||
|
|
||||||
|
var id: Int { buchung.id }
|
||||||
|
|
||||||
|
var istUebertrag: Bool { buchung.type == .transfer || verknuepfung != nil }
|
||||||
|
|
||||||
|
/// Von welchem Konto das Geld weggeht.
|
||||||
|
var vonKonto: Int {
|
||||||
|
guard let verknuepfung else { return buchung.accountId }
|
||||||
|
if verknuepfung.gehtRaus { return buchung.accountId }
|
||||||
|
return gegenstueck?.accountId ?? verknuepfung.otherAccountId ?? buchung.accountId
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auf welchem es ankommt – `nil`, wenn es sich nicht sagen lässt.
|
||||||
|
var nachKonto: Int? {
|
||||||
|
guard let verknuepfung else { return buchung.toAccountId }
|
||||||
|
if verknuepfung.gehtRaus { return gegenstueck?.accountId ?? verknuepfung.otherAccountId }
|
||||||
|
return buchung.accountId
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Baut aus einer Buchungsliste und der Paarliste die Anzeigezeilen.
|
||||||
|
///
|
||||||
|
/// Zusammengefasst wird nur, wenn **beide** Beine in dieser Liste stehen.
|
||||||
|
/// Fehlt eines, bleibt die Zeile für sich – sie trägt dann aber die
|
||||||
|
/// Verknüpfung, damit sie „Übertrag nach …" sagen kann statt gar nichts.
|
||||||
|
/// Ohne diese Vorsicht liesse ein Kontofilter Zeilen verschwinden, die
|
||||||
|
/// sehr wohl zu diesem Konto gehören.
|
||||||
|
static func zeilen(aus buchungen: [Buchung],
|
||||||
|
paare: [String: Uebertragsverknuepfung]) -> [Buchungszeile] {
|
||||||
|
let nachId = Dictionary(buchungen.map { ($0.id, $0) }, uniquingKeysWith: { erstes, _ in erstes })
|
||||||
|
var erledigt = Set<Int>()
|
||||||
|
var ergebnis: [Buchungszeile] = []
|
||||||
|
|
||||||
|
for buchung in buchungen where !erledigt.contains(buchung.id) {
|
||||||
|
let verknuepfung = paare[String(buchung.id)]
|
||||||
|
let anderes = verknuepfung.flatMap { nachId[$0.otherId] }
|
||||||
|
if let anderes {
|
||||||
|
erledigt.insert(anderes.id)
|
||||||
|
}
|
||||||
|
erledigt.insert(buchung.id)
|
||||||
|
ergebnis.append(Buchungszeile(buchung: buchung, gegenstueck: anderes,
|
||||||
|
verknuepfung: verknuepfung))
|
||||||
|
}
|
||||||
|
return ergebnis
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Die Monatsansicht aus `GET /api/budget/{label}` beziehungsweise
|
||||||
|
/// `/budget/current` (`_build_view` in `backend/app/routers/budgets.py`).
|
||||||
|
///
|
||||||
|
/// Alle Beträge stehen schon in der Hauptwährung. Der Client rechnet nur,
|
||||||
|
/// was es in der API nicht gibt: Prozente und „Rest".
|
||||||
|
struct Budgetmonat: Codable, Sendable, Hashable {
|
||||||
|
/// `"2026-10"`.
|
||||||
|
let label: String
|
||||||
|
let start: Tagesdatum
|
||||||
|
let end: Tagesdatum
|
||||||
|
/// Ob dieser Monat von der Vorlage abweicht.
|
||||||
|
let customized: Bool
|
||||||
|
let baseCurrency: String
|
||||||
|
let prev: String
|
||||||
|
let next: String
|
||||||
|
let groups: [Budgetgruppe]
|
||||||
|
let income: [GeplanteEinnahme]
|
||||||
|
let vertraege: Vertragsreste
|
||||||
|
let totals: Budgetsummen
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case label, start, end, customized, prev, next, groups, income, vertraege, totals
|
||||||
|
case baseCurrency = "base_currency"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was vom geplanten Ausgabenbudget noch übrig ist. Kann negativ werden.
|
||||||
|
var restCents: Int { totals.plannedExpense - totals.actualExpense }
|
||||||
|
|
||||||
|
/// Anteil des Verbrauchten, zwischen 0 und 1 gekappt – für einen Balken.
|
||||||
|
/// Ohne Plan gibt es keinen Anteil, nicht etwa einen vollen Balken.
|
||||||
|
var anteilVerbraucht: Double {
|
||||||
|
guard totals.plannedExpense > 0 else { return 0 }
|
||||||
|
return min(1, max(0, Double(totals.actualExpense) / Double(totals.plannedExpense)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Kategoriegruppe im Budget.
|
||||||
|
struct Budgetgruppe: Codable, Sendable, Identifiable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let name: String
|
||||||
|
let icon: String
|
||||||
|
let color: String
|
||||||
|
let plannedCents: Int
|
||||||
|
let actualCents: Int
|
||||||
|
/// Überzogen. Sparen ist davon ausgenommen – siehe `good`.
|
||||||
|
let over: Bool
|
||||||
|
/// Alle Kategorien darin sind „Ausgaben, die kein Verlust sind" (Sparen).
|
||||||
|
/// Dann ist Mehr-als-geplant keine Warnung.
|
||||||
|
let good: Bool
|
||||||
|
let ausVertraegenCents: Int
|
||||||
|
let categories: [Budgetkategorie]
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, icon, color, over, good, categories
|
||||||
|
case plannedCents = "planned_cents"
|
||||||
|
case actualCents = "actual_cents"
|
||||||
|
case ausVertraegenCents = "aus_vertraegen_cents"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Zeile im Budget.
|
||||||
|
struct Budgetkategorie: Codable, Sendable, Identifiable, Hashable {
|
||||||
|
let categoryId: Int
|
||||||
|
let name: String
|
||||||
|
let icon: String
|
||||||
|
let color: String
|
||||||
|
let plannedCents: Int
|
||||||
|
let actualCents: Int
|
||||||
|
let deltaCents: Int
|
||||||
|
let over: Bool
|
||||||
|
/// `"fest"` oder `"berechnet"`. Bei „berechnet" ergibt sich der Soll-Wert
|
||||||
|
/// aus den Verträgen plus Zuschlag.
|
||||||
|
let modus: String
|
||||||
|
let zuschlagCents: Int
|
||||||
|
/// Was die Verträge dieser Kategorie im Monat kosten. Steht auch bei
|
||||||
|
/// „fest" da – dann als Hinweis, nicht als Summand.
|
||||||
|
let ausVertraegenCents: Int
|
||||||
|
let vertraegeAnzahl: Int
|
||||||
|
let good: Bool
|
||||||
|
|
||||||
|
var id: Int { categoryId }
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case name, icon, color, over, modus, good
|
||||||
|
case categoryId = "category_id"
|
||||||
|
case plannedCents = "planned_cents"
|
||||||
|
case actualCents = "actual_cents"
|
||||||
|
case deltaCents = "delta_cents"
|
||||||
|
case zuschlagCents = "zuschlag_cents"
|
||||||
|
case ausVertraegenCents = "aus_vertraegen_cents"
|
||||||
|
case vertraegeAnzahl = "vertraege_anzahl"
|
||||||
|
}
|
||||||
|
|
||||||
|
var istBerechnet: Bool { modus == "berechnet" }
|
||||||
|
|
||||||
|
var anteilVerbraucht: Double {
|
||||||
|
guard plannedCents > 0 else { return 0 }
|
||||||
|
return min(1, max(0, Double(actualCents) / Double(plannedCents)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine geplante Einnahme – „Gehalt", „Kindergeld".
|
||||||
|
struct GeplanteEinnahme: Codable, Sendable, Identifiable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let label: String
|
||||||
|
let categoryId: Int?
|
||||||
|
let targetAccountId: Int?
|
||||||
|
let plannedCents: Int
|
||||||
|
let receivedCents: Int
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id, label
|
||||||
|
case categoryId = "category_id"
|
||||||
|
case targetAccountId = "target_account_id"
|
||||||
|
case plannedCents = "planned_cents"
|
||||||
|
case receivedCents = "received_cents"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verträge, die sich nicht zuordnen liessen.
|
||||||
|
///
|
||||||
|
/// Ohne diese Angaben fehlt ihr Geld im Budget, ohne dass irgendwo steht,
|
||||||
|
/// dass es fehlt. `ohneKurs` nennt die Währungen, für die kein Wechselkurs
|
||||||
|
/// vorlag – deren Beträge fehlen in der Summe.
|
||||||
|
struct Vertragsreste: Codable, Sendable, Hashable {
|
||||||
|
let ohneKategorieCents: Int
|
||||||
|
let ohneKategorieAnzahl: Int
|
||||||
|
let ohneKurs: [String]
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case ohneKategorieCents = "ohne_kategorie_cents"
|
||||||
|
case ohneKategorieAnzahl = "ohne_kategorie_anzahl"
|
||||||
|
case ohneKurs = "ohne_kurs"
|
||||||
|
}
|
||||||
|
|
||||||
|
var istLeer: Bool { ohneKategorieAnzahl == 0 && ohneKurs.isEmpty }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Budgetsummen: Codable, Sendable, Hashable {
|
||||||
|
let plannedExpense: Int
|
||||||
|
let actualExpense: Int
|
||||||
|
let plannedIncome: Int
|
||||||
|
let receivedIncome: Int
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case plannedExpense = "planned_expense"
|
||||||
|
case actualExpense = "actual_expense"
|
||||||
|
case plannedIncome = "planned_income"
|
||||||
|
case receivedIncome = "received_income"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/budget/template` – das Standard-Budget hinter den Monaten.
|
||||||
|
struct Budgetvorlage: Codable, Sendable, Hashable {
|
||||||
|
let lines: [Vorlagenzeile]
|
||||||
|
let income: [GeplanteEinnahme]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Vorlagenzeile: Codable, Sendable, Identifiable, Hashable {
|
||||||
|
let categoryId: Int
|
||||||
|
let plannedCents: Int
|
||||||
|
let modus: String
|
||||||
|
let zuschlagCents: Int
|
||||||
|
|
||||||
|
var id: Int { categoryId }
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case modus
|
||||||
|
case categoryId = "category_id"
|
||||||
|
case plannedCents = "planned_cents"
|
||||||
|
case zuschlagCents = "zuschlag_cents"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// Die Karten des Dashboards. Anders als im Web ist die Auswahl hier fest –
|
||||||
|
// ein verschiebbares Raster auf Telefonbreite wäre Arbeit ohne Gewinn. Die
|
||||||
|
// vier Endpunkte sind dieselben, die das Web benutzt.
|
||||||
|
|
||||||
|
/// `GET /api/dashboard/balances`
|
||||||
|
struct Kontostaende: Codable, Sendable, Hashable {
|
||||||
|
let accounts: [Kontozeile]
|
||||||
|
/// Verfügbar, ohne gebundene und archivierte Konten.
|
||||||
|
let total: Int
|
||||||
|
/// Was auf gebundenen Konten liegt – Geld, an das man nicht herankommt.
|
||||||
|
let gebunden: Int
|
||||||
|
let currency: String
|
||||||
|
|
||||||
|
var summe: Geldbetrag { Geldbetrag(cents: total, waehrung: currency) }
|
||||||
|
var gebundenBetrag: Geldbetrag { Geldbetrag(cents: gebunden, waehrung: currency) }
|
||||||
|
|
||||||
|
struct Kontozeile: Codable, Sendable, Identifiable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let name: String
|
||||||
|
let icon: String
|
||||||
|
let color: String
|
||||||
|
let currency: String
|
||||||
|
let balanceCents: Int
|
||||||
|
let balanceBaseCents: Int
|
||||||
|
let archived: Bool
|
||||||
|
let locked: Bool
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, icon, color, currency, archived, locked
|
||||||
|
case balanceCents = "balance_cents"
|
||||||
|
case balanceBaseCents = "balance_base_cents"
|
||||||
|
}
|
||||||
|
|
||||||
|
var saldo: Geldbetrag { Geldbetrag(cents: balanceCents, waehrung: currency) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/dashboard/budget-progress`
|
||||||
|
struct Budgetfortschritt: Codable, Sendable, Hashable {
|
||||||
|
let rows: [Zeile]
|
||||||
|
let currency: String
|
||||||
|
let label: String
|
||||||
|
let groupBy: String
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case rows, currency, label
|
||||||
|
case groupBy = "group_by"
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Zeile: Codable, Sendable, Identifiable, Hashable {
|
||||||
|
let name: String
|
||||||
|
let color: String
|
||||||
|
let planned: Int
|
||||||
|
let actual: Int
|
||||||
|
let over: Bool
|
||||||
|
/// Sparen: mehr als geplant ist hier keine Warnung.
|
||||||
|
let good: Bool
|
||||||
|
|
||||||
|
var id: String { name }
|
||||||
|
|
||||||
|
var anteil: Double {
|
||||||
|
guard planned > 0 else { return 0 }
|
||||||
|
return min(1, max(0, Double(actual) / Double(planned)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/dashboard/income-expense`
|
||||||
|
struct EinnahmenAusgaben: Codable, Sendable, Hashable {
|
||||||
|
let income: Int
|
||||||
|
let expense: Int
|
||||||
|
let net: Int
|
||||||
|
let currency: String
|
||||||
|
let label: String
|
||||||
|
|
||||||
|
var einnahmen: Geldbetrag { Geldbetrag(cents: income, waehrung: currency) }
|
||||||
|
var ausgaben: Geldbetrag { Geldbetrag(cents: expense, waehrung: currency) }
|
||||||
|
var uebrig: Geldbetrag { Geldbetrag(cents: net, waehrung: currency) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/dashboard/recent`
|
||||||
|
struct LetzteBuchungen: Codable, Sendable, Hashable {
|
||||||
|
let transactions: [Zeile]
|
||||||
|
let currency: String
|
||||||
|
|
||||||
|
struct Zeile: Codable, Sendable, Identifiable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let date: Tagesdatum
|
||||||
|
let type: Buchungsart
|
||||||
|
/// Schon fertig formuliert: die Kategorie, sonst der Empfänger, bei
|
||||||
|
/// einem Übertrag „Konto A → Konto B".
|
||||||
|
let title: String
|
||||||
|
let icon: String
|
||||||
|
let color: String
|
||||||
|
/// `"pos"`, `"neg"` oder nichts – bei einem Übertrag gibt es keine
|
||||||
|
/// Richtung, weil kein Geld den Haushalt verlässt.
|
||||||
|
let sign: String?
|
||||||
|
let amountCents: Int
|
||||||
|
let currency: String
|
||||||
|
let amountBaseCents: Int
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id, date, type, title, icon, color, sign, currency
|
||||||
|
case amountCents = "amount_cents"
|
||||||
|
case amountBaseCents = "amount_base_cents"
|
||||||
|
}
|
||||||
|
|
||||||
|
var betrag: Geldbetrag { Geldbetrag(cents: amountCents, waehrung: currency) }
|
||||||
|
|
||||||
|
/// Grün bei Einnahme, rot bei Ausgabe, neutral beim Übertrag.
|
||||||
|
var vorzeichen: Vorzeichen {
|
||||||
|
switch sign {
|
||||||
|
case "pos": return .plus
|
||||||
|
case "neg": return .minus
|
||||||
|
default: return .keines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Vorzeichen: Sendable { case plus, minus, keines }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,124 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// `CategoryGroupOut` – Kategorien stehen immer in einer Gruppe.
|
||||||
|
struct Kategoriegruppe: Codable, Sendable, Identifiable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let name: String
|
||||||
|
let kind: Kategorieart
|
||||||
|
let icon: String
|
||||||
|
let color: String
|
||||||
|
let position: Int
|
||||||
|
let isBuiltin: Bool
|
||||||
|
let categories: [Kategorie]
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, kind, icon, color, position, categories
|
||||||
|
case isBuiltin = "is_builtin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `CategoryOut`
|
||||||
|
struct Kategorie: Codable, Sendable, Identifiable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let groupId: Int
|
||||||
|
let name: String
|
||||||
|
let type: Kategorieart
|
||||||
|
let icon: String
|
||||||
|
let color: String
|
||||||
|
let position: Int
|
||||||
|
let isBuiltin: Bool
|
||||||
|
let isArchived: Bool
|
||||||
|
/// Zählt in keiner Auswertung mit – etwa „Durchlaufende Posten".
|
||||||
|
let excludedFromStats: Bool
|
||||||
|
/// Eine Ausgabe, die kein Verlust ist: Sparen. Mehr als geplant ist hier
|
||||||
|
/// keine Warnung.
|
||||||
|
let spendingIsGood: Bool
|
||||||
|
let keywordCount: Int
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, type, icon, color, position
|
||||||
|
case groupId = "group_id"
|
||||||
|
case isBuiltin = "is_builtin"
|
||||||
|
case isArchived = "is_archived"
|
||||||
|
case excludedFromStats = "excluded_from_stats"
|
||||||
|
case spendingIsGood = "spending_is_good"
|
||||||
|
case keywordCount = "keyword_count"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `CategoryKind`
|
||||||
|
enum Kategorieart: String, Codable, Sendable {
|
||||||
|
case income, expense
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `TagOut` – ein frei vergebenes Etikett, quer zur Kategorie.
|
||||||
|
struct Schlagwort: Codable, Sendable, Identifiable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let name: String
|
||||||
|
let color: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kategorien und Tags, wie die Ansichten sie brauchen: einmal geladen,
|
||||||
|
/// danach als Nachschlagewerk.
|
||||||
|
///
|
||||||
|
/// Fast jede Liste will zu einer ID den Namen, das Symbol und die Farbe.
|
||||||
|
/// Das je Zeile zu holen wäre genau das N+1, das das Backend sich selbst
|
||||||
|
/// verbietet.
|
||||||
|
struct Nachschlag: Codable, Sendable {
|
||||||
|
let gruppen: [Kategoriegruppe]
|
||||||
|
let schlagwoerter: [Schlagwort]
|
||||||
|
let konten: [Konto]
|
||||||
|
|
||||||
|
private let kategorienNachId: [Int: Kategorie]
|
||||||
|
private let schlagwortNachId: [Int: Schlagwort]
|
||||||
|
private let kontoNachId: [Int: Konto]
|
||||||
|
|
||||||
|
init(gruppen: [Kategoriegruppe], schlagwoerter: [Schlagwort], konten: [Konto]) {
|
||||||
|
self.gruppen = gruppen
|
||||||
|
self.schlagwoerter = schlagwoerter
|
||||||
|
self.konten = konten
|
||||||
|
let alle = gruppen.flatMap(\.categories)
|
||||||
|
self.kategorienNachId = Dictionary(alle.map { ($0.id, $0) },
|
||||||
|
uniquingKeysWith: { erstes, _ in erstes })
|
||||||
|
self.schlagwortNachId = Dictionary(schlagwoerter.map { ($0.id, $0) },
|
||||||
|
uniquingKeysWith: { erstes, _ in erstes })
|
||||||
|
self.kontoNachId = Dictionary(konten.map { ($0.id, $0) },
|
||||||
|
uniquingKeysWith: { erstes, _ in erstes })
|
||||||
|
}
|
||||||
|
|
||||||
|
static let leer = Nachschlag(gruppen: [], schlagwoerter: [], konten: [])
|
||||||
|
|
||||||
|
/// Gespeichert werden nur die drei Listen; die Nachschlagetabellen baut
|
||||||
|
/// der Konstruktor daraus neu. Sie mitzuschreiben hiesse, dieselben Daten
|
||||||
|
/// zweimal auf der Platte zu halten.
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case gruppen, schlagwoerter, konten
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let behaelter = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.init(gruppen: try behaelter.decode([Kategoriegruppe].self, forKey: .gruppen),
|
||||||
|
schlagwoerter: try behaelter.decode([Schlagwort].self, forKey: .schlagwoerter),
|
||||||
|
konten: try behaelter.decode([Konto].self, forKey: .konten))
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var behaelter = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try behaelter.encode(gruppen, forKey: .gruppen)
|
||||||
|
try behaelter.encode(schlagwoerter, forKey: .schlagwoerter)
|
||||||
|
try behaelter.encode(konten, forKey: .konten)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alle Ausgabekategorien, flach – für Auswahllisten.
|
||||||
|
var ausgabekategorien: [Kategorie] {
|
||||||
|
gruppen.filter { $0.kind == .expense }.flatMap(\.categories).filter { !$0.isArchived }
|
||||||
|
}
|
||||||
|
|
||||||
|
func kategorie(_ id: Int?) -> Kategorie? { id.flatMap { kategorienNachId[$0] } }
|
||||||
|
func schlagwort(_ id: Int) -> Schlagwort? { schlagwortNachId[id] }
|
||||||
|
func konto(_ id: Int?) -> Konto? { id.flatMap { kontoNachId[$0] } }
|
||||||
|
|
||||||
|
func kontoname(_ id: Int?) -> String {
|
||||||
|
konto(id)?.name ?? String(localized: "allgemein.unbekannt")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 Foundation
|
||||||
import Security
|
import Security
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ enum Ablage {
|
|||||||
static let serverAdresse = "certain.serverAdresse"
|
static let serverAdresse = "certain.serverAdresse"
|
||||||
static let marke = "certain.marke"
|
static let marke = "certain.marke"
|
||||||
static let benutzername = "certain.benutzername"
|
static let benutzername = "certain.benutzername"
|
||||||
|
static let pin = "certain.pin"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Server-Adresse
|
// 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.
|
/// Alles vergessen – beim Abmelden und wenn der Server die Marke ablehnt.
|
||||||
static func raeumeAuf() {
|
static func raeumeAuf() {
|
||||||
marke = nil
|
marke = nil
|
||||||
|
loeschePIN()
|
||||||
benutzername = nil
|
benutzername = nil
|
||||||
serverAdresse = nil
|
serverAdresse = nil
|
||||||
Zwischenspeicher.leere()
|
Zwischenspeicher.leere()
|
||||||
|
|||||||
@@ -95,4 +95,381 @@ extension CertainClient {
|
|||||||
func konto(_ id: Int) async throws -> Konto {
|
func konto(_ id: Int) async throws -> Konto {
|
||||||
try await hole("accounts/\(id)")
|
try await hole("accounts/\(id)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Kategorien und Schlagwörter
|
||||||
|
|
||||||
|
func kategoriegruppen() async throws -> [Kategoriegruppe] {
|
||||||
|
try await hole("categories")
|
||||||
|
}
|
||||||
|
|
||||||
|
func schlagwoerter() async throws -> [Schlagwort] {
|
||||||
|
try await hole("tags")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Das Nachschlagewerk, das fast jede Liste braucht: Kategorien, Tags und
|
||||||
|
/// Konten in einem Rutsch. Drei Abrufe nebeneinander statt nacheinander –
|
||||||
|
/// sie wissen nichts voneinander.
|
||||||
|
func nachschlag() async throws -> Nachschlag {
|
||||||
|
async let gruppen = kategoriegruppen()
|
||||||
|
async let tags = schlagwoerter()
|
||||||
|
async let alleKonten = konten()
|
||||||
|
return try await Nachschlag(gruppen: gruppen, schlagwoerter: tags, konten: alleKonten)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Buchungen
|
||||||
|
|
||||||
|
/// - Parameter suche: Volltext über Empfänger, Verwendungszweck und Notiz.
|
||||||
|
/// Seit der Backend-Ergänzung siebt der Server, nicht das Telefon.
|
||||||
|
func buchungen(konto: Int? = nil, kategorie: Int? = nil, art: Buchungsart? = nil,
|
||||||
|
von: Tagesdatum? = nil, bis: Tagesdatum? = nil,
|
||||||
|
suche: String? = nil, schlagwort: Int? = nil, abo: Int? = nil,
|
||||||
|
ohneKategorie: Bool = false, grenze: Int = 200) async throws -> [Buchung] {
|
||||||
|
var abfrage: [URLQueryItem] = [URLQueryItem(name: "limit", value: String(grenze))]
|
||||||
|
func dazu(_ name: String, _ wert: String?) {
|
||||||
|
guard let wert, !wert.isEmpty else { return }
|
||||||
|
abfrage.append(URLQueryItem(name: name, value: wert))
|
||||||
|
}
|
||||||
|
dazu("account_id", konto.map(String.init))
|
||||||
|
dazu("category_id", kategorie.map(String.init))
|
||||||
|
dazu("type", art?.rawValue)
|
||||||
|
dazu("date_from", von?.text)
|
||||||
|
dazu("date_to", bis?.text)
|
||||||
|
dazu("q", suche)
|
||||||
|
dazu("tag_id", schlagwort.map(String.init))
|
||||||
|
dazu("subscription_id", abo.map(String.init))
|
||||||
|
if ohneKategorie { dazu("uncategorized", "true") }
|
||||||
|
return try await hole("transactions", abfrage: abfrage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buchung(_ id: Int) async throws -> Buchung {
|
||||||
|
try await hole("transactions/\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Je Buchungs-ID die Gegenseite, wenn zwei Buchungen zu einem Übertrag
|
||||||
|
/// verknüpft sind. Der Schlüssel ist die ID als Zeichenkette – JSON kennt
|
||||||
|
/// keine Zahlen als Schlüssel.
|
||||||
|
func uebertragsverknuepfungen() async throws -> [String: Uebertragsverknuepfung] {
|
||||||
|
try await hole("transactions/links")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Budget
|
||||||
|
|
||||||
|
func budget(_ label: String? = nil) async throws -> Budgetmonat {
|
||||||
|
try await hole(label.map { "budget/\($0)" } ?? "budget/current")
|
||||||
|
}
|
||||||
|
|
||||||
|
func budgetvorlage() async throws -> Budgetvorlage {
|
||||||
|
try await hole("budget/template")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Verträge
|
||||||
|
|
||||||
|
func abos() async throws -> Aboliste {
|
||||||
|
try await hole("subscriptions")
|
||||||
|
}
|
||||||
|
|
||||||
|
func abo(_ id: Int) async throws -> Abo {
|
||||||
|
try await hole("subscriptions/\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Dashboard
|
||||||
|
|
||||||
|
func kontostaende() async throws -> Kontostaende {
|
||||||
|
try await hole("dashboard/balances")
|
||||||
|
}
|
||||||
|
|
||||||
|
func budgetfortschritt() async throws -> Budgetfortschritt {
|
||||||
|
try await hole("dashboard/budget-progress")
|
||||||
|
}
|
||||||
|
|
||||||
|
func einnahmenAusgaben() async throws -> EinnahmenAusgaben {
|
||||||
|
try await hole("dashboard/income-expense")
|
||||||
|
}
|
||||||
|
|
||||||
|
func letzteBuchungen(grenze: Int = 8) async throws -> LetzteBuchungen {
|
||||||
|
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 |
|
| `App/` | Einstiegspunkt, Navigation, globaler Zustand |
|
||||||
| `Netzwerk/` | Client, Endpunkte, Fehler, Offline-Ablage |
|
| `Netzwerk/` | Client, Endpunkte, Fehler, Offline-Ablage |
|
||||||
| `Modelle/` | Codable-Typen, eins zu eins zu den Backend-Schemas |
|
| `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 |
|
| `Anmeldung/` | Server-Adresse, Anmeldung, zweiter Faktor, Passwortwechsel |
|
||||||
| `Konten/` | Kontenliste (die erste Fachansicht) |
|
| `Dashboard/` | Die Übersicht: vier feste Karten |
|
||||||
| `Texte/` | Übersetzungen |
|
| `Budget/` | Soll gegen Ist, Monat für Monat |
|
||||||
| `Tests/` | Reine Logiktests, ohne Netz |
|
| `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
|
## Wie die Anmeldung funktioniert
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -302,3 +302,519 @@ struct ModellTests {
|
|||||||
#expect(fremd.istCertain == false)
|
#expect(fremd.istCertain == false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Überträge zusammenfassen
|
||||||
|
|
||||||
|
@Suite("Übertragszeilen")
|
||||||
|
struct UebertragszeilenTests {
|
||||||
|
|
||||||
|
/// Eine Buchung, wie der Server sie schickt – nur die Felder, auf die es
|
||||||
|
/// hier ankommt.
|
||||||
|
private func buchung(_ id: Int, konto: Int, cents: Int,
|
||||||
|
art: Buchungsart = .expense) throws -> Buchung {
|
||||||
|
let roh = """
|
||||||
|
{"id": \(id), "type": "\(art.rawValue)", "date": "2026-10-15", "time": null,
|
||||||
|
"account_id": \(konto), "to_account_id": null, "account_currency": "EUR",
|
||||||
|
"amount_cents": \(cents), "to_amount_cents": null, "fx_rate": null,
|
||||||
|
"amount_eur_cents": \(cents), "amount_base_cents": \(cents),
|
||||||
|
"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": "bank", "payee": "Test", "description": null, "notes": null,
|
||||||
|
"tag_ids": []}
|
||||||
|
"""
|
||||||
|
return try JSONDecoder().decode(Buchung.self, from: Data(roh.utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func verknuepfung(_ anderes: Int, raus: Bool, gegenkonto: Int?) -> Uebertragsverknuepfung {
|
||||||
|
Uebertragsverknuepfung(linkId: 1, otherId: anderes,
|
||||||
|
richtung: raus ? "raus" : "rein",
|
||||||
|
otherAccountId: gegenkonto, automatisch: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Zwei verknüpfte Buchungen ergeben eine Zeile, nicht zwei")
|
||||||
|
func paarWirdZusammengefasst() throws {
|
||||||
|
let raus = try buchung(1, konto: 10, cents: -5000)
|
||||||
|
let rein = try buchung(2, konto: 20, cents: 5000, art: .income)
|
||||||
|
let zeilen = Buchungszeile.zeilen(aus: [raus, rein], paare: [
|
||||||
|
"1": verknuepfung(2, raus: true, gegenkonto: 20),
|
||||||
|
"2": verknuepfung(1, raus: false, gegenkonto: 10),
|
||||||
|
])
|
||||||
|
|
||||||
|
#expect(zeilen.count == 1)
|
||||||
|
#expect(zeilen[0].istUebertrag)
|
||||||
|
#expect(zeilen[0].vonKonto == 10)
|
||||||
|
#expect(zeilen[0].nachKonto == 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Fehlt das zweite Bein, verschwindet die Zeile nicht")
|
||||||
|
func einzelnesBeinBleibtStehen() throws {
|
||||||
|
// Genau der Fall eines Kontofilters: Die Gegenbuchung liegt auf einem
|
||||||
|
// Konto, das gerade nicht gezeigt wird.
|
||||||
|
let raus = try buchung(1, konto: 10, cents: -5000)
|
||||||
|
let zeilen = Buchungszeile.zeilen(aus: [raus], paare: [
|
||||||
|
"1": verknuepfung(2, raus: true, gegenkonto: 20),
|
||||||
|
])
|
||||||
|
|
||||||
|
#expect(zeilen.count == 1)
|
||||||
|
#expect(zeilen[0].istUebertrag)
|
||||||
|
// Das Gegenkonto steht in der Verknüpfung, auch ohne die Buchung.
|
||||||
|
#expect(zeilen[0].nachKonto == 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Die Richtung kommt aus der Verknüpfung, nicht aus dem Vorzeichen")
|
||||||
|
func richtungZaehlt() throws {
|
||||||
|
// Manche Banken liefern beide Beine positiv. Wer am Vorzeichen
|
||||||
|
// ablesen wollte, wohin das Geld geht, läge hier falsch herum.
|
||||||
|
let rein = try buchung(2, konto: 20, cents: 5000, art: .income)
|
||||||
|
let zeilen = Buchungszeile.zeilen(aus: [rein], paare: [
|
||||||
|
"2": verknuepfung(1, raus: false, gegenkonto: 10),
|
||||||
|
])
|
||||||
|
|
||||||
|
#expect(zeilen[0].vonKonto == 10)
|
||||||
|
#expect(zeilen[0].nachKonto == 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ohne Verknüpfung bleibt jede Buchung ihre eigene Zeile")
|
||||||
|
func ohneVerknuepfung() throws {
|
||||||
|
let eins = try buchung(1, konto: 10, cents: -1000)
|
||||||
|
let zwei = try buchung(2, konto: 10, cents: -2000)
|
||||||
|
let zeilen = Buchungszeile.zeilen(aus: [eins, zwei], paare: [:])
|
||||||
|
|
||||||
|
#expect(zeilen.count == 2)
|
||||||
|
#expect(zeilen.allSatisfy { !$0.istUebertrag })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Was der Client selbst rechnet
|
||||||
|
|
||||||
|
@Suite("Budget-Anteile")
|
||||||
|
struct BudgetAnteilTests {
|
||||||
|
|
||||||
|
private func kategorie(geplant: Int, ist: Int) throws -> Budgetkategorie {
|
||||||
|
let roh = """
|
||||||
|
{"category_id": 1, "name": "Test", "icon": "home", "color": "#3f51b5",
|
||||||
|
"planned_cents": \(geplant), "actual_cents": \(ist),
|
||||||
|
"delta_cents": \(geplant - ist), "over": \(ist > geplant),
|
||||||
|
"modus": "fest", "zuschlag_cents": 0, "aus_vertraegen_cents": 0,
|
||||||
|
"vertraege_anzahl": 0, "good": false}
|
||||||
|
"""
|
||||||
|
return try JSONDecoder().decode(Budgetkategorie.self, from: Data(roh.utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ohne Plan gibt es keinen Anteil, keinen vollen Balken")
|
||||||
|
func ohnePlanKeinBalken() throws {
|
||||||
|
#expect(try kategorie(geplant: 0, ist: 0).anteilVerbraucht == 0)
|
||||||
|
// Ausgegeben ohne Plan: Ein voller Balken sähe aus wie "genau
|
||||||
|
// aufgebraucht" und wäre die falsche Auskunft.
|
||||||
|
#expect(try kategorie(geplant: 0, ist: 5000).anteilVerbraucht == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Überzogen wird bei eins gekappt")
|
||||||
|
func gekappt() throws {
|
||||||
|
#expect(try kategorie(geplant: 10000, ist: 25000).anteilVerbraucht == 1)
|
||||||
|
#expect(try kategorie(geplant: 10000, ist: 5000).anteilVerbraucht == 0.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Nachschlagewerk
|
||||||
|
|
||||||
|
@Suite("Nachschlag")
|
||||||
|
struct NachschlagTests {
|
||||||
|
|
||||||
|
private func gruppe() throws -> Kategoriegruppe {
|
||||||
|
let roh = """
|
||||||
|
{"id": 1, "name": "Fixkosten", "kind": "expense", "icon": "landmark",
|
||||||
|
"color": "#3f51b5", "position": 0, "is_builtin": true,
|
||||||
|
"categories": [{"id": 7, "group_id": 1, "name": "Wohnen", "type": "expense",
|
||||||
|
"icon": "home", "color": "#3f51b5", "position": 0, "is_builtin": true,
|
||||||
|
"is_archived": false, "excluded_from_stats": false,
|
||||||
|
"spending_is_good": false, "keyword_count": 0}]}
|
||||||
|
"""
|
||||||
|
return try JSONDecoder().decode(Kategoriegruppe.self, from: Data(roh.utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Eine ID findet ihren Namen")
|
||||||
|
func kategorieNachId() throws {
|
||||||
|
let nachschlag = Nachschlag(gruppen: [try gruppe()], schlagwoerter: [], konten: [])
|
||||||
|
#expect(nachschlag.kategorie(7)?.name == "Wohnen")
|
||||||
|
#expect(nachschlag.kategorie(999) == nil)
|
||||||
|
#expect(nachschlag.kategorie(nil) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ein unbekanntes Konto ergibt Text, nicht nichts")
|
||||||
|
func unbekanntesKonto() throws {
|
||||||
|
// Eine leere Zeile in der Liste wäre schlimmer als das Wort
|
||||||
|
// "Unbekannt" – sie sähe aus wie ein Fehler der App.
|
||||||
|
#expect(!Nachschlag.leer.kontoname(42).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Der Zwischenspeicher gibt dasselbe Nachschlagewerk zurück")
|
||||||
|
func ueberlebtDenSpeicher() throws {
|
||||||
|
let original = Nachschlag(gruppen: [try gruppe()], schlagwoerter: [], konten: [])
|
||||||
|
let roh = try JSONEncoder().encode(original)
|
||||||
|
let zurueck = try JSONDecoder().decode(Nachschlag.self, from: roh)
|
||||||
|
// Die Nachschlagetabellen werden beim Lesen neu gebaut, nicht
|
||||||
|
// mitgeschrieben – genau das prüft dieser Test.
|
||||||
|
#expect(zurueck.kategorie(7)?.name == "Wohnen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Die Modelle gegen echte Serverantworten
|
||||||
|
|
||||||
|
/// Nur da, um über `Bundle(for:)` an die Prüfdatei zu kommen. Swift Testing
|
||||||
|
/// kennt keine Testklasse, an der sich das Bündel ablesen liesse.
|
||||||
|
private final class Markierung {}
|
||||||
|
|
||||||
|
/// Entschlüsselt jedes Modell aus einer echten Antwort des Backends.
|
||||||
|
///
|
||||||
|
/// Das ist die Prüfung, die beim blinden Schreiben wirklich etwas fängt: Ein
|
||||||
|
/// vertippter Feldname übersetzt anstandslos und fällt erst auf, wenn eine
|
||||||
|
/// Liste beim Nutzer leer bleibt. `Tests/Fixtures/antworten.json` kommt aus
|
||||||
|
/// `backend/scripts/erzeuge_ios_fixture.py`; nach jeder Schema-Änderung neu
|
||||||
|
/// erzeugen.
|
||||||
|
@Suite("Serverantworten")
|
||||||
|
struct ServerantwortenTests {
|
||||||
|
|
||||||
|
private static let antworten: [String: Data] = {
|
||||||
|
guard let ort = Bundle(for: Markierung.self)
|
||||||
|
.url(forResource: "antworten", withExtension: "json"),
|
||||||
|
let roh = try? Data(contentsOf: ort),
|
||||||
|
let oben = try? JSONSerialization.jsonObject(with: roh) as? [String: Any]
|
||||||
|
else { return [:] }
|
||||||
|
return oben.compactMapValues {
|
||||||
|
try? JSONSerialization.data(withJSONObject: $0, options: .fragmentsAllowed)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
private func lies<A: Decodable>(_ typ: A.Type, _ name: String) throws -> A {
|
||||||
|
let daten = try #require(Self.antworten[name],
|
||||||
|
"Prüfdatei kennt '\(name)' nicht – neu erzeugen?")
|
||||||
|
return try JSONDecoder().decode(A.self, from: daten)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Die Prüfdatei liegt im Testbündel")
|
||||||
|
func dateiIstDa() {
|
||||||
|
// Ohne diesen Test wären alle folgenden still übersprungen, wenn die
|
||||||
|
// Datei nicht ins Ziel kopiert wird.
|
||||||
|
#expect(!Self.antworten.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Konten, Kategorien und Schlagwörter")
|
||||||
|
func stammdaten() throws {
|
||||||
|
let konten = try lies([Konto].self, "konten")
|
||||||
|
// 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")
|
||||||
|
#expect(gruppen.contains { !$0.categories.isEmpty })
|
||||||
|
|
||||||
|
#expect(try !lies([Schlagwort].self, "schlagwoerter").isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Buchungen samt Tags, Übertrag und Vertragszuordnung")
|
||||||
|
func buchungen() throws {
|
||||||
|
let alle = try lies([Buchung].self, "buchungen")
|
||||||
|
#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 })
|
||||||
|
#expect(alle.contains { $0.type == .transfer && $0.toAccountId != nil })
|
||||||
|
#expect(alle.contains { $0.subscriptionId != nil })
|
||||||
|
#expect(alle.contains { $0.notes?.isEmpty == false })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Die Paarliste fasst zwei getrennte Buchungen zusammen")
|
||||||
|
func verknuepfungen() throws {
|
||||||
|
let paare = try lies([String: Uebertragsverknuepfung].self, "uebertragsverknuepfungen")
|
||||||
|
#expect(paare.count == 2)
|
||||||
|
|
||||||
|
let alle = try lies([Buchung].self, "buchungen")
|
||||||
|
let zeilen = Buchungszeile.zeilen(aus: alle, paare: paare)
|
||||||
|
// 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")
|
||||||
|
func budget() throws {
|
||||||
|
let monat = try lies(Budgetmonat.self, "budget")
|
||||||
|
#expect(!monat.groups.isEmpty)
|
||||||
|
#expect(monat.groups.contains { !$0.categories.isEmpty })
|
||||||
|
#expect(monat.totals.plannedExpense > 0)
|
||||||
|
// prev/next tragen die Blätterung – ohne sie steht die Ansicht still.
|
||||||
|
#expect(!monat.prev.isEmpty && !monat.next.isEmpty)
|
||||||
|
|
||||||
|
let vorlage = try lies(Budgetvorlage.self, "budgetvorlage")
|
||||||
|
// Betriebsart und Zuschlag kamen mit Backend-Ergaenzung C dazu.
|
||||||
|
#expect(vorlage.lines.contains { $0.modus == "fest" })
|
||||||
|
|
||||||
|
let abos = try lies(Aboliste.self, "abos")
|
||||||
|
#expect(abos.summary.anzahlAktiv >= 1)
|
||||||
|
#expect(abos.subscriptions.contains { $0.intervalMonths == 12 })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Alle vier Dashboard-Karten")
|
||||||
|
func dashboard() throws {
|
||||||
|
let staende = try lies(Kontostaende.self, "kontostaende")
|
||||||
|
#expect(staende.accounts.count >= 2)
|
||||||
|
#expect(staende.gebunden != 0)
|
||||||
|
|
||||||
|
#expect(try !lies(Budgetfortschritt.self, "budgetfortschritt").rows.isEmpty)
|
||||||
|
#expect(try lies(EinnahmenAusgaben.self, "einnahmenAusgaben").income > 0)
|
||||||
|
|
||||||
|
let letzte = try lies(LetzteBuchungen.self, "letzteBuchungen")
|
||||||
|
#expect(!letzte.transactions.isEmpty)
|
||||||
|
// Ein Übertrag hat keine Richtung – sign ist dort null.
|
||||||
|
#expect(letzte.transactions.contains { $0.vorzeichen == .keines })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Benutzer, Einstellungen und Serverkennung")
|
||||||
|
func zugang() throws {
|
||||||
|
#expect(try lies(Serverkennung.self, "version").istCertain)
|
||||||
|
#expect(try !lies(Benutzer.self, "ichSelbst").username.isEmpty)
|
||||||
|
|
||||||
|
let roh = try lies([Einstellung].self, "einstellungen")
|
||||||
|
#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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,8 +8,19 @@
|
|||||||
"allgemein.nochmal" = "Nochmal versuchen";
|
"allgemein.nochmal" = "Nochmal versuchen";
|
||||||
"allgemein.abbrechen" = "Abbrechen";
|
"allgemein.abbrechen" = "Abbrechen";
|
||||||
"allgemein.abmelden" = "Abmelden";
|
"allgemein.abmelden" = "Abmelden";
|
||||||
|
"allgemein.unbekannt" = "Unbekannt";
|
||||||
|
"allgemein.sichern" = "Sichern";
|
||||||
|
"allgemein.ok" = "OK";
|
||||||
|
"allgemein.fertig" = "Fertig";
|
||||||
|
"allgemein.bearbeiten" = "Bearbeiten";
|
||||||
|
"allgemein.loeschen" = "Löschen";
|
||||||
|
|
||||||
/* --- Reiter --- */
|
/* --- Reiter --- */
|
||||||
|
"reiter.dashboard" = "Übersicht";
|
||||||
|
"reiter.budget" = "Budget";
|
||||||
|
"reiter.buchungen" = "Buchungen";
|
||||||
|
"reiter.abos" = "Verträge";
|
||||||
|
"reiter.arbeitsliste" = "Eingang";
|
||||||
"reiter.konten" = "Konten";
|
"reiter.konten" = "Konten";
|
||||||
"reiter.einstellungen" = "Einstellungen";
|
"reiter.einstellungen" = "Einstellungen";
|
||||||
|
|
||||||
@@ -102,3 +113,265 @@
|
|||||||
"einstellungen.budgetStarttag" = "Budget beginnt am";
|
"einstellungen.budgetStarttag" = "Budget beginnt am";
|
||||||
"einstellungen.abmeldenFrage" = "Von diesem Gerät abmelden?";
|
"einstellungen.abmeldenFrage" = "Von diesem Gerät abmelden?";
|
||||||
"einstellungen.abmeldenErklaerung" = "Beim Abmelden wird der Schlüssel dieses Geräts auch am Server entfernt.";
|
"einstellungen.abmeldenErklaerung" = "Beim Abmelden wird der Schlüssel dieses Geräts auch am Server entfernt.";
|
||||||
|
|
||||||
|
/* --- Dashboard --- */
|
||||||
|
"dashboard.bilanz" = "Dieser Monat";
|
||||||
|
"dashboard.einnahmen" = "Einnahmen";
|
||||||
|
"dashboard.ausgaben" = "Ausgaben";
|
||||||
|
"dashboard.budgetfortschritt" = "Budget";
|
||||||
|
"dashboard.keinBudget" = "Für diesen Monat ist noch nichts geplant.";
|
||||||
|
"dashboard.kontostaende" = "Konten";
|
||||||
|
"dashboard.letzteBuchungen" = "Zuletzt";
|
||||||
|
|
||||||
|
/* --- Budget --- */
|
||||||
|
"budget.einnahmen" = "Geplante Einnahmen";
|
||||||
|
"budget.uebrig" = "übrig";
|
||||||
|
"budget.vonGeplant" = "%1$@ von %2$@ ausgegeben";
|
||||||
|
"budget.vonBetrag" = "von %@";
|
||||||
|
"budget.angepasst" = "angepasst";
|
||||||
|
"budget.vorigerMonat" = "Voriger Monat";
|
||||||
|
"budget.naechsterMonat" = "Nächster Monat";
|
||||||
|
"budget.ausVertraegenHinweis" = "%1$d Verträge: %2$@";
|
||||||
|
"budget.ausVertraegenBerechnet" = "Berechnet aus %1$d Verträgen: %2$@";
|
||||||
|
"budget.vertraegeOhneKategorie" = "%1$d Verträge ohne Kategorie (%2$@) fehlen in diesen Summen.";
|
||||||
|
"budget.ohneKurs" = "Kein Wechselkurs für %@ – diese Beträge fehlen in den Summen.";
|
||||||
|
|
||||||
|
/* --- Buchungen --- */
|
||||||
|
"buchungen.suchen" = "Empfänger, Zweck, Notiz";
|
||||||
|
"buchungen.konto" = "Konto";
|
||||||
|
"buchungen.alleKonten" = "Alle Konten";
|
||||||
|
"buchungen.art" = "Art";
|
||||||
|
"buchungen.alleArten" = "Alle Arten";
|
||||||
|
"buchungen.leerTitel" = "Keine Buchungen";
|
||||||
|
"buchungen.leerErklaerung" = "Sobald etwas gebucht ist, steht es hier.";
|
||||||
|
"buchungen.leerGefiltert" = "Zu dieser Suche gibt es nichts. Andere Filter versuchen?";
|
||||||
|
"buchung.ohneBezeichnung" = "Ohne Bezeichnung";
|
||||||
|
"buchung.ohneKategorie" = "Ohne Kategorie";
|
||||||
|
"buchung.uebertrag" = "Übertrag";
|
||||||
|
|
||||||
|
/* --- Buchungsarten --- */
|
||||||
|
"buchungsart.ausgabe" = "Ausgabe";
|
||||||
|
"buchungsart.einnahme" = "Einnahme";
|
||||||
|
"buchungsart.uebertrag" = "Übertrag";
|
||||||
|
|
||||||
|
/* --- Verträge --- */
|
||||||
|
"abos.leerTitel" = "Keine Verträge";
|
||||||
|
"abos.leerErklaerung" = "Hier stehen Abos und Verträge, sobald welche angelegt sind.";
|
||||||
|
"abos.beendeteZeigen" = "Beendete zeigen";
|
||||||
|
"abos.beendet" = "beendet";
|
||||||
|
"abos.jaehrlichUndAnzahl" = "%1$@ im Jahr · %2$d aktiv";
|
||||||
|
"abos.ohneKurs" = "Kein Wechselkurs für %@ – diese Verträge fehlen in der Summe.";
|
||||||
|
"abo.monatlich" = "monatlich";
|
||||||
|
"abo.vierteljaehrlich" = "vierteljährlich";
|
||||||
|
"abo.halbjaehrlich" = "halbjährlich";
|
||||||
|
"abo.jaehrlich" = "jährlich";
|
||||||
|
"abo.alleNMonate" = "alle %d Monate";
|
||||||
|
"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";
|
||||||
|
|||||||
@@ -8,8 +8,19 @@
|
|||||||
"allgemein.nochmal" = "Try again";
|
"allgemein.nochmal" = "Try again";
|
||||||
"allgemein.abbrechen" = "Cancel";
|
"allgemein.abbrechen" = "Cancel";
|
||||||
"allgemein.abmelden" = "Sign out";
|
"allgemein.abmelden" = "Sign out";
|
||||||
|
"allgemein.unbekannt" = "Unknown";
|
||||||
|
"allgemein.sichern" = "Save";
|
||||||
|
"allgemein.ok" = "OK";
|
||||||
|
"allgemein.fertig" = "Done";
|
||||||
|
"allgemein.bearbeiten" = "Edit";
|
||||||
|
"allgemein.loeschen" = "Delete";
|
||||||
|
|
||||||
/* --- Tabs --- */
|
/* --- Tabs --- */
|
||||||
|
"reiter.dashboard" = "Overview";
|
||||||
|
"reiter.budget" = "Budget";
|
||||||
|
"reiter.buchungen" = "Transactions";
|
||||||
|
"reiter.abos" = "Subscriptions";
|
||||||
|
"reiter.arbeitsliste" = "Inbox";
|
||||||
"reiter.konten" = "Accounts";
|
"reiter.konten" = "Accounts";
|
||||||
"reiter.einstellungen" = "Settings";
|
"reiter.einstellungen" = "Settings";
|
||||||
|
|
||||||
@@ -102,3 +113,265 @@
|
|||||||
"einstellungen.budgetStarttag" = "Budget starts on";
|
"einstellungen.budgetStarttag" = "Budget starts on";
|
||||||
"einstellungen.abmeldenFrage" = "Sign out on this device?";
|
"einstellungen.abmeldenFrage" = "Sign out on this device?";
|
||||||
"einstellungen.abmeldenErklaerung" = "Signing out also removes this device's key on the server.";
|
"einstellungen.abmeldenErklaerung" = "Signing out also removes this device's key on the server.";
|
||||||
|
|
||||||
|
/* --- Dashboard --- */
|
||||||
|
"dashboard.bilanz" = "This month";
|
||||||
|
"dashboard.einnahmen" = "Income";
|
||||||
|
"dashboard.ausgaben" = "Expenses";
|
||||||
|
"dashboard.budgetfortschritt" = "Budget";
|
||||||
|
"dashboard.keinBudget" = "Nothing planned for this month yet.";
|
||||||
|
"dashboard.kontostaende" = "Accounts";
|
||||||
|
"dashboard.letzteBuchungen" = "Recent";
|
||||||
|
|
||||||
|
/* --- Budget --- */
|
||||||
|
"budget.einnahmen" = "Planned income";
|
||||||
|
"budget.uebrig" = "left";
|
||||||
|
"budget.vonGeplant" = "%1$@ of %2$@ spent";
|
||||||
|
"budget.vonBetrag" = "of %@";
|
||||||
|
"budget.angepasst" = "adjusted";
|
||||||
|
"budget.vorigerMonat" = "Previous month";
|
||||||
|
"budget.naechsterMonat" = "Next month";
|
||||||
|
"budget.ausVertraegenHinweis" = "%1$d subscriptions: %2$@";
|
||||||
|
"budget.ausVertraegenBerechnet" = "Calculated from %1$d subscriptions: %2$@";
|
||||||
|
"budget.vertraegeOhneKategorie" = "%1$d subscriptions without a category (%2$@) are missing from these totals.";
|
||||||
|
"budget.ohneKurs" = "No exchange rate for %@ – those amounts are missing from the totals.";
|
||||||
|
|
||||||
|
/* --- Transactions --- */
|
||||||
|
"buchungen.suchen" = "Payee, description, note";
|
||||||
|
"buchungen.konto" = "Account";
|
||||||
|
"buchungen.alleKonten" = "All accounts";
|
||||||
|
"buchungen.art" = "Type";
|
||||||
|
"buchungen.alleArten" = "All types";
|
||||||
|
"buchungen.leerTitel" = "No transactions";
|
||||||
|
"buchungen.leerErklaerung" = "Once something is booked, it shows up here.";
|
||||||
|
"buchungen.leerGefiltert" = "Nothing matches this search. Try different filters?";
|
||||||
|
"buchung.ohneBezeichnung" = "Untitled";
|
||||||
|
"buchung.ohneKategorie" = "No category";
|
||||||
|
"buchung.uebertrag" = "Transfer";
|
||||||
|
|
||||||
|
/* --- Transaction types --- */
|
||||||
|
"buchungsart.ausgabe" = "Expense";
|
||||||
|
"buchungsart.einnahme" = "Income";
|
||||||
|
"buchungsart.uebertrag" = "Transfer";
|
||||||
|
|
||||||
|
/* --- Subscriptions --- */
|
||||||
|
"abos.leerTitel" = "No subscriptions";
|
||||||
|
"abos.leerErklaerung" = "Subscriptions and contracts show up here once you add them.";
|
||||||
|
"abos.beendeteZeigen" = "Show cancelled";
|
||||||
|
"abos.beendet" = "cancelled";
|
||||||
|
"abos.jaehrlichUndAnzahl" = "%1$@ per year · %2$d active";
|
||||||
|
"abos.ohneKurs" = "No exchange rate for %@ – those subscriptions are missing from the total.";
|
||||||
|
"abo.monatlich" = "monthly";
|
||||||
|
"abo.vierteljaehrlich" = "quarterly";
|
||||||
|
"abo.halbjaehrlich" = "twice a year";
|
||||||
|
"abo.jaehrlich" = "yearly";
|
||||||
|
"abo.alleNMonate" = "every %d months";
|
||||||
|
"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 —
|
ich über das Backend herausgefunden habe. Lies sie ganz, bevor du anfängst —
|
||||||
die Abschnitte „Was das Backend vorgibt" und „Fallen" ersparen dir Tage.
|
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
|
## 4. Backend-Ergänzungen
|
||||||
|
|
||||||
Vier geplant. **A, B und C sind fertig**, D steht aus. Jede einzeln dem Nutzer
|
Vier geplant, **alle vier sind fertig**. Falls du eine fünfte brauchst: einzeln
|
||||||
vorlegen, bevor du sie schreibst.
|
dem Nutzer vorlegen, bevor du sie schreibst.
|
||||||
|
|
||||||
### A — App-Token ✅ (Commit `8a7545b`)
|
### 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
|
Speichern auch als feste zurück**. Die Weboberfläche traf es nicht, weil sie
|
||||||
die Betriebsart aus der Monatsansicht nimmt.
|
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
|
`copy_template_to_month` und `copy_month_to_template` in
|
||||||
`backend/app/services/budget.py` übernehmen nur `planned_cents`. Ein
|
`backend/app/services/budget.py` übernahmen nur `planned_cents`. Ein
|
||||||
„berechneter" Eintrag fällt beim Kopieren still auf „fest" zurück. Das ist ein
|
„berechneter" Eintrag fiel beim Kopieren still auf „fest" zurück — mit der
|
||||||
**bestehender Fehler**, kein App-Thema — aber die App macht ihn sichtbar, weil
|
Zahl, die zufällig zuletzt in `planned_cents` stand.
|
||||||
sie beide Richtungen anbietet. Nötig für Stufe 3.
|
|
||||||
|
`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/`:
|
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,
|
Faktor, Marke anlegen, Kontenliste — das lief bisher nur gegen die Modelle,
|
||||||
nicht gegen einen laufenden Server. Siehe „Klartext-HTTP" unten.
|
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
|
## 6. Was als Nächstes dran ist
|
||||||
|
|
||||||
**Stufe 2 — Lesen.** Dashboard (feste Ansicht: Kontostände, Budget-Fortschritt,
|
**Stufe 6 ist dran.** Alle vier Backend-Ergänzungen sind drin; von hier an
|
||||||
letzte Buchungen, Einnahmen/Ausgaben), Budget-Monatsansicht, Buchungsliste,
|
reicht die API, wie sie ist.
|
||||||
Abo-Liste. Die Backend-Ergänzungen **B** und **C**, die sie braucht, sind da.
|
|
||||||
|
|
||||||
**Stufe 3 — Schreiben.** Buchungen anlegen und ändern, Budget bearbeiten
|
Was bewusst nicht mitkam: das **Logo** eines Vertrags (Upload und Zuschnitt,
|
||||||
(Monat und Vorlage, beide Betriebsarten `fest`/`berechnet`), Abos verwalten
|
`POST /subscriptions/{id}/logo`), die **Reihenfolge** der Konten
|
||||||
samt Logo, Konten bearbeiten. Dazu Backend-Ergänzung **D**.
|
(`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,
|
Weiter aussen vor, wie von Anfang an entschieden: Bank **verbinden**,
|
||||||
Übertragsvorschlägen und Dubletten. Fachlich der Teil, der am besten aufs
|
Postfach einrichten, Sicherung und Wiederherstellung, Benutzerverwaltung.
|
||||||
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.
|
|
||||||
|
|
||||||
**Stufe 6 — Erweiterungen.** Teilen-Menü und Kurzbefehle. Brauchen eigene Ziele
|
**Stufe 6 — Erweiterungen.** Teilen-Menü und Kurzbefehle. Brauchen eigene Ziele
|
||||||
im Xcode-Projekt, eine **App-Gruppe** und eine **geteilte
|
im Xcode-Projekt, eine **App-Gruppe** und eine **geteilte
|
||||||
@@ -321,8 +507,13 @@ Vorlagen, Verlauf mit Rücknahme.
|
|||||||
### Zum Umfang, ehrlich
|
### Zum Umfang, ehrlich
|
||||||
|
|
||||||
Die Weboberfläche hat rund 8900 Zeilen JSX. Nativ wird das nicht weniger.
|
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
|
Stufe 2 hat rund 1800 Zeilen gebraucht, Stufe 3 rund 1200, Stufe 4 rund 900,
|
||||||
bis drei.
|
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
|
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
|
hat **keinen Server-Zustand** zwischen den Schritten: Die Datei wird bei jedem
|
||||||
|
|||||||
+25
-3
@@ -19,24 +19,41 @@ targets:
|
|||||||
sources:
|
sources:
|
||||||
- path: Anmeldung
|
- path: Anmeldung
|
||||||
- path: App
|
- path: App
|
||||||
|
- path: Abos
|
||||||
|
- path: Arbeitsliste
|
||||||
- path: Bausteine
|
- path: Bausteine
|
||||||
|
- path: Buchungen
|
||||||
|
- path: Budget
|
||||||
|
- path: Dashboard
|
||||||
- path: Einstellungen
|
- path: Einstellungen
|
||||||
- path: Konten
|
- path: Konten
|
||||||
- path: Modelle
|
- path: Modelle
|
||||||
|
- path: Nebenraeume
|
||||||
- path: Netzwerk
|
- path: Netzwerk
|
||||||
# Deutsch und Englisch, zeilengleich gehalten.
|
# Deutsch und Englisch, zeilengleich gehalten.
|
||||||
- path: Texte
|
- 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:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: com.scarriffle.certain
|
PRODUCT_BUNDLE_IDENTIFIER: com.scarriffle.certain
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
# Signing automatisch; wer mit einem anderen Apple-Konto baut, traegt
|
# Signing automatisch; wer mit einem anderen Apple-Konto baut, traegt
|
||||||
# hier seine Team-ID ein (Xcode: Signing & Capabilities -> Team).
|
# hier seine Team-ID ein (Xcode: Signing & Capabilities -> Team).
|
||||||
CODE_SIGN_STYLE: Automatic
|
CODE_SIGN_STYLE: Automatic
|
||||||
DEVELOPMENT_TEAM: PP34X97WS3
|
DEVELOPMENT_TEAM: PP34X97WS3
|
||||||
MARKETING_VERSION: "0.1"
|
MARKETING_VERSION: "0.1"
|
||||||
CURRENT_PROJECT_VERSION: "1"
|
CURRENT_PROJECT_VERSION: "1"
|
||||||
# iPhone und iPad, zweispaltig gedacht.
|
# Nur iPhone, nur Hochformat - am Rechner gibt es die Weboberflaeche.
|
||||||
TARGETED_DEVICE_FAMILY: "1,2"
|
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
|
# Sprachfassung 5: der Code ist nicht auf die strikte
|
||||||
# Nebenlaeufigkeitspruefung von Swift 6 hin geschrieben. Umstellen,
|
# Nebenlaeufigkeitspruefung von Swift 6 hin geschrieben. Umstellen,
|
||||||
# wenn die Uebergaenge AppZustand <-> CertainClient geprueft sind.
|
# wenn die Uebergaenge AppZustand <-> CertainClient geprueft sind.
|
||||||
@@ -46,12 +63,17 @@ targets:
|
|||||||
INFOPLIST_KEY_CFBundleDisplayName: Certain
|
INFOPLIST_KEY_CFBundleDisplayName: Certain
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation: YES
|
INFOPLIST_KEY_UILaunchScreen_Generation: YES
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: 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:
|
CertainTests:
|
||||||
type: bundle.unit-test
|
type: bundle.unit-test
|
||||||
platform: iOS
|
platform: iOS
|
||||||
sources:
|
sources:
|
||||||
|
# Tests/Fixtures/antworten.json landet als Ressource im Buendel -
|
||||||
|
# der Test "Serverantworten" liest sie von dort.
|
||||||
- path: Tests
|
- path: Tests
|
||||||
dependencies:
|
dependencies:
|
||||||
- target: Certain
|
- target: Certain
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ export default function Categories() {
|
|||||||
editing,
|
editing,
|
||||||
form: editing
|
form: editing
|
||||||
? { name: editing.name, kind: editing.kind, icon: editing.icon, color: editing.color }
|
? { 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) {
|
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
|
// Gesetzt werden Tags nicht hier, sondern an der Buchung – in der Liste und im
|
||||||
// Bearbeiten-Dialog. Hier geht es nur um den Vorrat.
|
// Bearbeiten-Dialog. Hier geht es nur um den Vorrat.
|
||||||
const STANDARDFARBE = "#3f51b5";
|
const STANDARDFARBE = "#3f9c72";
|
||||||
|
|
||||||
export default function Tags() {
|
export default function Tags() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
Reference in New Issue
Block a user